Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/coding-agent/docs/invocation-traces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Invocation Traces

Diffex creates one invocation trace for each CLI process whose initial session is persisted.
Ephemeral commands such as `--no-session`, `--help`, and `--list-models` do not create traces.

Trace creation is passive.
If the trace file cannot be created, Diffex prints one warning and continues without durable telemetry.

## Storage and Discovery

Trace files are stored in `~/.diffex/agent/traces/` by default, or beneath the directory selected by `DIFFEX_CODING_AGENT_DIR`.
Each file is named `<UTC timestamp>_<trace ID>.jsonl`.
The trace directory is restricted to the current user with mode `0700`, and each trace file uses mode `0600` on POSIX systems.

Tools can discover traces by scanning this directory for `.jsonl` files and validating them with the harness trace reader.
The trace header identifies the producing Diffex build and runtime.
Session attachment records identify every referenced session ID, path, format, and transition reason.

## Retention and Session Changes

Traces are retained until the user explicitly removes them.
Diffex does not currently apply an automatic age or size limit.

Deleting a session does not delete its traces.
Moving or renaming a session does not rewrite existing trace references.
This preserves invocation evidence while allowing readers to report that referenced session content is missing or moved.

Trace files contain execution telemetry and immutable session entry references, not duplicated transcript content.
7 changes: 5 additions & 2 deletions packages/coding-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "0.2.0",
"description": "Coding agent CLI with read, bash, edit, write, update_plan tools and session management",
"type": "module",
"imports": {
"#harness-trace": "./dist/vendor/harness-trace/index.js"
},
"diffexConfig": {
"configDir": ".diffex"
},
Expand Down Expand Up @@ -35,8 +38,8 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets",
"build:binary": "npm --prefix ../harness-state run build && npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"build:binary": "npm --prefix ../harness-state run build && npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../harness-trace run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/ && shx mkdir -p dist/vendor && shx cp -r ../harness-trace/dist dist/vendor/harness-trace",
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
"test": "vitest --run",
"shrinkwrap": "node ../../scripts/generate-coding-agent-shrinkwrap.mjs",
Expand Down
16 changes: 16 additions & 0 deletions packages/coding-agent/src/core/agent-session-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { HarnessVersion } from "@earendil-works/diffex-harness-state";
import type { SessionManager } from "./session-manager.ts";

export type InvocationSessionAttachmentReason = "startup" | "resume" | "new" | "import" | "fork" | "switch";

export interface AgentSessionOperationSettlement {
operationId: string;
sessionManager: SessionManager;
firstEntryOrdinal: number;
}

/** Passive process-owned observation hooks for durable invocation tracing. */
export interface AgentSessionLifecycleObserver {
operationSettled(settlement: AgentSessionOperationSettlement): void | Promise<void>;
harnessActivated(version: HarnessVersion): void | Promise<void>;
}
31 changes: 29 additions & 2 deletions packages/coding-agent/src/core/agent-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { basename, join, resolve } from "node:path";
import type { TelemetryContext } from "@earendil-works/diffex-agent-core";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { InvocationSessionAttachmentReason } from "./agent-session-lifecycle.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type {
ProjectTrustContext,
Expand Down Expand Up @@ -40,8 +41,11 @@ export type CreateAgentSessionRuntimeFactory = (options: {
sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
telemetryContext?: TelemetryContext;
invocationSessionReason?: InvocationSessionAttachmentReason;
}) => Promise<CreateAgentSessionRuntimeResult>;

export type InvocationTerminationStatus = "completed" | "failed" | "aborted";

/**
* Thrown when /import references a JSONL file path that does not exist.
*/
Expand Down Expand Up @@ -82,13 +86,15 @@ export class AgentSessionRuntime {
private _diagnostics: AgentSessionRuntimeDiagnostic[];
private _modelFallbackMessage?: string;
private disposePromise: Promise<void> | undefined;
private readonly onDispose: ((status: InvocationTerminationStatus) => Promise<void>) | undefined;

constructor(
_session: AgentSession,
_services: AgentSessionServices,
createRuntime: CreateAgentSessionRuntimeFactory,
_diagnostics: AgentSessionRuntimeDiagnostic[] = [],
_modelFallbackMessage?: string,
onDispose?: (status: InvocationTerminationStatus) => Promise<void>,
) {
this._session = _session;
this._services = _services;
Expand All @@ -99,6 +105,7 @@ export class AgentSessionRuntime {
});
this._diagnostics = _diagnostics;
this._modelFallbackMessage = _modelFallbackMessage;
this.onDispose = onDispose;
}

get services(): AgentSessionServices {
Expand Down Expand Up @@ -249,6 +256,7 @@ export class AgentSessionRuntime {
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()),
invocationSessionReason: "switch",
}),
);
await this.finishSessionReplacement(options?.withSession);
Expand Down Expand Up @@ -281,6 +289,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
invocationSessionReason: "new",
}),
);
if (options?.setup) {
Expand Down Expand Up @@ -335,6 +344,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
invocationSessionReason: "fork",
}),
);
await this.finishSessionReplacement(options?.withSession);
Expand All @@ -358,6 +368,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
invocationSessionReason: "fork",
}),
);
await this.finishSessionReplacement(options?.withSession);
Expand All @@ -377,6 +388,7 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
invocationSessionReason: "fork",
}),
);
await this.finishSessionReplacement(options?.withSession);
Expand Down Expand Up @@ -421,13 +433,14 @@ export class AgentSessionRuntime {
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
invocationSessionReason: "import",
}),
);
await this.finishSessionReplacement();
return { cancelled: false };
}

dispose(): Promise<void> {
dispose(terminationStatus: InvocationTerminationStatus = "completed"): Promise<void> {
if (this.disposePromise) return this.disposePromise;

let resolveDispose!: () => void;
Expand All @@ -437,9 +450,20 @@ export class AgentSessionRuntime {
rejectDispose = reject;
});
this.disposePromise = disposePromise;
void this.teardownCurrent("quit").then(resolveDispose, rejectDispose);
void this.finishDispose(terminationStatus).then(resolveDispose, rejectDispose);
return disposePromise;
}

private async finishDispose(terminationStatus: InvocationTerminationStatus): Promise<void> {
let teardownFailure: unknown;
try {
await this.teardownCurrent("quit");
} catch (error) {
teardownFailure = error;
}
await this.onDispose?.(teardownFailure === undefined ? terminationStatus : "failed");
if (teardownFailure !== undefined) throw teardownFailure;
}
}

/**
Expand All @@ -456,6 +480,8 @@ export async function createAgentSessionRuntime(
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
telemetryContext?: TelemetryContext;
invocationSessionReason?: InvocationSessionAttachmentReason;
onDispose?: (status: InvocationTerminationStatus) => Promise<void>;
},
): Promise<AgentSessionRuntime> {
assertSessionCwdExists(options.sessionManager, options.cwd);
Expand All @@ -466,6 +492,7 @@ export async function createAgentSessionRuntime(
createRuntime,
result.diagnostics,
result.modelFallbackMessage,
options.onDispose,
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Model } from "@earendil-works/diffex-ai";
import type { HarnessStateStore, HarnessVersionBundle } from "@earendil-works/diffex-harness-state";
import { getAgentDir } from "../config.ts";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSessionLifecycleObserver } from "./agent-session-lifecycle.ts";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { loadGlobalHarnessState } from "./harness-state-runtime.ts";
import { ModelRuntime } from "./model-runtime.ts";
Expand Down Expand Up @@ -68,6 +69,7 @@ export interface CreateAgentSessionFromServicesOptions {
noTools?: CreateAgentSessionOptions["noTools"];
customTools?: ToolDefinition[];
subagents?: CreateAgentSessionOptions["subagents"];
lifecycleObserver?: AgentSessionLifecycleObserver;
}

/**
Expand Down Expand Up @@ -243,6 +245,7 @@ export async function createAgentSessionFromServices(
noTools: options.noTools,
customTools: options.customTools,
subagents: options.subagents,
lifecycleObserver: options.lifecycleObserver,
sessionStartEvent: options.sessionStartEvent,
});
}
32 changes: 30 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts";
import { normalizeToolResultImages } from "../utils/tool-result-images.ts";
import type { AgentSessionLifecycleObserver } from "./agent-session-lifecycle.ts";
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts";
import { type BashResult, executeBashWithOperations } from "./bash-executor.ts";
import {
Expand Down Expand Up @@ -256,6 +257,8 @@ export interface AgentSessionConfig {
harnessVersionBundle?: HarnessVersionBundle;
/** Parent context for passive runtime telemetry. */
telemetryContext?: TelemetryContext;
/** Process-owned observer for durable lifecycle boundaries. */
lifecycleObserver?: AgentSessionLifecycleObserver;
}

export interface ExtensionBindings {
Expand Down Expand Up @@ -412,6 +415,7 @@ export class AgentSession {
private readonly _harnessStateStore: HarnessStateStore | undefined;
private _harnessVersionBundle: HarnessVersionBundle | undefined;
private readonly _telemetryContext: TelemetryContext;
private readonly _lifecycleObserver: AgentSessionLifecycleObserver | undefined;
private _harnessSwitchInProgress = false;
private _subagentWaitAbortController: AbortController | undefined;
private _agentRunAborted = false;
Expand All @@ -436,6 +440,7 @@ export class AgentSession {
this._harnessStateStore = config.harnessStateStore;
this._harnessVersionBundle = config.harnessVersionBundle;
this._telemetryContext = config.telemetryContext ?? NOOP_TELEMETRY_CONTEXT;
this._lifecycleObserver = config.lifecycleObserver;

this._armInitialCurrentPlanRestoration();

Expand Down Expand Up @@ -1197,6 +1202,7 @@ export class AgentSession {
this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
this._systemPromptOverride = undefined;
this.agent.state.systemPrompt = this._baseSystemPrompt;
await this._notifyHarnessActivated(version);
} finally {
this._harnessSwitchInProgress = false;
}
Expand Down Expand Up @@ -1353,11 +1359,13 @@ export class AgentSession {
kind: "prompt" | "compaction" | "navigation" | "abort" | "queue" | "bash" | "export",
callback: (telemetryContext: TelemetryContext) => Result | Promise<Result>,
): Promise<Result> {
return startCodingAgentSpan(
const operationId = uuidv7();
const firstEntryOrdinal = this.sessionManager.getEntryCount();
const operation = startCodingAgentSpan(
this.agent.telemetryContext,
"diffex.coding_agent.operation",
{
"diffex.operation.id": uuidv7(),
"diffex.operation.id": operationId,
"diffex.session.id": this.sessionId,
"diffex.operation.kind": kind,
},
Expand Down Expand Up @@ -1385,6 +1393,26 @@ export class AgentSession {
}
},
);
return operation.finally(() => this._notifyOperationSettled({ operationId, firstEntryOrdinal }));
}

private async _notifyOperationSettled(options: { operationId: string; firstEntryOrdinal: number }): Promise<void> {
try {
await this._lifecycleObserver?.operationSettled({
...options,
sessionManager: this.sessionManager,
});
} catch {
// Lifecycle observers are passive and never change operation behavior.
}
}

private async _notifyHarnessActivated(version: HarnessVersion): Promise<void> {
try {
await this._lifecycleObserver?.harnessActivated(version);
} catch {
// Lifecycle observers are passive and never change activation behavior.
}
}

private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
Expand Down
Loading