@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,466 @@
1
+ /**
2
+ * Standalone loop-orchestration command handlers for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the 11 related private/public methods live in
5
+ * their own testable module. The coordinator delegates to these functions by
6
+ * passing an object literal that satisfies `LoopOrchCommandDeps`.
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 `ensureTrackingFile` which is the sole IO
11
+ * operation used by `setupLoop`.
12
+ */
13
+
14
+ import type { TranscriptEntry } from "../shared/types"
15
+ import type { Subagent, AgentProvider } from "../shared/types"
16
+ import { AUTO_CONTINUE_EVENT_VERSION, type AutoContinueEvent } from "./auto-continue/events"
17
+ import { deriveChatSchedules, deriveLoopState, type LoopState } from "./auto-continue/read-model"
18
+ import { timestamped } from "./claude-message-normalizer"
19
+ import { buildTaskNotification } from "./claude-session-config"
20
+ import { validateLoopSetup, reconcileTrackingFile, type LoopSetupInput } from "./loop-template"
21
+ import type { EnsureTrackingFileArgs, EnsureTrackingFileResult } from "./loop-template-io.adapter"
22
+ import { validateOrchRun, toOrchRunDetail, type OrchRunContext } from "./orchestration-input"
23
+ import type { OrchRunDetail, OrchRunInput, OrchRunConfig, OrchTaskSpec, OrchRunSnapshot } from "../shared/orchestration-types"
24
+ import type { WorkerSpawnArgs, WorkerResult } from "./orchestration-queue"
25
+ import type { BackgroundRunOutcome, ProviderRunStart } from "./subagent-orchestrator"
26
+ import type { ClaudeSessionState } from "./claude-session-state"
27
+ import type { SetupLoopHandlerResult } from "./kanna-mcp"
28
+ import { log } from "../shared/log"
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Structural sub-interfaces — only the operations this module calls.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Subset of EventStore used by these handlers. */
35
+ interface LoopOrchCommandStore {
36
+ getOrchRun(runId: string): OrchRunSnapshot | null
37
+ getChat(chatId: string): { id: string; projectId: string } | null
38
+ getProject(projectId: string): { localPath: string; id: string } | null
39
+ getAutoContinueEvents(chatId: string): AutoContinueEvent[]
40
+ setSessionTokenForProvider(chatId: string, provider: AgentProvider, token: string | null): Promise<void>
41
+ appendMessage(chatId: string, entry: TranscriptEntry): Promise<void>
42
+ }
43
+
44
+ /** Subset of OrchestrationQueue used by these handlers. */
45
+ interface LoopOrchCommandOrchQueue {
46
+ createRun(config: OrchRunConfig, tasks: OrchTaskSpec[]): Promise<string>
47
+ cancelRun(runId: string): Promise<void>
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Dependency bundle injected by AgentCoordinator
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface LoopOrchCommandDeps {
55
+ /** EventStore — subset used by these handlers. */
56
+ store: LoopOrchCommandStore
57
+
58
+ /** OrchestrationQueue — subset used by these handlers. */
59
+ orchestrationQueue: LoopOrchCommandOrchQueue
60
+
61
+ /** Live Claude sessions map owned by the coordinator (read-only). */
62
+ claudeSessions: Pick<Map<string, ClaudeSessionState>, "get">
63
+
64
+ /** Active turns map — `.has()` to check existence. */
65
+ activeTurns: Pick<Map<string, unknown>, "has">
66
+
67
+ /** Returns all configured subagents. */
68
+ getSubagents(): Subagent[]
69
+
70
+ /** Returns the current app settings snapshot for subagentRuntime config. */
71
+ getAppSettingsSnapshot(): {
72
+ subagentRuntime?: {
73
+ defaultOrchSubagentId?: string | null
74
+ defaultLoopSubagentId?: string | null
75
+ } | null
76
+ }
77
+
78
+ /**
79
+ * Builds a provider-run start bundle for an orchestration worker subagent.
80
+ * Delegates to `AgentCoordinator.buildSubagentProviderRunForChat`.
81
+ */
82
+ buildSubagentProviderRunForChat(args: {
83
+ subagent: Subagent
84
+ chatId: string
85
+ primer: string | null
86
+ userInstruction: string | null
87
+ runId: string
88
+ abortSignal: AbortSignal
89
+ depth: number
90
+ ancestorSubagentIds: string[]
91
+ parentUserMessageId: string
92
+ cwdOverride?: string
93
+ }): ProviderRunStart
94
+
95
+ /**
96
+ * Tears down the given Claude session. No permit semantics here — caller
97
+ * must ensure the session is in a stable state before calling.
98
+ */
99
+ closeClaudeSession(chatId: string, session: ClaudeSessionState): void
100
+
101
+ /**
102
+ * Appends an AutoContinueEvent to the store, notifies the schedule manager,
103
+ * and emits a state-change event for the chat.
104
+ */
105
+ emitAutoContinueEvent(event: AutoContinueEvent): Promise<void>
106
+
107
+ /**
108
+ * IO adapter: create or reconcile the loop tracking file on disk. Injected
109
+ * so this module stays IO-free. See `loop-template-io.adapter.ts`.
110
+ */
111
+ ensureTrackingFile(args: EnsureTrackingFileArgs): Promise<EnsureTrackingFileResult>
112
+
113
+ /**
114
+ * Returns the current armed-loop state for a chat, or null if disarmed.
115
+ * Injected (rather than calling the module-level `isLoopArmed` fn) so
116
+ * AgentCoordinator can be monkey-patched in tests via
117
+ * `coordinator.isLoopArmed = () => ({...})`.
118
+ */
119
+ isLoopArmed(chatId: string): LoopState | null
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Exported standalone functions
124
+ // ---------------------------------------------------------------------------
125
+
126
+ // ── Orchestration commands ──────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Spawn a single orchestration phase worker. Looks up the run's persisted
130
+ * config for origin chat + subagent, then delegates to the coordinator's
131
+ * subagent provider-run machinery. Origin chat + subagent are read from the
132
+ * persisted run config so this resolves identically on a fresh run and after
133
+ * a restart.
134
+ */
135
+ export async function buildOrchWorker(
136
+ deps: LoopOrchCommandDeps,
137
+ spawn: WorkerSpawnArgs,
138
+ ): Promise<WorkerResult> {
139
+ const run = deps.store.getOrchRun(spawn.runId)
140
+ const chatId = run?.config.originChatId
141
+ const subagentId = run?.config.workerSubagentId
142
+ if (!chatId || !subagentId) {
143
+ return { kind: "failed", error: "orchestration run missing originChatId / workerSubagentId" }
144
+ }
145
+ const subagent = deps.getSubagents().find((s) => s.id === subagentId)
146
+ if (!subagent) return { kind: "failed", error: `orchestration worker subagent "${subagentId}" not found` }
147
+ if (!deps.store.getChat(chatId)) return { kind: "failed", error: `orchestration origin chat ${chatId} not found` }
148
+
149
+ const providerRun = deps.buildSubagentProviderRunForChat({
150
+ subagent,
151
+ chatId,
152
+ primer: null,
153
+ userInstruction: spawn.prompt,
154
+ runId: `${spawn.runId}:${spawn.workerId}`,
155
+ abortSignal: spawn.abortSignal,
156
+ depth: 0,
157
+ ancestorSubagentIds: [],
158
+ parentUserMessageId: spawn.runId,
159
+ cwdOverride: spawn.cwd,
160
+ })
161
+ try {
162
+ if (!(await providerRun.authReady())) {
163
+ return { kind: "failed", error: "orchestration worker auth not ready" }
164
+ }
165
+ const result = await providerRun.start(() => undefined, () => undefined)
166
+ return { kind: "completed", text: result.text }
167
+ } catch (err) {
168
+ if (spawn.abortSignal.aborted) return { kind: "failed", error: "aborted" }
169
+ return { kind: "failed", error: err instanceof Error ? err.message : String(err) }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Derive chat/project context for orchestration validation. Returns null when
175
+ * the chat or project is not found.
176
+ */
177
+ export function buildOrchRunContext(
178
+ deps: LoopOrchCommandDeps,
179
+ chatId: string,
180
+ ): OrchRunContext | null {
181
+ const chat = deps.store.getChat(chatId)
182
+ if (!chat) return null
183
+ const project = deps.store.getProject(chat.projectId)
184
+ if (!project) return null
185
+ return {
186
+ chatId,
187
+ repoRoot: project.localPath,
188
+ roster: deps.getSubagents().map((s) => ({ id: s.id, name: s.name })),
189
+ defaultOrchSubagentId: deps.getAppSettingsSnapshot().subagentRuntime?.defaultOrchSubagentId ?? null,
190
+ }
191
+ }
192
+
193
+ /**
194
+ * User-callable entry point (MCP `orch_run` + ws `orch.run`). Validates the
195
+ * task list into the fixed linear config, then starts the run. Returns the
196
+ * runId or the flat validation error list — never a partial run.
197
+ */
198
+ export async function runOrchestration(
199
+ deps: LoopOrchCommandDeps,
200
+ chatId: string,
201
+ input: OrchRunInput,
202
+ ): Promise<{ ok: true; runId: string } | { ok: false; errors: string[] }> {
203
+ const context = buildOrchRunContext(deps, chatId)
204
+ if (!context) return { ok: false, errors: [`chat ${chatId} not found or has no project`] }
205
+ const validation = validateOrchRun(input, context)
206
+ if (!validation.ok) return { ok: false, errors: validation.errors }
207
+ const runId = await deps.orchestrationQueue.createRun(
208
+ validation.resolved.config,
209
+ validation.resolved.tasks,
210
+ )
211
+ return { ok: true, runId }
212
+ }
213
+
214
+ /** Cancel a run (MCP `orch_cancel_run` + ws `orch.cancelRun`). */
215
+ export async function cancelOrchRun(
216
+ deps: LoopOrchCommandDeps,
217
+ runId: string,
218
+ ): Promise<void> {
219
+ await deps.orchestrationQueue.cancelRun(runId)
220
+ }
221
+
222
+ /** Canonical run detail DTO (MCP `orch_run_status` + ws `orch.getRun`). */
223
+ export function getOrchRunDetail(
224
+ deps: LoopOrchCommandDeps,
225
+ runId: string,
226
+ ): OrchRunDetail | null {
227
+ const snapshot = deps.store.getOrchRun(runId)
228
+ return snapshot ? toOrchRunDetail(snapshot) : null
229
+ }
230
+
231
+ // ── Loop + background delivery ──────────────────────────────────────────────
232
+
233
+ /**
234
+ * Wipe the main-agent's Claude session context (the /clear equivalent).
235
+ *
236
+ * Three things happen:
237
+ * - The persisted session token is set to null (so the next spawn starts fresh).
238
+ * - The live session's `suppressSessionTokenPersist` flag is set to true so the
239
+ * in-flight stream cannot overwrite the null we just wrote (avoids a
240
+ * 121 ms race on setup_loop /clear).
241
+ * - An idle warm SDK session is torn down so it cannot be reused in-band by
242
+ * the next turn (which would make the /clear a no-op).
243
+ */
244
+ export async function clearClaudeSessionContext(
245
+ deps: LoopOrchCommandDeps,
246
+ chatId: string,
247
+ ): Promise<void> {
248
+ await deps.store.setSessionTokenForProvider(chatId, "claude", null)
249
+ const session = deps.claudeSessions.get(chatId)
250
+ if (!session) return
251
+ session.suppressSessionTokenPersist = true
252
+ if (!deps.activeTurns.has(chatId)) {
253
+ deps.closeClaudeSession(chatId, session)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Deliver a finished `run_in_background` subagent's result back into the
259
+ * main chat as a fresh turn AND clear the main-agent's Claude session so the
260
+ * next turn starts with a fresh context window. Wired as the orchestrator's
261
+ * `onBackgroundRunComplete` hook.
262
+ *
263
+ * Loop-orchestration invariant: main is stateless-in-context / stateful-in-file.
264
+ * PROGRESS.md is the durability contract; every delivery re-reads it. Subagent
265
+ * output is NOT carried forward as prompt content — the subagent is expected
266
+ * to have written its findings into PROGRESS.md before terminating.
267
+ *
268
+ * See adr-20260711-notification-driven-loop-orchestration.
269
+ */
270
+ export async function deliverSubagentToMain(
271
+ deps: LoopOrchCommandDeps,
272
+ chatId: string,
273
+ runId: string,
274
+ outcome: BackgroundRunOutcome,
275
+ ): Promise<void> {
276
+ if (!deps.store.getChat(chatId)) return
277
+
278
+ // Structured re-entry: the completion is delivered as the same
279
+ // <task-notification> XML Claude Code's own background agents use
280
+ // (LocalAgentTask), so the model parses task identity/status with the
281
+ // format it already knows from native training.
282
+ //
283
+ // When a loop is armed, the FULL loop discipline prompt follows the
284
+ // notification on every wake — not a generic "decide next action" string,
285
+ // which drifted into self-implementation (the 7.5h marathon-turn bug).
286
+ // Armed notifications carry NO <result> body: PROGRESS.md is the loop's
287
+ // only durability contract. Non-loop deliveries include the (truncated)
288
+ // result since ad-hoc background delegations have no tracking file.
289
+ const armed = isLoopArmed(deps, chatId)
290
+ const notification = buildTaskNotification(runId, outcome, { includeResult: !armed })
291
+ let prompt: string
292
+ if (armed) {
293
+ prompt = `${notification}\n\n${armed.prompt}`
294
+ } else if (outcome.status === "completed") {
295
+ prompt = `${notification}\n\nYour Claude context has been cleared. Read PROGRESS.md if present, then decide the next action.`
296
+ } else {
297
+ prompt = `${notification}\n\nYour Claude context has been cleared. Read PROGRESS.md if present; decide whether to retry, try another approach, or stop.`
298
+ }
299
+
300
+ try {
301
+ // Wipe the main-agent's Claude session token so the next spawn starts
302
+ // fresh (the /clear equivalent). Codex path is unaffected.
303
+ await clearClaudeSessionContext(deps, chatId)
304
+ await deps.store.appendMessage(chatId, timestamped({ kind: "context_cleared" }))
305
+
306
+ const now = Date.now()
307
+ const scheduleId = crypto.randomUUID()
308
+ await deps.emitAutoContinueEvent({
309
+ v: AUTO_CONTINUE_EVENT_VERSION,
310
+ kind: "auto_continue_accepted",
311
+ timestamp: now,
312
+ chatId,
313
+ scheduleId,
314
+ scheduledAt: now,
315
+ tz: "system",
316
+ source: "subagent_background",
317
+ resetAt: now,
318
+ detectedAt: now,
319
+ prompt,
320
+ })
321
+ } catch (err) {
322
+ log.warn(`[kanna] deliverSubagentToMain failed`, { chatId, runId, err })
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Arm an autonomous loop on the main chat. Validates the loop spec, ensures
328
+ * the tracking file exists (writes a skeleton if absent), then /clears the
329
+ * main-agent Claude session and enqueues the templated recurring prompt so
330
+ * the next turn starts the loop. Backs `mcp__kanna__setup_loop`. See
331
+ * adr-20260711-setup-loop-template.
332
+ */
333
+ export async function setupLoop(
334
+ deps: LoopOrchCommandDeps,
335
+ args: {
336
+ chatId: string
337
+ input: LoopSetupInput
338
+ },
339
+ ): Promise<SetupLoopHandlerResult> {
340
+ const chat = deps.store.getChat(args.chatId)
341
+ if (!chat) return { ok: false, errors: [`chat ${args.chatId} not found`] }
342
+ const project = deps.store.getProject(chat.projectId)
343
+ if (!project) return { ok: false, errors: [`project ${chat.projectId} not found`] }
344
+
345
+ const validation = validateLoopSetup(args.input, project.localPath, {
346
+ roster: deps.getSubagents().map((s) => ({ id: s.id, name: s.name })),
347
+ defaultLoopSubagentId: deps.getAppSettingsSnapshot().subagentRuntime?.defaultLoopSubagentId ?? null,
348
+ })
349
+ if (!validation.ok) return { ok: false, errors: validation.errors }
350
+
351
+ const resolved = validation.resolved
352
+ let created: boolean
353
+ let reconciled: boolean
354
+ let reconcileActions: string[]
355
+ try {
356
+ const ensureResult = await deps.ensureTrackingFile({
357
+ absPath: resolved.trackingFileAbs,
358
+ skeleton: resolved.skeleton,
359
+ // Deterministic schema reconcile of an EXISTING tracking file: pure
360
+ // string transform — server-owned sections rewritten to the inputs,
361
+ // loop history preserved. No model judgement involved.
362
+ reconcile: (existing) =>
363
+ reconcileTrackingFile(existing, {
364
+ goal: resolved.goal,
365
+ verifyCommand: resolved.verifyCommand,
366
+ chunkHint: resolved.chunkHint,
367
+ }),
368
+ })
369
+ created = ensureResult.created
370
+ reconciled = ensureResult.reconciled
371
+ reconcileActions = ensureResult.actions
372
+ } catch (err) {
373
+ return {
374
+ ok: false,
375
+ errors: [`ensureTrackingFile failed: ${err instanceof Error ? err.message : String(err)}`],
376
+ }
377
+ }
378
+
379
+ try {
380
+ // Wipe main-agent Claude session so the next turn starts fresh with the
381
+ // rendered loop prompt. Codex untouched. setup_loop runs from INSIDE a
382
+ // live turn, so the suppression half of clearClaudeSessionContext is
383
+ // what keeps the wipe from being overwritten by the in-flight stream.
384
+ await clearClaudeSessionContext(deps, args.chatId)
385
+ await deps.store.appendMessage(args.chatId, timestamped({ kind: "context_cleared" }))
386
+
387
+ const now = Date.now()
388
+ // Arm the loop durably: every subsequent background-completion wake
389
+ // re-injects THIS prompt (not the generic one) and loop turns are
390
+ // tool-blocked. Superseded by a later setup_loop or cleared by stop_loop
391
+ // / a real user send. Replays from the auto-continue log on restart.
392
+ await deps.emitAutoContinueEvent({
393
+ v: AUTO_CONTINUE_EVENT_VERSION,
394
+ kind: "loop_armed",
395
+ timestamp: now,
396
+ chatId: args.chatId,
397
+ scheduleId: crypto.randomUUID(),
398
+ subagentId: resolved.subagentId,
399
+ prompt: resolved.prompt,
400
+ })
401
+
402
+ const scheduleId = crypto.randomUUID()
403
+ await deps.emitAutoContinueEvent({
404
+ v: AUTO_CONTINUE_EVENT_VERSION,
405
+ kind: "auto_continue_accepted",
406
+ timestamp: now,
407
+ chatId: args.chatId,
408
+ scheduleId,
409
+ scheduledAt: now,
410
+ tz: "system",
411
+ source: "subagent_background",
412
+ resetAt: now,
413
+ detectedAt: now,
414
+ prompt: resolved.prompt,
415
+ })
416
+ } catch (err) {
417
+ return {
418
+ ok: false,
419
+ errors: [`enqueue failed: ${err instanceof Error ? err.message : String(err)}`],
420
+ }
421
+ }
422
+
423
+ return {
424
+ ok: true,
425
+ trackingFileRel: resolved.trackingFileRel,
426
+ created,
427
+ reconciled,
428
+ reconcileActions,
429
+ prompt: resolved.prompt,
430
+ }
431
+ }
432
+
433
+ /** Current armed-loop state for a chat, or null. Pure replay of the auto-continue log. */
434
+ export function isLoopArmed(deps: LoopOrchCommandDeps, chatId: string): LoopState | null {
435
+ return deriveLoopState(deps.store.getAutoContinueEvents(chatId), chatId)
436
+ }
437
+
438
+ /**
439
+ * Disarm an armed loop (restores tools + stops prompt re-injection). Backs
440
+ * the `stop_loop` MCP tool (called by the model on GOAL MET) and the
441
+ * user-send takeover path. No-op when no loop is armed.
442
+ */
443
+ export async function stopLoop(
444
+ deps: LoopOrchCommandDeps,
445
+ chatId: string,
446
+ reason: "goal_met" | "user_send" | "chat_deleted",
447
+ ): Promise<void> {
448
+ if (!isLoopArmed(deps, chatId)) return
449
+ await deps.emitAutoContinueEvent({
450
+ v: AUTO_CONTINUE_EVENT_VERSION,
451
+ kind: "loop_disarmed",
452
+ timestamp: Date.now(),
453
+ chatId,
454
+ scheduleId: crypto.randomUUID(),
455
+ reason,
456
+ })
457
+ }
458
+
459
+ /** Returns live schedule IDs (proposed or scheduled) for the given chat. */
460
+ export function listLiveSchedules(deps: LoopOrchCommandDeps, chatId: string): string[] {
461
+ const { schedules } = deriveChatSchedules(deps.store.getAutoContinueEvents(chatId), chatId)
462
+ return Object.values(schedules)
463
+ .filter((s) => s.state === "proposed" || s.state === "scheduled")
464
+ .map((s) => s.scheduleId)
465
+ .sort()
466
+ }
@@ -0,0 +1,213 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ getClaudeAssistantMessageUsageId,
4
+ normalizeClaudeStreamMessage,
5
+ normalizeToolContent,
6
+ type ClaudeRawSdkMessage,
7
+ } from "./claude-message-normalizer"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // getClaudeAssistantMessageUsageId
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe("getClaudeAssistantMessageUsageId", () => {
14
+ test("returns uuid when present on message", () => {
15
+ const msg: ClaudeRawSdkMessage = { uuid: "msg-uuid-123", type: "assistant" }
16
+ expect(getClaudeAssistantMessageUsageId(msg)).toBe("msg-uuid-123")
17
+ })
18
+
19
+ test("returns message.id when uuid is absent", () => {
20
+ const msg: ClaudeRawSdkMessage = {
21
+ type: "assistant",
22
+ message: { id: "inner-id-456", content: [] },
23
+ }
24
+ expect(getClaudeAssistantMessageUsageId(msg)).toBe("inner-id-456")
25
+ })
26
+
27
+ test("returns null when neither uuid nor message.id are present", () => {
28
+ const msg: ClaudeRawSdkMessage = { type: "user" }
29
+ expect(getClaudeAssistantMessageUsageId(msg)).toBeNull()
30
+ })
31
+
32
+ test("prefers message.id over uuid when both are present", () => {
33
+ const msg: ClaudeRawSdkMessage = {
34
+ uuid: "outer",
35
+ type: "assistant",
36
+ message: { id: "inner", content: [] },
37
+ }
38
+ // message.id is checked first
39
+ expect(getClaudeAssistantMessageUsageId(msg)).toBe("inner")
40
+ })
41
+ })
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // normalizeToolContent
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("normalizeToolContent", () => {
48
+ test("returns null for null input", () => {
49
+ expect(normalizeToolContent(null)).toBeNull()
50
+ })
51
+
52
+ test("returns null for undefined input", () => {
53
+ expect(normalizeToolContent(undefined)).toBeNull()
54
+ })
55
+
56
+ test("returns string unchanged", () => {
57
+ expect(normalizeToolContent("hello")).toBe("hello")
58
+ })
59
+
60
+ test("returns object unchanged", () => {
61
+ const obj = { a: 1 }
62
+ expect(normalizeToolContent(obj)).toEqual({ a: 1 })
63
+ })
64
+
65
+ test("returns array unchanged", () => {
66
+ const arr = [1, 2, 3]
67
+ expect(normalizeToolContent(arr)).toEqual([1, 2, 3])
68
+ })
69
+ })
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // normalizeClaudeStreamMessage
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("normalizeClaudeStreamMessage", () => {
76
+ test("returns [] for unrecognized message type", () => {
77
+ const msg: ClaudeRawSdkMessage = { type: "unknown_type" }
78
+ expect(normalizeClaudeStreamMessage(msg)).toEqual([])
79
+ })
80
+
81
+ test("normalizes assistant text block to assistant_text entry", () => {
82
+ const msg: ClaudeRawSdkMessage = {
83
+ type: "assistant",
84
+ message: {
85
+ role: "assistant",
86
+ content: [{ type: "text", text: "Hello, world!" }],
87
+ },
88
+ }
89
+ const entries = normalizeClaudeStreamMessage(msg)
90
+ expect(entries).toHaveLength(1)
91
+ expect(entries[0].kind).toBe("assistant_text")
92
+ expect((entries[0] as { text: string }).text).toBe("Hello, world!")
93
+ })
94
+
95
+ test("normalizes assistant tool_use block to tool_call entry", () => {
96
+ const msg: ClaudeRawSdkMessage = {
97
+ type: "assistant",
98
+ message: {
99
+ role: "assistant",
100
+ content: [
101
+ { type: "tool_use", name: "Bash", id: "tool-123", input: { command: "ls" } },
102
+ ],
103
+ },
104
+ }
105
+ const entries = normalizeClaudeStreamMessage(msg)
106
+ expect(entries).toHaveLength(1)
107
+ expect(entries[0].kind).toBe("tool_call")
108
+ const tc = entries[0] as { tool: { toolName: string; toolId: string } }
109
+ expect(tc.tool.toolName).toBe("Bash")
110
+ expect(tc.tool.toolId).toBe("tool-123")
111
+ })
112
+
113
+ test("normalizes user tool_result block to tool_result entry", () => {
114
+ const msg: ClaudeRawSdkMessage = {
115
+ type: "user",
116
+ message: {
117
+ role: "user",
118
+ content: [
119
+ { type: "tool_result", tool_use_id: "tool-123", content: "output text", is_error: false },
120
+ ],
121
+ },
122
+ }
123
+ const entries = normalizeClaudeStreamMessage(msg)
124
+ expect(entries).toHaveLength(1)
125
+ expect(entries[0].kind).toBe("tool_result")
126
+ const tr = entries[0] as { toolId: string; content: string; isError: boolean }
127
+ expect(tr.toolId).toBe("tool-123")
128
+ expect(tr.content).toBe("output text")
129
+ expect(tr.isError).toBe(false)
130
+ })
131
+
132
+ test("drops synthetic non-error placeholder ('No response requested.')", () => {
133
+ const msg: ClaudeRawSdkMessage = {
134
+ type: "assistant",
135
+ isApiErrorMessage: false,
136
+ message: {
137
+ role: "assistant",
138
+ model: "<synthetic>",
139
+ content: [{ type: "text", text: "No response requested." }],
140
+ },
141
+ }
142
+ const entries = normalizeClaudeStreamMessage(msg)
143
+ expect(entries).toHaveLength(0)
144
+ })
145
+
146
+ test("normalizes result message to result entry", () => {
147
+ const msg: ClaudeRawSdkMessage = {
148
+ type: "result",
149
+ subtype: "success",
150
+ is_error: false,
151
+ duration_ms: 1200,
152
+ result: "all good",
153
+ total_cost_usd: 0.005,
154
+ }
155
+ const entries = normalizeClaudeStreamMessage(msg)
156
+ expect(entries).toHaveLength(1)
157
+ expect(entries[0].kind).toBe("result")
158
+ const r = entries[0] as { subtype: string; durationMs: number; result: string; costUsd: number }
159
+ expect(r.subtype).toBe("success")
160
+ expect(r.durationMs).toBe(1200)
161
+ expect(r.result).toBe("all good")
162
+ expect(r.costUsd).toBeCloseTo(0.005)
163
+ })
164
+
165
+ test("normalizes api_error assistant message to api_error entry", () => {
166
+ const msg: ClaudeRawSdkMessage = {
167
+ type: "assistant",
168
+ isApiErrorMessage: true,
169
+ apiErrorStatus: 529,
170
+ message: {
171
+ role: "assistant",
172
+ model: "<synthetic>",
173
+ content: [{ type: "text", text: "API Error: 529 Overloaded" }],
174
+ },
175
+ }
176
+ const entries = normalizeClaudeStreamMessage(msg)
177
+ expect(entries).toHaveLength(1)
178
+ expect(entries[0].kind).toBe("api_error")
179
+ const ae = entries[0] as { status: number }
180
+ expect(ae.status).toBe(529)
181
+ })
182
+
183
+ test("normalizes cancelled result to interrupted entry", () => {
184
+ const msg: ClaudeRawSdkMessage = {
185
+ type: "result",
186
+ subtype: "cancelled",
187
+ }
188
+ const entries = normalizeClaudeStreamMessage(msg)
189
+ expect(entries).toHaveLength(1)
190
+ expect(entries[0].kind).toBe("interrupted")
191
+ })
192
+
193
+ test("normalizes system_init message with tools and agents", () => {
194
+ const msg: ClaudeRawSdkMessage = {
195
+ type: "system",
196
+ subtype: "init",
197
+ model: "claude-opus-4-5",
198
+ tools: ["Bash", "Read"],
199
+ agents: ["my-agent"],
200
+ slash_commands: ["help", "._internal"],
201
+ mcp_servers: [],
202
+ }
203
+ const entries = normalizeClaudeStreamMessage(msg)
204
+ expect(entries).toHaveLength(1)
205
+ expect(entries[0].kind).toBe("system_init")
206
+ const si = entries[0] as { model: string; tools: string[]; agents: string[]; slashCommands: string[] }
207
+ expect(si.model).toBe("claude-opus-4-5")
208
+ expect(si.tools).toEqual(["Bash", "Read"])
209
+ expect(si.agents).toEqual(["my-agent"])
210
+ // Internal slash commands prefixed with '._' are filtered
211
+ expect(si.slashCommands).toEqual(["help"])
212
+ })
213
+ })