@loicngr/kobo 1.11.6 → 1.11.8

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 (77) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/server/routes/workspaces.js +66 -68
  3. package/dist/server/services/agent/engines/codex/engine.js +42 -7
  4. package/dist/server/services/agent/orchestrator.js +205 -54
  5. package/dist/server/services/agent/session-controller.js +20 -4
  6. package/dist/server/services/settings-service.js +58 -2
  7. package/dist/shared/whip-shortcut.js +41 -0
  8. package/package.json +1 -1
  9. package/src/client/dist/pwa/assets/ActivityFeed-BkkyAw81.js +8 -0
  10. package/src/client/dist/pwa/assets/{ChangelogPage-oLAJW1vx.js → ChangelogPage-BlvhBx6o.js} +1 -1
  11. package/src/client/dist/pwa/assets/{ClosePopup-BiMd0EFR.js → ClosePopup-_rbZbpfb.js} +1 -1
  12. package/src/client/dist/pwa/assets/{CreatePage-D7R9aFEL.js → CreatePage-RSyoQwD7.js} +2 -2
  13. package/src/client/dist/pwa/assets/{DiffViewer-CS1roZQD.js → DiffViewer-BSNxNex2.js} +3 -3
  14. package/src/client/dist/pwa/assets/{HealthPage-cP6NvQlI.js → HealthPage-CUD1kipV.js} +1 -1
  15. package/src/client/dist/pwa/assets/MainLayout-BCLgsn0C.js +37 -0
  16. package/src/client/dist/pwa/assets/{MainLayout-CYT-Kkia.css → MainLayout-DlS2wxkK.css} +1 -1
  17. package/src/client/dist/pwa/assets/{QBtnToggle-5Ypsr5PI.js → QBtnToggle-xg3LYW7D.js} +1 -1
  18. package/src/client/dist/pwa/assets/{QChip-CIwjmeox.js → QChip-DhUfnmbz.js} +1 -1
  19. package/src/client/dist/pwa/assets/QSelect-BhyT0k2N.js +36 -0
  20. package/src/client/dist/pwa/assets/QTooltip-ybbFi3KK.js +1 -0
  21. package/src/client/dist/pwa/assets/{SearchPage-DnUu9LCd.js → SearchPage-Bb7TG3Vo.js} +1 -1
  22. package/src/client/dist/pwa/assets/SettingsPage-C2qe4xKu.css +1 -0
  23. package/src/client/dist/pwa/assets/SettingsPage-CjPnnn60.js +16 -0
  24. package/src/client/dist/pwa/assets/{ThinkingItem-Dsh0S-4R.js → ThinkingItem-B1RKyCIS.js} +1 -1
  25. package/src/client/dist/pwa/assets/{TouchPan-BK8skWbU.js → TouchPan-DzZPYIol.js} +1 -1
  26. package/src/client/dist/pwa/assets/WorkspacePage-6Rgjarvf.js +4 -0
  27. package/src/client/dist/pwa/assets/{WorkspacePage-BE5DwWlF.css → WorkspacePage-Bgxg_Rua.css} +1 -1
  28. package/src/client/dist/pwa/assets/{build-path-tree-Dt3aRTqV.js → build-path-tree-KRACVkD3.js} +1 -1
  29. package/src/client/dist/pwa/assets/{cssMode-C2-EJrmK.js → cssMode-CQsB6mFO.js} +1 -1
  30. package/src/client/dist/pwa/assets/documents-C7efBF5t.js +1 -0
  31. package/src/client/dist/pwa/assets/{editor.api2-CkPvAZB5.js → editor.api2-C58pkxmw.js} +1 -1
  32. package/src/client/dist/pwa/assets/{editor.main-B-8Qv0oB.js → editor.main-BhLqjdq4.js} +2 -2
  33. package/src/client/dist/pwa/assets/{expand-template-Cnj1_kF0.js → expand-template-BA-hhG5v.js} +1 -1
  34. package/src/client/dist/pwa/assets/{formatters-BGHblyDL.js → formatters-DugWNOEs.js} +1 -1
  35. package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
  36. package/src/client/dist/pwa/assets/{freemarker2-VFyuTCM8.js → freemarker2-DczqHd5F.js} +1 -1
  37. package/src/client/dist/pwa/assets/{handlebars-g2m5ALjb.js → handlebars-DmA7twi8.js} +1 -1
  38. package/src/client/dist/pwa/assets/{html-DxnmOMw6.js → html-DtZDrViU.js} +1 -1
  39. package/src/client/dist/pwa/assets/{htmlMode-Df0wbbtZ.js → htmlMode-DO9_qUdT.js} +1 -1
  40. package/src/client/dist/pwa/assets/i18n-CipNqHTm.js +1 -0
  41. package/src/client/dist/pwa/assets/{index-CXciTur8.js → index-BxspWSDs.js} +9 -9
  42. package/src/client/dist/pwa/assets/{javascript-Df_-1Pv_.js → javascript-Bs83z_W4.js} +1 -1
  43. package/src/client/dist/pwa/assets/{jsonMode-BJFi57QF.js → jsonMode-Bc-8bzJi.js} +1 -1
  44. package/src/client/dist/pwa/assets/{kobo-commands-HJVSrhFj.js → kobo-commands-B2kM0INN.js} +1 -1
  45. package/src/client/dist/pwa/assets/{layout-DLVS_2RL.js → layout-C6frIxKZ.js} +1 -1
  46. package/src/client/dist/pwa/assets/{liquid-Dy9wgTn4.js → liquid-Dnxe0NUB.js} +1 -1
  47. package/src/client/dist/pwa/assets/{lspLanguageFeatures-DcaGTZUU.js → lspLanguageFeatures-qDBRYovw.js} +1 -1
  48. package/src/client/dist/pwa/assets/{mdx-B1nH83dR.js → mdx-C1oke9bq.js} +1 -1
  49. package/src/client/dist/pwa/assets/{monaco.contribution--RPgZPOx.js → monaco.contribution-BbFzrpO3.js} +2 -2
  50. package/src/client/dist/pwa/assets/network-auth-Bj1LS5R7.js +1 -0
  51. package/src/client/dist/pwa/assets/{pending-diff-open-BS9BktRr.js → pending-diff-open-C3voSObg.js} +1 -1
  52. package/src/client/dist/pwa/assets/{permissionModes-ClcSQKmQ.js → permissionModes-DMuXGtZu.js} +1 -1
  53. package/src/client/dist/pwa/assets/{project-color-SXRt-W03.js → project-color-DIFZ95_g.js} +1 -1
  54. package/src/client/dist/pwa/assets/{python-CJMnM2o7.js → python-Dgq-bJSM.js} +1 -1
  55. package/src/client/dist/pwa/assets/{razor-BUhyGYdm.js → razor-Bms4CQGF.js} +1 -1
  56. package/src/client/dist/pwa/assets/{render-chat-markdown-BH1Z10ar.js → render-chat-markdown-BRSTe4je.js} +1 -1
  57. package/src/client/dist/pwa/assets/{tsMode-Cps5WrVq.js → tsMode-DOsxzgZi.js} +1 -1
  58. package/src/client/dist/pwa/assets/{typescript-DHc8Mn2c.js → typescript-CzOp_GqX.js} +1 -1
  59. package/src/client/dist/pwa/assets/{use-onboarding-BEN9LPyX.js → use-onboarding-BGy3sOAN.js} +1 -1
  60. package/src/client/dist/pwa/assets/{use-quasar-CgjwhVVN.js → use-quasar-DsmzxifG.js} +1 -1
  61. package/src/client/dist/pwa/assets/{workers-B_i0s5nK.js → workers-0fozwhvS.js} +1 -1
  62. package/src/client/dist/pwa/assets/{xml-B4oQ8zNG.js → xml-CPa01uHm.js} +1 -1
  63. package/src/client/dist/pwa/assets/{yaml-CUpQdB7b.js → yaml-Cw3fzSuh.js} +1 -1
  64. package/src/client/dist/pwa/index.html +1 -1
  65. package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
  66. package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
  67. package/src/client/dist/pwa/sw.js +1 -1
  68. package/src/client/dist/pwa/assets/ActivityFeed-BM5l2qIA.js +0 -8
  69. package/src/client/dist/pwa/assets/MainLayout-BS27uyox.js +0 -37
  70. package/src/client/dist/pwa/assets/QSelect-BYNOOfHf.js +0 -36
  71. package/src/client/dist/pwa/assets/QTooltip-A4le4l3f.js +0 -1
  72. package/src/client/dist/pwa/assets/SettingsPage-BPplhEXC.js +0 -16
  73. package/src/client/dist/pwa/assets/SettingsPage-Os-6b-6H.css +0 -1
  74. package/src/client/dist/pwa/assets/WorkspacePage-YM_Qujn2.js +0 -4
  75. package/src/client/dist/pwa/assets/documents-BFIwenTv.js +0 -1
  76. package/src/client/dist/pwa/assets/i18n-Cf8eFhZ8.js +0 -1
  77. package/src/client/dist/pwa/assets/network-auth-DYx2-JTO.js +0 -1
@@ -17,6 +17,14 @@ import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } fr
17
17
  import { resolveEngine } from './engines/registry.js';
18
18
  import { routeEvent } from './event-router.js';
19
19
  import { SessionController } from './session-controller.js';
20
+ export class InterruptAgentError extends Error {
21
+ code;
22
+ constructor(message, code) {
23
+ super(message);
24
+ this.code = code;
25
+ this.name = 'InterruptAgentError';
26
+ }
27
+ }
20
28
  // ── State ──────────────────────────────────────────────────────────────────────
21
29
  /** Actual bound port of the running backend — set at startup via setBackendPort() */
22
30
  let backendPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
@@ -30,6 +38,58 @@ export function getBackendPort() {
30
38
  }
31
39
  /** workspaceId -> SessionController */
32
40
  const controllers = new Map();
41
+ /**
42
+ * Latest controller generation per persisted session row. A resumed run can
43
+ * reuse an agentSessionId while its predecessor is still draining events, so
44
+ * controller-map membership alone is not a durable ownership signal.
45
+ */
46
+ const sessionLifecycleOwners = new Map();
47
+ function registerSessionLifecycleOwner(workspaceId, agentSessionId, controller) {
48
+ const workspaceOwners = sessionLifecycleOwners.get(workspaceId) ?? new Map();
49
+ const existing = workspaceOwners.get(agentSessionId);
50
+ if (existing) {
51
+ if (!existing.ownerEnded)
52
+ existing.supersededControllers.add(existing.owner);
53
+ existing.owner = controller;
54
+ existing.ownerEnded = false;
55
+ }
56
+ else {
57
+ workspaceOwners.set(agentSessionId, {
58
+ owner: controller,
59
+ ownerEnded: false,
60
+ supersededControllers: new Set(),
61
+ });
62
+ }
63
+ sessionLifecycleOwners.set(workspaceId, workspaceOwners);
64
+ }
65
+ function getSessionLifecycleOwnership(workspaceId, agentSessionId) {
66
+ return sessionLifecycleOwners.get(workspaceId)?.get(agentSessionId);
67
+ }
68
+ function deleteSessionLifecycleOwnershipIfSettled(workspaceId, agentSessionId) {
69
+ const workspaceOwners = sessionLifecycleOwners.get(workspaceId);
70
+ const ownership = workspaceOwners?.get(agentSessionId);
71
+ if (!workspaceOwners || !ownership?.ownerEnded || ownership.supersededControllers.size > 0)
72
+ return;
73
+ workspaceOwners.delete(agentSessionId);
74
+ if (workspaceOwners.size === 0)
75
+ sessionLifecycleOwners.delete(workspaceId);
76
+ }
77
+ function markSessionLifecycleOwnerEnded(workspaceId, agentSessionId, controller) {
78
+ const ownership = getSessionLifecycleOwnership(workspaceId, agentSessionId);
79
+ if (!controller || !ownership || ownership.owner !== controller)
80
+ return;
81
+ ownership.ownerEnded = true;
82
+ deleteSessionLifecycleOwnershipIfSettled(workspaceId, agentSessionId);
83
+ }
84
+ function settleSupersededSessionController(workspaceId, agentSessionId, controller) {
85
+ const ownership = getSessionLifecycleOwnership(workspaceId, agentSessionId);
86
+ if (!ownership)
87
+ return;
88
+ ownership.supersededControllers.delete(controller);
89
+ deleteSessionLifecycleOwnershipIfSettled(workspaceId, agentSessionId);
90
+ }
91
+ export const FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS = 2_000;
92
+ export const FALLBACK_CONTROLLER_TURNOVER_POLL_MS = 25;
33
93
  /** workspaceId -> last engine session ID (for resume) */
34
94
  const sessionIds = new Map();
35
95
  /** workspaceId -> FIFO queue of pending items */
@@ -145,8 +205,8 @@ let availableSkills = (() => {
145
205
  })();
146
206
  /** workspaceId -> retry count (for quota backoff) */
147
207
  const retryCounts = new Map();
148
- /** Tracks workspaces where the current session failed due to a stale --resume session ID. */
149
- const resumeFailedSet = new Set();
208
+ /** Tracks agent sessions that failed due to a stale --resume session ID. */
209
+ const resumeFailedSessions = new Map();
150
210
  // ── Watchdog ──────────────────────────────────────────────────────────────────
151
211
  const WATCHDOG_INTERVAL_MS = 30_000;
152
212
  let watchdogTimer = null;
@@ -427,14 +487,35 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engine
427
487
  db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, engine, status, model, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, engine, 'running', model ?? null, new Date().toISOString());
428
488
  return agentSessionId;
429
489
  }
430
- // ── Event handler ─────────────────────────────────────────────────────────────
431
- /**
432
- * Snapshots of task completion and task state at `session:started`, read back
433
- * at `session:ended` for auto-loop stall detection. A task moved to
434
- * `in_progress` is meaningful progress even if it is not done yet.
435
- */
436
- const tasksDoneSnapshot = new Map();
437
- const taskStateSnapshot = new Map();
490
+ const taskProgressSnapshots = new Map();
491
+ function rememberTaskProgressSnapshot(workspaceId, agentSessionId) {
492
+ const workspaceSnapshots = taskProgressSnapshots.get(workspaceId) ?? new Map();
493
+ workspaceSnapshots.set(agentSessionId, {
494
+ doneCount: getDoneTaskCount(workspaceId),
495
+ stateSignature: getTaskStateSignature(workspaceId),
496
+ });
497
+ taskProgressSnapshots.set(workspaceId, workspaceSnapshots);
498
+ }
499
+ function consumeTaskProgressSnapshot(workspaceId, agentSessionId) {
500
+ const workspaceSnapshots = taskProgressSnapshots.get(workspaceId);
501
+ const snapshot = workspaceSnapshots?.get(agentSessionId);
502
+ workspaceSnapshots?.delete(agentSessionId);
503
+ if (workspaceSnapshots?.size === 0)
504
+ taskProgressSnapshots.delete(workspaceId);
505
+ return snapshot;
506
+ }
507
+ function rememberResumeFailed(workspaceId, agentSessionId) {
508
+ const workspaceSessions = resumeFailedSessions.get(workspaceId) ?? new Set();
509
+ workspaceSessions.add(agentSessionId);
510
+ resumeFailedSessions.set(workspaceId, workspaceSessions);
511
+ }
512
+ function consumeResumeFailed(workspaceId, agentSessionId) {
513
+ const workspaceSessions = resumeFailedSessions.get(workspaceId);
514
+ const hadResumeFailure = workspaceSessions?.delete(agentSessionId) ?? false;
515
+ if (workspaceSessions?.size === 0)
516
+ resumeFailedSessions.delete(workspaceId);
517
+ return hadResumeFailure;
518
+ }
438
519
  function getDoneTaskCount(workspaceId) {
439
520
  try {
440
521
  const db = getDb();
@@ -465,12 +546,11 @@ function getTaskStateSignature(workspaceId) {
465
546
  }
466
547
  /** Clear the in-memory done-count snapshot for a workspace (called on delete). */
467
548
  export function forgetTasksDoneSnapshot(workspaceId) {
468
- tasksDoneSnapshot.delete(workspaceId);
469
- taskStateSnapshot.delete(workspaceId);
549
+ taskProgressSnapshots.delete(workspaceId);
470
550
  }
471
551
  /** Drop the resume-failed flag for a workspace (called on delete). */
472
552
  export function forgetResumeFailed(workspaceId) {
473
- resumeFailedSet.delete(workspaceId);
553
+ resumeFailedSessions.delete(workspaceId);
474
554
  }
475
555
  /** Drop the pending question/permission queue for a workspace (called on delete). */
476
556
  export function forgetPendingQueue(workspaceId) {
@@ -483,17 +563,35 @@ export function forgetPreAwaitStatus(workspaceId) {
483
563
  /** Drop the cached engine session id for a workspace (called on delete). */
484
564
  export function forgetSessionId(workspaceId) {
485
565
  sessionIds.delete(workspaceId);
566
+ sessionLifecycleOwners.delete(workspaceId);
486
567
  }
487
- function handleEvent(workspaceId, agentSessionId, ev) {
488
- routeEvent(workspaceId, agentSessionId, ev);
568
+ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
569
+ const registeredController = controllers.get(workspaceId);
570
+ const hasReplacement = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
571
+ const sourceNoLongerOwnsWorkspace = sourceController !== undefined && registeredController !== sourceController;
572
+ const lifecycleOwnership = getSessionLifecycleOwnership(workspaceId, agentSessionId);
573
+ const sourceIsSuperseded = sourceController !== undefined &&
574
+ lifecycleOwnership !== undefined &&
575
+ lifecycleOwnership.owner !== sourceController &&
576
+ lifecycleOwnership.supersededControllers.has(sourceController);
577
+ // A stopped controller can continue draining buffered engine events after it
578
+ // has lost ownership. Drop every stale non-terminal event before it reaches
579
+ // persistence/live delivery, including the gap where no replacement is
580
+ // registered yet. A terminal event still records session-local history; it
581
+ // carries a durable marker only when a different controller now owns the
582
+ // workspace, or when the latest owner already ended, so replay does not
583
+ // depend on transient browser state.
584
+ if (sourceNoLongerOwnsWorkspace && ev.kind !== 'session:ended')
585
+ return;
586
+ const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
587
+ routeEvent(workspaceId, agentSessionId, routedEvent);
489
588
  if (ev.kind === 'rate_limit') {
490
589
  latestRateLimitInfo.set(workspaceId, ev.info);
491
590
  }
492
591
  // Snapshot the done-count at session start so the session:ended hook below
493
592
  // can compute a delta for auto-loop stall detection.
494
593
  if (ev.kind === 'session:started') {
495
- tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
496
- taskStateSnapshot.set(workspaceId, getTaskStateSignature(workspaceId));
594
+ rememberTaskProgressSnapshot(workspaceId, agentSessionId);
497
595
  }
498
596
  // Legacy fallback: the built-in `ScheduleWakeup` tool (CLI tradition) isn't
499
597
  // declared by the SDK, so we intercept the tool:call event and apply the
@@ -580,19 +678,23 @@ function handleEvent(workspaceId, agentSessionId, ev) {
580
678
  void handleTransientAutoLoopFailure(workspaceId);
581
679
  }
582
680
  if (ev.kind === 'error' && ev.category === 'resume_failed') {
583
- resumeFailedSet.add(workspaceId);
681
+ rememberResumeFailed(workspaceId, agentSessionId);
584
682
  clearStaleEngineSessionId(workspaceId);
585
683
  }
586
684
  if (ev.kind === 'session:ended') {
587
- const isResumeFailed = resumeFailedSet.delete(workspaceId);
588
- const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
685
+ if (sourceIsSuperseded && sourceController) {
686
+ settleSupersededSessionController(workspaceId, agentSessionId, sourceController);
687
+ return;
688
+ }
689
+ markSessionLifecycleOwnerEnded(workspaceId, agentSessionId, sourceController);
690
+ const isResumeFailed = consumeResumeFailed(workspaceId, agentSessionId);
691
+ const snapshot = consumeTaskProgressSnapshot(workspaceId, agentSessionId);
692
+ const before = snapshot?.doneCount ?? getDoneTaskCount(workspaceId);
589
693
  const after = getDoneTaskCount(workspaceId);
590
694
  const completedDelta = Math.max(0, after - before);
591
- const taskStateBefore = taskStateSnapshot.get(workspaceId) ?? getTaskStateSignature(workspaceId);
695
+ const taskStateBefore = snapshot?.stateSignature ?? getTaskStateSignature(workspaceId);
592
696
  const taskStateAfter = getTaskStateSignature(workspaceId);
593
697
  const progressDelta = completedDelta > 0 || taskStateBefore !== taskStateAfter ? 1 : 0;
594
- tasksDoneSnapshot.delete(workspaceId);
595
- taskStateSnapshot.delete(workspaceId);
596
698
  clearPendingForSession(workspaceId, agentSessionId);
597
699
  // A completed/failed SDK session cannot resolve canUseTool anymore. Drop
598
700
  // its persisted prompts too, otherwise a reconnect resurrects a panel
@@ -601,7 +703,9 @@ function handleEvent(workspaceId, agentSessionId, ev) {
601
703
  // Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
602
704
  // startAgent, otherwise startAgent throws "Agent already running" because
603
705
  // the just-ended controller is still in the map.
604
- onSessionEnded(workspaceId, agentSessionId, ev.exitCode, ev.reason, isResumeFailed);
706
+ const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
707
+ if (!ownsWorkspaceLifecycle)
708
+ return;
605
709
  // resume_failed exits with an error but the workspace is fine (stale id
606
710
  // cleared, next iteration will start fresh) — report 'completed' to
607
711
  // auto-loop so it continues.
@@ -664,21 +768,7 @@ function handleEvent(workspaceId, agentSessionId, ev) {
664
768
  }
665
769
  }
666
770
  }
667
- function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFailed = false) {
668
- const currentWorkspace = getWs(workspaceId);
669
- const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
670
- const ctrl = controllers.get(workspaceId);
671
- const wasStopping = ctrl?.status === 'stopping';
672
- // Identity-preserving cleanup: only remove the controller if the map still
673
- // points to this exact instance (a new controller may have been started in
674
- // the meantime via stop-then-start).
675
- if (ctrl && controllers.get(workspaceId) === ctrl) {
676
- controllers.delete(workspaceId);
677
- }
678
- unregisterProcess(workspaceId);
679
- if (!preserveQuotaBackoff) {
680
- retryCounts.delete(workspaceId);
681
- }
771
+ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode, reason, resumeFailed = false) {
682
772
  // Update the agent_sessions row
683
773
  try {
684
774
  const db = getDb();
@@ -687,8 +777,22 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFai
687
777
  catch (err) {
688
778
  console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
689
779
  }
780
+ const registeredController = controllers.get(workspaceId);
781
+ const isSuperseded = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
782
+ if (isSuperseded)
783
+ return false;
784
+ const currentWorkspace = getWs(workspaceId);
785
+ const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
786
+ const wasStopping = sourceController?.status === 'stopping';
787
+ if (registeredController === sourceController) {
788
+ controllers.delete(workspaceId);
789
+ }
790
+ unregisterProcess(workspaceId);
791
+ if (!preserveQuotaBackoff) {
792
+ retryCounts.delete(workspaceId);
793
+ }
690
794
  if (wasStopping)
691
- return;
795
+ return false;
692
796
  // When the session hit quota, handleQuota() already transitioned the
693
797
  // workspace to `quota` and armed the retry via quotaBackoffService.
694
798
  // Preserve that pending backoff in the quota path; otherwise clear any
@@ -704,7 +808,7 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFai
704
808
  catch {
705
809
  // best-effort
706
810
  }
707
- return;
811
+ return true;
708
812
  }
709
813
  // `reason` is authoritative (with the SDK engine `exitCode` is often null,
710
814
  // so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
@@ -733,6 +837,7 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFai
733
837
  catch {
734
838
  // best-effort
735
839
  }
840
+ return true;
736
841
  }
737
842
  // ── Public API ────────────────────────────────────────────────────────────────
738
843
  /**
@@ -816,7 +921,9 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
816
921
  mcpServers: buildMcpServers(workspaceId),
817
922
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
818
923
  };
819
- const controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, ev));
924
+ let controller;
925
+ controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
926
+ registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
820
927
  controllers.set(workspaceId, controller);
821
928
  // Kick off engine.start asynchronously. Errors surface as error events.
822
929
  void controller
@@ -836,12 +943,12 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
836
943
  .catch((err) => {
837
944
  console.error('[orchestrator] engine.start failed:', err);
838
945
  const message = err instanceof Error ? err.message : String(err);
839
- handleEvent(workspaceId, agentSessionId, {
946
+ handleEvent(workspaceId, agentSessionId, controller, {
840
947
  kind: 'error',
841
948
  category: 'spawn_failed',
842
949
  message,
843
950
  });
844
- handleEvent(workspaceId, agentSessionId, {
951
+ handleEvent(workspaceId, agentSessionId, controller, {
845
952
  kind: 'session:ended',
846
953
  reason: 'error',
847
954
  exitCode: null,
@@ -854,17 +961,23 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
854
961
  * alive — the current tool call is aborted and the agent waits for the next
855
962
  * user message.
856
963
  */
857
- export function interruptAgent(workspaceId) {
964
+ export function interruptAgent(workspaceId, options = {}) {
858
965
  const ctrl = controllers.get(workspaceId);
859
966
  if (!ctrl) {
860
- throw new Error(`No agent running for workspace '${workspaceId}'`);
967
+ throw new InterruptAgentError(`No agent running for workspace '${workspaceId}'`, 'no_agent_running');
968
+ }
969
+ if (options.expectedSessionId !== undefined && ctrl.agentSessionId !== options.expectedSessionId) {
970
+ throw new InterruptAgentError(`Session '${options.expectedSessionId}' is not active for workspace '${workspaceId}'`, 'session_not_active');
861
971
  }
862
972
  try {
863
973
  ctrl.interrupt();
864
974
  }
865
975
  catch (err) {
866
976
  const message = err instanceof Error ? err.message : String(err);
867
- throw new Error(`Failed to interrupt agent for workspace '${workspaceId}': ${message}`);
977
+ throw new InterruptAgentError(`Failed to interrupt agent for workspace '${workspaceId}': ${message}`, 'interrupt_failed');
978
+ }
979
+ if (options.disableAutoLoop && autoLoopService.getStatus(workspaceId).auto_loop) {
980
+ autoLoopService.disable(workspaceId, 'user-action');
868
981
  }
869
982
  }
870
983
  /** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
@@ -906,7 +1019,7 @@ export function stopAgent(workspaceId) {
906
1019
  });
907
1020
  }
908
1021
  /** Write a user message to the running agent. */
909
- export function sendMessage(workspaceId, content, expectedSessionId) {
1022
+ export async function sendMessage(workspaceId, content, expectedSessionId) {
910
1023
  const ctrl = controllers.get(workspaceId);
911
1024
  if (!ctrl) {
912
1025
  throw new Error(`No agent running for workspace '${workspaceId}'`);
@@ -915,7 +1028,34 @@ export function sendMessage(workspaceId, content, expectedSessionId) {
915
1028
  throw new Error(`Session '${expectedSessionId}' is not active for workspace '${workspaceId}'`);
916
1029
  }
917
1030
  wakeupService.cancel(workspaceId, 'user-message');
918
- return ctrl.sendMessage(content);
1031
+ await ctrl.sendMessage(content);
1032
+ }
1033
+ export async function sendMessageForFallback(workspaceId, content) {
1034
+ const capturedController = controllers.get(workspaceId);
1035
+ if (!capturedController)
1036
+ return { status: 'stopped' };
1037
+ wakeupService.cancel(workspaceId, 'user-message');
1038
+ try {
1039
+ await capturedController.sendMessage(content);
1040
+ return { status: 'sent', sessionId: capturedController.agentSessionId };
1041
+ }
1042
+ catch {
1043
+ const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
1044
+ while (controllers.get(workspaceId) === capturedController) {
1045
+ const remaining = deadline - Date.now();
1046
+ if (remaining <= 0) {
1047
+ throw new Error(`Timed out waiting for agent controller turnover for workspace '${workspaceId}'`);
1048
+ }
1049
+ await new Promise((resolve) => {
1050
+ setTimeout(resolve, Math.min(FALLBACK_CONTROLLER_TURNOVER_POLL_MS, remaining));
1051
+ });
1052
+ }
1053
+ const replacementController = controllers.get(workspaceId);
1054
+ if (!replacementController)
1055
+ return { status: 'stopped' };
1056
+ await replacementController.sendMessage(content);
1057
+ return { status: 'sent', sessionId: replacementController.agentSessionId };
1058
+ }
919
1059
  }
920
1060
  /**
921
1061
  * Render the user's answer to an AskUserQuestion as a markdown chat
@@ -1223,10 +1363,9 @@ export function forgetRateLimitInfo(workspaceId) {
1223
1363
  latestRateLimitInfo.delete(workspaceId);
1224
1364
  }
1225
1365
  /**
1226
- * Null out the engine_session_id on all agent_sessions rows for a workspace
1227
- * and clear the in-memory sessionIds cache. Called when a --resume attempt
1228
- * fails ("No conversation found with session ID") so that the next startAgent
1229
- * call starts a fresh conversation instead of retrying the stale ID.
1366
+ * Null out every engine_session_id for the workspace and clear its cache.
1367
+ * This runs only while the failing session owns the workspace, ensuring a
1368
+ * future resume cannot fall back to an older stale engine session.
1230
1369
  */
1231
1370
  function clearStaleEngineSessionId(workspaceId) {
1232
1371
  try {
@@ -1412,4 +1551,16 @@ export function _runWatchdogForTest() {
1412
1551
  runWatchdog();
1413
1552
  }
1414
1553
  /** Test-only export. Not part of the public module API. */
1415
- export const __test__ = { handleEvent, handleQuota };
1554
+ export const __test__ = {
1555
+ getSessionLifecycleOwnerCount(workspaceId) {
1556
+ return sessionLifecycleOwners.get(workspaceId)?.size ?? 0;
1557
+ },
1558
+ handleEvent(workspaceId, agentSessionId, ev) {
1559
+ const sourceController = controllers.get(workspaceId);
1560
+ if (sourceController && sourceController.agentSessionId !== agentSessionId) {
1561
+ throw new Error(`Session '${agentSessionId}' is not active for workspace '${workspaceId}'`);
1562
+ }
1563
+ handleEvent(workspaceId, agentSessionId, sourceController, ev);
1564
+ },
1565
+ handleQuota,
1566
+ };
@@ -4,6 +4,7 @@ export class SessionController {
4
4
  engine;
5
5
  onEvent;
6
6
  _engineProcess;
7
+ _startPromise;
7
8
  _status = 'running';
8
9
  get engineProcess() {
9
10
  return this._engineProcess;
@@ -15,15 +16,30 @@ export class SessionController {
15
16
  this.onEvent = onEvent;
16
17
  }
17
18
  async start(options) {
18
- if (this._engineProcess)
19
+ if (this._startPromise)
19
20
  throw new Error('SessionController already started');
20
- this._engineProcess = await this.engine.start(options, (ev) => this.handle(ev));
21
+ this._startPromise = this.startEngine(options);
22
+ await this._startPromise;
23
+ }
24
+ async startEngine(options) {
25
+ const process = await this.engine.start(options, (ev) => this.handle(ev));
26
+ this._engineProcess = process;
27
+ if (this._status === 'stopping') {
28
+ this._engineProcess = undefined;
29
+ await process.stop();
30
+ return;
31
+ }
21
32
  this._status = 'running';
22
33
  }
23
- sendMessage(content) {
34
+ async sendMessage(content) {
35
+ if (!this._startPromise)
36
+ throw new Error('SessionController not started');
37
+ await this._startPromise;
38
+ if (this._status === 'stopping')
39
+ throw new Error('SessionController is stopping');
24
40
  if (!this._engineProcess)
25
41
  throw new Error('SessionController not started');
26
- this._engineProcess.sendMessage(content);
42
+ await this._engineProcess.sendMessage(content);
27
43
  }
28
44
  interrupt() {
29
45
  if (!this._engineProcess)
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { WORKTREES_PATH } from '../../shared/consts.js';
4
4
  import { isValidProjectColor } from '../../shared/project-colors.js';
5
5
  import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
6
+ import { DEFAULT_WHIP_SHORTCUT, isValidWhipShortcut } from '../../shared/whip-shortcut.js';
6
7
  import { listClaudeMcpEntries } from '../utils/mcp-client.js';
7
8
  import { getSettingsPath } from '../utils/paths.js';
8
9
  import { InvalidWorktreesPathError, resolveGlobalWorktreesRoot, sanitizeWorktreesPath, validateWorktreesPath, } from '../utils/worktree-paths.js';
@@ -110,6 +111,12 @@ export const DEFAULT_WORKSPACE_TAGS = [
110
111
  * one pre-selected on the workspace creation page.
111
112
  */
112
113
  export const DEFAULT_BRANCH_PREFIXES = ['feature', 'fix', 'hotfix', 'chore', 'refactor', 'docs', 'test'];
114
+ function normalizeWhipShortcut(value) {
115
+ return isValidWhipShortcut(value) ? value : DEFAULT_WHIP_SHORTCUT;
116
+ }
117
+ function normalizeWhipVolume(value) {
118
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? value : 1;
119
+ }
113
120
  /**
114
121
  * Sanitize a raw branch-prefix list: trim, strip surrounding slashes, drop
115
122
  * entries that aren't valid git branch segments, dedupe, cap length. Returns
@@ -803,6 +810,28 @@ const settingsMigrations = [
803
810
  global.bitbucketUsername = '';
804
811
  },
805
812
  },
813
+ {
814
+ version: 51,
815
+ name: 'add-whip-feature-toggle',
816
+ migrate: ({ global }) => {
817
+ if (typeof global.whipEnabled !== 'boolean')
818
+ global.whipEnabled = false;
819
+ },
820
+ },
821
+ {
822
+ version: 52,
823
+ name: 'add-whip-keyboard-shortcut',
824
+ migrate: ({ global }) => {
825
+ global.whipShortcut = normalizeWhipShortcut(global.whipShortcut);
826
+ },
827
+ },
828
+ {
829
+ version: 53,
830
+ name: 'add-whip-volume',
831
+ migrate: ({ global }) => {
832
+ global.whipVolume = normalizeWhipVolume(global.whipVolume);
833
+ },
834
+ },
806
835
  ];
807
836
  /** Current settings schema version — always equals the highest migration version. */
808
837
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -899,6 +928,9 @@ function defaultSettings() {
899
928
  notionEnabled: true,
900
929
  sentryEnabled: true,
901
930
  showThinkingBlocks: true,
931
+ whipEnabled: false,
932
+ whipShortcut: DEFAULT_WHIP_SHORTCUT,
933
+ whipVolume: 1,
902
934
  tags: [...DEFAULT_WORKSPACE_TAGS],
903
935
  branchPrefixes: [...DEFAULT_BRANCH_PREFIXES],
904
936
  worktreesPath: WORKTREES_PATH,
@@ -982,6 +1014,9 @@ export function runSettingsMigrations(raw) {
982
1014
  }
983
1015
  }
984
1016
  current.global.worktreesPath = sanitizeWorktreesPath(current.global.worktreesPath);
1017
+ current.global.whipEnabled = current.global.whipEnabled === true;
1018
+ current.global.whipShortcut = normalizeWhipShortcut(current.global.whipShortcut);
1019
+ current.global.whipVolume = normalizeWhipVolume(current.global.whipVolume);
985
1020
  current.schemaVersion = version;
986
1021
  return current;
987
1022
  }
@@ -1010,15 +1045,18 @@ function readSettings() {
1010
1045
  return defaults;
1011
1046
  }
1012
1047
  const originalVersion = parsed.schemaVersion;
1048
+ const globalBeforeMigrations = JSON.stringify(parsed.global ?? null);
1013
1049
  const migrated = runSettingsMigrations(parsed);
1050
+ const normalizedGlobalFields = JSON.stringify(migrated.global) !== globalBeforeMigrations;
1014
1051
  // Restore any global fields that may have been removed by external edits.
1015
1052
  // Defaults act as fallback for missing keys; existing values are preserved.
1016
1053
  const globalDefaults = defaultSettings().global;
1017
1054
  const globalBeforeDefaults = JSON.stringify(migrated.global);
1018
1055
  migrated.global = { ...globalDefaults, ...migrated.global };
1019
1056
  const restoredGlobalFields = JSON.stringify(migrated.global) !== globalBeforeDefaults;
1020
- // Persist if migrations bumped the version, or if global fields were restored.
1021
- if (migrated.schemaVersion !== originalVersion || restoredGlobalFields) {
1057
+ // Persist if migrations bumped the version, known fields were normalized,
1058
+ // or missing global fields were restored.
1059
+ if (migrated.schemaVersion !== originalVersion || normalizedGlobalFields || restoredGlobalFields) {
1022
1060
  writeSettings(migrated);
1023
1061
  }
1024
1062
  return migrated;
@@ -1299,6 +1337,9 @@ export function updateGlobalSettings(data) {
1299
1337
  'notionEnabled',
1300
1338
  'sentryEnabled',
1301
1339
  'showThinkingBlocks',
1340
+ 'whipEnabled',
1341
+ 'whipShortcut',
1342
+ 'whipVolume',
1302
1343
  'tags',
1303
1344
  'branchPrefixes',
1304
1345
  'worktreesPath',
@@ -1335,6 +1376,15 @@ export function updateGlobalSettings(data) {
1335
1376
  const sanitized = sanitizeBranchPrefixes(filtered.branchPrefixes);
1336
1377
  filtered.branchPrefixes = sanitized.length > 0 ? sanitized : settings.global.branchPrefixes;
1337
1378
  }
1379
+ if (filtered.whipVolume !== undefined) {
1380
+ const rawWhipVolume = filtered.whipVolume;
1381
+ const whipVolume = typeof rawWhipVolume === 'number'
1382
+ ? rawWhipVolume
1383
+ : typeof rawWhipVolume === 'string' && rawWhipVolume.trim().length > 0
1384
+ ? Number(rawWhipVolume)
1385
+ : Number.NaN;
1386
+ filtered.whipVolume = Number.isFinite(whipVolume) ? Math.max(0, Math.min(1, whipVolume)) : 1;
1387
+ }
1338
1388
  for (const key of [
1339
1389
  'audioNotificationVolume',
1340
1390
  'audioQuestionVolume',
@@ -1356,6 +1406,12 @@ export function updateGlobalSettings(data) {
1356
1406
  const t = Number(filtered.voiceTemperature);
1357
1407
  filtered.voiceTemperature = Number.isFinite(t) ? Math.max(0, Math.min(1, t)) : settings.global.voiceTemperature;
1358
1408
  }
1409
+ if (filtered.whipEnabled !== undefined) {
1410
+ filtered.whipEnabled = filtered.whipEnabled === true;
1411
+ }
1412
+ if (filtered.whipShortcut !== undefined) {
1413
+ filtered.whipShortcut = normalizeWhipShortcut(filtered.whipShortcut);
1414
+ }
1359
1415
  if (filtered.worktreesPath !== undefined) {
1360
1416
  filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
1361
1417
  ensureGlobalWorktreesRootExists(filtered.worktreesPath);
@@ -0,0 +1,41 @@
1
+ export const DEFAULT_WHIP_SHORTCUT = 'mod+shift+x';
2
+ export const WHIP_SHORTCUT_MODIFIERS = ['mod', 'ctrl', 'meta', 'alt', 'shift'];
3
+ export const RESERVED_WHIP_SHORTCUTS = [
4
+ 'mod+w',
5
+ 'mod+shift+w',
6
+ 'ctrl+w',
7
+ 'ctrl+shift+w',
8
+ 'meta+w',
9
+ 'meta+shift+w',
10
+ ];
11
+ const RESERVED_WHIP_SHORTCUT_SET = new Set(RESERVED_WHIP_SHORTCUTS);
12
+ const NON_KEY_TOKENS = new Set(['control', ...WHIP_SHORTCUT_MODIFIERS]);
13
+ export function isReservedWhipShortcut(value) {
14
+ return RESERVED_WHIP_SHORTCUT_SET.has(value);
15
+ }
16
+ export function parseWhipShortcut(value) {
17
+ if (typeof value !== 'string' || value.length === 0 || value !== value.toLowerCase())
18
+ return null;
19
+ if (isReservedWhipShortcut(value))
20
+ return null;
21
+ const tokens = value.split('+');
22
+ if (tokens.some((token) => token.length === 0))
23
+ return null;
24
+ const key = tokens.at(-1);
25
+ if (!key || !/^[a-z0-9][a-z0-9_-]*$/.test(key) || NON_KEY_TOKENS.has(key))
26
+ return null;
27
+ const modifiers = tokens.slice(0, -1);
28
+ const parsedModifiers = new Set();
29
+ let previousIndex = -1;
30
+ for (const modifier of modifiers) {
31
+ const index = WHIP_SHORTCUT_MODIFIERS.indexOf(modifier);
32
+ if (index <= previousIndex)
33
+ return null;
34
+ previousIndex = index;
35
+ parsedModifiers.add(modifier);
36
+ }
37
+ return { modifiers: parsedModifiers, key };
38
+ }
39
+ export function isValidWhipShortcut(value) {
40
+ return parseWhipShortcut(value) !== null;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.11.6",
3
+ "version": "1.11.8",
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",