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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// import cycle (cli.ts imports the daemon entry point).

import { DEFAULT_AGENT_NAME } from './genaiSpans.js';
import { createHash } from 'crypto';
import { sha256Hex } from './utils.js';
import type { Settings } from './setup.js';

/** Where a resolved value came from, for user-facing "source" reporting. */
Expand Down Expand Up @@ -131,8 +131,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);
}
290 changes: 10 additions & 280 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,21 @@ import type {
import { loadSettings, VERSION } from './setup.js';
import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js';
import {
resolvePermissionIfPending,
hashPrompt,
computeSubagentTranscriptPath,
extractUserMessageContent,
lastAssistantTextEndsWith,
readSubagentFirstLineWithRetry,
newSessionState,
upsertInstruction,
} from './sessionState.js';
import type {
PendingToolCall,
SubagentTracker,
TeamMember,
SessionState,
LoadedInstruction,
} from './sessionState.js';
import { appendToLog, deepEqual } from './utils.js';
import {
Expand Down Expand Up @@ -98,37 +108,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<string, unknown>;
/** 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(),
});
}

/** 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
Expand Down Expand Up @@ -174,136 +153,6 @@ function parseIsoOrNow(ts: string | undefined): Date {
return parseTimestamp(ts) ?? new Date();
}

/**
* 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 <subagent_type>` 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;
}

/** One instruction file surfaced by the `InstructionsLoaded` hook. Accumulated
* per session (deduped by path) and stamped as `gen_ai.system_instructions` on
* each turn root. */
type LoadedInstruction = { filePath: string; content: string };

/** Append `item` to `list` in place, replacing any existing entry with the same
* filePath so a reloaded file (e.g. `load_reason=compact`) updates rather than
* duplicates. Preserves each file's first-seen position. */
function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void {
const idx = list.findIndex((i) => i.filePath === item.filePath);
if (idx >= 0) list[idx] = item;
else list.push(item);
}

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<string, number>;

pendingToolCalls: Map<string, PendingToolCall>;
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<string>;

/** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */
pendingCompaction?: CompactionAttrs;

/** Instruction files (global/project CLAUDE.md, .claude/rules, @-imports)
* captured from InstructionsLoaded, in load order, deduped by path. Stamped
* on every turn root as `gen_ai.system_instructions`. */
systemInstructions: LoadedInstruction[];

}

// ─────────────────────────────────────────────────────────────────────────────
// GlobalDaemon
// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -328,125 +177,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(),
systemInstructions: [],
};
}

/** 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
Expand Down
Loading
Loading