@loicngr/kobo 1.11.13 → 1.11.15

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 (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/mcp-server/kobo-tasks-handlers.js +26 -3
  3. package/dist/mcp-server/kobo-tasks-server.js +27 -0
  4. package/dist/server/db/migrations.js +13 -0
  5. package/dist/server/db/schema.js +2 -1
  6. package/dist/server/index.js +5 -7
  7. package/dist/server/routes/export.js +2 -1
  8. package/dist/server/routes/workspaces.js +15 -0
  9. package/dist/server/services/agent/engines/claude-code/engine.js +38 -11
  10. package/dist/server/services/agent/engines/claude-code/event-mapper.js +3 -1
  11. package/dist/server/services/agent/engines/claude-code/options-builder.js +2 -1
  12. package/dist/server/services/agent/engines/codex/engine.js +2 -0
  13. package/dist/server/services/agent/engines/codex/options-builder.js +2 -1
  14. package/dist/server/services/agent/engines/types.js +1 -0
  15. package/dist/server/services/agent/orchestrator.js +114 -17
  16. package/dist/server/services/auto-loop-service.js +6 -0
  17. package/dist/server/services/cron-service.js +2 -1
  18. package/dist/server/services/dev-server-service.js +13 -1
  19. package/dist/server/services/wakeup-service.js +14 -12
  20. package/dist/server/utils/git-ops.js +3 -2
  21. package/dist/server/utils/safe-path.js +34 -0
  22. package/package.json +3 -3
  23. package/src/client/dist/pwa/assets/{ActivityFeed-Ds8cAMNc.js → ActivityFeed-C7NLbzD6.js} +1 -1
  24. package/src/client/dist/pwa/assets/{ChangelogPage-DO6DNO4Q.js → ChangelogPage-DwWh9JgQ.js} +1 -1
  25. package/src/client/dist/pwa/assets/{ClosePopup-DuAqn8Pb.js → ClosePopup-B6KJBZyG.js} +1 -1
  26. package/src/client/dist/pwa/assets/{CreatePage-BnMSo_79.js → CreatePage-pyI9OKPs.js} +1 -1
  27. package/src/client/dist/pwa/assets/{DiffViewer-CMnFGReI.js → DiffViewer-DuNeI5C3.js} +3 -3
  28. package/src/client/dist/pwa/assets/{HealthPage-BRAxQKSP.js → HealthPage-CCJBw0MT.js} +1 -1
  29. package/src/client/dist/pwa/assets/{MainLayout-BN1ug95K.js → MainLayout-CaVTdsZl.js} +2 -2
  30. package/src/client/dist/pwa/assets/{QBtnToggle-DsCDNQzX.js → QBtnToggle-CadHfv93.js} +1 -1
  31. package/src/client/dist/pwa/assets/{QChip-DWGDTZnX.js → QChip-CktOt0oC.js} +1 -1
  32. package/src/client/dist/pwa/assets/{QSelect-8Ip46vhk.js → QSelect-C0yTmkrq.js} +1 -1
  33. package/src/client/dist/pwa/assets/{QTooltip-Bz4g3bmr.js → QTooltip-Cho7yoPz.js} +1 -1
  34. package/src/client/dist/pwa/assets/{SearchPage-BD53VJEr.js → SearchPage-DmfKy4pY.js} +1 -1
  35. package/src/client/dist/pwa/assets/{SettingsPage-3rYdJZga.js → SettingsPage-B-CtW1eE.js} +1 -1
  36. package/src/client/dist/pwa/assets/{TouchPan-BKu8V4eN.js → TouchPan-BAN0KuNq.js} +1 -1
  37. package/src/client/dist/pwa/assets/{WorkspacePage-CbC_t5Rx.js → WorkspacePage-DHXPtEsY.js} +3 -3
  38. package/src/client/dist/pwa/assets/{cssMode-vaMVESGy.js → cssMode-DXZAn6vO.js} +1 -1
  39. package/src/client/dist/pwa/assets/{documents-CyKQ9lT6.js → documents-B23TsgeA.js} +1 -1
  40. package/src/client/dist/pwa/assets/{editor.api-CH3dhpKq.js → editor.api-A6JQ_eAF.js} +1 -1
  41. package/src/client/dist/pwa/assets/{expand-template-DQQQftsk.js → expand-template-CFOxSpLI.js} +1 -1
  42. package/src/client/dist/pwa/assets/{freemarker2-deLpERsj.js → freemarker2-CUIeOWM8.js} +1 -1
  43. package/src/client/dist/pwa/assets/{handlebars-DdjS05Tc.js → handlebars-Cyql1jca.js} +1 -1
  44. package/src/client/dist/pwa/assets/{html-pK1ZEFvP.js → html-tH-6-_8T.js} +1 -1
  45. package/src/client/dist/pwa/assets/{htmlMode-DJFDI9Ru.js → htmlMode-VmrCyH2a.js} +1 -1
  46. package/src/client/dist/pwa/assets/i18n-Ds8Ww7mW.js +1 -0
  47. package/src/client/dist/pwa/assets/{index-B_OmZ65F.js → index-BvLZetuF.js} +3 -3
  48. package/src/client/dist/pwa/assets/{javascript-DbuFfxco.js → javascript-CDVRMaa9.js} +1 -1
  49. package/src/client/dist/pwa/assets/{jsonMode-B0Fu8Dcc.js → jsonMode-Ck7yXgSl.js} +1 -1
  50. package/src/client/dist/pwa/assets/{kobo-commands-rb43ZeWG.js → kobo-commands-DmKgSiYn.js} +1 -1
  51. package/src/client/dist/pwa/assets/{layout-tAFrvQ5V.js → layout-Bx3LBcNr.js} +1 -1
  52. package/src/client/dist/pwa/assets/{liquid-DqDIKOCy.js → liquid-DuPNGLDX.js} +1 -1
  53. package/src/client/dist/pwa/assets/{lspLanguageFeatures-CVcEibfJ.js → lspLanguageFeatures-nffl_SEi.js} +1 -1
  54. package/src/client/dist/pwa/assets/{mdx-Bs4CovhI.js → mdx-0sVmmIxp.js} +1 -1
  55. package/src/client/dist/pwa/assets/{network-auth-DYrrh7Zr.js → network-auth-Chk1CKmH.js} +1 -1
  56. package/src/client/dist/pwa/assets/{pending-diff-open-D1IYH8Pd.js → pending-diff-open-5x3bHt_s.js} +1 -1
  57. package/src/client/dist/pwa/assets/{project-color-DO6uj29M.js → project-color-DPlEC0w5.js} +1 -1
  58. package/src/client/dist/pwa/assets/{python-BJoP0uN-.js → python-C7z_oSTh.js} +1 -1
  59. package/src/client/dist/pwa/assets/{razor-cwD-YBcr.js → razor-Y5ev_nkW.js} +1 -1
  60. package/src/client/dist/pwa/assets/{render-chat-markdown-DZnIueUV.js → render-chat-markdown-Btg3ez9I.js} +1 -1
  61. package/src/client/dist/pwa/assets/{tsMode-of3oS9sU.js → tsMode-RLBeqCG7.js} +1 -1
  62. package/src/client/dist/pwa/assets/{typescript-BTWMoUxn.js → typescript-DLXsl8qJ.js} +1 -1
  63. package/src/client/dist/pwa/assets/{use-onboarding-BTFkRBjE.js → use-onboarding-B-38VMWL.js} +1 -1
  64. package/src/client/dist/pwa/assets/{use-quasar-C1RCDSb6.js → use-quasar-BBt-28X1.js} +1 -1
  65. package/src/client/dist/pwa/assets/{vs-DDeaNKsH.js → vs-Ddu-KQME.js} +3 -3
  66. package/src/client/dist/pwa/assets/{workers-DQnKuGcq.js → workers-Dt0XxgF3.js} +1 -1
  67. package/src/client/dist/pwa/assets/{xml-RglGWD_3.js → xml-CNH2EfiJ.js} +1 -1
  68. package/src/client/dist/pwa/assets/{yaml-CSqGQpai.js → yaml-FpVlkkq1.js} +1 -1
  69. package/src/client/dist/pwa/index.html +1 -1
  70. package/src/client/dist/pwa/sw.js +1 -1
  71. package/src/mcp-server/kobo-tasks-handlers.ts +24 -4
  72. package/src/mcp-server/kobo-tasks-server.ts +27 -0
  73. package/src/client/dist/pwa/assets/i18n-CK38o-IF.js +0 -1
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
+ ## 1.11.15
8
+
9
+ - feat(agent): harden autonomous session lifecycle
10
+
11
+ ## 1.11.14
12
+
13
+ - ⬆️ develop - refactor(deps): Bump SDKs + refactor shutdown + hardening sécurité
14
+
7
15
  ## 1.11.13
8
16
 
9
17
  - fix(engines): unstick agent sessions that never emit session:ended
@@ -4,6 +4,7 @@ import { nanoid } from 'nanoid';
4
4
  import * as cronService from '../server/services/cron-service.js';
5
5
  import * as settingsService from '../server/services/settings-service.js';
6
6
  import { slugifyProjectName } from '../server/utils/project-slug.js';
7
+ import { resolveExistingPathInside } from '../server/utils/safe-path.js';
7
8
  import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
8
9
  /** Allowed task status values. */
9
10
  export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
@@ -296,6 +297,7 @@ export function getTicketSourcesHandler(worktreePath) {
296
297
  export const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
297
298
  /** Depth cap to keep recursion bounded even on pathological symlink loops. */
298
299
  const DOC_MAX_DEPTH = 8;
300
+ const DOC_MAX_FILE_BYTES = 1024 * 1024;
299
301
  function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
300
302
  if (depth > DOC_MAX_DEPTH)
301
303
  return;
@@ -313,11 +315,13 @@ function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
313
315
  const relEntry = `${rootRel}/${entry}`;
314
316
  let stat;
315
317
  try {
316
- stat = fs.statSync(absEntry);
318
+ stat = fs.lstatSync(absEntry);
317
319
  }
318
320
  catch {
319
321
  continue;
320
322
  }
323
+ if (stat.isSymbolicLink())
324
+ continue;
321
325
  if (stat.isDirectory()) {
322
326
  walkMarkdownFiles(absEntry, relEntry, out, depth + 1);
323
327
  }
@@ -336,7 +340,14 @@ export function listDocumentsHandler(worktreePath) {
336
340
  const absDir = path.join(worktreePath, dir);
337
341
  if (!fs.existsSync(absDir))
338
342
  continue;
339
- walkMarkdownFiles(absDir, dir, documents);
343
+ try {
344
+ if (fs.lstatSync(absDir).isSymbolicLink())
345
+ continue;
346
+ walkMarkdownFiles(resolveExistingPathInside(worktreePath, absDir), dir, documents);
347
+ }
348
+ catch {
349
+ // A document directory outside the worktree is never eligible.
350
+ }
340
351
  }
341
352
  documents.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
342
353
  return documents;
@@ -361,7 +372,19 @@ export function readDocumentHandler(worktreePath, relPath) {
361
372
  if (!fs.existsSync(abs)) {
362
373
  throw new Error(`Document not found: ${normalized}`);
363
374
  }
364
- return { path: normalized, content: fs.readFileSync(abs, 'utf-8') };
375
+ const lstat = fs.lstatSync(abs);
376
+ if (lstat.isSymbolicLink()) {
377
+ // Resolve for a consistent error if the link escapes; internal links are
378
+ // rejected too so list/read share the same no-symlink contract.
379
+ resolveExistingPathInside(worktreePath, abs);
380
+ throw new Error('Symbolic links are not allowed in documents');
381
+ }
382
+ const safeAbs = resolveExistingPathInside(worktreePath, abs);
383
+ const stat = fs.statSync(safeAbs);
384
+ if (stat.size > DOC_MAX_FILE_BYTES) {
385
+ throw new Error(`Document too large (max ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB)`);
386
+ }
387
+ return { path: normalized, content: fs.readFileSync(safeAbs, 'utf-8') };
365
388
  }
366
389
  /**
367
390
  * Append a thought / decision / note to
@@ -188,6 +188,21 @@ const WORKSPACE_SCOPED_TOOLS = [
188
188
  inputSchema: { type: 'object', properties: {}, required: [] },
189
189
  annotations: { readOnlyHint: true, openWorldHint: false },
190
190
  },
191
+ {
192
+ name: 'set_auto_loop',
193
+ description: 'Enable or disable auto-loop for THIS workspace. Read get_workspace_info first. Enable it only when the user explicitly asks for autonomous continuation and pending tasks remain; disable it only when the user explicitly asks. Kōbō resumes the next iteration automatically after a session ends while it is enabled.',
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ enabled: {
198
+ type: 'boolean',
199
+ description: 'True to enable auto-loop; false to disable it.',
200
+ },
201
+ },
202
+ required: ['enabled'],
203
+ },
204
+ annotations: { destructiveHint: false, openWorldHint: false },
205
+ },
191
206
  {
192
207
  name: 'set_workspace_agent_description',
193
208
  description: "Set or clear the workspace's agent-side description (≤ 200 chars). Pass an empty string to clear. The user sees this string under the workspace title in the sidebar (it takes precedence over the user-controlled `description` field). Plain text only. The current value is available via get_workspace_info as agentDescription. NOTE: there is a separate user-controlled `description` field — do NOT try to write it; it has no MCP tool.",
@@ -641,6 +656,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
641
656
  if (name === 'get_workspace_info') {
642
657
  return ok(getWorkspaceInfoHandler(db, workspaceId));
643
658
  }
659
+ if (name === 'set_auto_loop') {
660
+ if (typeof a.enabled !== 'boolean')
661
+ return fail('enabled parameter is required and must be boolean');
662
+ try {
663
+ const method = a.enabled ? 'POST' : 'DELETE';
664
+ const result = await backendRequest(method, `/api/workspaces/${workspaceId}/auto-loop`);
665
+ return ok(result);
666
+ }
667
+ catch (err) {
668
+ return fail(err instanceof Error ? err.message : String(err));
669
+ }
670
+ }
644
671
  if (name === 'set_workspace_agent_description') {
645
672
  const description = a.description;
646
673
  if (typeof description !== 'string')
@@ -591,6 +591,19 @@ export const migrations = [
591
591
  `);
592
592
  },
593
593
  },
594
+ {
595
+ version: 35,
596
+ name: 'add-agent-session-task-progress-baseline',
597
+ migrate: (db) => {
598
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'agent_sessions'").get();
599
+ if (!table)
600
+ return;
601
+ const columns = db.prepare('PRAGMA table_info(agent_sessions)').all();
602
+ if (!columns.some((column) => column.name === 'task_progress_baseline')) {
603
+ db.exec('ALTER TABLE agent_sessions ADD COLUMN task_progress_baseline TEXT');
604
+ }
605
+ },
606
+ },
594
607
  ];
595
608
  /** Current schema version — always equals the highest migration version. */
596
609
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -62,7 +62,8 @@ export function initSchema(db) {
62
62
  model TEXT,
63
63
  started_at TEXT NOT NULL,
64
64
  ended_at TEXT,
65
- name TEXT
65
+ name TEXT,
66
+ task_progress_baseline TEXT
66
67
  );
67
68
 
68
69
  CREATE TABLE IF NOT EXISTS ws_events (
@@ -25,12 +25,12 @@ import templatesRouter from './routes/templates.js';
25
25
  import usageRoutes from './routes/usage.js';
26
26
  import voiceRouter from './routes/voice.js';
27
27
  import workspacesRouter from './routes/workspaces.js';
28
- import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
28
+ import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
29
29
  import * as autoLoopService from './services/auto-loop-service.js';
30
30
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
31
31
  import * as cronService from './services/cron-service.js';
32
32
  import { createDailyDbBackupIfNeeded, createPreMigrationBackup } from './services/db-backup-service.js';
33
- import { startDevServer, stopDevServer } from './services/dev-server-service.js';
33
+ import { startDevServer, stopAllDevServers, stopDevServer } from './services/dev-server-service.js';
34
34
  import { authorizeWsUpgrade, generateToken, getLanUrls, resolveBindHost, resolveNetworkAccessEnvOverrides, } from './services/network-access-service.js';
35
35
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
36
36
  import * as quotaBackoffService from './services/quota-backoff-service.js';
@@ -42,7 +42,6 @@ import * as wakeupService from './services/wakeup-service.js';
42
42
  import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
43
43
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
44
44
  import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
45
- import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
46
45
  console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
47
46
  // Initialize DB + run migrations
48
47
  const db = getDb();
@@ -72,7 +71,6 @@ void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
72
71
  }
73
72
  });
74
73
  // Initialize process cleanup, agent watchdog, PR watcher, and wakeup rehydration
75
- initProcessCleanup();
76
74
  reconcileOrphanSessions();
77
75
  startWatchdog();
78
76
  wakeupService.rehydrate();
@@ -490,10 +488,10 @@ async function gracefulShutdown(signal) {
490
488
  catch {
491
489
  // Best-effort
492
490
  }
493
- // Kill all tracked child processes (agents, dev servers)
491
+ // Stop managed agents and both direct/Docker dev servers before closing DB.
494
492
  try {
495
- killAllTrackedProcesses();
496
- console.log('[kobo] Tracked processes killed');
493
+ await Promise.all([stopAllAgents(), stopAllDevServers()]);
494
+ console.log('[kobo] Agents and dev servers stopped');
497
495
  }
498
496
  catch {
499
497
  // Best-effort
@@ -5,7 +5,8 @@ import * as workspaceService from '../services/workspace-service.js';
5
5
  const app = new Hono();
6
6
  /** Quote a CSV cell when it contains a delimiter, quote, or newline (RFC 4180). */
7
7
  function csvCell(value) {
8
- return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
8
+ const safeValue = /^[\s]*[=+\-@]/.test(value) ? `'${value}` : value;
9
+ return /[",\r\n]/.test(safeValue) ? `"${safeValue.replace(/"/g, '""')}"` : safeValue;
9
10
  }
10
11
  function csvRow(values) {
11
12
  return values.map(csvCell).join(',');
@@ -42,6 +42,7 @@ import * as worktreeService from '../services/worktree-service.js';
42
42
  import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
43
43
  import * as gitOps from '../utils/git-ops.js';
44
44
  import { slugifyProjectName } from '../utils/project-slug.js';
45
+ import * as safePath from '../utils/safe-path.js';
45
46
  import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
46
47
  /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
47
48
  const app = new Hono();
@@ -2692,6 +2693,13 @@ app.get('/:id/diff-file', (c) => {
2692
2693
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2693
2694
  }
2694
2695
  const worktreePath = workspace.worktreePath;
2696
+ try {
2697
+ safePath.assertPathInside(worktreePath, filePath);
2698
+ }
2699
+ catch (err) {
2700
+ const message = err instanceof Error ? err.message : String(err);
2701
+ return c.json({ error: message }, 400);
2702
+ }
2695
2703
  if (c.req.query('mode') === 'commits') {
2696
2704
  const from = c.req.query('from');
2697
2705
  const to = c.req.query('to');
@@ -2750,6 +2758,13 @@ app.post('/:id/rollback-file', async (c) => {
2750
2758
  if (!filePath) {
2751
2759
  return c.json({ error: 'Missing or invalid `path` field' }, 400);
2752
2760
  }
2761
+ try {
2762
+ safePath.assertPathInside(workspace.worktreePath, filePath);
2763
+ }
2764
+ catch (err) {
2765
+ const message = err instanceof Error ? err.message : String(err);
2766
+ return c.json({ error: message }, 400);
2767
+ }
2753
2768
  let target;
2754
2769
  try {
2755
2770
  target = gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
@@ -97,7 +97,9 @@ export function createClaudeCodeEngine() {
97
97
  async start(options, onEvent) {
98
98
  const abortController = new AbortController();
99
99
  const mapperState = createMapperState();
100
- const activeSubagentToolCallIds = new Set();
100
+ // UI/tool ids can differ from the SDK task id. Key by the stable UI id
101
+ // so a terminal notification that omits task_id still clears the task.
102
+ const activeSubagentTaskIds = new Map();
101
103
  // Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
102
104
  const pendingResolvers = new Map();
103
105
  const isInteractive = options.agentPermissionMode === 'interactive';
@@ -232,6 +234,22 @@ export function createClaudeCodeEngine() {
232
234
  let iteratorRunning = false;
233
235
  let userInterrupted = false;
234
236
  let completedResponses = 0;
237
+ let turnCompletedEmittedForResponse = 0;
238
+ // `result` is the Claude Agent SDK's per-turn completion signal. Keep
239
+ // it distinct from iterator completion: the SDK may still drain
240
+ // informational events afterwards, while Kōbō must retain the session
241
+ // for orchestration and auto-loop until `session:ended`.
242
+ const emitTurnCompletedIfSettled = () => {
243
+ if (completedResponses === 0 ||
244
+ completedResponses === turnCompletedEmittedForResponse ||
245
+ activeSubagentTaskIds.size > 0 ||
246
+ pendingResolvers.size > 0 ||
247
+ inputStream.hasUnansweredInput(completedResponses)) {
248
+ return;
249
+ }
250
+ turnCompletedEmittedForResponse = completedResponses;
251
+ safeEmit({ kind: 'turn:completed' });
252
+ };
235
253
  // Guard so the post-result drain watchdog and the natural loop exit (or
236
254
  // catch block) never both emit `session:ended` for the same run.
237
255
  let sessionEndedEmitted = false;
@@ -267,8 +285,12 @@ export function createClaudeCodeEngine() {
267
285
  return;
268
286
  }
269
287
  console.warn(`[claude-engine] SDK stream inactive ${TEXT_IDLE_TIMEOUT_MS}ms after a text-only response — forcing session:ended`);
270
- const reason = userInterrupted ? 'killed' : mapperState.sawErrorResult ? 'error' : 'completed';
271
- emitSessionEnded(reason, reason === 'completed' ? 0 : null);
288
+ if (userInterrupted)
289
+ emitSessionEnded('killed', null);
290
+ else if (mapperState.sawErrorResult)
291
+ emitSessionEnded('error', null);
292
+ else
293
+ emitSessionEnded('watchdog', null);
272
294
  abortController.abort();
273
295
  }, TEXT_IDLE_TIMEOUT_MS);
274
296
  textIdleTimer.unref?.();
@@ -278,8 +300,12 @@ export function createClaudeCodeEngine() {
278
300
  return;
279
301
  resultDrainTimer = setTimeout(() => {
280
302
  console.warn(`[claude-engine] SDK generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' — forcing session:ended`);
281
- const reason = userInterrupted ? 'killed' : mapperState.sawErrorResult ? 'error' : 'completed';
282
- emitSessionEnded(reason, reason === 'completed' ? 0 : null);
303
+ if (userInterrupted)
304
+ emitSessionEnded('killed', null);
305
+ else if (mapperState.sawErrorResult)
306
+ emitSessionEnded('error', null);
307
+ else
308
+ emitSessionEnded('watchdog', null);
283
309
  abortController.abort();
284
310
  }, RESULT_DRAIN_TIMEOUT_MS);
285
311
  resultDrainTimer.unref?.();
@@ -309,7 +335,7 @@ export function createClaudeCodeEngine() {
309
335
  if (inputStream.hasUnansweredInput(completedResponses))
310
336
  return;
311
337
  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();
338
+ activeSubagentTaskIds.clear();
313
339
  // This is a forced, unclean termination (the subagent may still be
314
340
  // alive server-side) — never report it as a normal completion, so
315
341
  // auto-loop doesn't treat an orphaned run as forward progress.
@@ -328,16 +354,16 @@ export function createClaudeCodeEngine() {
328
354
  if (ev.kind !== 'subagent:progress')
329
355
  continue;
330
356
  if (ev.status === 'running')
331
- activeSubagentToolCallIds.add(ev.toolCallId);
357
+ activeSubagentTaskIds.set(ev.toolCallId, ev.taskId ?? ev.toolCallId);
332
358
  else
333
- activeSubagentToolCallIds.delete(ev.toolCallId);
359
+ activeSubagentTaskIds.delete(ev.toolCallId);
334
360
  }
335
361
  // The stall watchdog is only armed while we're waiting purely on
336
362
  // background subagents (no further SDK turn expected unless one
337
363
  // reports done). React the moment the last one clears instead of
338
364
  // waiting for a 'result' message that may never come.
339
365
  if (subagentStallTimer &&
340
- activeSubagentToolCallIds.size === 0 &&
366
+ activeSubagentTaskIds.size === 0 &&
341
367
  !inputStream.hasUnansweredInput(completedResponses)) {
342
368
  clearSubagentStallWatchdog();
343
369
  inputStream.close();
@@ -358,9 +384,10 @@ export function createClaudeCodeEngine() {
358
384
  if (msg.type === 'result') {
359
385
  clearTextIdleWatchdog();
360
386
  completedResponses++;
387
+ emitTurnCompletedIfSettled();
361
388
  // A queued forced message starts the next response on this same SDK stream.
362
389
  if (!inputStream.hasUnansweredInput(completedResponses)) {
363
- if (activeSubagentToolCallIds.size === 0) {
390
+ if (activeSubagentTaskIds.size === 0) {
364
391
  clearSubagentStallWatchdog();
365
392
  inputStream.close();
366
393
  armResultDrainWatchdog();
@@ -454,7 +481,7 @@ export function createClaudeCodeEngine() {
454
481
  // background subagent task keeps running unless told to stop via
455
482
  // its own API, which would otherwise leave the Stop button
456
483
  // appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
457
- for (const taskId of activeSubagentToolCallIds) {
484
+ for (const taskId of activeSubagentTaskIds.values()) {
458
485
  try {
459
486
  void q.stopTask(taskId).catch(() => {
460
487
  /* best-effort */
@@ -192,7 +192,8 @@ export function mapSdkMessage(msg, state) {
192
192
  return events;
193
193
  }
194
194
  if (subtype === 'task_started' || subtype === 'task_progress' || subtype === 'task_notification') {
195
- const toolCallId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : undefined;
195
+ const taskId = typeof parsed.task_id === 'string' ? parsed.task_id : undefined;
196
+ const toolCallId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : taskId;
196
197
  if (toolCallId) {
197
198
  const usage = parsed.usage;
198
199
  // Unlike task_started/task_progress (in-flight signals), task_notification
@@ -204,6 +205,7 @@ export function mapSdkMessage(msg, state) {
204
205
  events.push({
205
206
  kind: 'subagent:progress',
206
207
  toolCallId,
208
+ ...(taskId ? { taskId } : {}),
207
209
  status: isDone ? 'done' : 'running',
208
210
  description: typeof parsed.description === 'string' ? parsed.description : undefined,
209
211
  taskType: typeof parsed.task_type === 'string' ? parsed.task_type : undefined,
@@ -2,7 +2,7 @@ const KOBO_MCP_BRIEF = [
2
2
  '[Kōbō MCP] This workspace exposes a dedicated MCP server with tools prefixed `kobo__`.',
3
3
  'Non-interactive mode: this session runs via the Claude Agent SDK in single-shot mode. To ask the user a question, call the `AskUserQuestion` tool — Kōbō will pause the session, collect the answer asynchronously, and resume.',
4
4
  '⚠️ HARD RULE — emit AT MOST ONE `AskUserQuestion` tool call per turn, and emit it ALONE (no other tool calls in the same response). The Claude Agent SDK rejects parallel defers: a second `AskUserQuestion` in the same turn breaks the pause mechanism, makes both calls fail with "AskUserQuestion fails", and the user sees the panel arrive only at the end of the turn (if at all). If you have N questions, group them as a multi-question array inside ONE call: `AskUserQuestion({ questions: [{ question: "Q1", options: [...] }, { question: "Q2", options: [...] }] })`. After invoking it, STOP — do not emit any other tool call. Wait for the resume.',
5
- 'Plan mode: when running with `permissionMode: plan`, the read-only restriction applies to MCP tools too. For Kōbō: `kobo__list_*`, `kobo__read_document`, `kobo__search_codebase`, `kobo__get_*` are fine; `kobo__mark_task_done`, `kobo__log_thought`, `kobo__set_workspace_status` are mutations and must wait until the plan is approved.',
5
+ 'Plan mode: when running with `permissionMode: plan`, the read-only restriction applies to MCP tools too. For Kōbō: `kobo__list_*`, `kobo__read_document`, `kobo__search_codebase`, `kobo__get_*` are fine; `kobo__mark_task_done`, `kobo__log_thought`, `kobo__set_workspace_status`, `kobo__set_auto_loop` are mutations and must wait until the plan is approved.',
6
6
  'Conventions — read these BEFORE starting work, not as a fallback:',
7
7
  '• `kobo__list_tasks` first on any non-trivial turn, then `kobo__mark_task_done` as each item completes.',
8
8
  '• `kobo__list_documents` / `kobo__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
@@ -10,6 +10,7 @@ const KOBO_MCP_BRIEF = [
10
10
  '• `kobo__search_codebase` to recall prior chat history (conversations, not source — use Grep for source).',
11
11
  '• `kobo__read_workspace_events_csv` to read paginated user/agent history from this workspace across sessions when prior context is useful. Use `limit` + `offset` (and optional `session_id`) instead of loading everything.',
12
12
  '• `kobo__get_workspace_info` / `kobo__get_git_info` / `kobo__get_ticket` for context.',
13
+ '• If `kobo__get_workspace_info` reports `autoLoop: false`, call `kobo__set_auto_loop({ enabled: true })` only when the user explicitly asks for autonomous continuation; never disable it unless explicitly asked.',
13
14
  '• `kobo__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; use AskUserQuestion for clarification, or end the turn.',
14
15
  '• `kobo__schedule_wakeup` / `kobo__cancel_wakeup` to schedule (or cancel) a follow-up session — prefer these over the built-in `ScheduleWakeup` tool.',
15
16
  'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
@@ -268,6 +268,8 @@ export function createCodexEngine() {
268
268
  armSubagentStallWatchdog();
269
269
  }
270
270
  else {
271
+ if (n.turn?.status === 'completed')
272
+ safeEmit({ kind: 'turn:completed' });
271
273
  resolveTurnDone();
272
274
  }
273
275
  }
@@ -1,12 +1,13 @@
1
1
  const CODEX_KOBO_MCP_BRIEF = [
2
2
  '[Kōbō MCP] This workspace exposes a dedicated MCP server named `kobo-tasks`. The Codex CLI surfaces its tools under the literal name `mcp__kobo-tasks__<tool>` — always use that full form when invoking them.',
3
- 'Plan mode: when running with a read-only sandbox, the read-only restriction applies to MCP tools too. Reads (`mcp__kobo-tasks__list_*`, `mcp__kobo-tasks__read_document`, `mcp__kobo-tasks__search_codebase`, `mcp__kobo-tasks__get_*`) are fine; mutations (`mcp__kobo-tasks__mark_task_done`, `mcp__kobo-tasks__log_thought`, `mcp__kobo-tasks__set_workspace_status`) must wait until the plan is approved.',
3
+ 'Plan mode: when running with a read-only sandbox, the read-only restriction applies to MCP tools too. Reads (`mcp__kobo-tasks__list_*`, `mcp__kobo-tasks__read_document`, `mcp__kobo-tasks__search_codebase`, `mcp__kobo-tasks__get_*`) are fine; mutations (`mcp__kobo-tasks__mark_task_done`, `mcp__kobo-tasks__log_thought`, `mcp__kobo-tasks__set_workspace_status`, `mcp__kobo-tasks__set_auto_loop`) must wait until the plan is approved.',
4
4
  'Conventions — read these BEFORE starting work, not as a fallback:',
5
5
  '• `mcp__kobo-tasks__list_tasks` first on any non-trivial turn, then `mcp__kobo-tasks__mark_task_done` as each item completes.',
6
6
  '• `mcp__kobo-tasks__list_documents` / `mcp__kobo-tasks__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
7
7
  '• `mcp__kobo-tasks__log_thought` to persist notable decisions to `.ai/thoughts/logs/<date>-<slug>.md`.',
8
8
  '• `mcp__kobo-tasks__search_codebase` to recall prior chat history (conversations, not source — use shell tools for source).',
9
9
  '• `mcp__kobo-tasks__get_workspace_info` / `mcp__kobo-tasks__get_git_info` / `mcp__kobo-tasks__get_ticket` for context.',
10
+ '• If `mcp__kobo-tasks__get_workspace_info` reports `autoLoop: false`, call `mcp__kobo-tasks__set_auto_loop({ enabled: true })` only when the user explicitly asks for autonomous continuation; never disable it unless explicitly asked.',
10
11
  '• `mcp__kobo-tasks__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; ask the user or end the turn instead.',
11
12
  '• `mcp__kobo-tasks__schedule_wakeup` / `mcp__kobo-tasks__cancel_wakeup` to schedule (or cancel) a follow-up session.',
12
13
  'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
@@ -2,6 +2,7 @@
2
2
  export const ALL_AGENT_EVENT_KINDS = [
3
3
  'session:started',
4
4
  'session:ended',
5
+ 'turn:completed',
5
6
  'session:user-input-requested',
6
7
  'session:compacted',
7
8
  'session:compacting',
@@ -488,21 +488,77 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engine
488
488
  return agentSessionId;
489
489
  }
490
490
  const taskProgressSnapshots = new Map();
491
- function rememberTaskProgressSnapshot(workspaceId, agentSessionId) {
492
- const workspaceSnapshots = taskProgressSnapshots.get(workspaceId) ?? new Map();
493
- workspaceSnapshots.set(agentSessionId, {
491
+ function createTaskProgressSnapshot(workspaceId) {
492
+ return {
494
493
  doneCount: getDoneTaskCount(workspaceId),
495
494
  stateSignature: getTaskStateSignature(workspaceId),
496
- });
495
+ };
496
+ }
497
+ function rememberTaskProgressSnapshot(workspaceId, agentSessionId, snapshot = createTaskProgressSnapshot(workspaceId)) {
498
+ const workspaceSnapshots = taskProgressSnapshots.get(workspaceId) ?? new Map();
499
+ workspaceSnapshots.set(agentSessionId, snapshot);
497
500
  taskProgressSnapshots.set(workspaceId, workspaceSnapshots);
498
501
  }
502
+ function parseTaskProgressSnapshot(value) {
503
+ if (!value)
504
+ return undefined;
505
+ try {
506
+ const parsed = JSON.parse(value);
507
+ if (typeof parsed.doneCount !== 'number' || typeof parsed.stateSignature !== 'string')
508
+ return undefined;
509
+ return { doneCount: parsed.doneCount, stateSignature: parsed.stateSignature };
510
+ }
511
+ catch {
512
+ return undefined;
513
+ }
514
+ }
515
+ function captureTaskProgressBaseline(workspaceId, agentSessionId) {
516
+ const snapshot = createTaskProgressSnapshot(workspaceId);
517
+ rememberTaskProgressSnapshot(workspaceId, agentSessionId, snapshot);
518
+ try {
519
+ getDb()
520
+ .prepare('UPDATE agent_sessions SET task_progress_baseline = ? WHERE id = ?')
521
+ .run(JSON.stringify(snapshot), agentSessionId);
522
+ }
523
+ catch (err) {
524
+ console.warn('[orchestrator] Failed to persist task progress baseline:', err);
525
+ }
526
+ }
527
+ function ensureTaskProgressBaseline(workspaceId, agentSessionId) {
528
+ if (taskProgressSnapshots.get(workspaceId)?.has(agentSessionId))
529
+ return;
530
+ try {
531
+ const row = getDb().prepare('SELECT task_progress_baseline FROM agent_sessions WHERE id = ?').get(agentSessionId);
532
+ if (row?.task_progress_baseline)
533
+ return;
534
+ }
535
+ catch {
536
+ // The in-memory fallback below is still useful in narrow unit-test and
537
+ // bootstrap windows where the persisted session row is unavailable.
538
+ }
539
+ captureTaskProgressBaseline(workspaceId, agentSessionId);
540
+ }
499
541
  function consumeTaskProgressSnapshot(workspaceId, agentSessionId) {
500
542
  const workspaceSnapshots = taskProgressSnapshots.get(workspaceId);
501
- const snapshot = workspaceSnapshots?.get(agentSessionId);
543
+ const memorySnapshot = workspaceSnapshots?.get(agentSessionId);
502
544
  workspaceSnapshots?.delete(agentSessionId);
503
545
  if (workspaceSnapshots?.size === 0)
504
546
  taskProgressSnapshots.delete(workspaceId);
505
- return snapshot;
547
+ try {
548
+ const db = getDb();
549
+ const readAndClear = db.transaction((id) => {
550
+ const row = db.prepare('SELECT task_progress_baseline FROM agent_sessions WHERE id = ?').get(id);
551
+ db.prepare('UPDATE agent_sessions SET task_progress_baseline = NULL WHERE id = ?').run(id);
552
+ return row?.task_progress_baseline ?? null;
553
+ });
554
+ const persistedSnapshot = parseTaskProgressSnapshot(readAndClear(agentSessionId));
555
+ if (persistedSnapshot)
556
+ return persistedSnapshot;
557
+ }
558
+ catch (err) {
559
+ console.warn('[orchestrator] Failed to consume task progress baseline:', err);
560
+ }
561
+ return memorySnapshot;
506
562
  }
507
563
  function rememberResumeFailed(workspaceId, agentSessionId) {
508
564
  const workspaceSessions = resumeFailedSessions.get(workspaceId) ?? new Set();
@@ -588,10 +644,11 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
588
644
  if (ev.kind === 'rate_limit') {
589
645
  latestRateLimitInfo.set(workspaceId, ev.info);
590
646
  }
591
- // Snapshot the done-count at session start so the session:ended hook below
592
- // can compute a delta for auto-loop stall detection.
647
+ // The normal start path captures this synchronously before engine.start.
648
+ // Keep an idempotent fallback for engines/tests that emit session:started
649
+ // without first going through startAgent.
593
650
  if (ev.kind === 'session:started') {
594
- rememberTaskProgressSnapshot(workspaceId, agentSessionId);
651
+ ensureTaskProgressBaseline(workspaceId, agentSessionId);
595
652
  }
596
653
  // Legacy fallback: the built-in `ScheduleWakeup` tool (CLI tradition) isn't
597
654
  // declared by the SDK, so we intercept the tool:call event and apply the
@@ -700,12 +757,23 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
700
757
  // its persisted prompts too, otherwise a reconnect resurrects a panel
701
758
  // whose backend callback has already been discarded.
702
759
  purgeAllPersistedUserInputRequests(workspaceId, agentSessionId);
760
+ // A watchdog end means Kōbō forced the stream closed after the engine
761
+ // failed to drain. It is neither a clean completion nor evidence of an
762
+ // agent stall. Move auto-loop work onto the existing persisted, bounded
763
+ // transient-retry path before lifecycle cleanup observes the status.
764
+ const watchdogRecovery = ev.reason === 'watchdog' && getWs(workspaceId)?.autoLoop === true;
765
+ if (watchdogRecovery) {
766
+ console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
767
+ void handleTransientAutoLoopFailure(workspaceId);
768
+ }
703
769
  // Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
704
770
  // startAgent, otherwise startAgent throws "Agent already running" because
705
771
  // the just-ended controller is still in the map.
706
772
  const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
707
773
  if (!ownsWorkspaceLifecycle)
708
774
  return;
775
+ if (watchdogRecovery)
776
+ return;
709
777
  // resume_failed exits with an error but the workspace is fine (stale id
710
778
  // cleared, next iteration will start fresh) — report 'completed' to
711
779
  // auto-loop so it continues.
@@ -905,6 +973,9 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
905
973
  else {
906
974
  agentSessionId = reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engineId);
907
975
  }
976
+ // Persist before the asynchronous engine can emit its first event. This
977
+ // survives a Kōbō restart and does not depend on SDK system:init delivery.
978
+ captureTaskProgressBaseline(workspaceId, agentSessionId);
908
979
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
909
980
  const options = {
910
981
  workspaceId,
@@ -987,12 +1058,7 @@ export function interruptAgent(workspaceId, options = {}) {
987
1058
  autoLoopService.disable(workspaceId, 'user-action');
988
1059
  }
989
1060
  }
990
- /** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
991
- export function stopAgent(workspaceId) {
992
- const ctrl = controllers.get(workspaceId);
993
- if (!ctrl) {
994
- throw new Error(`No agent running for workspace '${workspaceId}'`);
995
- }
1061
+ async function stopController(workspaceId, ctrl) {
996
1062
  wakeupService.cancel(workspaceId, 'stopped');
997
1063
  // If the session was waiting on a question/permission, normalize the state
998
1064
  // synchronously so callers (archive, delete, manual stop) see a clean
@@ -1020,11 +1086,42 @@ export function stopAgent(workspaceId) {
1020
1086
  controllers.delete(workspaceId);
1021
1087
  // Manual stop should also drop any pending quota auto-resume.
1022
1088
  quotaBackoffService.cancel(workspaceId, 'user');
1023
- // Fire-and-forget: controller.stop is async but we don't block callers.
1024
- void ctrl.stop().catch((err) => {
1089
+ await ctrl.stop();
1090
+ }
1091
+ /** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
1092
+ export function stopAgent(workspaceId) {
1093
+ const ctrl = controllers.get(workspaceId);
1094
+ if (!ctrl) {
1095
+ throw new Error(`No agent running for workspace '${workspaceId}'`);
1096
+ }
1097
+ // Fire-and-forget: callers such as archive/delete must remain synchronous.
1098
+ void stopController(workspaceId, ctrl).catch((err) => {
1025
1099
  console.error('[orchestrator] controller.stop failed:', err);
1026
1100
  });
1027
1101
  }
1102
+ /** Stop every live engine before process shutdown, with a bounded wait per engine. */
1103
+ export async function stopAllAgents(timeoutMs = 3_000) {
1104
+ const stops = [...controllers.entries()].map(async ([workspaceId, ctrl]) => {
1105
+ let timeout;
1106
+ try {
1107
+ await Promise.race([
1108
+ stopController(workspaceId, ctrl),
1109
+ new Promise((resolve) => {
1110
+ timeout = setTimeout(resolve, timeoutMs);
1111
+ timeout.unref?.();
1112
+ }),
1113
+ ]);
1114
+ }
1115
+ catch (err) {
1116
+ console.error(`[orchestrator] Failed to stop '${workspaceId}' during shutdown:`, err);
1117
+ }
1118
+ finally {
1119
+ if (timeout)
1120
+ clearTimeout(timeout);
1121
+ }
1122
+ });
1123
+ await Promise.all(stops);
1124
+ }
1028
1125
  /** Write a user message to the running agent. */
1029
1126
  export async function sendMessage(workspaceId, content, expectedSessionId) {
1030
1127
  const ctrl = controllers.get(workspaceId);
@@ -123,6 +123,12 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
123
123
  // will resume the deferred turn explicitly.
124
124
  if (row.status === 'awaiting-user')
125
125
  return;
126
+ // A watchdog denotes Kōbō's own forced recovery from a stuck engine stream.
127
+ // The orchestrator owns its bounded backoff/retry path; counting it as a
128
+ // normal no-progress iteration would falsely disable an otherwise healthy
129
+ // loop after three SDK teardown timeouts.
130
+ if (reason === 'watchdog')
131
+ return;
126
132
  if (reason === 'error') {
127
133
  disable(workspaceId, 'error');
128
134
  return;