@cuongtran001/kanna 1.3.0 → 1.4.0

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 (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.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.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Standalone event-loop runner for an active Claude session.
3
+ *
4
+ * Extracted from AgentCoordinator.runClaudeSession in agent.ts so the ~414-line
5
+ * method can live in its own testable module. The coordinator delegates to this
6
+ * function by passing an object literal that satisfies `RunClaudeSessionDeps`.
7
+ *
8
+ * Side-effect seal: this file is pure logic — no direct IO. Any IO is injected
9
+ * through the deps interface.
10
+ */
11
+
12
+ import { log } from "../shared/log"
13
+ import type { AnyValue } from "../shared/errors"
14
+ import type { AgentProvider, Subagent, TranscriptEntry } from "../shared/types"
15
+ import type { LimitDetector, LimitDetection } from "./auto-continue/limit-detector"
16
+ import type { AuthErrorDetection } from "./auto-continue/auth-error-detector"
17
+ import type { ClaudeDriverPreference } from "../shared/types"
18
+ import {
19
+ isPromptTooLongMessage,
20
+ isNoConversationFoundMessage,
21
+ backgroundTaskIdsFromToolResult,
22
+ } from "./claude-prompt-helpers"
23
+ import { timestamped } from "./claude-message-normalizer"
24
+ import { logClaudeSteer } from "./claude-steer-log"
25
+ import type { ClaudeSessionState, ActiveTurn, SlashCommand } from "./claude-session-state"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Structural auth-error detector — only the methods called in this module.
29
+ // Using a structural interface instead of the concrete ClaudeAuthErrorDetector
30
+ // class keeps test deps minimal without requiring `as unknown as`.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface AuthErrorDetectable {
34
+ detect(chatId: string, error: AnyValue): AuthErrorDetection | null
35
+ detectFromResultText(chatId: string, text: string): AuthErrorDetection | null
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Minimal store interface — only the methods runClaudeSession actually calls.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ interface RunClaudeSessionStore {
43
+ appendMessage(chatId: string, entry: TranscriptEntry): Promise<void>
44
+ recordTurnFailed(chatId: string, reason: string): Promise<void>
45
+ setSessionTokenForProvider(chatId: string, provider: AgentProvider, token: string | null): Promise<void>
46
+ setPendingForkSessionToken(
47
+ chatId: string,
48
+ token: { provider: AgentProvider; token: string } | null,
49
+ ): Promise<void>
50
+ recordTurnFinished(chatId: string): Promise<void>
51
+ setCompactFailureCount(chatId: string, count: number): Promise<void>
52
+ recordTurnCancelled(chatId: string): Promise<void>
53
+ getChat(chatId: string): { compactFailureCount?: number; pendingForkSessionToken?: { token: string } | null } | null | undefined
54
+ recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]): Promise<void>
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Minimal oauth-pool interface — only the release() call used in this module.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ interface OAuthPoolReleaseable {
62
+ release(chatId: string): void
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Dependency bundle injected by AgentCoordinator
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export interface RunClaudeSessionDeps {
70
+ openrouterFirstEntryTimeoutMs: number
71
+ claudeSessions: Map<string, ClaudeSessionState>
72
+ activeTurns: Map<string, ActiveTurn>
73
+ oauthPool: OAuthPoolReleaseable | null
74
+ claudeLimitDetector: LimitDetector
75
+ claudeAuthErrorDetector: AuthErrorDetectable
76
+ throwOnClaudeSessionStart: boolean
77
+ store: RunClaudeSessionStore
78
+ emitStateChange(chatId?: string): void
79
+ handleLimitDetection(chatId: string, detection: LimitDetection): Promise<boolean>
80
+ maybeRegisterSdkWorkflowsDir(session: ClaudeSessionState): void
81
+ getSubagents(): Subagent[]
82
+ resolveBackgroundTaskMaxMs(): number
83
+ mergeLocalCatalog(commands: SlashCommand[], cwd: string): SlashCommand[]
84
+ handleLimitError(chatId: string, detector: LimitDetector, error: AnyValue): Promise<boolean>
85
+ handleAuthFailure(session: ClaudeSessionState, detection: AuthErrorDetection): Promise<boolean>
86
+ closeClaudeSession(chatId: string, session: ClaudeSessionState): void
87
+ maybeStartNextQueuedMessage(chatId: string): Promise<boolean | void>
88
+ resolveClaudeDriverPreference(): ClaudeDriverPreference
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Main function
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Consumes the HarnessEvent stream for a running Claude session and handles:
97
+ * - session_token persistence and workflow-dir registration
98
+ * - rate-limit / auth-failure detection and delegation
99
+ * - transcript entry appending and active-turn bookkeeping
100
+ * - background-task keep-alive tracking
101
+ * - compact-boundary finalization (PTY driver only)
102
+ * - fail-close on stream end without a terminal result
103
+ *
104
+ * Behavior is identical to the original private method on AgentCoordinator.
105
+ */
106
+ export async function runClaudeSession(
107
+ deps: RunClaudeSessionDeps,
108
+ session: ClaudeSessionState,
109
+ ): Promise<void> {
110
+ // OpenRouter-only first-entry watchdog. OpenRouter routes through the
111
+ // Claude SDK; a stalled upstream emits the session-token handshake then
112
+ // goes silent — the stream stays open with no entry, so this for-await
113
+ // never returns or throws and the chat hangs "running" until restart. The
114
+ // existing catch/finally fail-close is claude-provider-gated and depends
115
+ // on an active turn that the openrouter path tears down early, so the
116
+ // watchdog records the failure itself, then interrupts + closes the
117
+ // session to end the stream. `firstEntrySeen` guards against a late real
118
+ // entry; close() prevents any further entry from being processed.
119
+ const isOpenRouterSession = session.openrouterModel !== null
120
+ let firstEntrySeen = false
121
+ let firstEntryWatchdog: ReturnType<typeof setTimeout> | null = null
122
+ const clearFirstEntryWatchdog = () => {
123
+ if (firstEntryWatchdog !== null) {
124
+ clearTimeout(firstEntryWatchdog)
125
+ firstEntryWatchdog = null
126
+ }
127
+ }
128
+ if (isOpenRouterSession) {
129
+ firstEntryWatchdog = setTimeout(() => {
130
+ if (firstEntrySeen) return
131
+ if (deps.claudeSessions.get(session.chatId) !== session) return
132
+ firstEntrySeen = true
133
+ const message = `OpenRouter produced no response within ${deps.openrouterFirstEntryTimeoutMs}ms — the selected model may be invalid or the upstream stalled.`
134
+ log.warn("[kanna/agent] openrouter stream produced no entry within watchdog window — failing turn", {
135
+ chatId: session.chatId,
136
+ sessionId: session.id,
137
+ model: session.openrouterModel,
138
+ timeoutMs: deps.openrouterFirstEntryTimeoutMs,
139
+ })
140
+ void (async () => {
141
+ await deps.store.appendMessage(
142
+ session.chatId,
143
+ timestamped({
144
+ kind: "result",
145
+ subtype: "error",
146
+ isError: true,
147
+ durationMs: deps.openrouterFirstEntryTimeoutMs,
148
+ result: message,
149
+ }),
150
+ )
151
+ await deps.store.recordTurnFailed(session.chatId, message)
152
+ const active = deps.activeTurns.get(session.chatId)
153
+ if (active) deps.activeTurns.delete(session.chatId)
154
+ deps.emitStateChange(session.chatId)
155
+ void session.session.interrupt().catch(() => {})
156
+ session.session.close()
157
+ })()
158
+ }, deps.openrouterFirstEntryTimeoutMs)
159
+ }
160
+ try {
161
+ let simulateLimit = deps.throwOnClaudeSessionStart
162
+ for await (const event of session.session.stream) {
163
+ if (simulateLimit) {
164
+ simulateLimit = false
165
+ throw new Error("simulated rate limit")
166
+ }
167
+ if (event.type === "session_token" && event.sessionToken) {
168
+ session.sessionToken = event.sessionToken
169
+ // Persist only when this session is still current, no cancel is in
170
+ // flight, and no /clear suppressed it. A cancelled spawn can emit
171
+ // its session_token AFTER the user interrupted — the CLI may never
172
+ // persist that conversation, so storing the token would poison the
173
+ // next `--resume` ("No conversation found with session ID"). A
174
+ // /clear (setup_loop, background delivery) mid-stream must likewise
175
+ // not be resurrected by the old conversation's next token event.
176
+ const isCurrentSession = deps.claudeSessions.get(session.chatId) === session
177
+ if (
178
+ isCurrentSession
179
+ && session.cancelledResultPending === 0
180
+ && !session.suppressSessionTokenPersist
181
+ ) {
182
+ await deps.store.setSessionTokenForProvider(session.chatId, "claude", event.sessionToken)
183
+ }
184
+ deps.maybeRegisterSdkWorkflowsDir(session)
185
+ deps.emitStateChange(session.chatId)
186
+ continue
187
+ }
188
+
189
+ if (event.type === "rate_limit" && event.rateLimit) {
190
+ // Stale rate_limit events from a session that has already been
191
+ // rotated away must not trigger another rotation/continue.
192
+ if (deps.claudeSessions.get(session.chatId) !== session) continue
193
+ await deps.handleLimitDetection(session.chatId, {
194
+ chatId: session.chatId,
195
+ resetAt: event.rateLimit.resetAt,
196
+ tz: event.rateLimit.tz,
197
+ raw: event,
198
+ })
199
+ if (deps.claudeSessions.get(session.chatId) !== session) break
200
+ continue
201
+ }
202
+
203
+ if (!event.entry) continue
204
+ firstEntrySeen = true
205
+ clearFirstEntryWatchdog()
206
+ if (deps.claudeSessions.get(session.chatId) !== session) break
207
+ // Suppress the interrupt-induced tail `result` of a cancelled turn.
208
+ // cancel() already removed the active turn, recorded the cancellation,
209
+ // and appended the `interrupted` entry; the SDK then emits one error
210
+ // `result` (subtype error_during_execution, empty text) that would
211
+ // otherwise render as "An unknown error occurred." Drop it (and skip
212
+ // the seq shift — cancel() already spliced the cancelled seq).
213
+ if (
214
+ event.entry.kind === "result" &&
215
+ event.entry.isError &&
216
+ session.cancelledResultPending > 0
217
+ ) {
218
+ session.cancelledResultPending -= 1
219
+ continue
220
+ }
221
+ if (event.entry.kind === "system_init") {
222
+ const kannaNames = deps.getSubagents().map((s) => s.name)
223
+ if (kannaNames.length > 0) {
224
+ const entry = event.entry
225
+ const existing = new Set(entry.agents)
226
+ const extra = kannaNames.filter((n) => !existing.has(n))
227
+ if (extra.length > 0) {
228
+ entry.agents = [...entry.agents, ...extra]
229
+ }
230
+ }
231
+ }
232
+ await deps.store.appendMessage(session.chatId, event.entry)
233
+ // Background-task keep-alive guard (SDK + PTY).
234
+ // On launch: add the task id and refresh the zombie-backstop deadline.
235
+ // On settle (task_notification): remove the id — gate primary signal is
236
+ // set size>0, not the clock. The deadline (default 4h) is refreshed on
237
+ // every launch and settle so it only fires when a notification is truly
238
+ // lost (SDK crash / dropped message), never during normal execution.
239
+ if (event.entry.kind === "tool_result") {
240
+ const launchedIds = backgroundTaskIdsFromToolResult(
241
+ event.entry.content,
242
+ )
243
+ if (launchedIds.length > 0) {
244
+ for (const id of launchedIds) session.backgroundTaskIds.add(id)
245
+ session.backgroundTaskDeadlineAt = Date.now() + deps.resolveBackgroundTaskMaxMs()
246
+ deps.emitStateChange(session.chatId)
247
+ }
248
+ }
249
+ if (event.entry.kind === "status" && event.entry.backgroundTaskId) {
250
+ const settledId = event.entry.backgroundTaskId
251
+ session.backgroundTaskIds.delete(settledId)
252
+ if (session.backgroundTaskIds.size > 0) {
253
+ session.backgroundTaskDeadlineAt = Date.now() + deps.resolveBackgroundTaskMaxMs()
254
+ } else {
255
+ session.backgroundTaskDeadlineAt = 0
256
+ }
257
+ deps.emitStateChange(session.chatId)
258
+ }
259
+ const active = deps.activeTurns.get(session.chatId)
260
+ if (event.entry.kind === "system_init" && active) {
261
+ active.status = "running"
262
+ const chat = deps.store.getChat(session.chatId)
263
+ if (
264
+ chat?.pendingForkSessionToken
265
+ && session.sessionToken
266
+ && session.sessionToken !== chat.pendingForkSessionToken.token
267
+ ) {
268
+ await deps.store.setPendingForkSessionToken(session.chatId, null)
269
+ }
270
+ // Refresh the chat's slashCommands from the live system_init list
271
+ // every spawn. The cold-start `getSupportedCommands()` call right
272
+ // after spawn often returns the static fallback because system_init
273
+ // hadn't arrived yet; this overwrites that with the canonical list
274
+ // (skills + plugins + built-ins, no `/` prefix).
275
+ if (Array.isArray(event.entry.slashCommands)) {
276
+ const names = event.entry.slashCommands
277
+ const commands: SlashCommand[] = names.map((name) => ({
278
+ name,
279
+ description: "",
280
+ argumentHint: "",
281
+ }))
282
+ const merged = deps.mergeLocalCatalog(commands, session.localPath)
283
+ await deps.store.recordSessionCommandsLoaded(session.chatId, merged)
284
+ }
285
+ logClaudeSteer("claude_event_system_init", {
286
+ chatId: session.chatId,
287
+ sessionId: session.id,
288
+ activePromptSeq: active.claudePromptSeq ?? null,
289
+ pendingPromptSeqs: [...session.pendingPromptSeqs],
290
+ })
291
+ }
292
+
293
+ const completedClaudePromptSeq = event.entry.kind === "result" || event.entry.kind === "interrupted"
294
+ ? (session.pendingPromptSeqs.shift() ?? null)
295
+ : null
296
+ if (completedClaudePromptSeq !== null) {
297
+ session.lastUsedAt = Date.now()
298
+ }
299
+
300
+ logClaudeSteer("claude_event", {
301
+ chatId: session.chatId,
302
+ sessionId: session.id,
303
+ entryKind: event.entry.kind,
304
+ activePromptSeq: active?.claudePromptSeq ?? null,
305
+ completedPromptSeq: completedClaudePromptSeq,
306
+ activeStatus: active?.status ?? null,
307
+ pendingPromptSeqs: [...session.pendingPromptSeqs],
308
+ })
309
+
310
+ // PTY-only: the Kanna-injected proactive `/compact` turn never emits a
311
+ // terminal `result`/`turn_duration` under the interactive TUI — it
312
+ // writes only a `system/compact_boundary` line (confirmed in the
313
+ // on-disk transcript). Without a result, the normal finalize path below
314
+ // (kind === "result") never runs, so the active turn and its
315
+ // `proactiveCompactInjection` flag linger forever — permanently wedging
316
+ // `dequeue()` ("Cannot remove queued message while compact is running")
317
+ // and the queued-message drain. Treat the boundary as the compact
318
+ // turn's completion: finalize like the SDK result path and drain the
319
+ // queued user message the compact made room for. The SDK driver is
320
+ // excluded because there a real `result` still follows; finalizing here
321
+ // would double-finalize and corrupt the trailing result's seq
322
+ // accounting. See adr-20260608-pty-compact-boundary-dequeue-finalize.
323
+ if (
324
+ event.entry.kind === "compact_boundary"
325
+ && active?.proactiveCompactInjection
326
+ && !active.cancelRequested
327
+ && deps.resolveClaudeDriverPreference() === "pty"
328
+ ) {
329
+ active.hasFinalResult = true
330
+ await deps.store.recordTurnFinished(session.chatId)
331
+ await deps.store.setCompactFailureCount(session.chatId, 0)
332
+ // The compact prompt's seq never gets shifted (no result event), so
333
+ // drop it here — otherwise the next real turn's result would shift
334
+ // this stale seq and FIFO-mismatch, wedging that turn. Mirrors
335
+ // cancel()'s pending-seq drain.
336
+ if (active.claudePromptSeq != null) {
337
+ const idx = session.pendingPromptSeqs.indexOf(active.claudePromptSeq)
338
+ if (idx >= 0) session.pendingPromptSeqs.splice(idx, 1)
339
+ }
340
+ deps.activeTurns.delete(session.chatId)
341
+ deps.oauthPool?.release(session.chatId)
342
+ await deps.maybeStartNextQueuedMessage(session.chatId)
343
+ deps.emitStateChange(session.chatId)
344
+ continue
345
+ }
346
+
347
+ if (event.entry.kind === "result" && active && completedClaudePromptSeq === (active.claudePromptSeq ?? null)) {
348
+ active.hasFinalResult = true
349
+ // True once a rate-limit / auth-error was routed through
350
+ // handleLimitDetection / handleAuthFailure. Those paths already
351
+ // marked the failed token limited/errored (dropping its
352
+ // reservation) and, when a rotation target exists, pinned the
353
+ // replacement token under this chatId for the scheduled
354
+ // auto-continue to reuse. The turn-scoped release below MUST be
355
+ // skipped in that case — otherwise it drops the freshly-pinned
356
+ // rotation token and a concurrent chat can steal it before
357
+ // fireAutoContinue spawns the replacement session (audit #1).
358
+ let failureHandled = false
359
+ if (event.entry.isError) {
360
+ const resultText = event.entry.result || "Turn failed"
361
+ const debugRaw = event.entry.debugRaw ?? ""
362
+ const detection = deps.claudeLimitDetector.detectFromResultText?.(session.chatId, resultText) ?? null
363
+ const authDetection = deps.claudeAuthErrorDetector.detectFromResultText(session.chatId, resultText)
364
+ ?? deps.claudeAuthErrorDetector.detectFromResultText(session.chatId, debugRaw)
365
+ let handled = false
366
+ if (detection) {
367
+ handled = await deps.handleLimitDetection(session.chatId, detection)
368
+ } else if (authDetection) {
369
+ handled = await deps.handleAuthFailure(session, authDetection)
370
+ }
371
+ failureHandled = handled
372
+ if (handled) {
373
+ await deps.store.recordTurnFailed(session.chatId, detection ? "rate_limit" : "auth_error")
374
+ } else if (
375
+ isPromptTooLongMessage(resultText)
376
+ || isNoConversationFoundMessage(resultText)
377
+ || isNoConversationFoundMessage(debugRaw)
378
+ ) {
379
+ await deps.store.recordTurnFailed(session.chatId, resultText)
380
+ deps.closeClaudeSession(session.chatId, session)
381
+ await deps.store.setSessionTokenForProvider(session.chatId, "claude", null)
382
+ } else {
383
+ await deps.store.recordTurnFailed(session.chatId, resultText)
384
+ }
385
+ if (active.proactiveCompactInjection) {
386
+ const prev = deps.store.getChat(session.chatId)?.compactFailureCount ?? 0
387
+ await deps.store.setCompactFailureCount(session.chatId, prev + 1)
388
+ }
389
+ } else if (!active.cancelRequested) {
390
+ await deps.store.recordTurnFinished(session.chatId)
391
+ if (active.proactiveCompactInjection) {
392
+ await deps.store.setCompactFailureCount(session.chatId, 0)
393
+ }
394
+ // Note: pending-workflow harvest wake removed — workflow-completion
395
+ // notification is a follow-up ADR. Model can delegate a status-check
396
+ // subagent if it needs event-driven workflow wake.
397
+ }
398
+ deps.activeTurns.delete(session.chatId)
399
+ // Turn-scoped reservation: release on turn end so other chats can
400
+ // claim the same token while this chat is idle. The next turn for
401
+ // this chat reuses the same claude session (no re-pick); the
402
+ // rotation race between in-flight turns is still serialized via
403
+ // markLimited/markError (both drop the reservation) and the
404
+ // atomic single-threaded pickActive(chatId) calls.
405
+ //
406
+ // Skip when a rotation handled the failure: the rotation already
407
+ // pinned the replacement token under this chatId and the
408
+ // scheduled auto-continue (TOKEN_ROTATION_SCHEDULE_DELAY_MS later)
409
+ // depends on that pin still being held.
410
+ if (!failureHandled) {
411
+ deps.oauthPool?.release(session.chatId)
412
+ }
413
+ if (!active.cancelRequested) {
414
+ await deps.maybeStartNextQueuedMessage(session.chatId)
415
+ }
416
+ } else if (event.entry.kind === "result" && event.entry.isError) {
417
+ // Fallback: an errored result carrying a recognizable rate-limit /
418
+ // auth signature must NOT be silently dropped just because its
419
+ // prompt-seq did not line up with the active turn. Observed as a 9h
420
+ // autonomous-loop stall: a synthetic 429 result on an auto-continue
421
+ // wake turn arrived with the pending prompt-seq queue already drained
422
+ // (so the seq gate above missed), and the loop died with no resume
423
+ // schedule — no proposal, no accept — until a human manually resumed.
424
+ // handleLimitDetection is idempotent (dedupes on a live schedule) and
425
+ // handleAuthFailure only rotates/proposes, so re-driving detection
426
+ // here only ever adds a missing resume, never a duplicate. The
427
+ // auto-resume setting is still honoured inside handleLimitDetection.
428
+ const resultText = event.entry.result || ""
429
+ const debugRaw = event.entry.debugRaw ?? ""
430
+ const detection = deps.claudeLimitDetector.detectFromResultText?.(session.chatId, resultText) ?? null
431
+ const authDetection = detection
432
+ ? null
433
+ : deps.claudeAuthErrorDetector.detectFromResultText(session.chatId, resultText)
434
+ ?? deps.claudeAuthErrorDetector.detectFromResultText(session.chatId, debugRaw)
435
+ if (detection) {
436
+ await deps.handleLimitDetection(session.chatId, detection)
437
+ } else if (authDetection) {
438
+ await deps.handleAuthFailure(session, authDetection)
439
+ }
440
+ }
441
+
442
+ deps.emitStateChange(session.chatId)
443
+ }
444
+ } catch (error) {
445
+ const active = deps.activeTurns.get(session.chatId)
446
+ if (active && !active.cancelRequested) {
447
+ const limitHandled = await deps.handleLimitError(session.chatId, deps.claudeLimitDetector, error)
448
+ const authDetection = limitHandled
449
+ ? null
450
+ : deps.claudeAuthErrorDetector.detect(session.chatId, error)
451
+ const authHandled = authDetection
452
+ ? await deps.handleAuthFailure(session, authDetection)
453
+ : false
454
+ const handled = limitHandled || authHandled
455
+ if (!handled) {
456
+ const message = error instanceof Error ? error.message : String(error)
457
+ await deps.store.appendMessage(
458
+ session.chatId,
459
+ timestamped({
460
+ kind: "result",
461
+ subtype: "error",
462
+ isError: true,
463
+ durationMs: 0,
464
+ result: message,
465
+ })
466
+ )
467
+ await deps.store.recordTurnFailed(session.chatId, message)
468
+ if (isPromptTooLongMessage(message) || isNoConversationFoundMessage(message)) {
469
+ deps.closeClaudeSession(session.chatId, session)
470
+ await deps.store.setSessionTokenForProvider(session.chatId, "claude", null)
471
+ }
472
+ } else {
473
+ await deps.store.recordTurnFailed(session.chatId, limitHandled ? "rate_limit" : "auth_error")
474
+ }
475
+ }
476
+ } finally {
477
+ clearFirstEntryWatchdog()
478
+ const active = deps.activeTurns.get(session.chatId)
479
+ const isCurrentSession = deps.claudeSessions.get(session.chatId) === session
480
+ // Trace point: stream-end-without-final-result is the hang signature.
481
+ // If `hasActiveTurn=true` && `hasFinalResult=false` && this fires,
482
+ // the user will see "still running" forever unless we fail-close.
483
+ log.info("[kanna/agent] runClaudeSession stream ended", {
484
+ chatId: session.chatId,
485
+ sessionId: session.id,
486
+ sessionToken: session.sessionToken,
487
+ isCurrentSession,
488
+ hasActiveTurn: Boolean(active),
489
+ activeStatus: active?.status,
490
+ cancelRequested: active?.cancelRequested,
491
+ hasFinalResult: active?.hasFinalResult,
492
+ })
493
+ // Only clear chat state if it still points at us. A cancel-then-steer,
494
+ // or an oauth-pool rotation that closes this session and schedules an
495
+ // auto-continue, can install a fresh session (and activeTurn) under
496
+ // the same chatId before this finally runs; wiping either
497
+ // unconditionally would break the fresh session's bookkeeping and
498
+ // leave its stream running headless (no isError branch fires →
499
+ // sessionToken never cleared → next turn loops with the same
500
+ // too-large --resume context).
501
+ if (isCurrentSession) {
502
+ deps.claudeSessions.delete(session.chatId)
503
+ deps.oauthPool?.release(session.chatId)
504
+ if (active?.provider === "claude") {
505
+ if (active.cancelRequested && !active.cancelRecorded) {
506
+ await deps.store.recordTurnCancelled(session.chatId)
507
+ } else if (!active.hasFinalResult) {
508
+ // Stream ended without any terminal result event (PTY died,
509
+ // SDK transport dropped, etc). Fail-close the turn so the UI
510
+ // stops showing "running" forever. Without this the chat is
511
+ // wedged until the user manually clicks Stop or reloads.
512
+ log.warn("[kanna/agent] stream ended with no final result — recording turn failure", { chatId: session.chatId, sessionId: session.id })
513
+ await deps.store.recordTurnFailed(session.chatId, "session stream ended without a result")
514
+ }
515
+ deps.activeTurns.delete(session.chatId)
516
+ }
517
+ }
518
+ session.session.close()
519
+ deps.emitStateChange(session.chatId)
520
+ }
521
+ }