@cuongtran001/kanna 1.2.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 (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.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-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Standalone session-state query helpers and idle-reaper for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the query logic lives in its own testable module.
5
+ * The coordinator delegates to these functions by passing an object literal
6
+ * that satisfies `SessionStateQueryDeps`.
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.
11
+ */
12
+
13
+ import type { AgentProvider, KannaStatus, PendingToolSnapshot } from "../shared/types"
14
+ import type { ActiveTurn, ClaudeSessionState } from "./claude-session-state"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Deps interface
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface SessionStateQueryDeps {
21
+ /** Live turn state keyed by chatId. */
22
+ activeTurns: Map<string, ActiveTurn>
23
+ /** Live Claude session state keyed by chatId. */
24
+ claudeSessions: Map<string, ClaudeSessionState>
25
+ /** Streams currently draining (only `.keys()` is consumed). */
26
+ drainingStreams: { keys(): IterableIterator<string> }
27
+ /** chatIds with a slash-commands load in-flight. */
28
+ slashCommandsInFlight: ReadonlySet<string>
29
+ /** Returns true when the given provider is a Claude SDK provider. */
30
+ isClaudeSdkProvider: (provider: AgentProvider) => boolean
31
+ /** Returns true when the session has a pending background Bash task. */
32
+ hasPendingBackgroundTask: (session: ClaudeSessionState, now: number) => boolean
33
+ /** Resolves the effective idle timeout in milliseconds. */
34
+ resolveClaudeIdleMs: () => number
35
+ /** Returns true when the chat has an in-flight Workflow. */
36
+ hasLiveWorkflow: (chatId: string) => boolean
37
+ /** Tears down and cleans up a Claude session. */
38
+ closeClaudeSession: (chatId: string, session: ClaudeSessionState) => void
39
+ /** Notifies subscribers that state has changed for the given chat. */
40
+ emitStateChange: (chatId: string) => void
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Public query functions
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Returns a map of chatId → KannaStatus for all currently active turns.
49
+ */
50
+ export function getActiveStatuses(deps: SessionStateQueryDeps): Map<string, KannaStatus> {
51
+ const statuses = new Map<string, KannaStatus>()
52
+ for (const [chatId, turn] of deps.activeTurns.entries()) {
53
+ statuses.set(chatId, turn.status)
54
+ }
55
+ return statuses
56
+ }
57
+
58
+ /**
59
+ * Returns a map of chatId → waitStartedAt for turns that are currently
60
+ * waiting (i.e. have a non-null waitStartedAt).
61
+ */
62
+ export function getWaitStartedAtByChatId(deps: SessionStateQueryDeps): Map<string, number> {
63
+ const out = new Map<string, number>()
64
+ for (const [chatId, turn] of deps.activeTurns.entries()) {
65
+ if (turn.waitStartedAt != null) out.set(chatId, turn.waitStartedAt)
66
+ }
67
+ return out
68
+ }
69
+
70
+ /**
71
+ * Returns the pending tool snapshot for the active turn in the given chat,
72
+ * or null if there is no active turn or no pending tool.
73
+ */
74
+ export function getPendingTool(
75
+ deps: SessionStateQueryDeps,
76
+ chatId: string,
77
+ ): PendingToolSnapshot | null {
78
+ const pending = deps.activeTurns.get(chatId)?.pendingTool
79
+ if (!pending) return null
80
+ return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
81
+ }
82
+
83
+ /**
84
+ * Returns the set of chatIds whose streams are currently draining.
85
+ */
86
+ export function getDrainingChatIds(deps: SessionStateQueryDeps): Set<string> {
87
+ return new Set(deps.drainingStreams.keys())
88
+ }
89
+
90
+ /**
91
+ * Returns the set of chatIds that have a slash-commands load in-flight.
92
+ */
93
+ export function getSlashCommandsLoadingChatIds(deps: SessionStateQueryDeps): Set<string> {
94
+ return new Set(deps.slashCommandsInFlight)
95
+ }
96
+
97
+ /**
98
+ * Snapshot of live Claude session states per chat. Used by the sidebar badge
99
+ * selector. Chats not present in the returned map are implicitly `cold`.
100
+ */
101
+ export function getClaudeSessionStates(
102
+ deps: SessionStateQueryDeps,
103
+ ): Map<string, "warming" | "active" | "idle"> {
104
+ const out = new Map<string, "warming" | "active" | "idle">()
105
+ const now = Date.now()
106
+ for (const [chatId, session] of deps.claudeSessions) {
107
+ const activeProv = deps.activeTurns.get(chatId)?.provider
108
+ if (activeProv !== undefined && deps.isClaudeSdkProvider(activeProv)) {
109
+ out.set(chatId, "active")
110
+ } else if (deps.hasPendingBackgroundTask(session, now)) {
111
+ // Held warm for a background Bash task — surface as "warming", not "idle".
112
+ out.set(chatId, "warming")
113
+ } else if (now - session.lastUsedAt >= deps.resolveClaudeIdleMs()) {
114
+ out.set(chatId, "idle")
115
+ } else {
116
+ out.set(chatId, "warming")
117
+ }
118
+ }
119
+ return out
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Idle-reaper helpers (private semantics preserved as package functions)
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Returns true when the given Claude session has no live activity and the
128
+ * idle timeout has elapsed since it was last used.
129
+ *
130
+ * Mirrors the private `isClaudeSessionIdle` on AgentCoordinator.
131
+ */
132
+ export function isClaudeSessionIdle(
133
+ deps: SessionStateQueryDeps,
134
+ chatId: string,
135
+ session: ClaudeSessionState,
136
+ now = Date.now(),
137
+ ): boolean {
138
+ const activeProv = deps.activeTurns.get(chatId)?.provider
139
+ if (activeProv !== undefined && deps.isClaudeSdkProvider(activeProv)) return false
140
+ if (session.pendingPromptSeqs.length > 0) return false
141
+ if (deps.hasLiveWorkflow(chatId)) return false
142
+ if (deps.hasPendingBackgroundTask(session, now)) return false
143
+ return now - session.lastUsedAt >= deps.resolveClaudeIdleMs()
144
+ }
145
+
146
+ /**
147
+ * Iterates all live Claude sessions and closes any that are idle.
148
+ *
149
+ * Mirrors the private `sweepIdleClaudeSessions` on AgentCoordinator.
150
+ */
151
+ export function sweepIdleClaudeSessions(
152
+ deps: SessionStateQueryDeps,
153
+ now = Date.now(),
154
+ ): void {
155
+ for (const [chatId, session] of [...deps.claudeSessions.entries()]) {
156
+ if (!isClaudeSessionIdle(deps, chatId, session, now)) continue
157
+ deps.closeClaudeSession(chatId, session)
158
+ deps.emitStateChange(chatId)
159
+ }
160
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Core session and turn state types shared between AgentCoordinator (agent.ts)
3
+ * and the extracted runClaudeSession event-loop (claude-session-runner.ts).
4
+ *
5
+ * Keeping these in a dedicated module avoids a circular import: agent.ts imports
6
+ * values from claude-session-runner.ts, so claude-session-runner.ts must not
7
+ * import values from agent.ts. Both sides import these type-only definitions
8
+ * from this neutral file instead.
9
+ */
10
+ import type { AgentProvider, KannaStatus, NormalizedToolCall, SlashCommand } from "../shared/types"
11
+ import type { AnyValue } from "../shared/errors"
12
+ import type { ClaudeSessionHandle, HarnessTurn } from "./harness-types"
13
+
14
+ export interface PendingToolRequest {
15
+ toolUseId: string
16
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
17
+ resolve: (result: AnyValue) => void
18
+ }
19
+
20
+ export interface ActiveTurn {
21
+ chatId: string
22
+ provider: AgentProvider
23
+ turn: HarnessTurn
24
+ claudePromptSeq?: number
25
+ model: string
26
+ effort?: string
27
+ serviceTier?: "fast"
28
+ planMode: boolean
29
+ status: KannaStatus
30
+ pendingTool: PendingToolRequest | null
31
+ postToolFollowUp: { content: string; planMode: boolean } | null
32
+ hasFinalResult: boolean
33
+ cancelRequested: boolean
34
+ cancelRecorded: boolean
35
+ clientTraceId?: string
36
+ profilingStartedAt?: number
37
+ waitStartedAt: number | null
38
+ // True when this turn was synthesised by Kanna to inject `/compact` before
39
+ // the user's real message. Used to update the per-chat compact circuit
40
+ // breaker on completion (reset on success, increment on failure).
41
+ proactiveCompactInjection?: boolean
42
+ // _id of the user_prompt entry that triggered this turn (when appended on
43
+ // this turn). Used to attribute main-Claude-initiated subagent runs to the
44
+ // originating user message.
45
+ userMessageId: string | null
46
+ }
47
+
48
+ export interface ClaudeSessionState {
49
+ id: string
50
+ chatId: string
51
+ session: ClaudeSessionHandle
52
+ localPath: string
53
+ additionalDirectories: string[]
54
+ model: string
55
+ effort?: string
56
+ planMode: boolean
57
+ sessionToken: string | null
58
+ accountInfoLoaded: boolean
59
+ nextPromptSeq: number
60
+ pendingPromptSeqs: number[]
61
+ activeTokenId: string | null
62
+ oauthKeyMasked: string | null
63
+ oauthLabel: string | null
64
+ // OpenRouter turns route through the SDK with ANTHROPIC_AUTH_TOKEN set to the
65
+ // OpenRouter key, so the SDK self-reports a misleading Anthropic source. Hold
66
+ // the OpenRouter identity here to surface it in the account_info entry.
67
+ openrouterKeyMasked: string | null
68
+ openrouterModel: string | null
69
+ lastUsedAt: number
70
+ // Claude-Code background Bash tasks (`Bash(run_in_background: true)`) run as
71
+ // children of this PTY process and notify completion via a `<task-notification>`
72
+ // transcript line that the continuous tail re-enters as a real turn — but ONLY
73
+ // if the process is still alive. Track launched task ids + a keep-alive
74
+ // deadline so the idle reaper / budget enforcer does not tear the process down
75
+ // mid-flight. See adr-20260604-pty-background-task-keepalive.
76
+ backgroundTaskIds: Set<string>
77
+ backgroundTaskDeadlineAt: number
78
+ // Armed-loop state captured at spawn. Both drivers bake the loop tool-block
79
+ // into the spawn (PTY: --disallowedTools CLI args; SDK: options.disallowedTools
80
+ // so the model never sees the blocked tools — Claude Code's filter-at-spawn
81
+ // pattern). When the armed state changes (setup_loop arms / stop_loop or
82
+ // user-send disarms) the session must be respawned at the next turn boundary
83
+ // or the block goes stale.
84
+ loopArmedAtSpawn: boolean
85
+ /** SDK only: set once the workflows dir has been registered for this session. */
86
+ workflowsDirRegistered?: boolean
87
+ // Number of cancelled turns awaiting their interrupt-induced tail `result`.
88
+ // The SDK's `interrupt()` resolves the query loop with a `result` whose
89
+ // subtype is `error_during_execution` (NOT `cancelled`) and empty text, which
90
+ // would otherwise render as "An unknown error occurred." after the
91
+ // `interrupted` entry. Set on cancel, consumed (and the tail suppressed) when
92
+ // that result arrives, reset on each new turn so a no-tail cancel can't leak
93
+ // suppression onto a later real error.
94
+ cancelledResultPending: number
95
+ // Set by clearClaudeSessionContext (/clear machinery: setup_loop, background
96
+ // delivery). Once the chat's context is declared cleared, any session_token
97
+ // this in-flight session still emits belongs to the OLD conversation and
98
+ // must never re-persist over the wipe. Fresh spawns start unsuppressed.
99
+ suppressSessionTokenPersist: boolean
100
+ }
101
+
102
+ // Re-export SlashCommand as a convenience so importers of this module can get
103
+ // the type without adding a separate shared/types import.
104
+ export type { SlashCommand }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Tests for claude-slash-commands.ts
3
+ *
4
+ * Covers the extracted ensureSlashCommandsLoaded and mergeLocalCatalog helpers.
5
+ * All IO is injected through the deps interface; no real sessions are spawned.
6
+ */
7
+
8
+ import { describe, test, expect } from "bun:test"
9
+ import { ensureSlashCommandsLoaded, mergeLocalCatalog, type SlashCommandsDeps } from "./claude-slash-commands"
10
+ import type { SlashCommand } from "../shared/types"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function makeSlashCommand(name: string, description = ""): SlashCommand {
17
+ return { name, description, argumentHint: "" }
18
+ }
19
+
20
+ function makeDeps(overrides: Partial<SlashCommandsDeps> = {}): SlashCommandsDeps {
21
+ return {
22
+ store: {
23
+ getChat: () => ({
24
+ provider: "claude" as const,
25
+ slashCommands: null,
26
+ planMode: false,
27
+ projectId: "proj-1",
28
+ sessionTokensByProvider: { claude: null },
29
+ }),
30
+ getProject: () => ({ id: "proj-1", localPath: "/tmp/proj" }),
31
+ recordSessionCommandsLoaded: async () => undefined,
32
+ },
33
+ claudeSessions: { get: () => undefined },
34
+ oauthPool: null,
35
+ slashCommandsInFlight: new Set<string>(),
36
+ emitStateChange: () => undefined,
37
+ resolveClaudeDriverPreference: () => "sdk",
38
+ startClaudeSessionPTY: async () => {
39
+ throw new Error("PTY not expected in these tests")
40
+ },
41
+ startClaudeSessionSDK: async () => {
42
+ throw new Error("SDK session not expected in these tests")
43
+ },
44
+ getSubagents: () => [],
45
+ getGlobalPromptAppend: () => undefined,
46
+ getEnabledCustomMcpServers: () => [],
47
+ claudePtyRegistry: null,
48
+ ptyInstanceRegistry: null,
49
+ workflowRegistry: null,
50
+ subagentTranscriptRegistry: null,
51
+ localCatalog: null,
52
+ ...overrides,
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // mergeLocalCatalog
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe("mergeLocalCatalog", () => {
61
+ test("returns commands unchanged when localCatalog is null", () => {
62
+ const commands = [makeSlashCommand("foo"), makeSlashCommand("bar")]
63
+ const deps = makeDeps({ localCatalog: null })
64
+ const result = mergeLocalCatalog(deps, commands, "/cwd")
65
+ expect(result).toEqual(commands)
66
+ })
67
+
68
+ test("appends local commands that do not conflict with CLI commands", () => {
69
+ const cliCommands = [makeSlashCommand("help")]
70
+ const localCommands = [makeSlashCommand("mylocal")]
71
+ const deps = makeDeps({
72
+ localCatalog: { list: () => localCommands },
73
+ })
74
+ const result = mergeLocalCatalog(deps, cliCommands, "/cwd")
75
+ expect(result).toHaveLength(2)
76
+ expect(result[0]?.name).toBe("help")
77
+ expect(result[1]?.name).toBe("mylocal")
78
+ })
79
+
80
+ test("filters out local commands that duplicate CLI commands (case-insensitive)", () => {
81
+ const cliCommands = [makeSlashCommand("Help")]
82
+ const localCommands = [makeSlashCommand("help"), makeSlashCommand("custom")]
83
+ const deps = makeDeps({
84
+ localCatalog: { list: () => localCommands },
85
+ })
86
+ const result = mergeLocalCatalog(deps, cliCommands, "/cwd")
87
+ expect(result).toHaveLength(2)
88
+ expect(result.map((c) => c.name)).toEqual(["Help", "custom"])
89
+ })
90
+
91
+ test("returns commands unchanged when localCatalog.list throws", () => {
92
+ const cliCommands = [makeSlashCommand("foo")]
93
+ const deps = makeDeps({
94
+ localCatalog: {
95
+ list: () => {
96
+ throw new Error("scan failed")
97
+ },
98
+ },
99
+ })
100
+ const result = mergeLocalCatalog(deps, cliCommands, "/cwd")
101
+ expect(result).toEqual(cliCommands)
102
+ })
103
+ })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // ensureSlashCommandsLoaded
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe("ensureSlashCommandsLoaded", () => {
110
+ test("returns early when chat is not found", async () => {
111
+ const deps = makeDeps({ store: { ...makeDeps().store, getChat: () => null } })
112
+ await expect(ensureSlashCommandsLoaded(deps, "chat-1")).resolves.toBeUndefined()
113
+ })
114
+
115
+ test("returns early for codex provider", async () => {
116
+ const deps = makeDeps({
117
+ store: {
118
+ ...makeDeps().store,
119
+ getChat: () => ({
120
+ provider: "codex" as const,
121
+ slashCommands: null,
122
+ planMode: false,
123
+ projectId: "proj-1",
124
+ sessionTokensByProvider: {},
125
+ }),
126
+ },
127
+ })
128
+ await expect(ensureSlashCommandsLoaded(deps, "chat-1")).resolves.toBeUndefined()
129
+ })
130
+
131
+ test("returns early when slash commands are already loaded", async () => {
132
+ const deps = makeDeps({
133
+ store: {
134
+ ...makeDeps().store,
135
+ getChat: () => ({
136
+ provider: "claude" as const,
137
+ slashCommands: [makeSlashCommand("help")],
138
+ planMode: false,
139
+ projectId: "proj-1",
140
+ sessionTokensByProvider: { claude: null },
141
+ }),
142
+ },
143
+ })
144
+ await expect(ensureSlashCommandsLoaded(deps, "chat-1")).resolves.toBeUndefined()
145
+ })
146
+
147
+ test("returns early when load is already in-flight", async () => {
148
+ const inFlight = new Set(["chat-1"])
149
+ const deps = makeDeps({ slashCommandsInFlight: inFlight })
150
+ await expect(ensureSlashCommandsLoaded(deps, "chat-1")).resolves.toBeUndefined()
151
+ })
152
+
153
+ test("loads commands from existing session and persists them", async () => {
154
+ const commands = [makeSlashCommand("clear"), makeSlashCommand("help")]
155
+ let recorded: SlashCommand[] = []
156
+ const deps = makeDeps({
157
+ claudeSessions: {
158
+ get: () => ({
159
+ session: { getSupportedCommands: async () => commands },
160
+ }),
161
+ },
162
+ store: {
163
+ getChat: () => ({
164
+ provider: "claude" as const,
165
+ slashCommands: null,
166
+ planMode: false,
167
+ projectId: "proj-1",
168
+ sessionTokensByProvider: { claude: null },
169
+ }),
170
+ getProject: () => ({ id: "proj-1", localPath: "/tmp/proj" }),
171
+ recordSessionCommandsLoaded: async (_chatId, cmds) => {
172
+ recorded = cmds
173
+ },
174
+ },
175
+ })
176
+ await ensureSlashCommandsLoaded(deps, "chat-1")
177
+ expect(recorded).toEqual(commands)
178
+ expect(deps.slashCommandsInFlight.has("chat-1")).toBe(false)
179
+ })
180
+
181
+ test("clears in-flight flag on error from existing session", async () => {
182
+ const deps = makeDeps({
183
+ claudeSessions: {
184
+ get: () => ({
185
+ session: {
186
+ getSupportedCommands: async () => {
187
+ throw new Error("session died")
188
+ },
189
+ },
190
+ }),
191
+ },
192
+ })
193
+ await ensureSlashCommandsLoaded(deps, "chat-1")
194
+ expect(deps.slashCommandsInFlight.has("chat-1")).toBe(false)
195
+ })
196
+
197
+ test("spawns ephemeral SDK session when no active session and no oauthPool", async () => {
198
+ const cliCommands = [makeSlashCommand("help")]
199
+ const closed = { value: false }
200
+ let recorded: SlashCommand[] = []
201
+ const noopAsync = async () => undefined
202
+ const deps = makeDeps({
203
+ startClaudeSessionSDK: async () => ({
204
+ provider: "claude" as const,
205
+ stream: (async function* () {})(),
206
+ interrupt: noopAsync,
207
+ close: () => { closed.value = true },
208
+ sendPrompt: noopAsync,
209
+ setModel: noopAsync,
210
+ setPermissionMode: noopAsync,
211
+ getSupportedCommands: async () => cliCommands,
212
+ }),
213
+ store: {
214
+ getChat: () => ({
215
+ provider: "claude" as const,
216
+ slashCommands: null,
217
+ planMode: false,
218
+ projectId: "proj-1",
219
+ sessionTokensByProvider: { claude: null },
220
+ }),
221
+ getProject: () => ({ id: "proj-1", localPath: "/tmp/proj" }),
222
+ recordSessionCommandsLoaded: async (_chatId, cmds) => {
223
+ recorded = cmds
224
+ },
225
+ },
226
+ })
227
+ await ensureSlashCommandsLoaded(deps, "chat-1")
228
+ expect(closed.value).toBe(true)
229
+ expect(recorded).toEqual(cliCommands)
230
+ expect(deps.slashCommandsInFlight.has("chat-1")).toBe(false)
231
+ })
232
+
233
+ test("merges local catalog commands into loaded commands", async () => {
234
+ const cliCommands = [makeSlashCommand("help")]
235
+ const localCommands = [makeSlashCommand("mylocal")]
236
+ let recorded: SlashCommand[] = []
237
+ const deps = makeDeps({
238
+ claudeSessions: {
239
+ get: () => ({
240
+ session: { getSupportedCommands: async () => cliCommands },
241
+ }),
242
+ },
243
+ localCatalog: { list: () => localCommands },
244
+ store: {
245
+ getChat: () => ({
246
+ provider: "claude" as const,
247
+ slashCommands: null,
248
+ planMode: false,
249
+ projectId: "proj-1",
250
+ sessionTokensByProvider: { claude: null },
251
+ }),
252
+ getProject: () => ({ id: "proj-1", localPath: "/tmp/proj" }),
253
+ recordSessionCommandsLoaded: async (_chatId, cmds) => {
254
+ recorded = cmds
255
+ },
256
+ },
257
+ })
258
+ await ensureSlashCommandsLoaded(deps, "chat-1")
259
+ expect(recorded).toHaveLength(2)
260
+ expect(recorded.map((c) => c.name)).toEqual(["help", "mylocal"])
261
+ })
262
+
263
+ test("skips ephemeral spawn when oauthPool has tokens but pickEphemeral returns null", async () => {
264
+ const deps = makeDeps({
265
+ oauthPool: {
266
+ pickEphemeral: () => null,
267
+ hasAnyToken: () => true,
268
+ markUsed: () => undefined,
269
+ },
270
+ startClaudeSessionSDK: async () => {
271
+ throw new Error("should not be called")
272
+ },
273
+ })
274
+ await expect(ensureSlashCommandsLoaded(deps, "chat-1")).resolves.toBeUndefined()
275
+ expect(deps.slashCommandsInFlight.has("chat-1")).toBe(false)
276
+ })
277
+ })