Skip to content
115 changes: 115 additions & 0 deletions src/vs/platform/agentHost/common/agentHostLogNaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { joinPath } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';
import { type IDebugArtifact } from './state/sessionState.js';

/**
* Subdirectory of the agent host's `logsHome` under which Claude SDK debug
* logs are written (one file per session materialize), mirroring the sibling
* `ahp/` directory used by {@link AhpJsonlLogger}.
*/
export const CLAUDE_LOG_DIR = 'claude';

/** Cap embedded id tokens so composed file names stay within FS limits (255 on ext4/APFS). */
const MAX_ID_TOKEN_LENGTH = 64;

/**
* Formats a {@link Date} as a file-name-safe timestamp (`:`/`.` → `-`), e.g.
* `2026-07-21T12-34-56-789Z`. Shared by the AHP JSONL logger and the Claude
* debug-file naming so rotated/segmented files sort lexicographically.
*/
export function toFileTimestamp(date: Date): string {
return date.toISOString().replace(/[:.]/g, '-');
}

/**
* Sanitizes an arbitrary value (connection id, session id, …) for safe use as
* part of a file name: any run of path/glob-hostile characters or whitespace
* collapses to a single `-`, with leading/trailing dashes trimmed. Falls back
* to `connection` when the input reduces to empty.
*/
export function sanitizeFilePart(value: string): string {
return value.replace(/[\\/:\*\?"<>\|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'connection';
}

/**
* The sanitized, length-capped token embedded in a Claude debug-log file name
* to identify its owning session. Kept as a single helper so the write side
* ({@link buildClaudeDebugFilePath}) and the export/read side filter agree on
* the exact token.
*/
export function claudeDebugLogSessionToken(sessionId: string): string {
return sanitizeFilePart(sessionId).slice(0, MAX_ID_TOKEN_LENGTH);
}

/**
* Computes the `<logsHome>/claude/` directory and the per-session debug-log
* file URI (`claude-<timestamp>-<sessionToken>.log`) for a Claude session
* startup. `timestamp` is supplied by the caller (via {@link toFileTimestamp})
* so this stays a pure, deterministic projection.
*
* The returned `file.fsPath` is passed to the SDK's `Options.debugFile`; the
* caller should ensure `dir` exists first (`IFileService.createFolder`).
*/
export function buildClaudeDebugFilePath(logsHome: URI, sessionId: string, timestamp: string): { readonly dir: URI; readonly file: URI } {
const dir = joinPath(logsHome, CLAUDE_LOG_DIR);
const file = joinPath(dir, `claude-${timestamp}-${claudeDebugLogSessionToken(sessionId)}.log`);
return { dir, file };
}

/**
* Labels for a Claude session's advertised debug artifacts. Each label also
* derives the export file name (via {@link sanitizeFilePart}), so debug logs are
* numbered to keep those names unique per session.
*/
export const CLAUDE_DEBUG_LOG_LABEL = 'Claude debug log';
export const CLAUDE_TRANSCRIPT_LABEL = 'Claude transcript';

/**
* Build the advertised {@link IDebugArtifact} list for a Claude session from its
* on-disk debug-log URIs plus an optional transcript URI (each a host-encoded
* `file://` string). A pure projection, so the labeling/numbering is unit-testable
* without any session state: logs are sorted lexically (their file names embed the
* timestamp, so this is chronological) and numbered only when there is more than
* one — keeping the single-log common case clean while ensuring unique export
* file names.
*/
export function buildClaudeDebugArtifacts(logUris: readonly string[], transcriptUri: string | undefined): IDebugArtifact[] {
const sorted = [...logUris].sort();
const artifacts: IDebugArtifact[] = sorted.map((uri, i) => ({
label: sorted.length > 1 ? `${CLAUDE_DEBUG_LOG_LABEL} ${i + 1}` : CLAUDE_DEBUG_LOG_LABEL,
uri,
}));
if (transcriptUri) {
artifacts.push({ label: CLAUDE_TRANSCRIPT_LABEL, uri: transcriptUri });
}
return artifacts;
}

/**
* Encodes a working directory into the Claude CLI's transcript "project" slug —
* every non-alphanumeric character becomes `-`, e.g. `/Users/foo/my-proj` →
* `-Users-foo-my-proj` and `C:\foo\bar` → `C--foo-bar`. The CLI slugs its native
* `process.cwd()`, so two caveats for a caller holding a VS Code URI's `fsPath`:
* it resolves symlinks (`/tmp` → `/private/tmp`), and on Windows it keeps the
* drive letter uppercase while VS Code lowercases it — so we re-uppercase a
* leading `<drive>:` here. A mismatch is best-effort: the caller should have a
* scan fallback.
*/
export function claudeProjectSlug(cwd: string): string {
return cwd.replace(/^([a-zA-Z]):/, (_, drive: string) => `${drive.toUpperCase()}:`).replace(/[^a-zA-Z0-9]/g, '-');
}

/**
* The Claude CLI session transcript path for a working directory + session id:
* `<userHome>/.claude/projects/<slug>/<sessionId>.jsonl` (see
* {@link claudeProjectSlug}). A pure projection, so resolving the transcript is a
* single deterministic `stat` rather than a scan of every project directory.
*/
export function buildClaudeTranscriptPath(userHome: URI, cwd: string, sessionId: string): URI {
return joinPath(userHome, '.claude', 'projects', claudeProjectSlug(cwd), `${sessionId}.jsonl`);
}
9 changes: 1 addition & 8 deletions src/vs/platform/agentHost/common/ahpJsonlLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { joinPath } from '../../../base/common/resources.js';
import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js';
import { IFileService, IFileStatWithMetadata } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { sanitizeFilePart, toFileTimestamp } from './agentHostLogNaming.js';

export type AhpLogDirection = 'c2s' | 's2c';

Expand Down Expand Up @@ -251,11 +252,3 @@ function _ahpReplacer(this: unknown, _key: string, value: unknown): unknown {
}
return value;
}

function toFileTimestamp(date: Date): string {
return date.toISOString().replace(/[:.]/g, '-');
}

function sanitizeFilePart(value: string): string {
return value.replace(/[\\/:\*\?"<>\|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'connection';
}
63 changes: 63 additions & 0 deletions src/vs/platform/agentHost/common/state/sessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,17 @@ export function withSessionPromptCacheState(meta: SessionMeta | undefined, promp
return Object.keys(next).length > 0 ? next : undefined;
}

/**
* Reserved key under {@link SessionMeta} for the well-known debug-artifacts
* payload. Value at this key, when present, MUST be an array shaped like
* {@link IDebugArtifact}. The host (which owns the on-disk layout of its own
* logs and the provider SDK's transcript) advertises the concrete file paths
* here so clients — e.g. the "Export Agent Host Debug Logs" command — can
* collect them via the resource proxy without hard-coding provider-specific
* path conventions.
*/
export const SESSION_META_DEBUG_ARTIFACTS_KEY = 'debugArtifacts';

/**
* Git state of a session's working directory, carried under
* {@link SessionMeta} at {@link SESSION_META_GIT_KEY}. Used by clients to
Expand Down Expand Up @@ -1223,6 +1234,58 @@ export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, git
return Object.keys(next).length > 0 ? next : undefined;
}

/**
* A single debug artifact the host advertises for a session, carried under
* {@link SessionMeta} at {@link SESSION_META_DEBUG_ARTIFACTS_KEY}. The
* {@link uri} is a serialized `file://` URI **encoded on the agent-host machine**
* (so drive letters, UNC roots and separators are the host's, not the client's);
* the client parses it and resolves it to a readable resource (`file://` locally,
* `vscode-agent-host://` remotely) via the resource proxy.
*/
export interface IDebugArtifact {
/** Human-readable label, used to derive the file name in the export. */
readonly label: string;
/** Serialized `file://` URI of the artifact, encoded on the agent-host machine. */
readonly uri: string;
}

/**
* Reads the well-known debug-artifacts payload from a {@link SessionMeta} bag.
* Returns `undefined` when the key is absent or not an array; entries missing a
* string `label`/`uri` are dropped so partial state still propagates.
*/
export function readSessionDebugArtifacts(meta: SessionMeta | undefined): IDebugArtifact[] | undefined {
const value = meta?.[SESSION_META_DEBUG_ARTIFACTS_KEY];
if (!Array.isArray(value)) {
return undefined;
}
const artifacts: IDebugArtifact[] = [];
for (const entry of value) {
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const raw = entry as Record<string, unknown>;
if (typeof raw['label'] === 'string' && typeof raw['uri'] === 'string') {
artifacts.push({ label: raw['label'], uri: raw['uri'] });
}
}
}
return artifacts;
}

/**
* Returns a new {@link SessionMeta} with the debug-artifacts payload set to
* `artifacts`, or with the slot removed if `artifacts` is `undefined`/empty.
* Returns `undefined` if the result would be empty.
*/
export function withSessionDebugArtifacts(meta: SessionMeta | undefined, artifacts: readonly IDebugArtifact[] | undefined): SessionMeta | undefined {
const next: { [key: string]: unknown } = { ...meta };
if (artifacts !== undefined && artifacts.length > 0) {
next[SESSION_META_DEBUG_ARTIFACTS_KEY] = artifacts;
} else {
delete next[SESSION_META_DEBUG_ARTIFACTS_KEY];
}
return Object.keys(next).length > 0 ? next : undefined;
}

/**
* Reserved key under {@link SessionSummaryMeta} recording how deeply a session
* was spawned via the `create_session` host tool (0 for a top-level, user-created
Expand Down
49 changes: 48 additions & 1 deletion src/vs/platform/agentHost/node/claude/claudeAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { CancellationError } from '../../../../base/common/errors.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { RunOnceScheduler } from '../../../../base/common/async.js';
import { isEqual } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { INativeEnvironmentService } from '../../../environment/common/environment.js';
Expand All @@ -24,8 +25,9 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
import { ActionType } from '../../common/state/sessionActions.js';
import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
import { isDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js';
import { isDefaultChatUri, withSessionDebugArtifacts, type Customization, type ToolCallResult } from '../../common/state/sessionState.js';
import { IClaudeAgentSdkService } from './claudeAgentSdkService.js';
import { ClaudeDebugArtifacts } from './claudeDebugArtifacts.js';
import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js';
import { toSdkModelId } from './claudeModelId.js';
import { buildServerToolMcpServer, CLAUDE_SERVER_TOOL_MCP_SERVER_NAME, serverToolAllowList } from './claudeServerToolMcpServer.js';
Expand Down Expand Up @@ -349,6 +351,7 @@ export class ClaudeAgentSession extends Disposable {
this._provisionalAgent = agent;
this.provisionalConfig = config;
this.abortController = abortController;
this._debugArtifacts = new ClaudeDebugArtifacts(this.sessionId, this._environmentService.logsHome, this._environmentService.userHome, this._fileService, this._logService);
this.toolDiff = this._register(toolDiff);
this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire()));

Expand All @@ -366,6 +369,35 @@ export class ClaudeAgentSession extends Disposable {
this._customizationWatcher.add(watcher.onDidChange(() => this._onDidCustomizationsChange.fire()));
}

/**
* Discovery of this session's debug artifacts (SDK debug logs + transcript),
* assigned in the constructor. Stateless — {@link _publishDebugArtifacts} reads
* it from disk and mirrors the result into `_meta`.
*/
private readonly _debugArtifacts: ClaudeDebugArtifacts;

/**
* Re-read this session's debug artifacts from disk and publish them into the
* well-known `_meta` key, so the export command reads concrete host-side paths
* instead of re-deriving the on-disk layout. `withSessionDebugArtifacts` merges
* with the existing `_meta` slots (git/github) and, on an empty set, clears only
* this slot — so the advertised state stays a projection of disk truth. Called
* at the moments the set can change: materialize/rematerialize (a new debug log)
* and once after the first turn (the transcript file appears). Host-local
* absolute paths; the client resolves them via the resource proxy (`file://`
* local / `vscode-agent-host://` remote).
*/
private async _publishDebugArtifacts(): Promise<void> {
const cwd = this.workingDirectory;
if (!cwd) {
return; // Not materialized yet; nothing on disk to advertise.
}
const artifacts = await this._debugArtifacts.refresh(cwd);
const sessionKey = this.sessionUri.toString();
const current = this._stateManager.getSessionState(sessionKey)?._meta;
this._stateManager.setSessionMeta(sessionKey, withSessionDebugArtifacts(current, artifacts));
}

/**
* One-shot SDK assistant-message uuid that the next materialize / rebuild
* resumes *up to and including* (the SDK's `Options.resumeSessionAt`).
Expand Down Expand Up @@ -446,6 +478,7 @@ export class ClaudeAgentSession extends Disposable {
const { mcpServers, allowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost);
const agentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId);

const debugFile = await this._debugArtifacts.prepareDebugFile();
const options = await buildOptions(
{
sessionId: this.sessionId,
Expand All @@ -461,6 +494,7 @@ export class ClaudeAgentSession extends Disposable {
allowedTools,
plugins: this.clientCustomizationsDiff.consume(this._desiredClientPluginPaths()),
agent: agentName,
debugFile,
},
ctx.transport,
data => this._logService.error(`[Claude SDK stderr] ${data}`),
Expand Down Expand Up @@ -496,6 +530,14 @@ export class ClaudeAgentSession extends Disposable {
}
this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s)))));
this._pipeline = pipeline;
// Publish the debug artifacts into `_meta` at the moments the set can change.
// The debug log exists now (the SDK wrote it during startup); the transcript
// FILE of a fresh session isn't on disk until the first turn writes it, so
// publish once more after the first turn completes (a short debounce lets it
// flush). Locations never change per turn, so no steady-state re-publishing.
const publishDebugArtifacts = this._register(new RunOnceScheduler(() => void this._publishDebugArtifacts(), 500));
this._register(Event.once(Event.filter(pipeline.onDidProduceSignal, s => s.kind === 'action' && s.action.type === ActionType.ChatTurnComplete))(() => publishDebugArtifacts.schedule()));
void this._publishDebugArtifacts();
// The materialize succeeded with the staged anchor applied to `Options`
// — clear it now so it isn't re-applied. A throw before this point (e.g.
// `startup` / pipeline-create) leaves it staged for the next retry.
Expand Down Expand Up @@ -543,6 +585,7 @@ export class ClaudeAgentSession extends Disposable {
const { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost);
const rebuildAgentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId);
const rebuildAbort = new AbortController();
const rebuildDebugFile = await this._debugArtifacts.prepareDebugFile();
const rebuildOptions = await buildOptions(
{
sessionId: this.sessionId,
Expand All @@ -558,6 +601,7 @@ export class ClaudeAgentSession extends Disposable {
allowedTools: rebuildAllowedTools,
plugins: this.clientCustomizationsDiff.consume(this._desiredClientPluginPaths()),
agent: rebuildAgentName,
debugFile: rebuildDebugFile,
},
ctx.transport,
data => this._logService.error(`[Claude SDK stderr] ${data}`),
Expand All @@ -569,6 +613,9 @@ export class ClaudeAgentSession extends Disposable {
// catch alongside the tool/customization diffs) so the next send
// retries the truncation instead of dropping the restore.
this._pendingResumeSessionAt = undefined;
// Rebuild started, so the SDK will write a fresh debug log; re-read from
// disk and re-publish it into `_meta`.
void this._publishDebugArtifacts();
return { warm: rebuildWarm, abortController: rebuildAbort };
} catch (err) {
this.toolDiff.markDirty();
Expand Down
Loading
Loading