From f05fd9ef1874b289f146c0faa89878fba70d75c5 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 1 Jul 2026 21:28:47 -0700 Subject: [PATCH 01/13] feat(daemon): emit GenAI spans via the Weave Node SDK Replace the hand-rolled OTLP exporter and genaiSpans span builders with weave.init() and the SDK genai primitives (startConversation, startTurn, startLLM, startTool, startSubagent). Integration identity rides the conversation attributes, dropping IntegrationBaggageSpanProcessor. Assistant text and thinking become ordered gen_ai.output.messages parts on the chat span; the model's tools nest under it. Subagents flatten (published SubAgent is a leaf): in-session subagent spans parent under the turn tagged gen_ai.agent.name; cross-session teammates get their own turn-trace stitched by conversation_id. Collapses the stale migrate-sdk stack (#73, #75, #76, #77, #78). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 10 +- src/chatSpans.ts | 103 ++ src/cli.ts | 113 +- src/daemon.ts | 1344 +++++++----------- src/genaiSpans.ts | 605 ++------ src/parser.ts | 60 +- src/sessionState.ts | 342 +++++ src/setup.ts | 93 +- src/utils.ts | 8 +- tests/daemon-shutdown-finalizes-turn.test.ts | 96 +- tests/genai-span-usage-tokens.test.ts | 113 +- tests/helpers.ts | 61 + tests/interleave-handlers.test.ts | 147 +- tests/interleave-split-lines.test.ts | 137 +- tests/interleaved-assistant-spans.test.ts | 219 +-- tests/teammate-idle.test.ts | 106 +- tests/turn-span-agent-name.test.ts | 71 +- tests/turn-span-integration.test.ts | 59 +- 18 files changed, 1613 insertions(+), 2074 deletions(-) create mode 100644 src/chatSpans.ts create mode 100644 src/sessionState.ts diff --git a/package.json b/package.json index 44f95a3..f3ac6e3 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", - "uuidv7": "1.2.1" + "uuidv7": "1.2.1", + "weave": "^0.16.1" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" diff --git a/src/chatSpans.ts b/src/chatSpans.ts new file mode 100644 index 0000000..cda15f7 --- /dev/null +++ b/src/chatSpans.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import * as weave from 'weave'; +import type { Attributes } from '@opentelemetry/api'; +import type { AssistantCallDetail } from './parser.js'; +import { isToolUseBlock } from './parser.js'; +import { + ATTR, + buildUsage, + contentBlocksToParts, + providerFromModel, + parseTimestamp, +} from './genaiSpans.js'; + +/** Stable identity for an assistant API call within a turn. Anthropic returns + * a `message.id` on every response; that's the primary key. When it's + * missing (legacy transcripts), fall back to the index, which is stable + * within a single parse + turn. */ +export function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { + return call.responseId ?? `idx:${callIdx}`; +} + +/** All calls belonging to one assistant API response, in transcript order. + * Claude Code splits a single response's thinking / text / tool_use blocks + * across separate transcript lines that share a `message.id`; the parser maps + * each line to its own `AssistantCallDetail`, so this regroups them by key. */ +export function callsForResponseKey( + calls: AssistantCallDetail[], + key: string, +): AssistantCallDetail[] { + const group: AssistantCallDetail[] = []; + for (let i = 0; i < calls.length; i++) { + if (chatMessageKey(calls[i], i) === key) group.push(calls[i]); + } + return group; +} + +/** Find the response key of the assistant call whose content contains a + * `tool_use` block with `toolUseId`, or undefined if not found (transcript + * not flushed yet, or unknown id). */ +export function findToolUseResponseKey( + calls: AssistantCallDetail[], + toolUseId: string, +): string | undefined { + for (let ci = 0; ci < calls.length; ci++) { + for (const block of calls[ci].contentBlocks) { + if (isToolUseBlock(block) && block.id === toolUseId) { + return chatMessageKey(calls[ci], ci); + } + } + } + return undefined; +} + +export function parseIsoOrNow(ts: string | undefined): Date { + return parseTimestamp(ts) ?? new Date(); +} + +/** Open a chat (LLM) span under `turn` for `model`, deriving the provider. */ +export function startChat(turn: weave.Turn, model: string, startTime: Date): weave.LLM { + const provider = providerFromModel(model); + return turn.startLLM({ model, ...(provider ? { providerName: provider } : {}), startTime }); +} + +/** + * Open a chat (LLM) span for one response `group`, backdating its start to the + * first call's request time. Returns undefined when no call in the group has a + * model yet (LLMInit.model is required), so the caller can fall back to the turn + * span and emit the chat span later once the model has flushed. + */ +export function openChatForGroup(turn: weave.Turn, group: AssistantCallDetail[]): weave.LLM | undefined { + const model = group.map(c => c.model).find(Boolean); + if (!model) return undefined; + return startChat(turn, model, parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp)); +} + +/** + * Populate a chat (LLM) span from the assistant calls that make up one response, + * then end it. Split transcript lines share the response's usage, so it is taken + * once from the last line (which also carries the stop_reason), not summed. + * `agentName`, when set, tags the span so the Agents view groups a + * subagent's/teammate's calls under that agent. + */ +export function recordChat( + llm: weave.LLM, + group: AssistantCallDetail[], + conversationId: string, + agentName?: string, +): void { + const last = group.at(-1)!; + const parts = contentBlocksToParts(group.flatMap(c => c.contentBlocks)); + if (parts.length) llm.outputMessages = [{ role: 'assistant', parts }]; + llm.usage = buildUsage(last.usage, last.reasoningTokens); + const attrs: Attributes = { [ATTR.CONVERSATION_ID]: conversationId, [ATTR.OUTPUT_TYPE]: 'text' }; + if (agentName) attrs[ATTR.AGENT_NAME] = agentName; + if (last.responseId) attrs[ATTR.RESPONSE_ID] = last.responseId; + const finishReason = group.map(c => c.finishReason).find(Boolean); + if (finishReason) attrs[ATTR.RESPONSE_FINISH_REASONS] = [finishReason]; + llm.setAttributes(attrs); + llm.end({ endTime: parseIsoOrNow(last.timestamp) }); +} diff --git a/src/cli.ts b/src/cli.ts index 18e91e4..5f2017e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,6 @@ import { CONFIG_DIR, SETTINGS_FILE, MARKETPLACE_NAME, - PLUGIN_NAME, VERSION, InstallSource, MarketplaceStatus, @@ -144,8 +143,8 @@ async function cmdInstall( process.exit(1); } - const effectiveProject = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const effectiveApiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const effectiveProject = resolveProject(settings).value; + const effectiveApiKey = resolveApiKey(settings).value; if (nonInteractive) { console.log('\n- Non-interactive install: skipping setup prompts'); @@ -242,6 +241,49 @@ function resolveAgentName(settings: Settings): { value: string; source: AgentNam return { value: DEFAULT_AGENT_NAME, source: AgentNameSource.Default }; } +/** + * Resolve the effective Weave project and where it came from, applying the + * env-over-settings precedence (`WEAVE_PROJECT` beats `settings.weave_project`). + * Shared by install, config, status, and restart so they report one value. + * `value` uses nullish coalescing and `source` uses truthiness, matching the + * per-site expressions this replaces. + */ +function resolveProject(settings: Settings): { value: string | null; source: WeaveProjectSource } { + const value = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; + const source = process.env['WEAVE_PROJECT'] + ? WeaveProjectSource.EnvVar + : settings.weave_project + ? WeaveProjectSource.Settings + : WeaveProjectSource.NotSet; + return { value, source }; +} + +/** + * Resolve the effective W&B API key and where it came from, applying the + * env-over-settings precedence (`WANDB_API_KEY` beats `settings.wandb_api_key`). + * Shared by install, config, status, and restart so they report one value. + * `value` uses nullish coalescing and `source` uses truthiness, matching the + * per-site expressions this replaces. + */ +function resolveApiKey(settings: Settings): { value: string | null; source: ApiKeySource } { + const value = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const source = process.env['WANDB_API_KEY'] + ? ApiKeySource.EnvVar + : settings.wandb_api_key + ? ApiKeySource.Settings + : ApiKeySource.NotSet; + return { value, source }; +} + +/** + * Render the comma-joined list of missing required config for the "incomplete" + * status/restart messages. `apiKeyLabel` differs by call site (`wandb_api_key` + * for the config-oriented message, `WANDB_API_KEY` for the env-oriented one). + */ +function missingConfig(project: string | null, apiKey: string | null, apiKeyLabel: string): string { + return [!project && 'weave_project', !apiKey && apiKeyLabel].filter(Boolean).join(', '); +} + async function cmdConfig(args: string[]): Promise { const action = args[0]; @@ -254,19 +296,8 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } - const effectiveProject = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const projectSource: WeaveProjectSource = process.env['WEAVE_PROJECT'] - ? WeaveProjectSource.EnvVar - : settings.weave_project - ? WeaveProjectSource.Settings - : WeaveProjectSource.NotSet; - - const effectiveApiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; - const apiKeySource: ApiKeySource = process.env['WANDB_API_KEY'] - ? ApiKeySource.EnvVar - : settings.wandb_api_key - ? ApiKeySource.Settings - : ApiKeySource.NotSet; + const { value: effectiveProject, source: projectSource } = resolveProject(settings); + const { value: effectiveApiKey, source: apiKeySource } = resolveApiKey(settings); const apiKeyDisplay = effectiveApiKey ? `${maskSecret(effectiveApiKey)} [${apiKeySource}]` : `(not set)`; console.log('Current configuration:'); @@ -308,11 +339,9 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } if (key === 'weave_project') { - const effective = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - console.log(effective ?? '(not set)'); + console.log(resolveProject(settings).value ?? '(not set)'); } else if (key === 'wandb_api_key') { - const effective = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; - console.log(effective ?? '(not set)'); + console.log(resolveApiKey(settings).value ?? '(not set)'); } else { console.log(value ?? '(not set)'); } @@ -327,11 +356,12 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } - const writableKeys = ['weave_project', 'wandb_api_key', 'agent_name', 'daemon_socket', 'debug']; - if (!writableKeys.includes(key)) { + const writableKeys: readonly (keyof Settings)[] = ['weave_project', 'wandb_api_key', 'agent_name', 'daemon_socket', 'debug']; + if (!writableKeys.includes(key as keyof Settings)) { console.error(`Cannot set '${key}'. Writable keys: ${writableKeys.join(', ')}`); process.exit(1); } + const writableKey = key as keyof Settings; if (key === 'weave_project' && !value.includes('/')) { console.error(`Invalid format for weave_project: '${value}'\nExpected: entity/project (e.g. my-entity/my-project)`); @@ -351,10 +381,19 @@ async function cmdConfig(args: string[]): Promise { process.exit(1); } - const coerced = key === 'debug' ? value === 'true' : value; - (settings as unknown as Record)[key] = coerced; + // `debug` is the only boolean Settings field; every other writable key is a + // string. Split the assignment so each branch's value type matches the + // narrowed property type (no whole-object cast needed). + let coerced: string | boolean; + if (writableKey === 'debug') { + coerced = value === 'true'; + settings.debug = coerced; + } else { + coerced = value; + settings[writableKey] = value; + } saveSettings(settings); - const displayValue = key === 'wandb_api_key' && typeof coerced === 'string' + const displayValue = writableKey === 'wandb_api_key' && typeof coerced === 'string' ? maskSecret(coerced) : coerced; console.log(`✓ Set ${key} = ${displayValue}`); @@ -473,18 +512,17 @@ async function gatherStatus(): Promise { return snap; } - // Env vars take precedence over settings.json for both project and key. - const effectiveProject = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; + const { value: effectiveProject, source: projectSource } = resolveProject(settings); if (effectiveProject) { report.weave_project = effectiveProject; - report.weave_project_source = process.env['WEAVE_PROJECT'] ? WeaveProjectSource.EnvVar : WeaveProjectSource.Settings; + report.weave_project_source = projectSource; } - const effectiveApiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const { value: effectiveApiKey, source: apiKeySource } = resolveApiKey(settings); if (effectiveApiKey) { report.api_key_configured = true; snap.api_key_masked = maskSecret(effectiveApiKey); - snap.api_key_source = process.env['WANDB_API_KEY'] ? ApiKeySource.EnvVar : ApiKeySource.Settings; + snap.api_key_source = apiKeySource; } report.agent_name = resolveAgentName(settings).value; @@ -601,10 +639,11 @@ function printPrettyStatus(snap: StatusSnapshot): void { } else if (socketState === SocketState.Stale) { console.log('Status: Daemon socket is stale — will auto-recover on next Claude Code hook'); } else { - const missing = [ - !report.weave_project && 'weave_project', - !report.api_key_configured && 'wandb_api_key', - ].filter(Boolean).join(', '); + const missing = missingConfig( + report.weave_project, + report.api_key_configured ? 'set' : null, + 'wandb_api_key', + ); console.log(`Status: Configuration incomplete — set ${missing} to start tracing`); } } @@ -795,10 +834,10 @@ async function cmdRestart(): Promise { } // Don't spawn a daemon that would just exit for lack of config (mirrors runDaemon). - const project = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const apiKey = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const project = resolveProject(settings).value; + const apiKey = resolveApiKey(settings).value; if (!project || !apiKey) { - const missing = [!project && 'weave_project', !apiKey && 'WANDB_API_KEY'].filter(Boolean).join(', '); + const missing = missingConfig(project, apiKey, 'WANDB_API_KEY'); console.error(`⚠ Not starting daemon, missing configuration: ${missing}`); console.error(' Set it with: weave-claude-code config set weave_project ENTITY/PROJECT'); process.exit(1); diff --git a/src/daemon.ts b/src/daemon.ts index dfc4c8b..a4796bb 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -6,53 +6,61 @@ import * as net from 'net'; import * as fs from 'fs'; import * as path from 'path'; import { createHash } from 'crypto'; -import { - Baggage, - Span, - SpanStatusCode, - Tracer, - context as otelContext, - propagation, - diag, - DiagConsoleLogger, - DiagLogLevel, -} from '@opentelemetry/api'; -import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; -import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { resourceFromAttributes } from '@opentelemetry/resources'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; +import type { Attributes } from '@opentelemetry/api'; +import type { + HookInput, + SessionStartHookInput, + UserPromptSubmitHookInput, + PreToolUseHookInput, + PostToolUseHookInput, + PostToolUseFailureHookInput, + PermissionRequestHookInput, + SubagentStartHookInput, + SubagentStopHookInput, + TeammateIdleHookInput, + PreCompactHookInput, + StopHookInput, + SessionEndHookInput, +} from '@anthropic-ai/claude-agent-sdk'; +import * as weave from 'weave'; import { loadSettings, VERSION, type Settings } from './setup.js'; import { appendToLog, deepEqual } from './utils.js'; -import { - parseSessionFd, - extractAssistantTextBlocks, - isTextBlock, - isThinkingBlock, - isRedactedThinkingBlock, -} from './parser.js'; +import { parseSessionFd } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; import { ATTR, DEFAULT_AGENT_NAME, CompactionAttrs, - IntegrationBaggageSpanProcessor, - createIntegrationBaggage, - startTurnSpan, - startToolSpan, - startInvokeAgentSpan, - startChatSpan, - finalizeChatSpan, - emitAssistantTextSpan, - emitThinkingSpan, - emitChatSpansFromAssistantCalls, addPermissionRequestEvent, - addPermissionResolvedEvent, setCompactionAttrs, toolDisplayName, promptSnippet, jsonStr, - parseTimestamp, } from './genaiSpans.js'; +import { + chatMessageKey, + callsForResponseKey, + findToolUseResponseKey, + parseIsoOrNow, + startChat, + openChatForGroup, + recordChat, +} from './chatSpans.js'; +import { + resolvePermissionIfPending, + hashPrompt, + computeSubagentTranscriptPath, + extractUserMessageContent, + lastAssistantTextEndsWith, + readSubagentFirstLineWithRetry, + newSessionState, +} from './sessionState.js'; +import type { + PendingToolCall, + SubagentTracker, + TeamMember, + SessionState, +} from './sessionState.js'; import type { AssistantCallDetail, ParsedSession } from './parser.js'; // ───────────────────────────────────────────────────────────────────────────── @@ -75,261 +83,6 @@ function isControlMessage(payload: unknown): payload is ControlMessage { return cmd === 'shutdown' || cmd === 'config-hash'; } -/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ -type PendingToolCall = { - span: Span; - toolName: string; - toolInput: Record; - /** True once a PermissionRequest event has been emitted for this tool. */ - permissionRequested?: boolean; -} - -/** Tracks the chat span currently open for a single assistant API response. - * Tool spans for that response parent here so the trace tree shows the - * model's interleaved text → tool_use → text order. The response's - * text/thinking children are emitted when the span is finalized (at the next - * response transition or at Stop), once all its split transcript lines are - * present. */ -type ActiveChatSpan = { - /** Response key (Anthropic `message.id`, or index fallback) this chat span - * represents; see `chatMessageKey`. */ - responseKey: string; - span: Span; -} - -/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ -function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { - if (!pending.permissionRequested) return; - addPermissionResolvedEvent(pending.span, { - approved, - timestamp: new Date(), - }); -} - -/** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with - * the subagent's SubagentStart by matching transcript content. */ -function hashPrompt(prompt: string): string { - return createHash('sha256').update(prompt, 'utf8').digest('hex'); -} - -/** Stable identity for an assistant API call within a turn. Anthropic returns - * a `message.id` on every response; that's the primary key. When it's - * missing (legacy transcripts), fall back to the index, which is stable - * within a single parse + turn. */ -function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { - return call.responseId ?? `idx:${callIdx}`; -} - -/** All calls belonging to one assistant API response, in transcript order. - * Claude Code splits a single response's thinking / text / tool_use blocks - * across separate transcript lines that share a `message.id`; the parser maps - * each line to its own `AssistantCallDetail`, so this regroups them by key. */ -function callsForResponseKey( - calls: AssistantCallDetail[], - key: string, -): AssistantCallDetail[] { - const group: AssistantCallDetail[] = []; - for (let i = 0; i < calls.length; i++) { - if (chatMessageKey(calls[i], i) === key) group.push(calls[i]); - } - return group; -} - -/** Find the response key of the assistant call whose content contains a - * `tool_use` block with `toolUseId`, or undefined if not found (transcript - * not flushed yet, or unknown id). */ -function findToolUseResponseKey( - calls: AssistantCallDetail[], - toolUseId: string, -): string | undefined { - for (let ci = 0; ci < calls.length; ci++) { - for (const raw of calls[ci].contentBlocks) { - const b = raw as Record | undefined; - if (b && typeof b === 'object' && b['type'] === 'tool_use' && b['id'] === toolUseId) { - return chatMessageKey(calls[ci], ci); - } - } - } - return undefined; -} - -function parseIsoOrNow(ts: string | undefined): Date { - return parseTimestamp(ts) ?? new Date(); -} - -/** - * Map a parent transcript path + subagent agent_id to the subagent's transcript - * file. Claude Code writes subagent transcripts as siblings of the parent in a - * `/subagents/` subdirectory: - * parent: /.jsonl - * subagent: //subagents/agent-.jsonl - */ -function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { - const projectDir = path.dirname(parentTranscriptPath); - const sessionDirName = path.basename(parentTranscriptPath, '.jsonl'); - return path.join(projectDir, sessionDirName, 'subagents', `agent-${agentId}.jsonl`); -} - -/** Pull the user-message content out of a transcript line. Returns the prompt - * string for `{type: 'user', message: {content: string|Array}}` lines, else - * undefined. Array-form content is joined across text blocks. */ -function extractUserMessageContent(line: Record | undefined): string | undefined { - if (!line || line['type'] !== 'user') return undefined; - const msg = line['message']; - if (!msg || typeof msg !== 'object') return undefined; - const content = (msg as Record)['content']; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const block of content) { - if (block && typeof block === 'object' && (block as Record)['type'] === 'text') { - const t = (block as Record)['text']; - if (typeof t === 'string') parts.push(t); - } - } - return parts.length > 0 ? parts.join('') : undefined; - } - return undefined; -} - -/** True if the last assistant call's joined text ends with `suffix`, - * ignoring trailing whitespace on either side. */ -function lastAssistantTextEndsWith( - result: NonNullable>, - suffix: string, -): boolean { - const call = result.turns.at(-1)?.assistantCalls().at(-1); - // Turn exists but parser saw no assistant calls (writer mid-flush). - if (!call) return false; - return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); -} - -/** Read the subagent transcript's first line, retrying briefly because Claude - * Code may not have flushed it yet when SubagentStart fires. Total wait - * bounded by the sum of `RETRY_DELAYS_MS`. */ -const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; -async function readSubagentFirstLineWithRetry( - transcriptPath: string, -): Promise | undefined> { - for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { - if (delay > 0) await new Promise(r => setTimeout(r, delay)); - const line = readFirstTranscriptLine(transcriptPath); - if (line && line['type'] === 'user') return line; - } - return undefined; -} - -/** - * Tracks a subagent across hook events. Two shapes: - * (a) Matched — created at PreToolUse when an Agent tool with subagent_type - * is detected; carries `toolUseId`, `promptHash`, and a reference to - * the subagent's `invoke_agent` span. `agentId` is filled in at - * SubagentStart via content-based correlation: sha256(firing prompt) + - * subagent_type. - * (b) Orphan — created at SubagentStart when no tracker matches the firing - * prompt (the parent's Agent PreToolUse never fired, or its prompt - * differs from the subagent transcript's line 1). The `invoke_agent` - * span is created at SubagentStart with the current turn span as - * parent and no input messages (the firing prompt is unavailable). - * - * The subagent is its own `invoke_agent ` span, child of the - * parent turn's `invoke_agent claude-code` span. Per the Weave Agents chat - * view (`weave/trace_server/agents/chat_view.py`), nested `invoke_agent` - * spans render as an `agent_start` lifecycle marker with the inner agent's - * own assistant text — distinct from an `execute_tool` tool-call event. - * The Agent tool call does NOT emit an `execute_tool` span; it emits this - * `invoke_agent` span directly. - */ -type SubagentTracker = { - subagentType: string; - detectedAt: Date; - toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) - invokeAgentSpan?: Span; // subagent's `invoke_agent` span; subagent chat/tool spans parent here - agentId?: string; - /** sha256 of the prompt passed to the Agent tool; matched against the - * subagent's transcript line-1 user message at SubagentStart. */ - promptHash?: string; - /** True once the invoke_agent span has been ended. Guards against - * double-end when PostToolUse and SubagentStop both try to close it. */ - ended?: boolean; - /** Subagent transcript path — stored at SubagentStart so TeammateIdle can - * read all turns without relying on the payload's transcript_path (which - * CC sets to the coordinator's path, not the subagent's). */ - transcriptPath?: string; - /** Set on orphan trackers when SubagentStop fires before TeammateIdle. - * Suppresses span closure at SubagentStop so TeammateIdle can close it - * with full all-turns content. */ - pendingTeammateIdle?: boolean; - /** Set when this Agent tool spawn carried a `team_name` (agent-teams model). - * The teammate runs in its OWN session, so its TeammateIdle fires under a - * different session_id and the per-session lookup misses. The invoke_agent - * span is registered in GlobalDaemon.teamMembers and closed there (at the - * teammate's TeammateIdle), NOT at the coordinator's PostToolUse(Agent). */ - teamName?: string; -} - -/** Cross-session team correlation. In agent-teams (TeamCreate) a teammate is an - * independent Claude session whose TeammateIdle fires under the teammate's own - * session_id, not the coordinator's — so the per-session SubagentTracking - * lookup misses. The coordinator's PreToolUse(Agent, team_name) is the one - * reliable anchor; we record its invoke_agent span here keyed by - * `${team_name}::${name}`. - * - * Entries are stored as a FIFO queue per key (not a single value) because the - * SAME `${team}::${name}` can be spawned more than once in a run — e.g. the - * TARS triage flow re-spawns a specialist (Sonnet→Opus) for deeper work. Each - * spawn pushes its own TeamMember; each teammate's TeammateIdle consumes the - * oldest not-yet-emitted entry (FIFO), so re-spawns never overwrite a live span - * (which would leak it and mis-attribute the first teammate's transcript). This - * mirrors SubagentTracking.findPendingTeammateIdle for the per-session path. */ -type TeamMember = { - invokeAgentSpan: Span; - conversationId: string; - coordinatorTranscriptPath: string; - emitted: boolean; -} - -type SessionState = { - sessionId: string; - /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed - * turns stitch with their pre-resume turns server-side. Equals `sessionId` - * for fresh (non-forked) sessions. Resolved once at SessionStart by - * walking `forkedFrom.sessionId` pointers across transcript files. */ - conversationId: string; - transcript: TranscriptFile; - cwd: string; - source: string; - initialRequestModel?: string; - /** Integration identity (name, version, meta.*) as OTel Baggage, built once - * at SessionStart. Activated for every event in `routeEvent` so each span - * inherits it via `IntegrationBaggageSpanProcessor`. */ - integrationBaggage: Baggage; - - currentTurnSpan?: Span; - - turnNumber: number; - totalToolCalls: number; - turnToolCalls: number; - toolCounts: Record; - - pendingToolCalls: Map; - subagents: SubagentTracking; - - /** Chat span currently open for an in-progress assistant API call. Tool - * spans from PreToolUse parent here; finalized at Stop or on transition - * to the next API call. Cleared at Stop. */ - activeChatSpan?: ActiveChatSpan; - /** Response keys (see `chatMessageKey`) in the current turn for which a chat - * span has been opened (open or already finalized). Stop uses this to - * identify responses that need a chat span emitted from scratch (responses - * with no tool_use blocks never triggered PreToolUse). Reset per turn. */ - emittedChatSpanResponseKeys: Set; - - /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ - pendingCompaction?: CompactionAttrs; - -} - // ───────────────────────────────────────────────────────────────────────────── // GlobalDaemon // ───────────────────────────────────────────────────────────────────────────── @@ -354,124 +107,6 @@ const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection const MAX_SOCKET_PAYLOAD_BYTES = 4 * 1024 * 1024; // 4 MiB per message -/** - * Per-session container that tracks subagents from PreToolUse (when an Agent - * tool with subagent_type is detected) through SubagentStop. Single source of - * truth for the tracker list, with intent-revealing lookup methods. - */ -class SubagentTracking { - private trackers: SubagentTracker[] = []; - - /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ - add(tracker: SubagentTracker): void { - this.trackers.push(tracker); - } - - /** - * Find the unmatched tracker (no agent_id yet) matching `(promptHash, - * subagentType)`. FIFO across ties: the oldest pending tracker wins, so two - * back-to-back identical Agent calls still correlate in dispatch order. - * Returns undefined if no candidate qualifies. - */ - findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (t.agentId) continue; - if (t.promptHash !== promptHash) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - byAgentId(agentId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.agentId === agentId); - } - - /** Find a tracker awaiting TeammateIdle by its subagentType. Used to - * correlate TeammateIdle(teammate_name) with the orphan tracker created - * at SubagentStart. Returns the oldest pending match (FIFO). */ - findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (!t.pendingTeammateIdle) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - /** Lookup by spawning Agent tool's tool_use_id. Used at PostToolUse to find - * the subagent's `invoke_agent` span when the matching toolUseId is not - * in `pendingToolCalls` (because the Agent tool emits an invoke_agent - * span instead of an execute_tool span). */ - byToolUseId(toolUseId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.toolUseId === toolUseId); - } - - remove(tracker: SubagentTracker): void { - const idx = this.trackers.indexOf(tracker); - if (idx >= 0) this.trackers.splice(idx, 1); - } - - size(): number { - return this.trackers.length; - } - - all(): SubagentTracker[] { - return [...this.trackers]; - } -} - -/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 - * for a brand-new session, or the number of turns already on disk when - * reconstructing a session lost across a daemon restart (so the resumed turn - * keeps counting up instead of resetting to 1). */ -type NewSessionStateOptions = { - sessionId: string; - conversationId: string; - transcript: TranscriptFile; - cwd: string; - source: string; - initialRequestModel: string | undefined; - turnNumber: number; -}; - -/** Build a fresh SessionState. */ -function newSessionState(options: NewSessionStateOptions): SessionState { - const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = - options; - // Claude Code stamps its CLI version on each transcript line; capture it - // best-effort from the head line for the integration metadata. Absent when - // the writer hasn't flushed yet, the meta key is simply omitted. Built - // here (not at the SessionStart call site) so a session reconstructed after - // a daemon restart carries the same integration identity on its spans. - const headLine = readFirstTranscriptLine(transcript.resolvedPath); - const version = headLine?.['version']; - const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; - const integrationBaggage = createIntegrationBaggage({ - version: VERSION, - meta: { claude_code_app_version: claudeCodeAppVersion }, - }); - - return { - sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, - integrationBaggage, - turnNumber, - totalToolCalls: 0, - turnToolCalls: 0, - toolCounts: {}, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), - }; -} - export class GlobalDaemon { private server?: net.Server; private running = false; @@ -481,8 +116,8 @@ export class GlobalDaemon { private readonly inactivityMs = Number(process.env.WEAVE_INACTIVITY_MS) || INACTIVITY_TIMEOUT_MS; private sessions = new Map(); private sessionQueues = new Map>(); - private provider: NodeTracerProvider | null = null; - private tracer: Tracer | null = null; + /** True once `weave.init` has completed. All span emission is gated on it. */ + private tracingEnabled = false; /** Cross-session team correlation, keyed by `${team_name}::${name}`. Bridges * the coordinator's PreToolUse(Agent) to each teammate's TeammateIdle. The * value is a FIFO queue: a re-spawned `${team}::${name}` appends rather than @@ -500,16 +135,15 @@ export class GlobalDaemon { ) {} async start(): Promise { - // Initialize the OTel tracer if Weave is configured + // Initialize the Weave SDK if Weave is configured if (this.weaveProject && this.apiKey) { try { - this.initTracer(); + await this.initWeave(); this.log('INFO', `OTel tracer initialized — project=${this.weaveProject}, endpoint=${this.baseUrl}/agents/otel/v1/traces`); this.log('INFO', `View traces: https://wandb.ai/${this.weaveProject}/weave/agents`); } catch (err) { this.log('ERROR', `Failed to initialize OTel tracer: ${err} — continuing without tracing`); - this.provider = null; - this.tracer = null; + this.tracingEnabled = false; } } else { this.log('INFO', 'No weave_project / API key configured — tracing disabled'); @@ -604,7 +238,7 @@ export class GlobalDaemon { // ── tracer initialization ─────────────────────────────────────────────── - private initTracer(): void { + private async initWeave(): Promise { if (!this.weaveProject) throw new Error('weaveProject required to init tracer'); if (!this.apiKey) throw new Error('apiKey required to init tracer'); @@ -613,34 +247,17 @@ export class GlobalDaemon { throw new Error(`Invalid weave_project format: '${this.weaveProject}' (expected entity/project)`); } - // Route OTel diagnostics into the daemon log so exporter errors surface. - if (this.debugEnabled) { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN); - } + // The Weave SDK has no programmatic apiKey/host in its Settings; it resolves + // both from the environment (weave login() would instead write a netrc + // entry, which is wrong for a background daemon). WF_TRACE_SERVER_URL points + // the OTLP exporter straight at our trace server; WANDB_API_KEY supplies the + // auth header. We deliberately do NOT set WANDB_BASE_URL (weave treats that + // as the API host and would derive a wrong trace URL from it). + process.env['WF_TRACE_SERVER_URL'] = this.baseUrl; + process.env['WANDB_API_KEY'] = this.apiKey; - const resource = resourceFromAttributes({ - // service.name has always mirrored the agent name; keep that coupling - // so a custom agent_name renames the OTel service too. - 'service.name': this.agentName, - 'service.version': VERSION, - 'wandb.entity': entity, - 'wandb.project': project, - }); - - const exporter = new OTLPTraceExporter({ - url: `${this.baseUrl}/agents/otel/v1/traces`, - headers: { 'wandb-api-key': this.apiKey }, - }); - - this.provider = new NodeTracerProvider({ - resource, - // IntegrationBaggageSpanProcessor runs first so it stamps the integration - // identity (from the active session baggage) onto every span before the - // batch processor exports it. - spanProcessors: [new IntegrationBaggageSpanProcessor(), new BatchSpanProcessor(exporter)], - }); - this.provider.register(); - this.tracer = this.provider.getTracer('weave-claude-code', VERSION); + await weave.init(this.weaveProject); + this.tracingEnabled = true; } // ── connection handling ─────────────────────────────────────────────────── @@ -722,91 +339,95 @@ export class GlobalDaemon { // ── event routing ───────────────────────────────────────────────────────── private async routeEvent(payload: HookPayload): Promise { - const eventName = payload['hook_event_name'] as string | undefined; - const sessionId = payload['session_id'] as string | undefined; - const agentId = payload['agent_id'] as string | undefined; - + // The socket delivers raw hook JSON; trust it against the SDK's hook schema + // once here so the dispatch and handlers work with typed, discriminated + // inputs instead of re-casting every field. + const input = payload as HookInput; + const sessionId = input.session_id; if (!sessionId) { this.log('ERROR', 'Missing session_id in payload'); return; } - this.log('INFO', `${eventName ?? 'unknown'} session=${sessionId}${agentId ? ` agent=${agentId}` : ''}`); - - // Activate the session's integration baggage for the whole event so every - // span created while handling it inherits the integration identity (copied - // on by IntegrationBaggageSpanProcessor). The session and its baggage don't - // exist until SessionStart runs, so that one event dispatches unwrapped — it - // creates no spans anyway. - const session = this.sessions.get(sessionId); - const eventContext = session - ? propagation.setBaggage(otelContext.active(), session.integrationBaggage) - : otelContext.active(); - await otelContext.with(eventContext, () => - this.dispatchEvent(eventName, sessionId, agentId, payload), - ); + this.log('INFO', `${input.hook_event_name} session=${sessionId}${input.agent_id ? ` agent=${input.agent_id}` : ''}`); + + // Each event runs in its own isolated context so ambient GenAI state (the + // conversation, and any turn/LLM the SDK's factories consult) never leaks + // across events. The SDK copies the conversation's `attributes` onto each + // span at creation, but runIsolated gives every frame a fresh empty state, + // so re-install the session's conversation here; otherwise the integration + // identity would only land on spans created in the SessionStart frame. The + // session (and its conversation) don't exist until SessionStart runs, so + // that one event runs without a re-install; it creates no child spans. + await weave.runIsolated(async () => { + const session = this.sessions.get(sessionId); + if (session) { + weave.startConversation({ + conversationId: session.conversationId, + agentName: this.agentName, + attributes: session.integrationAttrs, + }); + } + await this.dispatchEvent(input, sessionId); + }); } - /** Run the handler for a single hook event. Split out from `routeEvent` so - * the latter can run it inside the session's baggage context. */ - private async dispatchEvent( - eventName: string | undefined, - sessionId: string, - agentId: string | undefined, - payload: HookPayload, - ): Promise { + /** Run the handler for a single hook event, narrowing `input` to the event's + * variant via the discriminant. Split out from `routeEvent` so the latter can + * run it inside the isolated per-event context. */ + private async dispatchEvent(input: HookInput, sessionId: string): Promise { try { - switch (eventName) { + switch (input.hook_event_name) { case 'SessionStart': - await this.handleSessionStart(sessionId, payload); + await this.handleSessionStart(sessionId, input); break; case 'UserPromptSubmit': - await this.handleUserPromptSubmit(sessionId, payload); + await this.handleUserPromptSubmit(sessionId, input); break; case 'PreToolUse': - await this.handlePreToolUse(sessionId, agentId, payload); + await this.handlePreToolUse(sessionId, input); break; case 'PermissionRequest': - await this.handlePermissionRequest(sessionId, payload); + await this.handlePermissionRequest(sessionId, input); break; case 'PostToolUse': - await this.handlePostToolUse(sessionId, payload); + await this.handlePostToolUse(sessionId, input); break; case 'PostToolUseFailure': - await this.handlePostToolUseFailure(sessionId, payload); + await this.handlePostToolUseFailure(sessionId, input); break; case 'SubagentStart': - await this.handleSubagentStart(sessionId, payload); + await this.handleSubagentStart(sessionId, input); break; case 'SubagentStop': - await this.handleSubagentStop(sessionId, payload); + await this.handleSubagentStop(sessionId, input); break; case 'TeammateIdle': - await this.handleTeammateIdle(sessionId, payload); + await this.handleTeammateIdle(sessionId, input); break; case 'PreCompact': - await this.handlePreCompact(sessionId, payload); + await this.handlePreCompact(sessionId, input); break; case 'Stop': - await this.handleStop(sessionId, payload); + await this.handleStop(sessionId, input); break; case 'SessionEnd': - await this.handleSessionEnd(sessionId, payload); + await this.handleSessionEnd(sessionId, input); break; default: break; } } catch (err) { - this.log('ERROR', `Error handling ${eventName ?? 'unknown'}: ${err}`); + this.log('ERROR', `Error handling ${input.hook_event_name}: ${err}`); } } // ── event handlers ──────────────────────────────────────────────────────── - private async handleSessionStart(sessionId: string, payload: HookPayload): Promise { + private async handleSessionStart(sessionId: string, input: SessionStartHookInput): Promise { if (this.sessions.has(sessionId)) return; // idempotent - const rawPath = payload['transcript_path'] as string | undefined; + const rawPath = input.transcript_path; if (!rawPath) { this.log('ERROR', `Missing transcript_path for session ${sessionId}`); return; @@ -820,24 +441,34 @@ export class GlobalDaemon { return; } - const source = (payload['source'] as string | undefined) ?? 'unknown'; - const initialRequestModel = payload['model'] as string | undefined; - const cwd = (payload['cwd'] as string | undefined) ?? ''; + const source = input.source; + const initialRequestModel = input.model; + const cwd = input.cwd; const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); - this.sessions.set( + const session = newSessionState({ sessionId, - newSessionState({ - sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + turnNumber: 0, + }); + + // Install the conversation for this SessionStart frame; routeEvent + // re-installs it for every later event (each runIsolated frame is fresh). + // The SDK copies the integration identity onto every span created under it. + if (this.tracingEnabled) { + weave.startConversation({ conversationId, - transcript, - cwd, - source, - initialRequestModel, - turnNumber: 0, - }), - ); + agentName: this.agentName, + attributes: session.integrationAttrs, + }); + } + + this.sessions.set(sessionId, session); const resumed = conversationId !== sessionId; this.log('INFO', `Session created: ${sessionId}${resumed ? ` (resumed; conversation=${conversationId})` : ''}`); @@ -933,12 +564,12 @@ export class GlobalDaemon { */ private async getOrReconstructSession( sessionId: string, - payload: HookPayload, + input: HookInput, ): Promise { const existing = this.sessions.get(sessionId); if (existing) return existing; - const rawPath = payload['transcript_path'] as string | undefined; + const rawPath = input.transcript_path; if (!rawPath) return undefined; let transcript: TranscriptFile; @@ -949,9 +580,12 @@ export class GlobalDaemon { return undefined; } - const source = (payload['source'] as string | undefined) ?? 'reconstructed'; - const cwd = (payload['cwd'] as string | undefined) ?? ''; - const initialRequestModel = payload['model'] as string | undefined; + // source/model aren't on every hook variant (this reconstructs from a + // UserPromptSubmit), so read them best-effort off the raw record. + const raw = input as Record; + const source = (raw['source'] as string | undefined) ?? 'reconstructed'; + const cwd = input.cwd; + const initialRequestModel = raw['model'] as string | undefined; const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); // Seed the turn counter from the turns already on disk so numbering @@ -973,6 +607,16 @@ export class GlobalDaemon { turnNumber: priorTurns, }); this.sessions.set(sessionId, session); + // Install the conversation for the current event frame. routeEvent's + // re-install ran before this session existed, so without this the spans this + // event emits would miss the integration identity (matches handleSessionStart). + if (this.tracingEnabled) { + weave.startConversation({ + conversationId, + agentName: this.agentName, + attributes: session.integrationAttrs, + }); + } this.log( 'INFO', `Session reconstructed after restart: ${sessionId} (conversation=${conversationId}, prior_turns=${priorTurns})`, @@ -980,109 +624,110 @@ export class GlobalDaemon { return session; } - private async handleUserPromptSubmit(sessionId: string, payload: HookPayload): Promise { + private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { // Reconstruct the session if this daemon never saw its SessionStart (e.g. it // idled out mid-session and a fresh daemon took over) so the rest of the // session stays traced instead of dropping with "Unknown session". - const session = await this.getOrReconstructSession(sessionId, payload); + const session = await this.getOrReconstructSession(sessionId, input); if (!session) { this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); return; } - if (!this.tracer) return; + if (!this.tracingEnabled) return; - const prompt = (payload['prompt'] as string | undefined) ?? ''; + const prompt = input.prompt; this.log( 'DEBUG', - `UserPromptSubmit: session=${sessionId} current_turn_span=${session.currentTurnSpan ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${promptSnippet(prompt, 120)}`, + `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${promptSnippet(prompt, 120)}`, ); session.turnNumber += 1; session.turnToolCalls = 0; session.emittedChatSpanResponseKeys.clear(); - const turnSpan = startTurnSpan(this.tracer, { - sessionId: session.sessionId, - conversationId: session.conversationId, - turnNumber: session.turnNumber, - prompt, - cwd: session.cwd, - source: session.source, - pluginVersion: VERSION, + // The turn is the root of its own trace; the Weave Agents backend stitches + // turns into a conversation via `gen_ai.conversation.id` (inherited from the + // ambient conversation re-installed in routeEvent). Session-level metadata + // (cwd, source, plugin.version) is stamped on every turn so it's queryable + // without a separate session-level span. + // conversationId is inherited from the ambient conversation (re-installed in + // routeEvent), which stamps `gen_ai.conversation.id` on the turn and its + // children. + const turn = weave.startTurn({ agentName: this.agentName, - requestModel: session.initialRequestModel, - displayName: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`, + startTime: new Date(), }); - session.currentTurnSpan = turnSpan; + const attrs: Attributes = { + [ATTR.AGENT_VERSION]: VERSION, + [ATTR.WEAVE_SESSION_ID]: session.sessionId, + [ATTR.WEAVE_CWD]: session.cwd, + [ATTR.WEAVE_SOURCE]: session.source, + [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, + [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, + [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]), + [ATTR.WEAVE_DISPLAY_NAME]: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`, + }; + if (session.initialRequestModel) attrs[ATTR.REQUEST_MODEL] = session.initialRequestModel; + turn.setAttributes(attrs); + session.currentTurn = turn; // Drain compaction attrs buffered while no turn was open. if (session.pendingCompaction) { - setCompactionAttrs(turnSpan, session.pendingCompaction); + setCompactionAttrs(turn, session.pendingCompaction); session.pendingCompaction = undefined; } - - this.log( - 'INFO', - `Created turn span (turn ${session.turnNumber}) trace_id=${turnSpan.spanContext().traceId}`, - ); + this.log('INFO', `Created turn span (turn ${session.turnNumber})`); } - private async handlePreToolUse(sessionId: string, agentId: string | undefined, payload: HookPayload): Promise { + private async handlePreToolUse(sessionId: string, input: PreToolUseHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; + if (!session || !this.tracingEnabled) return; - const toolUseId = payload['tool_use_id'] as string | undefined; - const toolName = payload['tool_name'] as string | undefined; + const agentId = input.agent_id; + const toolUseId = input.tool_use_id; + const toolName = input.tool_name; if (!toolUseId || !toolName) return; - const toolInput = (payload['tool_input'] ?? {}) as Record; - - // Parent: subagent's invoke_agent span if this PreToolUse comes from inside - // a subagent, else the current turn span. - const parentSpan = agentId - ? session.subagents.byAgentId(agentId)?.invokeAgentSpan ?? session.currentTurnSpan - : session.currentTurnSpan; - if (!parentSpan) { - this.log('ERROR', `PreToolUse: no parent span for session=${sessionId} tool=${toolName}`); - return; - } - - // For the main agent, parent the tool span under its assistant response's - // chat span; subagents keep flat parenting under their invoke_agent span. - const toolParent = this.resolveMainAgentToolParent(session, agentId, toolUseId, parentSpan); + // tool_input is per-tool JSON the SDK types as `unknown`; narrow to index it. + const toolInput = (input.tool_input ?? {}) as Record; // Agent tool with subagent_type → emit a nested `invoke_agent ` - // span, NOT an `execute_tool Agent` span. The Weave Agents chat view renders - // nested invoke_agent spans as their own `agent_start` lifecycle marker; an - // execute_tool wrapper here would mis-render the subagent dispatch as a - // generic tool call. The Agent tool's PostToolUse closes this span with the - // subagent's final return as `gen_ai.output.messages`. + // span (a SubAgent marker), NOT an `execute_tool Agent` span. The Weave + // Agents chat view renders nested invoke_agent spans as their own + // `agent_start` lifecycle marker; an execute_tool wrapper here would + // mis-render the subagent dispatch as a generic tool call. The Agent tool's + // PostToolUse closes this span with the subagent's final return. // // `promptHash` lets SubagentStart correlate this tracker to the right // subagent deterministically by reading the subagent transcript's line 1 // (the firing prompt) and matching by sha256 + subagent_type. if (!agentId && toolName === 'Agent' && toolInput['subagent_type']) { + if (!session.currentTurn) { + this.log('ERROR', `PreToolUse(Agent): no current turn for session=${sessionId}`); + return; + } const subagentType = toolInput['subagent_type'] as string; - const prompt = typeof toolInput['prompt'] === 'string' ? (toolInput['prompt'] as string) : ''; - const invokeAgentSpan = startInvokeAgentSpan(this.tracer, toolParent, { - agentType: subagentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - inputMessages: prompt ? [{ role: 'user', content: prompt }] : undefined, - spawningToolCallId: toolUseId, - displayName: toolDisplayName(toolName, toolInput), - }); + const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; + const subAgent = session.currentTurn.startSubagent({ name: subagentType, startTime: new Date() }); + const subAttrs: Attributes = { + [ATTR.AGENT_VERSION]: VERSION, + [ATTR.CONVERSATION_ID]: session.conversationId, + [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, + [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput), + }; + if (prompt) subAttrs[ATTR.INPUT_MESSAGES] = jsonStr([{ role: 'user', content: prompt }]); + subAgent.setAttributes(subAttrs); // Agent-teams: when the Agent tool carries a `team_name`, the teammate // runs as its own session and TeammateIdle fires under the teammate's - // session_id. Register the invoke_agent span in the cross-session team - // map so TeammateIdle can find it regardless of which session fires it. - const teamName = typeof toolInput['team_name'] === 'string' ? (toolInput['team_name'] as string) : undefined; - const memberName = (typeof toolInput['name'] === 'string' && toolInput['name']) ? (toolInput['name'] as string) : subagentType; + // session_id. Register the SubAgent in the cross-session team map so + // TeammateIdle can find it regardless of which session fires it. + const teamName = typeof toolInput['team_name'] === 'string' ? toolInput['team_name'] : undefined; + const memberName = (typeof toolInput['name'] === 'string' && toolInput['name']) ? toolInput['name'] : subagentType; session.subagents.add({ toolUseId, subagentType, detectedAt: new Date(), - invokeAgentSpan, + subAgent, promptHash: hashPrompt(prompt), teamName, }); @@ -1094,9 +739,10 @@ export class GlobalDaemon { const key = `${teamName}::${memberName}`; const queue = this.teamMembers.get(key) ?? []; queue.push({ - invokeAgentSpan, + subAgent, conversationId: session.conversationId, coordinatorTranscriptPath: session.transcript.resolvedPath, + integrationAttrs: session.integrationAttrs, emitted: false, }); this.teamMembers.set(key, queue); @@ -1105,54 +751,50 @@ export class GlobalDaemon { return; } - const toolSpan = startToolSpan(this.tracer, toolParent, { - toolName, - toolUseId, - toolInput, - displayName: toolDisplayName(toolName, toolInput), + // Parent for the tool span. A SubAgent is a leaf (it can't parent tools), so + // a subagent's own tools nest directly under the turn and carry the + // subagent's `gen_ai.agent.name` so the Agents view groups them. For the + // main agent, nest under the active response's chat span (advanced from the + // transcript), falling back to the turn when the machine can't advance yet. + const subagentType = agentId ? session.subagents.byAgentId(agentId)?.subagentType : undefined; + const parent: weave.Turn | weave.LLM | undefined = agentId + ? session.currentTurn + : this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; + if (!parent) { + this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); + return; + } + + const tool = parent.startTool({ + name: toolName, + args: jsonStr(toolInput), + toolCallId: toolUseId, + startTime: new Date(), }); - session.pendingToolCalls.set(toolUseId, { span: toolSpan, toolName, toolInput }); + const toolAttrs: Attributes = { [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput) }; + if (subagentType) toolAttrs[ATTR.AGENT_NAME] = subagentType; + tool.setAttributes(toolAttrs); + session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); } /** - * Open (or reuse) the chat span for the assistant API response that produced - * `toolUseId`, so the tool span can parent under it. Reads the transcript to - * find which response the tool_use belongs to; on a transition to a new - * response, finalizes the previous chat span first. Returns the chat span, - * or `undefined` if the transcript can't be located / parsed yet (caller - * falls back to the turn span). + * Advance the chat-span state machine for the main agent: find the assistant + * response that produced `toolUseId`, ensure its `chat` span (an LLM) is open, + * and return it so the tool span nests under it. Reads the transcript to map + * the tool_use to its response; on a transition to a new response, finalizes + * the previous chat span first. Returns the LLM, or `undefined` if the + * transcript can't be located / parsed yet or the response has no model yet + * (LLMInit.model is required); the caller then falls back to the turn span. * - * Text/thinking children are NOT emitted here; they're emitted when the - * chat span is finalized (next transition or Stop), once all of the - * response's split transcript lines are present and can be stamped with - * their real timestamps. (Claude Code writes each content block as its own - * transcript line sharing a `message.id`; at PreToolUse the trailing lines - * may not be flushed yet.) - */ - /** - * Parent span for a tool span emitted from PreToolUse. For the main agent, - * advance the chat-span machine — find the assistant response containing this - * tool_use, ensure its `chat` span is open, and return it so the tool span - * nests under it. (The response's text/thinking children are emitted when the chat - * span is finalized, not here.) Falls back to `fallback` (the turn span) when - * the machine can't advance yet, e.g. the transcript writer hasn't flushed - * the assistant message. Subagents keep flat parenting under their - * invoke_agent span — their chat spans are emitted at SubagentStop / - * TeammateIdle, where the full transcript is available — so they return - * `fallback` unchanged. + * The response's text/thinking blocks are NOT set here; they become + * `gen_ai.output.messages` parts when the chat span is finalized (next + * transition or Stop), once all of the response's split transcript lines are + * present. (Claude Code writes each content block as its own transcript line + * sharing a `message.id`; at PreToolUse the trailing lines may not be flushed + * yet.) */ - private resolveMainAgentToolParent( - session: SessionState, - agentId: string | undefined, - toolUseId: string, - fallback: Span, - ): Span { - if (agentId) return fallback; - return this.advanceMainAgentChatSpan(session, toolUseId) ?? fallback; - } - - private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): Span | undefined { - if (!this.tracer || !session.currentTurnSpan) return undefined; + private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { + if (!this.tracingEnabled || !session.currentTurn) return undefined; // Re-parses the whole transcript per main-agent PreToolUse: O(size) per // tool call. Off CC's critical path (async daemon), so no editor latency; @@ -1165,7 +807,7 @@ export class GlobalDaemon { } const parsed = parseSessionFd(fd); if (!parsed) return undefined; - const lastTurn = parsed.turns[parsed.turns.length - 1]; + const lastTurn = parsed.turns.at(-1); if (!lastTurn) return undefined; const calls = lastTurn.assistantCalls(); const key = findToolUseResponseKey(calls, toolUseId); @@ -1176,113 +818,75 @@ export class GlobalDaemon { } // Transition to a new API response: finalize the previous chat span first. - if (session.activeChatSpan && session.activeChatSpan.responseKey !== key) { + if (session.activeChat && session.activeChat.responseKey !== key) { this.finalizeActiveChatSpan(session, calls); } - if (!session.activeChatSpan) { + if (!session.activeChat) { // key came from findToolUseResponseKey above, so the group is non-empty. const group = callsForResponseKey(calls, key); - const first = group[0]; - const span = startChatSpan(this.tracer, session.currentTurnSpan, { - conversationId: session.conversationId, - model: group.map(c => c.model).find(Boolean), - startedAt: parseIsoOrNow(first.prevTimestamp ?? first.timestamp), - }); - session.activeChatSpan = { responseKey: key, span }; + // If the writer hasn't flushed the model yet, fall back to the turn span + // (matching the undefined-transcript path); the response's chat span is + // emitted at Stop once the model is present. + const llm = openChatForGroup(session.currentTurn, group); + if (!llm) return undefined; + session.activeChat = { responseKey: key, llm }; session.emittedChatSpanResponseKeys.add(key); } - return session.activeChatSpan.span; + return session.activeChat.llm; } - /** Finalize `session.activeChatSpan` from the current transcript and clear it. */ + /** Finalize `session.activeChat` from the current transcript and clear it. */ private finalizeActiveChatSpan(session: SessionState, calls: AssistantCallDetail[]): void { - const active = session.activeChatSpan; + const active = session.activeChat; if (!active) return; - this.emitChatSpanForResponse(session, calls, active.responseKey, active.span); - session.activeChatSpan = undefined; + this.emitChatSpanForResponse(session, calls, active.responseKey, active.llm); + session.activeChat = undefined; } /** - * Emit a complete chat span for one assistant API response `key`. Emits each - * of the response's split transcript lines' text/thinking blocks as children - * stamped with that line's timestamp, so they sort into transcript order - * among the sibling `execute_tool` spans, which carry live PreToolUse times - * on the same wall clock. Usage is taken once from the response (the split + * Emit a complete chat span (LLM) for one assistant API response `key`. The + * response's ordered text / thinking / tool_use blocks become + * `gen_ai.output.messages` parts, so the model's natural interleave shows on + * the single chat span (the tools it called nest under this span as their own + * `execute_tool` children). Usage is taken once from the response (the split * lines duplicate the message usage, so it must not be summed), then the span - * is ended. Reuses `existingSpan` when the span was already opened during + * is ended. Reuses `existingLlm` when the span was already opened during * PreToolUse; otherwise opens a fresh one under the turn span. */ private emitChatSpanForResponse( session: SessionState, calls: AssistantCallDetail[], key: string, - existingSpan?: Span, + existingLlm?: weave.LLM, ): void { - if (!this.tracer || !session.currentTurnSpan) return; + if (!this.tracingEnabled || !session.currentTurn) return; // `key` always comes from a real call (findToolUseResponseKey / // chatMessageKey) and transcripts are append-only, so the group is never // empty. const group = callsForResponseKey(calls, key); - - const model = group.map(c => c.model).find(Boolean); - const span = existingSpan ?? startChatSpan(this.tracer, session.currentTurnSpan, { - conversationId: session.conversationId, - model, - startedAt: parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp), - }); - - for (const call of group) { - this.emitContentBlocks(span, call.contentBlocks, parseIsoOrNow(call.timestamp), session.conversationId); - } - - // Usage is identical across the response's split lines (each carries the - // full message usage), so take it from the last line, which also carries - // the stop_reason, rather than summing. - const last = group[group.length - 1]; - const finishReason = group.map(c => c.finishReason).find(Boolean); - const parts = group.flatMap(c => c.contentBlocks); - finalizeChatSpan(span, { - usage: last.usage, - reasoningTokens: last.reasoningTokens, - responseId: last.responseId, - finishReasons: finishReason ? [finishReason] : undefined, - model, - outputMessages: parts.length ? [{ role: 'assistant', parts }] : undefined, - endedAt: parseIsoOrNow(last.timestamp), - }); + // A response with no model yet can't open a chat span (LLMInit.model is + // required); skip it rather than guess a model. + const llm = existingLlm ?? openChatForGroup(session.currentTurn, group); + if (!llm) return; + recordChat(llm, group, session.conversationId); session.emittedChatSpanResponseKeys.add(key); } - /** Emit `assistant_text` / `thinking` spans for the text/thinking blocks in - * `blocks`, each stamped at `ts`. tool_use blocks are skipped; they render - * as their own live `execute_tool` spans. */ - private emitContentBlocks( - parent: Span, - blocks: unknown[], - ts: Date, - conversationId: string, - ): void { - if (!this.tracer) return; - for (const block of blocks) { - if (isTextBlock(block) && block.text.trim()) { - emitAssistantTextSpan(this.tracer, parent, { conversationId, text: block.text, startedAt: ts, endedAt: ts }); - } else if (isThinkingBlock(block) && block.thinking.trim()) { - emitThinkingSpan(this.tracer, parent, { conversationId, text: block.thinking, startedAt: ts, endedAt: ts }); - } else if (isRedactedThinkingBlock(block)) { - // Reasoning withheld by safety filtering: the `data` blob is encrypted, - // so surface a placeholder thinking span to keep it in transcript order. - emitThinkingSpan(this.tracer, parent, { conversationId, text: '[redacted]', startedAt: ts, endedAt: ts }); - } - } + /** Record one completed tool call in the session's total, per-turn, and + * per-tool-name counters (all bumped together whenever a tool finishes). */ + private countToolCall(session: SessionState, toolName: string): void { + session.totalToolCalls += 1; + session.turnToolCalls += 1; + session.toolCounts[toolName] = (session.toolCounts[toolName] ?? 0) + 1; } - private async handlePermissionRequest(sessionId: string, payload: HookPayload): Promise { + private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const toolName = payload['tool_name'] as string | undefined; + const toolName = input.tool_name; if (!toolName) return; // Correlate to a pending tool call by tool_name + tool_input. Record the @@ -1290,7 +894,7 @@ export class GlobalDaemon { // once we know whether it was approved. let pending: PendingToolCall | undefined; for (const call of session.pendingToolCalls.values()) { - if (call.toolName === toolName && !call.permissionRequested && deepEqual(call.toolInput, payload['tool_input'])) { + if (call.toolName === toolName && !call.permissionRequested && deepEqual(call.toolInput, input.tool_input)) { pending = call; break; } @@ -1301,38 +905,36 @@ export class GlobalDaemon { } pending.permissionRequested = true; - addPermissionRequestEvent(pending.span, { - suggestions: payload['permission_suggestions'], + addPermissionRequestEvent(pending.tool, { + suggestions: input.permission_suggestions, timestamp: new Date(), }); this.log('DEBUG', `Permission request recorded for ${toolName}`); } - private async handlePostToolUse(sessionId: string, payload: HookPayload): Promise { + private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const toolUseId = payload['tool_use_id'] as string | undefined; + const toolUseId = input.tool_use_id; if (!toolUseId) return; // Subagent dispatch: the matching span is the subagent's invoke_agent - // span (not a pendingToolCall), so we close it here with the subagent's + // marker (not a pendingToolCall), so we close it here with the subagent's // final assistant text as `gen_ai.output.messages`. const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.invokeAgentSpan) { + if (subagentTracker?.subAgent) { if (subagentTracker.teamName) { // Agent-teams: the Agent tool returns immediately (teammate runs async // in its own session). Do NOT close the invoke_agent span — it would // end empty before the teammate works. The team map owns it now. session.subagents.remove(subagentTracker); } else { - this.closeSubagentInvokeAgentSpan(subagentTracker, payload['tool_response'], /*failure*/ false); + this.closeSubagent(subagentTracker, input.tool_response, /*failure*/ false); session.subagents.remove(subagentTracker); } - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts['Agent'] = (session.toolCounts['Agent'] ?? 0) + 1; + this.countToolCall(session, 'Agent'); return; } @@ -1341,29 +943,27 @@ export class GlobalDaemon { resolvePermissionIfPending(pending, true); - pending.span.setAttribute(ATTR.TOOL_CALL_RESULT, jsonStr(payload['tool_response'])); - pending.span.end(); + pending.tool.result = jsonStr(input.tool_response); + pending.tool.end(); session.pendingToolCalls.delete(toolUseId); - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[pending.toolName] = (session.toolCounts[pending.toolName] ?? 0) + 1; + this.countToolCall(session, pending.toolName); } - private async handlePostToolUseFailure(sessionId: string, payload: HookPayload): Promise { + private async handlePostToolUseFailure(sessionId: string, input: PostToolUseFailureHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - const toolUseId = payload['tool_use_id'] as string | undefined; + const toolUseId = input.tool_use_id; if (!toolUseId) return; - const error = payload['error'] ?? payload['tool_response']; + const error = input.error; // Subagent dispatch failed (rare). Close the invoke_agent span with ERROR // status; subagent chat spans, if any reached SubagentStop, are already // attached as children. const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.invokeAgentSpan) { + if (subagentTracker?.subAgent) { if (subagentTracker.teamName) { // Agent-teams: the team map owns this span (closed at the teammate's // TeammateIdle, cross-session). Closing it here would end it early and @@ -1371,12 +971,10 @@ export class GlobalDaemon { // just drop the per-session tracker; the queue entry lives on. session.subagents.remove(subagentTracker); } else { - this.closeSubagentInvokeAgentSpan(subagentTracker, error, /*failure*/ true); + this.closeSubagent(subagentTracker, error, /*failure*/ true); session.subagents.remove(subagentTracker); } - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts['Agent'] = (session.toolCounts['Agent'] ?? 0) + 1; + this.countToolCall(session, 'Agent'); return; } @@ -1385,57 +983,50 @@ export class GlobalDaemon { resolvePermissionIfPending(pending, false); - pending.span.setAttribute(ATTR.TOOL_CALL_RESULT, jsonStr(error)); - pending.span.setAttribute(ATTR.ERROR_TYPE, this.errorTypeFor(error)); - pending.span.setStatus({ code: SpanStatusCode.ERROR, message: typeof error === 'string' ? error : 'tool failed' }); - pending.span.end(); + pending.tool.result = error; + pending.tool.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(error) }); + // The SDK records the exception + ERROR status from `error`. + pending.tool.end({ error: new Error(error) }); session.pendingToolCalls.delete(toolUseId); - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[pending.toolName] = (session.toolCounts[pending.toolName] ?? 0) + 1; + this.countToolCall(session, pending.toolName); } /** - * Close a subagent's `invoke_agent` span. Idempotent — guarded by + * Close a subagent's `invoke_agent` marker span. Idempotent: guarded by * `tracker.ended` so PostToolUse and SubagentStop can both safely call this * regardless of order. Sets `gen_ai.output.messages` from the canonical * tool return string when available; marks the span ERROR on failure. */ - private closeSubagentInvokeAgentSpan( + private closeSubagent( tracker: SubagentTracker, output: unknown, failure: boolean, ): void { - const span = tracker.invokeAgentSpan; - if (!span || tracker.ended) return; + const sub = tracker.subAgent; + if (!sub || tracker.ended) return; if (output !== undefined && output !== null && output !== '') { const outputText = typeof output === 'string' ? output : jsonStr(output); - span.setAttribute( - ATTR.OUTPUT_MESSAGES, - jsonStr([{ role: 'assistant', content: outputText }]), - ); + sub.setAttributes({ [ATTR.OUTPUT_MESSAGES]: jsonStr([{ role: 'assistant', content: outputText }]) }); } if (failure) { - span.setAttribute(ATTR.ERROR_TYPE, this.errorTypeFor(output)); - span.setStatus({ - code: SpanStatusCode.ERROR, - message: typeof output === 'string' ? output : 'subagent failed', - }); + sub.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(output) }); + sub.end({ error: new Error(typeof output === 'string' ? output : 'subagent failed') }); + } else { + sub.end(); } - span.end(); tracker.ended = true; } - private async handleSubagentStart(sessionId: string, payload: HookPayload): Promise { + private async handleSubagentStart(sessionId: string, input: SubagentStartHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; + if (!session || !this.tracingEnabled) return; - const agentId = payload['agent_id'] as string | undefined; + const agentId = input.agent_id; if (!agentId) return; - const agentType = (payload['agent_type'] as string | undefined) ?? 'unknown'; + const agentType = input.agent_type; // Content-based deterministic correlation: SubagentStart carries no // pointer back to the parent's `tool_use_id`, so we read the subagent's @@ -1471,33 +1062,33 @@ export class GlobalDaemon { transcriptPath: subagentPath, pendingTeammateIdle: true, }; - if (session.currentTurnSpan) { - bestTracker.invokeAgentSpan = startInvokeAgentSpan(this.tracer, session.currentTurnSpan, { - agentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - displayName: `Agent: ${agentType}`, + if (session.currentTurn) { + bestTracker.subAgent = session.currentTurn.startSubagent({ name: agentType, startTime: new Date() }); + bestTracker.subAgent.setAttributes({ + [ATTR.AGENT_VERSION]: VERSION, + [ATTR.CONVERSATION_ID]: session.conversationId, + [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, + [ATTR.WEAVE_ORPHAN_REASON]: reason, }); - bestTracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, reason); } session.subagents.add(bestTracker); } bestTracker.agentId = agentId; - if (bestTracker.invokeAgentSpan) { + if (bestTracker.subAgent) { // Stamp the runtime agent_id on the subagent's invoke_agent span — the // chat view uses `gen_ai.agent.id` to label the subagent's subtree. - bestTracker.invokeAgentSpan.setAttribute(ATTR.AGENT_ID, agentId); + bestTracker.subAgent.setAttributes({ [ATTR.AGENT_ID]: agentId }); } this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); } - private async handleSubagentStop(sessionId: string, payload: HookPayload): Promise { + private async handleSubagentStop(sessionId: string, input: SubagentStopHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; + if (!session || !this.tracingEnabled) return; - const agentId = payload['agent_id'] as string | undefined; + const agentId = input.agent_id; if (!agentId) return; const tracker = session.subagents.byAgentId(agentId); @@ -1506,12 +1097,12 @@ export class GlobalDaemon { return; } - // Chat spans for the subagent's LLM calls parent under the subagent's - // own invoke_agent span. For orphan trackers without an invoke_agent - // span (no current turn at SubagentStart), fall back to the turn span. - const chatParent = tracker.invokeAgentSpan ?? session.currentTurnSpan; + // The subagent marker (SubAgent) is a leaf and can't parent chat spans, so + // the subagent's LLM calls are emitted under the current turn and tagged + // with the subagent's `gen_ai.agent.name` so the Agents view groups them. + const chatParent = session.currentTurn; - const agentTranscriptPath = payload['agent_transcript_path'] as string | undefined; + const agentTranscriptPath = input.agent_transcript_path; let model: string | undefined; let lastAssistantText: string | undefined; if (agentTranscriptPath && chatParent) { @@ -1525,16 +1116,16 @@ export class GlobalDaemon { // and the user prompt that fires the subagent appears on line 1. // Emitting chat spans from earlier turns would mis-attribute the // parent's LLM call to this subagent invocation. - const lastTurn = parsed?.turns[parsed.turns.length - 1]; + const lastTurn = parsed?.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); if (lastTurn) { - emitChatSpansFromAssistantCalls( - this.tracer, + this.emitChatSpansUnderTurn( chatParent, session.conversationId, lastTurn.assistantCalls(), + tracker.subagentType, ); } } catch (err) { @@ -1544,11 +1135,11 @@ export class GlobalDaemon { } } - if (tracker.invokeAgentSpan) { + if (tracker.subAgent) { // Stamp the model the subagent actually ran on (Claude Code's // SubagentStart payload doesn't carry the model; the transcript does). if (model) { - tracker.invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); + tracker.subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); } // Orphan path: no PostToolUse will fire, so close the invoke_agent // span here — unless TeammateIdle is expected to follow (FleetView/ @@ -1558,7 +1149,7 @@ export class GlobalDaemon { // canonical tool_response and remove the tracker; if we removed the // tracker here, byToolUseId at PostToolUse would miss it. if (!tracker.ended && !tracker.toolUseId && !tracker.pendingTeammateIdle) { - this.closeSubagentInvokeAgentSpan(tracker, lastAssistantText, /*failure*/ false); + this.closeSubagent(tracker, lastAssistantText, /*failure*/ false); } } @@ -1575,8 +1166,8 @@ export class GlobalDaemon { } } - private async handleTeammateIdle(sessionId: string, payload: HookPayload): Promise { - if (!this.tracer) return; + private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { + if (!this.tracingEnabled) return; // FAIL-OPEN on a missing session: in the agent-teams model this hook fires // under the TEAMMATE's session_id, which may not be registered with this // daemon (only the coordinator is). `session` is therefore OPTIONAL for the @@ -1597,8 +1188,8 @@ export class GlobalDaemon { // // Note: CC docs incorrectly listed agent_id / agent_type — those fields do // not appear in practice. - const agentType = (payload['teammate_name'] as string | undefined) ?? 'teammate'; - const teamName = (payload['team_name'] as string | undefined) ?? '?'; + const agentType = input.teammate_name; + const teamName = input.team_name; // ── Cross-session team path (agent-teams / TeamCreate model) ───────── // The coordinator's PreToolUse(Agent, team_name) registered the invoke_agent @@ -1616,9 +1207,15 @@ export class GlobalDaemon { return; } member.emitted = true; - const idleTranscript = session?.transcript.resolvedPath ?? (payload['transcript_path'] as string | undefined); + const idleTranscript = session?.transcript.resolvedPath ?? input.transcript_path; const teammateTranscriptPath = this.resolveTeammateTranscript(member.coordinatorTranscriptPath, agentType, idleTranscript); - this.emitTeammateTranscript(member.invokeAgentSpan, member.conversationId, teammateTranscriptPath); + this.emitTeammateTurnTrace( + member.subAgent, + member.conversationId, + member.integrationAttrs, + agentType, + teammateTranscriptPath, + ); // Remove the consumed entry; drop the key once its queue drains. const idx = queue.indexOf(member); if (idx >= 0) queue.splice(idx, 1); @@ -1644,7 +1241,7 @@ export class GlobalDaemon { } const tracker = session.subagents.findPendingTeammateIdle(agentType); - if (!tracker?.invokeAgentSpan) { + if (!tracker?.subAgent) { this.log('DEBUG', `TeammateIdle: no pending tracker for ${agentType} team=${teamName} — skipping`); return; } @@ -1655,44 +1252,17 @@ export class GlobalDaemon { this.log('DEBUG', `TeammateIdle: agent=${agentType} team=${teamName} transcript=${transcriptPath ?? '(none)'}`); - let model: string | undefined; - let lastAssistantText: string | undefined; - let agentTranscript: TranscriptFile | undefined; - try { - if (!transcriptPath) throw new Error('no transcript path stored at SubagentStart'); - agentTranscript = new TranscriptFile(transcriptPath); - const parsed = parseSessionFd(agentTranscript.getFd()); - if (parsed) { - // Emit chat spans for ALL turns. Teammates are independent top-level - // sessions — every turn is their own work. SubagentStop only emitted - // the last turn; we replace that with full coverage here. - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls( - this.tracer, - tracker.invokeAgentSpan, - session.conversationId, - turn.assistantCalls(), - ); - } - const lastTurn = parsed.turns[parsed.turns.length - 1]; - model = lastTurn?.primaryModel(); - lastAssistantText = lastTurn?.textBlocks().join('\n'); - } - } catch (err) { - this.log('DEBUG', `TeammateIdle: could not parse transcript ${transcriptPath}: ${err}`); - } finally { - agentTranscript?.close(); - } - - if (model) tracker.invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); - if (lastAssistantText) { - tracker.invokeAgentSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - JSON.stringify([{ role: 'assistant', content: lastAssistantText }]), - ); - } - - this.closeSubagentInvokeAgentSpan(tracker, lastAssistantText, /*failure*/ false); + // Emit ALL turns from the teammate transcript under a fresh teammate turn + // trace (the coordinator turn that spawned it has already closed). Teammates + // are independent top-level sessions: every turn is their own work. + const model = this.emitTeammateTurnTrace( + tracker.subAgent, + session.conversationId, + session.integrationAttrs, + agentType, + transcriptPath, + ); + tracker.ended = true; session.subagents.remove(tracker); this.log('INFO', `TeammateIdle: traced ${agentType} model=${model ?? 'unknown'} path=${transcriptPath ?? '(no transcript)'}`); @@ -1733,13 +1303,40 @@ export class GlobalDaemon { return idleTranscriptPath; } - /** Parse a teammate's transcript and emit its chat spans under the given - * invoke_agent span, then end it. Used by the cross-session team path. */ - private emitTeammateTranscript( - invokeAgentSpan: Span, + /** + * Emit one `chat` span (LLM) per assistant call under `turn`, reconstructing + * each from transcript data (backdated start/end times, usage, ordered output + * parts). `agentName`, when set, tags each span with `gen_ai.agent.name` so + * the Agents view groups a subagent's/teammate's calls under that agent. + */ + private emitChatSpansUnderTurn( + turn: weave.Turn, conversationId: string, - transcriptPath: string | undefined, + calls: AssistantCallDetail[], + agentName?: string, ): void { + for (const c of calls) { + if (!c.model) continue; + const llm = startChat(turn, c.model, parseIsoOrNow(c.prevTimestamp ?? c.timestamp)); + recordChat(llm, [c], conversationId, agentName); + } + } + + /** + * Emit a teammate's whole transcript as its OWN turn trace, then close the + * teammate's SubAgent marker. TeammateIdle fires after the coordinator turn + * that spawned the teammate has already closed, so the teammate can't nest + * under it; instead it gets a fresh root `invoke_agent` turn (stamped with the + * integration identity, which it won't inherit cross-session) with the + * teammate's chat spans as children. Returns the teammate's model, if known. + */ + private emitTeammateTurnTrace( + subAgent: weave.SubAgent, + conversationId: string, + integrationAttrs: Attributes, + agentType: string, + transcriptPath: string | undefined, + ): string | undefined { let model: string | undefined; let lastAssistantText: string | undefined; let t: TranscriptFile | undefined; @@ -1747,41 +1344,58 @@ export class GlobalDaemon { if (!transcriptPath) throw new Error('no teammate transcript path'); t = new TranscriptFile(transcriptPath); const parsed = parseSessionFd(t.getFd()); - if (parsed && this.tracer) { - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls(this.tracer, invokeAgentSpan, conversationId, turn.assistantCalls()); + if (parsed) { + // No ambient conversation cross-session, so stamp the conversation id + // and integration identity onto the teammate's own turn root explicitly. + const turn = weave.startTurn({ agentName: agentType, startTime: new Date() }); + turn.setAttributes({ + ...integrationAttrs, + [ATTR.AGENT_VERSION]: VERSION, + [ATTR.CONVERSATION_ID]: conversationId, + }); + for (const parsedTurn of parsed.turns) { + this.emitChatSpansUnderTurn(turn, conversationId, parsedTurn.assistantCalls(), agentType); } - const lastTurn = parsed.turns[parsed.turns.length - 1]; + turn.end(); + const lastTurn = parsed.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); } } catch (err) { - this.log('DEBUG', `emitTeammateTranscript: could not parse ${transcriptPath}: ${err}`); + this.log('DEBUG', `emitTeammateTurnTrace: could not parse ${transcriptPath}: ${err}`); } finally { t?.close(); } - if (model) invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); + if (model) subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); if (lastAssistantText) { - invokeAgentSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - JSON.stringify([{ role: 'assistant', content: lastAssistantText }]), - ); + subAgent.setAttributes({ + [ATTR.OUTPUT_MESSAGES]: jsonStr([{ role: 'assistant', content: lastAssistantText }]), + }); } - invokeAgentSpan.end(); + subAgent.end(); + return model; } - private async handlePreCompact(sessionId: string, payload: HookPayload): Promise { + private async handlePreCompact(sessionId: string, input: PreCompactHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; + // The SDK's PreCompactHookInput exposes trigger/custom_instructions; the + // Weave Agents backend wants a compaction summary + item counts, which live + // CC payloads carry but the SDK type doesn't declare, so read them off the + // raw record. + const raw = input as Record; + const summary = raw['summary'] ?? raw['compaction_summary']; + const itemsBefore = raw['items_before']; + const itemsAfter = raw['items_after']; const attrs: CompactionAttrs = { - summary: (payload['summary'] as string | undefined) ?? (payload['compaction_summary'] as string | undefined), - itemsBefore: typeof payload['items_before'] === 'number' ? (payload['items_before'] as number) : undefined, - itemsAfter: typeof payload['items_after'] === 'number' ? (payload['items_after'] as number) : undefined, + summary: typeof summary === 'string' ? summary : undefined, + itemsBefore: typeof itemsBefore === 'number' ? itemsBefore : undefined, + itemsAfter: typeof itemsAfter === 'number' ? itemsAfter : undefined, }; - if (session.currentTurnSpan) { - setCompactionAttrs(session.currentTurnSpan, attrs); + if (session.currentTurn) { + setCompactionAttrs(session.currentTurn, attrs); this.log('INFO', `PreCompact attached to active turn ${session.turnNumber} (session ${sessionId})`); } else { // Buffer until the next UserPromptSubmit opens a turn span. @@ -1790,35 +1404,34 @@ export class GlobalDaemon { } } - private async handleStop(sessionId: string, payload: HookPayload): Promise { + private async handleStop(sessionId: string, input: StopHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session?.currentTurnSpan || !this.tracer) return; + if (!session?.currentTurn || !this.tracingEnabled) return; // Pass last_assistant_message so the retry waits for the synthesis to - // flush — otherwise the final chat span drops when the read races the writer. - const rawFinalMessage = payload['last_assistant_message']; - const finalAssistantMessage = typeof rawFinalMessage === 'string' ? rawFinalMessage : undefined; + // flush; otherwise the final chat span drops when the read races the writer. + const finalAssistantMessage = input.last_assistant_message; const parsedSession = await this.parseSessionFileWithRetry( session.transcript, finalAssistantMessage, ); - const currentTurn = parsedSession?.turns[parsedSession.turns.length - 1]; + const currentTurn = parsedSession?.turns.at(-1); const model = currentTurn?.primaryModel(); const transcriptTurns = parsedSession?.turns.length ?? 0; this.log( 'DEBUG', - `Stop: session=${sessionId} trace_id=${session.currentTurnSpan.spanContext().traceId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(payload['last_assistant_message'])}`, + `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, ); // Finalize the chat-span state machine for this turn. - // - The active chat span (open during PreToolUse) gets its trailing - // text/thinking children plus its usage attrs, then ends. + // - The active chat span (open during PreToolUse) gets its output parts + // plus its usage attrs, then ends. // - Assistant calls that never triggered a PreToolUse (final text-only // message, or any other tool-less call) get a fresh chat span emitted - // here with their full content as children. + // here with their full content as output parts. if (currentTurn) { const calls = currentTurn.assistantCalls(); - if (session.activeChatSpan) { + if (session.activeChat) { this.finalizeActiveChatSpan(session, calls); } // Emit a chat span for every response that never opened one during @@ -1831,40 +1444,35 @@ export class GlobalDaemon { } const parsedTexts = currentTurn?.textBlocks() ?? []; - const lastMessage = (payload['last_assistant_message'] as string | undefined) ?? ''; + const lastMessage = input.last_assistant_message ?? ''; const assistantMessages = parsedTexts.length > 0 ? parsedTexts : (lastMessage ? [lastMessage] : []); + const turnAttrs: Attributes = { [ATTR.WEAVE_TURN_TOOL_COUNT]: session.turnToolCalls }; if (assistantMessages.length) { - session.currentTurnSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - jsonStr(assistantMessages.map((m) => ({ role: 'assistant', content: m }))), - ); + turnAttrs[ATTR.OUTPUT_MESSAGES] = jsonStr(assistantMessages.map((m) => ({ role: 'assistant', content: m }))); } - // Aggregate finish reasons from per-call detail const finishReasons = currentTurn?.assistantCalls().map(c => c.finishReason).filter((r): r is string => !!r); if (finishReasons?.length) { - session.currentTurnSpan.setAttribute(ATTR.RESPONSE_FINISH_REASONS, finishReasons); + turnAttrs[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; } - if (model) { - session.currentTurnSpan.setAttribute(ATTR.REQUEST_MODEL, model); + turnAttrs[ATTR.REQUEST_MODEL] = model; } - - session.currentTurnSpan.setAttribute(ATTR.WEAVE_TURN_TOOL_COUNT, session.turnToolCalls); - session.currentTurnSpan.end(); - session.currentTurnSpan = undefined; + session.currentTurn.setAttributes(turnAttrs); + session.currentTurn.end(); + session.currentTurn = undefined; this.log('INFO', `Finished turn ${session.turnNumber} (${session.turnToolCalls} tools)`); } - private async handleSessionEnd(sessionId: string, payload: HookPayload): Promise { + private async handleSessionEnd(sessionId: string, input: SessionEndHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; this.log( 'DEBUG', - `SessionEnd: session=${sessionId} reason=${(payload['reason'] as string | undefined) ?? 'unknown'} transcript_path=${session.transcript.resolvedPath} turns=${session.turnNumber} total_tools=${session.totalToolCalls} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, + `SessionEnd: session=${sessionId} reason=${input.reason} transcript_path=${session.transcript.resolvedPath} turns=${session.turnNumber} total_tools=${session.totalToolCalls} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, ); this.finalizeSession(session, 'session_ended'); @@ -1894,54 +1502,52 @@ export class GlobalDaemon { // Close any pending tool calls that were never completed for (const [toolUseId, pending] of session.pendingToolCalls) { resolvePermissionIfPending(pending, false); - pending.span.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - pending.span.setStatus({ code: SpanStatusCode.ERROR, message: 'tool did not complete before shutdown' }); - pending.span.end(); + pending.tool.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + pending.tool.end({ error: new Error('tool did not complete before shutdown') }); this.log('DEBUG', `Closed orphaned tool span: ${toolUseId} (${pending.toolName})`); } session.pendingToolCalls.clear(); // Finalize a chat span left open mid-turn (Stop never fired) from the - // now-flushed transcript, like Stop does, so its text + usage aren't lost. + // now-flushed transcript, like Stop does, so its output + usage aren't lost. // Bare orphan close only if the parse fails or the turn span is gone. - if (session.activeChatSpan) { + if (session.activeChat) { let finalized = false; - if (session.currentTurnSpan) { + if (session.currentTurn) { let parsed: ParsedSession | null = null; try { parsed = parseSessionFd(session.transcript.getFd()); } catch { parsed = null; } - const lastTurn = parsed?.turns[parsed.turns.length - 1]; + const lastTurn = parsed?.turns.at(-1); if (lastTurn) { this.finalizeActiveChatSpan(session, lastTurn.assistantCalls()); finalized = true; } } - if (session.activeChatSpan) { - session.activeChatSpan.span.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - session.activeChatSpan.span.end(); - session.activeChatSpan = undefined; + if (session.activeChat) { + session.activeChat.llm.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + session.activeChat.llm.end(); + session.activeChat = undefined; } this.log('DEBUG', finalized ? `Finalized active chat span` : `Closed orphaned chat span`); } // Close the current turn (root) span if still open - if (session.currentTurnSpan) { - session.currentTurnSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - session.currentTurnSpan.end(); - session.currentTurnSpan = undefined; + if (session.currentTurn) { + session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + session.currentTurn.end(); + session.currentTurn = undefined; this.log('DEBUG', `Closed orphaned turn span`); } // Close any subagent invoke_agent spans that didn't receive PostToolUse // or SubagentStop. Without this they'd leak open and never export. for (const tracker of session.subagents.all()) { - if (tracker.invokeAgentSpan && !tracker.ended) { - tracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - tracker.invokeAgentSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'subagent did not complete before shutdown' }); - tracker.invokeAgentSpan.end(); + if (tracker.subAgent && !tracker.ended) { + tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + tracker.subAgent.end({ error: new Error('subagent did not complete before shutdown') }); tracker.ended = true; } this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); @@ -1992,7 +1598,7 @@ export class GlobalDaemon { * timeout so in-flight work isn't cut off mid-flight (see checkInactivity). */ private hasInFlightWork(): boolean { for (const s of this.sessions.values()) { - if (s.currentTurnSpan) return true; + if (s.currentTurn) return true; if (s.pendingToolCalls.size > 0) return true; if (s.subagents.size() > 0) return true; } @@ -2012,9 +1618,9 @@ export class GlobalDaemon { * `shutdown` so it can be exercised in tests without terminating the process. * * Order matters: open sessions are finalized (their root turn spans ended) - * BEFORE `provider.shutdown()` flushes, so those roots make the final export - * batch instead of being dropped — the fix for rootless traces left behind - * when the daemon exits mid-turn. + * BEFORE `weave.flushOTel()` runs, so those roots make the final export batch + * instead of being dropped: the fix for rootless traces left behind when the + * daemon exits mid-turn. */ private async drain(reason: string): Promise { this.log('INFO', `Shutdown: ${reason}`); @@ -2024,7 +1630,7 @@ export class GlobalDaemon { // mid-triage) so they flush as ended spans instead of leaking. for (const [, queue] of this.teamMembers) { for (const m of queue) { - if (!m.emitted) { try { m.invokeAgentSpan.end(); } catch { /* best effort */ } } + if (!m.emitted) { try { m.subAgent.end(); } catch { /* best effort */ } } } } this.teamMembers.clear(); @@ -2033,11 +1639,11 @@ export class GlobalDaemon { for (const session of this.sessions.values()) { this.finalizeSession(session, 'daemon_shutdown'); } - if (this.provider) { + if (this.tracingEnabled) { try { - await this.provider.shutdown(); + await weave.flushOTel(); } catch (err) { - this.log('ERROR', `Error shutting down OTel provider: ${err}`); + this.log('ERROR', `Error flushing Weave SDK: ${err}`); } } for (const session of this.sessions.values()) { diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 348bbea..7cd04cd 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -2,21 +2,15 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -import { - Attributes, - Baggage, - Span, - SpanKind, - Tracer, - Context, - TimeInput, - context as otelContext, - propagation, - trace, -} from '@opentelemetry/api'; -import type { ReadableSpan, Span as SdkSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { extractAssistantTextBlocks } from './parser.js'; -import type { AssistantCallDetail, UsageSummary } from './parser.js'; +// After the Weave SDK migration this module holds constants, formatting +// helpers, and thin span-shaping helpers typed against the `weave` SDK. All +// span construction/lifecycle lives in daemon.ts via +// `weave.startConversation/.startTurn/.startLLM/.startTool/.startSubagent`. + +import type { Attributes } from '@opentelemetry/api'; +import type { MessagePart, Tool, Turn, Usage } from 'weave'; +import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; +import type { UsageSummary } from './parser.js'; // ───────────────────────────────────────────────────────────────────────────── // Attribute keys @@ -24,51 +18,41 @@ import type { AssistantCallDetail, UsageSummary } from './parser.js'; // Canonical `gen_ai.*` keys come from the OTel GenAI semantic conventions // (https://github.com/open-telemetry/semantic-conventions-genai). `weave.*` keys are // Claude-Code-specific extensions with no semconv equivalent. Compaction keys -// (`weave.compaction.*`) match the Weave Agents backend's semconv exactly — +// (`weave.compaction.*`) match the Weave Agents backend's semconv exactly - // the backend extracts them into dedicated span columns. // ───────────────────────────────────────────────────────────────────────────── export const ATTR = { - // GenAI semconv — classification + // GenAI semconv - classification OPERATION_NAME: 'gen_ai.operation.name', - PROVIDER_NAME: 'gen_ai.provider.name', - // GenAI semconv — agent + // GenAI semconv - agent AGENT_NAME: 'gen_ai.agent.name', AGENT_ID: 'gen_ai.agent.id', - AGENT_DESCRIPTION: 'gen_ai.agent.description', AGENT_VERSION: 'gen_ai.agent.version', CONVERSATION_ID: 'gen_ai.conversation.id', - // GenAI semconv — model + // GenAI semconv - model REQUEST_MODEL: 'gen_ai.request.model', RESPONSE_MODEL: 'gen_ai.response.model', RESPONSE_ID: 'gen_ai.response.id', RESPONSE_FINISH_REASONS: 'gen_ai.response.finish_reasons', - // GenAI semconv — usage + // GenAI semconv - usage USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens', USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens', - USAGE_REASONING_TOKENS: 'gen_ai.usage.reasoning_tokens', USAGE_CACHE_READ_INPUT_TOKENS: 'gen_ai.usage.cache_read.input_tokens', USAGE_CACHE_CREATION_INPUT_TOKENS: 'gen_ai.usage.cache_creation.input_tokens', - // GenAI semconv — tool - TOOL_NAME: 'gen_ai.tool.name', - TOOL_CALL_ID: 'gen_ai.tool.call.id', - TOOL_CALL_ARGUMENTS: 'gen_ai.tool.call.arguments', - TOOL_CALL_RESULT: 'gen_ai.tool.call.result', - - // GenAI semconv — messages + // GenAI semconv - messages INPUT_MESSAGES: 'gen_ai.input.messages', OUTPUT_MESSAGES: 'gen_ai.output.messages', - SYSTEM_INSTRUCTIONS: 'gen_ai.system_instructions', OUTPUT_TYPE: 'gen_ai.output.type', - // GenAI semconv — errors + // GenAI semconv - errors ERROR_TYPE: 'error.type', - // Weave extensions — claude_code per-turn metadata + // Weave extensions - claude_code per-turn metadata WEAVE_SESSION_ID: 'weave.claude_code.session.id', WEAVE_CWD: 'weave.claude_code.cwd', WEAVE_SOURCE: 'weave.claude_code.source', @@ -78,14 +62,14 @@ export const ATTR = { WEAVE_ORPHAN_REASON: 'weave.claude_code.orphan_reason', WEAVE_DISPLAY_NAME: 'weave.claude_code.display_name', - // Integration identity — attributes the trace to the emitting integration + // Integration identity - attributes the trace to the emitting integration // (this plugin) so the Weave Agents backend can group/filter by integration // alongside peers (weave-openclaw, the playground's `weave.source`). Distinct // from `gen_ai.agent.name`, which is user-overridable and changes per // subagent. These are non-semconv `weave.*` keys, so the backend routes them - // into its queryable custom-attribute maps. Stamped on the turn (invoke_agent) - // root; `meta.*` keys (built with WEAVE_INTEGRATION_META_PREFIX) carry - // free-form per-session context. + // into its queryable custom-attribute maps. Installed on the session's + // conversation so the SDK copies them onto every span; `meta.*` keys (built + // with WEAVE_INTEGRATION_META_PREFIX) carry free-form per-session context. WEAVE_INTEGRATION_NAME: 'weave.integration.name', WEAVE_INTEGRATION_VERSION: 'weave.integration.version', @@ -95,7 +79,7 @@ export const ATTR = { // tool_use_id without walking the span tree. WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID: 'weave.claude_code.subagent.spawning_tool_call_id', - // Weave Agents backend — compaction (set as span attributes on the turn span; + // Weave Agents backend - compaction (set as span attributes on the turn span; // the backend extracts these into dedicated columns) COMPACTION_SUMMARY: 'weave.compaction.summary', COMPACTION_ITEMS_BEFORE: 'weave.compaction.items_before', @@ -123,83 +107,14 @@ export const DEFAULT_AGENT_NAME = 'claude-code'; * dimension for "which integration produced this trace" in the Weave Agents * backend. */ -export const INTEGRATION_NAME = 'weave-claude-code'; +const INTEGRATION_NAME = 'weave-claude-code'; /** - * Prefix for free-form integration metadata. Each entry of a turn's + * Prefix for free-form integration metadata. Each entry of a session's * `integrationMeta` is stamped as `weave.integration.meta.`, so new * fields (e.g. `claude_code_app_version`) need no new attribute constant. */ -export const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; - -/** Common prefix for all integration-identity attributes. The span processor - * copies baggage entries under this prefix onto each span. */ -export const WEAVE_INTEGRATION_PREFIX = 'weave.integration.'; - -/** - * Build the per-session integration Baggage. `name` is the fixed integration - * id; `version` is the plugin version; `meta` is free-form per-session context - * flattened to `weave.integration.meta.` (falsy values skipped). The - * daemon activates this baggage for each session event so - * `IntegrationBaggageSpanProcessor` stamps it onto every span the event emits. - */ -export function createIntegrationBaggage(args: { - version: string; - meta?: Record; -}): Baggage { - const entries: Record = { - [ATTR.WEAVE_INTEGRATION_NAME]: { value: INTEGRATION_NAME }, - [ATTR.WEAVE_INTEGRATION_VERSION]: { value: args.version }, - }; - if (args.meta) { - for (const [key, value] of Object.entries(args.meta)) { - if (value) entries[`${WEAVE_INTEGRATION_META_PREFIX}${key}`] = { value }; - } - } - return propagation.createBaggage(entries); -} - -/** - * Copies `weave.integration.*` baggage entries off the active context onto each - * span at start. This is how integration identity reaches every span (turn - * root and all children) from a single per-session baggage attribution, instead - * of stamping each builder. Runs at `onStart` because attributes set after a - * span ends are dropped; the copy is a one-time snapshot (baggage is static per - * session). Baggage itself is never exported, only the copied attributes. - */ -export class IntegrationBaggageSpanProcessor implements SpanProcessor { - onStart(span: SdkSpan, parentContext: Context): void { - const baggage = propagation.getBaggage(parentContext); - if (!baggage) return; - for (const [key, entry] of baggage.getAllEntries()) { - if (key.startsWith(WEAVE_INTEGRATION_PREFIX)) { - span.setAttribute(key, entry.value); - } - } - } - onEnd(_span: ReadableSpan): void {} - forceFlush(): Promise { - return Promise.resolve(); - } - shutdown(): Promise { - return Promise.resolve(); - } -} - -// Values for `gen_ai.operation.name`. `invoke_agent`, `chat`, and -// `execute_tool` are well-known values from the OTel GenAI semantic conventions -// (https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/registry/attributes/gen-ai.md#gen-ai-operation-name); -// the spec mandates the well-known value whenever one applies. `assistant_text` -// and `thinking` have no well-known equivalent, so they're spec-permitted custom -// values — the model's natural-language output and its private reasoning, each -// emitted as a `chat` child so they interleave with sibling `execute_tool` spans. -export const OP = { - INVOKE_AGENT: 'invoke_agent', - CHAT: 'chat', - EXECUTE_TOOL: 'execute_tool', - ASSISTANT_TEXT: 'assistant_text', - THINKING: 'thinking', -} as const; +const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -207,7 +122,7 @@ export const OP = { /** * Derive `gen_ai.provider.name` from a model id. Returns undefined when the - * routing layer is ambiguous — better to omit than guess. + * routing layer is ambiguous - better to omit than guess. */ export function providerFromModel(model: string | undefined): string | undefined { if (!model) return undefined; @@ -227,404 +142,92 @@ export function jsonStr(v: unknown): string { } } -/** - * Context carrying `parent` as the active span for child-span creation. - * Builds on `context.active()` (not `ROOT_CONTEXT`) so baggage on the active - * context propagates to children — relevant if we ever wire baggage for - * cross-process trace continuity. - */ -export function ctxWithParent(parent: Span): Context { - return trace.setSpan(otelContext.active(), parent); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Span builders -// ───────────────────────────────────────────────────────────────────────────── - -type TurnSpanArgs = { - /** Current process's Claude Code session id — stamped on the span as a - * debug breadcrumb (`weave.claude_code.session.id`). Per resume, this - * changes; the conversation id does not. */ - sessionId: string; - /** Stitching key for the multi-turn conversation. For resumed sessions, - * this is the root ancestor's session id (so turns from before and after - * resume share `gen_ai.conversation.id`). For fresh sessions, equals - * `sessionId`. */ - conversationId: string; - turnNumber: number; - prompt: string; - cwd: string; - source: string; - pluginVersion: string; - /** Top-level agent name; becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. Defaults to `DEFAULT_AGENT_NAME`; - * the daemon resolves any user override before calling. */ - agentName: string; - requestModel?: string; - displayName?: string; -}; - -/** - * Start a turn span. Each turn is the root of its own trace; the Weave Agents - * backend stitches turns into a conversation via `gen_ai.conversation.id`. - * Session-level metadata (cwd, source, plugin.version) is stamped on every - * turn span so it's queryable without a separate session-level span. - */ -export function startTurnSpan(tracer: Tracer, args: TurnSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentName, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.WEAVE_SESSION_ID]: args.sessionId, - [ATTR.WEAVE_CWD]: args.cwd, - [ATTR.WEAVE_SOURCE]: args.source, - [ATTR.WEAVE_PLUGIN_VERSION]: args.pluginVersion, - [ATTR.WEAVE_TURN_NUMBER]: args.turnNumber, - [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: args.prompt }]), - }; - if (args.requestModel) attrs[ATTR.REQUEST_MODEL] = args.requestModel; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - // `weave.integration.*` is not set here — it rides the active session baggage - // and is stamped on this span (and all children) by - // IntegrationBaggageSpanProcessor at onStart. - // - // No parent span in context — turn spans are roots, one trace per turn. (The - // active context carries integration baggage but no span, so this stays a - // root.) - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ); +/** Parse an ISO timestamp; returns undefined for missing or unparseable input. */ +export function parseTimestamp(ts: string | undefined): Date | undefined { + if (!ts) return undefined; + const d = new Date(ts); + return Number.isFinite(d.getTime()) ? d : undefined; } -type InvokeAgentSpanArgs = { - /** Agent type label — becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. For Claude Code subagents this is the - * `subagent_type` from the spawning `Agent` tool call (e.g. "Explore", - * "general-purpose"). */ - agentType: string; - /** Stitching key inherited from the parent turn span. */ - conversationId: string; - /** Plugin version, stamped as `gen_ai.agent.version` for parity with the - * outer turn span. */ - pluginVersion: string; - /** Initial input passed to the agent — typically the firing prompt from - * the parent agent's `Agent` tool call. Stamped as - * `gen_ai.input.messages`. */ - inputMessages?: unknown; - /** tool_use_id of the parent's `Agent` tool call. Stamped as a - * back-pointer attribute so queries can correlate the subagent - * invocation with the spawning tool call. */ - spawningToolCallId?: string; - displayName?: string; -}; - /** - * Start a nested `invoke_agent` span — used for subagents Claude Code - * dispatches via the `Agent` tool. Child of the parent turn (or, for nested - * subagent calls, of the spawning subagent's invoke_agent span). Subagent - * `chat` spans and any tool calls the subagent runs parent under this span, - * which the Weave Agents chat view renders as an `agent_start` lifecycle - * marker followed by the subagent's own assistant text. + * Build the per-session integration attributes. `version` is the plugin + * version; `meta` is free-form per-session context flattened to + * `weave.integration.meta.` (falsy values skipped). Installed on the + * session's conversation so the SDK stamps them onto every span the session + * emits (turn root and all children). */ -export function startInvokeAgentSpan( - tracer: Tracer, - parentSpan: Span, - args: InvokeAgentSpanArgs, -): Span { +export function buildIntegrationAttrs(args: { + version: string; + meta?: Record; +}): Attributes { const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentType, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, + [ATTR.WEAVE_INTEGRATION_NAME]: INTEGRATION_NAME, + [ATTR.WEAVE_INTEGRATION_VERSION]: args.version, }; - if (args.inputMessages !== undefined) { - attrs[ATTR.INPUT_MESSAGES] = jsonStr(args.inputMessages); - } - if (args.spawningToolCallId) { - attrs[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID] = args.spawningToolCallId; + if (args.meta) { + for (const [key, value] of Object.entries(args.meta)) { + if (value) attrs[`${WEAVE_INTEGRATION_META_PREFIX}${key}`] = value; + } } - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentType}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); + return attrs; } -type ToolSpanArgs = { - toolName: string; - toolUseId: string; - toolInput: Record; - displayName?: string; -}; - -export function startToolSpan(tracer: Tracer, parentSpan: Span, args: ToolSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.EXECUTE_TOOL, - [ATTR.TOOL_NAME]: args.toolName, - [ATTR.TOOL_CALL_ID]: args.toolUseId, - [ATTR.TOOL_CALL_ARGUMENTS]: jsonStr(args.toolInput), - }; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.EXECUTE_TOOL} ${args.toolName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ChatSpanArgs = { - /** Stitching key — same value as the parent turn span's - * `gen_ai.conversation.id`. For subagent chats this is suffixed with - * `:${agent_id}` upstream so the subagent's calls form their own - * conversation under the spawning tool span. */ - conversationId: string; - model: string; - startedAt: TimeInput; - endedAt: TimeInput; - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; -}; - /** - * Emit a chat span as a child of `parentSpan`. The span is started AND ended - * inside this helper. Used by code paths that construct the chat span from - * transcript data after the fact (SubagentStop, TeammateIdle). For the main - * agent path — where the chat span parents the assistant_text / thinking / - * execute_tool spans that occur during the API call — use `startChatSpan` / - * `finalizeChatSpan` instead. + * Map Claude assistant content blocks to ordered `MessagePart`s for a chat + * span's `gen_ai.output.messages`. Preserves transcript order so the model's + * natural interleave (text -> tool_use -> text) is visible in the Weave UI. + * text -> text part; thinking / redacted_thinking -> reasoning part; tool_use + * -> tool_call part. Empty text/thinking blocks are skipped. */ -export function emitChatSpan( - tracer: Tracer, - parentSpan: Span, - args: ChatSpanArgs, -): void { - const span = startChatSpan(tracer, parentSpan, { - conversationId: args.conversationId, - model: args.model, - startedAt: args.startedAt, - }); - finalizeChatSpan(span, { - usage: args.usage, - reasoningTokens: args.reasoningTokens, - responseId: args.responseId, - finishReasons: args.finishReasons, - inputMessages: args.inputMessages, - outputMessages: args.outputMessages, - endedAt: args.endedAt, - }); +export function contentBlocksToParts(blocks: unknown[]): MessagePart[] { + const parts: MessagePart[] = []; + for (const block of blocks) { + if (isTextBlock(block)) { + if (block.text.trim()) parts.push({ type: 'text', content: block.text }); + } else if (isThinkingBlock(block)) { + if (block.thinking.trim()) parts.push({ type: 'reasoning', content: block.thinking }); + } else if (isRedactedThinkingBlock(block)) { + // Reasoning withheld by safety filtering: the `data` blob is encrypted, + // so surface a placeholder so the part stays in transcript order. + parts.push({ type: 'reasoning', content: '[redacted]' }); + } else if (isToolUseBlock(block)) { + parts.push({ + type: 'tool_call', + toolCallId: block.id, + toolName: block.name, + arguments: jsonStr(block.input), + }); + } + } + return parts; } -type StartChatSpanArgs = { - conversationId: string; - model?: string; - startedAt: TimeInput; -}; - /** - * Start a chat span (open). Caller is responsible for emitting any child - * spans and calling `finalizeChatSpan` with the usage data and end time. - * - * `model` is optional at open time — Anthropic returns it in the response, so - * it may not be known until the assistant message is parsed. When omitted, - * the span name uses a placeholder; `finalizeChatSpan` overwrites the name - * with the actual model once it's known. + * Build a `weave.Usage` from Anthropic's per-call usage. OTel `inputTokens` is + * the total prompt; Anthropic splits it into three disjoint fields (uncached + + * cache_read + cache_creation), so sum them. + * https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md + * Cache and reasoning fields are set only when present so a call without them + * doesn't emit zero-valued attributes. */ -export function startChatSpan( - tracer: Tracer, - parentSpan: Span, - args: StartChatSpanArgs, -): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.CHAT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_TYPE]: 'text', +export function buildUsage(usage: UsageSummary, reasoningTokens?: number): Usage { + const out: Usage = { + inputTokens: + usage.input_tokens + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0), + outputTokens: usage.output_tokens, }; - if (args.model) { - attrs[ATTR.REQUEST_MODEL] = args.model; - const provider = providerFromModel(args.model); - if (provider) attrs[ATTR.PROVIDER_NAME] = provider; - } - const name = args.model ? `${OP.CHAT} ${args.model}` : OP.CHAT; - return tracer.startSpan( - name, - { kind: SpanKind.CLIENT, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); -} - -type FinalizeChatSpanArgs = { - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; - /** If set and the span was opened without a model, attaches the model - * attribute and updates the span name. */ - model?: string; - endedAt?: TimeInput; -}; - -/** Stamp usage / response attrs on an open chat span and end it. */ -export function finalizeChatSpan(span: Span, args: FinalizeChatSpanArgs): void { - // OTel `input_tokens` is the total prompt; Anthropic splits it into three - // disjoint fields (uncached + cache_read + cache_creation), so sum them. - // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md - const totalInputTokens = - args.usage.input_tokens - + (args.usage.cache_read_input_tokens ?? 0) - + (args.usage.cache_creation_input_tokens ?? 0); - - span.setAttribute(ATTR.USAGE_INPUT_TOKENS, totalInputTokens); - span.setAttribute(ATTR.USAGE_OUTPUT_TOKENS, args.usage.output_tokens); - if (args.usage.cache_read_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_READ_INPUT_TOKENS, args.usage.cache_read_input_tokens); - } - if (args.usage.cache_creation_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS, args.usage.cache_creation_input_tokens); - } - if (args.reasoningTokens !== undefined && args.reasoningTokens > 0) { - span.setAttribute(ATTR.USAGE_REASONING_TOKENS, args.reasoningTokens); - } - if (args.responseId) { - span.setAttribute(ATTR.RESPONSE_ID, args.responseId); - } - if (args.finishReasons?.length) { - span.setAttribute(ATTR.RESPONSE_FINISH_REASONS, args.finishReasons); + if (usage.cache_read_input_tokens !== undefined) { + out.cacheReadInputTokens = usage.cache_read_input_tokens; } - if (args.inputMessages !== undefined) { - span.setAttribute(ATTR.INPUT_MESSAGES, jsonStr(args.inputMessages)); + if (usage.cache_creation_input_tokens !== undefined) { + out.cacheCreationInputTokens = usage.cache_creation_input_tokens; } - if (args.outputMessages !== undefined) { - span.setAttribute(ATTR.OUTPUT_MESSAGES, jsonStr(args.outputMessages)); + if (reasoningTokens !== undefined && reasoningTokens > 0) { + out.reasoningTokens = reasoningTokens; } - if (args.model) { - span.setAttribute(ATTR.REQUEST_MODEL, args.model); - const provider = providerFromModel(args.model); - if (provider) span.setAttribute(ATTR.PROVIDER_NAME, provider); - span.updateName(`${OP.CHAT} ${args.model}`); - } - span.end(args.endedAt); -} - -type AssistantTextSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one text content block from an assistant message. - * Renders in the trace tree between sibling `execute_tool` spans so the - * model's natural interleave (say something → call tool → say something → - * call tool) is visible. Carries the text on `gen_ai.output.messages` so - * Weave's UI shows the content; no token attributes — tokens live on the - * parent chat span. - */ -export function emitAssistantTextSpan( - tracer: Tracer, - parentSpan: Span, - args: AssistantTextSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.ASSISTANT_TEXT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'text', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.ASSISTANT_TEXT, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -type ThinkingSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one thinking content block. Like - * `emitAssistantTextSpan` but for `{type: 'thinking'}` blocks — Claude's - * private reasoning surfaced in its content stream. Kept distinct so callers - * can hide thinking spans in the UI without hiding ordinary assistant text. - */ -export function emitThinkingSpan( - tracer: Tracer, - parentSpan: Span, - args: ThinkingSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.THINKING, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'thinking', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.THINKING, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -/** - * Walk a parsed list of per-message details and emit one chat span per - * assistant message. `parentSpan` is the turn-level span (for the main agent) - * or the spawning Agent tool span (for a subagent). - */ -export function emitChatSpansFromAssistantCalls( - tracer: Tracer, - parentSpan: Span, - conversationId: string, - calls: AssistantCallDetail[], -): void { - for (const c of calls) { - if (!c.model) continue; - const startedAt = parseTimestamp(c.prevTimestamp) ?? parseTimestamp(c.timestamp) ?? new Date(); - const endedAt = parseTimestamp(c.timestamp) ?? new Date(); - emitChatSpan(tracer, parentSpan, { - conversationId, - model: c.model, - startedAt, - endedAt, - usage: c.usage, - reasoningTokens: c.reasoningTokens, - responseId: c.responseId, - finishReasons: c.finishReason ? [c.finishReason] : undefined, - outputMessages: c.contentBlocks.length - ? [{ role: 'assistant', content: assistantBlocksToText(c.contentBlocks), parts: c.contentBlocks }] - : undefined, - }); - } -} - -/** Parse an ISO timestamp; returns undefined for missing or unparseable input. */ -export function parseTimestamp(ts: string | undefined): Date | undefined { - if (!ts) return undefined; - const d = new Date(ts); - return Number.isFinite(d.getTime()) ? d : undefined; -} - -function assistantBlocksToText(blocks: unknown[]): string { - return extractAssistantTextBlocks(blocks).join('\n'); + return out; } // ───────────────────────────────────────────────────────────────────────────── @@ -637,12 +240,12 @@ export interface PermissionRequestEventArgs { } /** Added at PermissionRequest time. Records that the request happened. */ -export function addPermissionRequestEvent(toolSpan: Span, args: PermissionRequestEventArgs): void { +export function addPermissionRequestEvent(tool: Tool, args: PermissionRequestEventArgs): void { const attrs: Attributes = {}; if (args.suggestions !== undefined) { attrs[ATTR.EVT_PERMISSION_SUGGESTIONS] = jsonStr(args.suggestions); } - toolSpan.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); + tool.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); } export interface PermissionResolvedEventArgs { @@ -651,8 +254,8 @@ export interface PermissionResolvedEventArgs { } /** Added at PostToolUse[Failure]. Records the request outcome. */ -export function addPermissionResolvedEvent(toolSpan: Span, args: PermissionResolvedEventArgs): void { - toolSpan.addEvent( +export function addPermissionResolvedEvent(tool: Tool, args: PermissionResolvedEventArgs): void { + tool.addEvent( ATTR.EVT_PERMISSION_RESOLVED, { [ATTR.EVT_PERMISSION_APPROVED]: args.approved }, args.timestamp, @@ -672,13 +275,15 @@ export interface CompactionAttrs { * "context_compacted" card in the chat view. * * Compaction is a session-level event, but with no session span it attaches - * to the turn span that's open when the compaction fires — or to the next + * to the turn span that's open when the compaction fires - or to the next * turn span, if compaction fires between turns. */ -export function setCompactionAttrs(turnSpan: Span, attrs: CompactionAttrs): void { - if (attrs.summary !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_SUMMARY, attrs.summary); - if (attrs.itemsBefore !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_ITEMS_BEFORE, attrs.itemsBefore); - if (attrs.itemsAfter !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_ITEMS_AFTER, attrs.itemsAfter); +export function setCompactionAttrs(turn: Turn, attrs: CompactionAttrs): void { + const out: Attributes = {}; + if (attrs.summary !== undefined) out[ATTR.COMPACTION_SUMMARY] = attrs.summary; + if (attrs.itemsBefore !== undefined) out[ATTR.COMPACTION_ITEMS_BEFORE] = attrs.itemsBefore; + if (attrs.itemsAfter !== undefined) out[ATTR.COMPACTION_ITEMS_AFTER] = attrs.itemsAfter; + if (Object.keys(out).length) turn.setAttributes(out); } // ───────────────────────────────────────────────────────────────────────────── @@ -709,7 +314,7 @@ export function toolDisplayName(toolName: string, input: Record case 'WebSearch': return `WebSearch: ${snippet(input['query'])}`; default: { - const first = Object.values(input).find((v) => typeof v === 'string') as string | undefined; + const first = Object.values(input).find((v): v is string => typeof v === 'string'); return first ? `${toolName}: ${snippet(first)}` : toolName; } } diff --git a/src/parser.ts b/src/parser.ts index 0b06ae7..b27e49a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -38,7 +38,7 @@ export interface ParsedSession { turns: Turn[]; } -export function rawToUsageSummary(raw: Record): UsageSummary { +function rawToUsageSummary(raw: Record): UsageSummary { return { input_tokens: raw['input_tokens'] ?? 0, output_tokens: raw['output_tokens'] ?? 0, @@ -47,7 +47,7 @@ export function rawToUsageSummary(raw: Record): UsageSummary { }; } -export function addUsage(a: UsageSummary, b: UsageSummary): UsageSummary { +function addUsage(a: UsageSummary, b: UsageSummary): UsageSummary { return { input_tokens: a.input_tokens + b.input_tokens, output_tokens: a.output_tokens + b.output_tokens, @@ -107,20 +107,19 @@ function buildSession(lines: unknown[]): ParsedSession { let prevTimestamp: string | undefined; for (const line of lines) { - const entry = line as Record; - const message = entry['message'] as Record | undefined; - const type = entry['type'] as string | undefined; - const role = (message?.['role'] as string | undefined) ?? type; - const timestamp = entry['timestamp'] as string | undefined; + const { message, type, role: rawRole, timestamp } = readTranscriptLine(line); + const role = rawRole ?? type; if (role === 'assistant') { - currentAssistantLines.push({ line: entry, prevTimestamp }); + // `role === 'assistant'` implies the line is an object (it carried either + // a `message.role` or a top-level `type`), so the {} fallback is unreachable. + currentAssistantLines.push({ line: isObject(line) ? line : {}, prevTimestamp }); } else if (role === 'user') { const rawContent = message?.['content']; - const content = Array.isArray(rawContent) ? rawContent as Array> : []; // A user message with text content marks the end of the previous turn. - const hasText = typeof rawContent === 'string' || content.some(block => block['type'] === 'text'); + const hasText = typeof rawContent === 'string' + || (Array.isArray(rawContent) ? rawContent : []).some(isTextBlock); if (hasText && currentAssistantLines.length > 0) { turns.push(buildTurn(currentAssistantLines)); currentAssistantLines = []; @@ -139,16 +138,14 @@ function buildSession(lines: unknown[]): ParsedSession { function buildTurn(assistantLines: AssistantLine[]): Turn { const calls: AssistantCallDetail[] = assistantLines.map(({ line, prevTimestamp }) => { - const message = line['message'] as Record | undefined; + const { message } = readTranscriptLine(line); const rawUsage = (message?.['usage'] ?? line['usage'] ?? {}) as Record; const usage = rawToUsageSummary(rawUsage); const reasoningTokens = typeof rawUsage['reasoning_tokens'] === 'number' ? rawUsage['reasoning_tokens'] : undefined; const model = (message?.['model'] ?? line['model']) as string | undefined; const rawContent = message?.['content']; - // `content` is either an array of blocks (the common assistant shape), a - // bare string (legacy single-text format), or missing. Fall back to [] for - // the missing / unknown case so downstream code sees a well-typed empty - // list instead of `undefined`. + // A bare-string `content` is the legacy single-text format; synthesize a + // text block so downstream sees a uniform block list (missing/other → []). const contentBlocks: unknown[] = Array.isArray(rawContent) ? (rawContent as unknown[]) : typeof rawContent === 'string' @@ -175,7 +172,7 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { { input_tokens: 0, output_tokens: 0 }, ); - const model = calls.map(call => call.model).filter(Boolean).pop(); + const model = calls.filter(call => call.model).at(-1)?.model; const texts = calls.flatMap(call => extractAssistantTextBlocks(call.contentBlocks)); @@ -194,11 +191,37 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { type TextBlock = { type: 'text'; text: string }; type ThinkingBlock = { type: 'thinking'; thinking: string }; type RedactedThinkingBlock = { type: 'redacted_thinking'; data?: string }; +type ToolUseBlock = { type: 'tool_use'; id: string; name: string; input?: unknown }; function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null; } +/** Structural shape of a single JSONL transcript line we care about. */ +type TranscriptLine = { + message?: Record; + type?: string; + role?: string; + timestamp?: string; +}; + +/** + * Decode one raw JSONL transcript line into the fields the parser reads, + * narrowing each with a runtime check instead of an `as` cast. `role` is the + * raw `message.role` (callers fall back to `type` for lines that carry only a + * top-level `type`). Fields absent or of the wrong type come back undefined. + */ +function readTranscriptLine(line: unknown): TranscriptLine { + if (!isObject(line)) return {}; + const message = isObject(line['message']) ? line['message'] : undefined; + return { + message, + type: typeof line['type'] === 'string' ? line['type'] : undefined, + role: typeof message?.['role'] === 'string' ? message['role'] : undefined, + timestamp: typeof line['timestamp'] === 'string' ? line['timestamp'] : undefined, + }; +} + export function isTextBlock(block: unknown): block is TextBlock { return isObject(block) && block['type'] === 'text' && typeof block['text'] === 'string'; } @@ -211,6 +234,11 @@ export function isRedactedThinkingBlock(block: unknown): block is RedactedThinki return isObject(block) && block['type'] === 'redacted_thinking'; } +export function isToolUseBlock(block: unknown): block is ToolUseBlock { + return isObject(block) && block['type'] === 'tool_use' + && typeof block['id'] === 'string' && typeof block['name'] === 'string'; +} + /** * Pull human-readable text out of assistant `content` blocks. Accepts the raw * union (string entries, `{type: 'text', text}` objects, etc.) and returns diff --git a/src/sessionState.ts b/src/sessionState.ts new file mode 100644 index 0000000..15b9ba2 --- /dev/null +++ b/src/sessionState.ts @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import * as path from 'path'; +import { createHash } from 'crypto'; +import type { Attributes } from '@opentelemetry/api'; +import * as weave from 'weave'; +import { VERSION } from './setup.js'; +import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; +import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; +import { buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; +import type { CompactionAttrs } from './genaiSpans.js'; + +/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ +export type PendingToolCall = { + tool: weave.Tool; + toolName: string; + toolInput: Record; + /** True once a PermissionRequest event has been emitted for this tool. */ + permissionRequested?: boolean; +} + +/** Tracks the chat span (LLM) currently open for a single assistant API + * response. Tool spans the model called parent here so the trace tree shows + * them nested under the response. The response's text/thinking blocks become + * ordered `gen_ai.output.messages` parts on this span, set when it is + * finalized (at the next response transition or at Stop), once all its split + * transcript lines are present. */ +export type ActiveChat = { + /** Response key (Anthropic `message.id`, or index fallback) this chat span + * represents; see `chatMessageKey`. */ + responseKey: string; + llm: weave.LLM; +} + +/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ +export function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { + if (!pending.permissionRequested) return; + addPermissionResolvedEvent(pending.tool, { + approved, + timestamp: new Date(), + }); +} + +/** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with + * the subagent's SubagentStart by matching transcript content. */ +export function hashPrompt(prompt: string): string { + return createHash('sha256').update(prompt, 'utf8').digest('hex'); +} + +/** + * Map a parent transcript path + subagent agent_id to the subagent's transcript + * file. Claude Code writes subagent transcripts as siblings of the parent in a + * `/subagents/` subdirectory: + * parent: /.jsonl + * subagent: //subagents/agent-.jsonl + */ +export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { + const projectDir = path.dirname(parentTranscriptPath); + const sessionDirName = path.basename(parentTranscriptPath, '.jsonl'); + return path.join(projectDir, sessionDirName, 'subagents', `agent-${agentId}.jsonl`); +} + +/** Pull the user-message content out of a transcript line. Returns the prompt + * string for `{type: 'user', message: {content: string|Array}}` lines, else + * undefined. Array-form content is joined across text blocks. */ +export function extractUserMessageContent(line: Record | undefined): string | undefined { + if (!line || line['type'] !== 'user') return undefined; + const msg = line['message']; + if (!msg || typeof msg !== 'object') return undefined; + const content = (msg as Record)['content']; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + // Keep every text block's text verbatim (including empties) and join with + // '' (this differs from extractAssistantTextBlocks, which drops empties). + const parts = content.filter(isTextBlock).map(block => block.text); + return parts.length > 0 ? parts.join('') : undefined; + } + return undefined; +} + +/** True if the last assistant call's joined text ends with `suffix`, + * ignoring trailing whitespace on either side. */ +export function lastAssistantTextEndsWith( + result: NonNullable>, + suffix: string, +): boolean { + const call = result.turns.at(-1)?.assistantCalls().at(-1); + // Turn exists but parser saw no assistant calls (writer mid-flush). + if (!call) return false; + return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); +} + +/** Read the subagent transcript's first line, retrying briefly because Claude + * Code may not have flushed it yet when SubagentStart fires. Total wait + * bounded by the sum of `RETRY_DELAYS_MS`. */ +const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; +export async function readSubagentFirstLineWithRetry( + transcriptPath: string, +): Promise | undefined> { + for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + const line = readFirstTranscriptLine(transcriptPath); + if (line && line['type'] === 'user') return line; + } + return undefined; +} + +/** + * Tracks a subagent across hook events. Two shapes: + * (a) Matched — created at PreToolUse when an Agent tool with subagent_type + * is detected; carries `toolUseId`, `promptHash`, and a reference to + * the subagent's `invoke_agent` span. `agentId` is filled in at + * SubagentStart via content-based correlation: sha256(firing prompt) + + * subagent_type. + * (b) Orphan — created at SubagentStart when no tracker matches the firing + * prompt (the parent's Agent PreToolUse never fired, or its prompt + * differs from the subagent transcript's line 1). The `invoke_agent` + * span is created at SubagentStart with the current turn span as + * parent and no input messages (the firing prompt is unavailable). + * + * The subagent is its own `invoke_agent ` span, child of the + * parent turn's `invoke_agent claude-code` span. Per the Weave Agents chat + * view (`weave/trace_server/agents/chat_view.py`), nested `invoke_agent` + * spans render as an `agent_start` lifecycle marker with the inner agent's + * own assistant text — distinct from an `execute_tool` tool-call event. + * The Agent tool call does NOT emit an `execute_tool` span; it emits this + * `invoke_agent` span directly. + */ +export type SubagentTracker = { + subagentType: string; + detectedAt: Date; + toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) + subAgent?: weave.SubAgent; // subagent's `invoke_agent` marker span (a leaf) + agentId?: string; + /** sha256 of the prompt passed to the Agent tool; matched against the + * subagent's transcript line-1 user message at SubagentStart. */ + promptHash?: string; + /** True once the invoke_agent span has been ended. Guards against + * double-end when PostToolUse and SubagentStop both try to close it. */ + ended?: boolean; + /** Subagent transcript path — stored at SubagentStart so TeammateIdle can + * read all turns without relying on the payload's transcript_path (which + * CC sets to the coordinator's path, not the subagent's). */ + transcriptPath?: string; + /** Set on orphan trackers when SubagentStop fires before TeammateIdle. + * Suppresses span closure at SubagentStop so TeammateIdle can close it + * with full all-turns content. */ + pendingTeammateIdle?: boolean; + /** Set when this Agent tool spawn carried a `team_name` (agent-teams model). + * The teammate runs in its OWN session, so its TeammateIdle fires under a + * different session_id and the per-session lookup misses. The invoke_agent + * span is registered in GlobalDaemon.teamMembers and closed there (at the + * teammate's TeammateIdle), NOT at the coordinator's PostToolUse(Agent). */ + teamName?: string; +} + +/** Cross-session team correlation. In agent-teams (TeamCreate) a teammate is an + * independent Claude session whose TeammateIdle fires under the teammate's own + * session_id, not the coordinator's — so the per-session SubagentTracking + * lookup misses. The coordinator's PreToolUse(Agent, team_name) is the one + * reliable anchor; we record its invoke_agent span here keyed by + * `${team_name}::${name}`. + * + * Entries are stored as a FIFO queue per key (not a single value) because the + * SAME `${team}::${name}` can be spawned more than once in a run — e.g. the + * TARS triage flow re-spawns a specialist (Sonnet→Opus) for deeper work. Each + * spawn pushes its own TeamMember; each teammate's TeammateIdle consumes the + * oldest not-yet-emitted entry (FIFO), so re-spawns never overwrite a live span + * (which would leak it and mis-attribute the first teammate's transcript). This + * mirrors SubagentTracking.findPendingTeammateIdle for the per-session path. */ +export type TeamMember = { + subAgent: weave.SubAgent; + conversationId: string; + coordinatorTranscriptPath: string; + /** Coordinator's integration identity, re-stamped on the teammate's own + * turn+chat spans (which are created cross-session, outside the + * coordinator's ambient conversation, so they don't inherit it). */ + integrationAttrs: Attributes; + emitted: boolean; +} + +export type SessionState = { + sessionId: string; + /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed + * turns stitch with their pre-resume turns server-side. Equals `sessionId` + * for fresh (non-forked) sessions. Resolved once at SessionStart by + * walking `forkedFrom.sessionId` pointers across transcript files. */ + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel?: string; + /** Integration identity (name, version, meta.*), built once at SessionStart. + * Installed on the session's conversation at SessionStart and re-installed + * for every later event in `routeEvent` (each `runIsolated` frame gets fresh + * ambient state), so the SDK copies it onto every span the session emits. */ + integrationAttrs: Attributes; + + currentTurn?: weave.Turn; + + turnNumber: number; + totalToolCalls: number; + turnToolCalls: number; + toolCounts: Record; + + pendingToolCalls: Map; + subagents: SubagentTracking; + + /** Chat span (LLM) currently open for an in-progress assistant API call. + * Tool spans from PreToolUse parent here; finalized at Stop or on transition + * to the next API call. Cleared at Stop. */ + activeChat?: ActiveChat; + /** Response keys (see `chatMessageKey`) in the current turn for which a chat + * span has been opened (open or already finalized). Stop uses this to + * identify responses that need a chat span emitted from scratch (responses + * with no tool_use blocks never triggered PreToolUse). Reset per turn. */ + emittedChatSpanResponseKeys: Set; + + /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ + pendingCompaction?: CompactionAttrs; + +} + +/** + * Per-session container that tracks subagents from PreToolUse (when an Agent + * tool with subagent_type is detected) through SubagentStop. Single source of + * truth for the tracker list, with intent-revealing lookup methods. + */ +export class SubagentTracking { + private trackers: SubagentTracker[] = []; + + /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ + add(tracker: SubagentTracker): void { + this.trackers.push(tracker); + } + + /** + * Find the unmatched tracker (no agent_id yet) matching `(promptHash, + * subagentType)`. FIFO across ties: the oldest pending tracker wins, so two + * back-to-back identical Agent calls still correlate in dispatch order. + * Returns undefined if no candidate qualifies. + */ + findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { + let best: SubagentTracker | undefined; + for (const t of this.trackers) { + if (t.agentId) continue; + if (t.promptHash !== promptHash) continue; + if (t.subagentType !== subagentType) continue; + if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; + } + return best; + } + + byAgentId(agentId: string): SubagentTracker | undefined { + return this.trackers.find(t => t.agentId === agentId); + } + + /** Find a tracker awaiting TeammateIdle by its subagentType. Used to + * correlate TeammateIdle(teammate_name) with the orphan tracker created + * at SubagentStart. Returns the oldest pending match (FIFO). */ + findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { + let best: SubagentTracker | undefined; + for (const t of this.trackers) { + if (!t.pendingTeammateIdle) continue; + if (t.subagentType !== subagentType) continue; + if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; + } + return best; + } + + /** Lookup by spawning Agent tool's tool_use_id. Used at PostToolUse to find + * the subagent's `invoke_agent` span when the matching toolUseId is not + * in `pendingToolCalls` (because the Agent tool emits an invoke_agent + * span instead of an execute_tool span). */ + byToolUseId(toolUseId: string): SubagentTracker | undefined { + return this.trackers.find(t => t.toolUseId === toolUseId); + } + + remove(tracker: SubagentTracker): void { + const idx = this.trackers.indexOf(tracker); + if (idx >= 0) this.trackers.splice(idx, 1); + } + + size(): number { + return this.trackers.length; + } + + all(): SubagentTracker[] { + return [...this.trackers]; + } +} + +/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 + * for a brand-new session, or the number of turns already on disk when + * reconstructing a session lost across a daemon restart (so the resumed turn + * keeps counting up instead of resetting to 1). */ +export type NewSessionStateOptions = { + sessionId: string; + conversationId: string; + transcript: TranscriptFile; + cwd: string; + source: string; + initialRequestModel: string | undefined; + turnNumber: number; +}; + +/** Build a fresh SessionState. */ +export function newSessionState(options: NewSessionStateOptions): SessionState { + const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = + options; + // Claude Code stamps its CLI version on each transcript line; capture it + // best-effort from the head line for the integration metadata. Absent when + // the writer hasn't flushed yet, the meta key is simply omitted. Built + // here (not at the SessionStart call site) so a session reconstructed after + // a daemon restart carries the same integration identity on its spans. + const headLine = readFirstTranscriptLine(transcript.resolvedPath); + const version = headLine?.['version']; + const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; + const integrationAttrs = buildIntegrationAttrs({ + version: VERSION, + meta: { claude_code_app_version: claudeCodeAppVersion }, + }); + + return { + sessionId, + conversationId, + transcript, + cwd, + source, + initialRequestModel, + integrationAttrs, + turnNumber, + totalToolCalls: 0, + turnToolCalls: 0, + toolCounts: {}, + pendingToolCalls: new Map(), + subagents: new SubagentTracking(), + emittedChatSpanResponseKeys: new Set(), + }; +} diff --git a/src/setup.ts b/src/setup.ts index 969c25d..1a0cf5c 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -5,12 +5,42 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { spawnSync } from 'child_process'; +import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from 'child_process'; import { findClaudeCLI, appendToLog } from './utils.js'; import { VERSION } from './version.mjs'; export { VERSION }; +// Shared spawnSync options for every `claude plugin ...` invocation: capture +// utf8 stdout/stderr through pipes so we can inspect the output for status. +const CLAUDE_SPAWN_OPTS: SpawnSyncOptionsWithStringEncoding = { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], +}; + +/** Concatenate a spawnSync result's stderr and stdout (either may be null). */ +function combinedOutput(result: SpawnSyncReturns): string { + return (result.stderr ?? '') + (result.stdout ?? ''); +} + +/** + * Throw (and log) `Failed to ${action}: ${output}` when a `claude plugin ...` + * command exited non-zero, unless `alreadyOk` (a matched "already + * registered/installed" message) makes the non-zero exit benign. + */ +function failIfError( + result: SpawnSyncReturns, + alreadyOk: boolean, + logFile: string, + action: string, +): void { + if (result.status === 0 || alreadyOk) return; + const output = combinedOutput(result).trim(); + const msg = `Failed to ${action}: ${output}`; + appendToLog(logFile, 'ERROR', msg); + throw new Error(msg); +} + export interface Settings { log_file: string; weave_project: string | null; @@ -177,7 +207,7 @@ export type PluginSource = * unparseable. Caller is responsible for shape validation on the returned * value (typed as `unknown`). */ -function readJsonFile(filePath: string): unknown | null { +function readJsonFile(filePath: string): unknown { if (!fs.existsSync(filePath)) return null; try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -192,17 +222,10 @@ function readJsonFile(filePath: string): unknown | null { * version for directory-source registrations. */ function readPackageVersion(dir: string): string | null { - const pkg = readJsonFile(path.join(dir, 'package.json')) as { version?: unknown } | null; + const pkg = readJsonFile(path.join(dir, 'package.json')) as { version?: unknown }; return typeof pkg?.version === 'string' ? pkg.version : null; } -/** - * Read and normalize the source spec Claude Code has registered for the given - * marketplace, or null if the marketplace isn't registered or the on-disk - * shape is unrecognized. Unknown shapes are treated as null rather than - * throwing so a future Claude Code schema change degrades to "Source: not - * registered" rather than crashing status. - */ /** * Shape of a github-source entry inside known_marketplaces.json. `ref` is * optional because pre-v0.2 marketplace registrations didn't pin to a tag. @@ -220,6 +243,13 @@ function isRawDirectorySource(s: Record): s is RawDirectorySour return s['source'] === 'directory' && typeof s['path'] === 'string'; } +/** + * Read and normalize the source spec Claude Code has registered for the given + * marketplace, or null if the marketplace isn't registered or the on-disk + * shape is unrecognized. Unknown shapes are treated as null rather than + * throwing so a future Claude Code schema change degrades to "Source: not + * registered" rather than crashing status. + */ export function readRegisteredPluginSource(marketplaceName: string): PluginSource | null { const knownPath = path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'); const raw = readJsonFile(knownPath); @@ -303,15 +333,10 @@ export function registerPlugin( const mktResult = spawnSync( claudePath, ['plugin', 'marketplace', 'add', marketplaceArg], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); - const mktAlready = /already/i.test((mktResult.stderr ?? '') + (mktResult.stdout ?? '')); - if (mktResult.status !== 0 && !mktAlready) { - const output = ((mktResult.stderr ?? '') + (mktResult.stdout ?? '')).trim(); - const msg = `Failed to register marketplace '${marketplaceArg}': ${output}`; - appendToLog(logFile, 'ERROR', msg); - throw new Error(msg); - } + const mktAlready = /already/i.test(combinedOutput(mktResult)); + failIfError(mktResult, mktAlready, logFile, `register marketplace '${marketplaceArg}'`); const refAfter = readRegisteredMarketplaceRef(MARKETPLACE_NAME); // Drift detection compares marketplace refs (version tags). Local sources @@ -323,15 +348,10 @@ export function registerPlugin( const pluginResult = spawnSync( claudePath, ['plugin', 'install', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, '--scope', 'user'], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); - const pluginAlready = /already/i.test((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')); - if (pluginResult.status !== 0 && !pluginAlready) { - const output = ((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')).trim(); - const msg = `Failed to install plugin '${PLUGIN_NAME}': ${output}`; - appendToLog(logFile, 'ERROR', msg); - throw new Error(msg); - } + const pluginAlready = /already/i.test(combinedOutput(pluginResult)); + failIfError(pluginResult, pluginAlready, logFile, `install plugin '${PLUGIN_NAME}'`); const { updated: pluginUpdated } = maybeUpdateOutdatedPlugin(claudePath, logFile, refDrifted, pluginAlready); @@ -364,14 +384,9 @@ function maybeUpdateOutdatedPlugin( const updateResult = spawnSync( claudePath, ['plugin', 'update', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, '--scope', 'user'], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); - if (updateResult.status !== 0) { - const output = ((updateResult.stderr ?? '') + (updateResult.stdout ?? '')).trim(); - const msg = `Failed to update plugin '${PLUGIN_NAME}': ${output}`; - appendToLog(logFile, 'ERROR', msg); - throw new Error(msg); - } + failIfError(updateResult, /*alreadyOk*/ false, logFile, `update plugin '${PLUGIN_NAME}'`); return { updated: true }; } @@ -399,15 +414,15 @@ export function unregisterPlugin(): UninstallResult { const pluginResult = spawnSync( claudePath, ['plugin', 'uninstall', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`, '--scope', 'user'], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); const pluginAlreadyAbsent = /not installed|not found|unknown plugin|no installed plugin/i - .test((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')); + .test(combinedOutput(pluginResult)); if (pluginResult.status !== 0) { if (pluginAlreadyAbsent) { pluginStatus = RemovalStatus.AlreadyAbsent; } else { - const output = ((pluginResult.stderr ?? '') + (pluginResult.stdout ?? '')).trim(); + const output = combinedOutput(pluginResult).trim(); pluginStatus = RemovalStatus.Failed; pluginError = `Failed to uninstall plugin '${PLUGIN_NAME}': ${output}`; } @@ -421,15 +436,15 @@ export function unregisterPlugin(): UninstallResult { const mktResult = spawnSync( claudePath, ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME], - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, + CLAUDE_SPAWN_OPTS, ); const marketplaceAlreadyAbsent = /not found|unknown marketplace|no configured marketplace/i - .test((mktResult.stderr ?? '') + (mktResult.stdout ?? '')); + .test(combinedOutput(mktResult)); if (mktResult.status !== 0) { if (marketplaceAlreadyAbsent) { marketplaceStatus = RemovalStatus.AlreadyAbsent; } else { - const output = ((mktResult.stderr ?? '') + (mktResult.stdout ?? '')).trim(); + const output = combinedOutput(mktResult).trim(); marketplaceStatus = RemovalStatus.Failed; marketplaceError = `Failed to remove marketplace '${MARKETPLACE_NAME}': ${output}`; } diff --git a/src/utils.ts b/src/utils.ts index e2fa616..5daa19b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -95,10 +95,12 @@ export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false; if (Array.isArray(a) !== Array.isArray(b)) return false; - const keysA = Object.keys(a as object); - const keysB = Object.keys(b as object); + const ao = a as Record; + const bo = b as Record; + const keysA = Object.keys(ao); + const keysB = Object.keys(bo); if (keysA.length !== keysB.length) return false; - return keysA.every(k => deepEqual((a as Record)[k], (b as Record)[k])); + return keysA.every(k => deepEqual(ao[k], bo[k])); } /** diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts index bd2032f..a479f55 100644 --- a/tests/daemon-shutdown-finalizes-turn.test.ts +++ b/tests/daemon-shutdown-finalizes-turn.test.ts @@ -2,13 +2,13 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// A turn's root span (`invoke_agent claude-code`) is created at -// UserPromptSubmit and only ended at Stop or SessionEnd. When the daemon exits -// for any other reason — inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a -// restart control message — its already-ended children (completed tool spans, -// finalized chat spans, closed subagent spans) have been exported, but the -// still-open root had not. The result was a rootless trace: tool spans with no -// user turn to attribute them to. +// A turn's root span (`invoke_agent`) is created at UserPromptSubmit and only +// ended at Stop or SessionEnd. When the daemon exits for any other reason +// (inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a restart control message), its +// already-ended children (completed tool spans, finalized chat spans, closed +// subagent spans) have been exported, but the still-open root had not. The +// result was a rootless trace: tool spans with no user turn to attribute them +// to. // // The fix finalizes every live session (ending its turn root) inside the // shutdown drain, before the exporter is flushed. These tests drive a turn to a @@ -19,19 +19,8 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { ATTR, OP } from '../src/genaiSpans.ts'; - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; @@ -54,51 +43,40 @@ function makeTranscript(sessionId: string): { file: string; append: (line: unkno } interface Harness { - handleSessionStart(s: string, p: Record): Promise; - handleUserPromptSubmit(s: string, p: Record): Promise; - handlePreToolUse(s: string, a: string | undefined, p: Record): Promise; - handlePostToolUse(s: string, p: Record): Promise; - handleSessionEnd(s: string, p: Record): Promise; + routeEvent(p: Record): Promise; drain(reason: string): Promise; - tracer: unknown; -} - -function makeDaemon(tracer: ReturnType['tracer']): Harness { - const logFile = path.join(os.tmpdir(), `wcp-shutdown-itest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-shutdown.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as Harness; } /** Drive a session to a mid-turn state: turn open, one tool completed. */ async function openTurnWithOneCompletedTool(d: Harness, sid: string, append: (l: unknown) => void, file: string) { append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do the thing' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'reading' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Read', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_1', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); } test('daemon shutdown mid-turn exports the turn root span (children are not left rootless)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-shutdown'; const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Harness; try { await openTurnWithOneCompletedTool(d, sid, append, file); // Neither Stop nor SessionEnd fired: the daemon exits (inactivity / signal // / restart). The drain must finalize the open turn before flushing. await d.drain('inactivity'); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); - const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === OP.EXECUTE_TOOL); + const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); assert.ok(tool, 'the completed tool span exported as a child'); - const root = spans.find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); + const root = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); assert.ok(root, 'the turn root span must be exported on shutdown, not leaked'); assert.equal(root!.attributes[ATTR.AGENT_NAME], 'claude-code'); assert.equal(root!.attributes[ATTR.CONVERSATION_ID], sid); @@ -112,29 +90,32 @@ test('daemon shutdown mid-turn exports the turn root span (children are not left }); test('daemon shutdown ends an open subagent invoke_agent span under the same trace', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-shutdown-subagent'; const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Harness; try { append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'spawn a reviewer' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'spawn a reviewer' }); - // Agent tool with subagent_type opens a nested invoke_agent span that a - // mid-flight shutdown would otherwise leave open. + // Agent tool with subagent_type opens a nested invoke_agent span (SubAgent) + // that a mid-flight shutdown would otherwise leave open. append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'tool_use', id: 'agent_1', name: 'Agent', input: { subagent_type: 'code-reviewer', prompt: 'review' } }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); await d.drain('SIGTERM'); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); - const root = spans.find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); - const sub = spans.find(s => s.name === `${OP.INVOKE_AGENT} code-reviewer`); + const invokeAgents = spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + const root = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const sub = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'code-reviewer'); assert.ok(root, 'turn root exported'); assert.ok(sub, 'open subagent invoke_agent span exported on shutdown'); assert.equal(sub!.spanContext().traceId, root!.spanContext().traceId, 'subagent nests under the same trace as the root'); + assert.equal(spanParentId(sub!), root!.spanContext().spanId, 'subagent parents under the turn root'); assert.equal(sub!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -142,16 +123,17 @@ test('daemon shutdown ends an open subagent invoke_agent span under the same tra }); test('SessionEnd still exports the turn root span after the finalize refactor', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-sessionend'; const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Harness; try { await openTurnWithOneCompletedTool(d, sid, append, file); - await d.handleSessionEnd(sid, { reason: 'clear' }); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); - const root = exporter.getFinishedSpans().find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); + const root = exporter.getFinishedSpans().find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); assert.ok(root, 'SessionEnd exports the turn root'); assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'session_ended'); } finally { diff --git a/tests/genai-span-usage-tokens.test.ts b/tests/genai-span-usage-tokens.test.ts index 2b9c188..6920a2f 100644 --- a/tests/genai-span-usage-tokens.test.ts +++ b/tests/genai-span-usage-tokens.test.ts @@ -5,9 +5,9 @@ // Regression test for the cache-hit-rate bug (Weave UI showed >100%). // // Anthropic's API splits prompt usage into three disjoint fields: -// input_tokens — new (uncached) prompt tokens -// cache_read_input_tokens — tokens served from prompt cache -// cache_creation_input_tokens — tokens written to prompt cache +// input_tokens - new (uncached) prompt tokens +// cache_read_input_tokens - tokens served from prompt cache +// cache_creation_input_tokens - tokens written to prompt cache // // OTel GenAI semconv requires `gen_ai.usage.input_tokens` to be the TOTAL // prompt size (including cache reads and writes). When the plugin forwarded @@ -15,52 +15,67 @@ // `cache_read / input_tokens` produced rates greater than 100% (the cache // portion was larger than the uncached portion). Spec ref: // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md +// +// Driven end-to-end through the daemon so the assertion is on the exported +// `chat` span's attributes (the public contract), not an internal helper. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { emitChatSpan, ATTR } from '../src/genaiSpans.ts'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { InMemorySpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; +interface Driver { + routeEvent(p: Record): Promise; } -test('emitChatSpan: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('parent'); +function aLine(id: string, ts: string, text: string, usage: Record) { + return { + type: 'assistant', + timestamp: ts, + message: { role: 'assistant', id, model: 'claude-opus-4-7', content: [{ type: 'text', text }], usage, stop_reason: 'end_turn' }, + }; +} +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} - const startedAt = new Date('2026-01-01T00:00:00Z'); - const endedAt = new Date('2026-01-01T00:00:01Z'); +/** Drive one turn whose single tool-less assistant response carries `usage`, + * and return the exported `chat` span. */ +async function chatSpanForUsage(exporter: InMemorySpanExporter, sid: string, usage: Record): Promise { + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-usage-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ + JSON.stringify(userText('2026-01-01T00:00:00Z', 'do it')), + JSON.stringify(aLine('msgA', '2026-01-01T00:00:01Z', 'all done', usage)), + ].join('\n') + '\n'); + const d = makeGenaiDaemon() as unknown as Driver; + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + const chat = exporter.getFinishedSpans().find(s => s.name === 'chat'); + assert.ok(chat, 'chat span should be emitted'); + return chat; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} - emitChatSpan(tracer, parent, { - conversationId: 'conv-1', - model: 'claude-opus-4-7', - startedAt, - endedAt, - usage: { - input_tokens: 7600, - output_tokens: 528, - cache_read_input_tokens: 36500, - cache_creation_input_tokens: 4100, - }, +test('chat span: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-1', { + input_tokens: 7600, + output_tokens: 528, + cache_read_input_tokens: 36500, + cache_creation_input_tokens: 4100, }); - parent.end(); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const chatSpan = spans.find(s => s.name === 'chat claude-opus-4-7'); - assert.ok(chatSpan, 'chat span should be emitted'); - // Total prompt = 7600 + 36500 + 4100 = 48200. // Without this fix the value was 7600, making cache_read/input_tokens = 480%. assert.equal( @@ -68,30 +83,16 @@ test('emitChatSpan: input_tokens includes cache_read + cache_creation (OTel semc 48200, 'gen_ai.usage.input_tokens must include cache_read and cache_creation per OTel semconv', ); - // Cache fields are reported separately and unchanged. assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], 36500); assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], 4100); assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 528); }); -test('emitChatSpan: input_tokens unchanged when no cache fields present', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('parent'); - - emitChatSpan(tracer, parent, { - conversationId: 'conv-2', - model: 'claude-haiku-4-5', - startedAt: new Date(), - endedAt: new Date(), - usage: { input_tokens: 1000, output_tokens: 200 }, - }); - - parent.end(); - await provider.forceFlush(); +test('chat span: input_tokens unchanged when no cache fields present', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-2', { input_tokens: 1000, output_tokens: 200 }); - const chatSpan = exporter.getFinishedSpans().find(s => s.name === 'chat claude-haiku-4-5'); - assert.ok(chatSpan); assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], undefined); assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], undefined); diff --git a/tests/helpers.ts b/tests/helpers.ts index 2e36691..9318c8f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -12,8 +12,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { spawn, type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import * as weave from 'weave'; import { MARKETPLACE_NAME } from '../src/setup.ts'; +import { GlobalDaemon } from '../src/daemon.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); @@ -103,6 +106,64 @@ export function writeKnownMarketplace(home: string, source: Record { + if (!genaiExporter) { + genaiExporter = new InMemorySpanExporter(); + await weave.init('e/p', { genai: { spanProcessor: new SimpleSpanProcessor(genaiExporter) } }); + } + return genaiExporter; +} + +/** Construct a GlobalDaemon with tracing marked enabled (the SDK is already + * initialised via `initWeaveInMemory`), skipping the real socket/`start()`. */ +export function makeGenaiDaemon(agentName = 'claude-code'): GlobalDaemon { + const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); + const d = new GlobalDaemon('/tmp/unused.sock', logFile, 'e/p', 'k', 'https://x', false, agentName); + (d as unknown as { tracingEnabled: boolean }).tracingEnabled = true; + return d; +} + +/** Flush any spans buffered in the SDK so the in-memory exporter has them. */ +export function flushWeave(): Promise { + return weave.flushOTel(); +} + +/** Parent span id of an exported span. weave's BasicTracerProvider exposes it + * as the `parentSpanId` string (older node providers used `parentSpanContext`), + * so read whichever is present. */ +export function spanParentId(s: ReadableSpan): string | undefined { + return (s as unknown as { parentSpanId?: string }).parentSpanId ?? s.parentSpanContext?.spanId; +} + +/** Direct children of `parent`, ordered by span start time. */ +export function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { + const parentId = parent.spanContext().spanId; + return spans + .filter(s => spanParentId(s) === parentId) + .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); +} + +function hrToNs(t: [number, number]): number { + return t[0] * 1e9 + t[1]; +} + function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts index f58276e..873daa5 100644 --- a/tests/interleave-handlers.test.ts +++ b/tests/interleave-handlers.test.ts @@ -2,30 +2,27 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Chat-span state machine, driven through the real hook handlers (not by -// calling emitChatSpanForResponse directly): chat span opened at PreToolUse + -// tool parenting, response transitions, the dedup (no double chat span at -// Stop), and SessionEnd finalizing a still-open span. The other interleave -// tests cover only the helpers in isolation. +// Chat-span state machine, driven through the real hook handlers (via +// routeEvent, so the ambient conversation is installed): chat span opened at +// PreToolUse + tool parenting, response transitions, the dedup (no double chat +// span at Stop), and SessionEnd finalizing a still-open span. +// +// Post-SDK-migration: a response's text/thinking blocks are ORDERED +// `gen_ai.output.messages` parts on its single `chat` span (not separate child +// spans). The tools the model called still nest under that chat span as +// `execute_tool` children. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, - type ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { ATTR, OP } from '../src/genaiSpans.ts'; - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - return { tracer: provider.getTracer('test'), exporter, provider }; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; + +interface Driver { + routeEvent(p: Record): Promise; } const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; @@ -62,54 +59,37 @@ function makeTranscript(sessionId: string): { file: string; append: (line: unkno }; } -interface Handlers { - handleSessionStart(s: string, p: Record): Promise; - handleUserPromptSubmit(s: string, p: Record): Promise; - handlePreToolUse(s: string, a: string | undefined, p: Record): Promise; - handlePostToolUse(s: string, p: Record): Promise; - handleStop(s: string, p: Record): Promise; - handleSessionEnd(s: string, p: Record): Promise; - tracer: unknown; -} - -function makeDaemon(tracer: ReturnType['tracer']): Handlers { - const logFile = path.join(os.tmpdir(), `wcp-itest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as Handlers; -} - -function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { - return spans - .filter(s => s.parentSpanContext?.spanId === parent.spanContext().spanId) - .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); -} function chatByResponse(spans: ReadableSpan[], id: string): ReadableSpan[] { - return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === OP.CHAT && s.attributes[ATTR.RESPONSE_ID] === id); + return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.RESPONSE_ID] === id); +} +function partsOf(span: ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; } test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool interleave, usage once, no double-emit', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-A'; const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Driver; try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do the thing' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); // Response msgA: text then tool_use (split lines, shared id), flushed // before the tool's PreToolUse fires. append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_1', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); - // msgB: text-only (no tool_use → no PreToolUse; back-filled at Stop). + // msgB: text-only (no tool_use -> no PreToolUse; back-filled at Stop). append(aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn')); - await d.handleStop(sid, {}); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); const spans = exporter.getFinishedSpans(); @@ -118,45 +98,53 @@ test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool inte assert.equal(chatByResponse(spans, 'msgB').length, 1, 'one chat span for msgB'); const chatA = chatByResponse(spans, 'msgA')[0]; + // msgA text + tool_use are ordered output parts on the chat span. + assert.deepEqual(partsOf(chatA), [ + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'msgA: text then tool_call, in transcript order, as output parts'); + // The tool the model called nests under the chat span as an execute_tool child. const aKids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(aKids, [OP.ASSISTANT_TEXT, OP.EXECUTE_TOOL], 'msgA: text then tool, both parented under the chat span'); + assert.deepEqual(aKids, ['execute_tool'], 'msgA: the execute_tool span nests under the chat span'); // Usage counted once for the split response. assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); const chatB = chatByResponse(spans, 'msgB')[0]; - assert.deepEqual(childrenOf(spans, chatB).map(s => s.attributes[ATTR.OPERATION_NAME]), [OP.ASSISTANT_TEXT]); + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); + assert.equal(childrenOf(spans, chatB).length, 0, 'tool-less msgB has no execute_tool children'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('handlers: a new response transitions and finalizes the previous chat span', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-B'; const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do two things')); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Driver; try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do two things' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do two things' }); append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing A' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_A', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_A', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_A', tool_response: 'ok' }); - // Second response with its own tool_use → PreToolUse(tool_B) must finalize + // Second response with its own tool_use -> PreToolUse(tool_B) must finalize // msgA's chat span (transition) before opening msgB's. append(aLine('msgB', '2026-01-01T00:00:05.000Z', { type: 'text', text: 'editing B' })); append(aLine('msgB', '2026-01-01T00:00:06.000Z', { type: 'tool_use', id: 'tool_B', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_B', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_B', tool_response: 'ok' }); - await d.handleStop(sid, {}); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); const spans = exporter.getFinishedSpans(); assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA finalized exactly once at the transition'); @@ -164,45 +152,44 @@ test('handlers: a new response transitions and finalizes the previous chat span' for (const id of ['msgA', 'msgB']) { const chat = chatByResponse(spans, id)[0]; + const parts = partsOf(chat).map(p => p['type']); + assert.deepEqual(parts, ['text', 'tool_call'], `${id}: text + tool_call output parts`); const kids = childrenOf(spans, chat).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(kids, [OP.ASSISTANT_TEXT, OP.EXECUTE_TOOL], `${id}: text + tool under its chat span`); + assert.deepEqual(kids, ['execute_tool'], `${id}: execute_tool nests under its chat span`); } } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); -test('handlers: SessionEnd finalizes a still-open chat span with its text + usage (not an empty orphan)', async () => { +test('handlers: SessionEnd finalizes a still-open chat span with its output + usage (not an empty orphan)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-C'; const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Driver; try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'do the thing' }); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); - await d.handlePreToolUse(sid, undefined, { tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); - // No Stop — session ends mid-turn with the chat span still open. - await d.handleSessionEnd(sid, { reason: 'clear' }); - await provider.forceFlush(); + // No Stop - session ends mid-turn with the chat span still open. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); const spans = exporter.getFinishedSpans(); const chatA = chatByResponse(spans, 'msgA')[0]; assert.ok(chatA, 'chat span for msgA was finalized at SessionEnd (has a response id)'); - // Finalized, not an empty orphan: usage + text child are present. + // Finalized, not an empty orphan: usage + text output part are present. assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508, 'usage recovered at SessionEnd'); - const kids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.ok(kids.includes(OP.ASSISTANT_TEXT), 'assistant_text child recovered at SessionEnd'); + const types = partsOf(chatA).map(p => p['type']); + assert.ok(types.includes('text'), 'assistant text output part recovered at SessionEnd'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); - -function hrToNs(t: [number, number]): number { - return t[0] * 1e9 + t[1]; -} diff --git a/tests/interleave-split-lines.test.ts b/tests/interleave-split-lines.test.ts index 1165e0d..0ce6e24 100644 --- a/tests/interleave-split-lines.test.ts +++ b/tests/interleave-split-lines.test.ts @@ -9,35 +9,25 @@ // `message.id`, and the parser maps each line to its own AssistantCallDetail. // An earlier version walked `blockIdx` within a single call's contentBlocks, // assuming all blocks lived together; against real (split) transcripts that -// emitted nothing for the text/thinking blocks (they were dropped) and lumped -// any surviving text at the end with emission-time timestamps. +// emitted nothing for the text/thinking blocks (they were dropped). // -// This drives the actual reconstruction (GlobalDaemon.emitChatSpanForResponse, -// reached the same way the Stop handler reaches it) against a realistic -// split-line transcript and asserts: text/thinking are NOT dropped, each lands -// on a span stamped with its transcript timestamp (so it sorts into order -// among the live tool spans), and the duplicated per-line usage is counted -// once per response. +// Post-SDK-migration each assistant response is a single `chat` span whose +// ordered `gen_ai.output.messages` parts carry the response's blocks in +// transcript order. This drives the actual reconstruction through the Stop +// handler and asserts: thinking / redacted_thinking / text / tool_use are NOT +// dropped, appear in order as parts, and the duplicated per-line usage is +// counted once per response. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, - type ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { parseSessionFile } from '../src/parser.ts'; -import { ATTR, OP } from '../src/genaiSpans.ts'; +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); - return { tracer: provider.getTracer('test'), exporter, provider }; +interface Driver { + routeEvent(p: Record): Promise; } /** One assistant transcript line carrying a single content block, mirroring how @@ -62,105 +52,60 @@ function userText(ts: string, text: string) { return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; } -function writeTranscript(lines: unknown[]): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wcp-interleave-')); - const file = path.join(dir, 'session.jsonl'); - fs.writeFileSync(file, lines.map(l => JSON.stringify(l)).join('\n') + '\n'); - return file; +function partsOf(span: import('@opentelemetry/sdk-trace-base').ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; } -function makeDaemon(tracer: ReturnType['tracer']): GlobalDaemon { - const d = new GlobalDaemon('/tmp/unused.sock', '/tmp/unused.log', 'e/p', 'k', 'https://x', false); - // Inject the in-memory tracer (normally created by initTracer at startup). - (d as unknown as { tracer: unknown }).tracer = tracer; - return d; -} - -test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave, none dropped, usage counted once', async () => { +test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave as ordered parts, none dropped, usage once', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); // One turn: // response msgA: thinking, redacted_thinking, text, tool_use (4 split lines, shared id) // response msgB: text-only (no tool_use) - const file = writeTranscript([ + const sid = 'sess-split'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-splitlines-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ userText('2026-01-01T00:00:00.000Z', 'do the thing'), aLine('msgA', '2026-01-01T00:00:01.000Z', { type: 'thinking', thinking: 'let me think' }), aLine('msgA', '2026-01-01T00:00:01.500Z', { type: 'redacted_thinking', data: 'ENCRYPTED' }), aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' }), aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use'), aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn'), - ]); + ].map(l => JSON.stringify(l)).join('\n') + '\n'); - const { tracer, exporter, provider } = setupTracer(); + const d = makeGenaiDaemon() as unknown as Driver; try { - const parsed = parseSessionFile(file); - assert.ok(parsed); - const calls = parsed.turns[parsed.turns.length - 1].assistantCalls(); - // Sanity: the parser really does split one response across lines. - assert.equal(calls.filter(c => c.responseId === 'msgA').length, 4, 'msgA is 4 split lines'); - - const daemon = makeDaemon(tracer); - const turn = tracer.startSpan('invoke_agent claude-code'); - const session = { - conversationId: 'conv-1', - currentTurnSpan: turn, - emittedChatSpanResponseKeys: new Set(), - activeChatSpan: undefined, - }; - - // Reach the real reconstruction the same way the Stop handler does. - const emit = (key: string) => - (daemon as unknown as { emitChatSpanForResponse: (s: unknown, c: unknown, k: string) => void }) - .emitChatSpanForResponse(session, calls, key); - emit('msgA'); - emit('msgB'); - - turn.end(); - await provider.forceFlush(); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); + // No PreToolUse fires here; both responses are back-filled at Stop, which + // is the reconstruction path this test exercises. + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); const spans = exporter.getFinishedSpans(); const chatA = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgA'); assert.ok(chatA, 'chat span for msgA emitted'); - const childrenOf = (parent: ReadableSpan) => - spans - .filter(s => s.parentSpanContext?.spanId === parent.spanContext().spanId) - .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); + // thinking, redacted_thinking (as a [redacted] reasoning part), text, and + // tool_use are NOT dropped and appear in transcript order as parts. + assert.deepEqual(partsOf(chatA), [ + { type: 'reasoning', content: 'let me think' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'thinking, redacted placeholder, text, tool_call: all present, in order'); - const aChildren = childrenOf(chatA); - // thinking, redacted_thinking, and text are NOT dropped, and appear in - // transcript order. redacted_thinking has no readable content, so it - // surfaces as a placeholder thinking span rather than being dropped. - assert.deepEqual( - aChildren.map(s => s.attributes[ATTR.OPERATION_NAME]), - [OP.THINKING, OP.THINKING, OP.ASSISTANT_TEXT], - 'thinking, redacted placeholder, text — all present, in order', - ); - assert.deepEqual( - JSON.parse(aChildren[1].attributes[ATTR.OUTPUT_MESSAGES] as string), - [{ role: 'assistant', parts: [{ type: 'thinking', content: '[redacted]' }] }], - 'redacted_thinking renders a [redacted] placeholder', - ); - // Each child is stamped with its transcript line timestamp (so it sorts - // before the tool_use of the same response, whose live span starts later). - assert.equal(isoOf(aChildren[0].startTime), '2026-01-01T00:00:01.000Z'); - assert.equal(isoOf(aChildren[1].startTime), '2026-01-01T00:00:01.500Z'); - assert.equal(isoOf(aChildren[2].startTime), '2026-01-01T00:00:02.000Z'); - - // Usage counted ONCE for the response (not 3x for the 3 split lines). + // Usage counted ONCE for the response (not 4x for the 4 split lines). assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); - // The tool-less final message still renders, after msgA. + // The tool-less final message still renders as its own chat span. const chatB = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgB'); assert.ok(chatB, 'chat span for tool-less msgB emitted'); - assert.equal(childrenOf(chatB).map(s => s.attributes[ATTR.OPERATION_NAME])[0], OP.ASSISTANT_TEXT); + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); } finally { - fs.rmSync(path.dirname(file), { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); } }); - -function hrToNs(t: [number, number]): number { - return t[0] * 1e9 + t[1]; -} -function isoOf(t: [number, number]): string { - return new Date(t[0] * 1000 + t[1] / 1e6).toISOString(); -} diff --git a/tests/interleaved-assistant-spans.test.ts b/tests/interleaved-assistant-spans.test.ts index fb9cf95..da52450 100644 --- a/tests/interleaved-assistant-spans.test.ts +++ b/tests/interleaved-assistant-spans.test.ts @@ -2,192 +2,57 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Regression test for the "final assistant message contains the interstitial -// Claude text that should appear between tool calls" bug. +// Unit test for `contentBlocksToParts`: the formatting layer that turns a +// Claude assistant message's content blocks into ordered `MessagePart`s for a +// chat span's `gen_ai.output.messages`. // -// The old emitChatSpansFromAssistantCalls path emitted one chat span per -// assistant API call as a sibling of tool spans, with text content joined and -// tool_use position info dropped. With parallel tool calls, the Weave UI -// rendered all tool spans first and a single chat span at the bottom holding -// every interstitial utterance smushed together. -// -// New behavior: each assistant API call gets a chat span that PARENTS the -// tool spans AND per-block assistant_text / thinking spans that occur during -// that call, in transcript order. Token usage stays on the chat span (where -// it accurately represents one API invocation). +// Post-SDK-migration, an assistant response's interleave (text -> tool_use -> +// text) is no longer separate child spans; it is the ordered `parts` array on +// the single `chat` span. This test pins the block -> part mapping and order. +// The end-to-end interleave behavior (parts on the chat span, tools nested +// under it) is covered by interleave-handlers / interleave-split-lines. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, - ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { - startChatSpan, - finalizeChatSpan, - startToolSpan, - emitAssistantTextSpan, - emitThinkingSpan, - OP, - ATTR, -} from '../src/genaiSpans.ts'; - -function setupTracer(): { - tracer: ReturnType; - exporter: InMemorySpanExporter; - provider: BasicTracerProvider; -} { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -function opName(span: ReadableSpan): string { - return span.attributes[ATTR.OPERATION_NAME] as string; -} - -test('chat span parents per-block assistant_text and execute_tool children in transcript order', async () => { - const { tracer, exporter, provider } = setupTracer(); - const turn = tracer.startSpan('invoke_agent claude-code'); - - // Simulated assistant API call: - // text "Now let me add the method" - // tool_use Edit - // text "Now let me add the test" - // tool_use Edit - // text "All done" - const chat = startChatSpan(tracer, turn, { - conversationId: 'conv-1', - model: 'claude-opus-4-7', - startedAt: new Date('2026-01-01T00:00:00Z'), - }); - - emitAssistantTextSpan(tracer, chat, { - conversationId: 'conv-1', - text: 'Now let me add the method', - }); - const t1 = startToolSpan(tracer, chat, { - toolName: 'Edit', - toolUseId: 'toolu_01', - toolInput: { file_path: '/foo.ts' }, - }); - t1.end(); - emitAssistantTextSpan(tracer, chat, { - conversationId: 'conv-1', - text: 'Now let me add the test', - }); - const t2 = startToolSpan(tracer, chat, { - toolName: 'Edit', - toolUseId: 'toolu_02', - toolInput: { file_path: '/foo.test.ts' }, - }); - t2.end(); - emitAssistantTextSpan(tracer, chat, { - conversationId: 'conv-1', - text: 'All done', - }); - - finalizeChatSpan(chat, { - usage: { input_tokens: 1000, output_tokens: 200 }, - endedAt: new Date('2026-01-01T00:00:05Z'), - }); - turn.end(); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const turnSpan = spans.find((s) => s.name === 'invoke_agent claude-code'); - const chatSpan = spans.find((s) => s.name === 'chat claude-opus-4-7'); - assert.ok(turnSpan, 'turn span emitted'); - assert.ok(chatSpan, 'chat span emitted with model in name'); - - // Chat span parents under the turn. - assert.equal(chatSpan.parentSpanContext?.spanId, turnSpan.spanContext().spanId); - - // Every assistant_text + execute_tool span parents under the chat span. - const chatChildren = spans.filter( - (s) => s.parentSpanContext?.spanId === chatSpan.spanContext().spanId, - ); - // Order in `chatChildren` reflects end-time ordering (SimpleSpanProcessor - // exports on span.end). Synchronous zero-duration emits in code order - // produce monotonic timestamps, so the assertion holds. - const childOps = chatChildren.map(opName); - assert.deepEqual( - childOps, - [ - OP.ASSISTANT_TEXT, - OP.EXECUTE_TOOL, - OP.ASSISTANT_TEXT, - OP.EXECUTE_TOOL, - OP.ASSISTANT_TEXT, - ], - 'children appear in interleaved transcript order', - ); - - // Token usage lives on the chat span, not on the per-block spans. - assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); - assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); - for (const child of chatChildren) { - assert.equal(child.attributes[ATTR.USAGE_INPUT_TOKENS], undefined); - assert.equal(child.attributes[ATTR.USAGE_OUTPUT_TOKENS], undefined); - } +import { contentBlocksToParts } from '../src/genaiSpans.ts'; + +test('contentBlocksToParts: interleaved text and tool_use map to ordered parts', () => { + const parts = contentBlocksToParts([ + { type: 'text', text: 'Now let me add the method' }, + { type: 'tool_use', id: 'toolu_01', name: 'Edit', input: { file_path: '/foo.ts' } }, + { type: 'text', text: 'Now let me add the test' }, + { type: 'tool_use', id: 'toolu_02', name: 'Edit', input: { file_path: '/foo.test.ts' } }, + { type: 'text', text: 'All done' }, + ]); - // assistant_text content lands on gen_ai.output.messages as a text part. - assert.deepEqual( - JSON.parse(chatChildren[0].attributes[ATTR.OUTPUT_MESSAGES] as string), - [{ role: 'assistant', parts: [{ type: 'text', content: 'Now let me add the method' }] }], - ); + assert.deepEqual(parts, [ + { type: 'text', content: 'Now let me add the method' }, + { type: 'tool_call', toolCallId: 'toolu_01', toolName: 'Edit', arguments: '{"file_path":"/foo.ts"}' }, + { type: 'text', content: 'Now let me add the test' }, + { type: 'tool_call', toolCallId: 'toolu_02', toolName: 'Edit', arguments: '{"file_path":"/foo.test.ts"}' }, + { type: 'text', content: 'All done' }, + ]); }); -test('emitThinkingSpan: thinking content lands as a thinking part on its own span', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('chat'); - - emitThinkingSpan(tracer, parent, { - conversationId: 'conv-1', - text: 'Let me reason about this...', - }); - - parent.end(); - await provider.forceFlush(); +test('contentBlocksToParts: thinking maps to a reasoning part; redacted_thinking to a placeholder', () => { + const parts = contentBlocksToParts([ + { type: 'thinking', thinking: 'Let me reason about this...' }, + { type: 'redacted_thinking', data: 'ENCRYPTED' }, + { type: 'text', text: 'answer' }, + ]); - const span = exporter - .getFinishedSpans() - .find((s) => s.attributes[ATTR.OPERATION_NAME] === OP.THINKING); - assert.ok(span); - assert.equal(span.name, OP.THINKING); - const messages = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string); - assert.deepEqual(messages, [ - { - role: 'assistant', - parts: [{ type: 'thinking', content: 'Let me reason about this...' }], - }, + assert.deepEqual(parts, [ + { type: 'reasoning', content: 'Let me reason about this...' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'answer' }, ]); }); -test('startChatSpan without model: finalizeChatSpan stamps the model and updates the name', async () => { - const { tracer, exporter, provider } = setupTracer(); - const turn = tracer.startSpan('invoke_agent claude-code'); - - const chat = startChatSpan(tracer, turn, { - conversationId: 'conv-1', - startedAt: new Date(), - }); - finalizeChatSpan(chat, { - usage: { input_tokens: 1, output_tokens: 1 }, - model: 'claude-haiku-4-5', - }); - turn.end(); - await provider.forceFlush(); - - const chatSpan = exporter - .getFinishedSpans() - .find((s) => s.attributes[ATTR.OPERATION_NAME] === OP.CHAT); - assert.ok(chatSpan); - assert.equal(chatSpan.name, 'chat claude-haiku-4-5'); - assert.equal(chatSpan.attributes[ATTR.REQUEST_MODEL], 'claude-haiku-4-5'); - assert.equal(chatSpan.attributes[ATTR.PROVIDER_NAME], 'anthropic'); +test('contentBlocksToParts: empty text and empty thinking are skipped', () => { + const parts = contentBlocksToParts([ + { type: 'text', text: ' ' }, + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'kept' }, + ]); + assert.deepEqual(parts, [{ type: 'text', content: 'kept' }]); }); diff --git a/tests/teammate-idle.test.ts b/tests/teammate-idle.test.ts index 0203390..4a0e5bd 100644 --- a/tests/teammate-idle.test.ts +++ b/tests/teammate-idle.test.ts @@ -25,28 +25,15 @@ import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; import { readFirstTranscriptLine } from '../src/transcriptFile.ts'; import { parseSessionFile } from '../src/parser.ts'; -import { startInvokeAgentSpan, emitChatSpansFromAssistantCalls, ATTR } from '../src/genaiSpans.ts'; +import { ATTR } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; // ── helpers ────────────────────────────────────────────────────────────────── -function setupTracer(): { - tracer: ReturnType; - exporter: InMemorySpanExporter; - provider: BasicTracerProvider; -} { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; +interface Driver { + routeEvent(p: Record): Promise; } /** Write a fake teammate transcript to a temp file and return its path. @@ -142,57 +129,56 @@ test('parseSessionFile: skips agent-setting lines, parses LLM calls from teammat } }); -test('TeammateIdle span tree: invoke_agent span contains chat child', async () => { - const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); - const { tracer, exporter, provider } = setupTracer(); - +test('TeammateIdle span tree: teammate turn carries the teammate chat span, tagged by agent name', async () => { + // Drive the per-session teammate path end-to-end in-process: SubagentStart + // (orphan) creates the SubAgent marker; SubagentStop keeps it open; + // TeammateIdle emits the teammate's chat spans under a fresh teammate turn + // (the SubAgent is a leaf and can't parent them). Each teammate chat span is + // tagged with `gen_ai.agent.name` so the Agents view groups it. + const exporter = await initWeaveInMemory(); + exporter.reset(); + + const home = os.homedir(); + const coordSid = 'coord-span-001'; + const coordDir = fs.mkdtempSync(path.join(home, '.weave-tmspan-')); + const coordPath = path.join(coordDir, `${coordSid}.jsonl`); + fs.writeFileSync(coordPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + // Subagent transcript at the path the daemon derives: + // //subagents/agent-.jsonl + const agentId = 'agent-span-abc'; + const subDir = path.join(coordDir, coordSid, 'subagents'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, `agent-${agentId}.jsonl`), + [USER_LINE, ASSISTANT_LINE].map(l => JSON.stringify(l)).join('\n') + '\n'); + + const d = makeGenaiDaemon() as unknown as Driver; try { - const parsed = parseSessionFile(filePath); - assert.ok(parsed); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: coordSid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordSid, prompt: '/triage' }); + // Orphan SubagentStart (no matching PreToolUse tracker). + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: coordSid, agent_id: agentId, agent_type: 'cks-specialist' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: coordSid, agent_id: agentId }); + await d.routeEvent({ hook_event_name: 'TeammateIdle', session_id: coordSid, teammate_name: 'cks-specialist', team_name: 'triage-span' }); + await flushWeave(); - // Mimic what handleTeammateIdle does: agent type comes from payload['teammate_name'], - // transcript path comes from payload['transcript_path']. - const agentType = 'cks-specialist'; // from payload['teammate_name'] - const parentSpan = tracer.startSpan('turn'); - const invokeSpan = startInvokeAgentSpan(tracer, parentSpan, { - agentType, - conversationId: 'conv-1', - pluginVersion: '0.2.6', - displayName: `Agent: ${agentType}`, - }); + const spans = exporter.getFinishedSpans(); - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls(tracer, invokeSpan, 'conv-1', turn.assistantCalls()); - } - invokeSpan.end(); - parentSpan.end(); + // The teammate's own turn root (fresh trace), tagged with the teammate name. + const teammateTurn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'cks-specialist'); + assert.ok(teammateTurn, 'teammate turn span exists tagged with gen_ai.agent.name'); - await provider.forceFlush(); + // The teammate chat span nests under the teammate turn and is tagged too. + const chatKids = childrenOf(spans, teammateTurn).filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.equal(chatKids.length, 1, 'one chat span under the teammate turn'); + const chatSpan = chatKids[0]; + assert.equal(chatSpan.attributes[ATTR.AGENT_NAME], 'cks-specialist', 'chat span tagged with the teammate name'); - const spans = exporter.getFinishedSpans(); - const invokeAgentSpan = spans.find(s => s.name === 'invoke_agent cks-specialist'); - assert.ok(invokeAgentSpan, 'invoke_agent span should exist'); - assert.equal( - invokeAgentSpan.attributes[ATTR.AGENT_NAME], - 'cks-specialist', - 'gen_ai.agent.name should be set', - ); - - const chatSpan = spans.find(s => s.name === 'chat claude-opus-4-8'); - assert.ok(chatSpan, 'chat span should exist'); - - // Chat span should be a child of the invoke_agent span. - assert.equal( - chatSpan.parentSpanContext?.spanId, - invokeAgentSpan.spanContext().spanId, - 'chat span should be child of invoke_agent span', - ); - - // Token counts should be correct (cache-inclusive total for input). + // Token counts are correct (cache-inclusive total for input). assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1500, 'input_tokens = 1000 + 500 cache_read'); assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); } finally { - fs.rmSync(path.dirname(filePath), { recursive: true }); + fs.rmSync(coordDir, { recursive: true, force: true }); } }); diff --git a/tests/turn-span-agent-name.test.ts b/tests/turn-span-agent-name.test.ts index 2bdcb60..6333df8 100644 --- a/tests/turn-span-agent-name.test.ts +++ b/tests/turn-span-agent-name.test.ts @@ -3,54 +3,51 @@ // SPDX-PackageName: weave-claude-code // The top-level agent name is user-customizable (settings `agent_name` / -// `WEAVE_AGENT_NAME`). The daemon resolves the effective value and passes it -// to startTurnSpan, which must stamp it on BOTH the span name (`invoke_agent -// `, which drives Weave's Agents-view grouping) and the -// `gen_ai.agent.name` attribute. +// `WEAVE_AGENT_NAME`). The daemon resolves the effective value and passes it to +// `weave.startTurn`, which the SDK stamps on the `gen_ai.agent.name` attribute +// that drives Weave's Agents-view grouping. (The SDK always names the span +// `invoke_agent`; the agent name lives in the attribute, not the span name.) import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { startTurnSpan, ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; +interface Driver { + routeEvent(p: Record): Promise; } -function baseArgs(agentName: string) { - return { - sessionId: 'sess-1', - conversationId: 'conv-1', - turnNumber: 1, - prompt: 'hello', - cwd: '/tmp', - source: 'startup', - pluginVersion: '0.0.0-test', - agentName, - }; +function writeTranscript(sessionId: string, text: string): { file: string; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-agentname-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n'); + return { file, dir }; } -test('startTurnSpan: agentName drives the span name and gen_ai.agent.name', async () => { - const { tracer, exporter, provider } = setupTracer(); +test('turn span: agentName drives gen_ai.agent.name', async () => { + const exporter = await initWeaveInMemory(); // A custom name and the default both flow through identically. for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { - startTurnSpan(tracer, baseArgs(name)).end(); - } - await provider.forceFlush(); + exporter.reset(); + const sid = `sess-${name}`; + const { file, dir } = writeTranscript(sid, 'hello'); + const d = makeGenaiDaemon(name) as unknown as Driver; + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/tmp' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'hello' }); + // The turn span only exports on end; SessionEnd finalizes an open turn. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); - for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { - const span = exporter.getFinishedSpans().find(s => s.name === `invoke_agent ${name}`); - assert.ok(span, `span name must embed the agent name "${name}"`); - assert.equal(span.attributes[ATTR.AGENT_NAME], name); + const turnSpans = exporter.getFinishedSpans().filter(s => s.name === 'invoke_agent'); + assert.equal(turnSpans.length, 1, 'exactly one turn span'); + assert.equal(turnSpans[0].attributes[ATTR.AGENT_NAME], name, `gen_ai.agent.name must be "${name}"`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } }); diff --git a/tests/turn-span-integration.test.ts b/tests/turn-span-integration.test.ts index 3c49d22..b717ace 100644 --- a/tests/turn-span-integration.test.ts +++ b/tests/turn-span-integration.test.ts @@ -2,45 +2,28 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Integration identity rides OTel Baggage onto EVERY span, not just the turn -// root. The daemon stashes per-session baggage at SessionStart and activates it -// for each event (in routeEvent); IntegrationBaggageSpanProcessor copies the -// `weave.integration.*` entries onto every span at onStart. So a chat or -// execute_tool span deep in a turn is filterable by integration just like the +// Integration identity rides onto EVERY span (turn root and all children), not +// just the turn root. The daemon builds per-session integration attributes at +// SessionStart and installs them on the session's Conversation; the SDK copies +// them onto every span it emits, and routeEvent re-installs the conversation for +// each event (each runIsolated frame starts with fresh ambient state). So a chat +// or execute_tool span deep in a turn is filterable by integration just like the // root. Assertions use the literal wire keys — those strings are the contract // the Weave backend reads into its queryable custom-attribute maps. // -// This drives the real routeEvent entry point (not the handlers directly) so -// the baggage context.with wrapping is exercised, and registers an -// AsyncLocalStorage context manager the way production's provider.register() -// does, so context.active() propagates across the handlers' awaits. +// Driven through the real routeEvent entry point so the per-event conversation +// re-install is exercised. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; import { VERSION } from '../src/setup.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; -// Production installs this via NodeTracerProvider.register(); the test injects a -// BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; +interface Driver { + routeEvent(p: Record): Promise; } const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; @@ -64,22 +47,16 @@ function aLine(id: string, ts: string, block: Record, stop?: st }; } -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-integ-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-integ.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as { routeEvent(p: Record): Promise }; -} - -test('integration baggage stamps weave.integration.* on every span (turn, chat, tool, text)', async () => { +test('integration identity stamps weave.integration.* on every span (turn, chat, tool)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); const sid = 'sess-bag'; const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-integ-')); const file = path.join(dir, `${sid}.jsonl`); // First transcript line carries the CC CLI version (real CC transcripts do). fs.appendFileSync(file, JSON.stringify(userText('2026-01-01T00:00:00.000Z', 'do it', '1.2.3')) + '\n'); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon() as unknown as Driver; try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); @@ -90,7 +67,7 @@ test('integration baggage stamps weave.integration.* on every span (turn, chat, await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const spans = exporter.getFinishedSpans(); const ops = new Set(spans.map((s) => s.attributes['gen_ai.operation.name'])); @@ -98,11 +75,11 @@ test('integration baggage stamps weave.integration.* on every span (turn, chat, assert.ok(ops.has('chat'), 'chat span present'); assert.ok(ops.has('execute_tool'), 'tool span present'); - // The baggage context.with wrapping must not disturb the trace tree: the + // The per-event conversation re-install must not disturb the trace tree: the // turn is still the root (no parent) and every span lives in its trace. const turn = spans.find((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent'); assert.ok(turn, 'turn span present'); - assert.equal(turn.parentSpanContext, undefined, 'turn span is a trace root'); + assert.equal(spanParentId(turn), undefined, 'turn span is a trace root'); for (const s of spans) { assert.equal(s.spanContext().traceId, turn.spanContext().traceId, `${s.name} shares the turn trace`); } From b5ff90587173235a63ae3036ae518d3be1165f59 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Mon, 6 Jul 2026 18:06:20 -0700 Subject: [PATCH 02/13] chore: bump weave to 0.16.2 and sync lockfile Bumps the weave floor to the released 0.16.2 (the version whose genai record()/agent-identity API this migration targets). Also regenerates package-lock.json, which the SDK-migration commit (f05fd9e) left in its pre-migration state: it still pinned the removed @opentelemetry OTLP-exporter tree and omitted both weave and the @anthropic-ai/claude-agent-sdk dev dependency, so `npm ci` (format-and-lint + publish) failed with EUSAGE. The lock is now in sync: npm ci, build, and 73 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 2386 +++++++++++++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 2207 insertions(+), 181 deletions(-) diff --git a/package-lock.json b/package-lock.json index b243fb3..f63cf40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,15 @@ "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", - "uuidv7": "1.2.1" + "uuidv7": "1.2.1", + "weave": "^0.16.2" }, "bin": { "weave-claude-code": "dist/cli.js" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" @@ -29,6 +27,177 @@ "node": ">=18.19.0" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", + "dev": true, + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz", + "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz", + "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz", + "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz", + "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz", + "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz", + "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz", + "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz", + "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -471,43 +640,76 @@ "node": ">=18" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "peer": true, "engines": { - "node": ">=8.0.0" + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", - "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", - "license": "Apache-2.0", + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@opentelemetry/api": "^1.3.0" + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { - "node": ">=8.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", - "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "node": ">=8.0.0" } }, "node_modules/@opentelemetry/core": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -519,65 +721,11 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz", - "integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-exporter-base": "0.219.0", - "@opentelemetry/otlp-transformer": "0.219.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", - "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-transformer": "0.219.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", - "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-logs": "0.219.0", - "@opentelemetry/sdk-metrics": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -590,44 +738,11 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", - "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", - "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -641,99 +756,1585 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", - "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.8.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "engines": { + "node": ">=0.4.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", - "optional": true, - "os": [ - "darwin" + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "peer": true, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -753,6 +2354,41 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -771,9 +2407,19 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uuidv7": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.2.1.tgz", @@ -782,6 +2428,386 @@ "bin": { "uuidv7": "cli.js" } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/weave": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/weave/-/weave-0.16.2.tgz", + "integrity": "sha512-38HOhb04fvGhN/Rp+ydXFmWPYSktBU4nRF9nISdKBcoWo3XD9J9ivPIiZCh5d6Ilw+dj7Vv9rpmZKmwa9w97Ag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-proto": "^0.53.0", + "@opentelemetry/resources": "^1.26.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "cli-progress": "^3.12.0", + "cross-spawn": "^7.0.5", + "form-data": "^4.0.4", + "import-in-the-middle": "^1.13.2", + "ini": "^5.0.0", + "module-details-from-path": "^1.0.4", + "semifies": "^1.0.0", + "uuidv7": "^1.0.1" + } + }, + "node_modules/weave/node_modules/@opentelemetry/api-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", + "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/weave/node_modules/@opentelemetry/core": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", + "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.53.0.tgz", + "integrity": "sha512-T/bdXslwRKj23S96qbvGtaYOdfyew3TjPEKOk5mHjkCmkVl1O9C/YMdejwSsdLdOq2YW30KjR9kVi0YMxZushQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-exporter-base": "0.53.0", + "@opentelemetry/otlp-transformer": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.26.0.tgz", + "integrity": "sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", + "integrity": "sha512-UCWPreGQEhD6FjBaeDuXhiMf6kkBODF0ZQzrk/tuQcaVDJ+dDQ/xhJp192H9yWnKxVpEjFrSSLnpqmX4VwX+eA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/otlp-transformer": "0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.53.0.tgz", + "integrity": "sha512-rM0sDA9HD8dluwuBxLetUmoqGJKSAbWenwD65KY9iZhUxdBHRLrIdrABfNDP7aiTjcgK8XFyTn5fhDz7N+W6DA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-logs": "0.53.0", + "@opentelemetry/sdk-metrics": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.26.0.tgz", + "integrity": "sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.53.0.tgz", + "integrity": "sha512-dhSisnEgIj/vJZXZV6f6KcTnyLDx/VuQ6l3ejuZpMpPlh9S1qMHiZU9NMmOkVkwwHkMy3G6mEBwdP23vUZVr4g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.26.0.tgz", + "integrity": "sha512-0SvDXmou/JjzSDOjUmetAAvcKQW6ZrvosU0rkbDGpXvvZN+pQF6JbK/Kd4hNdK4q/22yeruqvukXEJyySTzyTQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/resources": "1.26.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", + "integrity": "sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/weave/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/weave/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index f3ac6e3..8b84993 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "uuidv7": "1.2.1", - "weave": "^0.16.1" + "weave": "^0.16.2" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.178", From fb7f895836595f736577526fea0d9ae350badc5f Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Mon, 6 Jul 2026 18:21:38 -0700 Subject: [PATCH 03/13] test: seed a fake WANDB_API_KEY in the in-process genai bridge initWeaveInMemory() calls weave.init(), which resolves a W&B API key from WANDB_API_KEY/~/.netrc and throws without one, even with a custom offline span processor. The in-process genai tests therefore passed only where a netrc happened to exist and failed on CI (no netrc) with 'wandb API key not found', which surfaced once the lockfile fix let `npm ci` reach the test step. Seed a fake key (as startTestDaemon already does for the subprocess path) so the bridge is hermetic. Pre-existing behavior on 0.16.1 too; not specific to the 0.16.2 bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/helpers.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/helpers.ts b/tests/helpers.ts index 9318c8f..83a383d 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -125,6 +125,12 @@ let genaiExporter: InMemorySpanExporter | undefined; */ export async function initWeaveInMemory(): Promise { if (!genaiExporter) { + // weave.init() resolves a W&B API key from WANDB_API_KEY/~/.netrc and throws + // without one, even though the custom span processor keeps this fully offline + // (project 'e/p' is entity-qualified, so init never hits the network). Seed a + // fake key so the in-process bridge stays hermetic on CI (no netrc); mirrors + // startTestDaemon's wandb_api_key. + process.env.WANDB_API_KEY ??= 'fake-key-for-test'; genaiExporter = new InMemorySpanExporter(); await weave.init('e/p', { genai: { spanProcessor: new SimpleSpanProcessor(genaiExporter) } }); } From dd8f7fb8a13ae103d6c933ce7248bfada716538f Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 7 Jul 2026 00:00:18 -0700 Subject: [PATCH 04/13] refactor(daemon): emit spans via weave 0.16.2 structured API Use the 0.16.2 genai setters instead of hand-writing gen_ai.* attributes: recordChat now calls llm.record({outputMessages, usage, outputType, responseId, finishReasons}); Turn/SubAgent take agentVersion (and the turn's model) as init opts; the subagent's agent_id goes through subAgent.record({agentId}). Drop the subagent's manual conversation.id (inherited from the parent turn) and terse the touched comments. Behavior-preserving: the same gen_ai.* attributes are emitted, so the span-snapshot tests are unchanged (73/73). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chatSpans.ts | 27 ++++++++++++--------- src/daemon.ts | 61 +++++++++++++++++------------------------------- 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/src/chatSpans.ts b/src/chatSpans.ts index cda15f7..762508d 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -77,11 +77,11 @@ export function openChatForGroup(turn: weave.Turn, group: AssistantCallDetail[]) } /** - * Populate a chat (LLM) span from the assistant calls that make up one response, - * then end it. Split transcript lines share the response's usage, so it is taken - * once from the last line (which also carries the stop_reason), not summed. - * `agentName`, when set, tags the span so the Agents view groups a - * subagent's/teammate's calls under that agent. + * Populate a chat (LLM) span from the assistant calls of one response, then end + * it. Split lines share the response's usage, so take it once from the last line + * (which carries stop_reason), not summed. `agentName` tags the span so the + * Agents view groups a subagent's/teammate's calls under it; conversation.id is + * inherited from the parent turn. */ export function recordChat( llm: weave.LLM, @@ -91,13 +91,18 @@ export function recordChat( ): void { const last = group.at(-1)!; const parts = contentBlocksToParts(group.flatMap(c => c.contentBlocks)); - if (parts.length) llm.outputMessages = [{ role: 'assistant', parts }]; - llm.usage = buildUsage(last.usage, last.reasoningTokens); - const attrs: Attributes = { [ATTR.CONVERSATION_ID]: conversationId, [ATTR.OUTPUT_TYPE]: 'text' }; - if (agentName) attrs[ATTR.AGENT_NAME] = agentName; - if (last.responseId) attrs[ATTR.RESPONSE_ID] = last.responseId; const finishReason = group.map(c => c.finishReason).find(Boolean); - if (finishReason) attrs[ATTR.RESPONSE_FINISH_REASONS] = [finishReason]; + llm.record({ + ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), + usage: buildUsage(last.usage, last.reasoningTokens), + outputType: 'text', + ...(last.responseId ? { responseId: last.responseId } : {}), + ...(finishReason ? { finishReasons: [finishReason] } : {}), + }); + // agent.name, and conversation.id for cross-session teammate spans (no ambient + // conversation to inherit from), aren't on record()'s surface — set directly. + const attrs: Attributes = { [ATTR.CONVERSATION_ID]: conversationId }; + if (agentName) attrs[ATTR.AGENT_NAME] = agentName; llm.setAttributes(attrs); llm.end({ endTime: parseIsoOrNow(last.timestamp) }); } diff --git a/src/daemon.ts b/src/daemon.ts index a4796bb..0e9fd35 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -644,20 +644,17 @@ export class GlobalDaemon { session.turnNumber += 1; session.turnToolCalls = 0; session.emittedChatSpanResponseKeys.clear(); - // The turn is the root of its own trace; the Weave Agents backend stitches - // turns into a conversation via `gen_ai.conversation.id` (inherited from the - // ambient conversation re-installed in routeEvent). Session-level metadata - // (cwd, source, plugin.version) is stamped on every turn so it's queryable - // without a separate session-level span. - // conversationId is inherited from the ambient conversation (re-installed in - // routeEvent), which stamps `gen_ai.conversation.id` on the turn and its - // children. + // The turn is the root of its own trace; the backend stitches turns into a + // conversation via gen_ai.conversation.id, inherited from the ambient + // conversation (re-installed per event in routeEvent). Session metadata is + // stamped per-turn so it's queryable without a session-level span. const turn = weave.startTurn({ agentName: this.agentName, + agentVersion: VERSION, + ...(session.initialRequestModel ? { model: session.initialRequestModel } : {}), startTime: new Date(), }); - const attrs: Attributes = { - [ATTR.AGENT_VERSION]: VERSION, + turn.setAttributes({ [ATTR.WEAVE_SESSION_ID]: session.sessionId, [ATTR.WEAVE_CWD]: session.cwd, [ATTR.WEAVE_SOURCE]: session.source, @@ -665,9 +662,7 @@ export class GlobalDaemon { [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]), [ATTR.WEAVE_DISPLAY_NAME]: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`, - }; - if (session.initialRequestModel) attrs[ATTR.REQUEST_MODEL] = session.initialRequestModel; - turn.setAttributes(attrs); + }); session.currentTurn = turn; // Drain compaction attrs buffered while no turn was open. @@ -708,10 +703,8 @@ export class GlobalDaemon { } const subagentType = toolInput['subagent_type'] as string; const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; - const subAgent = session.currentTurn.startSubagent({ name: subagentType, startTime: new Date() }); + const subAgent = session.currentTurn.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); const subAttrs: Attributes = { - [ATTR.AGENT_VERSION]: VERSION, - [ATTR.CONVERSATION_ID]: session.conversationId, [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput), }; @@ -1063,10 +1056,8 @@ export class GlobalDaemon { pendingTeammateIdle: true, }; if (session.currentTurn) { - bestTracker.subAgent = session.currentTurn.startSubagent({ name: agentType, startTime: new Date() }); + bestTracker.subAgent = session.currentTurn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); bestTracker.subAgent.setAttributes({ - [ATTR.AGENT_VERSION]: VERSION, - [ATTR.CONVERSATION_ID]: session.conversationId, [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, [ATTR.WEAVE_ORPHAN_REASON]: reason, }); @@ -1076,9 +1067,8 @@ export class GlobalDaemon { bestTracker.agentId = agentId; if (bestTracker.subAgent) { - // Stamp the runtime agent_id on the subagent's invoke_agent span — the - // chat view uses `gen_ai.agent.id` to label the subagent's subtree. - bestTracker.subAgent.setAttributes({ [ATTR.AGENT_ID]: agentId }); + // The chat view uses gen_ai.agent.id to label the subagent's subtree. + bestTracker.subAgent.record({ agentId }); } this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); @@ -1121,12 +1111,7 @@ export class GlobalDaemon { lastAssistantText = lastTurn?.textBlocks().join('\n'); if (lastTurn) { - this.emitChatSpansUnderTurn( - chatParent, - session.conversationId, - lastTurn.assistantCalls(), - tracker.subagentType, - ); + this.emitChatSpansUnderTurn(chatParent, session.conversationId, lastTurn.assistantCalls(), tracker.subagentType); } } catch (err) { this.log('DEBUG', `SubagentStop: could not parse transcript: ${err}`); @@ -1304,10 +1289,10 @@ export class GlobalDaemon { } /** - * Emit one `chat` span (LLM) per assistant call under `turn`, reconstructing - * each from transcript data (backdated start/end times, usage, ordered output - * parts). `agentName`, when set, tags each span with `gen_ai.agent.name` so - * the Agents view groups a subagent's/teammate's calls under that agent. + * Emit one chat span (LLM) per assistant call under `turn`, reconstructed from + * transcript data (backdated times, usage, ordered output parts). `agentName` + * tags each span so the Agents view groups a subagent's/teammate's calls under + * that agent; conversation.id is inherited from `turn`. */ private emitChatSpansUnderTurn( turn: weave.Turn, @@ -1345,14 +1330,10 @@ export class GlobalDaemon { t = new TranscriptFile(transcriptPath); const parsed = parseSessionFd(t.getFd()); if (parsed) { - // No ambient conversation cross-session, so stamp the conversation id - // and integration identity onto the teammate's own turn root explicitly. - const turn = weave.startTurn({ agentName: agentType, startTime: new Date() }); - turn.setAttributes({ - ...integrationAttrs, - [ATTR.AGENT_VERSION]: VERSION, - [ATTR.CONVERSATION_ID]: conversationId, - }); + // No ambient conversation cross-session: stamp conversation.id and the + // integration identity (a custom attr map) onto the teammate turn root. + const turn = weave.startTurn({ agentName: agentType, agentVersion: VERSION, startTime: new Date() }); + turn.setAttributes({ ...integrationAttrs, [ATTR.CONVERSATION_ID]: conversationId }); for (const parsedTurn of parsed.turns) { this.emitChatSpansUnderTurn(turn, conversationId, parsedTurn.assistantCalls(), agentType); } From 3ceb9b8f5012b44a916db0161507ca320f6fa220 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 7 Jul 2026 00:15:24 -0700 Subject: [PATCH 05/13] refactor: extract shared config resolution into src/config.ts Move resolveProject/resolveApiKey/resolveAgentName (+ their source enums) and resolveDaemonConfig/daemonConfigFingerprint out of cli.ts and daemon.ts into a new config.ts, so both use one implementation without the cli<->daemon import cycle that previously blocked reuse. resolveDaemonConfig now delegates to the per-field resolvers (env param defaulting to process.env), so the env-over-settings precedence is defined once instead of re-implemented. The in-process genai test bridge resolves its project + key through the same path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli.ts | 80 ++++----------------------- src/config.ts | 108 +++++++++++++++++++++++++++++++++++++ src/daemon.ts | 44 +-------------- tests/config-drift.test.ts | 2 +- tests/helpers.ts | 21 +++++--- 5 files changed, 134 insertions(+), 121 deletions(-) create mode 100644 src/config.ts diff --git a/src/cli.ts b/src/cli.ts index 5f2017e..99977ca 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,7 +26,16 @@ import { type PluginSource, } from './setup.js'; import { prompt, sendToSocket, requestFromSocket, probeUnixSocket, SocketState } from './utils.js'; -import { runDaemon, resolveDaemonConfig, daemonConfigFingerprint } from './daemon.js'; +import { runDaemon } from './daemon.js'; +import { + resolveProject, + resolveApiKey, + resolveAgentName, + resolveDaemonConfig, + daemonConfigFingerprint, + WeaveProjectSource, + ApiKeySource, +} from './config.js'; import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; // --------------------------------------------------------------------------- @@ -218,63 +227,6 @@ function maskSecret(value: string): string { return `${value.slice(0, 4)}…`; } -/** Where the effective agent name came from. Parallels `WeaveProjectSource` / - * `ApiKeySource`; has no `NotSet` member because agent_name always resolves - * to the built-in default. */ -enum AgentNameSource { - EnvVar = 'WEAVE_AGENT_NAME env var', - Settings = 'settings.json', - Default = 'default', -} - -/** - * Resolve the effective top-level agent name and where it came from. Mirrors - * the env-over-settings precedence used for `weave_project`, with the - * hardcoded `DEFAULT_AGENT_NAME` as the final fallback. Shared by - * `config show` and `config get` so both report the same value. - */ -function resolveAgentName(settings: Settings): { value: string; source: AgentNameSource } { - const fromEnv = process.env['WEAVE_AGENT_NAME']?.trim(); - if (fromEnv) return { value: fromEnv, source: AgentNameSource.EnvVar }; - const fromSettings = settings.agent_name?.trim(); - if (fromSettings) return { value: fromSettings, source: AgentNameSource.Settings }; - return { value: DEFAULT_AGENT_NAME, source: AgentNameSource.Default }; -} - -/** - * Resolve the effective Weave project and where it came from, applying the - * env-over-settings precedence (`WEAVE_PROJECT` beats `settings.weave_project`). - * Shared by install, config, status, and restart so they report one value. - * `value` uses nullish coalescing and `source` uses truthiness, matching the - * per-site expressions this replaces. - */ -function resolveProject(settings: Settings): { value: string | null; source: WeaveProjectSource } { - const value = process.env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const source = process.env['WEAVE_PROJECT'] - ? WeaveProjectSource.EnvVar - : settings.weave_project - ? WeaveProjectSource.Settings - : WeaveProjectSource.NotSet; - return { value, source }; -} - -/** - * Resolve the effective W&B API key and where it came from, applying the - * env-over-settings precedence (`WANDB_API_KEY` beats `settings.wandb_api_key`). - * Shared by install, config, status, and restart so they report one value. - * `value` uses nullish coalescing and `source` uses truthiness, matching the - * per-site expressions this replaces. - */ -function resolveApiKey(settings: Settings): { value: string | null; source: ApiKeySource } { - const value = process.env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; - const source = process.env['WANDB_API_KEY'] - ? ApiKeySource.EnvVar - : settings.wandb_api_key - ? ApiKeySource.Settings - : ApiKeySource.NotSet; - return { value, source }; -} - /** * Render the comma-joined list of missing required config for the "incomplete" * status/restart messages. `apiKeyLabel` differs by call site (`wandb_api_key` @@ -408,18 +360,6 @@ async function cmdConfig(args: string[]): Promise { // status // --------------------------------------------------------------------------- -/** Where a configured value (project, API key) came from at gather time. */ -export enum WeaveProjectSource { - EnvVar = 'WEAVE_PROJECT env var', - Settings = 'settings.json', - NotSet = 'not set', -} -export enum ApiKeySource { - EnvVar = 'WANDB_API_KEY env var', - Settings = 'settings.json', - NotSet = 'not set', -} - /** Whether settings.json could be read at gather time. */ export enum ConfigState { Ok = 'ok', diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..0c2f2fe --- /dev/null +++ b/src/config.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Config resolution shared by the CLI and the daemon: the effective Weave +// project / API key / agent name (env over settings.json), plus the daemon's +// full config and its fingerprint. Lives here rather than in cli.ts or +// daemon.ts so both use one implementation without an import cycle (cli.ts +// imports the daemon entry point). + +import { createHash } from 'crypto'; +import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; +import type { Settings } from './setup.js'; + +/** Where a resolved value came from, for user-facing "source" reporting. */ +export enum WeaveProjectSource { + EnvVar = 'WEAVE_PROJECT env var', + Settings = 'settings.json', + NotSet = 'not set', +} +export enum ApiKeySource { + EnvVar = 'WANDB_API_KEY env var', + Settings = 'settings.json', + NotSet = 'not set', +} +/** No `NotSet`: agent_name always resolves to the built-in default. */ +export enum AgentNameSource { + EnvVar = 'WEAVE_AGENT_NAME env var', + Settings = 'settings.json', + Default = 'default', +} + +/** Resolve the effective Weave project (WEAVE_PROJECT env beats + * settings.weave_project) and where it came from. */ +export function resolveProject( + settings: Settings, + env: NodeJS.ProcessEnv = process.env, +): { value: string | null; source: WeaveProjectSource } { + const value = env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; + const source = env['WEAVE_PROJECT'] + ? WeaveProjectSource.EnvVar + : settings.weave_project + ? WeaveProjectSource.Settings + : WeaveProjectSource.NotSet; + return { value, source }; +} + +/** Resolve the effective W&B API key (WANDB_API_KEY env beats + * settings.wandb_api_key) and where it came from. */ +export function resolveApiKey( + settings: Settings, + env: NodeJS.ProcessEnv = process.env, +): { value: string | null; source: ApiKeySource } { + const value = env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; + const source = env['WANDB_API_KEY'] + ? ApiKeySource.EnvVar + : settings.wandb_api_key + ? ApiKeySource.Settings + : ApiKeySource.NotSet; + return { value, source }; +} + +/** Resolve the effective top-level agent name (WEAVE_AGENT_NAME env beats + * settings.agent_name), falling back to `DEFAULT_AGENT_NAME`. */ +export function resolveAgentName( + settings: Settings, + env: NodeJS.ProcessEnv = process.env, +): { value: string; source: AgentNameSource } { + const fromEnv = env['WEAVE_AGENT_NAME']?.trim(); + if (fromEnv) return { value: fromEnv, source: AgentNameSource.EnvVar }; + const fromSettings = settings.agent_name?.trim(); + if (fromSettings) return { value: fromSettings, source: AgentNameSource.Settings }; + return { value: DEFAULT_AGENT_NAME, source: AgentNameSource.Default }; +} + +/** The config the daemon loads at startup and holds for its lifetime. */ +export type DaemonConfig = { + weaveProject: string | null; + apiKey: string | null; + baseUrl: string; + agentName: string; + debug: boolean; +}; + +/** Resolve the daemon config from settings + env, reusing the per-field + * resolvers so the env-over-settings precedence is defined once. */ +export function resolveDaemonConfig(settings: Settings, env: NodeJS.ProcessEnv): DaemonConfig { + return { + weaveProject: resolveProject(settings, env).value, + apiKey: resolveApiKey(settings, env).value, + baseUrl: (env['WANDB_BASE_URL'] ?? 'https://trace.wandb.ai').replace(/\/+$/, ''), + agentName: resolveAgentName(settings, env).value, + debug: !!env['WEAVE_CLAUDE_DEBUG'] || settings.debug === true, + }; +} + +/** Hex chars kept from the config hash. 16 (64 bits) is ample to detect a + * config change while staying compact for logs and the socket reply. */ +const CONFIG_FINGERPRINT_LENGTH = 16; + +/** Short, stable hash of a daemon config. The API key is hashed, not exposed, + * so the fingerprint is safe to send over the socket. */ +export function daemonConfigFingerprint(c: DaemonConfig): string { + return createHash('sha256') + .update(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) + .digest('hex') + .slice(0, CONFIG_FINGERPRINT_LENGTH); +} diff --git a/src/daemon.ts b/src/daemon.ts index 0e9fd35..be51007 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,7 +5,6 @@ import * as net from 'net'; import * as fs from 'fs'; import * as path from 'path'; -import { createHash } from 'crypto'; import type { Attributes } from '@opentelemetry/api'; import type { HookInput, @@ -23,13 +22,12 @@ import type { SessionEndHookInput, } from '@anthropic-ai/claude-agent-sdk'; import * as weave from 'weave'; -import { loadSettings, VERSION, type Settings } from './setup.js'; +import { loadSettings, VERSION } from './setup.js'; import { appendToLog, deepEqual } from './utils.js'; import { parseSessionFd } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; import { ATTR, - DEFAULT_AGENT_NAME, CompactionAttrs, addPermissionRequestEvent, setCompactionAttrs, @@ -37,6 +35,7 @@ import { promptSnippet, jsonStr, } from './genaiSpans.js'; +import { resolveDaemonConfig, daemonConfigFingerprint } from './config.js'; import { chatMessageKey, callsForResponseKey, @@ -1709,45 +1708,6 @@ export class GlobalDaemon { } } -// ───────────────────────────────────────────────────────────────────────────── -// Config resolution and fingerprinting -// ───────────────────────────────────────────────────────────────────────────── - -/** The config the daemon loads at startup and holds for its lifetime. */ -type DaemonConfig = { - weaveProject: string | null; - apiKey: string | null; - baseUrl: string; - agentName: string; - debug: boolean; -} - -/** Resolve the effective daemon config from settings + env, applying the same - * env-over-settings precedence the daemon uses at startup. */ -export function resolveDaemonConfig(settings: Settings, env: NodeJS.ProcessEnv): DaemonConfig { - return { - weaveProject: env['WEAVE_PROJECT'] ?? settings.weave_project ?? null, - apiKey: env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null, - baseUrl: (env['WANDB_BASE_URL'] ?? 'https://trace.wandb.ai').replace(/\/+$/, ''), - // `||` (not `??`) so an empty/whitespace value falls through to the default - // rather than producing a blank `invoke_agent ` span name. - agentName: env['WEAVE_AGENT_NAME']?.trim() || settings.agent_name?.trim() || DEFAULT_AGENT_NAME, - debug: !!env['WEAVE_CLAUDE_DEBUG'] || settings.debug === true, - }; -} - -/** Hex chars kept from the config hash. 16 (64 bits) is ample to detect a - * config change while keeping the value compact for logs and the socket reply. */ -const CONFIG_FINGERPRINT_LENGTH = 16; - -/** Short, stable hash of a daemon config. The API key is hashed, not exposed, - * so the fingerprint is safe to send over the socket. */ -export function daemonConfigFingerprint(c: DaemonConfig): string { - return createHash('sha256') - .update(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) - .digest('hex') - .slice(0, CONFIG_FINGERPRINT_LENGTH); -} // ───────────────────────────────────────────────────────────────────────────── // Entry point (invoked by `weave-claude-code daemon`) diff --git a/tests/config-drift.test.ts b/tests/config-drift.test.ts index 3bb9cb5..9549070 100644 --- a/tests/config-drift.test.ts +++ b/tests/config-drift.test.ts @@ -16,7 +16,7 @@ import * as net from 'node:net'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveDaemonConfig, daemonConfigFingerprint } from '../src/daemon.ts'; +import { resolveDaemonConfig, daemonConfigFingerprint } from '../src/config.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 83a383d..603637a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -15,8 +15,9 @@ import { fileURLToPath } from 'node:url'; import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'; import * as weave from 'weave'; -import { MARKETPLACE_NAME } from '../src/setup.ts'; +import { MARKETPLACE_NAME, type Settings } from '../src/setup.ts'; import { GlobalDaemon } from '../src/daemon.ts'; +import { resolveApiKey, resolveProject } from '../src/config.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); @@ -125,14 +126,18 @@ let genaiExporter: InMemorySpanExporter | undefined; */ export async function initWeaveInMemory(): Promise { if (!genaiExporter) { - // weave.init() resolves a W&B API key from WANDB_API_KEY/~/.netrc and throws - // without one, even though the custom span processor keeps this fully offline - // (project 'e/p' is entity-qualified, so init never hits the network). Seed a - // fake key so the in-process bridge stays hermetic on CI (no netrc); mirrors - // startTestDaemon's wandb_api_key. - process.env.WANDB_API_KEY ??= 'fake-key-for-test'; + // weave.init() resolves a key from WANDB_API_KEY/~/.netrc and throws without + // one, even offline. Resolve project + key the way the daemon does (fake + // creds so the bridge stays hermetic on CI) and export the key for init. + const settings: Settings = { + log_file: '', daemon_socket: '', weave_project: 'e/p', wandb_api_key: 'fake-key-for-test', + agent_name: null, debug: false, installed_at: '', version: '0.0.0-test', + }; + process.env.WANDB_API_KEY = resolveApiKey(settings).value ?? ''; genaiExporter = new InMemorySpanExporter(); - await weave.init('e/p', { genai: { spanProcessor: new SimpleSpanProcessor(genaiExporter) } }); + await weave.init(resolveProject(settings).value ?? 'e/p', { + genai: { spanProcessor: new SimpleSpanProcessor(genaiExporter) }, + }); } return genaiExporter; } From d727149c85c1cdfa2aa7dffcb156ea2097be8950 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 18:05:23 -0700 Subject: [PATCH 06/13] refactor(daemon): seed identity via held Conversation handles (weave 0.16.3) weave 0.16.3 forwards a conversation's id and attributes down the handle chain (conversation -> turn -> llm/tool/subagent), so ambient state no longer carries identity. Hold one Conversation per session and drop the per-event re-install in routeEvent; runIsolated remains only to keep the SDK's single-active guards from tripping across concurrent sessions. - newSessionState starts the Conversation; startSessionTurn dedupes the turn-creation blocks (UserPromptSubmit + post-restart reconstruction) - turn input messages move to TurnInit.userMessage (semconv parts shape) - recordChat drops its conversationId param (inherited via handles); teammate traces get a dedicated Conversation instead of hand-stamping conversation.id + integration attrs - fold one-liner promptSnippet into snippet Co-Authored-By: Claude Fable 5 --- src/chatSpans.ts | 13 ++-- src/daemon.ts | 169 ++++++++++++++++++-------------------------- src/genaiSpans.ts | 7 +- src/sessionState.ts | 24 +++++-- 4 files changed, 94 insertions(+), 119 deletions(-) diff --git a/src/chatSpans.ts b/src/chatSpans.ts index 762508d..6ec5783 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -3,7 +3,6 @@ // SPDX-PackageName: weave-claude-code import * as weave from 'weave'; -import type { Attributes } from '@opentelemetry/api'; import type { AssistantCallDetail } from './parser.js'; import { isToolUseBlock } from './parser.js'; import { @@ -80,13 +79,12 @@ export function openChatForGroup(turn: weave.Turn, group: AssistantCallDetail[]) * Populate a chat (LLM) span from the assistant calls of one response, then end * it. Split lines share the response's usage, so take it once from the last line * (which carries stop_reason), not summed. `agentName` tags the span so the - * Agents view groups a subagent's/teammate's calls under it; conversation.id is - * inherited from the parent turn. + * subagent's/teammate's calls stay queryable by agent; conversation.id is + * inherited from the parent handle chain. */ export function recordChat( llm: weave.LLM, group: AssistantCallDetail[], - conversationId: string, agentName?: string, ): void { const last = group.at(-1)!; @@ -99,10 +97,7 @@ export function recordChat( ...(last.responseId ? { responseId: last.responseId } : {}), ...(finishReason ? { finishReasons: [finishReason] } : {}), }); - // agent.name, and conversation.id for cross-session teammate spans (no ambient - // conversation to inherit from), aren't on record()'s surface — set directly. - const attrs: Attributes = { [ATTR.CONVERSATION_ID]: conversationId }; - if (agentName) attrs[ATTR.AGENT_NAME] = agentName; - llm.setAttributes(attrs); + // agent.name isn't on record()'s surface — set directly. + if (agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: agentName }); llm.end({ endTime: parseIsoOrNow(last.timestamp) }); } diff --git a/src/daemon.ts b/src/daemon.ts index fef33d2..af34010 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -33,7 +33,7 @@ import { addPermissionRequestEvent, setCompactionAttrs, toolDisplayName, - promptSnippet, + snippet, jsonStr, } from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint } from './config.js'; @@ -380,25 +380,12 @@ export class GlobalDaemon { this.log('INFO', `${input.hook_event_name} session=${sessionId}${input.agent_id ? ` agent=${input.agent_id}` : ''}`); - // Each event runs in its own isolated context so ambient GenAI state (the - // conversation, and any turn/LLM the SDK's factories consult) never leaks - // across events. The SDK copies the conversation's `attributes` onto each - // span at creation, but runIsolated gives every frame a fresh empty state, - // so re-install the session's conversation here; otherwise the integration - // identity would only land on spans created in the SessionStart frame. The - // session (and its conversation) don't exist until SessionStart runs, so - // that one event runs without a re-install; it creates no child spans. - await weave.runIsolated(async () => { - const session = this.sessions.get(sessionId); - if (session) { - weave.startConversation({ - conversationId: session.conversationId, - agentName: this.agentName, - attributes: session.integrationAttrs, - }); - } - await this.dispatchEvent(input, sessionId); - }); + // Each event runs in its own isolated frame so the SDK's single-active + // guards (one Conversation/Turn/LLM per frame) never trip across + // concurrently open sessions. Identity doesn't ride on the frame: the + // conversation's id and attributes forward through the held handles + // (conversation → turn → llm/tool/subagent) onto every span. + await weave.runIsolated(() => this.dispatchEvent(input, sessionId)); } /** Run the handler for a single hook event, narrowing `input` to the event's @@ -489,19 +476,9 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: 0, + agentName: this.agentName, + tracingEnabled: this.tracingEnabled, }); - - // Install the conversation for this SessionStart frame; routeEvent - // re-installs it for every later event (each runIsolated frame is fresh). - // The SDK copies the integration identity onto every span created under it. - if (this.tracingEnabled) { - weave.startConversation({ - conversationId, - agentName: this.agentName, - attributes: session.integrationAttrs, - }); - } - this.sessions.set(sessionId, session); this.drainPendingInstructions(session); @@ -640,19 +617,11 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: priorTurns, + agentName: this.agentName, + tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); this.drainPendingInstructions(session); - // Install the conversation for the current event frame. routeEvent's - // re-install ran before this session existed, so without this the spans this - // event emits would miss the integration identity (matches handleSessionStart). - if (this.tracingEnabled) { - weave.startConversation({ - conversationId, - agentName: this.agentName, - attributes: session.integrationAttrs, - }); - } this.log( 'INFO', `Session reconstructed after restart: ${sessionId} (conversation=${conversationId}, prior_turns=${priorTurns})`, @@ -714,6 +683,35 @@ export class GlobalDaemon { this.log('DEBUG', `Drained ${pending.length} buffered instruction file(s) into session ${session.sessionId}`); } + /** + * Open a turn under the session's conversation and stamp the per-turn session + * metadata. Each turn is the root of its own trace; the backend stitches + * turns into a conversation via `gen_ai.conversation.id`, which — with the + * agent identity and integration attributes — the conversation handle seeds + * onto the turn and its whole span subtree. Session metadata is stamped + * per-turn so it's queryable without a session-level span. + */ + private startSessionTurn(session: SessionState, displayName: string, userMessage?: string): weave.Turn | undefined { + if (!session.conversation) return undefined; + const turn = session.conversation.startTurn({ + agentVersion: VERSION, + model: session.initialRequestModel, + userMessage, + systemInstructions: session.systemInstructions.map((i) => i.content), + startTime: new Date(), + }); + turn.setAttributes({ + [ATTR.WEAVE_SESSION_ID]: session.sessionId, + [ATTR.WEAVE_CWD]: session.cwd, + [ATTR.WEAVE_SOURCE]: session.source, + [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, + [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, + [ATTR.WEAVE_DISPLAY_NAME]: displayName, + }); + session.currentTurn = turn; + return turn; + } + private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { // Reconstruct the session if this daemon never saw its SessionStart (e.g. it // idled out mid-session and a fresh daemon took over) so the rest of the @@ -728,33 +726,14 @@ export class GlobalDaemon { const prompt = input.prompt; this.log( 'DEBUG', - `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${promptSnippet(prompt, 120)}`, + `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${snippet(prompt, 120)}`, ); session.turnNumber += 1; session.turnToolCalls = 0; session.emittedChatSpanResponseKeys.clear(); - // The turn is the root of its own trace; the backend stitches turns into a - // conversation via gen_ai.conversation.id, inherited from the ambient - // conversation (re-installed per event in routeEvent). Session metadata is - // stamped per-turn so it's queryable without a session-level span. - const turn = weave.startTurn({ - agentName: this.agentName, - agentVersion: VERSION, - ...(session.initialRequestModel ? { model: session.initialRequestModel } : {}), - systemInstructions: session.systemInstructions.map((i) => i.content), - startTime: new Date(), - }); - turn.setAttributes({ - [ATTR.WEAVE_SESSION_ID]: session.sessionId, - [ATTR.WEAVE_CWD]: session.cwd, - [ATTR.WEAVE_SOURCE]: session.source, - [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, - [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, - [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]), - [ATTR.WEAVE_DISPLAY_NAME]: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`, - }); - session.currentTurn = turn; + const turn = this.startSessionTurn(session, `Turn ${session.turnNumber}: ${snippet(prompt)}`, prompt); + if (!turn) return; // Drain compaction attrs buffered while no turn was open. if (session.pendingCompaction) { @@ -954,7 +933,7 @@ export class GlobalDaemon { // required); skip it rather than guess a model. const llm = existingLlm ?? openChatForGroup(session.currentTurn, group); if (!llm) return; - recordChat(llm, group, session.conversationId); + recordChat(llm, group); session.emittedChatSpanResponseKeys.add(key); } @@ -1169,24 +1148,9 @@ export class GlobalDaemon { * session without a turn, so a subagent recovered at SubagentStop has a parent. */ private getOrReconstructTurn(session: SessionState): weave.Turn | undefined { if (session.currentTurn) return session.currentTurn; - if (!this.tracingEnabled) return undefined; - const turnNumber = session.turnNumber || 1; - const turn = weave.startTurn({ - agentName: this.agentName, - agentVersion: VERSION, - ...(session.initialRequestModel ? { model: session.initialRequestModel } : {}), - startTime: new Date(), - }); - turn.setAttributes({ - [ATTR.WEAVE_SESSION_ID]: session.sessionId, - [ATTR.WEAVE_CWD]: session.cwd, - [ATTR.WEAVE_SOURCE]: session.source, - [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, - [ATTR.WEAVE_TURN_NUMBER]: turnNumber, - [ATTR.WEAVE_DISPLAY_NAME]: `Turn ${turnNumber} (reconstructed)`, - }); - session.currentTurn = turn; - this.log('INFO', `Reconstructed turn span (turn ${turnNumber}) after restart`); + session.turnNumber ||= 1; + const turn = this.startSessionTurn(session, `Turn ${session.turnNumber} (reconstructed)`); + if (turn) this.log('INFO', `Reconstructed turn span (turn ${session.turnNumber}) after restart`); return turn; } @@ -1263,7 +1227,7 @@ export class GlobalDaemon { lastAssistantText = lastTurn?.textBlocks().join('\n'); if (lastTurn) { - this.emitChatSpansUnderTurn(chatParent, session.conversationId, lastTurn.assistantCalls(), tracker.subagentType); + this.emitChatSpans(chatParent, lastTurn.assistantCalls(), tracker.subagentType); } } catch (err) { this.log('DEBUG', `SubagentStop: could not parse transcript: ${err}`); @@ -1441,21 +1405,20 @@ export class GlobalDaemon { } /** - * Emit one chat span (LLM) per assistant call under `turn`, reconstructed from - * transcript data (backdated times, usage, ordered output parts). `agentName` - * tags each span so the Agents view groups a subagent's/teammate's calls under - * that agent; conversation.id is inherited from `turn`. + * Emit one chat span (LLM) per assistant call under `parent`, reconstructed + * from transcript data (backdated times, usage, ordered output parts). + * `agentName` tags each span so a subagent's/teammate's calls stay queryable + * by agent; conversation.id is inherited from the parent handle chain. */ - private emitChatSpansUnderTurn( - turn: weave.Turn, - conversationId: string, + private emitChatSpans( + parent: weave.Turn, calls: AssistantCallDetail[], agentName?: string, ): void { for (const c of calls) { if (!c.model) continue; - const llm = startChat(turn, c.model, parseIsoOrNow(c.prevTimestamp ?? c.timestamp)); - recordChat(llm, [c], conversationId, agentName); + const llm = startChat(parent, c.model, parseIsoOrNow(c.prevTimestamp ?? c.timestamp)); + recordChat(llm, [c], agentName); } } @@ -1463,9 +1426,11 @@ export class GlobalDaemon { * Emit a teammate's whole transcript as its OWN turn trace, then close the * teammate's SubAgent marker. TeammateIdle fires after the coordinator turn * that spawned the teammate has already closed, so the teammate can't nest - * under it; instead it gets a fresh root `invoke_agent` turn (stamped with the - * integration identity, which it won't inherit cross-session) with the - * teammate's chat spans as children. Returns the teammate's model, if known. + * under it; instead it gets a fresh root `invoke_agent` turn with the + * teammate's chat spans as children. A dedicated Conversation handle seeds + * the coordinator's conversation.id and integration identity (neither is + * inherited cross-session) onto that whole subtree. Returns the teammate's + * model, if known. */ private emitTeammateTurnTrace( subAgent: weave.SubAgent, @@ -1482,12 +1447,14 @@ export class GlobalDaemon { t = new TranscriptFile(transcriptPath); const parsed = parseSessionFd(t.getFd()); if (parsed) { - // No ambient conversation cross-session: stamp conversation.id and the - // integration identity (a custom attr map) onto the teammate turn root. - const turn = weave.startTurn({ agentName: agentType, agentVersion: VERSION, startTime: new Date() }); - turn.setAttributes({ ...integrationAttrs, [ATTR.CONVERSATION_ID]: conversationId }); + const conversation = weave.startConversation({ + conversationId, + agentName: agentType, + attributes: integrationAttrs, + }); + const turn = conversation.startTurn({ agentVersion: VERSION, startTime: new Date() }); for (const parsedTurn of parsed.turns) { - this.emitChatSpansUnderTurn(turn, conversationId, parsedTurn.assistantCalls(), agentType); + this.emitChatSpans(turn, parsedTurn.assistantCalls(), agentType); } turn.end(); const lastTurn = parsed.turns.at(-1); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 3ef4f5f..fb5d45e 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -291,7 +291,8 @@ export function setCompactionAttrs(turn: Turn, attrs: CompactionAttrs): void { // Display-name helpers // ───────────────────────────────────────────────────────────────────────────── -function snippet(value: unknown, maxLen = 60): string { +/** Single-line preview of a value: whitespace collapsed, truncated with `…`. */ +export function snippet(value: unknown, maxLen = 60): string { const s = String(value ?? '').replace(/\s+/g, ' ').trim(); return s.length <= maxLen ? s : s.slice(0, maxLen - 1) + '…'; } @@ -320,7 +321,3 @@ export function toolDisplayName(toolName: string, input: Record } } } - -export function promptSnippet(prompt: string, maxLen = 60): string { - return snippet(prompt, maxLen); -} diff --git a/src/sessionState.ts b/src/sessionState.ts index da7b802..2c21247 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -207,11 +207,19 @@ export type SessionState = { source: string; initialRequestModel?: string; /** Integration identity (name, version, meta.*), built once at SessionStart. - * Installed on the session's conversation at SessionStart and re-installed - * for every later event in `routeEvent` (each `runIsolated` frame gets fresh - * ambient state), so the SDK copies it onto every span the session emits. */ + * Set as the conversation's attributes (the SDK forwards them down the + * handle chain onto every span) and kept here for the cross-session + * teammate path, which re-stamps them on spans created outside this + * session's conversation. */ integrationAttrs: Attributes; + /** The session's Conversation handle. Seeds `gen_ai.conversation.id`, the + * agent identity, and the integration attributes onto every turn started + * from it (and, via the handle chain, onto all child spans) — no ambient + * state involved, so events in separate `runIsolated` frames still inherit + * everything. Unset when tracing is disabled. */ + conversation?: weave.Conversation; + currentTurn?: weave.Turn; turnNumber: number; @@ -322,9 +330,13 @@ export type NewSessionStateOptions = { source: string; initialRequestModel: string | undefined; turnNumber: number; + /** The top-level agent name the conversation (and thus every turn) carries. */ + agentName: string; + /** When false (tracing disabled), no Conversation handle is created. */ + tracingEnabled: boolean; }; -/** Build a fresh SessionState. */ +/** Build a fresh SessionState, starting its Conversation when tracing is on. */ export function newSessionState(options: NewSessionStateOptions): SessionState { const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = options; @@ -340,6 +352,9 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { version: VERSION, meta: { claude_code_app_version: claudeCodeAppVersion }, }); + const conversation = options.tracingEnabled + ? weave.startConversation({ conversationId, agentName: options.agentName, attributes: integrationAttrs }) + : undefined; return { sessionId, @@ -349,6 +364,7 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { source, initialRequestModel, integrationAttrs, + conversation, turnNumber, totalToolCalls: 0, turnToolCalls: 0, From 64516d7771b143e29ba942e652128ac09453da73 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 18:08:34 -0700 Subject: [PATCH 07/13] feat(daemon): nest subagent chat/tool spans under their invoke_agent marker weave 0.16.3 lets a SubAgent parent LLM/Tool children (weave#7077), so the "SubAgent is a leaf" flattening workaround goes away: a subagent's own tools and chat spans now nest under its invoke_agent marker instead of the turn. The gen_ai.agent.name tag stays on those children so they remain queryable by agent; orphans without a marker still fall back to the turn. Adds a matched-path integration test asserting the nested tree, the PostToolUse close, and conversation-id/integration identity on every nested span. Co-Authored-By: Claude Fable 5 --- src/chatSpans.ts | 6 +- src/daemon.ts | 30 +++---- src/sessionState.ts | 2 +- tests/daemon-subagent-recovery.test.ts | 6 +- tests/subagent-nesting.test.ts | 103 +++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 tests/subagent-nesting.test.ts diff --git a/src/chatSpans.ts b/src/chatSpans.ts index 6ec5783..9782f07 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -57,10 +57,10 @@ export function parseIsoOrNow(ts: string | undefined): Date { return parseTimestamp(ts) ?? new Date(); } -/** Open a chat (LLM) span under `turn` for `model`, deriving the provider. */ -export function startChat(turn: weave.Turn, model: string, startTime: Date): weave.LLM { +/** Open a chat (LLM) span under a turn or subagent for `model`, deriving the provider. */ +export function startChat(parent: weave.Turn | weave.SubAgent, model: string, startTime: Date): weave.LLM { const provider = providerFromModel(model); - return turn.startLLM({ model, ...(provider ? { providerName: provider } : {}), startTime }); + return parent.startLLM({ model, ...(provider ? { providerName: provider } : {}), startTime }); } /** diff --git a/src/daemon.ts b/src/daemon.ts index af34010..bff2449 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -814,14 +814,15 @@ export class GlobalDaemon { return; } - // Parent for the tool span. A SubAgent is a leaf (it can't parent tools), so - // a subagent's own tools nest directly under the turn and carry the - // subagent's `gen_ai.agent.name` so the Agents view groups them. For the - // main agent, nest under the active response's chat span (advanced from the - // transcript), falling back to the turn when the machine can't advance yet. - const subagentType = agentId ? session.subagents.byAgentId(agentId)?.subagentType : undefined; - const parent: weave.Turn | weave.LLM | undefined = agentId - ? session.currentTurn + // Parent for the tool span. A subagent's tools nest under its own + // `invoke_agent` marker, tagged with the subagent's `gen_ai.agent.name` so + // they also stay queryable by agent (falling back to the turn if the marker + // is missing). For the main agent, nest under the active response's chat + // span (advanced from the transcript), falling back to the turn when the + // machine can't advance yet. + const tracker = agentId ? session.subagents.byAgentId(agentId) : undefined; + const parent: weave.Turn | weave.SubAgent | weave.LLM | undefined = agentId + ? tracker?.subAgent ?? session.currentTurn : this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; if (!parent) { this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); @@ -835,7 +836,7 @@ export class GlobalDaemon { startTime: new Date(), }); const toolAttrs: Attributes = { [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput) }; - if (subagentType) toolAttrs[ATTR.AGENT_NAME] = subagentType; + if (tracker) toolAttrs[ATTR.AGENT_NAME] = tracker.subagentType; tool.setAttributes(toolAttrs); session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); } @@ -1199,10 +1200,11 @@ export class GlobalDaemon { return; } - // The subagent marker (SubAgent) is a leaf and can't parent chat spans, so - // the subagent's LLM calls are emitted under the current turn and tagged - // with the subagent's `gen_ai.agent.name` so the Agents view groups them. - const chatParent = session.currentTurn; + // The subagent's LLM calls nest under its `invoke_agent` marker, so its + // work (and token usage) reads as the subagent's own subtree. Orphans that + // never got a marker fall back to the turn; the `gen_ai.agent.name` tag on + // each chat keeps them queryable by agent either way. + const chatParent = tracker.subAgent ?? session.currentTurn; // Fall back to the stored or agentId-derived path when the payload omits it. const agentTranscriptPath = @@ -1411,7 +1413,7 @@ export class GlobalDaemon { * by agent; conversation.id is inherited from the parent handle chain. */ private emitChatSpans( - parent: weave.Turn, + parent: weave.Turn | weave.SubAgent, calls: AssistantCallDetail[], agentName?: string, ): void { diff --git a/src/sessionState.ts b/src/sessionState.ts index 2c21247..413a961 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -146,7 +146,7 @@ export type SubagentTracker = { subagentType: string; detectedAt: Date; toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) - subAgent?: weave.SubAgent; // subagent's `invoke_agent` marker span (a leaf) + subAgent?: weave.SubAgent; // subagent's `invoke_agent` marker span; its chat/tool spans nest here agentId?: string; /** sha256 of the prompt passed to the Agent tool; matched against the * subagent's transcript line-1 user message at SubagentStart. */ diff --git a/tests/daemon-subagent-recovery.test.ts b/tests/daemon-subagent-recovery.test.ts index 7f64ad1..70127c9 100644 --- a/tests/daemon-subagent-recovery.test.ts +++ b/tests/daemon-subagent-recovery.test.ts @@ -67,13 +67,14 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a ); assert.ok(subInvoke, `expected a recovered subagent invoke_agent span; got: ${names}`); - // The SubAgent marker is a leaf, so the subagent's chat spans flatten under - // the turn tagged with the subagent's agent.name — and carry its tokens. + // The subagent's chat spans nest under its invoke_agent marker — and carry + // its tokens. const chat = spans.find( (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.attributes['gen_ai.agent.name'] === 'general-purpose', ); assert.ok(chat, `expected the subagent chat span; got: ${names}`); assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the subagent invoke_agent span'); // Recovery reconstructs the turn; the subagent nests under it. const turn = spans.find( @@ -81,7 +82,6 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a ); assert.ok(turn, `expected a reconstructed turn span to parent the subagent; got: ${names}`); assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); - assert.equal(spanParentId(chat), turn.spanContext().spanId, 'subagent chat flattens under the reconstructed turn'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts new file mode 100644 index 0000000..f52617d --- /dev/null +++ b/tests/subagent-nesting.test.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// The matched subagent path end-to-end: PreToolUse(Agent) opens the +// invoke_agent marker under the turn, SubagentStart correlates the agent_id by +// firing-prompt hash, the subagent's own tools and chat spans nest under the +// marker (weave 0.16.3 SubAgent parents children), and PostToolUse(Agent) +// closes the marker with the tool's canonical return. Conversation id and +// integration identity must reach every nested span through the handle chain. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; + +interface Driver { + routeEvent(p: Record): Promise; +} + +function userLine(text: string): string { + return JSON.stringify({ type: 'user', version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text }] } }); +} +function assistantLine(text: string, usage: Record): string { + return JSON.stringify({ + type: 'assistant', + timestamp: '2026-01-01T00:00:05.000Z', + message: { role: 'assistant', model: 'claude-opus-4-8', id: 'm1', usage, stop_reason: 'end_turn', content: [{ type: 'text', text }] }, + }); +} + +test('matched subagent: tools and chats nest under its invoke_agent marker with full identity', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-001'; + const agentId = 'nest-agent-1'; + const firingPrompt = 'find the flaky test'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + + // Subagent transcript at the derived path; line 1 is the firing prompt + // (byte-identical to the Agent tool's prompt) for content-based correlation. + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); + + const d = makeGenaiDaemon() as unknown as Driver; + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-agent', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: firingPrompt, description: 'Find it' }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + // The subagent runs its own tool. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', tool_response: 'contents' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent', tool_response: 'found the flaky test' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn, 'coordinator turn exported'); + const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(subInvoke, 'subagent invoke_agent marker exported'); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); + assert.equal(subInvoke.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'tu-agent'); + assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); + assert.equal( + subInvoke.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'found the flaky test' }]), + 'PostToolUse(Agent) closes the marker with the canonical tool return', + ); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'subagent tool span exported'); + assert.equal(spanParentId(readTool), subInvoke.spanContext().spanId, 'subagent tool nests under the marker'); + assert.equal(readTool.attributes[ATTR.AGENT_NAME], 'Explore', 'subagent tool tagged with the subagent name'); + + const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(chat, 'subagent chat span exported'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the marker'); + assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); + + // Identity flows through the handle chain to every nested span. + for (const s of [subInvoke, readTool, chat]) { + assert.equal(s.attributes[ATTR.CONVERSATION_ID], sid, `${s.name}: conversation id`); + assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 66cb87a69885c2055dc16a4605d36785c249fcfc Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 18:13:25 -0700 Subject: [PATCH 08/13] refactor: tighten the daemon surface - GlobalDaemon takes one DaemonConfig instead of 5 positional config args (easy to mis-order), and the config-hash reply fingerprints it directly - Stop records the parsed model via turn.record(): Turn.end() re-emits gen_ai.request.model from its internal field, so the raw attribute write was clobbered by the initial-request model - drop ATTR.AGENT_VERSION / ATTR.OUTPUT_TYPE (the SDK emits both natively; no remaining reader) and the exported one-use permission-event arg interfaces; kill a let-reassign in cmdConfig Co-Authored-By: Claude Fable 5 --- src/cli.ts | 9 ++----- src/daemon.ts | 65 +++++++++++++++++++---------------------------- src/genaiSpans.ts | 24 +++++------------ tests/helpers.ts | 4 ++- 4 files changed, 37 insertions(+), 65 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f39b0da..461e5f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -337,18 +337,13 @@ async function cmdConfig(args: string[]): Promise { // `debug` is the only boolean Settings field; every other writable key is a // string. Split the assignment so each branch's value type matches the // narrowed property type (no whole-object cast needed). - let coerced: string | boolean; if (writableKey === 'debug') { - coerced = value === 'true'; - settings.debug = coerced; + settings.debug = value === 'true'; } else { - coerced = value; settings[writableKey] = value; } saveSettings(settings); - const displayValue = writableKey === 'wandb_api_key' && typeof coerced === 'string' - ? maskSecret(coerced) - : coerced; + const displayValue = writableKey === 'wandb_api_key' ? maskSecret(value) : value; console.log(`✓ Set ${key} = ${displayValue}`); return; } diff --git a/src/daemon.ts b/src/daemon.ts index bff2449..4a3807b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -37,6 +37,7 @@ import { jsonStr, } from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint } from './config.js'; +import type { DaemonConfig } from './config.js'; import { chatMessageKey, callsForResponseKey, @@ -147,20 +148,16 @@ export class GlobalDaemon { constructor( private readonly socketPath: string, private readonly logFile: string, - private readonly weaveProject: string | null, - private readonly apiKey: string | null, - private readonly baseUrl: string, - private readonly debugEnabled: boolean, - private readonly agentName: string, + private readonly config: DaemonConfig, ) {} async start(): Promise { // Initialize the Weave SDK if Weave is configured - if (this.weaveProject && this.apiKey) { + if (this.config.weaveProject && this.config.apiKey) { try { await this.initWeave(); - this.log('INFO', `OTel tracer initialized — project=${this.weaveProject}, endpoint=${this.baseUrl}/agents/otel/v1/traces`); - this.log('INFO', `View traces: https://wandb.ai/${this.weaveProject}/weave/agents`); + this.log('INFO', `OTel tracer initialized — project=${this.config.weaveProject}, endpoint=${this.config.baseUrl}/agents/otel/v1/traces`); + this.log('INFO', `View traces: https://wandb.ai/${this.config.weaveProject}/weave/agents`); } catch (err) { this.log('ERROR', `Failed to initialize OTel tracer: ${err} — continuing without tracing`); this.tracingEnabled = false; @@ -259,12 +256,12 @@ export class GlobalDaemon { // ── tracer initialization ─────────────────────────────────────────────── private async initWeave(): Promise { - if (!this.weaveProject) throw new Error('weaveProject required to init tracer'); - if (!this.apiKey) throw new Error('apiKey required to init tracer'); + if (!this.config.weaveProject) throw new Error('weaveProject required to init tracer'); + if (!this.config.apiKey) throw new Error('apiKey required to init tracer'); - const [entity, project] = this.weaveProject.split('/', 2); + const [entity, project] = this.config.weaveProject.split('/', 2); if (!entity || !project) { - throw new Error(`Invalid weave_project format: '${this.weaveProject}' (expected entity/project)`); + throw new Error(`Invalid weave_project format: '${this.config.weaveProject}' (expected entity/project)`); } // The Weave SDK has no programmatic apiKey/host in its Settings; it resolves @@ -273,10 +270,10 @@ export class GlobalDaemon { // the OTLP exporter straight at our trace server; WANDB_API_KEY supplies the // auth header. We deliberately do NOT set WANDB_BASE_URL (weave treats that // as the API host and would derive a wrong trace URL from it). - process.env['WF_TRACE_SERVER_URL'] = this.baseUrl; - process.env['WANDB_API_KEY'] = this.apiKey; + process.env['WF_TRACE_SERVER_URL'] = this.config.baseUrl; + process.env['WANDB_API_KEY'] = this.config.apiKey; - await weave.init(this.weaveProject); + await weave.init(this.config.weaveProject); this.tracingEnabled = true; } @@ -335,7 +332,7 @@ export class GlobalDaemon { // actually running (pid + version + resolved entry script) rather than // just where the CLI symlink currently points. socket.end(JSON.stringify({ - config_hash: this.configFingerprint(), + config_hash: daemonConfigFingerprint(this.config), pid: process.pid, version: VERSION, path: daemonEntryPath(), @@ -476,7 +473,7 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: 0, - agentName: this.agentName, + agentName: this.config.agentName, tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); @@ -617,7 +614,7 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: priorTurns, - agentName: this.agentName, + agentName: this.config.agentName, tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); @@ -1553,15 +1550,17 @@ export class GlobalDaemon { if (assistantMessages.length) { turnAttrs[ATTR.OUTPUT_MESSAGES] = jsonStr(assistantMessages.map((m) => ({ role: 'assistant', content: m }))); } - // Aggregate finish reasons from per-call detail const finishReasons = currentTurn?.assistantCalls().map(c => c.finishReason).filter((r): r is string => !!r); if (finishReasons?.length) { turnAttrs[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; } + session.currentTurn.setAttributes(turnAttrs); + // Through record(), not setAttributes: Turn.end() re-emits + // gen_ai.request.model from its internal field, so a raw attribute write + // of the parsed model would be clobbered by the initial-request model. if (model) { - turnAttrs[ATTR.REQUEST_MODEL] = model; + session.currentTurn.record({ model }); } - session.currentTurn.setAttributes(turnAttrs); session.currentTurn.end(); session.currentTurn = undefined; @@ -1815,20 +1814,8 @@ export class GlobalDaemon { return 'tool_error'; } - /** Fingerprint of the config this daemon loaded at startup. Held in memory; - * replied over the socket for the `config-hash` control message. */ - private configFingerprint(): string { - return daemonConfigFingerprint({ - weaveProject: this.weaveProject, - apiKey: this.apiKey, - baseUrl: this.baseUrl, - agentName: this.agentName, - debug: this.debugEnabled, - }); - } - private log(level: 'DEBUG' | 'INFO' | 'ERROR', msg: string): void { - if (level === 'DEBUG' && !this.debugEnabled) return; + if (level === 'DEBUG' && !this.config.debug) return; appendToLog(this.logFile, level, msg); } } @@ -1844,18 +1831,18 @@ export async function runDaemon(): Promise { fs.mkdirSync(path.dirname(logFile), { recursive: true }); - const { weaveProject, apiKey, baseUrl, agentName, debug } = resolveDaemonConfig(settings, process.env); + const config = resolveDaemonConfig(settings, process.env); - if (!weaveProject || !apiKey) { - const missing = [!weaveProject && 'weave_project', !apiKey && 'WANDB_API_KEY'].filter(Boolean).join(', '); + if (!config.weaveProject || !config.apiKey) { + const missing = [!config.weaveProject && 'weave_project', !config.apiKey && 'WANDB_API_KEY'].filter(Boolean).join(', '); appendToLog(logFile, 'INFO', `Daemon not started — missing configuration: ${missing}`); process.exit(0); } // Ensure downstream tooling (e.g. wandb settings) still sees the API key. - process.env['WANDB_API_KEY'] = apiKey; + process.env['WANDB_API_KEY'] = config.apiKey; - const daemon = new GlobalDaemon(socketPath, logFile, weaveProject, apiKey, baseUrl, debug, agentName); + const daemon = new GlobalDaemon(socketPath, logFile, config); try { await daemon.start(); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index fb5d45e..f38b26e 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -2,10 +2,10 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// After the Weave SDK migration this module holds constants, formatting -// helpers, and thin span-shaping helpers typed against the `weave` SDK. All -// span construction/lifecycle lives in daemon.ts via -// `weave.startConversation/.startTurn/.startLLM/.startTool/.startSubagent`. +// Attribute-key constants, formatting helpers, and thin span-shaping helpers +// typed against the `weave` SDK. Span construction/lifecycle lives with the +// SDK handles: conversations start in sessionState.ts, turns/tools/subagents +// in daemon.ts, and chat (LLM) spans via chatSpans.ts. import type { Attributes } from '@opentelemetry/api'; import type { MessagePart, Tool, Turn, Usage } from 'weave'; @@ -29,7 +29,6 @@ export const ATTR = { // GenAI semconv - agent AGENT_NAME: 'gen_ai.agent.name', AGENT_ID: 'gen_ai.agent.id', - AGENT_VERSION: 'gen_ai.agent.version', CONVERSATION_ID: 'gen_ai.conversation.id', // GenAI semconv - model @@ -47,7 +46,6 @@ export const ATTR = { // GenAI semconv - messages INPUT_MESSAGES: 'gen_ai.input.messages', OUTPUT_MESSAGES: 'gen_ai.output.messages', - OUTPUT_TYPE: 'gen_ai.output.type', SYSTEM_INSTRUCTIONS: 'gen_ai.system_instructions', // GenAI semconv - errors @@ -235,13 +233,8 @@ export function buildUsage(usage: UsageSummary, reasoningTokens?: number): Usage // Span events // ───────────────────────────────────────────────────────────────────────────── -export interface PermissionRequestEventArgs { - suggestions?: unknown; - timestamp: Date; -} - /** Added at PermissionRequest time. Records that the request happened. */ -export function addPermissionRequestEvent(tool: Tool, args: PermissionRequestEventArgs): void { +export function addPermissionRequestEvent(tool: Tool, args: { suggestions?: unknown; timestamp: Date }): void { const attrs: Attributes = {}; if (args.suggestions !== undefined) { attrs[ATTR.EVT_PERMISSION_SUGGESTIONS] = jsonStr(args.suggestions); @@ -249,13 +242,8 @@ export function addPermissionRequestEvent(tool: Tool, args: PermissionRequestEve tool.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); } -export interface PermissionResolvedEventArgs { - approved: boolean; - timestamp: Date; -} - /** Added at PostToolUse[Failure]. Records the request outcome. */ -export function addPermissionResolvedEvent(tool: Tool, args: PermissionResolvedEventArgs): void { +export function addPermissionResolvedEvent(tool: Tool, args: { approved: boolean; timestamp: Date }): void { tool.addEvent( ATTR.EVT_PERMISSION_RESOLVED, { [ATTR.EVT_PERMISSION_APPROVED]: args.approved }, diff --git a/tests/helpers.ts b/tests/helpers.ts index 603637a..e76e477 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -146,7 +146,9 @@ export async function initWeaveInMemory(): Promise { * initialised via `initWeaveInMemory`), skipping the real socket/`start()`. */ export function makeGenaiDaemon(agentName = 'claude-code'): GlobalDaemon { const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused.sock', logFile, 'e/p', 'k', 'https://x', false, agentName); + const d = new GlobalDaemon('/tmp/unused.sock', logFile, { + weaveProject: 'e/p', apiKey: 'k', baseUrl: 'https://x', agentName, debug: false, + }); (d as unknown as { tracingEnabled: boolean }).tracingEnabled = true; return d; } From 1e4621a9036e7085cf2b0fcf9572052f0e507d36 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 18:38:47 -0700 Subject: [PATCH 09/13] fix(daemon): survive interrupted turns and correct multi-emission paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the migration surfaced a family of state-machine bugs around turns that end without a Stop hook (user interrupt): - a stale activeChat response key, finalized against the next turn's parse, produced an empty call group and crashed recordChat's group.at(-1)! — killing tool tracing for the rest of the session and, via an unguarded finalizeSession call in drain(), aborting shutdown before the final flush. emitChatSpanForResponse now bare-closes on an empty group, and drain isolates per-session finalize errors - the next UserPromptSubmit overwrote the still-open turn handle, leaking the root span unexported (rootless trace). finalizeOpenTurn (extracted from finalizeSession, now also closing the turn's dead pending tools) closes it as superseded_by_next_prompt first - handleStop left activeChat set when the transcript parse never caught up, leaking the chat span and priming the stale-key crash Multi-emission and telemetry-shape fixes: - teammate final-turn chats were emitted twice (SubagentStop under the marker + TeammateIdle under the fresh turn) — double-counting usage - emitChatSpans emitted one chat span per transcript LINE; split lines sharing a message.id duplicated the response's usage N-fold. It now groups by response key like the live main-agent path - teammate turns are backdated to span their transcript (children no longer start before the parent), close in a finally, and reuse the coordinator's Conversation handle (TeamMember drops its hand-copied conversationId + integrationAttrs) - drain's team backstop stamps orphan_reason + error instead of closing crashed teammates as clean successes - OTel diag warnings/errors now land in the daemon log (exporter failures were silent — the #113 lesson) Cleanups from the same review: missingConfig moved to config.ts (boolean args, shared with runDaemon), resolveProject/resolveApiKey deduped, dead ATTR keys / Turn.totalUsage() / over-exports dropped, instruction capture gated on tracing, redundant tracingEnabled conjuncts removed, tests share one DaemonDriver seam + transcript-line builders, and a new interrupted-turn regression test. Co-Authored-By: Claude Fable 5 --- src/chatSpans.ts | 4 +- src/cli.ts | 18 +- src/config.ts | 53 +++-- src/daemon.ts | 200 +++++++++++------- src/genaiSpans.ts | 1 - src/parser.ts | 16 -- src/sessionState.ts | 30 +-- tests/daemon-shutdown-finalizes-turn.test.ts | 11 +- tests/daemon-subagent-recovery.test.ts | 35 ++- tests/genai-span-usage-tokens.test.ts | 6 +- tests/helpers.ts | 45 +++- tests/interleave-handlers.test.ts | 10 +- tests/interleave-split-lines.test.ts | 6 +- tests/interrupted-turn.test.ts | 89 ++++++++ tests/subagent-nesting.test.ts | 29 ++- tests/system-instructions-integration.test.ts | 17 +- tests/teammate-idle.test.ts | 6 +- tests/turn-span-agent-name.test.ts | 6 +- tests/turn-span-integration.test.ts | 6 +- 19 files changed, 354 insertions(+), 234 deletions(-) create mode 100644 tests/interrupted-turn.test.ts diff --git a/src/chatSpans.ts b/src/chatSpans.ts index 9782f07..884d2f4 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -69,10 +69,10 @@ export function startChat(parent: weave.Turn | weave.SubAgent, model: string, st * model yet (LLMInit.model is required), so the caller can fall back to the turn * span and emit the chat span later once the model has flushed. */ -export function openChatForGroup(turn: weave.Turn, group: AssistantCallDetail[]): weave.LLM | undefined { +export function openChatForGroup(parent: weave.Turn | weave.SubAgent, group: AssistantCallDetail[]): weave.LLM | undefined { const model = group.map(c => c.model).find(Boolean); if (!model) return undefined; - return startChat(turn, model, parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp)); + return startChat(parent, model, parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp)); } /** diff --git a/src/cli.ts b/src/cli.ts index 461e5f6..673b8ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,6 +34,7 @@ import { resolveAgentName, resolveDaemonConfig, daemonConfigFingerprint, + missingConfig, WeaveProjectSource, ApiKeySource, } from './config.js'; @@ -228,15 +229,6 @@ function maskSecret(value: string): string { return `${value.slice(0, 4)}…`; } -/** - * Render the comma-joined list of missing required config for the "incomplete" - * status/restart messages. `apiKeyLabel` differs by call site (`wandb_api_key` - * for the config-oriented message, `WANDB_API_KEY` for the env-oriented one). - */ -function missingConfig(project: string | null, apiKey: string | null, apiKeyLabel: string): string { - return [!project && 'weave_project', !apiKey && apiKeyLabel].filter(Boolean).join(', '); -} - async function cmdConfig(args: string[]): Promise { const action = args[0]; @@ -555,11 +547,7 @@ function printPrettyStatus(snap: StatusSnapshot): void { } else if (socketState === SocketState.Stale) { console.log('Weave Claude Code — daemon socket stale (auto-recovers next session)'); } else { - const missing = missingConfig( - report.weave_project, - report.api_key_configured ? 'set' : null, - 'wandb_api_key', - ); + const missing = missingConfig(!!report.weave_project, report.api_key_configured, 'wandb_api_key'); console.log('Weave Claude Code — configuration incomplete'); if (missing) console.log(` Set ${missing} to start tracing`); } @@ -809,7 +797,7 @@ async function cmdRestart(): Promise { const project = resolveProject(settings).value; const apiKey = resolveApiKey(settings).value; if (!project || !apiKey) { - const missing = missingConfig(project, apiKey, 'WANDB_API_KEY'); + const missing = missingConfig(!!project, !!apiKey, 'WANDB_API_KEY'); console.error(`⚠ Not starting daemon, missing configuration: ${missing}`); console.error(' Set it with: weave-claude-code config set weave_project ENTITY/PROJECT'); process.exit(1); diff --git a/src/config.ts b/src/config.ts index 3fc4102..ec29878 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,19 +30,30 @@ export enum AgentNameSource { Default = 'default', } +/** Env-over-settings resolution shared by the project and API-key resolvers: + * a non-empty env value wins, then a non-empty settings value, else null. + * `sources` supplies the per-field labels for the matching branch. */ +function resolveFromEnvOrSettings( + envValue: string | undefined, + settingsValue: string | null | undefined, + sources: { env: S; settings: S; notSet: S }, +): { value: string | null; source: S } { + if (envValue) return { value: envValue, source: sources.env }; + if (settingsValue) return { value: settingsValue, source: sources.settings }; + return { value: null, source: sources.notSet }; +} + /** Resolve the effective Weave project (WEAVE_PROJECT env beats * settings.weave_project) and where it came from. */ export function resolveProject( settings: Settings, env: NodeJS.ProcessEnv = process.env, ): { value: string | null; source: WeaveProjectSource } { - const value = env['WEAVE_PROJECT'] ?? settings.weave_project ?? null; - const source = env['WEAVE_PROJECT'] - ? WeaveProjectSource.EnvVar - : settings.weave_project - ? WeaveProjectSource.Settings - : WeaveProjectSource.NotSet; - return { value, source }; + return resolveFromEnvOrSettings(env['WEAVE_PROJECT'], settings.weave_project, { + env: WeaveProjectSource.EnvVar, + settings: WeaveProjectSource.Settings, + notSet: WeaveProjectSource.NotSet, + }); } /** Resolve the effective W&B API key (WANDB_API_KEY env beats @@ -51,13 +62,11 @@ export function resolveApiKey( settings: Settings, env: NodeJS.ProcessEnv = process.env, ): { value: string | null; source: ApiKeySource } { - const value = env['WANDB_API_KEY'] ?? settings.wandb_api_key ?? null; - const source = env['WANDB_API_KEY'] - ? ApiKeySource.EnvVar - : settings.wandb_api_key - ? ApiKeySource.Settings - : ApiKeySource.NotSet; - return { value, source }; + return resolveFromEnvOrSettings(env['WANDB_API_KEY'], settings.wandb_api_key, { + env: ApiKeySource.EnvVar, + settings: ApiKeySource.Settings, + notSet: ApiKeySource.NotSet, + }); } /** Resolve the effective top-level agent name (WEAVE_AGENT_NAME env beats @@ -94,6 +103,10 @@ export function resolveDaemonConfig(settings: Settings, env: NodeJS.ProcessEnv): }; } +/** SaaS trace-ingest host: the default OTLP target, and what the routeless + * SaaS API host remaps to. */ +const DEFAULT_TRACE_BASE_URL = 'https://trace.wandb.ai'; + /** Resolve the Weave trace server base URL for OTLP export. `WF_TRACE_SERVER_URL` * wins when set. Otherwise `WANDB_BASE_URL` is used, but SaaS `api.wandb.ai` is * the wandb API host with no OTLP route, so it maps to `trace.wandb.ai`; a @@ -101,8 +114,16 @@ export function resolveDaemonConfig(settings: Settings, env: NodeJS.ProcessEnv): function resolveTraceBaseUrl(env: NodeJS.ProcessEnv): string { const explicit = env['WF_TRACE_SERVER_URL']?.trim(); if (explicit) return explicit.replace(/\/+$/, ''); - const base = (env['WANDB_BASE_URL'] ?? 'https://trace.wandb.ai').replace(/\/+$/, ''); - return /^https?:\/\/api\.wandb\.ai$/i.test(base) ? 'https://trace.wandb.ai' : base; + const base = (env['WANDB_BASE_URL'] ?? DEFAULT_TRACE_BASE_URL).replace(/\/+$/, ''); + return /^https?:\/\/api\.wandb\.ai$/i.test(base) ? DEFAULT_TRACE_BASE_URL : base; +} + +/** Comma-joined list of missing required config, for the "incomplete" + * status/startup messages. `apiKeyLabel` differs by call site + * (`wandb_api_key` for config-oriented messages, `WANDB_API_KEY` for + * env-oriented ones). */ +export function missingConfig(hasProject: boolean, hasApiKey: boolean, apiKeyLabel: string): string { + return [!hasProject && 'weave_project', !hasApiKey && apiKeyLabel].filter(Boolean).join(', '); } /** Hex chars kept from the config hash. 16 (64 bits) is ample to detect a diff --git a/src/daemon.ts b/src/daemon.ts index 4a3807b..51cd323 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,6 +5,7 @@ import * as net from 'net'; import * as fs from 'fs'; import * as path from 'path'; +import { diag, DiagLogLevel } from '@opentelemetry/api'; import type { Attributes } from '@opentelemetry/api'; import type { HookInput, @@ -36,7 +37,7 @@ import { snippet, jsonStr, } from './genaiSpans.js'; -import { resolveDaemonConfig, daemonConfigFingerprint } from './config.js'; +import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; import type { DaemonConfig } from './config.js'; import { chatMessageKey, @@ -273,6 +274,16 @@ export class GlobalDaemon { process.env['WF_TRACE_SERVER_URL'] = this.config.baseUrl; process.env['WANDB_API_KEY'] = this.config.apiKey; + // Route OTel's internal warnings/errors into the daemon log. The batch + // exporter fails silently otherwise (a bad key or unreachable trace host + // drops every span with nothing logged anywhere). + const otelDiag = (message: string, ...args: unknown[]) => + this.log('ERROR', `otel: ${message}${args.length ? ` ${args.map(String).join(' ')}` : ''}`); + diag.setLogger( + { verbose: otelDiag, debug: otelDiag, info: otelDiag, warn: otelDiag, error: otelDiag }, + DiagLogLevel.WARN, + ); + await weave.init(this.config.weaveProject); this.tracingEnabled = true; } @@ -645,6 +656,9 @@ export class GlobalDaemon { * (re)load afterward (e.g. load_reason=compact). */ private handleInstructionsLoaded(sessionId: string, input: InstructionsLoadedHookInput): void { + // Without tracing there is no turn to stamp these on — skip the file reads + // rather than buffer content that nothing will ever consume. + if (!this.tracingEnabled) return; const filePath = input.file_path; let content: string; try { @@ -726,6 +740,11 @@ export class GlobalDaemon { `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${snippet(prompt, 120)}`, ); + // A user interrupt ends a turn with no Stop hook, so the previous turn (and + // its chat span) can still be open here. Close it as superseded before the + // new turn overwrites the handle, or its root span would never export. + this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); + session.turnNumber += 1; session.turnToolCalls = 0; session.emittedChatSpanResponseKeys.clear(); @@ -791,7 +810,7 @@ export class GlobalDaemon { promptHash: hashPrompt(prompt), teamName, }); - if (teamName) { + if (teamName && session.conversation) { // Append to the per-key FIFO queue (do NOT overwrite): the same // `${team}::${name}` may be spawned again later in the run (e.g. TARS // re-spawns a specialist Sonnet→Opus). Overwriting would orphan the @@ -800,9 +819,8 @@ export class GlobalDaemon { const queue = this.teamMembers.get(key) ?? []; queue.push({ subAgent, - conversationId: session.conversationId, + conversation: session.conversation, coordinatorTranscriptPath: session.transcript.resolvedPath, - integrationAttrs: session.integrationAttrs, emitted: false, }); this.teamMembers.set(key, queue); @@ -855,7 +873,7 @@ export class GlobalDaemon { * yet.) */ private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { - if (!this.tracingEnabled || !session.currentTurn) return undefined; + if (!session.currentTurn) return undefined; // Re-parses the whole transcript per main-agent PreToolUse: O(size) per // tool call. Off CC's critical path (async daemon), so no editor latency; @@ -922,15 +940,22 @@ export class GlobalDaemon { key: string, existingLlm?: weave.LLM, ): void { - if (!this.tracingEnabled || !session.currentTurn) return; - // `key` always comes from a real call (findToolUseResponseKey / - // chatMessageKey) and transcripts are append-only, so the group is never - // empty. + if (!session.currentTurn) return; const group = callsForResponseKey(calls, key); + // Empty group: `key` is stale relative to `calls` — an interrupted turn's + // activeChat finalized against the next turn's parse. Close the span bare + // rather than fabricate content (or throw on group.at(-1)). + if (!group.length) { + existingLlm?.end(); + return; + } // A response with no model yet can't open a chat span (LLMInit.model is // required); skip it rather than guess a model. const llm = existingLlm ?? openChatForGroup(session.currentTurn, group); - if (!llm) return; + if (!llm) { + this.log('DEBUG', `Chat span skipped (no model flushed for response ${key}); usage not recorded`); + return; + } recordChat(llm, group); session.emittedChatSpanResponseKeys.add(key); } @@ -1225,7 +1250,10 @@ export class GlobalDaemon { model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); - if (lastTurn) { + // pendingTeammateIdle: TeammateIdle will emit the FULL transcript as + // the teammate's own turn trace; emitting the last turn here too would + // double-count its chat spans (and their token usage). + if (lastTurn && !tracker.pendingTeammateIdle) { this.emitChatSpans(chatParent, lastTurn.assistantCalls(), tracker.subagentType); } } catch (err) { @@ -1309,13 +1337,7 @@ export class GlobalDaemon { member.emitted = true; const idleTranscript = session?.transcript.resolvedPath ?? input.transcript_path; const teammateTranscriptPath = this.resolveTeammateTranscript(member.coordinatorTranscriptPath, agentType, idleTranscript); - this.emitTeammateTurnTrace( - member.subAgent, - member.conversationId, - member.integrationAttrs, - agentType, - teammateTranscriptPath, - ); + this.emitTeammateTurnTrace(member.subAgent, member.conversation, agentType, teammateTranscriptPath); // Remove the consumed entry; drop the key once its queue drains. const idx = queue.indexOf(member); if (idx >= 0) queue.splice(idx, 1); @@ -1355,13 +1377,8 @@ export class GlobalDaemon { // Emit ALL turns from the teammate transcript under a fresh teammate turn // trace (the coordinator turn that spawned it has already closed). Teammates // are independent top-level sessions: every turn is their own work. - const model = this.emitTeammateTurnTrace( - tracker.subAgent, - session.conversationId, - session.integrationAttrs, - agentType, - transcriptPath, - ); + if (!session.conversation) return; + const model = this.emitTeammateTurnTrace(tracker.subAgent, session.conversation, agentType, transcriptPath); tracker.ended = true; session.subagents.remove(tracker); @@ -1404,20 +1421,27 @@ export class GlobalDaemon { } /** - * Emit one chat span (LLM) per assistant call under `parent`, reconstructed - * from transcript data (backdated times, usage, ordered output parts). - * `agentName` tags each span so a subagent's/teammate's calls stay queryable - * by agent; conversation.id is inherited from the parent handle chain. + * Emit one chat span (LLM) per assistant API response under `parent`, + * reconstructed from transcript data (backdated times, usage, ordered output + * parts). Split transcript lines sharing a `message.id` are grouped into one + * span — matching the live main-agent path — so a response's usage is never + * multiply counted. `agentName` tags each span so a subagent's/teammate's + * calls stay queryable by agent; conversation.id is inherited from the parent + * handle chain. */ private emitChatSpans( parent: weave.Turn | weave.SubAgent, calls: AssistantCallDetail[], agentName?: string, ): void { - for (const c of calls) { - if (!c.model) continue; - const llm = startChat(parent, c.model, parseIsoOrNow(c.prevTimestamp ?? c.timestamp)); - recordChat(llm, [c], agentName); + const emitted = new Set(); + for (let i = 0; i < calls.length; i++) { + const key = chatMessageKey(calls[i], i); + if (emitted.has(key)) continue; + emitted.add(key); + const group = callsForResponseKey(calls, key); + const llm = openChatForGroup(parent, group); + if (llm) recordChat(llm, group, agentName); } } @@ -1425,16 +1449,16 @@ export class GlobalDaemon { * Emit a teammate's whole transcript as its OWN turn trace, then close the * teammate's SubAgent marker. TeammateIdle fires after the coordinator turn * that spawned the teammate has already closed, so the teammate can't nest - * under it; instead it gets a fresh root `invoke_agent` turn with the - * teammate's chat spans as children. A dedicated Conversation handle seeds - * the coordinator's conversation.id and integration identity (neither is - * inherited cross-session) onto that whole subtree. Returns the teammate's - * model, if known. + * under it; instead it gets a fresh root `invoke_agent` turn started from + * the coordinator's Conversation handle, which seeds the conversation.id + * and integration identity (neither is inherited cross-session) onto the + * whole subtree. The turn is backdated to span the transcript's first + * request through its last response, so its backdated chat children stay + * inside the parent's time window. Returns the teammate's model, if known. */ private emitTeammateTurnTrace( subAgent: weave.SubAgent, - conversationId: string, - integrationAttrs: Attributes, + conversation: weave.Conversation, agentType: string, transcriptPath: string | undefined, ): string | undefined { @@ -1445,17 +1469,23 @@ export class GlobalDaemon { if (!transcriptPath) throw new Error('no teammate transcript path'); t = new TranscriptFile(transcriptPath); const parsed = parseSessionFd(t.getFd()); - if (parsed) { - const conversation = weave.startConversation({ - conversationId, + if (parsed?.turns.length) { + const allCalls = parsed.turns.flatMap((pt) => pt.assistantCalls()); + const first = allCalls[0]; + const turn = conversation.startTurn({ agentName: agentType, - attributes: integrationAttrs, + agentVersion: VERSION, + startTime: parseIsoOrNow(first?.prevTimestamp ?? first?.timestamp), }); - const turn = conversation.startTurn({ agentVersion: VERSION, startTime: new Date() }); - for (const parsedTurn of parsed.turns) { - this.emitChatSpans(turn, parsedTurn.assistantCalls(), agentType); + turn.setAttributes({ [ATTR.WEAVE_DISPLAY_NAME]: `Teammate: ${agentType}` }); + try { + for (const parsedTurn of parsed.turns) { + this.emitChatSpans(turn, parsedTurn.assistantCalls(), agentType); + } + } finally { + // In a finally so a mid-emit throw can't leak the root un-exported. + turn.end({ endTime: parseIsoOrNow(allCalls.at(-1)?.timestamp) }); } - turn.end(); const lastTurn = parsed.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); @@ -1505,7 +1535,7 @@ export class GlobalDaemon { private async handleStop(sessionId: string, input: StopHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session?.currentTurn || !this.tracingEnabled) return; + if (!session?.currentTurn) return; // Pass last_assistant_message so the retry waits for the synthesis to // flush; otherwise the final chat span drops when the read races the writer. @@ -1540,6 +1570,11 @@ export class GlobalDaemon { if (session.emittedChatSpanResponseKeys.has(key)) continue; this.emitChatSpanForResponse(session, calls, key); } + } else if (session.activeChat) { + // Parse failed (retry budget exhausted): close the chat span bare rather + // than leak it un-ended and leave a stale key for the next turn. + session.activeChat.llm.end(); + session.activeChat = undefined; } const parsedTexts = currentTurn?.textBlocks() ?? []; @@ -1603,18 +1638,40 @@ export class GlobalDaemon { * per span — each builder ends at most once. */ private finalizeSession(session: SessionState, orphanReason: string): void { - // Close any pending tool calls that were never completed + this.finalizeOpenTurn(session, orphanReason); + + // Close any subagent invoke_agent spans that didn't receive PostToolUse + // or SubagentStop. Without this they'd leak open and never export. + for (const tracker of session.subagents.all()) { + if (tracker.subAgent && !tracker.ended) { + tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + tracker.subAgent.end({ error: new Error('subagent did not complete before shutdown') }); + tracker.ended = true; + } + this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); + } + } + + /** + * Close everything still open on the current turn — pending tool calls, the + * active chat span, and the turn (root) span itself — stamping + * `orphanReason`. The chat span is finalized from the now-flushed + * transcript, like Stop does, so its output + usage aren't lost; only a + * failed parse falls back to a bare orphan close. Called from + * `finalizeSession` and from `handleUserPromptSubmit` when a user interrupt + * ended the previous turn without a Stop hook — the interrupt also kills + * in-flight tools (no PostToolUse will follow), and without this, opening + * the next turn would overwrite the handle and leak the root unexported. + */ + private finalizeOpenTurn(session: SessionState, orphanReason: string): void { for (const [toolUseId, pending] of session.pendingToolCalls) { resolvePermissionIfPending(pending, false); pending.tool.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); - pending.tool.end({ error: new Error('tool did not complete before shutdown') }); + pending.tool.end({ error: new Error(`tool did not complete (${orphanReason})`) }); this.log('DEBUG', `Closed orphaned tool span: ${toolUseId} (${pending.toolName})`); } session.pendingToolCalls.clear(); - // Finalize a chat span left open mid-turn (Stop never fired) from the - // now-flushed transcript, like Stop does, so its output + usage aren't lost. - // Bare orphan close only if the parse fails or the turn span is gone. if (session.activeChat) { let finalized = false; if (session.currentTurn) { @@ -1638,23 +1695,11 @@ export class GlobalDaemon { this.log('DEBUG', finalized ? `Finalized active chat span` : `Closed orphaned chat span`); } - // Close the current turn (root) span if still open if (session.currentTurn) { session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); session.currentTurn.end(); session.currentTurn = undefined; - this.log('DEBUG', `Closed orphaned turn span`); - } - - // Close any subagent invoke_agent spans that didn't receive PostToolUse - // or SubagentStop. Without this they'd leak open and never export. - for (const tracker of session.subagents.all()) { - if (tracker.subAgent && !tracker.ended) { - tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); - tracker.subAgent.end({ error: new Error('subagent did not complete before shutdown') }); - tracker.ended = true; - } - this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); + this.log('DEBUG', `Closed orphaned turn span (${orphanReason})`); } } @@ -1731,17 +1776,27 @@ export class GlobalDaemon { this.server?.close(); // Backstop: close any queued team-member invoke_agent spans whose teammate // never emitted a TeammateIdle (e.g. teammate crashed, or daemon exits - // mid-triage) so they flush as ended spans instead of leaking. + // mid-triage) so they flush as ended spans — marked orphaned, matching + // finalizeSession's subagent close — instead of leaking. for (const [, queue] of this.teamMembers) { for (const m of queue) { - if (!m.emitted) { try { m.subAgent.end(); } catch { /* best effort */ } } + if (m.emitted) continue; + try { + m.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: 'daemon_shutdown' }); + m.subAgent.end({ error: new Error('teammate did not complete before shutdown') }); + } catch { /* best effort */ } } } this.teamMembers.clear(); // Finalize every live session's still-open spans (turn root, active chat, // pending tools, subagents) so an interrupted turn keeps its exported root. + // Per-session try: one bad session must not abort the flush below. for (const session of this.sessions.values()) { - this.finalizeSession(session, 'daemon_shutdown'); + try { + this.finalizeSession(session, 'daemon_shutdown'); + } catch (err) { + this.log('ERROR', `Error finalizing session ${session.sessionId} at shutdown: ${err}`); + } } if (this.tracingEnabled) { try { @@ -1834,14 +1889,11 @@ export async function runDaemon(): Promise { const config = resolveDaemonConfig(settings, process.env); if (!config.weaveProject || !config.apiKey) { - const missing = [!config.weaveProject && 'weave_project', !config.apiKey && 'WANDB_API_KEY'].filter(Boolean).join(', '); + const missing = missingConfig(!!config.weaveProject, !!config.apiKey, 'WANDB_API_KEY'); appendToLog(logFile, 'INFO', `Daemon not started — missing configuration: ${missing}`); process.exit(0); } - // Ensure downstream tooling (e.g. wandb settings) still sees the API key. - process.env['WANDB_API_KEY'] = config.apiKey; - const daemon = new GlobalDaemon(socketPath, logFile, config); try { diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index f38b26e..d2d0c6a 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -32,7 +32,6 @@ export const ATTR = { CONVERSATION_ID: 'gen_ai.conversation.id', // GenAI semconv - model - REQUEST_MODEL: 'gen_ai.request.model', RESPONSE_MODEL: 'gen_ai.response.model', RESPONSE_ID: 'gen_ai.response.id', RESPONSE_FINISH_REASONS: 'gen_ai.response.finish_reasons', diff --git a/src/parser.ts b/src/parser.ts index b27e49a..2956795 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -28,7 +28,6 @@ export interface AssistantCallDetail { } export interface Turn { - totalUsage(): UsageSummary; primaryModel(): string | undefined; textBlocks(): string[]; assistantCalls(): AssistantCallDetail[]; @@ -47,15 +46,6 @@ function rawToUsageSummary(raw: Record): UsageSummary { }; } -function addUsage(a: UsageSummary, b: UsageSummary): UsageSummary { - return { - input_tokens: a.input_tokens + b.input_tokens, - output_tokens: a.output_tokens + b.output_tokens, - cache_read_input_tokens: (a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0), - cache_creation_input_tokens: (a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0), - }; -} - export function parseSessionFile(filePath: string): ParsedSession | null { return parseSessionReader(() => fs.readFileSync(filePath, 'utf8')); } @@ -167,17 +157,11 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { }; }); - const totalUsageValue = calls.reduce( - (acc, call) => addUsage(acc, call.usage), - { input_tokens: 0, output_tokens: 0 }, - ); - const model = calls.filter(call => call.model).at(-1)?.model; const texts = calls.flatMap(call => extractAssistantTextBlocks(call.contentBlocks)); return { - totalUsage: () => totalUsageValue, primaryModel: () => model, textBlocks: () => texts, assistantCalls: () => calls, diff --git a/src/sessionState.ts b/src/sessionState.ts index 413a961..289d117 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -4,7 +4,6 @@ import * as path from 'path'; import { createHash } from 'crypto'; -import type { Attributes } from '@opentelemetry/api'; import * as weave from 'weave'; import { VERSION } from './setup.js'; import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; @@ -27,7 +26,7 @@ export type PendingToolCall = { * ordered `gen_ai.output.messages` parts on this span, set when it is * finalized (at the next response transition or at Stop), once all its split * transcript lines are present. */ -export type ActiveChat = { +type ActiveChat = { /** Response key (Anthropic `message.id`, or index fallback) this chat span * represents; see `chatMessageKey`. */ responseKey: string; @@ -186,12 +185,11 @@ export type SubagentTracker = { * mirrors SubagentTracking.findPendingTeammateIdle for the per-session path. */ export type TeamMember = { subAgent: weave.SubAgent; - conversationId: string; + /** Coordinator's Conversation handle. The teammate's own turn trace starts + * from it so the coordinator's conversation.id and integration identity + * (which don't inherit cross-session) seed the teammate's span subtree. */ + conversation: weave.Conversation; coordinatorTranscriptPath: string; - /** Coordinator's integration identity, re-stamped on the teammate's own - * turn+chat spans (which are created cross-session, outside the - * coordinator's ambient conversation, so they don't inherit it). */ - integrationAttrs: Attributes; emitted: boolean; } @@ -206,18 +204,13 @@ export type SessionState = { cwd: string; source: string; initialRequestModel?: string; - /** Integration identity (name, version, meta.*), built once at SessionStart. - * Set as the conversation's attributes (the SDK forwards them down the - * handle chain onto every span) and kept here for the cross-session - * teammate path, which re-stamps them on spans created outside this - * session's conversation. */ - integrationAttrs: Attributes; /** The session's Conversation handle. Seeds `gen_ai.conversation.id`, the - * agent identity, and the integration attributes onto every turn started - * from it (and, via the handle chain, onto all child spans) — no ambient - * state involved, so events in separate `runIsolated` frames still inherit - * everything. Unset when tracing is disabled. */ + * agent identity, and the integration attributes (name, version, meta.*, + * built at session creation) onto every turn started from it — and, via + * the handle chain, onto all child spans. No ambient state involved, so + * events in separate `runIsolated` frames still inherit everything. Unset + * when tracing is disabled. */ conversation?: weave.Conversation; currentTurn?: weave.Turn; @@ -322,7 +315,7 @@ export class SubagentTracking { * for a brand-new session, or the number of turns already on disk when * reconstructing a session lost across a daemon restart (so the resumed turn * keeps counting up instead of resetting to 1). */ -export type NewSessionStateOptions = { +type NewSessionStateOptions = { sessionId: string; conversationId: string; transcript: TranscriptFile; @@ -363,7 +356,6 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { cwd, source, initialRequestModel, - integrationAttrs, conversation, turnNumber, totalToolCalls: 0, diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts index a479f55..ee3d26e 100644 --- a/tests/daemon-shutdown-finalizes-turn.test.ts +++ b/tests/daemon-shutdown-finalizes-turn.test.ts @@ -42,11 +42,6 @@ function makeTranscript(sessionId: string): { file: string; append: (line: unkno return { file, dir, append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n') }; } -interface Harness { - routeEvent(p: Record): Promise; - drain(reason: string): Promise; -} - /** Drive a session to a mid-turn state: turn open, one tool completed. */ async function openTurnWithOneCompletedTool(d: Harness, sid: string, append: (l: unknown) => void, file: string) { append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); @@ -63,7 +58,7 @@ test('daemon shutdown mid-turn exports the turn root span (children are not left exporter.reset(); const sid = 'sess-shutdown'; const { file, append, dir } = makeTranscript(sid); - const d = makeGenaiDaemon() as unknown as Harness; + const d = makeGenaiDaemon(); try { await openTurnWithOneCompletedTool(d, sid, append, file); @@ -94,7 +89,7 @@ test('daemon shutdown ends an open subagent invoke_agent span under the same tra exporter.reset(); const sid = 'sess-shutdown-subagent'; const { file, append, dir } = makeTranscript(sid); - const d = makeGenaiDaemon() as unknown as Harness; + const d = makeGenaiDaemon(); try { append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); @@ -127,7 +122,7 @@ test('SessionEnd still exports the turn root span after the finalize refactor', exporter.reset(); const sid = 'sess-sessionend'; const { file, append, dir } = makeTranscript(sid); - const d = makeGenaiDaemon() as unknown as Harness; + const d = makeGenaiDaemon(); try { await openTurnWithOneCompletedTool(d, sid, append, file); await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); diff --git a/tests/daemon-subagent-recovery.test.ts b/tests/daemon-subagent-recovery.test.ts index 70127c9..dc95dd0 100644 --- a/tests/daemon-subagent-recovery.test.ts +++ b/tests/daemon-subagent-recovery.test.ts @@ -12,21 +12,14 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; - -interface Driver { - routeEvent(p: Record): Promise; -} - -function userLine(text: string): string { - return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }); -} -function assistantLine(text: string, usage: Record): string { - return JSON.stringify({ - type: 'assistant', - message: { role: 'assistant', model: 'claude-opus-4-8', id: 'm1', usage, stop_reason: 'end_turn', content: [{ type: 'text', text }] }, - }); -} +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, +} from './helpers.ts'; test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_agent + chat with tokens', async () => { const exporter = await initWeaveInMemory(); @@ -37,14 +30,14 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a // Main transcript: the in-progress turn the subagent ran under, already on disk. const mainPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(mainPath, userLine('spawn a subagent') + '\n' + assistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); + fs.writeFileSync(mainPath, transcriptUserLine('spawn a subagent') + '\n' + transcriptAssistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); // Subagent transcript where the daemon derives it (agentId-based sibling dir). const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine('do the subtask') + '\n' + assistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); + fs.writeFileSync(subPath, transcriptUserLine('do the subtask') + '\n' + transcriptAssistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { // Fresh daemon that only sees the subagent's completion, not its start. await d.routeEvent({ @@ -95,12 +88,12 @@ test('recovery reuses an already-open turn span instead of creating a spurious s const agentId = 'b1234567890abcdef'; const mainPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(mainPath, userLine('start') + '\n'); + fs.writeFileSync(mainPath, transcriptUserLine('start') + '\n'); const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine('subtask') + '\n' + assistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); + fs.writeFileSync(subPath, transcriptUserLine('subtask') + '\n' + transcriptAssistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { // UserPromptSubmit reconstructs the session and opens a turn first; recovery // must nest under that existing turn, not create a second one. diff --git a/tests/genai-span-usage-tokens.test.ts b/tests/genai-span-usage-tokens.test.ts index 6920a2f..e82c368 100644 --- a/tests/genai-span-usage-tokens.test.ts +++ b/tests/genai-span-usage-tokens.test.ts @@ -28,10 +28,6 @@ import type { InMemorySpanExporter, ReadableSpan } from '@opentelemetry/sdk-trac import { ATTR } from '../src/genaiSpans.ts'; import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -interface Driver { - routeEvent(p: Record): Promise; -} - function aLine(id: string, ts: string, text: string, usage: Record) { return { type: 'assistant', @@ -53,7 +49,7 @@ async function chatSpanForUsage(exporter: InMemorySpanExporter, sid: string, usa JSON.stringify(userText('2026-01-01T00:00:00Z', 'do it')), JSON.stringify(aLine('msgA', '2026-01-01T00:00:01Z', 'all done', usage)), ].join('\n') + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); diff --git a/tests/helpers.ts b/tests/helpers.ts index e76e477..9511bf9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -142,15 +142,54 @@ export async function initWeaveInMemory(): Promise { return genaiExporter; } +/** The daemon surface the genai tests drive: the (private) routeEvent entry + * point production feeds from the socket, plus drain for shutdown tests. */ +export type DaemonDriver = { + routeEvent(p: Record): Promise; + drain(reason: string): Promise; +}; + /** Construct a GlobalDaemon with tracing marked enabled (the SDK is already - * initialised via `initWeaveInMemory`), skipping the real socket/`start()`. */ -export function makeGenaiDaemon(agentName = 'claude-code'): GlobalDaemon { + * initialised via `initWeaveInMemory`), skipping the real socket/`start()`, + * viewed through the {@link DaemonDriver} test seam. */ +export function makeGenaiDaemon(agentName = 'claude-code'): DaemonDriver { const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); const d = new GlobalDaemon('/tmp/unused.sock', logFile, { weaveProject: 'e/p', apiKey: 'k', baseUrl: 'https://x', agentName, debug: false, }); (d as unknown as { tracingEnabled: boolean }).tracingEnabled = true; - return d; + return d as unknown as DaemonDriver; +} + +/** One JSONL transcript line for a user text message. `version` mirrors the + * CC CLI version stamp real transcripts carry on their head line. */ +export function transcriptUserLine(text: string, opts: { version?: string; timestamp?: string } = {}): string { + return JSON.stringify({ + type: 'user', + ...(opts.version ? { version: opts.version } : {}), + ...(opts.timestamp ? { timestamp: opts.timestamp } : {}), + message: { role: 'user', content: [{ type: 'text', text }] }, + }); +} + +/** One JSONL transcript line for a single-text assistant response. */ +export function transcriptAssistantLine( + text: string, + usage: Record, + opts: { id?: string; model?: string; timestamp?: string } = {}, +): string { + return JSON.stringify({ + type: 'assistant', + ...(opts.timestamp ? { timestamp: opts.timestamp } : {}), + message: { + role: 'assistant', + id: opts.id ?? 'm1', + model: opts.model ?? 'claude-opus-4-8', + usage, + stop_reason: 'end_turn', + content: [{ type: 'text', text }], + }, + }); } /** Flush any spans buffered in the SDK so the in-memory exporter has them. */ diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts index 873daa5..6631ff6 100644 --- a/tests/interleave-handlers.test.ts +++ b/tests/interleave-handlers.test.ts @@ -21,10 +21,6 @@ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { ATTR } from '../src/genaiSpans.ts'; import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -interface Driver { - routeEvent(p: Record): Promise; -} - const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; function aLine(id: string, ts: string, block: Record, stop?: string) { @@ -74,7 +70,7 @@ test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool inte const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); @@ -126,7 +122,7 @@ test('handlers: a new response transitions and finalizes the previous chat span' const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do two things')); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do two things' }); @@ -169,7 +165,7 @@ test('handlers: SessionEnd finalizes a still-open chat span with its output + us const { file, append, dir } = makeTranscript(sid); append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); diff --git a/tests/interleave-split-lines.test.ts b/tests/interleave-split-lines.test.ts index 0ce6e24..f0ba825 100644 --- a/tests/interleave-split-lines.test.ts +++ b/tests/interleave-split-lines.test.ts @@ -26,10 +26,6 @@ import * as path from 'node:path'; import { ATTR } from '../src/genaiSpans.ts'; import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -interface Driver { - routeEvent(p: Record): Promise; -} - /** One assistant transcript line carrying a single content block, mirroring how * Claude Code splits a response. `usage` is the FULL message usage, duplicated * on every line of the same response (verified against real transcripts). */ @@ -75,7 +71,7 @@ test('reconstruction: split thinking/redacted_thinking/text/tool_use lines inter aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn'), ].map(l => JSON.stringify(l)).join('\n') + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do the thing' }); diff --git a/tests/interrupted-turn.test.ts b/tests/interrupted-turn.test.ts new file mode 100644 index 0000000..31676f8 --- /dev/null +++ b/tests/interrupted-turn.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// A user interrupt ends a turn WITHOUT a Stop hook, so the next +// UserPromptSubmit arrives with the previous turn (and possibly its chat span) +// still open. Regression coverage for two bugs in that window: +// 1. the open turn's handle was silently overwritten, leaking its root span +// un-exported (rootless trace); +// 2. the stale activeChat's response key, finalized against the NEXT turn's +// transcript, produced an empty call group and crashed recordChat — +// killing tool tracing for the rest of the session. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + transcriptUserLine, +} from './helpers.ts'; + +function assistantToolUseLine(msgId: string, toolUseId: string, ts: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id: msgId, + model: 'claude-opus-4-8', + usage: { input_tokens: 100, output_tokens: 10 }, + stop_reason: 'tool_use', + content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 999' } }], + }, + }); +} + +test('interrupted turn: next prompt closes the open turn and tool tracing survives', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-interrupt'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-interrupt-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, transcriptUserLine('turn one', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn one' }); + + // Turn 1's response msgA starts a tool; the user interrupts before it + // completes, so neither PostToolUse nor Stop ever fires. + fs.appendFileSync(file, assistantToolUseLine('msgA', 'tool_1', '2026-01-01T00:00:02.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + + // Turn 2 begins: the transcript's new user message starts a new parsed turn, + // making turn 1's msgA key stale relative to the latest parse. + fs.appendFileSync(file, transcriptUserLine('turn two', { timestamp: '2026-01-01T00:00:10.000Z' }) + '\n'); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); + + // Tool tracing in turn 2 must still work (this crashed on the stale key). + fs.appendFileSync(file, assistantToolUseLine('msgB', 'tool_2', '2026-01-01T00:00:12.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_2', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_2', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.equal(turns.length, 2, 'both turn roots exported (interrupted turn not leaked)'); + + const superseded = turns.find((s) => s.attributes[ATTR.WEAVE_ORPHAN_REASON] === 'superseded_by_next_prompt'); + assert.ok(superseded, 'interrupted turn closed with the superseded orphan reason'); + + const tools = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); + assert.equal(tools.length, 2, 'tool spans from both turns exported (turn 2 tracing survived)'); + + // The interrupted turn's chat span is finalized from the transcript with + // its real usage, not dropped. + const chats = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.ok(chats.some((c) => c.attributes[ATTR.RESPONSE_ID] === 'msgA'), 'interrupted chat span exported'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts index f52617d..2061133 100644 --- a/tests/subagent-nesting.test.ts +++ b/tests/subagent-nesting.test.ts @@ -15,22 +15,19 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { ATTR } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, +} from './helpers.ts'; -interface Driver { - routeEvent(p: Record): Promise; -} - -function userLine(text: string): string { - return JSON.stringify({ type: 'user', version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text }] } }); -} -function assistantLine(text: string, usage: Record): string { - return JSON.stringify({ - type: 'assistant', - timestamp: '2026-01-01T00:00:05.000Z', - message: { role: 'assistant', model: 'claude-opus-4-8', id: 'm1', usage, stop_reason: 'end_turn', content: [{ type: 'text', text }] }, - }); -} +const userLine = (text: string): string => + transcriptUserLine(text, { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }); +const assistantLine = (text: string, usage: Record): string => + transcriptAssistantLine(text, usage, { timestamp: '2026-01-01T00:00:05.000Z' }); test('matched subagent: tools and chats nest under its invoke_agent marker with full identity', async () => { const exporter = await initWeaveInMemory(); @@ -48,7 +45,7 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with fs.mkdirSync(path.dirname(subPath), { recursive: true }); fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); diff --git a/tests/system-instructions-integration.test.ts b/tests/system-instructions-integration.test.ts index b455987..09c84e9 100644 --- a/tests/system-instructions-integration.test.ts +++ b/tests/system-instructions-integration.test.ts @@ -20,19 +20,14 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { ATTR } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; - -interface Driver { - routeEvent(p: Record): Promise; -} +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, transcriptUserLine } from './helpers.ts'; /** Seed a transcript file with a single user line (the first line carries the * CC CLI version, as real transcripts do) and return its path. */ function seedTranscript(sid: string): { dir: string; file: string } { const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-sysinstr-')); const file = path.join(dir, `${sid}.jsonl`); - const userLine = { type: 'user', version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }; - fs.writeFileSync(file, JSON.stringify(userLine) + '\n'); + fs.writeFileSync(file, transcriptUserLine('hi', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); return { dir, file }; } @@ -57,7 +52,7 @@ test('buffers InstructionsLoaded fired before SessionStart, then accumulates in exporter.reset(); const sid = 'sess-order'; const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); // Global CLAUDE.md loads BEFORE SessionStart (the real, non-deterministic order). @@ -88,7 +83,7 @@ test('re-loading the same file replaces its content rather than duplicating', as exporter.reset(); const sid = 'sess-dedup'; const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); @@ -115,7 +110,7 @@ test('stamps system instructions on every turn root (no session span to hang the exporter.reset(); const sid = 'sess-multiturn'; const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); @@ -142,7 +137,7 @@ test('omits gen_ai.system_instructions when no instructions were loaded', async exporter.reset(); const sid = 'sess-none'; const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); diff --git a/tests/teammate-idle.test.ts b/tests/teammate-idle.test.ts index 4a0e5bd..553043a 100644 --- a/tests/teammate-idle.test.ts +++ b/tests/teammate-idle.test.ts @@ -32,10 +32,6 @@ import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './he // ── helpers ────────────────────────────────────────────────────────────────── -interface Driver { - routeEvent(p: Record): Promise; -} - /** Write a fake teammate transcript to a temp file and return its path. * * readFirstTranscriptLine requires the path to be within os.homedir() (security @@ -152,7 +148,7 @@ test('TeammateIdle span tree: teammate turn carries the teammate chat span, tagg fs.writeFileSync(path.join(subDir, `agent-${agentId}.jsonl`), [USER_LINE, ASSISTANT_LINE].map(l => JSON.stringify(l)).join('\n') + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: coordSid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordSid, prompt: '/triage' }); diff --git a/tests/turn-span-agent-name.test.ts b/tests/turn-span-agent-name.test.ts index 6333df8..989d971 100644 --- a/tests/turn-span-agent-name.test.ts +++ b/tests/turn-span-agent-name.test.ts @@ -16,10 +16,6 @@ import * as path from 'node:path'; import { ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; -interface Driver { - routeEvent(p: Record): Promise; -} - function writeTranscript(sessionId: string, text: string): { file: string; dir: string } { const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-agentname-')); const file = path.join(dir, `${sessionId}.jsonl`); @@ -35,7 +31,7 @@ test('turn span: agentName drives gen_ai.agent.name', async () => { exporter.reset(); const sid = `sess-${name}`; const { file, dir } = writeTranscript(sid, 'hello'); - const d = makeGenaiDaemon(name) as unknown as Driver; + const d = makeGenaiDaemon(name); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/tmp' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'hello' }); diff --git a/tests/turn-span-integration.test.ts b/tests/turn-span-integration.test.ts index b717ace..293b589 100644 --- a/tests/turn-span-integration.test.ts +++ b/tests/turn-span-integration.test.ts @@ -22,10 +22,6 @@ import * as path from 'node:path'; import { VERSION } from '../src/setup.ts'; import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; -interface Driver { - routeEvent(p: Record): Promise; -} - const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; function userText(ts: string, text: string, version: string) { @@ -56,7 +52,7 @@ test('integration identity stamps weave.integration.* on every span (turn, chat, // First transcript line carries the CC CLI version (real CC transcripts do). fs.appendFileSync(file, JSON.stringify(userText('2026-01-01T00:00:00.000Z', 'do it', '1.2.3')) + '\n'); - const d = makeGenaiDaemon() as unknown as Driver; + const d = makeGenaiDaemon(); try { await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); From 0aa834377b72d43c734692ed1324bd1baa4a4fce Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 18:50:22 -0700 Subject: [PATCH 10/13] refactor: dedupe the remaining copy-paste seams - settleSubagentDispatch: one implementation of the Agent-dispatch settle (team spawns keep the marker open; in-session spawns close with the tool return) shared by PostToolUse and PostToolUseFailure - startOrphanSubagent: one marker-creation path for SubagentStart orphans and post-restart recovery - sha256Hex (utils) behind hashPrompt and daemonConfigFingerprint - subagentsDirFor: single derivation of the /subagents directory, shared by transcript-path mapping and teammate-transcript resolution - assistantOutputMessages: one builder for the plain-text gen_ai.output.messages shape on turn/subagent spans No hand-rolled attribute writes remain where the SDK has a field for them: error.type stays manual (the SDK records exception + status only), and turn/subagent output messages have no SDK surface. Co-Authored-By: Claude Fable 5 --- src/config.ts | 6 +-- src/daemon.ts | 96 +++++++++++++++++++++------------------------ src/genaiSpans.ts | 6 +++ src/sessionState.ts | 23 ++++++----- src/utils.ts | 6 +++ 5 files changed, 73 insertions(+), 64 deletions(-) diff --git a/src/config.ts b/src/config.ts index ec29878..013c493 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,8 +8,8 @@ // daemon.ts so both use one implementation without an import cycle (cli.ts // imports the daemon entry point). -import { createHash } from 'crypto'; import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; +import { sha256Hex } from './utils.js'; import type { Settings } from './setup.js'; /** Where a resolved value came from, for user-facing "source" reporting. */ @@ -133,8 +133,6 @@ const CONFIG_FINGERPRINT_LENGTH = 16; /** Short, stable hash of a daemon config. The API key is hashed, not exposed, * so the fingerprint is safe to send over the socket. */ export function daemonConfigFingerprint(c: DaemonConfig): string { - return createHash('sha256') - .update(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) - .digest('hex') + return sha256Hex(JSON.stringify([c.weaveProject, c.apiKey, c.baseUrl, c.agentName, c.debug])) .slice(0, CONFIG_FINGERPRINT_LENGTH); } diff --git a/src/daemon.ts b/src/daemon.ts index 51cd323..14f20bb 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -34,6 +34,7 @@ import { addPermissionRequestEvent, setCompactionAttrs, toolDisplayName, + assistantOutputMessages, snippet, jsonStr, } from './genaiSpans.js'; @@ -52,6 +53,7 @@ import { resolvePermissionIfPending, hashPrompt, computeSubagentTranscriptPath, + subagentsDirFor, extractUserMessageContent, lastAssistantTextEndsWith, readSubagentFirstLineWithRetry, @@ -999,6 +1001,30 @@ export class GlobalDaemon { this.log('DEBUG', `Permission request recorded for ${toolName}`); } + /** + * Settle the Agent-dispatch tracker for `toolUseId` at PostToolUse[Failure], + * if one exists. An Agent tool call has no pendingToolCall; its span is the + * subagent's `invoke_agent` marker, closed here with the tool's canonical + * return. Team spawns are the exception: the Agent tool returns immediately + * (the teammate runs async in its own session), so the marker stays open — + * the team map owns it and the teammate's TeammateIdle closes it — and only + * the per-session tracker is dropped. Returns true when the tool call was a + * subagent dispatch. + */ + private settleSubagentDispatch( + session: SessionState, + toolUseId: string, + output: unknown, + failure: boolean, + ): boolean { + const tracker = session.subagents.byToolUseId(toolUseId); + if (!tracker?.subAgent) return false; + if (!tracker.teamName) this.closeSubagent(tracker, output, failure); + session.subagents.remove(tracker); + this.countToolCall(session, 'Agent'); + return true; + } + private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; @@ -1006,23 +1032,7 @@ export class GlobalDaemon { const toolUseId = input.tool_use_id; if (!toolUseId) return; - // Subagent dispatch: the matching span is the subagent's invoke_agent - // marker (not a pendingToolCall), so we close it here with the subagent's - // final assistant text as `gen_ai.output.messages`. - const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.subAgent) { - if (subagentTracker.teamName) { - // Agent-teams: the Agent tool returns immediately (teammate runs async - // in its own session). Do NOT close the invoke_agent span — it would - // end empty before the teammate works. The team map owns it now. - session.subagents.remove(subagentTracker); - } else { - this.closeSubagent(subagentTracker, input.tool_response, /*failure*/ false); - session.subagents.remove(subagentTracker); - } - this.countToolCall(session, 'Agent'); - return; - } + if (this.settleSubagentDispatch(session, toolUseId, input.tool_response, /*failure*/ false)) return; const pending = session.pendingToolCalls.get(toolUseId); if (!pending) return; @@ -1045,24 +1055,7 @@ export class GlobalDaemon { const error = input.error; - // Subagent dispatch failed (rare). Close the invoke_agent span with ERROR - // status; subagent chat spans, if any reached SubagentStop, are already - // attached as children. - const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.subAgent) { - if (subagentTracker.teamName) { - // Agent-teams: the team map owns this span (closed at the teammate's - // TeammateIdle, cross-session). Closing it here would end it early and - // then double-end when TeammateIdle fires. Mirror handlePostToolUse: - // just drop the per-session tracker; the queue entry lives on. - session.subagents.remove(subagentTracker); - } else { - this.closeSubagent(subagentTracker, error, /*failure*/ true); - session.subagents.remove(subagentTracker); - } - this.countToolCall(session, 'Agent'); - return; - } + if (this.settleSubagentDispatch(session, toolUseId, error, /*failure*/ true)) return; const pending = session.pendingToolCalls.get(toolUseId); if (!pending) return; @@ -1094,7 +1087,7 @@ export class GlobalDaemon { if (output !== undefined && output !== null && output !== '') { const outputText = typeof output === 'string' ? output : jsonStr(output); - sub.setAttributes({ [ATTR.OUTPUT_MESSAGES]: jsonStr([{ role: 'assistant', content: outputText }]) }); + sub.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([outputText]) }); } if (failure) { sub.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(output) }); @@ -1149,11 +1142,7 @@ export class GlobalDaemon { pendingTeammateIdle: true, }; if (session.currentTurn) { - bestTracker.subAgent = session.currentTurn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); - bestTracker.subAgent.setAttributes({ - [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, - [ATTR.WEAVE_ORPHAN_REASON]: reason, - }); + bestTracker.subAgent = this.startOrphanSubagent(session.currentTurn, agentType, reason); } session.subagents.add(bestTracker); } @@ -1167,6 +1156,17 @@ export class GlobalDaemon { this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); } + /** Open an orphan `invoke_agent` marker under `turn` for a subagent with no + * matched Agent tool call, stamping why it exists outside the normal path. */ + private startOrphanSubagent(turn: weave.Turn, agentType: string, orphanReason: string): weave.SubAgent { + const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); + subAgent.setAttributes({ + [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, + [ATTR.WEAVE_ORPHAN_REASON]: orphanReason, + }); + return subAgent; + } + /** The session's open turn, opening a fresh one if a restart left the * session without a turn, so a subagent recovered at SubagentStop has a parent. */ private getOrReconstructTurn(session: SessionState): weave.Turn | undefined { @@ -1188,12 +1188,8 @@ export class GlobalDaemon { ): SubagentTracker | undefined { const turn = this.getOrReconstructTurn(session); if (!turn) return undefined; - const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); + const subAgent = this.startOrphanSubagent(turn, agentType, 'recovered at SubagentStop after daemon restart (no tracker)'); subAgent.record({ agentId }); - subAgent.setAttributes({ - [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, - [ATTR.WEAVE_ORPHAN_REASON]: 'recovered at SubagentStop after daemon restart (no tracker)', - }); const tracker: SubagentTracker = { subagentType: agentType, detectedAt: new Date(), @@ -1397,9 +1393,7 @@ export class GlobalDaemon { idleTranscriptPath: string | undefined, ): string | undefined { try { - const projectDir = path.dirname(coordinatorTranscriptPath); - const sessionDirName = path.basename(coordinatorTranscriptPath, '.jsonl'); - const subagentsDir = path.join(projectDir, sessionDirName, 'subagents'); + const subagentsDir = subagentsDirFor(coordinatorTranscriptPath); if (fs.existsSync(subagentsDir)) { let best: { p: string; mtime: number } | undefined; for (const meta of fs.readdirSync(subagentsDir).filter(f => f.endsWith('.meta.json'))) { @@ -1498,7 +1492,7 @@ export class GlobalDaemon { if (model) subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); if (lastAssistantText) { subAgent.setAttributes({ - [ATTR.OUTPUT_MESSAGES]: jsonStr([{ role: 'assistant', content: lastAssistantText }]), + [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([lastAssistantText]), }); } subAgent.end(); @@ -1583,7 +1577,7 @@ export class GlobalDaemon { const turnAttrs: Attributes = { [ATTR.WEAVE_TURN_TOOL_COUNT]: session.turnToolCalls }; if (assistantMessages.length) { - turnAttrs[ATTR.OUTPUT_MESSAGES] = jsonStr(assistantMessages.map((m) => ({ role: 'assistant', content: m }))); + turnAttrs[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(assistantMessages); } const finishReasons = currentTurn?.assistantCalls().map(c => c.finishReason).filter((r): r is string => !!r); if (finishReasons?.length) { diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index d2d0c6a..503d6b6 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -140,6 +140,12 @@ export function jsonStr(v: unknown): string { } } +/** `gen_ai.output.messages` JSON for plain assistant text(s) — the shape used + * on turn and subagent `invoke_agent` spans (chat spans carry parts instead). */ +export function assistantOutputMessages(texts: string[]): string { + return jsonStr(texts.map((content) => ({ role: 'assistant', content }))); +} + /** Parse an ISO timestamp; returns undefined for missing or unparseable input. */ export function parseTimestamp(ts: string | undefined): Date | undefined { if (!ts) return undefined; diff --git a/src/sessionState.ts b/src/sessionState.ts index 289d117..2a453b1 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -3,11 +3,11 @@ // SPDX-PackageName: weave-claude-code import * as path from 'path'; -import { createHash } from 'crypto'; import * as weave from 'weave'; import { VERSION } from './setup.js'; import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; +import { sha256Hex } from './utils.js'; import { buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; import type { CompactionAttrs } from './genaiSpans.js'; @@ -45,20 +45,25 @@ export function resolvePermissionIfPending(pending: PendingToolCall, approved: b /** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with * the subagent's SubagentStart by matching transcript content. */ export function hashPrompt(prompt: string): string { - return createHash('sha256').update(prompt, 'utf8').digest('hex'); + return sha256Hex(prompt); } /** - * Map a parent transcript path + subagent agent_id to the subagent's transcript - * file. Claude Code writes subagent transcripts as siblings of the parent in a - * `/subagents/` subdirectory: - * parent: /.jsonl + * Directory holding a session's subagent transcripts. Claude Code writes them + * as siblings of the session transcript in a `/subagents/` + * subdirectory: + * session: /.jsonl * subagent: //subagents/agent-.jsonl */ +export function subagentsDirFor(sessionTranscriptPath: string): string { + const projectDir = path.dirname(sessionTranscriptPath); + const sessionDirName = path.basename(sessionTranscriptPath, '.jsonl'); + return path.join(projectDir, sessionDirName, 'subagents'); +} + +/** Map a parent transcript path + subagent agent_id to the subagent's transcript file. */ export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { - const projectDir = path.dirname(parentTranscriptPath); - const sessionDirName = path.basename(parentTranscriptPath, '.jsonl'); - return path.join(projectDir, sessionDirName, 'subagents', `agent-${agentId}.jsonl`); + return path.join(subagentsDirFor(parentTranscriptPath), `agent-${agentId}.jsonl`); } /** Pull the user-message content out of a transcript line. Returns the prompt diff --git a/src/utils.ts b/src/utils.ts index 5daa19b..2765fea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,12 @@ import * as net from 'net'; import * as path from 'path'; import * as readline from 'readline'; import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; + +/** Hex-encoded sha256 of `input`. */ +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex'); +} export function prompt(question: string): Promise { return new Promise((resolve) => { From 100c1fad0a799dcaa113fea8f722e75ac7f0304f Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 19:47:05 -0700 Subject: [PATCH 11/13] docs: trim comment noise; fold single-caller startChat Comment pass over daemon.ts: delete lines that echo the code (config check, countToolCall) or duplicate docs owned elsewhere (config-hash reply vs the ControlMessage type, SubagentTracker's rendering rationale vs the Agent-dispatch branch), and tighten the over-long blocks (Agent dispatch, orphan creation, SubagentStop close) to the load-bearing why. startChat's only caller became openChatForGroup after the emitChatSpans dedup, so fold it in and drop the export + daemon import. Co-Authored-By: Claude Fable 5 --- src/chatSpans.ts | 22 +++++++-------- src/daemon.ts | 65 ++++++++++++++------------------------------- src/sessionState.ts | 11 +++----- 3 files changed, 35 insertions(+), 63 deletions(-) diff --git a/src/chatSpans.ts b/src/chatSpans.ts index 884d2f4..a133175 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -57,22 +57,22 @@ export function parseIsoOrNow(ts: string | undefined): Date { return parseTimestamp(ts) ?? new Date(); } -/** Open a chat (LLM) span under a turn or subagent for `model`, deriving the provider. */ -export function startChat(parent: weave.Turn | weave.SubAgent, model: string, startTime: Date): weave.LLM { - const provider = providerFromModel(model); - return parent.startLLM({ model, ...(provider ? { providerName: provider } : {}), startTime }); -} - /** - * Open a chat (LLM) span for one response `group`, backdating its start to the - * first call's request time. Returns undefined when no call in the group has a - * model yet (LLMInit.model is required), so the caller can fall back to the turn - * span and emit the chat span later once the model has flushed. + * Open a chat (LLM) span under a turn or subagent for one response `group`, + * deriving the provider and backdating the start to the first call's request + * time. Returns undefined when no call in the group has a model yet + * (LLMInit.model is required), so the caller can fall back to the turn span + * and emit the chat span later once the model has flushed. */ export function openChatForGroup(parent: weave.Turn | weave.SubAgent, group: AssistantCallDetail[]): weave.LLM | undefined { const model = group.map(c => c.model).find(Boolean); if (!model) return undefined; - return startChat(parent, model, parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp)); + const provider = providerFromModel(model); + return parent.startLLM({ + model, + ...(provider ? { providerName: provider } : {}), + startTime: parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp), + }); } /** diff --git a/src/daemon.ts b/src/daemon.ts index 14f20bb..a6e65f2 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -45,7 +45,6 @@ import { callsForResponseKey, findToolUseResponseKey, parseIsoOrNow, - startChat, openChatForGroup, recordChat, } from './chatSpans.js'; @@ -155,7 +154,6 @@ export class GlobalDaemon { ) {} async start(): Promise { - // Initialize the Weave SDK if Weave is configured if (this.config.weaveProject && this.config.apiKey) { try { await this.initWeave(); @@ -340,10 +338,6 @@ export class GlobalDaemon { if (isControlMessage(payload)) { if (payload.command === 'config-hash') { - // Reply carries the config fingerprint (for drift detection) plus the - // daemon's runtime identity, so `status` can show which build is - // actually running (pid + version + resolved entry script) rather than - // just where the CLI symlink currently points. socket.end(JSON.stringify({ config_hash: daemonConfigFingerprint(this.config), pid: process.pid, @@ -408,8 +402,7 @@ export class GlobalDaemon { await this.handleSessionStart(sessionId, input); break; case 'InstructionsLoaded': - // Reads the instruction file synchronously off the hook's file_path; - // no async work to await. + // Synchronous: reads the instruction file inline; nothing to await. this.handleInstructionsLoaded(sessionId, input); break; case 'UserPromptSubmit': @@ -774,16 +767,12 @@ export class GlobalDaemon { // tool_input is per-tool JSON the SDK types as `unknown`; narrow to index it. const toolInput = (input.tool_input ?? {}) as Record; - // Agent tool with subagent_type → emit a nested `invoke_agent ` - // span (a SubAgent marker), NOT an `execute_tool Agent` span. The Weave - // Agents chat view renders nested invoke_agent spans as their own - // `agent_start` lifecycle marker; an execute_tool wrapper here would - // mis-render the subagent dispatch as a generic tool call. The Agent tool's - // PostToolUse closes this span with the subagent's final return. - // - // `promptHash` lets SubagentStart correlate this tracker to the right - // subagent deterministically by reading the subagent transcript's line 1 - // (the firing prompt) and matching by sha256 + subagent_type. + // Agent tool with subagent_type → a nested `invoke_agent` marker, NOT an + // `execute_tool Agent` span: the chat view renders nested invoke_agent as + // an `agent_start` lifecycle event, and a tool wrapper would mis-render the + // dispatch as a generic tool call. PostToolUse(Agent) closes the marker. + // `promptHash` lets SubagentStart correlate deterministically: sha256 of + // the firing prompt (the subagent transcript's line 1) + subagent_type. if (!agentId && toolName === 'Agent' && toolInput['subagent_type']) { if (!session.currentTurn) { this.log('ERROR', `PreToolUse(Agent): no current turn for session=${sessionId}`); @@ -930,10 +919,8 @@ export class GlobalDaemon { * Emit a complete chat span (LLM) for one assistant API response `key`. The * response's ordered text / thinking / tool_use blocks become * `gen_ai.output.messages` parts, so the model's natural interleave shows on - * the single chat span (the tools it called nest under this span as their own - * `execute_tool` children). Usage is taken once from the response (the split - * lines duplicate the message usage, so it must not be summed), then the span - * is ended. Reuses `existingLlm` when the span was already opened during + * the single chat span (its tool calls nest under it as `execute_tool` + * children). Reuses `existingLlm` when the span was already opened during * PreToolUse; otherwise opens a fresh one under the turn span. */ private emitChatSpanForResponse( @@ -962,8 +949,6 @@ export class GlobalDaemon { session.emittedChatSpanResponseKeys.add(key); } - /** Record one completed tool call in the session's total, per-turn, and - * per-tool-name counters (all bumped together whenever a tool finishes). */ private countToolCall(session: SessionState, toolName: string): void { session.totalToolCalls += 1; session.turnToolCalls += 1; @@ -1123,14 +1108,10 @@ export class GlobalDaemon { const matched = !!bestTracker; if (!bestTracker) { - // No matching Agent tool call — either the parent's PreToolUse never - // fired, or the firing prompt couldn't be read from the subagent - // transcript. Create an orphan tracker AND an orphan `invoke_agent` - // span so the subagent still produces a valid nested agent invocation - // in the chat view. The span parents under the current turn (no - // spawning tool_use_id) and has no input_messages (firing prompt - // unavailable). Closed at SubagentStop since there will be no - // PostToolUse for it. + // No matching Agent tool call (the parent's PreToolUse never fired, or + // the firing prompt couldn't be read). Create an orphan tracker + marker + // so the subagent still renders as a nested invocation; closed at + // SubagentStop since no PostToolUse will come for it. const reason = firingPrompt === undefined ? 'transcript line 1 missing or non-user' : `no tracker matches (promptHash, type=${agentType})`; @@ -1236,12 +1217,10 @@ export class GlobalDaemon { try { agentTranscript = new TranscriptFile(agentTranscriptPath); const parsed = parseSessionFd(agentTranscript.getFd()); - // Use the last turn only. Subagent transcripts are almost always - // single-turn; the rare 2-turn case occurs when the parent agent's - // prior assistant message is carried in as pre-context on line 0 - // and the user prompt that fires the subagent appears on line 1. - // Emitting chat spans from earlier turns would mis-attribute the - // parent's LLM call to this subagent invocation. + // Last turn only: a subagent transcript occasionally carries the + // parent's prior assistant message as pre-context (a 2-turn parse); + // emitting earlier turns would mis-attribute the parent's LLM call + // to this subagent invocation. const lastTurn = parsed?.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); @@ -1265,13 +1244,9 @@ export class GlobalDaemon { if (model) { tracker.subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); } - // Orphan path: no PostToolUse will fire, so close the invoke_agent - // span here — unless TeammateIdle is expected to follow (FleetView/ - // Teammate pattern). In that case, keep the span open so TeammateIdle - // can emit all-turns content and close it correctly. - // Matched path: leave the span open for PostToolUse to close with the - // canonical tool_response and remove the tracker; if we removed the - // tracker here, byToolUseId at PostToolUse would miss it. + // Close only plain orphans (no PostToolUse will fire for them). Matched + // trackers wait for PostToolUse's canonical tool_response; orphans + // awaiting TeammateIdle stay open so it can emit all-turns content. if (!tracker.ended && !tracker.toolUseId && !tracker.pendingTeammateIdle) { this.closeSubagent(tracker, lastAssistantText, /*failure*/ false); } diff --git a/src/sessionState.ts b/src/sessionState.ts index 2a453b1..d81e537 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -138,13 +138,10 @@ export async function readSubagentFirstLineWithRetry( * span is created at SubagentStart with the current turn span as * parent and no input messages (the firing prompt is unavailable). * - * The subagent is its own `invoke_agent ` span, child of the - * parent turn's `invoke_agent claude-code` span. Per the Weave Agents chat - * view (`weave/trace_server/agents/chat_view.py`), nested `invoke_agent` - * spans render as an `agent_start` lifecycle marker with the inner agent's - * own assistant text — distinct from an `execute_tool` tool-call event. - * The Agent tool call does NOT emit an `execute_tool` span; it emits this - * `invoke_agent` span directly. + * The subagent is its own `invoke_agent ` span under the parent + * turn, and its chat/tool spans nest beneath it. (Why an `invoke_agent` marker + * rather than an `execute_tool` span: see the Agent-dispatch branch in + * `handlePreToolUse`.) */ export type SubagentTracker = { subagentType: string; From 77b8ccb3a42a265d56bbd5b988b6ea8204ffddb7 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 19:59:26 -0700 Subject: [PATCH 12/13] refactor(parser): fold split transcript lines into one call per response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code splits one API response's blocks across transcript lines sharing a message.id. The parser now folds those into a single AssistantCallDetail, so the response-regrouping layer the daemon carried (chatMessageKey scans, callsForResponseKey, findToolUseResponseKey, the emit dedup set) collapses to direct lookups — and chatSpans.ts goes away entirely, its three survivors (chatMessageKey, openChat, recordChat) inlined as module helpers in daemon.ts, their only consumer. Also a comment pass across the PR: doc blocks cut to the load-bearing why (constants, conversation-id walk, instruction capture, teammate paths, shutdown ordering, type docs). Co-Authored-By: Claude Fable 5 --- src/chatSpans.ts | 103 --------- src/config.ts | 8 +- src/daemon.ts | 540 +++++++++++++++++--------------------------- src/genaiSpans.ts | 104 +++------ src/parser.ts | 43 ++-- src/sessionState.ts | 107 +++------ 6 files changed, 294 insertions(+), 611 deletions(-) delete mode 100644 src/chatSpans.ts diff --git a/src/chatSpans.ts b/src/chatSpans.ts deleted file mode 100644 index a133175..0000000 --- a/src/chatSpans.ts +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -import * as weave from 'weave'; -import type { AssistantCallDetail } from './parser.js'; -import { isToolUseBlock } from './parser.js'; -import { - ATTR, - buildUsage, - contentBlocksToParts, - providerFromModel, - parseTimestamp, -} from './genaiSpans.js'; - -/** Stable identity for an assistant API call within a turn. Anthropic returns - * a `message.id` on every response; that's the primary key. When it's - * missing (legacy transcripts), fall back to the index, which is stable - * within a single parse + turn. */ -export function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { - return call.responseId ?? `idx:${callIdx}`; -} - -/** All calls belonging to one assistant API response, in transcript order. - * Claude Code splits a single response's thinking / text / tool_use blocks - * across separate transcript lines that share a `message.id`; the parser maps - * each line to its own `AssistantCallDetail`, so this regroups them by key. */ -export function callsForResponseKey( - calls: AssistantCallDetail[], - key: string, -): AssistantCallDetail[] { - const group: AssistantCallDetail[] = []; - for (let i = 0; i < calls.length; i++) { - if (chatMessageKey(calls[i], i) === key) group.push(calls[i]); - } - return group; -} - -/** Find the response key of the assistant call whose content contains a - * `tool_use` block with `toolUseId`, or undefined if not found (transcript - * not flushed yet, or unknown id). */ -export function findToolUseResponseKey( - calls: AssistantCallDetail[], - toolUseId: string, -): string | undefined { - for (let ci = 0; ci < calls.length; ci++) { - for (const block of calls[ci].contentBlocks) { - if (isToolUseBlock(block) && block.id === toolUseId) { - return chatMessageKey(calls[ci], ci); - } - } - } - return undefined; -} - -export function parseIsoOrNow(ts: string | undefined): Date { - return parseTimestamp(ts) ?? new Date(); -} - -/** - * Open a chat (LLM) span under a turn or subagent for one response `group`, - * deriving the provider and backdating the start to the first call's request - * time. Returns undefined when no call in the group has a model yet - * (LLMInit.model is required), so the caller can fall back to the turn span - * and emit the chat span later once the model has flushed. - */ -export function openChatForGroup(parent: weave.Turn | weave.SubAgent, group: AssistantCallDetail[]): weave.LLM | undefined { - const model = group.map(c => c.model).find(Boolean); - if (!model) return undefined; - const provider = providerFromModel(model); - return parent.startLLM({ - model, - ...(provider ? { providerName: provider } : {}), - startTime: parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp), - }); -} - -/** - * Populate a chat (LLM) span from the assistant calls of one response, then end - * it. Split lines share the response's usage, so take it once from the last line - * (which carries stop_reason), not summed. `agentName` tags the span so the - * subagent's/teammate's calls stay queryable by agent; conversation.id is - * inherited from the parent handle chain. - */ -export function recordChat( - llm: weave.LLM, - group: AssistantCallDetail[], - agentName?: string, -): void { - const last = group.at(-1)!; - const parts = contentBlocksToParts(group.flatMap(c => c.contentBlocks)); - const finishReason = group.map(c => c.finishReason).find(Boolean); - llm.record({ - ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), - usage: buildUsage(last.usage, last.reasoningTokens), - outputType: 'text', - ...(last.responseId ? { responseId: last.responseId } : {}), - ...(finishReason ? { finishReasons: [finishReason] } : {}), - }); - // agent.name isn't on record()'s surface — set directly. - if (agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: agentName }); - llm.end({ endTime: parseIsoOrNow(last.timestamp) }); -} diff --git a/src/config.ts b/src/config.ts index 013c493..572e43c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,11 +2,9 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Config resolution shared by the CLI and the daemon: the effective Weave -// project / API key / agent name (env over settings.json), plus the daemon's -// full config and its fingerprint. Lives here rather than in cli.ts or -// daemon.ts so both use one implementation without an import cycle (cli.ts -// imports the daemon entry point). +// Config resolution shared by the CLI and the daemon (env over +// settings.json). Lives here so both use one implementation without an +// import cycle (cli.ts imports the daemon entry point). import { DEFAULT_AGENT_NAME } from './genaiSpans.js'; import { sha256Hex } from './utils.js'; diff --git a/src/daemon.ts b/src/daemon.ts index a6e65f2..6efcbed 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -26,7 +26,7 @@ import type { import * as weave from 'weave'; import { loadSettings, VERSION } from './setup.js'; import { appendToLog, deepEqual } from './utils.js'; -import { parseSessionFd } from './parser.js'; +import { parseSessionFd, isToolUseBlock } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; import { ATTR, @@ -35,19 +35,15 @@ import { setCompactionAttrs, toolDisplayName, assistantOutputMessages, + buildUsage, + contentBlocksToParts, + providerFromModel, + parseTimestamp, snippet, jsonStr, } from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; import type { DaemonConfig } from './config.js'; -import { - chatMessageKey, - callsForResponseKey, - findToolUseResponseKey, - parseIsoOrNow, - openChatForGroup, - recordChat, -} from './chatSpans.js'; import { resolvePermissionIfPending, hashPrompt, @@ -72,10 +68,9 @@ import type { AssistantCallDetail, ParsedSession } from './parser.js'; // Types // ───────────────────────────────────────────────────────────────────────────── -/** Inbound control message sent directly to the socket (not a hook event). - * `shutdown` stops the daemon; `config-hash` asks it to reply with the - * fingerprint of the config it loaded (used by `status` for drift detection) - * plus the daemon's runtime identity (pid, version, entry path). */ +/** Socket control message (not a hook event): `shutdown` stops the daemon; + * `config-hash` replies with the loaded config's fingerprint (drift + * detection) plus the daemon's identity (pid, version, entry path). */ type ControlMessage = { command: 'shutdown' | 'config-hash'; } @@ -89,10 +84,8 @@ function isControlMessage(payload: unknown): payload is ControlMessage { return cmd === 'shutdown' || cmd === 'config-hash'; } -/** Absolute real path of the daemon's own entry script, resolving the npm bin - * symlink to the actual dist/cli.js (or src/cli.ts under tsx). Lets `status` - * report which build the running daemon is executing. Falls back to the raw - * argv path if it can't be resolved. */ +/** Real path of the daemon's entry script (npm bin symlink resolved), so + * `status` can report which build is actually running. */ function daemonEntryPath(): string { const entry = process.argv[1] ?? ''; try { @@ -102,25 +95,62 @@ function daemonEntryPath(): string { } } +// ───────────────────────────────────────────────────────────────────────────── +// Chat-span helpers (over parsed assistant responses) +// ───────────────────────────────────────────────────────────────────────────── + +/** Stable identity for a response within a turn: `message.id`, or the index + * for legacy transcripts without ids. */ +function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { + return call.responseId ?? `idx:${callIdx}`; +} + +function parseIsoOrNow(ts: string | undefined): Date { + return parseTimestamp(ts) ?? new Date(); +} + +/** Open a chat (LLM) span under a turn or subagent, backdated to the request + * start. Undefined when the response has no model yet (LLMInit requires one); + * callers fall back to the turn and emit the span later. */ +function openChat(parent: weave.Turn | weave.SubAgent, call: AssistantCallDetail): weave.LLM | undefined { + if (!call.model) return undefined; + const provider = providerFromModel(call.model); + return parent.startLLM({ + model: call.model, + ...(provider ? { providerName: provider } : {}), + startTime: parseIsoOrNow(call.prevTimestamp ?? call.timestamp), + }); +} + +/** Populate a chat span from one assistant response, then end it. `agentName` + * keeps a subagent's/teammate's calls queryable by agent; conversation.id is + * inherited from the parent handle chain. */ +function recordChat(llm: weave.LLM, call: AssistantCallDetail, agentName?: string): void { + const parts = contentBlocksToParts(call.contentBlocks); + llm.record({ + ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), + usage: buildUsage(call.usage, call.reasoningTokens), + outputType: 'text', + ...(call.responseId ? { responseId: call.responseId } : {}), + ...(call.finishReason ? { finishReasons: [call.finishReason] } : {}), + }); + // agent.name isn't on record()'s surface — set directly. + if (agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: agentName }); + llm.end({ endTime: parseIsoOrNow(call.timestamp) }); +} + // ───────────────────────────────────────────────────────────────────────────── // GlobalDaemon // ───────────────────────────────────────────────────────────────────────────── -// How long the daemon stays alive with no hook events before self-reaping. It -// only fires when nothing is in flight (the INFLIGHT_HOLD_MAX_MS guards keep an -// active turn/tool/team alive regardless), so this window purely governs how -// long an idle daemon stays warm for the next prompt. Set to 120 min so gaps in -// a working session (a long build, a meeting, lunch) don't reap the daemon and -// strand the resumed session on a fresh, amnesiac one, the dominant source of -// "Unknown session" drops. Longer idle gaps still reap; session reconstruction -// then recovers those. Override with WEAVE_INACTIVITY_MS. +// Idle window before self-reap; fires only with nothing in flight. 120 min so +// think-time gaps in a working session don't strand it on a fresh, amnesiac +// daemon ("Unknown session" drops); reconstruction covers longer gaps. +// Override with WEAVE_INACTIVITY_MS. const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes -// Absolute ceiling for holding the daemon open past the normal inactivity -// timeout while work is still in flight — either cross-session team -// correlation (hasUnemittedTeamMembers) or an ordinary open turn / pending -// tool / tracked subagent (hasInFlightWork); see checkInactivity. Bounds the -// pathological case (a teammate that never emits TeammateIdle, or a stuck -// session) so an in-flight entry can't pin the daemon forever. +// Ceiling for holding past the idle window while work is in flight (open +// turn/tool/subagent, or a teammate that hasn't reported) so a stuck entry +// can't pin the daemon forever. See checkInactivity. const INFLIGHT_HOLD_MAX_MS = 60 * 60 * 1_000; // 60 minutes const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection @@ -141,10 +171,9 @@ export class GlobalDaemon { private pendingInstructions = new Map(); /** True once `weave.init` has completed. All span emission is gated on it. */ private tracingEnabled = false; - /** Cross-session team correlation, keyed by `${team_name}::${name}`. Bridges - * the coordinator's PreToolUse(Agent) to each teammate's TeammateIdle. The - * value is a FIFO queue: a re-spawned `${team}::${name}` appends rather than - * overwriting, so two live spans for the same name never collide. */ + /** Cross-session team correlation (coordinator's PreToolUse(Agent) → the + * teammate's TeammateIdle), keyed `${team_name}::${name}`. FIFO queue per + * key so a re-spawned name never overwrites a live span. */ private teamMembers = new Map(); constructor( @@ -167,10 +196,8 @@ export class GlobalDaemon { this.log('INFO', 'No weave_project / API key configured — tracing disabled'); } - // Bind the socket, exiting cleanly if another daemon already owns it. - // Concurrent hook invocations can each cold-start a daemon, but only one - // can bind; the losers exit (process.exit(0)) and their hook still reaches - // the winner over the socket. See bindSocketWithHerdProtection. + // Concurrent hook invocations can each cold-start a daemon; only one + // binds, the losers exit and their event reaches the winner over the socket. await this.bindSocketWithHerdProtection(); this.running = true; @@ -178,13 +205,11 @@ export class GlobalDaemon { process.on('SIGTERM', () => void this.shutdown('SIGTERM')); process.on('SIGINT', () => void this.shutdown('SIGINT')); - // Without SIGHUP, Node terminates the process on terminal close with no JS - // handler — leaving the socket inode behind for the next hook event to - // mistake for a live daemon. Routing SIGHUP through shutdown() unlinks it. + // Without a SIGHUP handler, terminal close kills the process and leaves a + // stale socket inode behind; routing it through shutdown() unlinks it. process.on('SIGHUP', () => void this.shutdown('SIGHUP')); - // Belt-and-suspenders: catch any non-signal exit (uncaught exception, - // process.exit from elsewhere) and remove the inode. Does NOT cover SIGKILL - // or OOM — the hook handler's probe handles those at next event. + // Non-signal exits (uncaught exception, process.exit) also unlink. SIGKILL + // and OOM are not coverable — the hook handler's probe recovers those. process.on('exit', () => { try { if (fs.existsSync(this.socketPath)) fs.unlinkSync(this.socketPath); } catch { /* nothing more we can do */ } }); @@ -228,12 +253,9 @@ export class GlobalDaemon { }); } - /** - * Bind the daemon socket, tolerant of a herd of concurrent starts. Listen; on - * EADDRINUSE/EEXIST, re-probe: a live listener means another daemon won → exit - * 0; a stale inode is unlinked and retried. Only a confirmed-stale socket is - * ever unlinked, so a late starter can't delete the winner's live socket. - */ + /** Bind the socket, tolerant of a start herd: on EADDRINUSE/EEXIST, a live + * listener means another daemon won (exit 0); only a confirmed-stale inode + * is unlinked and retried, so a late starter can't delete the winner's. */ private async bindSocketWithHerdProtection(): Promise { const MAX_RECLAIM_ATTEMPTS = 5; for (let attempt = 0; ; attempt++) { @@ -265,18 +287,15 @@ export class GlobalDaemon { throw new Error(`Invalid weave_project format: '${this.config.weaveProject}' (expected entity/project)`); } - // The Weave SDK has no programmatic apiKey/host in its Settings; it resolves - // both from the environment (weave login() would instead write a netrc - // entry, which is wrong for a background daemon). WF_TRACE_SERVER_URL points - // the OTLP exporter straight at our trace server; WANDB_API_KEY supplies the - // auth header. We deliberately do NOT set WANDB_BASE_URL (weave treats that - // as the API host and would derive a wrong trace URL from it). + // The SDK has no programmatic apiKey/host (login() writes netrc — wrong + // for a daemon); it reads env. WF_TRACE_SERVER_URL aims the exporter at + // our trace server. WANDB_BASE_URL is deliberately NOT set: weave treats + // it as the API host and would derive a wrong trace URL from it. process.env['WF_TRACE_SERVER_URL'] = this.config.baseUrl; process.env['WANDB_API_KEY'] = this.config.apiKey; - // Route OTel's internal warnings/errors into the daemon log. The batch - // exporter fails silently otherwise (a bad key or unreachable trace host - // drops every span with nothing logged anywhere). + // Route OTel diag warnings/errors into the daemon log — the batch exporter + // otherwise drops every span silently on a bad key or unreachable host. const otelDiag = (message: string, ...args: unknown[]) => this.log('ERROR', `otel: ${message}${args.length ? ` ${args.map(String).join(' ')}` : ''}`); diag.setLogger( @@ -372,9 +391,8 @@ export class GlobalDaemon { // ── event routing ───────────────────────────────────────────────────────── private async routeEvent(payload: HookPayload): Promise { - // The socket delivers raw hook JSON; trust it against the SDK's hook schema - // once here so the dispatch and handlers work with typed, discriminated - // inputs instead of re-casting every field. + // Trust the raw hook JSON against the SDK's schema once here so dispatch + // and handlers work with typed, discriminated inputs. const input = payload as HookInput; const sessionId = input.session_id; if (!sessionId) { @@ -384,17 +402,14 @@ export class GlobalDaemon { this.log('INFO', `${input.hook_event_name} session=${sessionId}${input.agent_id ? ` agent=${input.agent_id}` : ''}`); - // Each event runs in its own isolated frame so the SDK's single-active - // guards (one Conversation/Turn/LLM per frame) never trip across - // concurrently open sessions. Identity doesn't ride on the frame: the - // conversation's id and attributes forward through the held handles - // (conversation → turn → llm/tool/subagent) onto every span. + // Fresh frame per event so the SDK's single-active guards never trip + // across concurrent sessions. Identity rides the held handles + // (conversation → turn → children), not the frame. await weave.runIsolated(() => this.dispatchEvent(input, sessionId)); } - /** Run the handler for a single hook event, narrowing `input` to the event's - * variant via the discriminant. Split out from `routeEvent` so the latter can - * run it inside the isolated per-event context. */ + /** Narrow `input` via the discriminant and run its handler (inside the + * per-event frame `routeEvent` installs). */ private async dispatchEvent(input: HookInput, sessionId: string): Promise { try { switch (input.hook_event_name) { @@ -494,20 +509,12 @@ export class GlobalDaemon { } /** - * Resolve the canonical `gen_ai.conversation.id` for a session by walking - * the `forkedFrom.sessionId` chain to the root. Returns `sessionId` itself - * for fresh (non-forked) sessions, or when the chain can't be resolved. - * - * `claude --continue` / `claude --resume ` produce a new process-level - * session_id but stamp every transcript line with `forkedFrom.sessionId` - * pointing at the immediate parent. Each transcript file is named after - * its session id and lives in the same project directory, so walking the - * chain is just sibling-file reads. - * - * SessionStart fires roughly when Claude Code flushes the first transcript - * line, so we retry briefly if the file isn't readable yet. The hard cap - * on chain depth is a sanity guard against pathological forking, not a - * real limit. + * Canonical `gen_ai.conversation.id`: the root of the `forkedFrom.sessionId` + * chain. `--continue`/`--resume` mint a new session_id but stamp the parent + * on every transcript line, and ancestors are sibling files named by session + * id, so the walk is plain file reads. Returns `sessionId` when there is no + * chain. The first head read retries briefly (SessionStart can beat the + * transcript flush); the depth cap is a pathological-forking guard. */ private async resolveConversationId( sessionId: string, @@ -544,9 +551,8 @@ export class GlobalDaemon { const parentPath = path.join(transcriptDir, `${parent}.jsonl`); current = parent; if (!fs.existsSync(parentPath)) { - // Parent transcript not on disk (e.g., resumed across machines). - // Stop here — the recorded parent id is still the best stitching - // key we have, even though we can't verify if IT was a fork too. + // Parent transcript not on disk (e.g. resumed across machines): stop — + // the recorded parent id is still the best stitching key we have. this.log( 'DEBUG', `resolveConversationId: parent transcript not on disk: ${parentPath} — stopping chain walk at ${parent}`, @@ -557,9 +563,8 @@ export class GlobalDaemon { } if (current !== sessionId && source !== 'resume') { - // Fork detected but `source` doesn't say resume — log so the mismatch - // is visible. We still stitch by the chain root because that's the - // correct behavior; this just surfaces an unexpected hook payload. + // Unexpected payload (fork found but source isn't 'resume') — still + // stitch by the chain root; just surface the mismatch. this.log( 'DEBUG', `resolveConversationId: forkedFrom chain found but source='${source}' (expected 'resume') session=${sessionId} root=${current}`, @@ -569,13 +574,10 @@ export class GlobalDaemon { } /** - * Return the tracked session, reconstructing it from the event's - * `transcript_path` when this daemon never saw its SessionStart. The daemon - * idles out after a short quiet window and keeps all session state in memory; - * Claude Code only emits SessionStart on startup/resume/clear/compact, so a - * session that outlives a daemon restart would otherwise be permanently - * untraced (the "Unknown session" errors). Every hook event carries - * `transcript_path`, which is enough to rebuild state and resume tracing. + * Return the tracked session, rebuilding it from the event's + * `transcript_path` when this daemon never saw its SessionStart — sessions + * outlive daemon restarts, and Claude Code re-emits SessionStart only on + * startup/resume/clear/compact. */ private async getOrReconstructSession( sessionId: string, @@ -633,22 +635,12 @@ export class GlobalDaemon { } /** - * Capture one instruction file (global/project CLAUDE.md, .claude/rules, - * @-import) from InstructionsLoaded for `gen_ai.system_instructions`. - * - * The hook is observability-only and gives `file_path`, not contents, so we - * read the file ourselves. The read is synchronous to keep a session-start - * burst in load order (async reads could reorder); the files are small and - * local, off Claude Code's hot path. - * - * The hook's order vs SessionStart is not guaranteed (a file can load first), - * so instructions arriving before the session exists are buffered and drained - * on creation. We do not reconstruct here: handleSessionStart is idempotent, - * so a premature reconstruct would no-op the real one and lose its source/model. - * - * Only files loaded while this daemon runs are captured; a session - * reconstructed after a restart starts empty and picks up only files that - * (re)load afterward (e.g. load_reason=compact). + * Capture one instruction file (CLAUDE.md, .claude/rules, @-imports) for + * `gen_ai.system_instructions`. The hook carries only file_path, so read the + * file ourselves — synchronously, to keep a session-start burst in load + * order. Files can load BEFORE SessionStart, so instructions for unknown + * sessions are buffered and drained at session creation. (No reconstruct + * here: it would no-op the real SessionStart and lose its source/model.) */ private handleInstructionsLoaded(sessionId: string, input: InstructionsLoadedHookInput): void { // Without tracing there is no turn to stamp these on — skip the file reads @@ -689,14 +681,10 @@ export class GlobalDaemon { this.log('DEBUG', `Drained ${pending.length} buffered instruction file(s) into session ${session.sessionId}`); } - /** - * Open a turn under the session's conversation and stamp the per-turn session - * metadata. Each turn is the root of its own trace; the backend stitches - * turns into a conversation via `gen_ai.conversation.id`, which — with the - * agent identity and integration attributes — the conversation handle seeds - * onto the turn and its whole span subtree. Session metadata is stamped - * per-turn so it's queryable without a session-level span. - */ + /** Open a turn under the session's conversation and stamp per-turn session + * metadata (queryable without a session-level span). Each turn roots its + * own trace; the conversation handle seeds conversation.id, agent identity, + * and integration attrs onto the whole subtree. */ private startSessionTurn(session: SessionState, displayName: string, userMessage?: string): weave.Turn | undefined { if (!session.conversation) return undefined; const turn = session.conversation.startTurn({ @@ -719,9 +707,6 @@ export class GlobalDaemon { } private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { - // Reconstruct the session if this daemon never saw its SessionStart (e.g. it - // idled out mid-session and a fresh daemon took over) so the rest of the - // session stays traced instead of dropping with "Unknown session". const session = await this.getOrReconstructSession(sessionId, input); if (!session) { this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); @@ -787,10 +772,8 @@ export class GlobalDaemon { }; if (prompt) subAttrs[ATTR.INPUT_MESSAGES] = jsonStr([{ role: 'user', content: prompt }]); subAgent.setAttributes(subAttrs); - // Agent-teams: when the Agent tool carries a `team_name`, the teammate - // runs as its own session and TeammateIdle fires under the teammate's - // session_id. Register the SubAgent in the cross-session team map so - // TeammateIdle can find it regardless of which session fires it. + // A team_name spawn runs as its own session; register in the + // cross-session map so its TeammateIdle finds the span from any session. const teamName = typeof toolInput['team_name'] === 'string' ? toolInput['team_name'] : undefined; const memberName = (typeof toolInput['name'] === 'string' && toolInput['name']) ? toolInput['name'] : subagentType; session.subagents.add({ @@ -802,10 +785,6 @@ export class GlobalDaemon { teamName, }); if (teamName && session.conversation) { - // Append to the per-key FIFO queue (do NOT overwrite): the same - // `${team}::${name}` may be spawned again later in the run (e.g. TARS - // re-spawns a specialist Sonnet→Opus). Overwriting would orphan the - // first, still-open span and mis-route its teammate's transcript. const key = `${teamName}::${memberName}`; const queue = this.teamMembers.get(key) ?? []; queue.push({ @@ -848,27 +827,18 @@ export class GlobalDaemon { } /** - * Advance the chat-span state machine for the main agent: find the assistant - * response that produced `toolUseId`, ensure its `chat` span (an LLM) is open, - * and return it so the tool span nests under it. Reads the transcript to map - * the tool_use to its response; on a transition to a new response, finalizes - * the previous chat span first. Returns the LLM, or `undefined` if the - * transcript can't be located / parsed yet or the response has no model yet - * (LLMInit.model is required); the caller then falls back to the turn span. - * - * The response's text/thinking blocks are NOT set here; they become - * `gen_ai.output.messages` parts when the chat span is finalized (next - * transition or Stop), once all of the response's split transcript lines are - * present. (Claude Code writes each content block as its own transcript line - * sharing a `message.id`; at PreToolUse the trailing lines may not be flushed - * yet.) + * Chat-span state machine for the main agent: map `toolUseId` to its + * assistant response via the transcript, finalize the previous response's + * span on transition, and return the open one so the tool nests under it. + * Undefined when the transcript or the response's model hasn't flushed yet; + * the caller falls back to the turn. Output parts land at finalize time, + * once all of the response's split lines are on disk. */ private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { if (!session.currentTurn) return undefined; - // Re-parses the whole transcript per main-agent PreToolUse: O(size) per - // tool call. Off CC's critical path (async daemon), so no editor latency; - // parse the current turn's tail instead if it shows up in profiling. + // O(transcript) re-parse per tool call — off CC's critical path (async + // daemon); parse the turn's tail instead if it shows up in profiling. let fd: number; try { fd = session.transcript.getFd(); @@ -880,12 +850,9 @@ export class GlobalDaemon { const lastTurn = parsed.turns.at(-1); if (!lastTurn) return undefined; const calls = lastTurn.assistantCalls(); - const key = findToolUseResponseKey(calls, toolUseId); - if (!key) { - // Transcript writer hasn't flushed the assistant message yet. Fall back - // to the turn span. - return undefined; - } + const idx = calls.findIndex(c => c.contentBlocks.some(b => isToolUseBlock(b) && b.id === toolUseId)); + if (idx < 0) return undefined; // writer hasn't flushed the assistant message yet + const key = chatMessageKey(calls[idx], idx); // Transition to a new API response: finalize the previous chat span first. if (session.activeChat && session.activeChat.responseKey !== key) { @@ -893,13 +860,8 @@ export class GlobalDaemon { } if (!session.activeChat) { - // key came from findToolUseResponseKey above, so the group is non-empty. - const group = callsForResponseKey(calls, key); - // If the writer hasn't flushed the model yet, fall back to the turn span - // (matching the undefined-transcript path); the response's chat span is - // emitted at Stop once the model is present. - const llm = openChatForGroup(session.currentTurn, group); - if (!llm) return undefined; + const llm = openChat(session.currentTurn, calls[idx]); + if (!llm) return undefined; // model not flushed yet — emitted at Stop instead session.activeChat = { responseKey: key, llm }; session.emittedChatSpanResponseKeys.add(key); } @@ -915,14 +877,10 @@ export class GlobalDaemon { session.activeChat = undefined; } - /** - * Emit a complete chat span (LLM) for one assistant API response `key`. The - * response's ordered text / thinking / tool_use blocks become - * `gen_ai.output.messages` parts, so the model's natural interleave shows on - * the single chat span (its tool calls nest under it as `execute_tool` - * children). Reuses `existingLlm` when the span was already opened during - * PreToolUse; otherwise opens a fresh one under the turn span. - */ + /** Emit a complete chat span for response `key`: its ordered text/thinking/ + * tool_use blocks become `gen_ai.output.messages` parts (tool calls nest + * under it as children). Reuses `existingLlm` when PreToolUse already + * opened the span; otherwise opens a fresh one under the turn. */ private emitChatSpanForResponse( session: SessionState, calls: AssistantCallDetail[], @@ -930,22 +888,20 @@ export class GlobalDaemon { existingLlm?: weave.LLM, ): void { if (!session.currentTurn) return; - const group = callsForResponseKey(calls, key); - // Empty group: `key` is stale relative to `calls` — an interrupted turn's - // activeChat finalized against the next turn's parse. Close the span bare - // rather than fabricate content (or throw on group.at(-1)). - if (!group.length) { + const call = calls.find((c, i) => chatMessageKey(c, i) === key); + // No match: `key` is stale relative to `calls` — an interrupted turn's + // activeChat finalized against the next turn's parse. Close bare rather + // than fabricate content. + if (!call) { existingLlm?.end(); return; } - // A response with no model yet can't open a chat span (LLMInit.model is - // required); skip it rather than guess a model. - const llm = existingLlm ?? openChatForGroup(session.currentTurn, group); + const llm = existingLlm ?? openChat(session.currentTurn, call); if (!llm) { this.log('DEBUG', `Chat span skipped (no model flushed for response ${key}); usage not recorded`); return; } - recordChat(llm, group); + recordChat(llm, call); session.emittedChatSpanResponseKeys.add(key); } @@ -987,14 +943,11 @@ export class GlobalDaemon { } /** - * Settle the Agent-dispatch tracker for `toolUseId` at PostToolUse[Failure], - * if one exists. An Agent tool call has no pendingToolCall; its span is the + * Settle an Agent-dispatch tracker at PostToolUse[Failure]: its span is the * subagent's `invoke_agent` marker, closed here with the tool's canonical - * return. Team spawns are the exception: the Agent tool returns immediately - * (the teammate runs async in its own session), so the marker stays open — - * the team map owns it and the teammate's TeammateIdle closes it — and only - * the per-session tracker is dropped. Returns true when the tool call was a - * subagent dispatch. + * return. Team spawns stay open (the Agent tool returns immediately; the + * team map owns the marker until the teammate's TeammateIdle) — only the + * tracker drops. Returns true when `toolUseId` was a subagent dispatch. */ private settleSubagentDispatch( session: SessionState, @@ -1056,12 +1009,9 @@ export class GlobalDaemon { this.countToolCall(session, pending.toolName); } - /** - * Close a subagent's `invoke_agent` marker span. Idempotent: guarded by - * `tracker.ended` so PostToolUse and SubagentStop can both safely call this - * regardless of order. Sets `gen_ai.output.messages` from the canonical - * tool return string when available; marks the span ERROR on failure. - */ + /** Close a subagent's `invoke_agent` marker with its output; ERROR on + * failure. Idempotent via `tracker.ended`, so PostToolUse and SubagentStop + * can both call it regardless of order. */ private closeSubagent( tracker: SubagentTracker, output: unknown, @@ -1092,11 +1042,9 @@ export class GlobalDaemon { const agentType = input.agent_type; - // Content-based deterministic correlation: SubagentStart carries no - // pointer back to the parent's `tool_use_id`, so we read the subagent's - // transcript line 1 (the firing user prompt — byte-identical to the - // parent Agent tool's `tool_input.prompt`) and match by sha256 of that - // string plus the subagent_type. No temporal window. + // SubagentStart carries no pointer to the spawning tool_use_id — match + // deterministically by sha256 of the firing prompt (the subagent + // transcript's line 1, byte-identical to the Agent tool's prompt) + type. const subagentPath = computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); const firstLine = await readSubagentFirstLineWithRetry(subagentPath); const firingPrompt = extractUserMessageContent(firstLine); @@ -1199,10 +1147,8 @@ export class GlobalDaemon { return; } - // The subagent's LLM calls nest under its `invoke_agent` marker, so its - // work (and token usage) reads as the subagent's own subtree. Orphans that - // never got a marker fall back to the turn; the `gen_ai.agent.name` tag on - // each chat keeps them queryable by agent either way. + // Chats nest under the subagent's marker; orphans without one fall back to + // the turn. The agent.name tag keeps them queryable either way. const chatParent = tracker.subAgent ?? session.currentTurn; // Fall back to the stored or agentId-derived path when the payload omits it. @@ -1267,41 +1213,26 @@ export class GlobalDaemon { private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { if (!this.tracingEnabled) return; - // FAIL-OPEN on a missing session: in the agent-teams model this hook fires - // under the TEAMMATE's session_id, which may not be registered with this - // daemon (only the coordinator is). `session` is therefore OPTIONAL for the - // cross-session team path and only REQUIRED for the per-session fallback. - // Do NOT early-return on a missing session — that would silently drop - // cross-session nesting, the whole point of this handler. + // Do NOT early-return on a missing session: this hook fires under the + // TEAMMATE's session_id, which the daemon usually doesn't track. `session` + // is optional for the cross-session path, required only for the fallback. const session = this.sessions.get(sessionId); - // TeammateIdle payload (actual schema, confirmed from live TARS triage): - // session_id — the teammate's session UUID (NOT the coordinator's) - // teammate_name — agent name, e.g. "cks-specialist". INVARIANT: must equal - // the `name` the coordinator passed to the Agent tool (in - // TARS, name === subagent_type), else the lookup misses. - // team_name — team name, e.g. "triage-supp-25017" - // transcript_path — CC sets this to the coordinator's transcript (not the - // teammate's), so we ignore it and use the path stored - // at SubagentStart instead. - // - // Note: CC docs incorrectly listed agent_id / agent_type — those fields do - // not appear in practice. + // Payload (verified against live TARS runs; CC docs wrongly list + // agent_id/agent_type): teammate_name must equal the Agent tool's `name` + // or the lookup misses, and transcript_path is the COORDINATOR's — the + // teammate's own path is resolved separately. const agentType = input.teammate_name; const teamName = input.team_name; - // ── Cross-session team path (agent-teams / TeamCreate model) ───────── - // The coordinator's PreToolUse(Agent, team_name) registered the invoke_agent - // span in teamMembers under `${team_name}::${name}` (a FIFO queue). Consume - // the OLDEST not-yet-emitted entry, so re-spawns of the same name match in - // dispatch order instead of overwriting each other. + // Cross-session team path: consume the oldest not-yet-emitted queue entry + // for `${team}::${name}`, so re-spawns match in dispatch order. const key = `${teamName}::${agentType}`; const queue = this.teamMembers.get(key); if (queue && queue.length) { const member = queue.find(m => !m.emitted); if (!member) { - // All queued spans for this key already emitted — duplicate (repeat) - // TeammateIdle. Expected; nothing to do. + // Duplicate (repeat) TeammateIdle — expected; nothing to do. this.log('DEBUG', `TeammateIdle: ${key} all ${queue.length} entries already emitted — skipping duplicate idle`); return; } @@ -1317,17 +1248,14 @@ export class GlobalDaemon { return; } - // No team entry for this key. If OTHER team keys ARE registered, this most - // likely means the teammate_name ≠ Agent.name invariant was violated — log - // it loudly (not silently) so it is debuggable, then try the per-session path. + // Other team keys registered but not this one: most likely the + // teammate_name ≠ Agent.name invariant broke — log it, then fall through. if (this.teamMembers.size > 0) { this.log('INFO', `TeammateIdle: no team entry for ${key} (registered: ${[...this.teamMembers.keys()].join(', ')}) — check teammate_name === Agent.name`); } - // ── Per-session path (individual Agent calls without team_name) ────── - // Requires the firing session to be known to this daemon. Find the orphan - // tracker created at SubagentStart; SubagentStop left its invoke_agent span - // open specifically so we can close it here with full all-turns content. + // Per-session path (Agent calls without team_name): find the orphan + // tracker whose span SubagentStop left open for us to close with content. if (!session) { this.log('DEBUG', `TeammateIdle: session ${sessionId} unknown and no team entry for ${key} — skipping`); return; @@ -1339,15 +1267,10 @@ export class GlobalDaemon { return; } - // Use the transcript path stored at SubagentStart — more reliable than - // the payload's transcript_path which CC sets to the coordinator's path. - const transcriptPath = tracker.transcriptPath; + const transcriptPath = tracker.transcriptPath; // payload's is the coordinator's this.log('DEBUG', `TeammateIdle: agent=${agentType} team=${teamName} transcript=${transcriptPath ?? '(none)'}`); - // Emit ALL turns from the teammate transcript under a fresh teammate turn - // trace (the coordinator turn that spawned it has already closed). Teammates - // are independent top-level sessions: every turn is their own work. if (!session.conversation) return; const model = this.emitTeammateTurnTrace(tracker.subAgent, session.conversation, agentType, transcriptPath); tracker.ended = true; @@ -1356,12 +1279,10 @@ export class GlobalDaemon { this.log('INFO', `TeammateIdle: traced ${agentType} model=${model ?? 'unknown'} path=${transcriptPath ?? '(no transcript)'}`); } - /** Resolve a teammate's OWN transcript. TeammateIdle.session_id is unreliable - * (sometimes the teammate's, sometimes the coordinator's), so the idle - * session's transcript may be the coordinator's. The authoritative source is - * `/subagents/agent-.jsonl` paired with a sibling - * `agent-.meta.json` carrying `{"agentType": }`. Match by - * agentType === teammateName; pick the most-recently-modified if re-spawned. */ + /** Resolve a teammate's OWN transcript: the coordinator's subagents dir + * holds `agent-.jsonl` + `agent-.meta.json` ({"agentType": name}); + * match by agentType, newest mtime wins (re-spawns). Falls back to the idle + * session's transcript (TeammateIdle.session_id is unreliable). */ private resolveTeammateTranscript( coordinatorTranscriptPath: string, teammateName: string, @@ -1389,41 +1310,26 @@ export class GlobalDaemon { return idleTranscriptPath; } - /** - * Emit one chat span (LLM) per assistant API response under `parent`, - * reconstructed from transcript data (backdated times, usage, ordered output - * parts). Split transcript lines sharing a `message.id` are grouped into one - * span — matching the live main-agent path — so a response's usage is never - * multiply counted. `agentName` tags each span so a subagent's/teammate's - * calls stay queryable by agent; conversation.id is inherited from the parent - * handle chain. - */ + /** Emit one chat span per assistant response under `parent`, reconstructed + * from transcript data (backdated times, usage, ordered output parts). */ private emitChatSpans( parent: weave.Turn | weave.SubAgent, calls: AssistantCallDetail[], agentName?: string, ): void { - const emitted = new Set(); - for (let i = 0; i < calls.length; i++) { - const key = chatMessageKey(calls[i], i); - if (emitted.has(key)) continue; - emitted.add(key); - const group = callsForResponseKey(calls, key); - const llm = openChatForGroup(parent, group); - if (llm) recordChat(llm, group, agentName); + for (const call of calls) { + const llm = openChat(parent, call); + if (llm) recordChat(llm, call, agentName); } } /** - * Emit a teammate's whole transcript as its OWN turn trace, then close the - * teammate's SubAgent marker. TeammateIdle fires after the coordinator turn - * that spawned the teammate has already closed, so the teammate can't nest - * under it; instead it gets a fresh root `invoke_agent` turn started from - * the coordinator's Conversation handle, which seeds the conversation.id - * and integration identity (neither is inherited cross-session) onto the - * whole subtree. The turn is backdated to span the transcript's first - * request through its last response, so its backdated chat children stay - * inside the parent's time window. Returns the teammate's model, if known. + * Emit a teammate's whole transcript as its OWN turn trace (the spawning + * coordinator turn has long closed), then close the SubAgent marker. The + * coordinator's Conversation handle seeds conversation.id + integration + * identity, neither of which inherits cross-session; the turn is backdated + * to span the transcript so its chat children stay inside its window. + * Returns the teammate's model, if known. */ private emitTeammateTurnTrace( subAgent: weave.SubAgent, @@ -1478,10 +1384,8 @@ export class GlobalDaemon { const session = this.sessions.get(sessionId); if (!session) return; - // The SDK's PreCompactHookInput exposes trigger/custom_instructions; the - // Weave Agents backend wants a compaction summary + item counts, which live - // CC payloads carry but the SDK type doesn't declare, so read them off the - // raw record. + // Live CC payloads carry a summary + item counts the SDK type doesn't + // declare — read them off the raw record. const raw = input as Record; const summary = raw['summary'] ?? raw['compaction_summary']; const itemsBefore = raw['items_before']; @@ -1506,8 +1410,8 @@ export class GlobalDaemon { const session = this.sessions.get(sessionId); if (!session?.currentTurn) return; - // Pass last_assistant_message so the retry waits for the synthesis to - // flush; otherwise the final chat span drops when the read races the writer. + // The retry waits for the final synthesis to flush; otherwise the last + // chat span drops when the read races the writer. const finalAssistantMessage = input.last_assistant_message; const parsedSession = await this.parseSessionFileWithRetry( session.transcript, @@ -1521,19 +1425,13 @@ export class GlobalDaemon { `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, ); - // Finalize the chat-span state machine for this turn. - // - The active chat span (open during PreToolUse) gets its output parts - // plus its usage attrs, then ends. - // - Assistant calls that never triggered a PreToolUse (final text-only - // message, or any other tool-less call) get a fresh chat span emitted - // here with their full content as output parts. + // Finalize the active chat span (open since PreToolUse), then emit fresh + // spans for responses that never opened one (tool-less responses). if (currentTurn) { const calls = currentTurn.assistantCalls(); if (session.activeChat) { this.finalizeActiveChatSpan(session, calls); } - // Emit a chat span for every response that never opened one during - // PreToolUse (tool-less responses, or any not yet emitted). for (let i = 0; i < calls.length; i++) { const key = chatMessageKey(calls[i], i); if (session.emittedChatSpanResponseKeys.has(key)) continue; @@ -1622,15 +1520,13 @@ export class GlobalDaemon { } /** - * Close everything still open on the current turn — pending tool calls, the - * active chat span, and the turn (root) span itself — stamping - * `orphanReason`. The chat span is finalized from the now-flushed - * transcript, like Stop does, so its output + usage aren't lost; only a - * failed parse falls back to a bare orphan close. Called from - * `finalizeSession` and from `handleUserPromptSubmit` when a user interrupt - * ended the previous turn without a Stop hook — the interrupt also kills - * in-flight tools (no PostToolUse will follow), and without this, opening - * the next turn would overwrite the handle and leak the root unexported. + * Close everything still open on the current turn (pending tools, active + * chat, the root span), stamping `orphanReason`. The chat span is finalized + * from the transcript like Stop does; only a failed parse closes bare. + * Callers: finalizeSession, and handleUserPromptSubmit for turns a user + * interrupt ended without a Stop hook (the interrupt also kills in-flight + * tools) — otherwise the next turn would overwrite the handle and leak the + * root unexported. */ private finalizeOpenTurn(session: SessionState, orphanReason: string): void { for (const [toolUseId, pending] of session.pendingToolCalls) { @@ -1677,23 +1573,15 @@ export class GlobalDaemon { private checkInactivity(): void { const idle = Date.now() - this.lastActivity; if (idle <= this.inactivityMs) return; - // Do NOT shut down while cross-session team correlation is in flight: a - // shutdown wipes the in-memory teamMembers map and breaks nesting for every - // still-open specialist span. Agent-teams runs have quiet windows (engineer - // think-time; gaps between spawn and first teammate report) that would - // otherwise trip the 10-min timeout mid-triage. Hold open until the team - // work drains, bounded by INFLIGHT_HOLD_MAX_MS so a crashed teammate that - // never emits TeammateIdle can't pin the daemon indefinitely. + // A shutdown wipes the in-memory teamMembers map and breaks nesting for + // every still-open specialist span; agent-teams runs have long quiet + // windows, so hold open (bounded) until the team work drains. if (idle < INFLIGHT_HOLD_MAX_MS && this.hasUnemittedTeamMembers()) { this.log('DEBUG', 'Inactivity timeout reached but team correlation in flight — staying up'); return; } - // Also hold open while ordinary work is in flight: an open turn span, a - // pending tool call, or a tracked subagent. A long-running tool or turn - // (longer than the timeout, with no other session active) would otherwise - // trip the timeout mid-flight — dropping the still-open spans and forcing - // the resumed work onto a fresh, amnesiac daemon. Same INFLIGHT_HOLD_MAX_MS - // ceiling so a stuck session can't pin the daemon indefinitely. + // Same for ordinary in-flight work: a tool or turn running longer than + // the timeout would otherwise be cut off mid-flight. if (idle < INFLIGHT_HOLD_MAX_MS && this.hasInFlightWork()) { this.log('DEBUG', 'Inactivity timeout reached but work in flight — staying up'); return; @@ -1702,8 +1590,7 @@ export class GlobalDaemon { void this.shutdown('inactivity'); } - /** True if any registered team member still awaits its TeammateIdle. Used to - * keep the daemon alive across an agent-teams run's quiet windows. */ + /** True if any registered team member still awaits its TeammateIdle. */ private hasUnemittedTeamMembers(): boolean { for (const queue of this.teamMembers.values()) { if (queue.some(m => !m.emitted)) return true; @@ -1711,9 +1598,7 @@ export class GlobalDaemon { return false; } - /** True if any session has work in flight: an open turn span, a pending tool - * call, or a tracked subagent. Keeps the daemon alive across the inactivity - * timeout so in-flight work isn't cut off mid-flight (see checkInactivity). */ + /** True if any session has an open turn, a pending tool, or a tracked subagent. */ private hasInFlightWork(): boolean { for (const s of this.sessions.values()) { if (s.currentTurn) return true; @@ -1731,22 +1616,15 @@ export class GlobalDaemon { } /** - * Everything a shutdown does except the final `process.exit`: end in-flight - * spans, flush the exporter, and release the socket. Split out from - * `shutdown` so it can be exercised in tests without terminating the process. - * - * Order matters: open sessions are finalized (their root turn spans ended) - * BEFORE `weave.flushOTel()` runs, so those roots make the final export batch - * instead of being dropped: the fix for rootless traces left behind when the - * daemon exits mid-turn. + * Everything shutdown does except `process.exit` (testable in-process). + * Order matters: sessions finalize BEFORE `weave.flushOTel()`, so roots + * ended here make the final export batch instead of leaking rootless traces. */ private async drain(reason: string): Promise { this.log('INFO', `Shutdown: ${reason}`); this.server?.close(); - // Backstop: close any queued team-member invoke_agent spans whose teammate - // never emitted a TeammateIdle (e.g. teammate crashed, or daemon exits - // mid-triage) so they flush as ended spans — marked orphaned, matching - // finalizeSession's subagent close — instead of leaking. + // Close queued team-member spans whose teammate never reported (crashed, + // or the daemon exits mid-triage) as orphaned instead of leaking them. for (const [, queue] of this.teamMembers) { for (const m of queue) { if (m.emitted) continue; @@ -1757,8 +1635,6 @@ export class GlobalDaemon { } } this.teamMembers.clear(); - // Finalize every live session's still-open spans (turn root, active chat, - // pending tools, subagents) so an interrupted turn keeps its exported root. // Per-session try: one bad session must not abort the flush below. for (const session of this.sessions.values()) { try { @@ -1784,10 +1660,9 @@ export class GlobalDaemon { // ── helpers ─────────────────────────────────────────────────────────────── - /** Retry parseSessionFile while the transcript writer catches up to Stop. - * If `finalAssistantMessage` is set, require the last assistant call's - * text to end with it (mod trailing whitespace) — guards against reading - * before the synthesis line lands. Default budget: 5 × 200ms = 1s. */ + /** Retry the transcript parse while the writer catches up to Stop; when + * `finalAssistantMessage` is set, require the last assistant text to end + * with it. Budget: 5 × 200ms. */ private async parseSessionFileWithRetry( transcript: TranscriptFile, finalAssistantMessage?: string, @@ -1805,12 +1680,9 @@ export class GlobalDaemon { let result: ReturnType = null; for (let i = 0; i < attempts; i++) { result = parseSessionFd(fd); - // Writer caught up: parsed at least one turn AND (no synthesis to verify, - // OR the last assistant call ends with it). if (result?.turns.length && (!expected || lastAssistantTextEndsWith(result, expected))) { return result; } - // No next parse to wait for on the last iteration, so skip the sleep. if (i < attempts - 1) await new Promise(r => setTimeout(r, delayMs)); } return result; diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 503d6b6..a73339b 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -2,25 +2,19 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Attribute-key constants, formatting helpers, and thin span-shaping helpers -// typed against the `weave` SDK. Span construction/lifecycle lives with the -// SDK handles: conversations start in sessionState.ts, turns/tools/subagents -// in daemon.ts, and chat (LLM) spans via chatSpans.ts. +// Attribute-key constants and formatting helpers. Span construction lives +// with the SDK handles: conversations start in sessionState.ts, everything +// else in daemon.ts. import type { Attributes } from '@opentelemetry/api'; import type { MessagePart, Tool, Turn, Usage } from 'weave'; import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; import type { UsageSummary } from './parser.js'; -// ───────────────────────────────────────────────────────────────────────────── -// Attribute keys -// -// Canonical `gen_ai.*` keys come from the OTel GenAI semantic conventions -// (https://github.com/open-telemetry/semantic-conventions-genai). `weave.*` keys are -// Claude-Code-specific extensions with no semconv equivalent. Compaction keys -// (`weave.compaction.*`) match the Weave Agents backend's semconv exactly - -// the backend extracts them into dedicated span columns. -// ───────────────────────────────────────────────────────────────────────────── +// Attribute keys: `gen_ai.*` from the OTel GenAI semconv +// (https://github.com/open-telemetry/semantic-conventions-genai); `weave.*` +// are Claude-Code-specific extensions the backend routes into its queryable +// custom-attribute maps (compaction keys get dedicated columns). export const ATTR = { // GenAI semconv - classification @@ -60,21 +54,15 @@ export const ATTR = { WEAVE_ORPHAN_REASON: 'weave.claude_code.orphan_reason', WEAVE_DISPLAY_NAME: 'weave.claude_code.display_name', - // Integration identity - attributes the trace to the emitting integration - // (this plugin) so the Weave Agents backend can group/filter by integration - // alongside peers (weave-openclaw, the playground's `weave.source`). Distinct - // from `gen_ai.agent.name`, which is user-overridable and changes per - // subagent. These are non-semconv `weave.*` keys, so the backend routes them - // into its queryable custom-attribute maps. Installed on the session's - // conversation so the SDK copies them onto every span; `meta.*` keys (built - // with WEAVE_INTEGRATION_META_PREFIX) carry free-form per-session context. + // Integration identity: attributes the trace to this plugin, alongside + // peers like weave-openclaw. Unlike gen_ai.agent.name it is not + // user-overridable and never changes per subagent. Set on the session's + // conversation so the SDK copies it onto every span. WEAVE_INTEGRATION_NAME: 'weave.integration.name', WEAVE_INTEGRATION_VERSION: 'weave.integration.version', - // Back-pointer from a subagent `invoke_agent` span to the parent agent's - // `Agent` tool call that spawned it. Set on the inner `invoke_agent` span - // so queries can correlate the subagent invocation with the spawning - // tool_use_id without walking the span tree. + // Back-pointer from a subagent's invoke_agent span to the tool_use_id of + // the Agent call that spawned it (correlation without walking the tree). WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID: 'weave.claude_code.subagent.spawning_tool_call_id', // Weave Agents backend - compaction (set as span attributes on the turn span; @@ -90,28 +78,14 @@ export const ATTR = { EVT_PERMISSION_SUGGESTIONS: 'weave.permission.suggestions', } as const; -/** - * Default name for the top-level agent: the value shown in Weave's Agents - * view and stamped as `gen_ai.agent.name` on every turn span. Users can - * override it (settings `agent_name` / `WEAVE_AGENT_NAME`); this is the - * fallback when neither is set. - */ +/** Top-level `gen_ai.agent.name` fallback; users override via settings + * `agent_name` / `WEAVE_AGENT_NAME`. */ export const DEFAULT_AGENT_NAME = 'claude-code'; -/** - * Stable identifier for this integration, stamped as `weave.integration.name` - * on every turn span. Unlike the agent name (`gen_ai.agent.name`), it is not - * user-overridable and does not change for subagents, so it's a reliable - * dimension for "which integration produced this trace" in the Weave Agents - * backend. - */ const INTEGRATION_NAME = 'weave-claude-code'; -/** - * Prefix for free-form integration metadata. Each entry of a session's - * `integrationMeta` is stamped as `weave.integration.meta.`, so new - * fields (e.g. `claude_code_app_version`) need no new attribute constant. - */ +/** Free-form integration metadata prefix: new fields (e.g. + * `claude_code_app_version`) need no new attribute constant. */ const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; // ───────────────────────────────────────────────────────────────────────────── @@ -153,13 +127,8 @@ export function parseTimestamp(ts: string | undefined): Date | undefined { return Number.isFinite(d.getTime()) ? d : undefined; } -/** - * Build the per-session integration attributes. `version` is the plugin - * version; `meta` is free-form per-session context flattened to - * `weave.integration.meta.` (falsy values skipped). Installed on the - * session's conversation so the SDK stamps them onto every span the session - * emits (turn root and all children). - */ +/** Per-session integration attributes; `meta` flattens to + * `weave.integration.meta.` (falsy values skipped). */ export function buildIntegrationAttrs(args: { version: string; meta?: Record; @@ -176,13 +145,9 @@ export function buildIntegrationAttrs(args: { return attrs; } -/** - * Map Claude assistant content blocks to ordered `MessagePart`s for a chat - * span's `gen_ai.output.messages`. Preserves transcript order so the model's - * natural interleave (text -> tool_use -> text) is visible in the Weave UI. - * text -> text part; thinking / redacted_thinking -> reasoning part; tool_use - * -> tool_call part. Empty text/thinking blocks are skipped. - */ +/** Map assistant content blocks to ordered `MessagePart`s (text → text, + * thinking/redacted → reasoning, tool_use → tool_call; empties skipped), so + * the model's natural interleave survives into `gen_ai.output.messages`. */ export function contentBlocksToParts(blocks: unknown[]): MessagePart[] { const parts: MessagePart[] = []; for (const block of blocks) { @@ -206,14 +171,10 @@ export function contentBlocksToParts(blocks: unknown[]): MessagePart[] { return parts; } -/** - * Build a `weave.Usage` from Anthropic's per-call usage. OTel `inputTokens` is - * the total prompt; Anthropic splits it into three disjoint fields (uncached + - * cache_read + cache_creation), so sum them. - * https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md - * Cache and reasoning fields are set only when present so a call without them - * doesn't emit zero-valued attributes. - */ +/** Anthropic usage → `weave.Usage`. OTel inputTokens is the TOTAL prompt, so + * sum Anthropic's three disjoint fields (uncached + cache_read + + * cache_creation); optional fields are set only when present. + * https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md */ export function buildUsage(usage: UsageSummary, reasoningTokens?: number): Usage { const out: Usage = { inputTokens: @@ -262,16 +223,9 @@ export interface CompactionAttrs { itemsAfter?: number; } -/** - * Stamp `weave.compaction.*` attributes onto a turn span. The Weave Agents - * backend extracts these into dedicated columns (`compaction_summary`, - * `compaction_items_before`, `compaction_items_after`) and renders a - * "context_compacted" card in the chat view. - * - * Compaction is a session-level event, but with no session span it attaches - * to the turn span that's open when the compaction fires - or to the next - * turn span, if compaction fires between turns. - */ +/** Stamp `weave.compaction.*` onto a turn (the backend renders a + * context_compacted card). Compaction is session-level, but with no session + * span it rides the open turn — or the next one, between turns. */ export function setCompactionAttrs(turn: Turn, attrs: CompactionAttrs): void { const out: Attributes = {}; if (attrs.summary !== undefined) out[ATTR.COMPACTION_SUMMARY] = attrs.summary; diff --git a/src/parser.ts b/src/parser.ts index 2956795..08fb5a1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -12,17 +12,17 @@ export interface UsageSummary { } /** - * Per-API-call detail for a single assistant message in the transcript. - * Each entry corresponds to one LLM invocation within a turn, used to emit - * one `chat ` span per call at Stop time. + * One assistant API response within a turn. Claude Code splits a response's + * thinking / text / tool_use blocks across transcript lines sharing a + * `message.id`; `buildTurn` folds those back into a single entry. */ export interface AssistantCallDetail { - timestamp: string; // ISO timestamp of the assistant message - prevTimestamp?: string; // ISO timestamp of preceding transcript line (proxy for "request started") + timestamp: string; // ISO timestamp of the response's last transcript line + prevTimestamp?: string; // ISO timestamp of the line preceding it (proxy for "request started") model?: string; - usage: UsageSummary; // per-call usage + usage: UsageSummary; reasoningTokens?: number; // reasoning/thinking tokens, if any - contentBlocks: unknown[]; // raw assistant content blocks (text, tool_use, thinking, ...) + contentBlocks: unknown[]; // raw content blocks in transcript order (text, tool_use, thinking, ...) responseId?: string; // provider message id finishReason?: string; // stop_reason / finish_reason if present } @@ -127,7 +127,8 @@ function buildSession(lines: unknown[]): ParsedSession { } function buildTurn(assistantLines: AssistantLine[]): Turn { - const calls: AssistantCallDetail[] = assistantLines.map(({ line, prevTimestamp }) => { + const calls: AssistantCallDetail[] = []; + for (const { line, prevTimestamp } of assistantLines) { const { message } = readTranscriptLine(line); const rawUsage = (message?.['usage'] ?? line['usage'] ?? {}) as Record; const usage = rawToUsageSummary(rawUsage); @@ -145,17 +146,21 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { const stopReason = (message?.['stop_reason'] ?? message?.['finish_reason']) as string | undefined; const timestamp = (line['timestamp'] as string | undefined) ?? ''; - return { - timestamp, - prevTimestamp, - model, - usage, - reasoningTokens, - contentBlocks, - responseId, - finishReason: stopReason, - }; - }); + // Fold split lines (shared message.id) into one call per API response. + // Split lines duplicate the response usage — keep the last line's, which + // accompanies stop_reason; keep the first line's prevTimestamp as start. + const prev = calls.at(-1); + if (responseId && prev?.responseId === responseId) { + prev.contentBlocks.push(...contentBlocks); + prev.timestamp = timestamp; + prev.usage = usage; + prev.reasoningTokens = reasoningTokens ?? prev.reasoningTokens; + prev.model ??= model; + prev.finishReason ??= stopReason; + continue; + } + calls.push({ timestamp, prevTimestamp, model, usage, reasoningTokens, contentBlocks, responseId, finishReason: stopReason }); + } const model = calls.filter(call => call.model).at(-1)?.model; diff --git a/src/sessionState.ts b/src/sessionState.ts index d81e537..931b347 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -20,12 +20,9 @@ export type PendingToolCall = { permissionRequested?: boolean; } -/** Tracks the chat span (LLM) currently open for a single assistant API - * response. Tool spans the model called parent here so the trace tree shows - * them nested under the response. The response's text/thinking blocks become - * ordered `gen_ai.output.messages` parts on this span, set when it is - * finalized (at the next response transition or at Stop), once all its split - * transcript lines are present. */ +/** The chat span (LLM) open for the in-flight assistant response; its tool + * spans parent here. Content lands when it is finalized (next response + * transition, or Stop), once all its transcript lines are flushed. */ type ActiveChat = { /** Response key (Anthropic `message.id`, or index fallback) this chat span * represents; see `chatMessageKey`. */ @@ -126,22 +123,12 @@ export async function readSubagentFirstLineWithRetry( } /** - * Tracks a subagent across hook events. Two shapes: - * (a) Matched — created at PreToolUse when an Agent tool with subagent_type - * is detected; carries `toolUseId`, `promptHash`, and a reference to - * the subagent's `invoke_agent` span. `agentId` is filled in at - * SubagentStart via content-based correlation: sha256(firing prompt) + - * subagent_type. - * (b) Orphan — created at SubagentStart when no tracker matches the firing - * prompt (the parent's Agent PreToolUse never fired, or its prompt - * differs from the subagent transcript's line 1). The `invoke_agent` - * span is created at SubagentStart with the current turn span as - * parent and no input messages (the firing prompt is unavailable). - * - * The subagent is its own `invoke_agent ` span under the parent - * turn, and its chat/tool spans nest beneath it. (Why an `invoke_agent` marker - * rather than an `execute_tool` span: see the Agent-dispatch branch in - * `handlePreToolUse`.) + * Tracks a subagent across hook events. Matched trackers are created at + * PreToolUse(Agent) and correlated to an agent_id at SubagentStart by + * sha256(firing prompt) + type; orphans are created at SubagentStart when + * nothing matches. Either way the subagent is its own `invoke_agent` span + * under the turn, with its chat/tool spans nested beneath it (why a marker + * and not `execute_tool`: see handlePreToolUse's Agent-dispatch branch). */ export type SubagentTracker = { subagentType: string; @@ -155,36 +142,23 @@ export type SubagentTracker = { /** True once the invoke_agent span has been ended. Guards against * double-end when PostToolUse and SubagentStop both try to close it. */ ended?: boolean; - /** Subagent transcript path — stored at SubagentStart so TeammateIdle can - * read all turns without relying on the payload's transcript_path (which - * CC sets to the coordinator's path, not the subagent's). */ + /** Stored at SubagentStart; TeammateIdle's own transcript_path is the + * coordinator's, so this is the reliable copy. */ transcriptPath?: string; - /** Set on orphan trackers when SubagentStop fires before TeammateIdle. - * Suppresses span closure at SubagentStop so TeammateIdle can close it - * with full all-turns content. */ + /** Orphan awaiting TeammateIdle: SubagentStop leaves the span open so + * TeammateIdle can close it with full all-turns content. */ pendingTeammateIdle?: boolean; - /** Set when this Agent tool spawn carried a `team_name` (agent-teams model). - * The teammate runs in its OWN session, so its TeammateIdle fires under a - * different session_id and the per-session lookup misses. The invoke_agent - * span is registered in GlobalDaemon.teamMembers and closed there (at the - * teammate's TeammateIdle), NOT at the coordinator's PostToolUse(Agent). */ + /** Set for `team_name` spawns: the marker is owned by + * GlobalDaemon.teamMembers and closed at the teammate's TeammateIdle, + * NOT at the coordinator's PostToolUse(Agent). */ teamName?: string; } -/** Cross-session team correlation. In agent-teams (TeamCreate) a teammate is an - * independent Claude session whose TeammateIdle fires under the teammate's own - * session_id, not the coordinator's — so the per-session SubagentTracking - * lookup misses. The coordinator's PreToolUse(Agent, team_name) is the one - * reliable anchor; we record its invoke_agent span here keyed by - * `${team_name}::${name}`. - * - * Entries are stored as a FIFO queue per key (not a single value) because the - * SAME `${team}::${name}` can be spawned more than once in a run — e.g. the - * TARS triage flow re-spawns a specialist (Sonnet→Opus) for deeper work. Each - * spawn pushes its own TeamMember; each teammate's TeammateIdle consumes the - * oldest not-yet-emitted entry (FIFO), so re-spawns never overwrite a live span - * (which would leak it and mis-attribute the first teammate's transcript). This - * mirrors SubagentTracking.findPendingTeammateIdle for the per-session path. */ +/** One queued team-member spawn. A teammate is an independent session whose + * TeammateIdle fires under its OWN session_id, so the coordinator's + * PreToolUse(Agent, team_name) is the only reliable anchor: it queues the + * marker in GlobalDaemon.teamMembers (FIFO per `${team}::${name}` — the same + * name can be re-spawned; overwriting would leak the first, still-open span). */ export type TeamMember = { subAgent: weave.SubAgent; /** Coordinator's Conversation handle. The teammate's own turn trace starts @@ -207,12 +181,9 @@ export type SessionState = { source: string; initialRequestModel?: string; - /** The session's Conversation handle. Seeds `gen_ai.conversation.id`, the - * agent identity, and the integration attributes (name, version, meta.*, - * built at session creation) onto every turn started from it — and, via - * the handle chain, onto all child spans. No ambient state involved, so - * events in separate `runIsolated` frames still inherit everything. Unset - * when tracing is disabled. */ + /** Conversation handle: seeds conversation.id, agent identity, and the + * integration attributes onto every turn (and, via the handle chain, all + * children) regardless of runIsolated frame. Unset when tracing is off. */ conversation?: weave.Conversation; currentTurn?: weave.Turn; @@ -229,10 +200,8 @@ export type SessionState = { * Tool spans from PreToolUse parent here; finalized at Stop or on transition * to the next API call. Cleared at Stop. */ activeChat?: ActiveChat; - /** Response keys (see `chatMessageKey`) in the current turn for which a chat - * span has been opened (open or already finalized). Stop uses this to - * identify responses that need a chat span emitted from scratch (responses - * with no tool_use blocks never triggered PreToolUse). Reset per turn. */ + /** Response keys with a chat span already opened this turn; Stop emits + * fresh spans for the rest (tool-less responses never hit PreToolUse). */ emittedChatSpanResponseKeys: Set; /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ @@ -257,12 +226,8 @@ export class SubagentTracking { this.trackers.push(tracker); } - /** - * Find the unmatched tracker (no agent_id yet) matching `(promptHash, - * subagentType)`. FIFO across ties: the oldest pending tracker wins, so two - * back-to-back identical Agent calls still correlate in dispatch order. - * Returns undefined if no candidate qualifies. - */ + /** Oldest unmatched tracker (no agent_id yet) for `(promptHash, type)` — + * FIFO so back-to-back identical Agent calls correlate in dispatch order. */ findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { let best: SubagentTracker | undefined; for (const t of this.trackers) { @@ -278,9 +243,7 @@ export class SubagentTracking { return this.trackers.find(t => t.agentId === agentId); } - /** Find a tracker awaiting TeammateIdle by its subagentType. Used to - * correlate TeammateIdle(teammate_name) with the orphan tracker created - * at SubagentStart. Returns the oldest pending match (FIFO). */ + /** Oldest tracker awaiting TeammateIdle for this subagentType (FIFO). */ findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { let best: SubagentTracker | undefined; for (const t of this.trackers) { @@ -291,10 +254,7 @@ export class SubagentTracking { return best; } - /** Lookup by spawning Agent tool's tool_use_id. Used at PostToolUse to find - * the subagent's `invoke_agent` span when the matching toolUseId is not - * in `pendingToolCalls` (because the Agent tool emits an invoke_agent - * span instead of an execute_tool span). */ + /** Lookup by the spawning Agent tool's tool_use_id (PostToolUse settle). */ byToolUseId(toolUseId: string): SubagentTracker | undefined { return this.trackers.find(t => t.toolUseId === toolUseId); } @@ -335,11 +295,8 @@ type NewSessionStateOptions = { export function newSessionState(options: NewSessionStateOptions): SessionState { const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = options; - // Claude Code stamps its CLI version on each transcript line; capture it - // best-effort from the head line for the integration metadata. Absent when - // the writer hasn't flushed yet, the meta key is simply omitted. Built - // here (not at the SessionStart call site) so a session reconstructed after - // a daemon restart carries the same integration identity on its spans. + // Best-effort CC CLI version from the transcript head line; built here so a + // reconstructed session carries the same integration identity. const headLine = readFirstTranscriptLine(transcript.resolvedPath); const version = headLine?.['version']; const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; From c1545c1ecb1520fa5bb95cccce462298c2770cab Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 20:02:35 -0700 Subject: [PATCH 13/13] fix(daemon): nest recursive subagent dispatches under the spawner's marker An Agent dispatch from within a subagent (agent_id set) previously fell through to the generic tool branch: no tracker, so its SubagentStart became an orphan flattened under the turn with an ERROR log each time. Parent the marker under the spawning subagent's own marker instead, so recursive spawns keep their depth; correlation and PostToolUse settling work unchanged. Observed live via a recursive depth test (the standing fix/nested-subagent-orphan-span problem, now solved by 0.16.3 nesting). Co-Authored-By: Claude Fable 5 --- src/daemon.ts | 11 +++--- tests/subagent-nesting.test.ts | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 6efcbed..1868719 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -758,14 +758,17 @@ export class GlobalDaemon { // dispatch as a generic tool call. PostToolUse(Agent) closes the marker. // `promptHash` lets SubagentStart correlate deterministically: sha256 of // the firing prompt (the subagent transcript's line 1) + subagent_type. - if (!agentId && toolName === 'Agent' && toolInput['subagent_type']) { - if (!session.currentTurn) { - this.log('ERROR', `PreToolUse(Agent): no current turn for session=${sessionId}`); + // A dispatch from within a subagent (agent_id set) nests under the + // spawner's own marker, so recursive spawns keep their depth. + if (toolName === 'Agent' && toolInput['subagent_type']) { + const spawner = agentId ? session.subagents.byAgentId(agentId)?.subAgent : session.currentTurn; + if (!spawner) { + this.log('ERROR', `PreToolUse(Agent): no parent for session=${sessionId}${agentId ? ` agent=${agentId}` : ''}`); return; } const subagentType = toolInput['subagent_type'] as string; const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; - const subAgent = session.currentTurn.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); + const subAgent = spawner.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); const subAttrs: Attributes = { [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput), diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts index 2061133..dced089 100644 --- a/tests/subagent-nesting.test.ts +++ b/tests/subagent-nesting.test.ts @@ -98,3 +98,64 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('recursive dispatch: a subagent spawning a subagent nests the child under its own marker', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-002'; + const outerPrompt = 'do the outer task'; + const innerPrompt = 'do the inner task'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest2-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + for (const [agentId, prompt] of [['outer-1', outerPrompt], ['inner-1', innerPrompt]] as const) { + const p = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, userLine(prompt) + '\n' + assistantLine('done', { input_tokens: 10, output_tokens: 5 }) + '\n'); + } + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + // Main agent dispatches the outer subagent. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-outer', + tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: outerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'outer-1', agent_type: 'general-purpose' }); + // The OUTER subagent dispatches the inner one (agent_id set on the event). + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: innerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'inner-1', agent_type: 'Explore' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/f.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'inner-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-inner-1.jsonl'), agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', tool_response: 'inner done' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'outer-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-outer-1.jsonl'), agent_type: 'general-purpose' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-outer', tool_response: 'outer done' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const outer = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'general-purpose'); + const inner = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(turn && outer && inner, 'turn + both markers exported'); + assert.equal(spanParentId(outer), turn.spanContext().spanId, 'outer marker nests under the turn'); + assert.equal(spanParentId(inner), outer.spanContext().spanId, 'inner marker nests under the OUTER marker'); + assert.equal(inner.attributes[ATTR.AGENT_ID], 'inner-1', 'inner marker matched (not an orphan)'); + assert.equal(inner.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined, 'no orphan fallback'); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'inner tool exported'); + assert.equal(spanParentId(readTool), inner.spanContext().spanId, 'inner tool nests under the inner marker'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});