@cuongtran001/kanna 0.97.2 → 0.97.3

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 (57) hide show
  1. package/dist/client/assets/{arc-BeYqRlS4.js → arc-MpgmgmPa.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BTxcC3Bx.js → architectureDiagram-3BPJPVTR-BmyzKjcm.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-B_CLWeW3.js → blockDiagram-GPEHLZMM-DDwSQpXy.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-kZm6qlQm.js → c4Diagram-AAUBKEIU-DGU9MS8l.js} +1 -1
  5. package/dist/client/assets/channel-hDBiexM3.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-C77Np9ft.js → chunk-2J33WTMH-CzAgupMo.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DUyKnkbT.js → chunk-4BX2VUAB-BuhpJag7.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-HUFJLmvp.js → chunk-55IACEB6-C3whvGLO.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-eMaVSOKi.js → chunk-727SXJPM-Czerk6m5.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Dqr7iBWK.js → chunk-AQP2D5EJ-B_kOYwUJ.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-BtAWu6Fv.js → chunk-FMBD7UC4-psmwuhCl.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-CyxbGgpO.js → chunk-ND2GUHAM-tHiEXCGI.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-aqVpGxUl.js → chunk-QZHKN3VN-RDSvxWqW.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CCsEgAfN.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CCsEgAfN.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-GzK3z9WD.js → cose-bilkent-S5V4N54A-BSKH2P-t.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Djd07Bgc.js → dagre-BM42HDAG-D0IFx6n9.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-CPTC78mN.js → diagram-2AECGRRQ-fIDGRT5b.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-DVTkaw4f.js → diagram-5GNKFQAL-WDW2pNsB.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-10KOjvpy.js → diagram-KO2AKTUF-D4osuldj.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-Myxb2cAv.js → diagram-LMA3HP47-sXE6nZWg.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BuRZkgMd.js → diagram-OG6HWLK6-D0hJNJhu.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Be5hhZh4.js → erDiagram-TEJ5UH35-DZG5CvSu.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-BhiDJ4Mp.js → flowDiagram-I6XJVG4X-Bp8MtWSC.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CZ_jbXit.js → ganttDiagram-6RSMTGT7-DNZ5nlYY.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DcliQiyM.js → gitGraphDiagram-PVQCEYII-cBXUm24V.js} +1 -1
  27. package/dist/client/assets/{index-BC-Tl-79.js → index-B4ajO29H.js} +1 -1
  28. package/dist/client/assets/{index-MixgFXRr.js → index-aHr593E5.js} +2 -2
  29. package/dist/client/assets/{infoDiagram-5YYISTIA-pteIHWnV.js → infoDiagram-5YYISTIA-B7EGfGYx.js} +1 -1
  30. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-B_L5beaQ.js → ishikawaDiagram-YF4QCWOH-SQh4xs_U.js} +1 -1
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-hen4Bh6f.js → journeyDiagram-JHISSGLW-CbeaclIp.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-BtUymVff.js → kanban-definition-UN3LZRKU-COp-ikqh.js} +1 -1
  33. package/dist/client/assets/{linear-FUiPyB6F.js → linear-CrlbLXCR.js} +1 -1
  34. package/dist/client/assets/{mermaid.core-CoJEFYct.js → mermaid.core-MgV5Y9BW.js} +4 -4
  35. package/dist/client/assets/{mindmap-definition-RKZ34NQL-CazuLaBR.js → mindmap-definition-RKZ34NQL-D9CoXKCy.js} +1 -1
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-CRb7TKXS.js → pieDiagram-4H26LBE5-CYiW7xSP.js} +1 -1
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-BSJq42JJ.js → quadrantDiagram-W4KKPZXB-CIsSZ-Zh.js} +1 -1
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BnvGLDfm.js → requirementDiagram-4Y6WPE33-B7-GZSuQ.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BsefP6U4.js → sankeyDiagram-5OEKKPKP-BpI06biT.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-9nUKArcj.js → sequenceDiagram-3UESZ5HK-AfJCM_jE.js} +1 -1
  41. package/dist/client/assets/{stateDiagram-AJRCARHV-BzYVPtVp.js → stateDiagram-AJRCARHV-BjE5oO5C.js} +1 -1
  42. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CgYlyNXS.js +1 -0
  43. package/dist/client/assets/{timeline-definition-PNZ67QCA-Dk6D_V0F.js → timeline-definition-PNZ67QCA-RqjT3Z41.js} +1 -1
  44. package/dist/client/assets/{vennDiagram-CIIHVFJN-B-gVcndn.js → vennDiagram-CIIHVFJN-CFo_JoJQ.js} +1 -1
  45. package/dist/client/assets/{wardley-L42UT6IY-DKOWjaGp.js → wardley-L42UT6IY-xa7pZ69p.js} +1 -1
  46. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-DhXoRo4O.js → wardleyDiagram-YWT4CUSO-J9a616X6.js} +1 -1
  47. package/dist/client/assets/{xychartDiagram-2RQKCTM6-Bth68Xec.js → xychartDiagram-2RQKCTM6-DGpUoD4-.js} +1 -1
  48. package/dist/client/index.html +1 -1
  49. package/package.json +1 -1
  50. package/src/server/agent.openrouter-watchdog.test.ts +280 -0
  51. package/src/server/agent.ts +81 -5
  52. package/src/server/server.ts +4 -0
  53. package/src/shared/types.ts +16 -0
  54. package/dist/client/assets/channel-ohENpLj7.js +0 -1
  55. package/dist/client/assets/classDiagram-4FO5ZUOK-BlyPnDJH.js +0 -1
  56. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-BlyPnDJH.js +0 -1
  57. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-BRLtys3w.js +0 -1
@@ -0,0 +1,280 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { AgentCoordinator } from "./agent"
3
+ import type { HarnessEvent } from "./harness-types"
4
+ import type { LlmProviderSnapshot, SlashCommand, TranscriptEntry } from "../shared/types"
5
+ import { AsyncEventQueue } from "./test-helpers/async-event-queue"
6
+ import { waitFor } from "./test-helpers/wait-for"
7
+
8
+ // Minimal store (trimmed copy from agent.openrouter-model.test.ts — do NOT
9
+ // modify agent.test.ts).
10
+ function createFakeStore() {
11
+ const chat = {
12
+ id: "chat-1",
13
+ projectId: "project-1",
14
+ title: "New Chat",
15
+ provider: null as string | null,
16
+ planMode: false,
17
+ sessionToken: null as string | null,
18
+ sessionTokensByProvider: {} as Record<string, string | null>,
19
+ slashCommands: undefined as SlashCommand[] | undefined,
20
+ pendingForkSessionToken: null as { provider: string; token: string } | null,
21
+ }
22
+ const project = { id: "project-1", localPath: "/tmp/project" }
23
+ return {
24
+ chat,
25
+ turnFailedCount: 0,
26
+ turnFailedReasons: [] as string[],
27
+ messages: [] as TranscriptEntry[],
28
+ queuedMessages: [] as Array<{ id: string; content: string }>,
29
+ async recordSessionCommandsLoaded(_chatId: string, commands: SlashCommand[]) {
30
+ chat.slashCommands = commands
31
+ },
32
+ requireChat() {
33
+ return chat
34
+ },
35
+ getChat(chatId: string) {
36
+ if (chatId !== "chat-1") return null
37
+ return chat
38
+ },
39
+ getProject() {
40
+ return project
41
+ },
42
+ getMessages() {
43
+ return this.messages
44
+ },
45
+ async setChatProvider(_chatId: string, provider: string) {
46
+ chat.provider = provider
47
+ },
48
+ async setPlanMode(_chatId: string, planMode: boolean) {
49
+ chat.planMode = planMode
50
+ },
51
+ async renameChat(_chatId: string, title: string) {
52
+ chat.title = title
53
+ },
54
+ async appendMessage(_chatId: string, entry: TranscriptEntry) {
55
+ this.messages.push(entry)
56
+ },
57
+ async recordTurnStarted() {},
58
+ async recordTurnFinished() {},
59
+ async recordTurnFailed(_chatId: string, reason: string) {
60
+ this.turnFailedCount += 1
61
+ this.turnFailedReasons.push(reason)
62
+ },
63
+ async recordTurnCancelled() {},
64
+ async setSessionToken(_chatId: string, sessionToken: string | null) {
65
+ chat.sessionToken = sessionToken
66
+ },
67
+ async setSessionTokenForProvider(_chatId: string, provider: string, sessionToken: string | null) {
68
+ chat.sessionTokensByProvider = { ...chat.sessionTokensByProvider, [provider]: sessionToken }
69
+ chat.sessionToken = sessionToken
70
+ },
71
+ async setPendingForkSessionToken(_chatId: string, value: { provider: string; token: string } | null) {
72
+ chat.pendingForkSessionToken = value
73
+ },
74
+ async createChat() {
75
+ return chat
76
+ },
77
+ async enqueueMessage() {
78
+ return { id: crypto.randomUUID(), content: "" }
79
+ },
80
+ getQueuedMessages() {
81
+ return [...this.queuedMessages]
82
+ },
83
+ *runningSubagentRuns() {},
84
+ }
85
+ }
86
+
87
+ function openrouterSnapshot(): LlmProviderSnapshot {
88
+ return {
89
+ provider: "openrouter",
90
+ apiKey: "sk-or-v1-abcdef1234567890",
91
+ model: "moonshotai/kimi-k2.5:nitro",
92
+ baseUrl: "",
93
+ resolvedBaseUrl: "https://openrouter.ai/api",
94
+ enabled: true,
95
+ warning: null,
96
+ filePathDisplay: "~/.kanna/llm-provider.json",
97
+ }
98
+ }
99
+
100
+ describe("AgentCoordinator OpenRouter first-entry watchdog", () => {
101
+ test(
102
+ "fails closed when the OpenRouter stream emits session_token then no entry",
103
+ async () => {
104
+ const events = new AsyncEventQueue<HarnessEvent>()
105
+ const store = createFakeStore()
106
+ let closeCalled = 0
107
+ const coordinator = new AgentCoordinator({
108
+ store: store as never,
109
+ onStateChange: () => {},
110
+ readLlmProvider: async () => openrouterSnapshot(),
111
+ openrouterFirstEntryTimeoutMs: 200,
112
+ startClaudeSession: async () => {
113
+ return {
114
+ provider: "claude",
115
+ stream: events,
116
+ getAccountInfo: async () => null,
117
+ interrupt: async () => {},
118
+ close: () => {
119
+ closeCalled += 1
120
+ events.close()
121
+ },
122
+ setModel: async () => {},
123
+ setPermissionMode: async () => {},
124
+ getSupportedCommands: async () => [],
125
+ // Never emit an entry — reproduces session a71516d4's silent stall
126
+ // where the SDK connected (account_info) but no system_init/result
127
+ // ever arrived.
128
+ sendPrompt: async () => {},
129
+ }
130
+ },
131
+ } as never)
132
+
133
+ await coordinator.send({
134
+ type: "chat.send",
135
+ chatId: "chat-1",
136
+ provider: "openrouter" as never,
137
+ content: "tell me about this project",
138
+ model: "qwen/qwen3.7-plus",
139
+ })
140
+
141
+ await waitFor(() => store.turnFailedCount > 0, 4000, "turn failed via watchdog")
142
+
143
+ expect(store.turnFailedCount).toBeGreaterThan(0)
144
+ expect(closeCalled).toBeGreaterThan(0)
145
+ const errorResult = store.messages.find(
146
+ (m) => m.kind === "result" && (m as { isError?: boolean }).isError === true,
147
+ )
148
+ expect(errorResult).toBeDefined()
149
+ expect((errorResult as { result?: string }).result).toContain("OpenRouter produced no response")
150
+ },
151
+ 10_000,
152
+ )
153
+
154
+ test(
155
+ "does not fail the turn when the OpenRouter stream emits an entry in time",
156
+ async () => {
157
+ const events = new AsyncEventQueue<HarnessEvent>()
158
+ const store = createFakeStore()
159
+ const coordinator = new AgentCoordinator({
160
+ store: store as never,
161
+ onStateChange: () => {},
162
+ readLlmProvider: async () => openrouterSnapshot(),
163
+ openrouterFirstEntryTimeoutMs: 200,
164
+ startClaudeSession: async () => {
165
+ // Emit a result entry promptly — the watchdog must be cleared and
166
+ // never fire.
167
+ events.push({
168
+ type: "transcript",
169
+ entry: {
170
+ _id: "result-1",
171
+ createdAt: Date.now(),
172
+ kind: "result",
173
+ subtype: "success",
174
+ isError: false,
175
+ durationMs: 0,
176
+ result: "done",
177
+ } as never,
178
+ })
179
+ return {
180
+ provider: "claude",
181
+ stream: events,
182
+ getAccountInfo: async () => null,
183
+ interrupt: async () => {},
184
+ close: () => events.close(),
185
+ setModel: async () => {},
186
+ setPermissionMode: async () => {},
187
+ getSupportedCommands: async () => [],
188
+ sendPrompt: async () => {},
189
+ }
190
+ },
191
+ } as never)
192
+
193
+ await coordinator.send({
194
+ type: "chat.send",
195
+ chatId: "chat-1",
196
+ provider: "openrouter" as never,
197
+ content: "hi",
198
+ model: "moonshotai/kimi-k2.5:nitro",
199
+ })
200
+
201
+ // Wait past the watchdog window; the timely entry must keep it disarmed.
202
+ await new Promise((resolve) => setTimeout(resolve, 500))
203
+
204
+ expect(store.turnFailedCount).toBe(0)
205
+ const errorResult = store.messages.find(
206
+ (m) => m.kind === "result" && (m as { isError?: boolean }).isError === true,
207
+ )
208
+ expect(errorResult).toBeUndefined()
209
+ },
210
+ 10_000,
211
+ )
212
+ })
213
+
214
+ describe("AgentCoordinator OpenRouter SDK-session prompt delivery", () => {
215
+ test(
216
+ "delivers the user prompt to the SDK session for openrouter (regression: prompt-delivery gate)",
217
+ async () => {
218
+ // Regression for the gate that delivered prompts only when
219
+ // `provider === "claude"`. OpenRouter rides the same SDK session but was
220
+ // excluded, so its prompt never reached `sendPrompt` and every turn hung
221
+ // until the watchdog. `providerUsesSdkSession` now covers both.
222
+ const events = new AsyncEventQueue<HarnessEvent>()
223
+ const store = createFakeStore()
224
+ const sentPrompts: string[] = []
225
+ const coordinator = new AgentCoordinator({
226
+ store: store as never,
227
+ onStateChange: () => {},
228
+ readLlmProvider: async () => openrouterSnapshot(),
229
+ openrouterFirstEntryTimeoutMs: 5000,
230
+ startClaudeSession: async () => {
231
+ return {
232
+ provider: "claude",
233
+ stream: events,
234
+ getAccountInfo: async () => null,
235
+ interrupt: async () => {},
236
+ close: () => events.close(),
237
+ setModel: async () => {},
238
+ setPermissionMode: async () => {},
239
+ getSupportedCommands: async () => [],
240
+ sendPrompt: async (content: string) => {
241
+ sentPrompts.push(content)
242
+ // A real upstream would now stream a turn; emit a success result
243
+ // so the turn completes cleanly and the watchdog never fires.
244
+ events.push({
245
+ type: "transcript",
246
+ entry: {
247
+ _id: "result-1",
248
+ createdAt: Date.now(),
249
+ kind: "result",
250
+ subtype: "success",
251
+ isError: false,
252
+ durationMs: 0,
253
+ result: "ok",
254
+ } as never,
255
+ })
256
+ },
257
+ }
258
+ },
259
+ } as never)
260
+
261
+ await coordinator.send({
262
+ type: "chat.send",
263
+ chatId: "chat-1",
264
+ provider: "openrouter" as never,
265
+ content: "hello openrouter",
266
+ model: "moonshotai/kimi-k2.5:nitro",
267
+ })
268
+
269
+ await waitFor(
270
+ () => sentPrompts.some((p) => p.includes("hello openrouter")),
271
+ 4000,
272
+ "openrouter prompt delivered to the SDK session",
273
+ )
274
+
275
+ expect(sentPrompts.some((p) => p.includes("hello openrouter"))).toBe(true)
276
+ expect(store.turnFailedCount).toBe(0)
277
+ },
278
+ 10_000,
279
+ )
280
+ })
@@ -48,7 +48,7 @@ import {
48
48
  openrouterAuthReady,
49
49
  } from "./provider-catalog"
50
50
  import { readLlmProviderSnapshot } from "./llm-provider"
51
- import { resolveClaudeApiModelId, type ClaudeDriverPreference } from "../shared/types"
51
+ import { providerUsesSdkSession, resolveClaudeApiModelId, type ClaudeDriverPreference } from "../shared/types"
52
52
  import { fallbackTitleFromMessage } from "./generate-title"
53
53
  import { AUTO_CONTINUE_EVENT_VERSION, type AutoContinueEvent } from "./auto-continue/events"
54
54
  import { ClaudeLimitDetector, CodexLimitDetector, type LimitDetection, type LimitDetector } from "./auto-continue/limit-detector"
@@ -340,6 +340,17 @@ interface AgentCoordinatorArgs {
340
340
  * 120000 (2 min). Bounded by `maxAgentWakes`.
341
341
  */
342
342
  pendingWorkflowPollMs?: number
343
+ /**
344
+ * Watchdog (ms) for an OpenRouter turn whose SDK stream emits no transcript
345
+ * entry (no `system_init`) after the session-token handshake. OpenRouter
346
+ * routes through the Claude SDK; a stalled upstream leaves the stream open
347
+ * but silent, so the `runClaudeSession` for-await never returns or throws
348
+ * and the existing fail-close never fires. On timeout the watchdog
349
+ * interrupts + closes the session so the stream ends and the turn is
350
+ * recorded failed. OpenRouter-only; cleared on the first entry. Default
351
+ * 120000 (2 min).
352
+ */
353
+ openrouterFirstEntryTimeoutMs?: number
343
354
  getSubagents?: () => Subagent[]
344
355
  getAppSettingsSnapshot?: () => {
345
356
  claudeAuth?: { authenticated?: boolean } | null
@@ -1324,6 +1335,12 @@ const DEFAULT_CLAUDE_SESSION_SWEEP_INTERVAL_MS = 60 * 1000
1324
1335
  // Keep a PTY session warm up to 30 min while a background Bash task is pending —
1325
1336
  // comfortably longer than the 10-min idle window and typical CI durations.
1326
1337
  const DEFAULT_PTY_BACKGROUND_TASK_MAX_MS = 30 * 60 * 1000
1338
+ // OpenRouter-only watchdog: a stalled upstream leaves the SDK stream open but
1339
+ // silent after the session-token handshake, so the runClaudeSession for-await
1340
+ // never ends and the existing fail-close never fires. Abort if no transcript
1341
+ // entry arrives within this window. system_init is the SDK init echo (precedes
1342
+ // model inference), so 2 min is generous; env-tunable per deployment.
1343
+ const DEFAULT_OPENROUTER_FIRST_ENTRY_TIMEOUT_MS = 2 * 60 * 1000
1327
1344
  // Agent wakes are clamped to one idle window minus this buffer so a re-entry
1328
1345
  // always lands before the idle reaper closes the PTY (see scheduleAgentWakeup).
1329
1346
  const WAKE_GUARD_BUFFER_MS = 60 * 1000
@@ -1412,6 +1429,7 @@ export class AgentCoordinator {
1412
1429
  private readonly agentWakeChainByChat = new Map<string, number>()
1413
1430
  private readonly maxAgentWakes: number
1414
1431
  private readonly pendingWorkflowPollMs: number
1432
+ private readonly openrouterFirstEntryTimeoutMs: number
1415
1433
  // Per-tokenId rotation dedupe state. When a shared OAuth token throws
1416
1434
  // limit/auth-error against N chats simultaneously, only the first chat
1417
1435
  // pays the cost of marking the pool + picking a fresh target; subsequent
@@ -1456,6 +1474,8 @@ export class AgentCoordinator {
1456
1474
  this.getAutoResumePreference = args.getAutoResumePreference ?? (() => false)
1457
1475
  this.maxAgentWakes = args.maxAgentWakes ?? 25
1458
1476
  this.pendingWorkflowPollMs = args.pendingWorkflowPollMs ?? 120_000
1477
+ this.openrouterFirstEntryTimeoutMs =
1478
+ args.openrouterFirstEntryTimeoutMs ?? DEFAULT_OPENROUTER_FIRST_ENTRY_TIMEOUT_MS
1459
1479
  this.getSubagents = args.getSubagents ?? (() => [])
1460
1480
  this.getAppSettingsSnapshot = args.getAppSettingsSnapshot ?? (() => ({}))
1461
1481
  this.readLlmProvider = args.readLlmProvider ?? readLlmProviderSnapshot
@@ -2446,10 +2466,13 @@ export class AgentCoordinator {
2446
2466
  .catch(() => undefined)
2447
2467
  }
2448
2468
 
2449
- if (args.provider === "claude") {
2469
+ if (providerUsesSdkSession(args.provider)) {
2470
+ // claude and openrouter both deliver their prompt through the SDK
2471
+ // session queue; gating this on `=== "claude"` is what left openrouter's
2472
+ // prompt undelivered, hanging every openrouter turn until the watchdog.
2450
2473
  const session = this.claudeSessions.get(args.chatId)
2451
2474
  if (!session) {
2452
- throw new Error("Claude session was not initialized")
2475
+ throw new Error("SDK session was not initialized")
2453
2476
  }
2454
2477
  const promptSeq = session.nextPromptSeq + 1
2455
2478
  session.nextPromptSeq = promptSeq
@@ -2487,12 +2510,12 @@ export class AgentCoordinator {
2487
2510
  planMode: boolean
2488
2511
  clientTraceId?: string
2489
2512
  }): ActiveTurn | undefined {
2490
- if (args.provider !== "claude") return undefined
2513
+ if (!providerUsesSdkSession(args.provider)) return undefined
2491
2514
  const session = this.claudeSessions.get(args.chatId)
2492
2515
  if (!session) return undefined
2493
2516
 
2494
2517
  const ghostTurn: HarnessTurn = {
2495
- provider: "claude",
2518
+ provider: args.provider,
2496
2519
  stream: { async *[Symbol.asyncIterator]() {} },
2497
2520
  getAccountInfo: session.session.getAccountInfo,
2498
2521
  interrupt: session.session.interrupt,
@@ -3089,6 +3112,56 @@ export class AgentCoordinator {
3089
3112
  }
3090
3113
 
3091
3114
  private async runClaudeSession(session: ClaudeSessionState) {
3115
+ // OpenRouter-only first-entry watchdog. OpenRouter routes through the
3116
+ // Claude SDK; a stalled upstream emits the session-token handshake then
3117
+ // goes silent — the stream stays open with no entry, so this for-await
3118
+ // never returns or throws and the chat hangs "running" until restart. The
3119
+ // existing catch/finally fail-close is claude-provider-gated and depends
3120
+ // on an active turn that the openrouter path tears down early, so the
3121
+ // watchdog records the failure itself, then interrupts + closes the
3122
+ // session to end the stream. `firstEntrySeen` guards against a late real
3123
+ // entry; close() prevents any further entry from being processed.
3124
+ const isOpenRouterSession = session.openrouterModel !== null
3125
+ let firstEntrySeen = false
3126
+ let firstEntryWatchdog: ReturnType<typeof setTimeout> | null = null
3127
+ const clearFirstEntryWatchdog = () => {
3128
+ if (firstEntryWatchdog !== null) {
3129
+ clearTimeout(firstEntryWatchdog)
3130
+ firstEntryWatchdog = null
3131
+ }
3132
+ }
3133
+ if (isOpenRouterSession) {
3134
+ firstEntryWatchdog = setTimeout(() => {
3135
+ if (firstEntrySeen) return
3136
+ if (this.claudeSessions.get(session.chatId) !== session) return
3137
+ firstEntrySeen = true
3138
+ const message = `OpenRouter produced no response within ${this.openrouterFirstEntryTimeoutMs}ms — the selected model may be invalid or the upstream stalled.`
3139
+ console.warn("[kanna/agent] openrouter stream produced no entry within watchdog window — failing turn", {
3140
+ chatId: session.chatId,
3141
+ sessionId: session.id,
3142
+ model: session.openrouterModel,
3143
+ timeoutMs: this.openrouterFirstEntryTimeoutMs,
3144
+ })
3145
+ void (async () => {
3146
+ await this.store.appendMessage(
3147
+ session.chatId,
3148
+ timestamped({
3149
+ kind: "result",
3150
+ subtype: "error",
3151
+ isError: true,
3152
+ durationMs: this.openrouterFirstEntryTimeoutMs,
3153
+ result: message,
3154
+ }),
3155
+ )
3156
+ await this.store.recordTurnFailed(session.chatId, message)
3157
+ const active = this.activeTurns.get(session.chatId)
3158
+ if (active) this.activeTurns.delete(session.chatId)
3159
+ this.emitStateChange(session.chatId)
3160
+ void session.session.interrupt().catch(() => {})
3161
+ session.session.close()
3162
+ })()
3163
+ }, this.openrouterFirstEntryTimeoutMs)
3164
+ }
3092
3165
  try {
3093
3166
  let simulateLimit = this.throwOnClaudeSessionStart
3094
3167
  for await (const event of session.session.stream) {
@@ -3119,6 +3192,8 @@ export class AgentCoordinator {
3119
3192
  }
3120
3193
 
3121
3194
  if (!event.entry) continue
3195
+ firstEntrySeen = true
3196
+ clearFirstEntryWatchdog()
3122
3197
  if (this.claudeSessions.get(session.chatId) !== session) break
3123
3198
  // Suppress the interrupt-induced tail `result` of a cancelled turn.
3124
3199
  // cancel() already removed the active turn, recorded the cancellation,
@@ -3337,6 +3412,7 @@ export class AgentCoordinator {
3337
3412
  }
3338
3413
  }
3339
3414
  } finally {
3415
+ clearFirstEntryWatchdog()
3340
3416
  const active = this.activeTurns.get(session.chatId)
3341
3417
  const isCurrentSession = this.claudeSessions.get(session.chatId) === session
3342
3418
  // Trace point: stream-end-without-final-result is the hang signature.
@@ -432,6 +432,10 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
432
432
  scheduleManager,
433
433
  maxAgentWakes: parsePositiveIntEnv(process.env.KANNA_MAX_AGENT_WAKES, 25),
434
434
  pendingWorkflowPollMs: parsePositiveIntEnv(process.env.KANNA_PENDING_WORKFLOW_POLL_MS, 120_000),
435
+ openrouterFirstEntryTimeoutMs: parsePositiveIntEnv(
436
+ process.env.KANNA_OPENROUTER_FIRST_ENTRY_TIMEOUT_MS,
437
+ 2 * 60 * 1000,
438
+ ),
435
439
  claudeLimitDetector: options.agentOverrides?.claudeLimitDetector,
436
440
  codexLimitDetector: options.agentOverrides?.codexLimitDetector,
437
441
  throwOnClaudeSessionStart: options.agentOverrides?.throwOnClaudeSessionStart,
@@ -425,6 +425,22 @@ export function getProviderCatalog(provider: AgentProvider): ProviderCatalogEntr
425
425
  return entry
426
426
  }
427
427
 
428
+ /**
429
+ * True when the provider's turns run through the Claude SDK session transport
430
+ * (a live `claudeSessions` entry consumed by `runClaudeSession`, prompts
431
+ * delivered via `session.sendPrompt`) rather than the generic harness-turn
432
+ * transport (`runTurn` over `active.turn.stream`).
433
+ *
434
+ * `claude` and `openrouter` both ride the SDK session — openrouter just points
435
+ * the SDK at OpenRouter's Anthropic-compatible endpoint. Branching on
436
+ * `provider === "claude"` where the real intent is "uses the SDK session" is
437
+ * what silently dropped openrouter's prompt delivery; use this predicate so a
438
+ * new SDK-backed provider can never be forgotten by an `if`-chain again.
439
+ */
440
+ export function providerUsesSdkSession(provider: AgentProvider): boolean {
441
+ return provider === "claude" || provider === "openrouter"
442
+ }
443
+
428
444
  function getProviderModelMatch(provider: AgentProvider, modelId?: string): ProviderModelOption | undefined {
429
445
  if (!modelId) return undefined
430
446
 
@@ -1 +0,0 @@
1
- import{U as a,D as n}from"./mermaid.core-CoJEFYct.js";const t=(r,o)=>a.lang.round(n.parse(r)[o]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-eMaVSOKi.js";import{_ as i}from"./mermaid.core-CoJEFYct.js";import"./chunk-FMBD7UC4-BtAWu6Fv.js";import"./chunk-ND2GUHAM-CyxbGgpO.js";import"./chunk-55IACEB6-HUFJLmvp.js";import"./chunk-2J33WTMH-C77Np9ft.js";import"./index-MixgFXRr.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-727SXJPM-eMaVSOKi.js";import{_ as i}from"./mermaid.core-CoJEFYct.js";import"./chunk-FMBD7UC4-BtAWu6Fv.js";import"./chunk-ND2GUHAM-CyxbGgpO.js";import"./chunk-55IACEB6-HUFJLmvp.js";import"./chunk-2J33WTMH-C77Np9ft.js";import"./index-MixgFXRr.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
@@ -1 +0,0 @@
1
- import{s as e,b as r,a,S as s}from"./chunk-AQP2D5EJ-Dqr7iBWK.js";import{_ as i}from"./mermaid.core-CoJEFYct.js";import"./chunk-55IACEB6-HUFJLmvp.js";import"./chunk-2J33WTMH-C77Np9ft.js";import"./index-MixgFXRr.js";var p={parser:a,get db(){return new s(2)},renderer:r,styles:e,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{p as diagram};