Skip to content
Open
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
117 changes: 117 additions & 0 deletions src/callLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: 2026 CoreWeave, Inc.
// SPDX-License-Identifier: MIT
// SPDX-PackageName: weave-claude-code

import type { Tool } from 'weave';
import { ATTR, jsonStr } from './genaiSpans.js';
import type { TurnTrace } from './session.js';

export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };

export type JsonObject = Record<string, JsonValue>;

export type ToolCall = {
toolUseId: string;
name: string;
input: JsonObject;
};

export type ToolResult =
| { ok: true; output: JsonValue }
| { ok: false; error: string };

type OpenTool = {
span: Tool;
turn: TurnTrace;
toolUseId: string;
};

/** Owns the exact hook identities for ordinary tools in one session. */
export class ToolLifecycle {
private readonly openById = new Map<string, OpenTool>();
private readonly openIdsByTurn = new Map<TurnTrace, Set<string>>();
private readonly tombstones = new Set<string>();

start(turn: TurnTrace, call: ToolCall): boolean {
return Boolean(this.open(turn, call));
}

private open(turn: TurnTrace, call: ToolCall): OpenTool | undefined {
if (this.openById.has(call.toolUseId) || this.tombstones.has(call.toolUseId)) {
return undefined;
}

const span = turn.span.startTool({
name: call.name,
args: jsonStr(call.input),
toolCallId: call.toolUseId,
});
const open = { span, turn, toolUseId: call.toolUseId };
const openIds = this.openIdsByTurn.get(turn) ?? new Set<string>();
openIds.add(call.toolUseId);
this.openIdsByTurn.set(turn, openIds);
this.openById.set(call.toolUseId, open);
return open;
}

/** A terminal hook may be the first hook observed after a daemon restart. */
finishOrRecover(
turn: () => TurnTrace,
call: ToolCall,
result: ToolResult,
): boolean {
if (this.tombstones.has(call.toolUseId)) return false;
const open = this.openById.get(call.toolUseId) ?? this.open(turn(), call);
if (!open) return false;

if (result.ok) {
open.span.result = jsonStr(result.output);
open.span.end();
} else {
const error = result.error;
open.span.result = error;
open.span.setAttributes({ [ATTR.ERROR_TYPE]: errorType(error) });
open.span.end({ error: new Error(error) });
}
this.complete(call.toolUseId, open);
return true;
}

/** End every unfinished child before its owning turn. */
finalizeChildren(turn: TurnTrace, reason: string): string[] {
const closed: string[] = [];
for (const toolUseId of [...(this.openIdsByTurn.get(turn) ?? [])].reverse()) {
const open = this.openById.get(toolUseId);
if (!open) continue;
open.span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: reason });
open.span.end({ error: new Error(`call did not complete (${reason})`) });
this.complete(toolUseId, open);
closed.push(toolUseId);
}
return closed;
}

hasOpenTools(turn?: TurnTrace): boolean {
return turn
? Boolean(this.openIdsByTurn.get(turn)?.size)
: this.openById.size > 0;
}

private complete(toolUseId: string, open: OpenTool): void {
const openIds = this.openIdsByTurn.get(open.turn);
openIds?.delete(toolUseId);
if (openIds?.size === 0) this.openIdsByTurn.delete(open.turn);
this.openById.delete(toolUseId);
this.tombstones.add(toolUseId);
}
}

function errorType(error: string): string {
return error.trim().match(/^[A-Z][A-Za-z_]*Error/)?.[0] ?? 'tool_error';
}
28 changes: 1 addition & 27 deletions src/genaiSpans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export const ATTR = {
WEAVE_SOURCE: 'weave.claude_code.source',
WEAVE_PLUGIN_VERSION: 'weave.claude_code.plugin.version',
WEAVE_ORPHAN_REASON: 'weave.claude_code.orphan_reason',
WEAVE_DISPLAY_NAME: 'weave.claude_code.display_name',

// Integration identity: unlike gen_ai.agent.name, not user-overridable and
// never changes per subagent. Set on the conversation; propagated to every span.
Expand Down Expand Up @@ -225,36 +224,11 @@ export function setCompactionAttrs(turn: Turn, attrs: CompactionAttrs): void {
}

// ─────────────────────────────────────────────────────────────────────────────
// Display-name helpers
// Formatting helpers
// ─────────────────────────────────────────────────────────────────────────────

/** 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) + '…';
}

export function toolDisplayName(toolName: string, input: Record<string, unknown>): string {
switch (toolName) {
case 'Read':
case 'Edit':
case 'Write':
return `${toolName}: ${snippet(input['file_path'])}`;
case 'Glob':
return `Glob: ${snippet(input['pattern'])}`;
case 'Grep':
return `Grep: ${snippet(input['pattern'])}`;
case 'Bash':
return `Bash: ${snippet(input['command'])}`;
case 'Agent':
return `Agent: ${snippet(input['description'] ?? input['subagent_type'])}`;
case 'WebFetch':
return `WebFetch: ${snippet(input['url'])}`;
case 'WebSearch':
return `WebSearch: ${snippet(input['query'])}`;
default: {
const first = Object.values(input).find((v): v is string => typeof v === 'string');
return first ? `${toolName}: ${snippet(first)}` : toolName;
}
}
}
104 changes: 104 additions & 0 deletions src/hookHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,59 @@ import * as path from 'path';
import type {
HookInput,
InstructionsLoadedHookInput,
PreToolUseHookInput,
PreCompactHookInput,
SessionEndHookInput,
SessionStartHookInput,
StopHookInput,
UserPromptSubmitHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import * as weave from 'weave';
import type {
JsonObject,
JsonValue,
ToolCall,
ToolResult,
} from './callLifecycle.js';
import type { CompactionAttrs } from './genaiSpans.js';
import { snippet } from './genaiSpans.js';
import { Session } from './session.js';
import { TranscriptFile } from './transcriptFile.js';

type TraceLog = (level: 'DEBUG' | 'INFO' | 'ERROR', message: string) => void;

type HookInputFor<Event extends HookInput['hook_event_name']> = Extract<
HookInput,
{ hook_event_name: Event }
>;
type PostToolResultHookInput = HookInputFor<'PostToolUse' | 'PostToolUseFailure'>;

function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
switch (typeof value) {
case 'boolean':
case 'string':
return true;
case 'number':
return Number.isFinite(value);
case 'object':
return Array.isArray(value)
? value.every(isJsonValue)
: isJsonObject(value);
default:
return false;
}
}

function isJsonObject(value: unknown): value is JsonObject {
return value !== null
&& typeof value === 'object'
&& !Array.isArray(value)
&& (Object.getPrototypeOf(value) === Object.prototype
|| Object.getPrototypeOf(value) === null)
&& Object.values(value).every(isJsonValue);
}

function parseHookInput(payload: unknown): HookInput | undefined {
if (!payload || typeof payload !== 'object') return undefined;
const input = payload as Record<string, unknown>;
Expand Down Expand Up @@ -86,6 +125,13 @@ export class HookHandler {
case 'UserPromptSubmit':
await this.handleUserPromptSubmit(sessionId, input);
return;
case 'PreToolUse':
await this.handlePreToolUse(sessionId, input);
return;
case 'PostToolUse':
case 'PostToolUseFailure':
await this.handlePostToolResult(sessionId, input);
return;
case 'PreCompact':
this.handlePreCompact(sessionId, input);
return;
Expand Down Expand Up @@ -254,6 +300,64 @@ export class HookHandler {
}
}

private async handlePreToolUse(
sessionId: string,
input: PreToolUseHookInput,
): Promise<void> {
if (input.tool_name === 'Agent' || input.agent_id) return;
const call = this.toolCall(input);
if (!call) return;
const session = await this.getOrReconstructSession(sessionId, input);
if (!session) return;
session.startTool(input.prompt_id, call);
}

private async handlePostToolResult(
sessionId: string,
input: PostToolResultHookInput,
): Promise<void> {
if (input.tool_name === 'Agent' || input.agent_id) return;
const call = this.toolCall(input);
const result = this.toolResult(input);
if (!call || !result) return;
const session = await this.getOrReconstructSession(sessionId, input);
if (!session) return;
session.finishTool(input.prompt_id, call, result);
}

private toolCall(
input: Pick<PreToolUseHookInput, 'tool_use_id' | 'tool_name' | 'tool_input'>,
): ToolCall | undefined {
if (
typeof input.tool_use_id !== 'string'
|| typeof input.tool_name !== 'string'
|| !isJsonObject(input.tool_input)
) {
this.log('ERROR', 'Invalid tool hook payload');
return undefined;
}
return {
toolUseId: input.tool_use_id,
name: input.tool_name,
input: input.tool_input,
};
}

private toolResult(input: PostToolResultHookInput): ToolResult | undefined {
if (input.hook_event_name === 'PostToolUse') {
if (!isJsonValue(input.tool_response)) {
this.log('ERROR', 'Invalid tool result payload');
return undefined;
}
return { ok: true, output: input.tool_response };
}
if (typeof input.error !== 'string') {
this.log('ERROR', 'Invalid tool result payload');
return undefined;
}
return { ok: false, error: input.error };
}

private async handleStop(sessionId: string, input: StopHookInput): Promise<void> {
const session = await this.getOrReconstructSession(sessionId, input);
if (!session) return;
Expand Down
Loading
Loading