@loicngr/kobo 2.1.11 → 2.1.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/AGENTS.md CHANGED
@@ -269,6 +269,18 @@ Two engines live under `src/server/services/agent/engines/`, both implementing t
269
269
 
270
270
  **Claude Code** (`claude-code/`): consumes the `@anthropic-ai/claude-agent-sdk` async iterator. The SDK manages the Claude runtime; Kōbō does not launch a separate `claude` CLI command. Authentication reuses the user's Claude login or `ANTHROPIC_API_KEY`. A settled result gets a **3 s continuation grace**, then a **15 s result-drain watchdog**. Trailing metadata and child output must not cancel the parent's drain/grace. Silence is bounded at **2 minutes** without tools, **30 minutes** for foreground tools and background shell/workflow/MCP jobs, and **10 minutes** for agents. Compaction has an absolute **10-minute** ceiling; repeated status messages cannot extend it. Human questions/permissions pause inactivity; resolution AND SDK cancellation re-evaluate that pause. Watchdogs request abort plus SDK `query.close()`; neither is proof of closure: controller ownership is released only when `EngineProcess.closed` confirms it. Never fake closure or allow a second writer if the SDK ignores termination. End emission is idempotent and timers are cleared on termination and in `finally`.
271
271
 
272
+ After the continuation grace, Claude explicitly calls `query.close()`; closing
273
+ the input iterator alone can leave the SDK waiting for an idle notification after
274
+ a successful result. The 15-second drain watchdog is a failed-close backstop,
275
+ not normal cleanup. The SDK still constructs its runtime command; Kōbō's
276
+ `spawnClaudeCodeProcess` hook tracks actual child exit and forwards stderr.
277
+ `EngineProcess.closed` waits for both that exit and iterator completion because
278
+ the SDK's internal cleanup wait is bounded and can return before child exit.
279
+ Liveness must also inspect non-empty SDK content deltas (text, thinking,
280
+ input JSON, signature), not just mapped UI events: long reasoning/tool argument
281
+ generation can otherwise be killed as idle. Do not count empty deltas or neutral
282
+ metadata, or let child deltas cancel a settled parent's drain/grace.
283
+
272
284
  **OpenAI Codex** (`codex/`): uses the **`codex app-server` JSON-RPC protocol** (line-delimited JSON over stdio with a long-lived `codex` subprocess). The engine layers are:
273
285
  - `jsonrpc/transport.ts` + `jsonrpc/peer.ts`: generic JSON-RPC 2.0 stdio peer (request correlation, notifications, server-initiated requests)
274
286
  - `client.ts`: typed `AppServerClient` wrapping the peer (initialize / thread.start / thread.resume / turn.start / turn.interrupt)
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ 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
+ ## 2.1.13
8
+
9
+ - fix(claude): count streaming generation as watchdog activity
10
+
11
+ ## 2.1.12
12
+
13
+ - fix(claude): close settled SDK sessions without false watchdogs
14
+
7
15
  ## 2.1.11
8
16
 
9
17
  - fix: harden agent lifecycle and workspace workflows
package/CONFIGURATION.md CHANGED
@@ -1273,14 +1273,24 @@ The model selector is populated from [`src/shared/models.ts`](./src/shared/model
1273
1273
  #### Session watchdogs
1274
1274
 
1275
1275
  Kōbō distinguishes a finished response from a fully closed SDK session. Once a
1276
- response is settled, it allows 3 seconds for continuation, then 15 seconds for
1277
- SDK cleanup. Metadata alone cannot extend this cleanup deadline.
1276
+ response is settled, it allows 3 seconds for continuation, then explicitly closes
1277
+ the SDK query. Normal cleanup is reported as completion, not a watchdog failure.
1278
+ A 15-second drain watchdog remains if that close fails to end the iterator.
1279
+ Metadata alone cannot extend this cleanup deadline. Writer ownership waits for
1280
+ both iterator completion and confirmed runtime exit, even if the SDK's own
1281
+ bounded cleanup returns earlier.
1278
1282
 
1279
1283
  Silence limits are 2 minutes without a pending tool, 10 minutes for background
1280
1284
  agents, and 30 minutes for tools (including background Bash, workflow and MCP
1281
1285
  jobs). A compaction has a separate absolute 10-minute limit. Waiting for your
1282
1286
  answer or permission pauses the inactivity watchdog.
1283
1287
 
1288
+ Non-empty streaming text, reasoning, tool-argument JSON and signature fragments
1289
+ count as activity even when they are not displayed in chat. Empty deltas and
1290
+ neutral metadata do not reset inactivity, and child output cannot postpone a
1291
+ settled parent's closure. The inactivity limit measures silence, not total time
1292
+ spent generating a large plan or file.
1293
+
1284
1294
  Watchdog notices remain in conversation history, but do not display a red error
1285
1295
  banner. Other failures retain their banners. Watchdog shutdown requests both
1286
1296
  cancellation and closure of the SDK transport. Kōbō still requires confirmed
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { query, } from '@anthropic-ai/claude-agent-sdk';
3
4
  import { nanoid } from 'nanoid';
@@ -144,6 +145,24 @@ class ClaudeInputStream {
144
145
  return { type: 'user', uuid: randomUUID(), message: { role: 'user', content: text }, parent_tool_use_id: null };
145
146
  }
146
147
  }
148
+ /** Generation can be active without any user-visible mapped event. */
149
+ function hasStreamingContent(msg) {
150
+ if (msg.type !== 'stream_event' || msg.event.type !== 'content_block_delta')
151
+ return false;
152
+ const delta = msg.event.delta;
153
+ switch (delta.type) {
154
+ case 'text_delta':
155
+ return delta.text.length > 0;
156
+ case 'thinking_delta':
157
+ return delta.thinking.length > 0;
158
+ case 'input_json_delta':
159
+ return delta.partial_json.length > 0;
160
+ case 'signature_delta':
161
+ return delta.signature.length > 0;
162
+ default:
163
+ return false;
164
+ }
165
+ }
147
166
  export function createClaudeCodeEngine() {
148
167
  return {
149
168
  id: 'claude-code',
@@ -327,6 +346,31 @@ export function createClaudeCodeEngine() {
327
346
  env: options.env,
328
347
  });
329
348
  sdkOptions.abortController = abortController;
349
+ // SDK cleanup has its own bounded wait. Iterator completion alone does
350
+ // not prove that its runtime exited: retain writer ownership until both.
351
+ let runtimeRunning = false;
352
+ let runtimeClosed = Promise.resolve();
353
+ sdkOptions.spawnClaudeCodeProcess = ({ command, args, cwd, env, signal }) => {
354
+ const child = spawn(command, args, { cwd, env, signal, windowsHide: true, stdio: 'pipe' });
355
+ runtimeRunning = true;
356
+ runtimeClosed = new Promise((resolve) => {
357
+ const exited = () => {
358
+ runtimeRunning = false;
359
+ resolve();
360
+ };
361
+ child.once('exit', exited);
362
+ child.once('error', () => {
363
+ // A failed spawn has no process; an abort/kill error does not
364
+ // establish that an already spawned runtime has actually exited.
365
+ if (child.pid === undefined)
366
+ exited();
367
+ });
368
+ });
369
+ child.stderr.setEncoding('utf8');
370
+ child.stderr.on('data', (data) => sdkOptions.stderr?.(data));
371
+ child.stderr.on('error', () => { });
372
+ return child;
373
+ };
330
374
  // Override the SDK's libc-blind binary resolution on Linux glibc — see
331
375
  // resolve-binary.ts for the full rationale. No-op on macOS/Windows/musl.
332
376
  const explicitBinary = resolveClaudeBinaryPath();
@@ -395,6 +439,9 @@ export function createClaudeCodeEngine() {
395
439
  const safeEmit = (ev) => streamingBatcher.push(ev);
396
440
  let iteratorRunning = false;
397
441
  let userInterrupted = false;
442
+ // Normal post-result disposal is distinct from a watchdog/user abort.
443
+ // The SDK can wait for an `idle` state even after stdin's iterable ends.
444
+ let settledCloseRequested = false;
398
445
  let completedResponses = 0;
399
446
  let waitingForBackground = false;
400
447
  let turnCompletedEmittedForResponse = 0;
@@ -556,7 +603,9 @@ export function createClaudeCodeEngine() {
556
603
  clearContinuationGrace();
557
604
  continuationGraceTimer = setTimeout(() => {
558
605
  continuationGraceTimer = undefined;
559
- if (activeSubagentTaskIds.size > 0 ||
606
+ if (completedResponses === 0 ||
607
+ !settlementPending ||
608
+ activeSubagentTaskIds.size > 0 ||
560
609
  pendingResolvers.size > 0 ||
561
610
  pendingToolCallIds.size > 0 ||
562
611
  isCompacting ||
@@ -567,6 +616,20 @@ export function createClaudeCodeEngine() {
567
616
  emitTurnCompletedIfSettled();
568
617
  inputStream.close();
569
618
  armResultDrainWatchdog();
619
+ // Closing the input iterable alone leaves the SDK in waitForRunEnd
620
+ // when its last session state is still running. Dispose explicitly,
621
+ // but let the iterator confirm actual closure before session:ended.
622
+ // The drain watchdog now guards a FAILED close, not normal cleanup.
623
+ if (!settledCloseRequested && typeof q.close === 'function') {
624
+ settledCloseRequested = true;
625
+ try {
626
+ q.close();
627
+ }
628
+ catch (error) {
629
+ settledCloseRequested = false;
630
+ console.warn('[claude-engine] Post-result SDK close failed:', error);
631
+ }
632
+ }
570
633
  }, delayMs);
571
634
  continuationGraceTimer.unref?.();
572
635
  };
@@ -616,11 +679,13 @@ export function createClaudeCodeEngine() {
616
679
  for await (const msg of q) {
617
680
  const events = mapSdkMessage(msg, mapperState);
618
681
  const isForeground = !('parent_tool_use_id' in msg && msg.parent_tool_use_id != null);
682
+ const streamingContent = hasStreamingContent(msg);
619
683
  const foregroundProgress = isForeground &&
620
- events.some((ev) => ev.kind === 'message:text' ||
621
- ev.kind === 'message:thinking' ||
622
- ev.kind === 'tool:call' ||
623
- ev.kind === 'tool:result');
684
+ (streamingContent ||
685
+ events.some((ev) => ev.kind === 'message:text' ||
686
+ ev.kind === 'message:thinking' ||
687
+ ev.kind === 'tool:call' ||
688
+ ev.kind === 'tool:result'));
624
689
  // A parent continuation owns the foreground again. Background
625
690
  // progress notifications alone do not end the between-turn wait.
626
691
  if (foregroundProgress) {
@@ -757,7 +822,12 @@ export function createClaudeCodeEngine() {
757
822
  abortController.signal.aborted ||
758
823
  /aborted by user|process aborted|abortError|ede_diagnostic/i.test(error.message ?? '');
759
824
  if (isAbort) {
760
- emitSessionEnded('killed', null);
825
+ if (settledCloseRequested && !userInterrupted && !abortController.signal.aborted) {
826
+ const reason = mapperState.sawErrorResult ? 'error' : 'completed';
827
+ emitSessionEnded(reason, reason === 'completed' ? 0 : null);
828
+ }
829
+ else
830
+ emitSessionEnded('killed', null);
761
831
  }
762
832
  else {
763
833
  safeEmit({
@@ -797,7 +867,7 @@ export function createClaudeCodeEngine() {
797
867
  })();
798
868
  const engineProcess = {
799
869
  ready,
800
- closed: iteratorPromise.then(() => { }),
870
+ closed: iteratorPromise.then(() => runtimeClosed),
801
871
  get pid() {
802
872
  return undefined;
803
873
  },
@@ -805,10 +875,10 @@ export function createClaudeCodeEngine() {
805
875
  return discoveredSessionId;
806
876
  },
807
877
  isAlive() {
808
- return iteratorRunning;
878
+ return iteratorRunning || runtimeRunning;
809
879
  },
810
880
  sendMessage(text) {
811
- if (!iteratorRunning || queryCloseRequested || sessionEndedEmitted)
881
+ if (!iteratorRunning || queryCloseRequested || settledCloseRequested || sessionEndedEmitted)
812
882
  throw new Error(`Claude ${AGENT_NO_LONGER_RUNNING_TEXT}`);
813
883
  inputStream.send(text);
814
884
  armSubagentStallWatchdog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "2.1.11",
3
+ "version": "2.1.13",
4
4
  "description": "Kōbō — local multi-workspace orchestrator for Claude Code and OpenAI Codex, with isolated Git worktrees, live review and optional automation.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",