@cuongtran001/kanna 1.3.0 → 1.3.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 (186) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-CbSY9HfS.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/{index-C0U2kwuB.js → index-TU1k6ov6.js} +5 -5
  29. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  30. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  33. package/dist/client/assets/{linear-DLR6miy3.js → linear-ByKHtsIx.js} +1 -1
  34. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-Dl_f2KLc.js} +4 -4
  35. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  41. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  42. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  43. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  44. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  45. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  46. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  47. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  48. package/dist/client/index.html +1 -1
  49. package/package.json +1 -1
  50. package/src/server/agent-coordinator-types.ts +158 -0
  51. package/src/server/agent-coordinator.ts +919 -0
  52. package/src/server/agent-deps-builders.ts +425 -0
  53. package/src/server/agent.test.ts +22 -6
  54. package/src/server/agent.ts +35 -4987
  55. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  56. package/src/server/claude-autocontinue-commands.ts +256 -0
  57. package/src/server/claude-cancel-handler.test.ts +472 -0
  58. package/src/server/claude-cancel-handler.ts +241 -0
  59. package/src/server/claude-chat-management.test.ts +395 -0
  60. package/src/server/claude-chat-management.ts +251 -0
  61. package/src/server/claude-harness-stream.test.ts +273 -0
  62. package/src/server/claude-harness-stream.ts +158 -0
  63. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  64. package/src/server/claude-loop-orch-commands.ts +466 -0
  65. package/src/server/claude-message-normalizer.test.ts +213 -0
  66. package/src/server/claude-message-normalizer.ts +436 -0
  67. package/src/server/claude-prompt-helpers.test.ts +248 -0
  68. package/src/server/claude-prompt-helpers.ts +139 -0
  69. package/src/server/claude-sdk-queue.test.ts +169 -0
  70. package/src/server/claude-sdk-queue.ts +82 -0
  71. package/src/server/claude-send-command.test.ts +414 -0
  72. package/src/server/claude-send-command.ts +420 -0
  73. package/src/server/claude-session-config-helpers.test.ts +279 -0
  74. package/src/server/claude-session-config-helpers.ts +151 -0
  75. package/src/server/claude-session-config.test.ts +250 -0
  76. package/src/server/claude-session-config.ts +159 -0
  77. package/src/server/claude-session-error-handler.test.ts +498 -0
  78. package/src/server/claude-session-error-handler.ts +361 -0
  79. package/src/server/claude-session-lifecycle.test.ts +669 -0
  80. package/src/server/claude-session-lifecycle.ts +283 -0
  81. package/src/server/claude-session-rebuild.test.ts +224 -0
  82. package/src/server/claude-session-rebuild.ts +101 -0
  83. package/src/server/claude-session-runner.test.ts +523 -0
  84. package/src/server/claude-session-runner.ts +521 -0
  85. package/src/server/claude-session-spawner.test.ts +401 -0
  86. package/src/server/claude-session-spawner.ts +401 -0
  87. package/src/server/claude-session-start.test.ts +205 -0
  88. package/src/server/claude-session-start.ts +277 -0
  89. package/src/server/claude-session-state-queries.test.ts +344 -0
  90. package/src/server/claude-session-state-queries.ts +160 -0
  91. package/src/server/claude-session-state.ts +104 -0
  92. package/src/server/claude-slash-commands.test.ts +277 -0
  93. package/src/server/claude-slash-commands.ts +234 -0
  94. package/src/server/claude-spawn-helpers.test.ts +221 -0
  95. package/src/server/claude-spawn-helpers.ts +179 -0
  96. package/src/server/claude-steer-log.test.ts +243 -0
  97. package/src/server/claude-steer-log.ts +58 -0
  98. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  99. package/src/server/claude-subagent-tool-response.ts +165 -0
  100. package/src/server/claude-subagent-wiring.test.ts +263 -0
  101. package/src/server/claude-subagent-wiring.ts +324 -0
  102. package/src/server/claude-tool-respond.test.ts +321 -0
  103. package/src/server/claude-tool-respond.ts +142 -0
  104. package/src/server/claude-turn-runner.test.ts +310 -0
  105. package/src/server/claude-turn-runner.ts +228 -0
  106. package/src/server/claude-turn-starter.test.ts +266 -0
  107. package/src/server/claude-turn-starter.ts +566 -0
  108. package/src/server/claude-usage-math.test.ts +169 -0
  109. package/src/server/claude-usage-math.ts +132 -0
  110. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  111. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  112. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  113. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  114. package/src/server/diff-store-errors.ts +85 -0
  115. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  116. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  117. package/src/server/diff-store-io.adapter.ts +27 -0
  118. package/src/server/diff-store-parse.test.ts +212 -0
  119. package/src/server/diff-store-parse.ts +122 -0
  120. package/src/server/diff-store-state.ts +82 -0
  121. package/src/server/diff-store.ts +76 -1854
  122. package/src/server/event-store-apply.test.ts +280 -0
  123. package/src/server/event-store-apply.ts +132 -0
  124. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  125. package/src/server/event-store-chat-lifecycle.ts +441 -0
  126. package/src/server/event-store-entity-write.ts +356 -0
  127. package/src/server/event-store-helpers.test.ts +230 -0
  128. package/src/server/event-store-helpers.ts +235 -0
  129. package/src/server/event-store-init.ts +276 -0
  130. package/src/server/event-store-messages.adapter.test.ts +365 -0
  131. package/src/server/event-store-messages.adapter.ts +213 -0
  132. package/src/server/event-store-orch-subscription.ts +124 -0
  133. package/src/server/event-store-orch.test.ts +462 -0
  134. package/src/server/event-store-orch.ts +312 -0
  135. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  136. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  137. package/src/server/event-store-snapshot.test.ts +597 -0
  138. package/src/server/event-store-snapshot.ts +658 -0
  139. package/src/server/event-store-subagent.test.ts +408 -0
  140. package/src/server/event-store-subagent.ts +198 -0
  141. package/src/server/event-store-tool-requests.test.ts +210 -0
  142. package/src/server/event-store-tool-requests.ts +131 -0
  143. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  144. package/src/server/event-store-write-ops.test.ts +554 -0
  145. package/src/server/event-store-write-ops.ts +529 -0
  146. package/src/server/event-store.test.ts +35 -0
  147. package/src/server/event-store.ts +251 -2285
  148. package/src/server/harness-types.ts +15 -1
  149. package/src/server/oauth-errors.ts +19 -0
  150. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  151. package/src/server/ws-router-agent-ctrl.ts +137 -0
  152. package/src/server/ws-router-broadcast.ts +488 -0
  153. package/src/server/ws-router-chat.test.ts +461 -0
  154. package/src/server/ws-router-chat.ts +279 -0
  155. package/src/server/ws-router-defaults.test.ts +283 -0
  156. package/src/server/ws-router-defaults.ts +434 -0
  157. package/src/server/ws-router-diff.test.ts +167 -0
  158. package/src/server/ws-router-diff.ts +250 -0
  159. package/src/server/ws-router-envelope.ts +329 -0
  160. package/src/server/ws-router-misc.test.ts +422 -0
  161. package/src/server/ws-router-misc.ts +265 -0
  162. package/src/server/ws-router-orch.test.ts +190 -0
  163. package/src/server/ws-router-orch.ts +93 -0
  164. package/src/server/ws-router-project.test.ts +403 -0
  165. package/src/server/ws-router-project.ts +256 -0
  166. package/src/server/ws-router-push.test.ts +193 -0
  167. package/src/server/ws-router-push.ts +120 -0
  168. package/src/server/ws-router-settings.test.ts +173 -0
  169. package/src/server/ws-router-settings.ts +416 -0
  170. package/src/server/ws-router-skills.test.ts +194 -0
  171. package/src/server/ws-router-skills.ts +204 -0
  172. package/src/server/ws-router-utils.ts +224 -0
  173. package/src/server/ws-router.ts +339 -2254
  174. package/src/shared/app-settings-types.ts +365 -0
  175. package/src/shared/core-types.ts +27 -0
  176. package/src/shared/git-diff-types.ts +165 -0
  177. package/src/shared/mcp-types.ts +105 -0
  178. package/src/shared/provider-model-types.ts +437 -0
  179. package/src/shared/subagent-types.ts +198 -0
  180. package/src/shared/tool-call-types.ts +314 -0
  181. package/src/shared/transcript-types.ts +270 -0
  182. package/src/shared/types.ts +25 -1711
  183. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  184. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  185. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  186. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Standalone session-lifecycle helpers for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the eight related private methods live in their
5
+ * own testable module. The coordinator delegates to these functions by passing
6
+ * an object literal that satisfies `SessionLifecycleDeps`.
7
+ *
8
+ * Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
9
+ * calls, no Bun primitives). Every effectful operation is injected through
10
+ * the deps interface, including the `homeDir` used by computeWorkflowsDir so
11
+ * tests can control it without real OS calls.
12
+ */
13
+
14
+ import type { ClaudeDriverPreference } from "../shared/types"
15
+ import type { ClaudeSessionState, ActiveTurn } from "./claude-session-state"
16
+ import type { TokenUnavailability } from "./oauth-pool/oauth-token-pool"
17
+ import { computeWorkflowsDir } from "./claude-pty/jsonl-path.adapter"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Structural sub-interfaces — only the operations this module calls.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Subset of OAuthTokenPool used by the lifecycle helpers. */
24
+ interface LifecycleOAuthPool {
25
+ release(chatId: string): void
26
+ describeUnavailability(reservedFor?: string): TokenUnavailability[]
27
+ }
28
+
29
+ /** Subset of WorkflowRegistry used by the lifecycle helpers. */
30
+ interface LifecycleWorkflowRegistry {
31
+ hasActiveRun(chatId: string, freshnessMs: number, now: number): boolean
32
+ register(chatId: string, workflowsDir: string): void
33
+ unregister(chatId: string): void
34
+ }
35
+
36
+ /** Subset of EventStore used by buildPoolUnavailableMessage. */
37
+ interface LifecycleStore {
38
+ getChat(id: string): { title?: string | null } | null | undefined
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Dependency bundle injected by AgentCoordinator
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface SessionLifecycleDeps {
46
+ /** Returns the current app settings snapshot. Only lifecycle + driver slices are read. */
47
+ getAppSettingsSnapshot(): {
48
+ claudeDriver?: {
49
+ lifecycle?: {
50
+ idleTimeoutMs?: number
51
+ maxConcurrent?: number
52
+ }
53
+ }
54
+ }
55
+
56
+ /** Default idle timeout (from claudeSessionLifecycle.idleMs constructor arg). */
57
+ defaultIdleMs: number
58
+ /** Default max resident sessions (from claudeSessionLifecycle.maxResidentSessions). */
59
+ defaultMaxResidentSessions: number
60
+
61
+ /** The live claudeSessions map owned by the coordinator. */
62
+ claudeSessions: Map<string, ClaudeSessionState>
63
+ /** Active turns map — only `.has()` is called. */
64
+ activeTurns: Pick<Map<string, ActiveTurn>, "has">
65
+
66
+ /** OAuth token pool, or null when no pool is configured. */
67
+ oauthPool: LifecycleOAuthPool | null
68
+
69
+ /** Workflow registry, or null when not configured. */
70
+ workflowRegistry: LifecycleWorkflowRegistry | null
71
+
72
+ /** Returns the currently resolved Claude driver preference. */
73
+ resolveClaudeDriverPreference(): ClaudeDriverPreference
74
+
75
+ /** Emit a state-change event for a chat (called after evictions). */
76
+ emitStateChange(chatId: string): void
77
+
78
+ /** Store — used by buildPoolUnavailableMessage to resolve chat titles. */
79
+ store: LifecycleStore
80
+
81
+ /** Home directory — injected so tests do not need a real OS call. */
82
+ homeDir: string
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Exported standalone functions
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Resolve the effective Claude idle timeout. Prefers the user-configured
91
+ * override in app settings; falls back to the constructor default.
92
+ */
93
+ export function resolveClaudeIdleMs(deps: SessionLifecycleDeps): number {
94
+ const fromSettings = deps.getAppSettingsSnapshot().claudeDriver?.lifecycle?.idleTimeoutMs
95
+ if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings > 0) {
96
+ return Math.round(fromSettings)
97
+ }
98
+ return deps.defaultIdleMs
99
+ }
100
+
101
+ /**
102
+ * Resolve the effective max-resident-sessions cap. Prefers the user-configured
103
+ * override in app settings; falls back to the constructor default.
104
+ */
105
+ export function resolveClaudeMaxResident(deps: SessionLifecycleDeps): number {
106
+ const fromSettings = deps.getAppSettingsSnapshot().claudeDriver?.lifecycle?.maxConcurrent
107
+ if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings > 0) {
108
+ return Math.round(fromSettings)
109
+ }
110
+ return deps.defaultMaxResidentSessions
111
+ }
112
+
113
+ /**
114
+ * True when the chat is hosting an in-flight background Workflow. A live
115
+ * workflow runs inside the warm PTY claude process but registers no
116
+ * activeTurn, pendingPromptSeq, or lastUsedAt bump, so without this signal
117
+ * the idle reaper / budget enforcer would tear the process down mid-run and
118
+ * abort the workflow.
119
+ *
120
+ * Liveness comes from the registry's live-run-dir probe, NOT the terminal
121
+ * `wf_<runId>.json` sidecar: Claude only flushes that sidecar at/near
122
+ * termination, so a sidecar-only check is blind for the entire run (the
123
+ * window the guard must cover). `hasActiveRun` reads the live
124
+ * `subagents/workflows/wf_*` transcript dirs (written from second one) and
125
+ * requires activity within one idle window so a stalled/crashed run still
126
+ * eventually reaps.
127
+ */
128
+ export function hasLiveWorkflow(deps: SessionLifecycleDeps, chatId: string): boolean {
129
+ return deps.workflowRegistry?.hasActiveRun(chatId, resolveClaudeIdleMs(deps), Date.now()) ?? false
130
+ }
131
+
132
+ /**
133
+ * True while the session has at least one Claude-Code background Bash task
134
+ * that has not yet settled. Primary gate is set size > 0: settle events
135
+ * (task_notification) remove their id from the set, so the guard clears the
136
+ * moment the last task reports. The deadline is a zombie backstop only —
137
+ * it fires when a settle notification is genuinely lost (SDK crash / dropped
138
+ * message) and is reset on every launch and settle, so it never expires
139
+ * during normal execution regardless of task duration.
140
+ */
141
+ export function hasPendingBackgroundTask(session: ClaudeSessionState, now: number): boolean {
142
+ if (session.backgroundTaskIds.size === 0) return false
143
+ if (now < session.backgroundTaskDeadlineAt) return true
144
+ session.backgroundTaskIds.clear()
145
+ session.backgroundTaskDeadlineAt = 0
146
+ return false
147
+ }
148
+
149
+ /**
150
+ * Tear down a Claude session and (by default) release the OAuth-pool
151
+ * reservation owned by the chat.
152
+ *
153
+ * `keepReservation: true` — used by rate-limit / auth-error rotation
154
+ * paths that have ALREADY claimed a fresh token via `pickActive(chatId)`
155
+ * before calling close. Without this flag, `release(chatId)` would
156
+ * scan reservedBy for `owner === chatId` and drop the *new* token the
157
+ * rotation just claimed, leaking the rotation's reservation (audit #9d).
158
+ */
159
+ export function closeClaudeSession(
160
+ deps: SessionLifecycleDeps,
161
+ chatId: string,
162
+ session: ClaudeSessionState,
163
+ opts?: { keepReservation?: boolean },
164
+ ): void {
165
+ if (deps.claudeSessions.get(chatId) === session) {
166
+ deps.claudeSessions.delete(chatId)
167
+ }
168
+ if (!opts?.keepReservation) {
169
+ deps.oauthPool?.release(chatId)
170
+ }
171
+ session.session.close()
172
+ // For SDK sessions, unregister the workflow dir here. PTY sessions unregister
173
+ // inside the driver's cleanupResources (driver.ts) — do not double-fire.
174
+ if (deps.resolveClaudeDriverPreference() !== "pty") {
175
+ deps.workflowRegistry?.unregister(chatId)
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Register the workflow disk-watch dir for an SDK session once the session
181
+ * token is known. No-op if the registry is absent, already registered, or
182
+ * the driver preference is PTY (the PTY driver registers from its own
183
+ * resolved transcript path in driver.ts cleanup and must not be double-fired).
184
+ */
185
+ export function maybeRegisterSdkWorkflowsDir(
186
+ deps: SessionLifecycleDeps,
187
+ session: ClaudeSessionState,
188
+ ): void {
189
+ if (!deps.workflowRegistry) return
190
+ if (session.workflowsDirRegistered) return
191
+ // PTY registers from its own resolved transcript path; SDK derives from session_token.
192
+ if (deps.resolveClaudeDriverPreference() === "pty") return
193
+ if (!session.sessionToken) return
194
+ const dir = computeWorkflowsDir({
195
+ homeDir: deps.homeDir,
196
+ cwd: session.localPath,
197
+ sessionId: session.sessionToken,
198
+ })
199
+ deps.workflowRegistry.register(session.chatId, dir)
200
+ session.workflowsDirRegistered = true
201
+ }
202
+
203
+ /**
204
+ * Evict LRU idle sessions when the resident count exceeds the configured cap.
205
+ * Never evicts: the protected chat, chats with an active turn, chats with
206
+ * queued prompts, chats with a live workflow, or chats with a pending
207
+ * background task.
208
+ */
209
+ export function enforceClaudeSessionBudget(
210
+ deps: SessionLifecycleDeps,
211
+ protectedChatId?: string,
212
+ ): void {
213
+ const max = resolveClaudeMaxResident(deps)
214
+ if (max <= 0 || deps.claudeSessions.size <= max) return
215
+
216
+ const now = Date.now()
217
+ const candidates = [...deps.claudeSessions.entries()]
218
+ .filter(([chatId, session]) => (
219
+ chatId !== protectedChatId
220
+ && !deps.activeTurns.has(chatId)
221
+ && session.pendingPromptSeqs.length === 0
222
+ && !hasLiveWorkflow(deps, chatId)
223
+ && !hasPendingBackgroundTask(session, now)
224
+ ))
225
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
226
+
227
+ while (deps.claudeSessions.size > max && candidates.length > 0) {
228
+ const next = candidates.shift()
229
+ if (!next) break
230
+ const [chatId, session] = next
231
+ closeClaudeSession(deps, chatId, session)
232
+ deps.emitStateChange(chatId)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Format a refusal message when `pickActive(chatId)` returned null but the
238
+ * pool has tokens. Names the offending tokens so the user knows which
239
+ * chat to close or which token to add a quota to, instead of seeing the
240
+ * generic "all tokens unavailable" line that doesn't say what's holding
241
+ * them. `scopeSuffix` lets the subagent path tag its variant.
242
+ */
243
+ export function buildPoolUnavailableMessage(
244
+ deps: SessionLifecycleDeps,
245
+ reservedFor: string,
246
+ scopeSuffix: string,
247
+ ): string {
248
+ const pool = deps.oauthPool
249
+ if (!pool) {
250
+ return `All OAuth tokens are unavailable${scopeSuffix} (rate-limited, errored, or in use).`
251
+ }
252
+ const now = Date.now()
253
+ const fmtTime = (ms: number): string => {
254
+ const mins = Math.max(0, Math.round((ms - now) / 60_000))
255
+ if (mins < 60) return `${mins}m`
256
+ const h = Math.floor(mins / 60)
257
+ const m = mins % 60
258
+ return m === 0 ? `${h}h` : `${h}h${m}m`
259
+ }
260
+ const lines: string[] = []
261
+ for (const u of pool.describeUnavailability(reservedFor)) {
262
+ if (u.reason === "available") continue
263
+ const label = u.label || u.tokenId.slice(0, 8)
264
+ if (u.reason === "limited") {
265
+ lines.push(` - ${label}: rate-limited (~${fmtTime(u.until)} remaining)`)
266
+ } else if (u.reason === "reserved") {
267
+ const refs = u.byChatIds.map((id) => {
268
+ const chat = deps.store.getChat(id)
269
+ const title = chat?.title || `chat ${id.slice(0, 8)}`
270
+ return `[${title}](/chat/${id})`
271
+ })
272
+ const joined = refs.length === 0 ? "another chat" : refs.join(", ")
273
+ lines.push(` - ${label}: in use by ${joined}`)
274
+ } else if (u.reason === "error") {
275
+ lines.push(` - ${label}: errored${u.message ? ` (${u.message})` : ""}`)
276
+ } else if (u.reason === "disabled") {
277
+ lines.push(` - ${label}: disabled`)
278
+ }
279
+ }
280
+ const header = `All OAuth tokens are unavailable${scopeSuffix}:`
281
+ const footer = "Close the chat holding a contested token, wait for the rate-limit to reset, or add another token."
282
+ return [header, ...lines, footer].join("\n")
283
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tests for claude-session-rebuild.ts helpers.
3
+ *
4
+ * Uses fake/stub deps so no real IO is involved. Covers both exported
5
+ * functions: recreateActiveTurnFromSession and findLastUserMessageId.
6
+ */
7
+ import { describe, test, expect } from "bun:test"
8
+ import {
9
+ findLastUserMessageId,
10
+ recreateActiveTurnFromSession,
11
+ type SessionRebuildDeps,
12
+ } from "./claude-session-rebuild"
13
+ import type { ClaudeSessionState, ActiveTurn } from "./claude-session-state"
14
+ import type { MessageEntry } from "./claude-session-rebuild"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ function makeSession(overrides: Partial<ClaudeSessionState> = {}): ClaudeSessionState {
21
+ return {
22
+ id: "session-1",
23
+ chatId: "chat-1",
24
+ session: {
25
+ provider: "claude",
26
+ stream: {
27
+ async *[Symbol.asyncIterator]() {},
28
+ },
29
+ getAccountInfo: async () => null,
30
+ interrupt: async () => {},
31
+ close: () => {},
32
+ sendPrompt: async () => {},
33
+ setModel: async () => {},
34
+ setPermissionMode: async () => {},
35
+ getSupportedCommands: async () => [],
36
+ },
37
+ localPath: "/tmp/test",
38
+ additionalDirectories: [],
39
+ model: "claude-opus-4-5",
40
+ effort: undefined,
41
+ planMode: false,
42
+ sessionToken: null,
43
+ accountInfoLoaded: false,
44
+ nextPromptSeq: 0,
45
+ pendingPromptSeqs: [],
46
+ activeTokenId: null,
47
+ oauthKeyMasked: null,
48
+ oauthLabel: null,
49
+ openrouterKeyMasked: null,
50
+ openrouterModel: null,
51
+ lastUsedAt: 0,
52
+ backgroundTaskIds: new Set(),
53
+ backgroundTaskDeadlineAt: 0,
54
+ // loopArmedAtSpawn is optional per the type - omit it
55
+ ...overrides,
56
+ } as ClaudeSessionState
57
+ }
58
+
59
+ function makeDeps(overrides: {
60
+ claudeSessions?: Map<string, ClaudeSessionState>
61
+ activeTurns?: Map<string, ActiveTurn>
62
+ providerUsesSdkSession?: (p: string) => boolean
63
+ messages?: MessageEntry[]
64
+ } = {}): SessionRebuildDeps {
65
+ return {
66
+ claudeSessions: overrides.claudeSessions ?? new Map(),
67
+ activeTurns: overrides.activeTurns ?? new Map(),
68
+ providerUsesSdkSession: overrides.providerUsesSdkSession ?? (() => true),
69
+ getMessages: (_chatId) => overrides.messages ?? [],
70
+ }
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // findLastUserMessageId
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("findLastUserMessageId", () => {
78
+ test("returns null when transcript is empty", () => {
79
+ const deps = makeDeps({ messages: [] })
80
+ expect(findLastUserMessageId(deps, "chat-1")).toBeNull()
81
+ })
82
+
83
+ test("returns null when no user_prompt entry exists", () => {
84
+ const deps = makeDeps({
85
+ messages: [
86
+ { kind: "assistant_text", _id: "a1" },
87
+ { kind: "result", _id: "r1" },
88
+ ],
89
+ })
90
+ expect(findLastUserMessageId(deps, "chat-1")).toBeNull()
91
+ })
92
+
93
+ test("returns _id of the last user_prompt entry", () => {
94
+ const deps = makeDeps({
95
+ messages: [
96
+ { kind: "user_prompt", _id: "u1" },
97
+ { kind: "assistant_text", _id: "a1" },
98
+ { kind: "user_prompt", _id: "u2" },
99
+ { kind: "result", _id: "r1" },
100
+ ],
101
+ })
102
+ expect(findLastUserMessageId(deps, "chat-1")).toBe("u2")
103
+ })
104
+
105
+ test("returns the only user_prompt _id when there is exactly one", () => {
106
+ const deps = makeDeps({
107
+ messages: [
108
+ { kind: "user_prompt", _id: "u1" },
109
+ { kind: "assistant_text", _id: "a1" },
110
+ ],
111
+ })
112
+ expect(findLastUserMessageId(deps, "chat-1")).toBe("u1")
113
+ })
114
+ })
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // recreateActiveTurnFromSession
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("recreateActiveTurnFromSession", () => {
121
+ test("returns undefined when provider does not use SDK session", () => {
122
+ const deps = makeDeps({ providerUsesSdkSession: () => false })
123
+ const result = recreateActiveTurnFromSession(deps, {
124
+ chatId: "chat-1",
125
+ provider: "codex",
126
+ model: "o3",
127
+ planMode: false,
128
+ })
129
+ expect(result).toBeUndefined()
130
+ })
131
+
132
+ test("returns undefined when no live session exists for the chat", () => {
133
+ const deps = makeDeps({
134
+ claudeSessions: new Map(), // empty — no session for chat-1
135
+ providerUsesSdkSession: () => true,
136
+ })
137
+ const result = recreateActiveTurnFromSession(deps, {
138
+ chatId: "chat-1",
139
+ provider: "claude",
140
+ model: "claude-opus-4-5",
141
+ planMode: false,
142
+ })
143
+ expect(result).toBeUndefined()
144
+ })
145
+
146
+ test("returns an ActiveTurn and stores it in activeTurns when session exists", () => {
147
+ const session = makeSession({ chatId: "chat-1", model: "claude-opus-4-5", planMode: false })
148
+ const claudeSessions = new Map([["chat-1", session]])
149
+ const activeTurns = new Map<string, ActiveTurn>()
150
+ const deps = makeDeps({
151
+ claudeSessions,
152
+ activeTurns,
153
+ providerUsesSdkSession: () => true,
154
+ messages: [{ kind: "user_prompt", _id: "u1" }],
155
+ })
156
+
157
+ const result = recreateActiveTurnFromSession(deps, {
158
+ chatId: "chat-1",
159
+ provider: "claude",
160
+ model: "claude-opus-4-5",
161
+ planMode: false,
162
+ clientTraceId: "trace-abc",
163
+ })
164
+
165
+ expect(result).toBeDefined()
166
+ if (!result) throw new Error("Expected ActiveTurn")
167
+ expect(result.chatId).toBe("chat-1")
168
+ expect(result.provider).toBe("claude")
169
+ expect(result.model).toBe("claude-opus-4-5")
170
+ expect(result.planMode).toBe(false)
171
+ expect(result.status).toBe("waiting_for_user")
172
+ expect(result.pendingTool).toBeNull()
173
+ expect(result.hasFinalResult).toBe(false)
174
+ expect(result.cancelRequested).toBe(false)
175
+ expect(result.clientTraceId).toBe("trace-abc")
176
+ expect(result.userMessageId).toBe("u1")
177
+ expect(activeTurns.get("chat-1")).toBe(result)
178
+ })
179
+
180
+ test("sets userMessageId to null when no user_prompt in transcript", () => {
181
+ const session = makeSession({ chatId: "chat-2" })
182
+ const claudeSessions = new Map([["chat-2", session]])
183
+ const activeTurns = new Map<string, ActiveTurn>()
184
+ const deps = makeDeps({
185
+ claudeSessions,
186
+ activeTurns,
187
+ providerUsesSdkSession: () => true,
188
+ messages: [],
189
+ })
190
+
191
+ const result = recreateActiveTurnFromSession(deps, {
192
+ chatId: "chat-2",
193
+ provider: "claude",
194
+ model: "claude-opus-4-5",
195
+ planMode: false,
196
+ })
197
+
198
+ expect(result?.userMessageId).toBeNull()
199
+ })
200
+
201
+ test("ghost turn stream is an empty async iterable", async () => {
202
+ const session = makeSession({ chatId: "chat-3" })
203
+ const claudeSessions = new Map([["chat-3", session]])
204
+ const deps = makeDeps({
205
+ claudeSessions,
206
+ activeTurns: new Map(),
207
+ providerUsesSdkSession: () => true,
208
+ })
209
+
210
+ const result = recreateActiveTurnFromSession(deps, {
211
+ chatId: "chat-3",
212
+ provider: "claude",
213
+ model: "claude-opus-4-5",
214
+ planMode: false,
215
+ })
216
+
217
+ expect(result).toBeDefined()
218
+ const events: unknown[] = []
219
+ for await (const ev of result!.turn.stream) {
220
+ events.push(ev)
221
+ }
222
+ expect(events).toHaveLength(0)
223
+ })
224
+ })
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Session-rebuild helpers extracted from AgentCoordinator (agent.ts).
3
+ *
4
+ * Pure coordination logic — no direct IO. All side-effectful dependencies are
5
+ * injected via `SessionRebuildDeps` so this module stays testable and sealed.
6
+ */
7
+ import type { AgentProvider } from "../shared/types"
8
+ import type { HarnessEvent, HarnessTurn } from "./harness-types"
9
+ import type { ActiveTurn, ClaudeSessionState } from "./claude-session-state"
10
+
11
+ /** Minimal message shape consumed by findLastUserMessageId. */
12
+ export interface MessageEntry {
13
+ kind: string
14
+ _id: string
15
+ }
16
+
17
+ /** Injected dependencies — no concrete IO allowed in this module. */
18
+ export interface SessionRebuildDeps {
19
+ claudeSessions: Map<string, ClaudeSessionState>
20
+ activeTurns: Map<string, ActiveTurn>
21
+ providerUsesSdkSession: (provider: AgentProvider) => boolean
22
+ getMessages: (chatId: string) => readonly MessageEntry[]
23
+ }
24
+
25
+ /** Args forwarded from AgentCoordinator.recreateActiveTurnFromSession. */
26
+ export interface RecreateActiveTurnArgs {
27
+ chatId: string
28
+ provider: AgentProvider
29
+ model: string
30
+ effort?: string
31
+ serviceTier?: "fast"
32
+ planMode: boolean
33
+ clientTraceId?: string
34
+ }
35
+
36
+ /**
37
+ * Re-creates an ActiveTurn from an existing Claude session (SDK providers only).
38
+ * Returns `undefined` when the provider does not use an SDK session or when no
39
+ * live session is found for the chat.
40
+ *
41
+ * Side-effect: mutates `deps.activeTurns` (sets the reconstructed turn).
42
+ */
43
+ export function recreateActiveTurnFromSession(
44
+ deps: SessionRebuildDeps,
45
+ args: RecreateActiveTurnArgs,
46
+ ): ActiveTurn | undefined {
47
+ if (!deps.providerUsesSdkSession(args.provider)) return undefined
48
+ const session = deps.claudeSessions.get(args.chatId)
49
+ if (!session) return undefined
50
+
51
+ const ghostTurn: HarnessTurn = {
52
+ provider: args.provider,
53
+ stream: {
54
+ async *[Symbol.asyncIterator](): AsyncGenerator<HarnessEvent> {
55
+ // intentionally empty — ghost turn emits no events
56
+ },
57
+ },
58
+ getAccountInfo: session.session.getAccountInfo,
59
+ interrupt: session.session.interrupt,
60
+ close: () => {
61
+ // no-op — session lifetime is managed by AgentCoordinator
62
+ },
63
+ }
64
+
65
+ const active: ActiveTurn = {
66
+ chatId: args.chatId,
67
+ provider: args.provider,
68
+ turn: ghostTurn,
69
+ model: session.model,
70
+ effort: session.effort,
71
+ serviceTier: args.serviceTier,
72
+ planMode: session.planMode,
73
+ status: "waiting_for_user",
74
+ pendingTool: null,
75
+ postToolFollowUp: null,
76
+ hasFinalResult: false,
77
+ cancelRequested: false,
78
+ cancelRecorded: false,
79
+ clientTraceId: args.clientTraceId,
80
+ waitStartedAt: null,
81
+ userMessageId: findLastUserMessageId(deps, args.chatId),
82
+ }
83
+ deps.activeTurns.set(args.chatId, active)
84
+ return active
85
+ }
86
+
87
+ /**
88
+ * Scans the chat transcript backwards and returns the `_id` of the most recent
89
+ * `user_prompt` entry, or `null` when the transcript contains no user prompts.
90
+ */
91
+ export function findLastUserMessageId(
92
+ deps: Pick<SessionRebuildDeps, "getMessages">,
93
+ chatId: string,
94
+ ): string | null {
95
+ const messages = deps.getMessages(chatId)
96
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
97
+ const entry = messages[i]
98
+ if (entry.kind === "user_prompt") return entry._id
99
+ }
100
+ return null
101
+ }