@loicngr/kobo 1.11.12 → 1.11.13

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 1.11.13
8
+
9
+ - fix(engines): unstick agent sessions that never emit session:ended
10
+
7
11
  ## 1.11.12
8
12
 
9
13
  - ✨ develop - feat(engines): Garder les sessions ouvertes pour les subagents en arrière-plan
@@ -20,6 +20,12 @@ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
20
20
  // without producing a result message. Keep a separate, conservative guard for
21
21
  // that shape; active tool/message traffic always cancels and re-arms it.
22
22
  const TEXT_IDLE_TIMEOUT_MS = 120_000;
23
+ // Safety net for a subagent whose terminal `task_notification` carries a
24
+ // status outside the mapper's known-terminal set (SDK schema drift) — it
25
+ // never clears `activeSubagentToolCallIds`, which otherwise blocks
26
+ // `inputStream.close()`/the result-drain watchdog forever. Generous window:
27
+ // legitimate subagents can run for several minutes.
28
+ const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
23
29
  const MAX_PENDING_USER_MESSAGES = 20;
24
30
  function toMcpServersMap(specs) {
25
31
  if (!specs || specs.length === 0)
@@ -278,6 +284,41 @@ export function createClaudeCodeEngine() {
278
284
  }, RESULT_DRAIN_TIMEOUT_MS);
279
285
  resultDrainTimer.unref?.();
280
286
  };
287
+ // Safety net for the "waiting on a background subagent" branch below:
288
+ // if `activeSubagentToolCallIds` never empties (a missed/unrecognised
289
+ // terminal notification), the workspace would otherwise stay `executing`
290
+ // forever. Force the drain through once this fires.
291
+ let subagentStallTimer;
292
+ const clearSubagentStallWatchdog = () => {
293
+ if (!subagentStallTimer)
294
+ return;
295
+ clearTimeout(subagentStallTimer);
296
+ subagentStallTimer = undefined;
297
+ };
298
+ // Re-armed (not merely armed-once) on every qualifying `result` so the
299
+ // deadline tracks the last observed activity, matching the warning
300
+ // message below — a long chain of legitimate subagent-backed turns
301
+ // must not be cut off just because 10 minutes have passed in total.
302
+ const armSubagentStallWatchdog = () => {
303
+ clearSubagentStallWatchdog();
304
+ subagentStallTimer = setTimeout(() => {
305
+ subagentStallTimer = undefined;
306
+ // A message queued between arming and firing means a new turn is
307
+ // about to start on this same stream — don't discard the subagent
308
+ // tracking or force-close under it; the next 'result' re-evaluates.
309
+ if (inputStream.hasUnansweredInput(completedResponses))
310
+ return;
311
+ console.warn(`[claude-engine] Subagent(s) still tracked active ${SUBAGENT_STALL_TIMEOUT_MS}ms after the last turn ended — forcing session drain.`);
312
+ activeSubagentToolCallIds.clear();
313
+ // This is a forced, unclean termination (the subagent may still be
314
+ // alive server-side) — never report it as a normal completion, so
315
+ // auto-loop doesn't treat an orphaned run as forward progress.
316
+ mapperState.sawErrorResult = true;
317
+ inputStream.close();
318
+ armResultDrainWatchdog();
319
+ }, SUBAGENT_STALL_TIMEOUT_MS);
320
+ subagentStallTimer.unref?.();
321
+ };
281
322
  const iteratorPromise = (async () => {
282
323
  iteratorRunning = true;
283
324
  try {
@@ -291,6 +332,17 @@ export function createClaudeCodeEngine() {
291
332
  else
292
333
  activeSubagentToolCallIds.delete(ev.toolCallId);
293
334
  }
335
+ // The stall watchdog is only armed while we're waiting purely on
336
+ // background subagents (no further SDK turn expected unless one
337
+ // reports done). React the moment the last one clears instead of
338
+ // waiting for a 'result' message that may never come.
339
+ if (subagentStallTimer &&
340
+ activeSubagentToolCallIds.size === 0 &&
341
+ !inputStream.hasUnansweredInput(completedResponses)) {
342
+ clearSubagentStallWatchdog();
343
+ inputStream.close();
344
+ armResultDrainWatchdog();
345
+ }
294
346
  for (const ev of events) {
295
347
  if (ev.kind === 'session:started')
296
348
  discoveredSessionId = ev.engineSessionId;
@@ -307,9 +359,15 @@ export function createClaudeCodeEngine() {
307
359
  clearTextIdleWatchdog();
308
360
  completedResponses++;
309
361
  // A queued forced message starts the next response on this same SDK stream.
310
- if (!inputStream.hasUnansweredInput(completedResponses) && activeSubagentToolCallIds.size === 0) {
311
- inputStream.close();
312
- armResultDrainWatchdog();
362
+ if (!inputStream.hasUnansweredInput(completedResponses)) {
363
+ if (activeSubagentToolCallIds.size === 0) {
364
+ clearSubagentStallWatchdog();
365
+ inputStream.close();
366
+ armResultDrainWatchdog();
367
+ }
368
+ else {
369
+ armSubagentStallWatchdog();
370
+ }
313
371
  }
314
372
  }
315
373
  }
@@ -354,6 +412,7 @@ export function createClaudeCodeEngine() {
354
412
  resultDrainTimer = undefined;
355
413
  }
356
414
  clearTextIdleWatchdog();
415
+ clearSubagentStallWatchdog();
357
416
  // Drain any callback still pending (SDK terminated while awaiting an
358
417
  // answer). canUseTool's abort path covers signalled stops; this
359
418
  // covers natural iterator completion.
@@ -391,6 +450,20 @@ export function createClaudeCodeEngine() {
391
450
  // subtype `error_during_execution` through the normal iterator —
392
451
  // the mapper needs this flag to treat it as a clean stop.
393
452
  mapperState.userInterrupted = true;
453
+ // The soft interrupt below only ends the foreground turn — a
454
+ // background subagent task keeps running unless told to stop via
455
+ // its own API, which would otherwise leave the Stop button
456
+ // appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
457
+ for (const taskId of activeSubagentToolCallIds) {
458
+ try {
459
+ void q.stopTask(taskId).catch(() => {
460
+ /* best-effort */
461
+ });
462
+ }
463
+ catch {
464
+ /* best-effort */
465
+ }
466
+ }
394
467
  const qq = q;
395
468
  if (typeof qq.interrupt === 'function') {
396
469
  try {
@@ -195,10 +195,12 @@ export function mapSdkMessage(msg, state) {
195
195
  const toolCallId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : undefined;
196
196
  if (toolCallId) {
197
197
  const usage = parsed.usage;
198
- const taskStatus = typeof parsed.status === 'string' ? parsed.status : undefined;
199
- const isDone = subtype === 'task_notification' &&
200
- taskStatus !== undefined &&
201
- ['completed', 'stopped', 'failed', 'cancelled'].includes(taskStatus);
198
+ // Unlike task_started/task_progress (in-flight signals), task_notification
199
+ // is only ever emitted once a task settles — its `status` value is a
200
+ // reason, not a liveness signal. Treat every task_notification as
201
+ // terminal instead of matching against a status whitelist, so an SDK
202
+ // status value added later doesn't leave the subagent stuck 'running'.
203
+ const isDone = subtype === 'task_notification';
202
204
  events.push({
203
205
  kind: 'subagent:progress',
204
206
  toolCallId,
@@ -11,6 +11,13 @@ import { createTurnLiveness } from './turn-liveness.js';
11
11
  /** Long enough for normal tool work, short enough to recover a lost turn event. */
12
12
  export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
13
13
  export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
14
+ // Safety net while `turnLiveness` is paused for background subagents: that
15
+ // pause is deliberately unbounded (a legitimate subagent can run long), but
16
+ // if a thread never reports a terminal status (dropped notification, or the
17
+ // sub-thread's own process hanging), nothing else would ever resume it or
18
+ // resolve `turnDonePromise` — the session would hang forever. Generous
19
+ // window, refreshed on every observed subagent-thread status change.
20
+ export const CODEX_SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
14
21
  function wait(ms) {
15
22
  return new Promise((resolve) => setTimeout(resolve, ms));
16
23
  }
@@ -108,6 +115,30 @@ export function createCodexEngine() {
108
115
  resolveTurnDone = resolve;
109
116
  rejectTurnDone = reject;
110
117
  });
118
+ let subagentStallTimer;
119
+ const clearSubagentStallWatchdog = () => {
120
+ if (!subagentStallTimer)
121
+ return;
122
+ clearTimeout(subagentStallTimer);
123
+ subagentStallTimer = undefined;
124
+ };
125
+ // Re-armed on every observed subagent-thread status change so the
126
+ // deadline tracks the last activity, not the start of the wait.
127
+ const armSubagentStallWatchdog = () => {
128
+ clearSubagentStallWatchdog();
129
+ subagentStallTimer = setTimeout(() => {
130
+ subagentStallTimer = undefined;
131
+ console.warn(`[codex-engine] Background subagent thread(s) still tracked active ${CODEX_SUBAGENT_STALL_TIMEOUT_MS}ms after the turn completed — forcing session drain.`);
132
+ activeSubagentThreads.clear();
133
+ // Forced, unclean termination (a thread may still be alive
134
+ // server-side) — never report it as a normal completion, so
135
+ // auto-loop doesn't treat an orphaned run as forward progress.
136
+ mapperState.sawErrorResult = true;
137
+ if (waitingForBackgroundSubagents)
138
+ resolveTurnDone();
139
+ }, CODEX_SUBAGENT_STALL_TIMEOUT_MS);
140
+ subagentStallTimer.unref?.();
141
+ };
111
142
  const finishBackgroundSubagent = (threadId, alreadyEmittedToolCallId) => {
112
143
  const tracked = activeSubagentThreads.get(threadId);
113
144
  if (!tracked)
@@ -122,8 +153,15 @@ export function createCodexEngine() {
122
153
  taskType: tracked.taskType,
123
154
  });
124
155
  }
125
- if (waitingForBackgroundSubagents && activeSubagentThreads.size === 0)
126
- resolveTurnDone();
156
+ if (waitingForBackgroundSubagents) {
157
+ if (activeSubagentThreads.size === 0) {
158
+ clearSubagentStallWatchdog();
159
+ resolveTurnDone();
160
+ }
161
+ else {
162
+ armSubagentStallWatchdog();
163
+ }
164
+ }
127
165
  };
128
166
  const turnLiveness = createTurnLiveness({
129
167
  timeoutMs: CODEX_TURN_IDLE_TIMEOUT_MS,
@@ -227,6 +265,7 @@ export function createCodexEngine() {
227
265
  if (n.turn?.status === 'completed' && activeSubagentThreads.size > 0) {
228
266
  waitingForBackgroundSubagents = true;
229
267
  turnLiveness.pause();
268
+ armSubagentStallWatchdog();
230
269
  }
231
270
  else {
232
271
  resolveTurnDone();
@@ -364,8 +403,21 @@ export function createCodexEngine() {
364
403
  }
365
404
  finally {
366
405
  turnLiveness.stop();
406
+ clearSubagentStallWatchdog();
367
407
  streamingBatcher.close();
368
408
  iteratorRunning = false;
409
+ // Drain any outstanding approval/elicitation request (SDK terminated
410
+ // while awaiting a human decision) — without a response, Codex's own
411
+ // process would otherwise wait forever for a reply that never comes.
412
+ for (const pending of pendingByCallId.values()) {
413
+ try {
414
+ client.peer.respondError(pending.requestId, -32000, 'session ended');
415
+ }
416
+ catch {
417
+ // best-effort
418
+ }
419
+ }
420
+ pendingByCallId.clear();
369
421
  client.close();
370
422
  try {
371
423
  child.kill('SIGTERM');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.11.12",
3
+ "version": "1.11.13",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",