@humwick/runner 0.0.0-stage → 0.130.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +167 -0
- package/agent-bin/agent-relay-browser +10 -0
- package/agent-bin/humwick +10 -0
- package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
- package/hooks/fff-search-redirect/hooks.json +13 -0
- package/hooks/rg-r-guard/hooks.json +18 -0
- package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/hooks/web-tools-redirect/hooks.json +13 -0
- package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
- package/node_modules/@humwick/offload/LICENSE +167 -0
- package/node_modules/@humwick/offload/README.md +57 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
- package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
- package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
- package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/envelope.js +80 -0
- package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
- package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
- package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/index.js +5 -0
- package/node_modules/@humwick/offload/dist/index.js.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
- package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
- package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
- package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/types.js +176 -0
- package/node_modules/@humwick/offload/dist/types.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
- package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
- package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
- package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
- package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
- package/node_modules/@humwick/offload/dist/worker.js +1 -0
- package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
- package/node_modules/@humwick/offload/package.json +46 -0
- package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
- package/node_modules/@humwick/offload/src/envelope.ts +92 -0
- package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
- package/node_modules/@humwick/offload/src/index.ts +4 -0
- package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
- package/node_modules/@humwick/offload/src/types.ts +235 -0
- package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
- package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
- package/node_modules/@humwick/offload/src/worker.ts +5 -0
- package/package.json +47 -4
- package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
- package/plugins/claude/.claude-plugin/plugin.json +8 -0
- package/plugins/claude/claude-statusline-probe.cjs +144 -0
- package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
- package/plugins/claude/hooks/gate-run-guard.sh +9 -0
- package/plugins/claude/hooks/gate-run-guard.ts +237 -0
- package/plugins/claude/hooks/hooks.json +158 -0
- package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
- package/plugins/claude/hooks/kill-guard.sh +341 -0
- package/plugins/claude/hooks/kill-guard.ts +337 -0
- package/plugins/claude/hooks/permission-request.sh +22 -0
- package/plugins/claude/hooks/post-compact.sh +10 -0
- package/plugins/claude/hooks/post-tool-use.sh +8 -0
- package/plugins/claude/hooks/pre-compact.sh +12 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
- package/plugins/claude/hooks/relay-status.sh +373 -0
- package/plugins/claude/hooks/session-end.sh +26 -0
- package/plugins/claude/hooks/session-start.sh +31 -0
- package/plugins/claude/hooks/shell-trust.ts +28 -0
- package/plugins/claude/hooks/stop-failure.sh +47 -0
- package/plugins/claude/hooks/stop.sh +48 -0
- package/plugins/claude/hooks/subagent-guard.cjs +26 -0
- package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
- package/plugins/claude/hooks/subagent-start.sh +13 -0
- package/plugins/claude/hooks/subagent-stop.sh +19 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
- package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
- package/plugins/claude/monitors/monitors.json +7 -0
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
- package/plugins/claude/monitors/relay-monitor.ts +58 -0
- package/plugins/claude/skills/disconnect/SKILL.md +16 -0
- package/plugins/claude/skills/label/SKILL.md +23 -0
- package/plugins/claude/skills/message/SKILL.md +40 -0
- package/plugins/claude/skills/pair/SKILL.md +30 -0
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +44 -0
- package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
- package/plugins/claude/skills/slice-first/SKILL.md +32 -0
- package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/claude/skills/status/SKILL.md +16 -0
- package/plugins/claude/skills/tags/SKILL.md +25 -0
- package/plugins/claude/skills/task/SKILL.md +28 -0
- package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
- package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +40 -0
- package/plugins/codex/skills/pair/SKILL.md +29 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +44 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
- package/plugins/codex/skills/slice-first/SKILL.md +32 -0
- package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/plugins/codex/skills/task/SKILL.md +28 -0
- package/src/adapter.ts +1061 -0
- package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
- package/src/adapters/claude-api.ts +243 -0
- package/src/adapters/claude-context-command.ts +16 -0
- package/src/adapters/claude-credentials.ts +22 -0
- package/src/adapters/claude-delivery-lanes.ts +28 -0
- package/src/adapters/claude-delivery.ts +166 -0
- package/src/adapters/claude-effort.ts +94 -0
- package/src/adapters/claude-exit.ts +290 -0
- package/src/adapters/claude-headless-mirror.ts +88 -0
- package/src/adapters/claude-headless-refusal.ts +51 -0
- package/src/adapters/claude-headless.ts +617 -0
- package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
- package/src/adapters/claude-permission.ts +243 -0
- package/src/adapters/claude-prompt-gates.ts +269 -0
- package/src/adapters/claude-prompt-hook.ts +145 -0
- package/src/adapters/claude-session-capture.ts +293 -0
- package/src/adapters/claude-session-probe.ts +414 -0
- package/src/adapters/claude-shutdown.ts +190 -0
- package/src/adapters/claude-status-detectors.ts +426 -0
- package/src/adapters/claude-subagents.ts +93 -0
- package/src/adapters/claude-submit-confirmation.ts +287 -0
- package/src/adapters/claude-tmux.ts +548 -0
- package/src/adapters/claude-token-usage-capture.ts +253 -0
- package/src/adapters/claude-tool-policy.ts +165 -0
- package/src/adapters/claude-transcript-tail.ts +193 -0
- package/src/adapters/claude-transcript.ts +812 -0
- package/src/adapters/claude.ts +1097 -0
- package/src/adapters/codex-agent-message-capture.ts +189 -0
- package/src/adapters/codex-app-server.ts +70 -0
- package/src/adapters/codex-client.ts +362 -0
- package/src/adapters/codex-effective-config.ts +87 -0
- package/src/adapters/codex-launch-config.ts +153 -0
- package/src/adapters/codex-rollout-context.ts +272 -0
- package/src/adapters/codex-skill-discovery.ts +18 -0
- package/src/adapters/codex-token-usage-capture.ts +163 -0
- package/src/adapters/codex-tool-output.ts +53 -0
- package/src/adapters/codex-turn-outcome.ts +27 -0
- package/src/adapters/codex-version.ts +64 -0
- package/src/adapters/codex.ts +1442 -0
- package/src/adapters/incremental-file.ts +110 -0
- package/src/adapters/opencode-api.ts +71 -0
- package/src/adapters/opencode-context.ts +277 -0
- package/src/adapters/opencode-model-registry.ts +126 -0
- package/src/adapters/opencode-provider.ts +169 -0
- package/src/adapters/opencode-server-retry.ts +50 -0
- package/src/adapters/opencode-soft-steer.ts +302 -0
- package/src/adapters/opencode-spawn.ts +145 -0
- package/src/adapters/opencode-subscribe.ts +62 -0
- package/src/adapters/opencode-token-usage-capture.ts +187 -0
- package/src/adapters/opencode-tool-events.ts +302 -0
- package/src/adapters/opencode-tools-config.ts +17 -0
- package/src/adapters/opencode.ts +2264 -0
- package/src/adapters/project-shell.ts +80 -0
- package/src/adapters/provider-launch-config.ts +21 -0
- package/src/adapters/provider-quota-harvest.ts +127 -0
- package/src/adapters/service-access.ts +153 -0
- package/src/adapters/tmux-offload.ts +207 -0
- package/src/adapters/tool-summary.ts +214 -0
- package/src/agent-redact.ts +75 -0
- package/src/agent-runner/commands/dispatch.ts +185 -0
- package/src/agent-runner/commands/lifecycle.ts +84 -0
- package/src/agent-runner/commands/prompt.ts +284 -0
- package/src/agent-runner/credentials.ts +208 -0
- package/src/agent-runner/http-liveness.ts +85 -0
- package/src/agent-runner/lifecycle-milestones.ts +207 -0
- package/src/agent-runner/lifecycle-phase.ts +80 -0
- package/src/agent-runner/registration.ts +163 -0
- package/src/agent-runner/runner-context.ts +158 -0
- package/src/agent-runner/session-mirror.ts +278 -0
- package/src/agent-runner/task-claims.ts +116 -0
- package/src/agent-runner/token-usage-coverage.ts +54 -0
- package/src/agent-runner/turn-report.ts +269 -0
- package/src/api-budget-accounting.ts +91 -0
- package/src/area-memory.ts +255 -0
- package/src/artifact-path-materialize.ts +52 -0
- package/src/async-guard.ts +35 -0
- package/src/attachment-cache-sweep.ts +31 -0
- package/src/attachment-cache.ts +195 -0
- package/src/background-script-reconcile.ts +106 -0
- package/src/bounded-log.ts +59 -0
- package/src/bounded-spawn-sync.ts +18 -0
- package/src/busy-reconciler.ts +282 -0
- package/src/claim-tracker.ts +140 -0
- package/src/claude-settings.ts +167 -0
- package/src/claude-workspace-trust.ts +289 -0
- package/src/config.ts +547 -0
- package/src/context-boundary.ts +101 -0
- package/src/context-command.ts +151 -0
- package/src/context-probe-state.ts +6 -0
- package/src/continuation-archive.ts +179 -0
- package/src/control-server.ts +730 -0
- package/src/delivery-policy.ts +9 -0
- package/src/delivery-result.ts +24 -0
- package/src/effort-command.ts +125 -0
- package/src/empty-turn.ts +816 -0
- package/src/free-port.ts +25 -0
- package/src/fs-offload.ts +26 -0
- package/src/gate-run-config.ts +35 -0
- package/src/gate-run-lease-probe.ts +38 -0
- package/src/host-asset-plan.ts +450 -0
- package/src/host-claude-config-worker.ts +34 -0
- package/src/host-sources.ts +40 -0
- package/src/inbox-carrier.ts +7 -0
- package/src/index.ts +386 -0
- package/src/launch-assembly.ts +955 -0
- package/src/liveness.ts +105 -0
- package/src/logger.ts +108 -0
- package/src/mcp-outbox.ts +204 -0
- package/src/mcp-preflight.ts +86 -0
- package/src/mcp-source-selection.ts +54 -0
- package/src/mcp-tool-deferral.ts +35 -0
- package/src/mcp-vault-env.ts +25 -0
- package/src/message-body-config/config.ts +16 -0
- package/src/message-body-config.ts +1 -0
- package/src/mid-turn-delivery.ts +43 -0
- package/src/native-memory-lever.ts +329 -0
- package/src/native-self-resume.ts +244 -0
- package/src/outbox.ts +453 -0
- package/src/park-post.ts +17 -0
- package/src/pending-permission-store.ts +148 -0
- package/src/process-meta.ts +9 -0
- package/src/profile-home.ts +419 -0
- package/src/profile-projection.ts +411 -0
- package/src/project-hooks.ts +65 -0
- package/src/project-instructions.ts +119 -0
- package/src/provider-double.ts +59 -0
- package/src/provider-hold.ts +228 -0
- package/src/provider-home-marker.ts +3 -0
- package/src/provider-home-tombstone.ts +4 -0
- package/src/provider-input-queue.ts +134 -0
- package/src/provider-integration.ts +93 -0
- package/src/provider-processes.ts +17 -0
- package/src/provider-submit-failure.ts +15 -0
- package/src/providers.ts +35 -0
- package/src/provisioning.ts +703 -0
- package/src/question-decision.ts +18 -0
- package/src/quota.ts +22 -0
- package/src/rate-limit.ts +351 -0
- package/src/refused-prompt-store.ts +36 -0
- package/src/relay-injection-events.ts +161 -0
- package/src/relay-instructions.ts +107 -0
- package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
- package/src/relay-mcp-proxy.ts +704 -0
- package/src/relay-mcp.ts +583 -0
- package/src/reply-obligation-cache.ts +211 -0
- package/src/response-capture-report.ts +111 -0
- package/src/response-routing.ts +6 -0
- package/src/runner-core.ts +2955 -0
- package/src/runner-exit.ts +12 -0
- package/src/runner-helpers.ts +582 -0
- package/src/runner-insights.ts +105 -0
- package/src/runner-mcp-proxy.ts +46 -0
- package/src/runner-spawn-config.ts +68 -0
- package/src/runner-stale-scratch-sweep.ts +21 -0
- package/src/runner.ts +9 -0
- package/src/runtime-token-file.ts +45 -0
- package/src/runtime-token-remint.ts +56 -0
- package/src/service-access.ts +88 -0
- package/src/session-destroy.ts +27 -0
- package/src/session-insights.ts +118 -0
- package/src/session-scratch.ts +296 -0
- package/src/spawn-input.ts +147 -0
- package/src/startup-config-fetch.ts +48 -0
- package/src/subreaper.ts +113 -0
- package/src/template-resolver.ts +29 -0
- package/src/test-run-dedup.ts +435 -0
- package/src/transcript-context.ts +99 -0
- package/src/transport-errors.ts +48 -0
- package/src/turn-opener.ts +55 -0
- package/src/turn-reconcile-event.ts +88 -0
- package/src/vault-proxy-hop.ts +99 -0
- package/src/version.ts +44 -0
- package/src/web-tool-call-proxy.ts +128 -0
- package/src/web-tools.ts +59 -0
- package/README.md +0 -3
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
// Phase 1 live-session lane: extract the user-visible assistant turn from a
|
|
2
|
+
// Claude Code transcript JSONL so it can be surfaced in the dashboard chat
|
|
3
|
+
// without the agent re-emitting it via /reply (zero agent tokens).
|
|
4
|
+
//
|
|
5
|
+
// Transcript shape (one JSON object per line):
|
|
6
|
+
// { type: "user", message: { content: "..." | [{type:"text"|"tool_result", ...}] } }
|
|
7
|
+
// { type: "assistant", message: { content: [{type:"thinking"|"text"|"tool_use", ...}], stop_reason } }
|
|
8
|
+
//
|
|
9
|
+
// The "current turn" is everything after the last *real* user prompt (a user
|
|
10
|
+
// entry carrying text, not just tool_result blocks). We collect the assistant
|
|
11
|
+
// `text` blocks from that turn — thinking and tool_use are dropped.
|
|
12
|
+
|
|
13
|
+
import { isRecord, type ToolCallCategory } from "@humwick/sdk";
|
|
14
|
+
import type { ProviderSessionEvent } from "../adapter";
|
|
15
|
+
import { computeContextRatio, type SessionAnalysis, type SessionEvent } from "../session-insights";
|
|
16
|
+
import { absoluteToolPaths, clipToolSummary, redactAndCapParams, summarizeToolInput } from "./tool-summary";
|
|
17
|
+
|
|
18
|
+
interface TranscriptBlock {
|
|
19
|
+
type?: string;
|
|
20
|
+
text?: string;
|
|
21
|
+
thinking?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
input?: Record<string, unknown>;
|
|
24
|
+
is_error?: boolean;
|
|
25
|
+
id?: string;
|
|
26
|
+
tool_use_id?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TurnStep {
|
|
30
|
+
type: "narration" | "reasoning" | "tool";
|
|
31
|
+
text: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
category?: ToolCallCategory;
|
|
34
|
+
occurredAt?: number;
|
|
35
|
+
/** #2437 — redacted, size-capped tool input, only set for type "tool". */
|
|
36
|
+
params?: unknown;
|
|
37
|
+
/** The native tool_use id, only set for type "tool"; pairs the call with its tool_result. */
|
|
38
|
+
toolUseId?: string;
|
|
39
|
+
/** #3411 — absolute file paths the tool reads or edits, only set for type "tool". */
|
|
40
|
+
paths?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TranscriptMessage {
|
|
44
|
+
role?: string;
|
|
45
|
+
content?: string | TranscriptBlock[];
|
|
46
|
+
stop_reason?: string;
|
|
47
|
+
// Assistant entries carry the model id and the API-reported token usage for
|
|
48
|
+
// the turn. `usage.{input,cache_creation_input,cache_read_input}_tokens` sum to
|
|
49
|
+
// the same context-size figure the status-line probe reports — read here so the
|
|
50
|
+
// runner can keep context stats advancing every turn without the probe (#1512).
|
|
51
|
+
model?: string;
|
|
52
|
+
usage?: TranscriptUsage;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TranscriptUsage {
|
|
56
|
+
input_tokens?: number;
|
|
57
|
+
cache_creation_input_tokens?: number;
|
|
58
|
+
cache_read_input_tokens?: number;
|
|
59
|
+
output_tokens?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fresh, per-turn context usage read straight from the transcript (#1512). */
|
|
63
|
+
export interface TranscriptContextUsage {
|
|
64
|
+
/** input + cache-creation + cache-read tokens — the context size sent this turn. */
|
|
65
|
+
tokensUsed: number;
|
|
66
|
+
model?: string;
|
|
67
|
+
/** Wall-clock ms of the assistant entry, when the transcript stamps one. */
|
|
68
|
+
occurredAt?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TranscriptEntry {
|
|
72
|
+
type?: string;
|
|
73
|
+
timestamp?: string;
|
|
74
|
+
message?: TranscriptMessage;
|
|
75
|
+
// Claude Code stamps every transcript entry with `isSidechain`: true for
|
|
76
|
+
// entries belonging to a Task (subagent) run, false for the root session.
|
|
77
|
+
// Current CC writes sidechains to a separate subagents/*.jsonl so they don't
|
|
78
|
+
// reach the root transcript the runner tails — but older CC inlined them, and
|
|
79
|
+
// the behavior can revert, so the chat-mirror parsers below defensively skip
|
|
80
|
+
// sidechain entries to keep a subagent's reasoning/tools/responses from
|
|
81
|
+
// leaking into the parent agent's chat. Insights parsers (collectClaudeSession-
|
|
82
|
+
// Events/countSubstantiveTurns) intentionally do NOT filter — changing them
|
|
83
|
+
// would shift the #184/#185 baselines, a separate concern.
|
|
84
|
+
isSidechain?: boolean;
|
|
85
|
+
// Every entry carries its own `uuid` and its `parentUuid` (the entry it was
|
|
86
|
+
// appended after), forming a causal chain for the whole session. A `user`-type
|
|
87
|
+
// entry — whether the real prompt or a synthetic tool_result — is stamped with
|
|
88
|
+
// `promptId`: the same UUID Claude's Stop-hook payload reports as `prompt_id`,
|
|
89
|
+
// shared by every entry from that prompt until the next one. Assistant entries
|
|
90
|
+
// don't carry `promptId` directly; their turn is resolved by walking `parentUuid`
|
|
91
|
+
// back to the nearest ancestor that has one (see resolveEntryPromptId).
|
|
92
|
+
uuid?: string;
|
|
93
|
+
parentUuid?: string;
|
|
94
|
+
promptId?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
|
|
98
|
+
if (!message || !Array.isArray(message.content)) return [];
|
|
99
|
+
return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True for a subagent (Task) transcript entry — see the note on TranscriptEntry.isSidechain. */
|
|
103
|
+
function isSidechainEntry(entry: TranscriptEntry): boolean {
|
|
104
|
+
return entry.isSidechain === true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isRealUserPrompt(entry: TranscriptEntry): boolean {
|
|
108
|
+
if (entry.type !== "user") return false;
|
|
109
|
+
const content = entry.message?.content;
|
|
110
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
111
|
+
// An array user entry is a real prompt only if it carries a text block;
|
|
112
|
+
// pure tool_result entries are mid-turn plumbing, not a new prompt.
|
|
113
|
+
return blocks(entry.message).some((b) => b.type === "text" && Boolean(b.text?.trim()));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// #2840 — `closingOnly` keeps just the text after the turn's last tool call (the closing message,
|
|
117
|
+
// without narration the reasoning tail already mirrored); a turn ending on a tool keeps its last text.
|
|
118
|
+
class AssistantTurnText {
|
|
119
|
+
private texts: string[] = [];
|
|
120
|
+
private lastSegment: string[] = [];
|
|
121
|
+
constructor(private readonly closingOnly: boolean) {}
|
|
122
|
+
|
|
123
|
+
reset(): void {
|
|
124
|
+
this.texts = [];
|
|
125
|
+
this.lastSegment = [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
add(entry: TranscriptEntry): void {
|
|
129
|
+
if (entry.type !== "assistant") return;
|
|
130
|
+
for (const b of blocks(entry.message)) {
|
|
131
|
+
if (this.closingOnly && b.type === "tool_use") {
|
|
132
|
+
if (this.texts.length) this.lastSegment = this.texts;
|
|
133
|
+
this.texts = [];
|
|
134
|
+
} else if (b.type === "text" && typeof b.text === "string" && b.text.trim()) this.texts.push(b.text.trim());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
text(): string {
|
|
139
|
+
return (this.texts.length ? this.texts : this.lastSegment).join("\n\n").trim();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Returns true when the transcript's final parseable entry is an assistant
|
|
145
|
+
* message with stop_reason "end_turn", meaning the full turn has been flushed.
|
|
146
|
+
* Used to detect a race where the Stop hook reads the file before the last
|
|
147
|
+
* assistant block is written to disk.
|
|
148
|
+
*/
|
|
149
|
+
export function transcriptLooksComplete(jsonl: string): boolean {
|
|
150
|
+
const state = new LatestTurnStepAccumulator();
|
|
151
|
+
state.appendJsonl(jsonl);
|
|
152
|
+
return state.transcriptLooksComplete;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Returns the concatenated assistant `text` for the most recent turn (since the
|
|
157
|
+
* last real user prompt), or "" if there is nothing user-visible to surface.
|
|
158
|
+
*/
|
|
159
|
+
export function extractLastAssistantTurn(jsonl: string, closingOnly = false): string {
|
|
160
|
+
const lines = jsonl.split("\n");
|
|
161
|
+
const collected = new AssistantTurnText(closingOnly);
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
const trimmed = line.trim();
|
|
164
|
+
if (!trimmed) continue;
|
|
165
|
+
let entry: TranscriptEntry;
|
|
166
|
+
try {
|
|
167
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (isSidechainEntry(entry)) continue;
|
|
172
|
+
if (isRealUserPrompt(entry)) {
|
|
173
|
+
collected.reset();
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
collected.add(entry);
|
|
177
|
+
}
|
|
178
|
+
return collected.text();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
|
|
183
|
+
* Used when a partial transcript flush already emitted entries 1..afterEntry so the
|
|
184
|
+
* Stop-hook full-mode capture doesn't re-emit the already-published text.
|
|
185
|
+
*/
|
|
186
|
+
export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number, closingOnly = false): string {
|
|
187
|
+
const lines = jsonl.split("\n");
|
|
188
|
+
const collected = new AssistantTurnText(closingOnly);
|
|
189
|
+
let entryIndex = 0;
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
const trimmed = line.trim();
|
|
192
|
+
if (!trimmed) continue;
|
|
193
|
+
let entry: TranscriptEntry;
|
|
194
|
+
try {
|
|
195
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
196
|
+
} catch {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
entryIndex++;
|
|
200
|
+
if (isSidechainEntry(entry)) continue;
|
|
201
|
+
if (isRealUserPrompt(entry)) {
|
|
202
|
+
collected.reset();
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (entryIndex <= afterEntry) continue;
|
|
206
|
+
collected.add(entry);
|
|
207
|
+
}
|
|
208
|
+
return collected.text();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the `promptId` (Stop hook's `prompt_id`) a transcript entry belongs to.
|
|
213
|
+
* A `user`-type entry (the real prompt, or a synthetic tool_result) carries its own
|
|
214
|
+
* `promptId`; an assistant entry doesn't, so its turn is resolved by walking
|
|
215
|
+
* `parentUuid` back through the causal chain until an ancestor with one is found.
|
|
216
|
+
* Returns undefined for an entry with no resolvable ancestor (e.g. the very first
|
|
217
|
+
* turn of a process, before any `promptId` has ever been stamped).
|
|
218
|
+
*/
|
|
219
|
+
function resolveEntryPromptId(entry: TranscriptEntry, byUuid: ReadonlyMap<string, TranscriptEntry>): string | undefined {
|
|
220
|
+
let current: TranscriptEntry | undefined = entry;
|
|
221
|
+
const visited = new Set<string>();
|
|
222
|
+
while (current) {
|
|
223
|
+
if (typeof current.promptId === "string" && current.promptId) return current.promptId;
|
|
224
|
+
const parentUuid = current.parentUuid;
|
|
225
|
+
if (!parentUuid || visited.has(parentUuid)) return undefined;
|
|
226
|
+
visited.add(parentUuid);
|
|
227
|
+
current = byUuid.get(parentUuid);
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Deterministic replacement for extractLastAssistantTurnAfterEntry's "walk back to
|
|
234
|
+
* the last real user prompt" heuristic: collects assistant `text` blocks for entries
|
|
235
|
+
* whose resolved promptId (see resolveEntryPromptId) equals the Claude-native
|
|
236
|
+
* `promptId` the Stop hook reported for this turn. Skips the first `afterEntry`
|
|
237
|
+
* entries the same way, for pre-flush resume. Since entries never move once
|
|
238
|
+
* written, a turn's promptId membership is exact — no text-shape guessing about
|
|
239
|
+
* what counts as a "real" prompt.
|
|
240
|
+
*/
|
|
241
|
+
export function extractAssistantTurnForPrompt(jsonl: string, promptId: string, afterEntry = 0, closingOnly = false): string {
|
|
242
|
+
const lines = jsonl.split("\n");
|
|
243
|
+
const byUuid = new Map<string, TranscriptEntry>();
|
|
244
|
+
const collected = new AssistantTurnText(closingOnly);
|
|
245
|
+
let entryIndex = 0;
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
const trimmed = line.trim();
|
|
248
|
+
if (!trimmed) continue;
|
|
249
|
+
let entry: TranscriptEntry;
|
|
250
|
+
try {
|
|
251
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
entryIndex++;
|
|
256
|
+
if (typeof entry.uuid === "string" && entry.uuid) byUuid.set(entry.uuid, entry);
|
|
257
|
+
if (isSidechainEntry(entry)) continue;
|
|
258
|
+
if (entryIndex <= afterEntry) continue;
|
|
259
|
+
if (entry.type !== "assistant") continue;
|
|
260
|
+
if (resolveEntryPromptId(entry, byUuid) !== promptId) continue;
|
|
261
|
+
collected.add(entry);
|
|
262
|
+
}
|
|
263
|
+
return collected.text();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Count valid JSON entries in a JSONL string. Used to record the high-water-mark
|
|
268
|
+
* after a pre-flush so the Stop hook knows where to resume in full-capture mode.
|
|
269
|
+
*/
|
|
270
|
+
export function countTranscriptEntries(jsonl: string): number {
|
|
271
|
+
let count = 0;
|
|
272
|
+
for (const line of jsonl.split("\n")) {
|
|
273
|
+
if (!line.trim()) continue;
|
|
274
|
+
try { JSON.parse(line); count++; } catch {}
|
|
275
|
+
}
|
|
276
|
+
return count;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface QueueOperationEntry extends TranscriptEntry {
|
|
280
|
+
operation?: string;
|
|
281
|
+
content?: unknown;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* #2919 — prompts left in Claude's input queue at the end of `jsonl`: a mid-turn prompt is `enqueue`d,
|
|
286
|
+
* then `remove`d when a tool boundary absorbs it, or `dequeue`d (every queued prompt at once) to run as
|
|
287
|
+
* the next turn after the Stop. So a dequeue or a new promptId empties the queue.
|
|
288
|
+
*/
|
|
289
|
+
export function pendingQueuedPrompts(jsonl: string): string[] {
|
|
290
|
+
let pending: string[] = [];
|
|
291
|
+
let promptId: string | undefined;
|
|
292
|
+
for (const line of jsonl.split("\n")) {
|
|
293
|
+
if (!line.includes("\"queue-operation\"") && !line.includes("\"promptId\"")) continue;
|
|
294
|
+
let entry: QueueOperationEntry;
|
|
295
|
+
try {
|
|
296
|
+
entry = JSON.parse(line) as QueueOperationEntry;
|
|
297
|
+
} catch {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (isSidechainEntry(entry)) continue;
|
|
301
|
+
if (entry.promptId && entry.promptId !== promptId) {
|
|
302
|
+
promptId = entry.promptId;
|
|
303
|
+
pending = [];
|
|
304
|
+
}
|
|
305
|
+
if (entry.type !== "queue-operation") continue;
|
|
306
|
+
const content = typeof entry.content === "string" ? entry.content : undefined;
|
|
307
|
+
if (entry.operation === "dequeue") pending = [];
|
|
308
|
+
else if (entry.operation === "enqueue" && content !== undefined) pending.push(content);
|
|
309
|
+
else if (entry.operation === "remove" && content !== undefined && pending.includes(content)) pending.splice(pending.indexOf(content), 1);
|
|
310
|
+
}
|
|
311
|
+
return pending;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Key-order-independent structural equality, cheap enough for a small tool_input object. */
|
|
315
|
+
function canonicalJson(value: unknown): string {
|
|
316
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
317
|
+
if (value && typeof value === "object") {
|
|
318
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
319
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
|
320
|
+
}
|
|
321
|
+
return JSON.stringify(value);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* True when the transcript already contains the exact assistant tool_use block that
|
|
326
|
+
* triggered a PreToolUse hook — the settle-poll anchor (#1116). The hook payload's own
|
|
327
|
+
* `tool_name`/`tool_input` are the precise call that fired it, so matching them against
|
|
328
|
+
* the transcript's tool_use blocks (name + structural input equality) is exact, unlike
|
|
329
|
+
* guessing from transcript shape alone. Claude's JSONL flush is async/bursty and can
|
|
330
|
+
* lag well behind the hook firing, so the entry backing the hook's own call may still be
|
|
331
|
+
* missing from disk the instant the hook runs — this is what the settle-poll waits for.
|
|
332
|
+
* #1086 should adopt this same anchor for its own turn-final settle marker rather than
|
|
333
|
+
* inventing a second one.
|
|
334
|
+
*/
|
|
335
|
+
export function transcriptHasToolUseAnchor(jsonl: string, toolName: string, toolInput: unknown): boolean {
|
|
336
|
+
const wantInput = canonicalJson(toolInput ?? {});
|
|
337
|
+
for (const line of jsonl.split("\n")) {
|
|
338
|
+
const trimmed = line.trim();
|
|
339
|
+
if (!trimmed) continue;
|
|
340
|
+
let entry: TranscriptEntry;
|
|
341
|
+
try {
|
|
342
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
343
|
+
} catch {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
|
|
347
|
+
for (const block of blocks(entry.message)) {
|
|
348
|
+
if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) return true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The transcript-embedded `timestamp` of the specific assistant entry carrying the exact
|
|
356
|
+
* tool_use block that triggered a PreToolUse hook (same anchor match as
|
|
357
|
+
* transcriptHasToolUseAnchor above). Used to stamp a resolved permission prompt with the
|
|
358
|
+
* tool call's real event time — a hook's own wall-clock receipt time can end up earlier than
|
|
359
|
+
* the entry the provider eventually flushes for that same call, which otherwise sorts the
|
|
360
|
+
* resolved prompt ahead of the narration that led up to it (#1185).
|
|
361
|
+
*/
|
|
362
|
+
export function transcriptToolUseOccurredAt(jsonl: string, toolName: string, toolInput: unknown): number | undefined {
|
|
363
|
+
const wantInput = canonicalJson(toolInput ?? {});
|
|
364
|
+
for (const line of jsonl.split("\n")) {
|
|
365
|
+
const trimmed = line.trim();
|
|
366
|
+
if (!trimmed) continue;
|
|
367
|
+
let entry: TranscriptEntry;
|
|
368
|
+
try {
|
|
369
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
370
|
+
} catch {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
|
|
374
|
+
for (const block of blocks(entry.message)) {
|
|
375
|
+
if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) {
|
|
376
|
+
return transcriptEntryOccurredAt(entry);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Extract the ordered narration, reasoning, and tool steps for the most recent
|
|
385
|
+
* turn (since the last real user prompt). Used by the reasoning tailer to stream
|
|
386
|
+
* progress into chat while a turn is in flight. `narration` is the assistant's
|
|
387
|
+
* intermediate `text` between tool calls (the terminal's `●` lines); it is the
|
|
388
|
+
* primary, default-visible turn content. Returns steps in transcript order so the
|
|
389
|
+
* tailer can emit only the ones it hasn't seen yet.
|
|
390
|
+
*/
|
|
391
|
+
export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
|
|
392
|
+
const state = new LatestTurnStepAccumulator();
|
|
393
|
+
state.appendJsonl(jsonl);
|
|
394
|
+
return state.steps;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export class LatestTurnStepAccumulator {
|
|
398
|
+
private latestSteps: TurnStep[] = [];
|
|
399
|
+
private newUserPrompts: Array<{ text: string; occurredAt?: number }> = [];
|
|
400
|
+
private lastAssistantStopReason: string | undefined;
|
|
401
|
+
private parsedEntries = 0;
|
|
402
|
+
private latestPromptId: string | undefined;
|
|
403
|
+
|
|
404
|
+
get steps(): TurnStep[] {
|
|
405
|
+
return [...this.latestSteps];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
get transcriptLooksComplete(): boolean {
|
|
409
|
+
return this.lastAssistantStopReason === "end_turn";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
get entryCount(): number {
|
|
413
|
+
return this.parsedEntries;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Claude's native prompt id for the latest turn, when its prompt entry carries one. */
|
|
417
|
+
get promptId(): string | undefined {
|
|
418
|
+
return this.latestPromptId;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
drainUserPrompts(): Array<{ text: string; occurredAt?: number }> {
|
|
422
|
+
const prompts = this.newUserPrompts;
|
|
423
|
+
this.newUserPrompts = [];
|
|
424
|
+
return prompts;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
reset(): void {
|
|
428
|
+
this.latestSteps = [];
|
|
429
|
+
this.newUserPrompts = [];
|
|
430
|
+
this.lastAssistantStopReason = undefined;
|
|
431
|
+
this.parsedEntries = 0;
|
|
432
|
+
this.latestPromptId = undefined;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
appendJsonl(jsonl: string): void {
|
|
436
|
+
for (const line of jsonl.split("\n")) this.appendLine(line);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
appendLine(line: string): void {
|
|
440
|
+
const trimmed = line.trim();
|
|
441
|
+
if (!trimmed) return;
|
|
442
|
+
let entry: TranscriptEntry;
|
|
443
|
+
try {
|
|
444
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
445
|
+
} catch {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.parsedEntries += 1;
|
|
449
|
+
if (isSidechainEntry(entry)) return;
|
|
450
|
+
if (entry.type === "assistant") this.lastAssistantStopReason = entry.message?.stop_reason;
|
|
451
|
+
if (isRealUserPrompt(entry)) {
|
|
452
|
+
const content = entry.message?.content;
|
|
453
|
+
const text = typeof content === "string" ? content : blocks(entry.message).filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n");
|
|
454
|
+
this.newUserPrompts.push({ text, occurredAt: transcriptEntryOccurredAt(entry) });
|
|
455
|
+
this.latestSteps = [];
|
|
456
|
+
this.latestPromptId = entry.promptId;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (entry.type !== "assistant") return;
|
|
460
|
+
this.latestSteps.push(...claudeAssistantSteps(entry.message, transcriptEntryOccurredAt(entry)));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Narration, reasoning and tool steps of one assistant message, shared by the transcript tail and the headless stream (#2771). */
|
|
465
|
+
export function claudeAssistantSteps(message: TranscriptMessage | undefined, occurredAt?: number): TurnStep[] {
|
|
466
|
+
const steps: TurnStep[] = [];
|
|
467
|
+
for (const b of blocks(message)) {
|
|
468
|
+
if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
|
|
469
|
+
steps.push({ type: "narration", text: b.text.trim(), ...(occurredAt ? { occurredAt } : {}) });
|
|
470
|
+
} else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
|
|
471
|
+
steps.push({ type: "reasoning", text: b.thinking.trim(), ...(occurredAt ? { occurredAt } : {}) });
|
|
472
|
+
} else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
|
|
473
|
+
const paths = claudeToolPaths(b.name, b.input);
|
|
474
|
+
steps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input), category: claudeToolCategory(b.name), params: redactAndCapParams(b.input), ...(typeof b.id === "string" && b.id ? { toolUseId: b.id } : {}), ...(occurredAt ? { occurredAt } : {}), ...(paths ? { paths } : {}) });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return steps;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// #3411 — Claude's file tools and the input field naming their file. Keyed on the tool name, not the
|
|
481
|
+
// category: MultiEdit is "generic". Claude's file tools take absolute paths only.
|
|
482
|
+
const CLAUDE_TOOL_PATH_FIELDS: Record<string, string> = {
|
|
483
|
+
Read: "file_path",
|
|
484
|
+
Edit: "file_path",
|
|
485
|
+
MultiEdit: "file_path",
|
|
486
|
+
Write: "file_path",
|
|
487
|
+
NotebookEdit: "notebook_path",
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/** The absolute file path a Claude file tool reads or edits, from its raw (uncapped) input. */
|
|
491
|
+
export function claudeToolPaths(name: string, input: Record<string, unknown> | undefined): string[] | undefined {
|
|
492
|
+
const field = CLAUDE_TOOL_PATH_FIELDS[name];
|
|
493
|
+
return field && input ? absoluteToolPaths([input[field]]) : undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The raw PostToolUse payload uses the same path contract as transcript tools (#3646). */
|
|
497
|
+
export function claudeToolHookPaths(input: unknown): string[] | undefined {
|
|
498
|
+
return isRecord(input) && typeof input.tool_name === "string"
|
|
499
|
+
? claudeToolPaths(input.tool_name, isRecord(input.tool_input) ? input.tool_input : undefined) : undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The session event of one step, shared by the transcript tail and the headless stream. */
|
|
503
|
+
export function turnStepEvent(step: TurnStep, turnId: string | undefined): ProviderSessionEvent {
|
|
504
|
+
return {
|
|
505
|
+
type: step.type,
|
|
506
|
+
origin: "provider",
|
|
507
|
+
body: step.text,
|
|
508
|
+
...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
|
|
509
|
+
...(turnId ? { turnId } : {}),
|
|
510
|
+
...(step.label ? { label: step.label } : {}),
|
|
511
|
+
...(step.category ? { category: step.category } : {}),
|
|
512
|
+
...(step.params !== undefined ? { params: step.params } : {}),
|
|
513
|
+
...(step.paths ? { paths: step.paths } : {}),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** tool_use_id → error outcome for each tool_result block of a user message. */
|
|
518
|
+
export function claudeToolResults(message: TranscriptMessage | undefined): Array<{ toolUseId: string; isError: boolean }> {
|
|
519
|
+
return blocks(message).flatMap((b) => b.type === "tool_result" && typeof b.tool_use_id === "string" ? [{ toolUseId: b.tool_use_id, isError: b.is_error === true }] : []);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function transcriptEntryOccurredAt(entry: TranscriptEntry): number | undefined {
|
|
523
|
+
if (typeof entry.timestamp !== "string" || !entry.timestamp) return undefined;
|
|
524
|
+
const parsed = Date.parse(entry.timestamp);
|
|
525
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Stable dedup keys for a turn's steps, in order. Each key is salted with how many
|
|
530
|
+
* identical (type,label,text) steps preceded it in the same window — so running the
|
|
531
|
+
* same tool twice with identical input within a turn yields two distinct keys and
|
|
532
|
+
* both show in the activity trace (#265). Keying on occurrence-within-window rather
|
|
533
|
+
* than raw transcript index keeps the reasoning tailer idempotent when the "latest
|
|
534
|
+
* turn" window shrinks/resets mid-poll: a surviving step recomputes to the same or a
|
|
535
|
+
* lower occurrence, so an already-emitted step never re-fires.
|
|
536
|
+
*/
|
|
537
|
+
export function stepDedupKeys(steps: TurnStep[]): string[] {
|
|
538
|
+
const counts = new Map<string, number>();
|
|
539
|
+
return steps.map((step) => {
|
|
540
|
+
const base = JSON.stringify([step.type, step.label ?? "", step.text]);
|
|
541
|
+
const occ = counts.get(base) ?? 0;
|
|
542
|
+
counts.set(base, occ + 1);
|
|
543
|
+
return JSON.stringify([step.type, step.label ?? "", step.text, occ]);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// #1498: Claude's native tool names → the provider-independent category taxonomy. Tools with
|
|
548
|
+
// no dedicated category (Grep, Glob, LS, WebFetch, WebSearch, ...) fall to "generic" rather than
|
|
549
|
+
// being force-fit into a category that doesn't really describe them — the fallback exists
|
|
550
|
+
// precisely so nothing is hidden from the dashboard's activity trace.
|
|
551
|
+
const CLAUDE_TOOL_CATEGORIES: Record<string, ToolCallCategory> = {
|
|
552
|
+
Bash: "cli-bash",
|
|
553
|
+
Read: "read",
|
|
554
|
+
Edit: "edit",
|
|
555
|
+
Write: "edit",
|
|
556
|
+
NotebookEdit: "edit",
|
|
557
|
+
AskUserQuestion: "ask-user-question",
|
|
558
|
+
ExitPlanMode: "plan",
|
|
559
|
+
Task: "agent-spawn",
|
|
560
|
+
ToolSearch: "mcp-tool-search",
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
/** Map a Claude tool_use name onto the provider-independent tool-call category (#1498). */
|
|
564
|
+
export function claudeToolCategory(name: string): ToolCallCategory {
|
|
565
|
+
if (name.startsWith("mcp__")) return "mcp-tool-call";
|
|
566
|
+
return CLAUDE_TOOL_CATEGORIES[name] ?? "generic";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Compact one-line summary of a tool invocation for the discreet activity row. The one-line +
|
|
570
|
+
* redact + clip is the provider-neutral primitive in `tool-summary.ts` (shared with Codex and
|
|
571
|
+
* OpenCode); only the Claude-specific candidate-field order lives here. */
|
|
572
|
+
export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
|
|
573
|
+
const summary = summarizeToolInput(input);
|
|
574
|
+
if (!summary) return name;
|
|
575
|
+
return clipToolSummary(summary);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// --- Insights #184: context-gathering ratio (epic #183, docs/self-improvement.md) ---
|
|
579
|
+
//
|
|
580
|
+
// Computed mechanically from the whole-session transcript at session end — no model
|
|
581
|
+
// involvement, so it costs zero agent tokens and the agent can't game it. The ratio is
|
|
582
|
+
// paired with cheap outcome proxies (user re-prompts, tool errors) so it's never read
|
|
583
|
+
// alone — see the anti-Goodhart constraint in the epic.
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Normalize a full Claude transcript into the provider-agnostic `SessionEvent` stream
|
|
587
|
+
* (#183/#184). The classifier and ratio math live in `session-insights.ts` and are shared
|
|
588
|
+
* across providers; this only knows the Claude JSONL shape. Events are emitted in
|
|
589
|
+
* transcript order so `leadingGather` is meaningful.
|
|
590
|
+
*/
|
|
591
|
+
export function collectClaudeSessionEvents(jsonl: string): SessionEvent[] {
|
|
592
|
+
const events: SessionEvent[] = [];
|
|
593
|
+
for (const line of jsonl.split("\n")) {
|
|
594
|
+
const trimmed = line.trim();
|
|
595
|
+
if (!trimmed) continue;
|
|
596
|
+
let entry: TranscriptEntry;
|
|
597
|
+
try {
|
|
598
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
599
|
+
} catch {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (isRealUserPrompt(entry)) events.push({ type: "user_prompt" });
|
|
603
|
+
if (entry.type === "user") {
|
|
604
|
+
for (const b of blocks(entry.message)) {
|
|
605
|
+
if (b.type === "tool_result" && b.is_error === true) events.push({ type: "tool_error" });
|
|
606
|
+
}
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (entry.type !== "assistant") continue;
|
|
610
|
+
let producedSomething = false;
|
|
611
|
+
for (const b of blocks(entry.message)) {
|
|
612
|
+
if (b.type === "text" && b.text?.trim()) producedSomething = true;
|
|
613
|
+
if (b.type !== "tool_use" || typeof b.name !== "string" || !b.name) continue;
|
|
614
|
+
producedSomething = true;
|
|
615
|
+
events.push({ type: "tool", name: b.name });
|
|
616
|
+
}
|
|
617
|
+
if (producedSomething) events.push({ type: "turn" });
|
|
618
|
+
}
|
|
619
|
+
return events;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Walk the full transcript and compute the context-gathering ratio plus paired outcome
|
|
624
|
+
* proxies. Returns null when there's nothing substantive to measure (no tool calls) —
|
|
625
|
+
* trivial sessions have nothing to learn from and shouldn't pollute the baselines.
|
|
626
|
+
*/
|
|
627
|
+
export function analyzeSession(jsonl: string): SessionAnalysis | null {
|
|
628
|
+
return computeContextRatio(collectClaudeSessionEvents(jsonl));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Count substantive assistant turns — used by the #185 introspection gate. */
|
|
632
|
+
export function countSubstantiveTurns(jsonl: string): number {
|
|
633
|
+
let turns = 0;
|
|
634
|
+
for (const line of jsonl.split("\n")) {
|
|
635
|
+
const trimmed = line.trim();
|
|
636
|
+
if (!trimmed) continue;
|
|
637
|
+
let entry: TranscriptEntry;
|
|
638
|
+
try {
|
|
639
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
640
|
+
} catch {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (entry.type !== "assistant") continue;
|
|
644
|
+
const hasContent = blocks(entry.message).some(
|
|
645
|
+
(b) => (b.type === "text" && b.text?.trim()) || (b.type === "tool_use" && b.name),
|
|
646
|
+
);
|
|
647
|
+
if (hasContent) turns++;
|
|
648
|
+
}
|
|
649
|
+
return turns;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Walk the transcript from the end and return the most recent root-session
|
|
653
|
+
// assistant turn's token usage (#1512). This is the SAME input+cache token sum
|
|
654
|
+
// the Claude status-line probe reports, but read directly from the transcript —
|
|
655
|
+
// which the Stop hook hands the runner every turn — so context stats keep
|
|
656
|
+
// advancing across compact/clear/native-resume even when the status-line probe
|
|
657
|
+
// goes silent. Sidechain (subagent) entries are skipped so a Task's usage never
|
|
658
|
+
// masquerades as the root session's context size.
|
|
659
|
+
export function extractLatestContextUsage(jsonl: string): TranscriptContextUsage | undefined {
|
|
660
|
+
const lines = jsonl.split("\n");
|
|
661
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
662
|
+
const trimmed = lines[i]?.trim();
|
|
663
|
+
if (!trimmed) continue;
|
|
664
|
+
let entry: TranscriptEntry;
|
|
665
|
+
try {
|
|
666
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
667
|
+
} catch {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
|
|
671
|
+
const usage = entry.message?.usage;
|
|
672
|
+
if (!usage) continue;
|
|
673
|
+
const tokensUsed = sumUsageTokens(usage);
|
|
674
|
+
if (tokensUsed === undefined) continue;
|
|
675
|
+
const occurredAt = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
|
|
676
|
+
return {
|
|
677
|
+
tokensUsed,
|
|
678
|
+
...(typeof entry.message?.model === "string" ? { model: entry.message.model } : {}),
|
|
679
|
+
...(Number.isFinite(occurredAt) ? { occurredAt } : {}),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Context size == prompt input tokens for the turn: fresh input + newly-cached
|
|
686
|
+
// input + cache-read input. Matches the status-line probe's sum (output tokens
|
|
687
|
+
// are excluded — they are not part of the context window carried forward).
|
|
688
|
+
export function sumUsageTokens(usage: TranscriptUsage): number | undefined {
|
|
689
|
+
let total = 0;
|
|
690
|
+
let seen = false;
|
|
691
|
+
for (const value of [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens]) {
|
|
692
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
693
|
+
total += value;
|
|
694
|
+
seen = true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return seen ? total : undefined;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// #2482 — the token-usage spine's capture parser. Where extractLatestContextUsage
|
|
701
|
+
// collapses a turn's usage into one context-size sum, this sibling keeps the RAW
|
|
702
|
+
// per-entry breakdown the spine records, one record per assistant transcript entry:
|
|
703
|
+
// dedupeKey = the entry `uuid` (entries never move once written, so the uuid is the
|
|
704
|
+
// stable turn-less key — replay-safe against the spine's dedupe_key upsert);
|
|
705
|
+
// createdAt = the entry `timestamp` (the provider's observed usage time, which the
|
|
706
|
+
// spine requires and never defaults from a receipt clock).
|
|
707
|
+
// Sidechain (subagent) entries are skipped exactly as extractLatestContextUsage does —
|
|
708
|
+
// a Task's usage is the subagent's own session's accounting, not the root turn's.
|
|
709
|
+
export interface TranscriptTokenUsageEntry {
|
|
710
|
+
uuid: string;
|
|
711
|
+
/** The Stop hook's prompt_id this entry belongs to (resolved via the parentUuid chain). */
|
|
712
|
+
promptId?: string;
|
|
713
|
+
model: string;
|
|
714
|
+
inputTokens: number;
|
|
715
|
+
outputTokens: number;
|
|
716
|
+
cacheReadTokens: number;
|
|
717
|
+
cacheWriteTokens: number;
|
|
718
|
+
occurredAt: number;
|
|
719
|
+
/** #3351 — an API assistant entry without a usage block: spend happened, counts are unknown. */
|
|
720
|
+
usageStatus?: "unknown";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function extractTokenUsageEntries(jsonl: string, afterEntry = 0): TranscriptTokenUsageEntry[] {
|
|
724
|
+
const extractor = new TranscriptTokenUsageExtractor(afterEntry);
|
|
725
|
+
const entries: TranscriptTokenUsageEntry[] = [];
|
|
726
|
+
for (const line of jsonl.split("\n")) {
|
|
727
|
+
const entry = extractor.appendLine(line);
|
|
728
|
+
if (entry) entries.push(entry);
|
|
729
|
+
}
|
|
730
|
+
return entries;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** One parseable transcript line → its spine usage record, or undefined when the line
|
|
734
|
+
* is blank/malformed, isn't a reportable root-session assistant usage entry, or falls
|
|
735
|
+
* inside the suppressed prefix. */
|
|
736
|
+
function usageEntryFromTranscriptEntry(entry: TranscriptEntry, byUuid: ReadonlyMap<string, TranscriptEntry>, includeSidechain = false): TranscriptTokenUsageEntry | undefined {
|
|
737
|
+
if (entry.type !== "assistant" || (!includeSidechain && isSidechainEntry(entry))) return undefined;
|
|
738
|
+
const usage = entry.message?.usage;
|
|
739
|
+
const occurredAt = transcriptEntryOccurredAt(entry);
|
|
740
|
+
const uuid = typeof entry.uuid === "string" ? entry.uuid : "";
|
|
741
|
+
const model = typeof entry.message?.model === "string" && entry.message.model ? entry.message.model : usage ? "unknown" : "";
|
|
742
|
+
// The spine requires both an observed createdAt and a dedupeKey: an entry the
|
|
743
|
+
// transcript hasn't stamped yet is not reportable this pass (it stays unconsumed
|
|
744
|
+
// and is picked up once Claude flushes the full entry).
|
|
745
|
+
if (!uuid || occurredAt === undefined || !model) return undefined;
|
|
746
|
+
const promptId = resolveEntryPromptId(entry, byUuid);
|
|
747
|
+
return {
|
|
748
|
+
uuid,
|
|
749
|
+
...(promptId ? { promptId } : {}),
|
|
750
|
+
model,
|
|
751
|
+
inputTokens: usageCount(usage?.input_tokens),
|
|
752
|
+
outputTokens: usageCount(usage?.output_tokens),
|
|
753
|
+
cacheReadTokens: usageCount(usage?.cache_read_input_tokens),
|
|
754
|
+
cacheWriteTokens: usageCount(usage?.cache_creation_input_tokens),
|
|
755
|
+
occurredAt,
|
|
756
|
+
...(usage ? {} : { usageStatus: "unknown" as const }),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Incremental #2482 extractor: feeds transcript lines one at a time so a caller can
|
|
762
|
+
* consume only newly-appended data without re-walking the file. promptId is resolved
|
|
763
|
+
* against the entries THIS instance has seen (the uuid→entry map) — feed a whole file
|
|
764
|
+
* for whole-transcript resolution, or feed only an appended tail and accept that an
|
|
765
|
+
* entry whose promptId-bearing ancestor was flushed in an earlier pass resolves without
|
|
766
|
+
* one. promptId is diagnostic metadata only; the spine record keys on the entry uuid
|
|
767
|
+
* alone. `skipFirst` suppresses the first N parseable lines (the afterEntry cursor of
|
|
768
|
+
* {@link extractTokenUsageEntries}); the capture passes none.
|
|
769
|
+
*/
|
|
770
|
+
export class TranscriptTokenUsageExtractor {
|
|
771
|
+
private readonly byUuid = new Map<string, TranscriptEntry>();
|
|
772
|
+
private seenCount = 0;
|
|
773
|
+
|
|
774
|
+
constructor(private readonly skipFirst = 0, private readonly includeSidechain = false) {}
|
|
775
|
+
|
|
776
|
+
appendLine(line: string): TranscriptTokenUsageEntry | undefined {
|
|
777
|
+
const trimmed = line.trim();
|
|
778
|
+
if (!trimmed) return undefined;
|
|
779
|
+
let entry: TranscriptEntry;
|
|
780
|
+
try {
|
|
781
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
782
|
+
} catch {
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
this.seenCount++;
|
|
786
|
+
if (typeof entry.uuid === "string" && entry.uuid) this.byUuid.set(entry.uuid, entry);
|
|
787
|
+
if (this.seenCount <= this.skipFirst) return undefined;
|
|
788
|
+
return usageEntryFromTranscriptEntry(entry, this.byUuid, this.includeSidechain);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Claude API usage fields are non-negative integers; anything else reports as 0. */
|
|
793
|
+
function usageCount(value: number | undefined): number {
|
|
794
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Claude names its transcript file `<sessionId>.jsonl` (#1235), so the provider's own
|
|
798
|
+
* session uuid is recoverable from the path even when the status probe is silent. */
|
|
799
|
+
export function claudeSessionIdFromTranscriptPath(transcriptPath: string): string {
|
|
800
|
+
const file = transcriptPath.split(/[\\/]/).pop() ?? "";
|
|
801
|
+
return file.replace(/\.[^.]+$/, "");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function extractHookAssistantMessage(content: unknown): string {
|
|
805
|
+
if (typeof content === "string") return content.trim();
|
|
806
|
+
if (!Array.isArray(content)) return "";
|
|
807
|
+
return (content as TranscriptBlock[])
|
|
808
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
809
|
+
.map((b) => b.text!.trim())
|
|
810
|
+
.filter(Boolean)
|
|
811
|
+
.join("\n\n");
|
|
812
|
+
}
|