@loicngr/kobo 1.11.6 → 1.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/dist/server/routes/workspaces.js +66 -68
- package/dist/server/services/agent/engines/codex/engine.js +42 -7
- package/dist/server/services/agent/orchestrator.js +205 -54
- package/dist/server/services/agent/session-controller.js +20 -4
- package/dist/server/services/settings-service.js +58 -2
- package/dist/shared/whip-shortcut.js +41 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-CdjS_Ay5.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-oLAJW1vx.js → ChangelogPage-BTU5fnD6.js} +1 -1
- package/src/client/dist/pwa/assets/{ClosePopup-BiMd0EFR.js → ClosePopup-BAuFTRhx.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-D7R9aFEL.js → CreatePage-CsEecOVF.js} +2 -2
- package/src/client/dist/pwa/assets/{DiffViewer-CS1roZQD.js → DiffViewer-Dq8vqrxA.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-cP6NvQlI.js → HealthPage-sbDeyEHP.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-DFwMZKt9.js +37 -0
- package/src/client/dist/pwa/assets/{MainLayout-CYT-Kkia.css → MainLayout-DlS2wxkK.css} +1 -1
- package/src/client/dist/pwa/assets/{QBtnToggle-5Ypsr5PI.js → QBtnToggle-dJ_m8WhV.js} +1 -1
- package/src/client/dist/pwa/assets/{QChip-CIwjmeox.js → QChip-Bo3J1D2W.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-OaS8rLcb.js +36 -0
- package/src/client/dist/pwa/assets/QTooltip-j1MBzbTI.js +1 -0
- package/src/client/dist/pwa/assets/{SearchPage-DnUu9LCd.js → SearchPage-BmwtbAot.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-COvUlfw5.js +16 -0
- package/src/client/dist/pwa/assets/SettingsPage-DyZUZK5m.css +1 -0
- package/src/client/dist/pwa/assets/{ThinkingItem-Dsh0S-4R.js → ThinkingItem-6MaIe4ws.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-BK8skWbU.js → TouchPan-BIp_9Cq6.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-BE5DwWlF.css → WorkspacePage-Bgxg_Rua.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DLtgENoR.js +4 -0
- package/src/client/dist/pwa/assets/{build-path-tree-Dt3aRTqV.js → build-path-tree-Du1muMLG.js} +1 -1
- package/src/client/dist/pwa/assets/{cssMode-C2-EJrmK.js → cssMode-DJXtqmV0.js} +1 -1
- package/src/client/dist/pwa/assets/documents-Br5SnzNo.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api2-CkPvAZB5.js → editor.api2-CZ8VVoEU.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.main-B-8Qv0oB.js → editor.main-DevrSvQT.js} +2 -2
- package/src/client/dist/pwa/assets/{expand-template-Cnj1_kF0.js → expand-template-CL8E3tTa.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BGHblyDL.js → formatters-BfN5-DOd.js} +1 -1
- package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
- package/src/client/dist/pwa/assets/{freemarker2-VFyuTCM8.js → freemarker2-C2qa_OD-.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-g2m5ALjb.js → handlebars-rmnqsHUd.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DxnmOMw6.js → html-BGmv8Xd1.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-Df0wbbtZ.js → htmlMode-BAm18C8p.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-JdLLVp05.js +1 -0
- package/src/client/dist/pwa/assets/{index-CXciTur8.js → index-BbjyEh9i.js} +9 -9
- package/src/client/dist/pwa/assets/{javascript-Df_-1Pv_.js → javascript-CHZDr7Bk.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-BJFi57QF.js → jsonMode-B6gDhGXQ.js} +1 -1
- package/src/client/dist/pwa/assets/{kobo-commands-HJVSrhFj.js → kobo-commands-BR2agQLr.js} +1 -1
- package/src/client/dist/pwa/assets/{layout-DLVS_2RL.js → layout-D2SIWUB9.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Dy9wgTn4.js → liquid-oF7cqWzJ.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-DcaGTZUU.js → lspLanguageFeatures-B8EE1Ucr.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-B1nH83dR.js → mdx-DXdcJzaj.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution--RPgZPOx.js → monaco.contribution-CAMlPG7z.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-BnLlhIx6.js +1 -0
- package/src/client/dist/pwa/assets/{pending-diff-open-BS9BktRr.js → pending-diff-open-BMRVHZZL.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-ClcSQKmQ.js → permissionModes-CfAFkWP3.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-SXRt-W03.js → project-color-DO7Am583.js} +1 -1
- package/src/client/dist/pwa/assets/{python-CJMnM2o7.js → python-DKQcYTdf.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BUhyGYdm.js → razor-2a-_ORi3.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BH1Z10ar.js → render-chat-markdown-ESsukc9f.js} +1 -1
- package/src/client/dist/pwa/assets/{tsMode-Cps5WrVq.js → tsMode-DB3IvOW4.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-DHc8Mn2c.js → typescript-Dc5EP7Bs.js} +1 -1
- package/src/client/dist/pwa/assets/{use-onboarding-BEN9LPyX.js → use-onboarding-x0pF_4eq.js} +1 -1
- package/src/client/dist/pwa/assets/{use-quasar-CgjwhVVN.js → use-quasar-CBDv7_fL.js} +1 -1
- package/src/client/dist/pwa/assets/{workers-B_i0s5nK.js → workers-DO53K6md.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-B4oQ8zNG.js → xml-D-yJwZeB.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-CUpQdB7b.js → yaml--VhNMxaM.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
- package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BM5l2qIA.js +0 -8
- package/src/client/dist/pwa/assets/MainLayout-BS27uyox.js +0 -37
- package/src/client/dist/pwa/assets/QSelect-BYNOOfHf.js +0 -36
- package/src/client/dist/pwa/assets/QTooltip-A4le4l3f.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-BPplhEXC.js +0 -16
- package/src/client/dist/pwa/assets/SettingsPage-Os-6b-6H.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-YM_Qujn2.js +0 -4
- package/src/client/dist/pwa/assets/documents-BFIwenTv.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Cf8eFhZ8.js +0 -1
- 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
|
|
149
|
-
const
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
+
rememberResumeFailed(workspaceId, agentSessionId);
|
|
584
682
|
clearStaleEngineSessionId(workspaceId);
|
|
585
683
|
}
|
|
586
684
|
if (ev.kind === 'session:ended') {
|
|
587
|
-
|
|
588
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
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__ = {
|
|
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.
|
|
19
|
+
if (this._startPromise)
|
|
19
20
|
throw new Error('SessionController already started');
|
|
20
|
-
this.
|
|
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,
|
|
1021
|
-
|
|
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.
|
|
3
|
+
"version": "1.11.7",
|
|
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",
|