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.
Files changed (179) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +6 -6
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/adapters/claude-delivery.ts +127 -0
  88. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  89. package/runner/src/adapters/claude-session-probe.ts +225 -0
  90. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  91. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  92. package/runner/src/adapters/claude-transcript.ts +414 -0
  93. package/runner/src/adapters/claude.ts +672 -0
  94. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  95. package/runner/src/adapters/codex-client.ts +279 -0
  96. package/runner/src/adapters/codex.ts +1345 -0
  97. package/runner/src/attachment-cache.ts +189 -0
  98. package/runner/src/busy-reconciler.ts +102 -0
  99. package/runner/src/claim-tracker.ts +140 -0
  100. package/runner/src/claude-prompt-gates.ts +268 -0
  101. package/runner/src/config.ts +1 -4
  102. package/runner/src/context-probe-state.ts +6 -0
  103. package/runner/src/continuation-archive.ts +179 -0
  104. package/runner/src/control-server.ts +639 -0
  105. package/runner/src/index.ts +396 -0
  106. package/runner/src/launch-assembly.ts +508 -0
  107. package/runner/src/liveness.ts +50 -0
  108. package/runner/src/logger.ts +99 -0
  109. package/runner/src/mcp-outbox.ts +120 -0
  110. package/runner/src/message-body-config/config.ts +3 -0
  111. package/runner/src/message-body-config.ts +1 -0
  112. package/runner/src/native-self-resume.ts +202 -0
  113. package/runner/src/outbox.ts +342 -0
  114. package/runner/src/process-meta.ts +9 -0
  115. package/runner/src/profile-home.ts +260 -0
  116. package/runner/src/profile-projection.ts +305 -0
  117. package/runner/src/providers.ts +20 -0
  118. package/runner/src/provisioning.ts +314 -0
  119. package/runner/src/quota.ts +40 -0
  120. package/runner/src/rate-limit.ts +189 -0
  121. package/runner/src/relay-injection-events.ts +102 -0
  122. package/runner/src/relay-instructions.ts +60 -0
  123. package/runner/src/relay-mcp-proxy.ts +383 -0
  124. package/runner/src/relay-mcp.ts +156 -0
  125. package/runner/src/reply-obligation-cache.ts +136 -0
  126. package/runner/src/response-capture-report.ts +63 -0
  127. package/runner/src/runner-core.ts +2409 -0
  128. package/runner/src/runner-helpers.ts +435 -0
  129. package/runner/src/runner-insights.ts +105 -0
  130. package/runner/src/runner.ts +14 -0
  131. package/runner/src/session-destroy.ts +22 -0
  132. package/runner/src/session-insights.ts +118 -0
  133. package/runner/src/session-scratch.ts +271 -0
  134. package/runner/src/template-resolver.ts +29 -0
  135. package/runner/src/version.ts +43 -0
  136. package/src/agent-card-projection.ts +5 -1
  137. package/src/agent-command-activity.ts +39 -0
  138. package/src/automations/reconcile.ts +2 -2
  139. package/src/cli/workspace.ts +3 -1
  140. package/src/config.ts +10 -1
  141. package/src/db/agents.ts +118 -37
  142. package/src/db/message-reads.ts +50 -0
  143. package/src/db/messages.ts +48 -9
  144. package/src/db/migrations.ts +54 -0
  145. package/src/db/provider-quota-burn.ts +96 -67
  146. package/src/db/provider-quotas.ts +29 -7
  147. package/src/db/scheduled-timers.ts +21 -0
  148. package/src/db/schema.ts +1 -0
  149. package/src/db/tasks.ts +33 -3
  150. package/src/db/work-evidence.ts +10 -7
  151. package/src/event-loop-lag.ts +15 -0
  152. package/src/gate-resolver.ts +19 -1
  153. package/src/maintenance/jobs.ts +23 -6
  154. package/src/managed-policy.ts +5 -12
  155. package/src/mcp-plan-tools.ts +13 -1
  156. package/src/project-instructions.ts +36 -2
  157. package/src/quota-advisory.ts +6 -4
  158. package/src/quota-band-transition.ts +23 -14
  159. package/src/routes/agent-sessions.ts +39 -7
  160. package/src/routes/agents.ts +3 -2
  161. package/src/routes/tasks.ts +16 -4
  162. package/src/routes/workspaces.ts +35 -10
  163. package/src/services/plan-graph.ts +27 -9
  164. package/src/services/register-agent.ts +12 -12
  165. package/src/services/send-message.ts +43 -15
  166. package/src/services/spawn-agent.ts +3 -0
  167. package/src/spawn-command.ts +8 -0
  168. package/src/spawn-targets.ts +19 -34
  169. package/src/sse.ts +25 -3
  170. package/src/workspace-merge.ts +25 -5
  171. package/src/workspace-orphans.ts +13 -6
  172. package/public/assets/display-exQDWum3.js +0 -3
  173. package/public/assets/display-exQDWum3.js.map +0 -1
  174. package/public/assets/index-D9OogaB5.js +0 -25
  175. package/public/assets/projects-DdPNPFJI.js +0 -2
  176. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  177. package/public/assets/store-DKTLubBT.js.map +0 -1
  178. package/public/assets/teams-B8f2pGJe.js +0 -2
  179. 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
+ }