agent-relay-server 0.121.5 → 0.122.1
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/docs/openapi.json +1 -1
- package/package.json +6 -6
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +672 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/config.ts +1 -4
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +508 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +43 -0
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +118 -37
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +19 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +5 -12
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -12
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
|
|
2
|
+
import type { ProviderStatusUpdate } from "../adapter";
|
|
3
|
+
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
4
|
+
|
|
5
|
+
// LEGACY PANE-SCRAPE FALLBACK (#766/#768) — busy/idle is now driven primarily by
|
|
6
|
+
// Claude Code's intrinsic session probe (readClaudeSessionStatus, ./claude-session-probe):
|
|
7
|
+
// `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
|
|
8
|
+
// on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
|
|
9
|
+
// probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
|
|
10
|
+
// (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
|
|
11
|
+
// CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
|
|
12
|
+
// restyles its footer/banner. Deliberately substring/regex based (no machine-readable
|
|
13
|
+
// signal existed when these were written). Known break conditions, so the next CC restyle
|
|
14
|
+
// is a fast fix rather than a hunt:
|
|
15
|
+
// readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
|
|
16
|
+
// "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
|
|
17
|
+
// the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
|
|
18
|
+
// busy (claudePaneIsBusy) breaks if CC drops the live "… (<elapsed>" spinner counter
|
|
19
|
+
// (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
|
|
20
|
+
// FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
|
|
21
|
+
// transcript discussing "esc to interrupt", or this very comment shown in a pane)
|
|
22
|
+
// reads as ready/busy. Tolerated because the markers are CC-specific enough to be
|
|
23
|
+
// rare in real output; if it bites, gate on the LAST N lines only (the live footer).
|
|
24
|
+
// History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
|
|
25
|
+
export function claudePaneLooksReady(text: string): boolean {
|
|
26
|
+
// Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
|
|
27
|
+
// conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
|
|
28
|
+
// a run) must detect readiness from the PERSISTENT input-box footer that Claude always
|
|
29
|
+
// renders at the prompt. Requiring the banner made every delivery after the first screen
|
|
30
|
+
// of output time out as "input not ready". Any one of these strong, Claude-specific
|
|
31
|
+
// markers means the TUI is up and the input box exists.
|
|
32
|
+
return text.includes("bypass permissions") // footer in --dangerously-skip-permissions mode
|
|
33
|
+
|| text.includes("shift+tab to cycle") // mode-cycle footer hint (persistent)
|
|
34
|
+
|| text.includes("? for shortcuts") // default footer hint (persistent)
|
|
35
|
+
|| text.includes("/effort")
|
|
36
|
+
|| text.includes("Welcome back")
|
|
37
|
+
|| text.includes("Claude Code");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The working-spinner footer carries a live elapsed-time counter while a turn is in
|
|
41
|
+
// flight, e.g. "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)" — gerund, "… (", then
|
|
42
|
+
// "[Nh ][Nm ]Ns". Anchored on the gerund ellipsis so it can't match the "… +N lines
|
|
43
|
+
// (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
|
|
44
|
+
// "/btw … without interrupting Claude's current work" queue hint.
|
|
45
|
+
const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
|
|
46
|
+
|
|
47
|
+
export function claudePaneIsBusy(text: string): boolean {
|
|
48
|
+
// Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
|
|
49
|
+
// dropped that hint but kept the "(<elapsed>" counter, which is the stable busy
|
|
50
|
+
// signal across versions. Match either so the busy probe (and the reconciler
|
|
51
|
+
// backstop that depends on it) keep working as the footer wording changes.
|
|
52
|
+
return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// #286: detect the subscription usage-limit modal in the pane and turn it into the
|
|
56
|
+
// provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
|
|
57
|
+
// at reset. Mirrors claudeModelUnavailableStatus, but non-terminal (idle, not error).
|
|
58
|
+
export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
59
|
+
const parsed = parseClaudeRateLimitPane(text);
|
|
60
|
+
if (!parsed) return null;
|
|
61
|
+
return {
|
|
62
|
+
status: "idle",
|
|
63
|
+
clear: ["subagent"],
|
|
64
|
+
providerState: buildRateLimitProviderState({
|
|
65
|
+
errorType: parsed.errorType ?? "session_limit", kind: parsed.kind ?? "usage_limit",
|
|
66
|
+
...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
|
|
67
|
+
message: parsed.message,
|
|
68
|
+
source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// #769 — detect the API connection-retry banner in the live terminal footer and surface a
|
|
74
|
+
// transient blocked hold (non-terminal, clears on recovery). Three stable anchors from the
|
|
75
|
+
// banner text: "Unable to connect to API", "Retrying in <N>s", "attempt <N>/<M>".
|
|
76
|
+
// Gated on the LAST N lines so agent output that merely QUOTES these strings (e.g. quoting
|
|
77
|
+
// this comment, or a discussion of retry logic) doesn't false-positive — the live banner
|
|
78
|
+
// always lives in the footer; quoted strings appear in the scrollback body.
|
|
79
|
+
// Break condition: if CC renames/removes any anchor substring the detector stops firing and
|
|
80
|
+
// sessions return to idle/available — a fast fix once the new footer wording is known.
|
|
81
|
+
const CLAUDE_CONN_RETRY_FOOTER_LINES = 10;
|
|
82
|
+
const CLAUDE_CONN_RETRY_ANCHOR_RE = /Unable to connect to API/;
|
|
83
|
+
const CLAUDE_CONN_RETRY_RATE_RE = /Retrying in \d+s/;
|
|
84
|
+
const CLAUDE_CONN_RETRY_ATTEMPT_RE = /attempt \d+\/\d+/;
|
|
85
|
+
|
|
86
|
+
export function claudeConnectionRetryStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
87
|
+
const footer = text.split("\n").slice(-CLAUDE_CONN_RETRY_FOOTER_LINES).join("\n");
|
|
88
|
+
if (
|
|
89
|
+
!CLAUDE_CONN_RETRY_ANCHOR_RE.test(footer) ||
|
|
90
|
+
!CLAUDE_CONN_RETRY_RATE_RE.test(footer) ||
|
|
91
|
+
!CLAUDE_CONN_RETRY_ATTEMPT_RE.test(footer)
|
|
92
|
+
) return null;
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
return {
|
|
95
|
+
status: "idle",
|
|
96
|
+
clear: ["subagent"],
|
|
97
|
+
providerState: {
|
|
98
|
+
state: "blocked",
|
|
99
|
+
reason: "conn_retry",
|
|
100
|
+
kind: "conn_retry",
|
|
101
|
+
label: "connection degraded · retrying",
|
|
102
|
+
message: "Claude cannot reach the Anthropic API and is retrying the connection.",
|
|
103
|
+
source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
|
|
104
|
+
terminal: false,
|
|
105
|
+
recommendedAction: "Holding; agent-relay will clear this state once the session reconnects.",
|
|
106
|
+
enteredAt: now,
|
|
107
|
+
updatedAt: now,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function claudeModelUnavailableStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
113
|
+
const message = extractClaudeModelUnavailableMessage(text);
|
|
114
|
+
if (!message) return null;
|
|
115
|
+
return {
|
|
116
|
+
status: "error",
|
|
117
|
+
clear: ["provider-turn", "subagent"],
|
|
118
|
+
providerState: {
|
|
119
|
+
state: "failed",
|
|
120
|
+
reason: "model-unavailable",
|
|
121
|
+
message,
|
|
122
|
+
source: "claude-pane",
|
|
123
|
+
terminal: true,
|
|
124
|
+
...(sessionName ? { sessionName } : {}),
|
|
125
|
+
recommendedAction: "Choose a different Claude model before restarting this agent.",
|
|
126
|
+
},
|
|
127
|
+
metadata: {
|
|
128
|
+
terminalFailureReason: "model-unavailable",
|
|
129
|
+
terminalFailureMessage: message,
|
|
130
|
+
},
|
|
131
|
+
timeline: {
|
|
132
|
+
status: "provider.restart_decision",
|
|
133
|
+
id: `provider-model-unavailable-${Date.now()}`,
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
title: "Provider restart skipped",
|
|
136
|
+
body: message,
|
|
137
|
+
icon: "ti-player-stop",
|
|
138
|
+
metadata: {
|
|
139
|
+
eventType: "provider.restart_decision",
|
|
140
|
+
decision: "stop-surface",
|
|
141
|
+
reason: "model-unavailable",
|
|
142
|
+
modelUnavailable: true,
|
|
143
|
+
modelUnavailableMessage: message,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
|
+
import { LatestTurnStepAccumulator, type TurnStep } from "./claude-transcript";
|
|
3
|
+
|
|
4
|
+
const FINGERPRINT_BYTES = 4096;
|
|
5
|
+
|
|
6
|
+
export interface ClaudeTranscriptFileIdentity {
|
|
7
|
+
dev: number;
|
|
8
|
+
ino: number;
|
|
9
|
+
birthtimeMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TailFingerprint {
|
|
13
|
+
offset: number;
|
|
14
|
+
bytes: Buffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IncrementalTranscriptPollResult {
|
|
18
|
+
available: boolean;
|
|
19
|
+
changed: boolean;
|
|
20
|
+
reset: boolean;
|
|
21
|
+
steps: TurnStep[];
|
|
22
|
+
transcriptLooksComplete: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class IncrementalClaudeTranscriptTail {
|
|
26
|
+
private offset = 0;
|
|
27
|
+
private pending = Buffer.alloc(0);
|
|
28
|
+
private identity: ClaudeTranscriptFileIdentity | undefined;
|
|
29
|
+
private fingerprint: TailFingerprint | undefined;
|
|
30
|
+
private readonly accumulator = new LatestTurnStepAccumulator();
|
|
31
|
+
|
|
32
|
+
async poll(path: string): Promise<IncrementalTranscriptPollResult> {
|
|
33
|
+
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
34
|
+
try {
|
|
35
|
+
fileStat = await stat(path);
|
|
36
|
+
} catch {
|
|
37
|
+
return this.result({ available: false, changed: false, reset: false });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const nextIdentity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
|
|
41
|
+
const replaced = this.identity !== undefined && !sameIdentity(this.identity, nextIdentity);
|
|
42
|
+
const truncated = this.offset > fileStat.size;
|
|
43
|
+
let reset = replaced || truncated;
|
|
44
|
+
if (!reset && this.identity && this.fingerprint && fileStat.size >= this.offset) {
|
|
45
|
+
reset = !(await this.fingerprintMatches(path));
|
|
46
|
+
}
|
|
47
|
+
if (reset) this.reset(nextIdentity);
|
|
48
|
+
else if (!this.identity) this.identity = nextIdentity;
|
|
49
|
+
|
|
50
|
+
if (this.offset === fileStat.size) {
|
|
51
|
+
return this.result({ available: true, changed: false, reset });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const length = fileStat.size - this.offset;
|
|
55
|
+
const buffer = Buffer.alloc(length);
|
|
56
|
+
let bytesRead = 0;
|
|
57
|
+
const handle = await open(path, "r");
|
|
58
|
+
try {
|
|
59
|
+
const read = await handle.read(buffer, 0, length, this.offset);
|
|
60
|
+
bytesRead = read.bytesRead;
|
|
61
|
+
} finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.offset += bytesRead;
|
|
66
|
+
const delta = buffer.subarray(0, bytesRead);
|
|
67
|
+
this.updateFingerprint(delta);
|
|
68
|
+
const changed = bytesRead > 0 && this.consume(delta);
|
|
69
|
+
return this.result({ available: true, changed, reset });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private reset(identity: ClaudeTranscriptFileIdentity): void {
|
|
73
|
+
this.offset = 0;
|
|
74
|
+
this.pending = Buffer.alloc(0);
|
|
75
|
+
this.fingerprint = undefined;
|
|
76
|
+
this.identity = identity;
|
|
77
|
+
this.accumulator.reset();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async fingerprintMatches(path: string): Promise<boolean> {
|
|
81
|
+
if (!this.fingerprint) return true;
|
|
82
|
+
const buffer = Buffer.alloc(this.fingerprint.bytes.length);
|
|
83
|
+
const handle = await open(path, "r");
|
|
84
|
+
try {
|
|
85
|
+
const read = await handle.read(buffer, 0, buffer.length, this.fingerprint.offset);
|
|
86
|
+
return read.bytesRead === buffer.length && buffer.equals(this.fingerprint.bytes);
|
|
87
|
+
} finally {
|
|
88
|
+
await handle.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private updateFingerprint(delta: Buffer): void {
|
|
93
|
+
if (!delta.length) return;
|
|
94
|
+
const previous = this.fingerprint?.bytes ?? Buffer.alloc(0);
|
|
95
|
+
const combined = previous.length ? Buffer.concat([previous, delta]) : delta;
|
|
96
|
+
const bytes = Buffer.from(combined.subarray(Math.max(0, combined.length - FINGERPRINT_BYTES)));
|
|
97
|
+
this.fingerprint = { offset: this.offset - bytes.length, bytes };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private consume(delta: Buffer): boolean {
|
|
101
|
+
if (!delta.length) return false;
|
|
102
|
+
const combined = this.pending.length ? Buffer.concat([this.pending, delta]) : delta;
|
|
103
|
+
const lastNewline = combined.lastIndexOf(0x0a);
|
|
104
|
+
if (lastNewline === -1) {
|
|
105
|
+
this.pending = Buffer.from(combined);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const complete = combined.subarray(0, lastNewline + 1);
|
|
110
|
+
this.pending = Buffer.from(combined.subarray(lastNewline + 1));
|
|
111
|
+
this.accumulator.appendJsonl(complete.toString("utf8"));
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private result(input: { available: boolean; changed: boolean; reset: boolean }): IncrementalTranscriptPollResult {
|
|
116
|
+
return {
|
|
117
|
+
...input,
|
|
118
|
+
steps: this.accumulator.steps,
|
|
119
|
+
transcriptLooksComplete: this.accumulator.transcriptLooksComplete,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sameIdentity(a: ClaudeTranscriptFileIdentity, b: ClaudeTranscriptFileIdentity): boolean {
|
|
125
|
+
return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const sameClaudeTranscriptFileIdentityForTests = sameIdentity;
|
|
@@ -0,0 +1,414 @@
|
|
|
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 { computeContextRatio, type SessionAnalysis, type SessionEvent } from "../session-insights";
|
|
14
|
+
|
|
15
|
+
interface TranscriptBlock {
|
|
16
|
+
type?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
thinking?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
input?: Record<string, unknown>;
|
|
21
|
+
is_error?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TurnStep {
|
|
25
|
+
type: "narration" | "reasoning" | "tool";
|
|
26
|
+
text: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TranscriptMessage {
|
|
31
|
+
role?: string;
|
|
32
|
+
content?: string | TranscriptBlock[];
|
|
33
|
+
stop_reason?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TranscriptEntry {
|
|
37
|
+
type?: string;
|
|
38
|
+
message?: TranscriptMessage;
|
|
39
|
+
// Claude Code stamps every transcript entry with `isSidechain`: true for
|
|
40
|
+
// entries belonging to a Task (subagent) run, false for the root session.
|
|
41
|
+
// Current CC writes sidechains to a separate subagents/*.jsonl so they don't
|
|
42
|
+
// reach the root transcript the runner tails — but older CC inlined them, and
|
|
43
|
+
// the behavior can revert, so the chat-mirror parsers below defensively skip
|
|
44
|
+
// sidechain entries to keep a subagent's reasoning/tools/responses from
|
|
45
|
+
// leaking into the parent agent's chat. Insights parsers (collectClaudeSession-
|
|
46
|
+
// Events/countSubstantiveTurns) intentionally do NOT filter — changing them
|
|
47
|
+
// would shift the #184/#185 baselines, a separate concern.
|
|
48
|
+
isSidechain?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
|
|
52
|
+
if (!message || !Array.isArray(message.content)) return [];
|
|
53
|
+
return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True for a subagent (Task) transcript entry — see the note on TranscriptEntry.isSidechain. */
|
|
57
|
+
function isSidechainEntry(entry: TranscriptEntry): boolean {
|
|
58
|
+
return entry.isSidechain === true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isRealUserPrompt(entry: TranscriptEntry): boolean {
|
|
62
|
+
if (entry.type !== "user") return false;
|
|
63
|
+
const content = entry.message?.content;
|
|
64
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
65
|
+
// An array user entry is a real prompt only if it carries a text block;
|
|
66
|
+
// pure tool_result entries are mid-turn plumbing, not a new prompt.
|
|
67
|
+
return blocks(entry.message).some((b) => b.type === "text" && Boolean(b.text?.trim()));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assistantText(entry: TranscriptEntry): string {
|
|
71
|
+
if (entry.type !== "assistant") return "";
|
|
72
|
+
return blocks(entry.message)
|
|
73
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
74
|
+
.map((b) => b.text!.trim())
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join("\n\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns true when the transcript's final parseable entry is an assistant
|
|
81
|
+
* message with stop_reason "end_turn", meaning the full turn has been flushed.
|
|
82
|
+
* Used to detect a race where the Stop hook reads the file before the last
|
|
83
|
+
* assistant block is written to disk.
|
|
84
|
+
*/
|
|
85
|
+
export function transcriptLooksComplete(jsonl: string): boolean {
|
|
86
|
+
const state = new LatestTurnStepAccumulator();
|
|
87
|
+
state.appendJsonl(jsonl);
|
|
88
|
+
return state.transcriptLooksComplete;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the concatenated assistant `text` for the most recent turn (since the
|
|
93
|
+
* last real user prompt), or "" if there is nothing user-visible to surface.
|
|
94
|
+
*/
|
|
95
|
+
export function extractLastAssistantTurn(jsonl: string): string {
|
|
96
|
+
const lines = jsonl.split("\n");
|
|
97
|
+
let collected: string[] = [];
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
if (!trimmed) continue;
|
|
101
|
+
let entry: TranscriptEntry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (isSidechainEntry(entry)) continue;
|
|
108
|
+
if (isRealUserPrompt(entry)) {
|
|
109
|
+
collected = [];
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const text = assistantText(entry);
|
|
113
|
+
if (text) collected.push(text);
|
|
114
|
+
}
|
|
115
|
+
return collected.join("\n\n").trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Like extractLastAssistantTurn but skips the first `afterEntry` valid JSON entries.
|
|
120
|
+
* Used when a partial transcript flush already emitted entries 1..afterEntry so the
|
|
121
|
+
* Stop-hook full-mode capture doesn't re-emit the already-published text.
|
|
122
|
+
*/
|
|
123
|
+
export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: number): string {
|
|
124
|
+
const lines = jsonl.split("\n");
|
|
125
|
+
const collected: string[] = [];
|
|
126
|
+
let entryIndex = 0;
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (!trimmed) continue;
|
|
130
|
+
let entry: TranscriptEntry;
|
|
131
|
+
try {
|
|
132
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
entryIndex++;
|
|
137
|
+
if (isSidechainEntry(entry)) continue;
|
|
138
|
+
if (isRealUserPrompt(entry)) {
|
|
139
|
+
collected.length = 0;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (entryIndex <= afterEntry) continue;
|
|
143
|
+
const text = assistantText(entry);
|
|
144
|
+
if (text) collected.push(text);
|
|
145
|
+
}
|
|
146
|
+
return collected.join("\n\n").trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Count valid JSON entries in a JSONL string. Used to record the high-water-mark
|
|
151
|
+
* after a pre-flush so the Stop hook knows where to resume in full-capture mode.
|
|
152
|
+
*/
|
|
153
|
+
export function countTranscriptEntries(jsonl: string): number {
|
|
154
|
+
let count = 0;
|
|
155
|
+
for (const line of jsonl.split("\n")) {
|
|
156
|
+
if (!line.trim()) continue;
|
|
157
|
+
try { JSON.parse(line); count++; } catch {}
|
|
158
|
+
}
|
|
159
|
+
return count;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Returns only the text from the LAST assistant entry in the current turn.
|
|
164
|
+
* Unlike extractLastAssistantTurn which collects all intermediate text between
|
|
165
|
+
* tool calls, this returns just the final response — what the user sees as the
|
|
166
|
+
* concluding message.
|
|
167
|
+
*/
|
|
168
|
+
export function extractFinalAssistantMessage(jsonl: string): string {
|
|
169
|
+
const lines = jsonl.split("\n");
|
|
170
|
+
let lastText = "";
|
|
171
|
+
let pastLastUserPrompt = false;
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const trimmed = line.trim();
|
|
174
|
+
if (!trimmed) continue;
|
|
175
|
+
let entry: TranscriptEntry;
|
|
176
|
+
try {
|
|
177
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (isSidechainEntry(entry)) continue;
|
|
182
|
+
if (isRealUserPrompt(entry)) {
|
|
183
|
+
pastLastUserPrompt = true;
|
|
184
|
+
lastText = "";
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!pastLastUserPrompt) continue;
|
|
188
|
+
const text = assistantText(entry);
|
|
189
|
+
if (text) lastText = text;
|
|
190
|
+
}
|
|
191
|
+
return lastText.trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Like extractFinalAssistantMessage but skips the first `afterEntry` valid JSON
|
|
196
|
+
* entries. Used when a PreToolUse pre-flush already emitted the text before a
|
|
197
|
+
* blocking control, so final-mode Stop capture only sees later assistant text.
|
|
198
|
+
*/
|
|
199
|
+
export function extractFinalAssistantMessageAfterEntry(jsonl: string, afterEntry: number): string {
|
|
200
|
+
const lines = jsonl.split("\n");
|
|
201
|
+
let lastText = "";
|
|
202
|
+
let pastLastUserPrompt = afterEntry > 0;
|
|
203
|
+
let entryIndex = 0;
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (!trimmed) continue;
|
|
207
|
+
let entry: TranscriptEntry;
|
|
208
|
+
try {
|
|
209
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
210
|
+
} catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
entryIndex++;
|
|
214
|
+
if (isSidechainEntry(entry)) continue;
|
|
215
|
+
if (entryIndex <= afterEntry) continue;
|
|
216
|
+
if (isRealUserPrompt(entry)) {
|
|
217
|
+
pastLastUserPrompt = true;
|
|
218
|
+
lastText = "";
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!pastLastUserPrompt) continue;
|
|
222
|
+
const text = assistantText(entry);
|
|
223
|
+
if (text) lastText = text;
|
|
224
|
+
}
|
|
225
|
+
return lastText.trim();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Extract text from the `last_assistant_message` field in the Stop hook
|
|
230
|
+
* payload. This is the content of the final assistant message — either a plain
|
|
231
|
+
* string or an array of content blocks (same shape as transcript entries).
|
|
232
|
+
* Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
|
|
233
|
+
*/
|
|
234
|
+
/**
|
|
235
|
+
* Extract the ordered narration, reasoning, and tool steps for the most recent
|
|
236
|
+
* turn (since the last real user prompt). Used by the reasoning tailer to stream
|
|
237
|
+
* progress into chat while a turn is in flight. `narration` is the assistant's
|
|
238
|
+
* intermediate `text` between tool calls (the terminal's `●` lines); it is the
|
|
239
|
+
* primary, default-visible turn content. Returns steps in transcript order so the
|
|
240
|
+
* tailer can emit only the ones it hasn't seen yet.
|
|
241
|
+
*/
|
|
242
|
+
export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
|
|
243
|
+
const state = new LatestTurnStepAccumulator();
|
|
244
|
+
state.appendJsonl(jsonl);
|
|
245
|
+
return state.steps;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export class LatestTurnStepAccumulator {
|
|
249
|
+
private latestSteps: TurnStep[] = [];
|
|
250
|
+
private lastAssistantStopReason: string | undefined;
|
|
251
|
+
private parsedEntries = 0;
|
|
252
|
+
|
|
253
|
+
get steps(): TurnStep[] {
|
|
254
|
+
return [...this.latestSteps];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get transcriptLooksComplete(): boolean {
|
|
258
|
+
return this.lastAssistantStopReason === "end_turn";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
get entryCount(): number {
|
|
262
|
+
return this.parsedEntries;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
reset(): void {
|
|
266
|
+
this.latestSteps = [];
|
|
267
|
+
this.lastAssistantStopReason = undefined;
|
|
268
|
+
this.parsedEntries = 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
appendJsonl(jsonl: string): void {
|
|
272
|
+
for (const line of jsonl.split("\n")) this.appendLine(line);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
appendLine(line: string): void {
|
|
276
|
+
const trimmed = line.trim();
|
|
277
|
+
if (!trimmed) return;
|
|
278
|
+
let entry: TranscriptEntry;
|
|
279
|
+
try {
|
|
280
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
281
|
+
} catch {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.parsedEntries += 1;
|
|
285
|
+
if (isSidechainEntry(entry)) return;
|
|
286
|
+
if (entry.type === "assistant") this.lastAssistantStopReason = entry.message?.stop_reason;
|
|
287
|
+
if (isRealUserPrompt(entry)) {
|
|
288
|
+
this.latestSteps = [];
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (entry.type !== "assistant") return;
|
|
292
|
+
for (const b of blocks(entry.message)) {
|
|
293
|
+
if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
|
|
294
|
+
this.latestSteps.push({ type: "narration", text: b.text.trim() });
|
|
295
|
+
} else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
|
|
296
|
+
this.latestSteps.push({ type: "reasoning", text: b.thinking.trim() });
|
|
297
|
+
} else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
|
|
298
|
+
this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Stable dedup keys for a turn's steps, in order. Each key is salted with how many
|
|
306
|
+
* identical (type,label,text) steps preceded it in the same window — so running the
|
|
307
|
+
* same tool twice with identical input within a turn yields two distinct keys and
|
|
308
|
+
* both show in the activity trace (#265). Keying on occurrence-within-window rather
|
|
309
|
+
* than raw transcript index keeps the reasoning tailer idempotent when the "latest
|
|
310
|
+
* turn" window shrinks/resets mid-poll: a surviving step recomputes to the same or a
|
|
311
|
+
* lower occurrence, so an already-emitted step never re-fires.
|
|
312
|
+
*/
|
|
313
|
+
export function stepDedupKeys(steps: TurnStep[]): string[] {
|
|
314
|
+
const counts = new Map<string, number>();
|
|
315
|
+
return steps.map((step) => {
|
|
316
|
+
const base = JSON.stringify([step.type, step.label ?? "", step.text]);
|
|
317
|
+
const occ = counts.get(base) ?? 0;
|
|
318
|
+
counts.set(base, occ + 1);
|
|
319
|
+
return JSON.stringify([step.type, step.label ?? "", step.text, occ]);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Compact one-line summary of a tool invocation for the discreet activity row. */
|
|
324
|
+
export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
|
|
325
|
+
const str = (key: string): string | undefined => (input && typeof input[key] === "string" ? (input[key] as string) : undefined);
|
|
326
|
+
const candidate = str("command") ?? str("file_path") ?? str("path") ?? str("pattern") ?? str("query") ?? str("url") ?? str("description") ?? str("prompt");
|
|
327
|
+
const summary = candidate ? candidate.replace(/\s+/g, " ").trim() : "";
|
|
328
|
+
if (!summary) return name;
|
|
329
|
+
return summary.length > 200 ? `${summary.slice(0, 197)}…` : summary;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- Insights #184: context-gathering ratio (epic #183, docs/self-improvement.md) ---
|
|
333
|
+
//
|
|
334
|
+
// Computed mechanically from the whole-session transcript at session end — no model
|
|
335
|
+
// involvement, so it costs zero agent tokens and the agent can't game it. The ratio is
|
|
336
|
+
// paired with cheap outcome proxies (user re-prompts, tool errors) so it's never read
|
|
337
|
+
// alone — see the anti-Goodhart constraint in the epic.
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Normalize a full Claude transcript into the provider-agnostic `SessionEvent` stream
|
|
341
|
+
* (#183/#184). The classifier and ratio math live in `session-insights.ts` and are shared
|
|
342
|
+
* across providers; this only knows the Claude JSONL shape. Events are emitted in
|
|
343
|
+
* transcript order so `leadingGather` is meaningful.
|
|
344
|
+
*/
|
|
345
|
+
export function collectClaudeSessionEvents(jsonl: string): SessionEvent[] {
|
|
346
|
+
const events: SessionEvent[] = [];
|
|
347
|
+
for (const line of jsonl.split("\n")) {
|
|
348
|
+
const trimmed = line.trim();
|
|
349
|
+
if (!trimmed) continue;
|
|
350
|
+
let entry: TranscriptEntry;
|
|
351
|
+
try {
|
|
352
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
353
|
+
} catch {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (isRealUserPrompt(entry)) events.push({ type: "user_prompt" });
|
|
357
|
+
if (entry.type === "user") {
|
|
358
|
+
for (const b of blocks(entry.message)) {
|
|
359
|
+
if (b.type === "tool_result" && b.is_error === true) events.push({ type: "tool_error" });
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (entry.type !== "assistant") continue;
|
|
364
|
+
let producedSomething = false;
|
|
365
|
+
for (const b of blocks(entry.message)) {
|
|
366
|
+
if (b.type === "text" && b.text?.trim()) producedSomething = true;
|
|
367
|
+
if (b.type !== "tool_use" || typeof b.name !== "string" || !b.name) continue;
|
|
368
|
+
producedSomething = true;
|
|
369
|
+
events.push({ type: "tool", name: b.name });
|
|
370
|
+
}
|
|
371
|
+
if (producedSomething) events.push({ type: "turn" });
|
|
372
|
+
}
|
|
373
|
+
return events;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Walk the full transcript and compute the context-gathering ratio plus paired outcome
|
|
378
|
+
* proxies. Returns null when there's nothing substantive to measure (no tool calls) —
|
|
379
|
+
* trivial sessions have nothing to learn from and shouldn't pollute the baselines.
|
|
380
|
+
*/
|
|
381
|
+
export function analyzeSession(jsonl: string): SessionAnalysis | null {
|
|
382
|
+
return computeContextRatio(collectClaudeSessionEvents(jsonl));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Count substantive assistant turns — used by the #185 introspection gate. */
|
|
386
|
+
export function countSubstantiveTurns(jsonl: string): number {
|
|
387
|
+
let turns = 0;
|
|
388
|
+
for (const line of jsonl.split("\n")) {
|
|
389
|
+
const trimmed = line.trim();
|
|
390
|
+
if (!trimmed) continue;
|
|
391
|
+
let entry: TranscriptEntry;
|
|
392
|
+
try {
|
|
393
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
394
|
+
} catch {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (entry.type !== "assistant") continue;
|
|
398
|
+
const hasContent = blocks(entry.message).some(
|
|
399
|
+
(b) => (b.type === "text" && b.text?.trim()) || (b.type === "tool_use" && b.name),
|
|
400
|
+
);
|
|
401
|
+
if (hasContent) turns++;
|
|
402
|
+
}
|
|
403
|
+
return turns;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function extractHookAssistantMessage(content: unknown): string {
|
|
407
|
+
if (typeof content === "string") return content.trim();
|
|
408
|
+
if (!Array.isArray(content)) return "";
|
|
409
|
+
return (content as TranscriptBlock[])
|
|
410
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
411
|
+
.map((b) => b.text!.trim())
|
|
412
|
+
.filter(Boolean)
|
|
413
|
+
.join("\n\n");
|
|
414
|
+
}
|