@loicngr/kobo 1.11.12 → 1.11.14

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 (65) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/mcp-server/kobo-tasks-handlers.js +26 -3
  3. package/dist/server/index.js +5 -7
  4. package/dist/server/routes/export.js +2 -1
  5. package/dist/server/routes/workspaces.js +15 -0
  6. package/dist/server/services/agent/engines/claude-code/engine.js +81 -6
  7. package/dist/server/services/agent/engines/claude-code/event-mapper.js +9 -5
  8. package/dist/server/services/agent/engines/codex/engine.js +54 -2
  9. package/dist/server/services/agent/orchestrator.js +34 -8
  10. package/dist/server/services/cron-service.js +2 -1
  11. package/dist/server/services/dev-server-service.js +13 -1
  12. package/dist/server/services/wakeup-service.js +14 -12
  13. package/dist/server/utils/git-ops.js +3 -2
  14. package/dist/server/utils/safe-path.js +34 -0
  15. package/package.json +3 -3
  16. package/src/client/dist/pwa/assets/{ActivityFeed-Ds8cAMNc.js → ActivityFeed-DqDJDX2v.js} +1 -1
  17. package/src/client/dist/pwa/assets/{ChangelogPage-DO6DNO4Q.js → ChangelogPage-Dw2LbIK2.js} +1 -1
  18. package/src/client/dist/pwa/assets/{ClosePopup-DuAqn8Pb.js → ClosePopup-eKuUr7CY.js} +1 -1
  19. package/src/client/dist/pwa/assets/{CreatePage-BnMSo_79.js → CreatePage--4KDhiYt.js} +1 -1
  20. package/src/client/dist/pwa/assets/{DiffViewer-CMnFGReI.js → DiffViewer-Bl9D1N1M.js} +3 -3
  21. package/src/client/dist/pwa/assets/{HealthPage-BRAxQKSP.js → HealthPage-CaOdjuVx.js} +1 -1
  22. package/src/client/dist/pwa/assets/{MainLayout-BN1ug95K.js → MainLayout-DP9shoO1.js} +2 -2
  23. package/src/client/dist/pwa/assets/{QBtnToggle-DsCDNQzX.js → QBtnToggle-BwPZ0Smh.js} +1 -1
  24. package/src/client/dist/pwa/assets/{QChip-DWGDTZnX.js → QChip-CY0_8v_e.js} +1 -1
  25. package/src/client/dist/pwa/assets/{QSelect-8Ip46vhk.js → QSelect-xHphd7hW.js} +1 -1
  26. package/src/client/dist/pwa/assets/{QTooltip-Bz4g3bmr.js → QTooltip-DyS20jDT.js} +1 -1
  27. package/src/client/dist/pwa/assets/{SearchPage-BD53VJEr.js → SearchPage-CggXs3dH.js} +1 -1
  28. package/src/client/dist/pwa/assets/{SettingsPage-3rYdJZga.js → SettingsPage-8AWL_4DM.js} +1 -1
  29. package/src/client/dist/pwa/assets/{TouchPan-BKu8V4eN.js → TouchPan-Cd9KTb6-.js} +1 -1
  30. package/src/client/dist/pwa/assets/{WorkspacePage-CbC_t5Rx.js → WorkspacePage-CJKSrSEC.js} +3 -3
  31. package/src/client/dist/pwa/assets/{cssMode-vaMVESGy.js → cssMode-BsN8TWME.js} +1 -1
  32. package/src/client/dist/pwa/assets/{documents-CyKQ9lT6.js → documents-DJy1tgPd.js} +1 -1
  33. package/src/client/dist/pwa/assets/{editor.api-CH3dhpKq.js → editor.api-Dj6TIm_z.js} +1 -1
  34. package/src/client/dist/pwa/assets/{expand-template-DQQQftsk.js → expand-template-By-8Db7_.js} +1 -1
  35. package/src/client/dist/pwa/assets/{freemarker2-deLpERsj.js → freemarker2-DC0xjWx5.js} +1 -1
  36. package/src/client/dist/pwa/assets/{handlebars-DdjS05Tc.js → handlebars-0Uz9nw5d.js} +1 -1
  37. package/src/client/dist/pwa/assets/{html-pK1ZEFvP.js → html-BYPeU1Vb.js} +1 -1
  38. package/src/client/dist/pwa/assets/{htmlMode-DJFDI9Ru.js → htmlMode-B0UND_vz.js} +1 -1
  39. package/src/client/dist/pwa/assets/i18n-DPDR4mQI.js +1 -0
  40. package/src/client/dist/pwa/assets/{index-B_OmZ65F.js → index-CtK8ORpZ.js} +3 -3
  41. package/src/client/dist/pwa/assets/{javascript-DbuFfxco.js → javascript-B8PZW8pe.js} +1 -1
  42. package/src/client/dist/pwa/assets/{jsonMode-B0Fu8Dcc.js → jsonMode-BixnmFse.js} +1 -1
  43. package/src/client/dist/pwa/assets/{kobo-commands-rb43ZeWG.js → kobo-commands-CX8pforo.js} +1 -1
  44. package/src/client/dist/pwa/assets/{layout-tAFrvQ5V.js → layout-BAE6NR2A.js} +1 -1
  45. package/src/client/dist/pwa/assets/{liquid-DqDIKOCy.js → liquid-BH16_Zn1.js} +1 -1
  46. package/src/client/dist/pwa/assets/{lspLanguageFeatures-CVcEibfJ.js → lspLanguageFeatures-YMzTIuK0.js} +1 -1
  47. package/src/client/dist/pwa/assets/{mdx-Bs4CovhI.js → mdx-CrrWJ7fg.js} +1 -1
  48. package/src/client/dist/pwa/assets/{network-auth-DYrrh7Zr.js → network-auth-NMnCVoaZ.js} +1 -1
  49. package/src/client/dist/pwa/assets/{pending-diff-open-D1IYH8Pd.js → pending-diff-open-DHlXLorX.js} +1 -1
  50. package/src/client/dist/pwa/assets/{project-color-DO6uj29M.js → project-color-BJrCEQ0A.js} +1 -1
  51. package/src/client/dist/pwa/assets/{python-BJoP0uN-.js → python-fk3K3s3o.js} +1 -1
  52. package/src/client/dist/pwa/assets/{razor-cwD-YBcr.js → razor-Dg1yHOal.js} +1 -1
  53. package/src/client/dist/pwa/assets/{render-chat-markdown-DZnIueUV.js → render-chat-markdown-C00A6CfY.js} +1 -1
  54. package/src/client/dist/pwa/assets/{tsMode-of3oS9sU.js → tsMode-qJ7nTArc.js} +1 -1
  55. package/src/client/dist/pwa/assets/{typescript-BTWMoUxn.js → typescript-DayfKox1.js} +1 -1
  56. package/src/client/dist/pwa/assets/{use-onboarding-BTFkRBjE.js → use-onboarding-CF_D2gbm.js} +1 -1
  57. package/src/client/dist/pwa/assets/{use-quasar-C1RCDSb6.js → use-quasar-C3oo6nVn.js} +1 -1
  58. package/src/client/dist/pwa/assets/{vs-DDeaNKsH.js → vs-qnWnWXG9.js} +3 -3
  59. package/src/client/dist/pwa/assets/{workers-DQnKuGcq.js → workers-CvvtZekC.js} +1 -1
  60. package/src/client/dist/pwa/assets/{xml-RglGWD_3.js → xml-CJ1QFxhD.js} +1 -1
  61. package/src/client/dist/pwa/assets/{yaml-CSqGQpai.js → yaml-C3N49N5E.js} +1 -1
  62. package/src/client/dist/pwa/index.html +1 -1
  63. package/src/client/dist/pwa/sw.js +1 -1
  64. package/src/mcp-server/kobo-tasks-handlers.ts +24 -4
  65. 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.14
8
+
9
+ - ⬆️ develop - refactor(deps): Bump SDKs + refactor shutdown + hardening sécurité
10
+
11
+ ## 1.11.13
12
+
13
+ - fix(engines): unstick agent sessions that never emit session:ended
14
+
7
15
  ## 1.11.12
8
16
 
9
17
  - ✨ develop - feat(engines): Garder les sessions ouvertes pour les subagents en arrière-plan
@@ -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
@@ -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);
@@ -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)
@@ -91,7 +97,9 @@ export function createClaudeCodeEngine() {
91
97
  async start(options, onEvent) {
92
98
  const abortController = new AbortController();
93
99
  const mapperState = createMapperState();
94
- 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();
95
103
  // Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
96
104
  const pendingResolvers = new Map();
97
105
  const isInteractive = options.agentPermissionMode === 'interactive';
@@ -278,6 +286,41 @@ export function createClaudeCodeEngine() {
278
286
  }, RESULT_DRAIN_TIMEOUT_MS);
279
287
  resultDrainTimer.unref?.();
280
288
  };
289
+ // Safety net for the "waiting on a background subagent" branch below:
290
+ // if `activeSubagentToolCallIds` never empties (a missed/unrecognised
291
+ // terminal notification), the workspace would otherwise stay `executing`
292
+ // forever. Force the drain through once this fires.
293
+ let subagentStallTimer;
294
+ const clearSubagentStallWatchdog = () => {
295
+ if (!subagentStallTimer)
296
+ return;
297
+ clearTimeout(subagentStallTimer);
298
+ subagentStallTimer = undefined;
299
+ };
300
+ // Re-armed (not merely armed-once) on every qualifying `result` so the
301
+ // deadline tracks the last observed activity, matching the warning
302
+ // message below — a long chain of legitimate subagent-backed turns
303
+ // must not be cut off just because 10 minutes have passed in total.
304
+ const armSubagentStallWatchdog = () => {
305
+ clearSubagentStallWatchdog();
306
+ subagentStallTimer = setTimeout(() => {
307
+ subagentStallTimer = undefined;
308
+ // A message queued between arming and firing means a new turn is
309
+ // about to start on this same stream — don't discard the subagent
310
+ // tracking or force-close under it; the next 'result' re-evaluates.
311
+ if (inputStream.hasUnansweredInput(completedResponses))
312
+ return;
313
+ console.warn(`[claude-engine] Subagent(s) still tracked active ${SUBAGENT_STALL_TIMEOUT_MS}ms after the last turn ended — forcing session drain.`);
314
+ activeSubagentTaskIds.clear();
315
+ // This is a forced, unclean termination (the subagent may still be
316
+ // alive server-side) — never report it as a normal completion, so
317
+ // auto-loop doesn't treat an orphaned run as forward progress.
318
+ mapperState.sawErrorResult = true;
319
+ inputStream.close();
320
+ armResultDrainWatchdog();
321
+ }, SUBAGENT_STALL_TIMEOUT_MS);
322
+ subagentStallTimer.unref?.();
323
+ };
281
324
  const iteratorPromise = (async () => {
282
325
  iteratorRunning = true;
283
326
  try {
@@ -287,9 +330,20 @@ export function createClaudeCodeEngine() {
287
330
  if (ev.kind !== 'subagent:progress')
288
331
  continue;
289
332
  if (ev.status === 'running')
290
- activeSubagentToolCallIds.add(ev.toolCallId);
333
+ activeSubagentTaskIds.set(ev.toolCallId, ev.taskId ?? ev.toolCallId);
291
334
  else
292
- activeSubagentToolCallIds.delete(ev.toolCallId);
335
+ activeSubagentTaskIds.delete(ev.toolCallId);
336
+ }
337
+ // The stall watchdog is only armed while we're waiting purely on
338
+ // background subagents (no further SDK turn expected unless one
339
+ // reports done). React the moment the last one clears instead of
340
+ // waiting for a 'result' message that may never come.
341
+ if (subagentStallTimer &&
342
+ activeSubagentTaskIds.size === 0 &&
343
+ !inputStream.hasUnansweredInput(completedResponses)) {
344
+ clearSubagentStallWatchdog();
345
+ inputStream.close();
346
+ armResultDrainWatchdog();
293
347
  }
294
348
  for (const ev of events) {
295
349
  if (ev.kind === 'session:started')
@@ -307,9 +361,15 @@ export function createClaudeCodeEngine() {
307
361
  clearTextIdleWatchdog();
308
362
  completedResponses++;
309
363
  // 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();
364
+ if (!inputStream.hasUnansweredInput(completedResponses)) {
365
+ if (activeSubagentTaskIds.size === 0) {
366
+ clearSubagentStallWatchdog();
367
+ inputStream.close();
368
+ armResultDrainWatchdog();
369
+ }
370
+ else {
371
+ armSubagentStallWatchdog();
372
+ }
313
373
  }
314
374
  }
315
375
  }
@@ -354,6 +414,7 @@ export function createClaudeCodeEngine() {
354
414
  resultDrainTimer = undefined;
355
415
  }
356
416
  clearTextIdleWatchdog();
417
+ clearSubagentStallWatchdog();
357
418
  // Drain any callback still pending (SDK terminated while awaiting an
358
419
  // answer). canUseTool's abort path covers signalled stops; this
359
420
  // covers natural iterator completion.
@@ -391,6 +452,20 @@ export function createClaudeCodeEngine() {
391
452
  // subtype `error_during_execution` through the normal iterator —
392
453
  // the mapper needs this flag to treat it as a clean stop.
393
454
  mapperState.userInterrupted = true;
455
+ // The soft interrupt below only ends the foreground turn — a
456
+ // background subagent task keeps running unless told to stop via
457
+ // its own API, which would otherwise leave the Stop button
458
+ // appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
459
+ for (const taskId of activeSubagentTaskIds.values()) {
460
+ try {
461
+ void q.stopTask(taskId).catch(() => {
462
+ /* best-effort */
463
+ });
464
+ }
465
+ catch {
466
+ /* best-effort */
467
+ }
468
+ }
394
469
  const qq = q;
395
470
  if (typeof qq.interrupt === 'function') {
396
471
  try {
@@ -192,16 +192,20 @@ 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
- 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);
199
+ // Unlike task_started/task_progress (in-flight signals), task_notification
200
+ // is only ever emitted once a task settles — its `status` value is a
201
+ // reason, not a liveness signal. Treat every task_notification as
202
+ // terminal instead of matching against a status whitelist, so an SDK
203
+ // status value added later doesn't leave the subagent stuck 'running'.
204
+ const isDone = subtype === 'task_notification';
202
205
  events.push({
203
206
  kind: 'subagent:progress',
204
207
  toolCallId,
208
+ ...(taskId ? { taskId } : {}),
205
209
  status: isDone ? 'done' : 'running',
206
210
  description: typeof parsed.description === 'string' ? parsed.description : undefined,
207
211
  taskType: typeof parsed.task_type === 'string' ? parsed.task_type : undefined,
@@ -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');
@@ -987,12 +987,7 @@ export function interruptAgent(workspaceId, options = {}) {
987
987
  autoLoopService.disable(workspaceId, 'user-action');
988
988
  }
989
989
  }
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
- }
990
+ async function stopController(workspaceId, ctrl) {
996
991
  wakeupService.cancel(workspaceId, 'stopped');
997
992
  // If the session was waiting on a question/permission, normalize the state
998
993
  // synchronously so callers (archive, delete, manual stop) see a clean
@@ -1020,11 +1015,42 @@ export function stopAgent(workspaceId) {
1020
1015
  controllers.delete(workspaceId);
1021
1016
  // Manual stop should also drop any pending quota auto-resume.
1022
1017
  quotaBackoffService.cancel(workspaceId, 'user');
1023
- // Fire-and-forget: controller.stop is async but we don't block callers.
1024
- void ctrl.stop().catch((err) => {
1018
+ await ctrl.stop();
1019
+ }
1020
+ /** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
1021
+ export function stopAgent(workspaceId) {
1022
+ const ctrl = controllers.get(workspaceId);
1023
+ if (!ctrl) {
1024
+ throw new Error(`No agent running for workspace '${workspaceId}'`);
1025
+ }
1026
+ // Fire-and-forget: callers such as archive/delete must remain synchronous.
1027
+ void stopController(workspaceId, ctrl).catch((err) => {
1025
1028
  console.error('[orchestrator] controller.stop failed:', err);
1026
1029
  });
1027
1030
  }
1031
+ /** Stop every live engine before process shutdown, with a bounded wait per engine. */
1032
+ export async function stopAllAgents(timeoutMs = 3_000) {
1033
+ const stops = [...controllers.entries()].map(async ([workspaceId, ctrl]) => {
1034
+ let timeout;
1035
+ try {
1036
+ await Promise.race([
1037
+ stopController(workspaceId, ctrl),
1038
+ new Promise((resolve) => {
1039
+ timeout = setTimeout(resolve, timeoutMs);
1040
+ timeout.unref?.();
1041
+ }),
1042
+ ]);
1043
+ }
1044
+ catch (err) {
1045
+ console.error(`[orchestrator] Failed to stop '${workspaceId}' during shutdown:`, err);
1046
+ }
1047
+ finally {
1048
+ if (timeout)
1049
+ clearTimeout(timeout);
1050
+ }
1051
+ });
1052
+ await Promise.all(stops);
1053
+ }
1028
1054
  /** Write a user message to the running agent. */
1029
1055
  export async function sendMessage(workspaceId, content, expectedSessionId) {
1030
1056
  const ctrl = controllers.get(workspaceId);
@@ -170,7 +170,6 @@ function fireOrSkip(id) {
170
170
  }
171
171
  let status = 'skipped-active';
172
172
  if (!orchestrator.hasController(row.workspace_id)) {
173
- status = 'fired';
174
173
  try {
175
174
  const globalSettings = settingsService.getGlobalSettings();
176
175
  const projectSettings = settingsService.getProjectSettings(wsRow.project_path);
@@ -186,9 +185,11 @@ function fireOrSkip(id) {
186
185
  // every fire" (clean context, no conversation continuity).
187
186
  const resumeMode = row.agent_session_id !== null;
188
187
  orchestrator.startAgent(row.workspace_id, worktreePath, row.prompt, wsRow.model, resumeMode, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
188
+ status = 'fired';
189
189
  }
190
190
  catch (err) {
191
191
  console.error(`[cron-service] startAgent at fire time failed for cron '${id}':`, err);
192
+ status = 'start-failed';
192
193
  }
193
194
  }
194
195
  const now = new Date();
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { getProjectSettings } from './settings-service.js';
5
5
  import { emitEphemeral } from './websocket-service.js';
6
- import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
6
+ import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
7
7
  // ── Helpers ───────────────────────────────────────────────────────────────────
8
8
  /** Build a clean env for child processes, stripping Kobo-specific variables. */
9
9
  function cleanEnv() {
@@ -312,6 +312,18 @@ export async function stopDevServer(workspaceId) {
312
312
  emitEphemeral(workspaceId, 'devserver:status', status);
313
313
  return status;
314
314
  }
315
+ /** Stop direct and Docker dev servers for every persisted workspace. */
316
+ export async function stopAllDevServers() {
317
+ const workspaceIds = new Set([...listWorkspaces(true).map((workspace) => workspace.id), ...trackedProcesses.keys()]);
318
+ await Promise.all([...workspaceIds].map(async (workspaceId) => {
319
+ try {
320
+ await stopDevServer(workspaceId);
321
+ }
322
+ catch (err) {
323
+ console.error(`[dev-server] Failed to stop '${workspaceId}' during shutdown:`, err);
324
+ }
325
+ }));
326
+ }
315
327
  // ── Logs ───────────────────────────────────────────────────────────────────────
316
328
  /**
317
329
  * Get logs from running dev-server containers for a workspace.
@@ -6,6 +6,7 @@ import * as settingsService from './settings-service.js';
6
6
  import { emitEphemeral } from './websocket-service.js';
7
7
  const MIN_DELAY_SECONDS = 60;
8
8
  const MAX_DELAY_SECONDS = 21600;
9
+ const ACTIVE_SESSION_RETRY_MS = 15_000;
9
10
  const STALE_WAKEUP_GRACE_MS = 5 * 60 * 1000;
10
11
  const AUTONOMOUS_LOOP_SENTINEL = '<<autonomous-loop-dynamic>>';
11
12
  const AUTONOMOUS_LOOP_FALLBACK_PROMPT = 'Continue where you left off.';
@@ -19,6 +20,15 @@ function rowToPending(row) {
19
20
  return null;
20
21
  return { targetAt: row.target_at, reason: row.reason ?? undefined };
21
22
  }
23
+ /** Keep a claimed wakeup durable while the session is still unavailable. */
24
+ function defer(workspaceId, row) {
25
+ const targetAt = new Date(Date.now() + ACTIVE_SESSION_RETRY_MS).toISOString();
26
+ getDb().prepare('UPDATE pending_wakeups SET target_at = ? WHERE workspace_id = ?').run(targetAt, workspaceId);
27
+ const timeout = setTimeout(() => fire(workspaceId), ACTIVE_SESSION_RETRY_MS);
28
+ timeout.unref?.();
29
+ timers.set(workspaceId, timeout);
30
+ emitEphemeral(workspaceId, 'wakeup:scheduled', { targetAt, reason: row.reason ?? undefined });
31
+ }
22
32
  /** Schedule a wakeup for the given workspace. Replaces any existing pending wakeup. */
23
33
  export function schedule(workspaceId, delaySeconds, prompt, reason, agentSessionId) {
24
34
  try {
@@ -118,21 +128,12 @@ export function rehydrate() {
118
128
  function fire(workspaceId) {
119
129
  try {
120
130
  const db = getDb();
121
- // Atomic claim: SELECT + DELETE in a single transaction so a concurrent
122
- // cancel() can't race us between the read and the act. If we come out
123
- // with a row, it's ours exclusively; no other caller will ever see it.
124
- const row = db.transaction(() => {
125
- const r = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
126
- if (r) {
127
- db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
128
- }
129
- return r;
130
- })();
131
+ const row = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
131
132
  timers.delete(workspaceId);
132
133
  if (!row)
133
134
  return;
134
135
  if (orchestrator.hasController(workspaceId)) {
135
- emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'session-active' });
136
+ defer(workspaceId, row);
136
137
  return;
137
138
  }
138
139
  const wsRow = db
@@ -155,12 +156,13 @@ function fire(workspaceId) {
155
156
  const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
156
157
  try {
157
158
  orchestrator.startAgent(workspaceId, worktreePath, row.prompt, wsRow.model, true, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
159
+ db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
158
160
  emitEphemeral(workspaceId, 'wakeup:fired', {});
159
161
  }
160
162
  catch (err) {
161
163
  const message = err instanceof Error ? err.message : String(err);
162
164
  console.error(`[wakeup-service] startAgent at fire time failed for '${workspaceId}':`, message);
163
- emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'fire-failed' });
165
+ defer(workspaceId, row);
164
166
  }
165
167
  }
166
168
  catch (err) {
@@ -2,6 +2,7 @@ import { execFile as execFileCb, execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
+ import { resolvePathInside } from './safe-path.js';
5
6
  const execFileAsync = promisify(execFileCb);
6
7
  const READ_ONLY_GIT_TIMEOUT_MS = 15_000;
7
8
  function git(repoPath, args) {
@@ -854,6 +855,7 @@ export function getChangedFilesBetween(repoPath, fromRef, toRef) {
854
855
  * feedback in the UI.
855
856
  */
856
857
  export function rollbackFile(repoPath, branchName, filePath, remote = 'origin') {
858
+ const absPath = resolvePathInside(repoPath, filePath);
857
859
  const remoteRef = `${remote}/${branchName}`;
858
860
  let remoteRefExists = false;
859
861
  try {
@@ -884,7 +886,6 @@ export function rollbackFile(repoPath, branchName, filePath, remote = 'origin')
884
886
  // with a previous rollback, stale UI list, manual rm), we still return
885
887
  // 'deleted' since the end state matches the user's intent. `rmSync`
886
888
  // over `git clean -f` keeps the action narrow to one file.
887
- const absPath = join(repoPath, filePath);
888
889
  if (existsSync(absPath)) {
889
890
  rmSync(absPath, { force: true });
890
891
  }
@@ -896,7 +897,7 @@ export const rollbackFileToRemote = rollbackFile;
896
897
  /** Get the current content of a file in the worktree. Returns null if the file doesn't exist. */
897
898
  export function getFileContent(repoPath, filePath) {
898
899
  try {
899
- return readFileSync(join(repoPath, filePath), 'utf-8');
900
+ return readFileSync(resolvePathInside(repoPath, filePath), 'utf-8');
900
901
  }
901
902
  catch {
902
903
  return null;