@loicngr/kobo 1.11.44 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +57 -4
- package/CHANGELOG.md +13 -0
- package/CODE_OF_CONDUCT.md +16 -0
- package/CONFIGURATION.md +1968 -0
- package/CONTRIBUTING.md +83 -0
- package/DESIGN.md +296 -0
- package/README.md +48 -8
- package/SECURITY.md +54 -0
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
- package/dist/mcp-server/kobo-tasks-server.js +31 -5
- package/dist/server/db/migrations.js +37 -0
- package/dist/server/db/schema.js +6 -1
- package/dist/server/db/session-handoff-schema.js +24 -0
- package/dist/server/index.js +8 -0
- package/dist/server/routes/documents.js +1 -1
- package/dist/server/routes/environment.js +18 -0
- package/dist/server/routes/health.js +17 -2
- package/dist/server/routes/integrations.js +29 -0
- package/dist/server/routes/settings.js +5 -0
- package/dist/server/routes/sounds.js +60 -0
- package/dist/server/routes/workspaces.js +118 -20
- package/dist/server/services/agent/engines/claude-code/engine.js +15 -0
- package/dist/server/services/agent/engines/codex/engine.js +1 -0
- package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
- package/dist/server/services/agent/engines/codex/spawn.js +2 -2
- package/dist/server/services/agent/integration-mcp.js +49 -0
- package/dist/server/services/agent/orchestrator.js +68 -11
- package/dist/server/services/auto-loop-service.js +45 -25
- package/dist/server/services/custom-sound-service.js +0 -0
- package/dist/server/services/engine-handoff-service.js +5 -5
- package/dist/server/services/environment-check-service.js +125 -0
- package/dist/server/services/initial-prompt-template-service.js +8 -21
- package/dist/server/services/integration-config-service.js +65 -0
- package/dist/server/services/legacy-public-prompts.js +112 -0
- package/dist/server/services/notion-service.js +25 -61
- package/dist/server/services/sentry-service.js +17 -12
- package/dist/server/services/session-activity-service.js +17 -0
- package/dist/server/services/session-handoff-runtime.js +26 -0
- package/dist/server/services/session-handoff-service.js +479 -0
- package/dist/server/services/settings-defaults.js +5 -0
- package/dist/server/services/settings-service.js +112 -23
- package/dist/server/services/skill-suite-prompts.js +29 -22
- package/dist/server/services/templates-service.js +31 -8
- package/dist/server/services/terminal-service.js +16 -0
- package/dist/server/services/wakeup-service.js +15 -5
- package/dist/server/services/workspace-service.js +30 -6
- package/dist/server/services/worktree-service.js +3 -1
- package/dist/server/utils/git-repo-lock.js +7 -3
- package/dist/server/utils/mcp-client.js +26 -12
- package/dist/server/utils/mcp-process.js +25 -0
- package/dist/server/utils/node-pty-spawn-helper.js +31 -0
- package/dist/server/utils/paths.js +7 -0
- package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
- package/dist/shared/auto-loop-prompts.js +6 -2
- package/dist/shared/automatic-admission.js +1 -0
- package/dist/shared/document-roots.js +2 -0
- package/dist/shared/environment.js +1 -0
- package/dist/shared/notification-assets.js +73 -0
- package/dist/shared/session-handoff.js +4 -0
- package/dist/shared/skill-suite-prompts.js +6 -3
- package/dist/shared/workflow-policy.js +25 -0
- package/docs/getting-started.md +47 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +19 -7
- package/src/client/dist/pwa/assets/ActivityFeed-DRgetl1M.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-DSKCvpr2.js → ChangelogPage-BJdICFvz.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-DDxcsxyC.js → ComparisonPanel-2GLd8KIy.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-B9AdNJZj.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-CzVOvjSP.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-X25vuf83.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-DfEoI_f7.js → DocumentsPanel-BastjrEm.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-D85PwhKb.js → DrawerToggleButton-DbipGmBL.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-BFCnyhE2.css → GitPanel-C4wOcZV2.css} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-CnW4iVao.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-CaLCpejR.js → HealthPage-aieNQly-.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-Cc4XzCAy.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DqBbE8o4.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-qJWGcZpC.js → QBtnDropdown-BWy-rh45.js} +1 -1
- package/src/client/dist/pwa/assets/QExpansionItem-DfmdBGuW.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-BfqaWakg.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-cDK5AOQ5.js → QTooltip-Cy0T0vk2.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-YQPObRR3.js +1 -0
- package/src/client/dist/pwa/assets/{SearchPage-BO-J4aNc.js → SearchPage-BoBjmItc.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-0GtDcXRC.js +9 -0
- package/src/client/dist/pwa/assets/{SettingsPage-W6N01tGz.css → SettingsPage-BYdLXJEJ.css} +1 -1
- package/src/client/dist/pwa/assets/SplitWorkspacePage-4wFh65Lh.js +1 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-DitOCad1.js → TerminalPanel-b70x_jqH.js} +9 -9
- package/src/client/dist/pwa/assets/{TouchPan-DvPAhZsa.js → TouchPan-CcTVcxwd.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-CJYdH3Jf.js → TourReplayButton-CpEMnQS8.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-C5lTHTp2.js +1 -0
- package/src/client/dist/pwa/assets/{WorkspacePage-DAVo-E4p.css → WorkspacePage--5HsnMQ1.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-D_V9gNON.js +4 -0
- package/src/client/dist/pwa/assets/{browser-BsB6cFv4.js → browser-LBbxVwWf.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-DyvtWxb0.js +1 -0
- package/src/client/dist/pwa/assets/custom-sounds-BpeLZUDW.js +1 -0
- package/src/client/dist/pwa/assets/de-v6i2HlE2.js +17 -0
- package/src/client/dist/pwa/assets/{documents-SCd3w8xV.js → documents-Bjx8lz-h.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-BUdM7I6y.js → editor.api-BC3PbJkx.js} +1 -1
- package/src/client/dist/pwa/assets/efforts-BDHAnNUs.js +9 -0
- package/src/client/dist/pwa/assets/engineFeatures-hlEiwqt-.js +1 -0
- package/src/client/dist/pwa/assets/es-Bc1KgkAT.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DLDl1qPx.js +1 -0
- package/src/client/dist/pwa/assets/{format-BjQxf4pe.js → format-Q94deO3I.js} +7 -7
- package/src/client/dist/pwa/assets/{formatters-BejhmNTV.js → formatters-CWxRHuUu.js} +1 -1
- package/src/client/dist/pwa/assets/fr-ByYPK5vr.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-5d5GhyRp.js → freemarker2-MPhk0eTh.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DaafEsDK.js → handlebars-C60ru5gR.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CwHBDd2A.js → html-ClzchR9j.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-Sy42tm-V.js +1 -0
- package/src/client/dist/pwa/assets/index-CqZ7sDcM.js +20 -0
- package/src/client/dist/pwa/assets/it-BYF_88z6.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-DSNIEEH5.js → javascript-C43UJmMx.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Dma1UpA1.js → liquid-CYTrvTKF.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-D8rqEAws.js → mdx-CxsXOvkQ.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-2E7O2zoi.js → monaco-contributions-Cl4-iVpK.js} +1 -1
- package/src/client/dist/pwa/assets/monaco.contribution-DkjLUmyA.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-CXQn2wO7.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-BrueBr2j.js +1 -0
- package/src/client/dist/pwa/assets/{pending-diff-open-CeiVIFZn.js → pending-diff-open-B4POItbR.js} +1 -1
- package/src/client/dist/pwa/assets/permissionModes-B-ciuUbz.js +36 -0
- package/src/client/dist/pwa/assets/{project-color-BCp9SWoI.js → project-color-CM3hflLH.js} +1 -1
- package/src/client/dist/pwa/assets/{python-f-9NqRlI.js → python-s2IMpsoV.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DsCmoX0h.js → razor-DVNXiVBh.js} +1 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-TU6CDfD8.js +69 -0
- package/src/client/dist/pwa/assets/{typescript-cEHg4L10.js → typescript-BYT_8ABE.js} +1 -1
- package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
- package/src/client/dist/pwa/assets/use-tours-CNe8qb1q.js +2 -0
- package/src/client/dist/pwa/assets/workspace-templates-tBxXE52A.js +1 -0
- package/src/client/dist/pwa/assets/{xml-Plt-Y7we.js → xml-BIG-fmxk.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-ScEmaAeN.js → yaml-D7gu2CQh.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sounds/neutral.wav +0 -0
- package/src/client/dist/pwa/sounds/ready.wav +0 -0
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +8 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +4 -1
- package/src/mcp-server/kobo-tasks-server.ts +38 -5
- package/src/client/dist/pwa/assets/ActivityFeed-D_y4aikp.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-DKVEknbL.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-TY9g3hZb.css +0 -1
- package/src/client/dist/pwa/assets/DashboardPage-Dg2s9nnv.js +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-BSzNScIr.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +0 -2
- package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +0 -1
- package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-LvHiZ6ZH.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Aw89fRSq.js +0 -9
- package/src/client/dist/pwa/assets/SplitWorkspacePage-BIEceNwH.js +0 -1
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CY-9z9nw.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +0 -1
- package/src/client/dist/pwa/assets/de-Mu7biceO.js +0 -17
- package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +0 -9
- package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +0 -1
- package/src/client/dist/pwa/assets/es-BY5IfNI2.js +0 -17
- package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +0 -1
- package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
- package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +0 -1
- package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +0 -20
- package/src/client/dist/pwa/assets/it-ByPZQK_K.js +0 -17
- package/src/client/dist/pwa/assets/monaco.contribution-DqOgo72G.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-DNmhJTZn.js +0 -1
- package/src/client/dist/pwa/assets/notify-theme-B6Hiwvwb.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +0 -36
- package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +0 -67
- package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +0 -2
- package/src/client/dist/pwa/assets/workspace-templates-DUWDLMIX.js +0 -1
- package/src/client/dist/pwa/sounds/7eme-compagnie-03.mp3 +0 -0
- package/src/client/dist/pwa/sounds/aller-ftg.mp3 +0 -0
- package/src/client/dist/pwa/sounds/arrete-de-mentir.mp3 +0 -0
- package/src/client/dist/pwa/sounds/arretez-les-messages.mp3 +0 -0
- package/src/client/dist/pwa/sounds/bah-alors-on-est-nul.mp3 +0 -0
- package/src/client/dist/pwa/sounds/basic-notification.mp3 +0 -0
- package/src/client/dist/pwa/sounds/ca_va_peter.mp3 +0 -0
- package/src/client/dist/pwa/sounds/dry-fart.mp3 +0 -0
- package/src/client/dist/pwa/sounds/faaah.mp3 +0 -0
- package/src/client/dist/pwa/sounds/for-shure.mp3 +0 -0
- package/src/client/dist/pwa/sounds/gta-v-death.mp3 +0 -0
- package/src/client/dist/pwa/sounds/hey.mp3 +0 -0
- package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
- package/src/client/dist/pwa/sounds/nan-tu-degages.mp3 +0 -0
- package/src/client/dist/pwa/sounds/nan-wallah-pardon.mp3 +0 -0
- package/src/client/dist/pwa/sounds/ouais-cest-greg.mp3 +0 -0
- package/src/client/dist/pwa/sounds/pas-ca-zinedine.mp3 +0 -0
- package/src/client/dist/pwa/sounds/ta-gueule.mp3 +0 -0
- package/src/client/dist/pwa/sounds/travail_termine.mp3 +0 -0
- package/src/client/dist/pwa/sounds/tu-vas-la-fermer.mp3 +0 -0
- package/src/client/dist/pwa/sounds/warcraft-3-humain-travail.mp3 +0 -0
- package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
|
|
2
|
+
import { stopMcpProcess } from '../utils/mcp-process.js';
|
|
3
|
+
import { getIntegrationConfig } from './integration-config-service.js';
|
|
2
4
|
import { getGlobalSettings } from './settings-service.js';
|
|
3
5
|
// ─── parseSentryUrl ───────────────────────────────────────────────────────────
|
|
4
6
|
/**
|
|
@@ -29,8 +31,7 @@ export async function testSentryConnection() {
|
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
finally {
|
|
32
|
-
mcpProcess
|
|
33
|
-
mcpProcess.kill();
|
|
34
|
+
stopMcpProcess(mcpProcess);
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
const SENTRY_CONFIG_ERROR = "Sentry MCP server not configured in ~/.claude.json — add an enabled 'sentry' entry under mcpServers";
|
|
@@ -42,7 +43,13 @@ const SENTRY_CONFIG_ERROR = "Sentry MCP server not configured in ~/.claude.json
|
|
|
42
43
|
*
|
|
43
44
|
* Throws with a clear setup message when no enabled Sentry entry exists.
|
|
44
45
|
*/
|
|
45
|
-
export function readSentryMcpConfig(preferredKey) {
|
|
46
|
+
export function readSentryMcpConfig(preferredKey, inheritProcessEnv = true) {
|
|
47
|
+
const inheritedEnv = inheritProcessEnv
|
|
48
|
+
? process.env
|
|
49
|
+
: Object.fromEntries(['SENTRY_ACCESS_TOKEN', 'SENTRY_AUTH_TOKEN', 'SENTRY_HOST'].flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]]));
|
|
50
|
+
const direct = getIntegrationConfig('sentry');
|
|
51
|
+
if (direct)
|
|
52
|
+
return { ...direct, env: { ...inheritedEnv, ...direct.env } };
|
|
46
53
|
const normalizedPreferred = preferredKey?.trim();
|
|
47
54
|
const match = normalizedPreferred
|
|
48
55
|
? readClaudeMcpEntry((k) => k === normalizedPreferred)
|
|
@@ -55,7 +62,7 @@ export function readSentryMcpConfig(preferredKey) {
|
|
|
55
62
|
command: entry.command ?? 'npx',
|
|
56
63
|
args: entry.args ?? [],
|
|
57
64
|
env: {
|
|
58
|
-
...
|
|
65
|
+
...inheritedEnv,
|
|
59
66
|
...(entry.env ?? {}),
|
|
60
67
|
},
|
|
61
68
|
};
|
|
@@ -138,9 +145,9 @@ export async function extractSentryIssue(url) {
|
|
|
138
145
|
// Give the process a moment to start; reject if it errors immediately.
|
|
139
146
|
await new Promise((resolve, reject) => {
|
|
140
147
|
const timeout = setTimeout(() => resolve(), 1000);
|
|
141
|
-
mcpProcess.on('error', (
|
|
148
|
+
mcpProcess.on('error', () => {
|
|
142
149
|
clearTimeout(timeout);
|
|
143
|
-
reject(new Error(
|
|
150
|
+
reject(new Error('Failed to start Sentry MCP server; check the configured executable'));
|
|
144
151
|
});
|
|
145
152
|
});
|
|
146
153
|
await initializeMcp(mcpProcess);
|
|
@@ -152,8 +159,7 @@ export async function extractSentryIssue(url) {
|
|
|
152
159
|
return parseSentryResponse(markdown, numericId);
|
|
153
160
|
}
|
|
154
161
|
finally {
|
|
155
|
-
mcpProcess
|
|
156
|
-
mcpProcess.kill();
|
|
162
|
+
stopMcpProcess(mcpProcess);
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
function extractSentryUserId(whoamiResult) {
|
|
@@ -193,9 +199,9 @@ export async function assignSentryIssueToSelf(issueUrl) {
|
|
|
193
199
|
try {
|
|
194
200
|
await new Promise((resolve, reject) => {
|
|
195
201
|
const timeout = setTimeout(() => resolve(), 1000);
|
|
196
|
-
mcpProcess.on('error', (
|
|
202
|
+
mcpProcess.on('error', () => {
|
|
197
203
|
clearTimeout(timeout);
|
|
198
|
-
reject(new Error(
|
|
204
|
+
reject(new Error('Failed to start Sentry MCP server; check the configured executable'));
|
|
199
205
|
});
|
|
200
206
|
});
|
|
201
207
|
await initializeMcp(mcpProcess);
|
|
@@ -215,7 +221,6 @@ export async function assignSentryIssueToSelf(issueUrl) {
|
|
|
215
221
|
return { assigned: false, reason: err instanceof Error ? err.message : String(err) };
|
|
216
222
|
}
|
|
217
223
|
finally {
|
|
218
|
-
mcpProcess
|
|
219
|
-
mcpProcess.kill();
|
|
224
|
+
stopMcpProcess(mcpProcess);
|
|
220
225
|
}
|
|
221
226
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getDb } from '../db/index.js';
|
|
2
|
+
// Preserve legacy last-use ordering until a session is started or restored.
|
|
3
|
+
export const SESSION_RECENCY_ORDER = 'activation_order DESC, MAX(started_at, COALESCE(ended_at, started_at)) DESC, started_at DESC';
|
|
4
|
+
/** Make a conversation current without changing its execution history or starting an agent. */
|
|
5
|
+
export function activateSession(workspaceId, sessionId) {
|
|
6
|
+
getDb()
|
|
7
|
+
.prepare(`UPDATE agent_sessions SET activation_order =
|
|
8
|
+
(SELECT COALESCE(MAX(activation_order), 0) + 1 FROM agent_sessions WHERE workspace_id = ? AND activation_order >= 0)
|
|
9
|
+
WHERE workspace_id = ? AND id = ? AND status != 'idle'`)
|
|
10
|
+
.run(workspaceId, workspaceId, sessionId);
|
|
11
|
+
}
|
|
12
|
+
/** Keep a rolled-back target in history without selecting or resuming it implicitly. */
|
|
13
|
+
export function deactivateSession(workspaceId, sessionId) {
|
|
14
|
+
getDb()
|
|
15
|
+
.prepare("UPDATE agent_sessions SET activation_order = -1 WHERE workspace_id = ? AND id = ? AND status != 'running'")
|
|
16
|
+
.run(workspaceId, sessionId);
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Decouples the orchestrator's explicit Stop path from the transfer service. */
|
|
2
|
+
const handlers = new Map();
|
|
3
|
+
export function registerHandoffStopHandler(workspaceId, handler) {
|
|
4
|
+
handlers.set(workspaceId, handler);
|
|
5
|
+
return () => {
|
|
6
|
+
if (handlers.get(workspaceId) === handler)
|
|
7
|
+
handlers.delete(workspaceId);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function requestHandoffStop(workspaceId) {
|
|
11
|
+
const handler = handlers.get(workspaceId);
|
|
12
|
+
handler?.requested();
|
|
13
|
+
if (!handler)
|
|
14
|
+
return undefined;
|
|
15
|
+
let settled = false;
|
|
16
|
+
return (outcome) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
handler.settled(outcome);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function suspendHandoffTransfers() {
|
|
24
|
+
for (const handler of handlers.values())
|
|
25
|
+
handler.shutdown();
|
|
26
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
import { HANDOFF_REPORT_MAX_CHARS, isHandoffPending, } from '../../shared/session-handoff.js';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { assertAgentStopped } from '../utils/agent-stop-result.js';
|
|
7
|
+
import { ensureDirectoryInside } from '../utils/safe-path.js';
|
|
8
|
+
import { reserveWorkspaceLifecycle } from '../utils/workspace-lifecycle-guard.js';
|
|
9
|
+
import { listEngines } from './agent/engines/registry.js';
|
|
10
|
+
import * as agents from './agent/orchestrator.js';
|
|
11
|
+
import { buildEngineHandoff } from './engine-handoff-service.js';
|
|
12
|
+
import { getReviewReturn } from './review-return-service.js';
|
|
13
|
+
import { activateSession, deactivateSession } from './session-activity-service.js';
|
|
14
|
+
import { registerHandoffStopHandler } from './session-handoff-runtime.js';
|
|
15
|
+
import * as wakeupService from './wakeup-service.js';
|
|
16
|
+
import { emit, emitEphemeral } from './websocket-service.js';
|
|
17
|
+
import * as workspaces from './workspace-service.js';
|
|
18
|
+
export class SessionHandoffError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
constructor(message, status = 409) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const runtimes = new Map();
|
|
26
|
+
const GENERATION_TIMEOUT_MS = 5 * 60_000;
|
|
27
|
+
function map(row) {
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
workspaceId: row.workspace_id,
|
|
31
|
+
sourceSessionId: row.source_session_id,
|
|
32
|
+
targetSessionId: row.target_session_id,
|
|
33
|
+
source: JSON.parse(row.source_configuration),
|
|
34
|
+
target: JSON.parse(row.target_configuration),
|
|
35
|
+
generateSummary: !!row.generate_summary,
|
|
36
|
+
state: row.state,
|
|
37
|
+
reportPath: row.report_path,
|
|
38
|
+
error: row.error,
|
|
39
|
+
createdAt: row.created_at,
|
|
40
|
+
updatedAt: row.updated_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function read(id) {
|
|
44
|
+
const row = getDb().prepare('SELECT * FROM session_handoffs WHERE id = ?').get(id);
|
|
45
|
+
if (!row)
|
|
46
|
+
throw new SessionHandoffError('Session transfer not found', 404);
|
|
47
|
+
return row;
|
|
48
|
+
}
|
|
49
|
+
export function getCurrentSessionHandoff(workspaceId) {
|
|
50
|
+
const row = getDb()
|
|
51
|
+
.prepare('SELECT * FROM session_handoffs WHERE workspace_id = ? ORDER BY rowid DESC LIMIT 1')
|
|
52
|
+
.get(workspaceId);
|
|
53
|
+
return row ? map(row) : null;
|
|
54
|
+
}
|
|
55
|
+
function publish(id) {
|
|
56
|
+
const handoff = map(read(id));
|
|
57
|
+
emitEphemeral(handoff.workspaceId, 'workspace:handoff', { handoff });
|
|
58
|
+
return handoff;
|
|
59
|
+
}
|
|
60
|
+
function persistState(id, next, error = null) {
|
|
61
|
+
getDb()
|
|
62
|
+
.prepare('UPDATE session_handoffs SET state = ?, error = ?, updated_at = ? WHERE id = ?')
|
|
63
|
+
.run(next, error, new Date().toISOString(), id);
|
|
64
|
+
}
|
|
65
|
+
function state(id, next, error = null) {
|
|
66
|
+
persistState(id, next, error);
|
|
67
|
+
return publish(id);
|
|
68
|
+
}
|
|
69
|
+
function configure(workspaceId, configuration) {
|
|
70
|
+
workspaces.updateWorkspaceEngineConfiguration(workspaceId, configuration.engine, configuration.model, configuration.reasoningEffort ?? 'auto', configuration.agentPermissionMode);
|
|
71
|
+
emitEphemeral(workspaceId, 'workspace:configuration', {
|
|
72
|
+
...configuration,
|
|
73
|
+
reasoningEffort: configuration.reasoningEffort ?? 'auto',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function normalizeStoppedWorkspace(workspaceId) {
|
|
77
|
+
const workspace = workspaces.getWorkspace(workspaceId);
|
|
78
|
+
if (!agents.hasController(workspaceId) &&
|
|
79
|
+
workspace &&
|
|
80
|
+
['created', 'executing', 'brainstorming', 'extracting', 'awaiting-user', 'compacting'].includes(workspace.status))
|
|
81
|
+
workspaces.updateWorkspaceStatus(workspaceId, 'idle');
|
|
82
|
+
}
|
|
83
|
+
function restoreSource(row) {
|
|
84
|
+
if (!agents.hasController(row.workspace_id)) {
|
|
85
|
+
if (row.target_session_id)
|
|
86
|
+
deactivateSession(row.workspace_id, row.target_session_id);
|
|
87
|
+
if (row.source_session_id)
|
|
88
|
+
activateSession(row.workspace_id, row.source_session_id);
|
|
89
|
+
}
|
|
90
|
+
configure(row.workspace_id, map(row).source);
|
|
91
|
+
}
|
|
92
|
+
function clearTimer(runtime) {
|
|
93
|
+
if (runtime.timer)
|
|
94
|
+
clearTimeout(runtime.timer);
|
|
95
|
+
runtime.timer = undefined;
|
|
96
|
+
}
|
|
97
|
+
function release(workspaceId, runtime) {
|
|
98
|
+
clearTimer(runtime);
|
|
99
|
+
runtime.removeStopHandler();
|
|
100
|
+
if (runtimes.get(workspaceId) === runtime)
|
|
101
|
+
runtimes.delete(workspaceId);
|
|
102
|
+
runtime.reservation.release();
|
|
103
|
+
}
|
|
104
|
+
function reserve(workspaceId, id) {
|
|
105
|
+
const runtime = {
|
|
106
|
+
id,
|
|
107
|
+
reservation: reserveWorkspaceLifecycle(workspaceId, 'session-handoff'),
|
|
108
|
+
cancelled: false,
|
|
109
|
+
pendingStops: 0,
|
|
110
|
+
running: false,
|
|
111
|
+
removeStopHandler: () => { },
|
|
112
|
+
};
|
|
113
|
+
runtimes.set(workspaceId, runtime);
|
|
114
|
+
runtime.removeStopHandler = registerHandoffStopHandler(workspaceId, {
|
|
115
|
+
shutdown() {
|
|
116
|
+
runtime.cancelled = true;
|
|
117
|
+
clearTimer(runtime);
|
|
118
|
+
getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(id);
|
|
119
|
+
restoreSource(read(id));
|
|
120
|
+
state(id, 'interrupted', 'Kōbō stopped during this transfer. Inspect the sessions before retrying.');
|
|
121
|
+
},
|
|
122
|
+
requested() {
|
|
123
|
+
runtime.pendingStops++;
|
|
124
|
+
runtime.cancelled = true;
|
|
125
|
+
clearTimer(runtime);
|
|
126
|
+
getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(id);
|
|
127
|
+
},
|
|
128
|
+
settled(outcome) {
|
|
129
|
+
if (runtimes.get(workspaceId) !== runtime)
|
|
130
|
+
return;
|
|
131
|
+
runtime.pendingStops--;
|
|
132
|
+
if (outcome !== 'stopped' && outcome !== 'not-running') {
|
|
133
|
+
state(id, 'failed', 'Agent shutdown is not confirmed. Retry Stop before starting another session.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
restoreSource(read(id));
|
|
137
|
+
normalizeStoppedWorkspace(workspaceId);
|
|
138
|
+
state(id, 'cancelled');
|
|
139
|
+
release(workspaceId, runtime);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return runtime;
|
|
143
|
+
}
|
|
144
|
+
function fail(runtime, error) {
|
|
145
|
+
if (runtime.cancelled || !isHandoffPending(map(read(runtime.id))))
|
|
146
|
+
return;
|
|
147
|
+
clearTimer(runtime);
|
|
148
|
+
const row = read(runtime.id);
|
|
149
|
+
getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(row.id);
|
|
150
|
+
restoreSource(row);
|
|
151
|
+
normalizeStoppedWorkspace(row.workspace_id);
|
|
152
|
+
state(row.id, 'failed', error instanceof Error ? error.message : String(error));
|
|
153
|
+
}
|
|
154
|
+
function parseRequest(input) {
|
|
155
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
156
|
+
throw new SessionHandoffError('Invalid transfer request', 400);
|
|
157
|
+
const body = input;
|
|
158
|
+
const target = body.target;
|
|
159
|
+
if (typeof body.requestId !== 'string' ||
|
|
160
|
+
!body.requestId.trim() ||
|
|
161
|
+
body.requestId.length > 128 ||
|
|
162
|
+
(body.sourceSessionId !== null && typeof body.sourceSessionId !== 'string') ||
|
|
163
|
+
typeof body.generateSummary !== 'boolean' ||
|
|
164
|
+
!target ||
|
|
165
|
+
typeof target !== 'object' ||
|
|
166
|
+
typeof target.engine !== 'string' ||
|
|
167
|
+
typeof target.model !== 'string' ||
|
|
168
|
+
(target.reasoningEffort !== null && typeof target.reasoningEffort !== 'string') ||
|
|
169
|
+
typeof target.agentPermissionMode !== 'string')
|
|
170
|
+
throw new SessionHandoffError('Invalid transfer request', 400);
|
|
171
|
+
return {
|
|
172
|
+
requestId: body.requestId,
|
|
173
|
+
sourceSessionId: body.sourceSessionId,
|
|
174
|
+
generateSummary: body.generateSummary,
|
|
175
|
+
target: {
|
|
176
|
+
engine: target.engine,
|
|
177
|
+
model: target.model,
|
|
178
|
+
reasoningEffort: target.reasoningEffort ?? 'auto',
|
|
179
|
+
agentPermissionMode: target.agentPermissionMode,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function createSessionHandoff(workspaceId, input, suppliedReport) {
|
|
184
|
+
if (agents.isShuttingDown())
|
|
185
|
+
throw new SessionHandoffError('Kōbō is shutting down');
|
|
186
|
+
const body = parseRequest(input);
|
|
187
|
+
const fingerprint = JSON.stringify({ ...body, suppliedReport });
|
|
188
|
+
const previous = getDb()
|
|
189
|
+
.prepare('SELECT * FROM session_handoffs WHERE workspace_id = ? AND request_id = ?')
|
|
190
|
+
.get(workspaceId, body.requestId);
|
|
191
|
+
if (previous) {
|
|
192
|
+
if (previous.request_fingerprint !== fingerprint)
|
|
193
|
+
throw new SessionHandoffError('This request id was already used for another transfer');
|
|
194
|
+
return map(previous);
|
|
195
|
+
}
|
|
196
|
+
const workspace = workspaces.getWorkspace(workspaceId);
|
|
197
|
+
if (!workspace)
|
|
198
|
+
throw new SessionHandoffError('Workspace not found', 404);
|
|
199
|
+
if (workspace.archivedAt || workspace.worktreePurgedAt)
|
|
200
|
+
throw new SessionHandoffError('Restore the workspace before transferring its session');
|
|
201
|
+
if (getReviewReturn(workspaceId))
|
|
202
|
+
throw new SessionHandoffError('Finish or stop the review with automatic return first');
|
|
203
|
+
if (isHandoffPending(getCurrentSessionHandoff(workspaceId)))
|
|
204
|
+
throw new SessionHandoffError('A session transfer is already pending');
|
|
205
|
+
const sourceSession = workspaces.getActiveSession(workspaceId);
|
|
206
|
+
if ((sourceSession?.id ?? null) !== body.sourceSessionId)
|
|
207
|
+
throw new SessionHandoffError('The current session changed. Reopen the transfer dialog.');
|
|
208
|
+
const engine = listEngines().find((item) => item.id === body.target.engine);
|
|
209
|
+
if (!engine?.capabilities.permissionModes.includes(body.target.agentPermissionMode) ||
|
|
210
|
+
(!engine.capabilities.models.some((model) => model.id === body.target.model) &&
|
|
211
|
+
!(workspace.engine === body.target.engine && workspace.model === body.target.model)) ||
|
|
212
|
+
(body.target.reasoningEffort !== 'auto' &&
|
|
213
|
+
!engine.capabilities.effortLevels?.some((effort) => effort.id === body.target.reasoningEffort)))
|
|
214
|
+
throw new SessionHandoffError('The target engine configuration is not supported', 400);
|
|
215
|
+
const id = nanoid();
|
|
216
|
+
const runtime = reserve(workspaceId, id);
|
|
217
|
+
try {
|
|
218
|
+
const source = {
|
|
219
|
+
engine: workspace.engine,
|
|
220
|
+
model: workspace.model,
|
|
221
|
+
reasoningEffort: workspace.reasoningEffort,
|
|
222
|
+
agentPermissionMode: workspace.agentPermissionMode,
|
|
223
|
+
};
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
getDb()
|
|
226
|
+
.prepare(`INSERT INTO session_handoffs
|
|
227
|
+
(id, workspace_id, request_id, request_fingerprint, source_session_id, source_configuration, target_configuration, source_model, generate_summary, state, report, created_at, updated_at)
|
|
228
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopping', ?, ?, ?)`)
|
|
229
|
+
.run(id, workspaceId, body.requestId, fingerprint, body.sourceSessionId, JSON.stringify(source), JSON.stringify(body.target), sourceSession?.model ?? workspace.model, body.generateSummary ? 1 : 0, suppliedReport?.slice(0, HANDOFF_REPORT_MAX_CHARS) ?? null, now, now);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
release(workspaceId, runtime);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
const result = publish(id);
|
|
236
|
+
void run(runtime);
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
function generationPrompt() {
|
|
240
|
+
return `The user has requested an immediate transfer to a fresh conversation. Your previous work has been interrupted. Your ONLY task now is to prepare a concise handoff for the next agent using the context of this exact conversation.
|
|
241
|
+
Do not modify the project, tasks, criteria, schedules, or settings. Do not continue implementation. Respect all existing user constraints and permissions.
|
|
242
|
+
Call kobo__submit_session_handoff with a Markdown report (at most ${HANDOFF_REPORT_MAX_CHARS} characters) covering:
|
|
243
|
+
1. Objective, user constraints, and remaining scope.
|
|
244
|
+
2. Decisions and their reasons; separate verified facts from assumptions.
|
|
245
|
+
3. Progress, relevant files, tasks, checks actually performed and their outcomes.
|
|
246
|
+
4. Failed approaches and mistakes to avoid repeating.
|
|
247
|
+
5. Open questions, uncertainties, and the concrete next action.
|
|
248
|
+
Do not claim checks you did not run. Reference source history where needed. After successful submission, end your turn immediately. The backend will start the fresh session; do not do that yourself.`;
|
|
249
|
+
}
|
|
250
|
+
function buildPrompt(row) {
|
|
251
|
+
const workspace = workspaces.getWorkspaceWithTasks(row.workspace_id);
|
|
252
|
+
if (!workspace)
|
|
253
|
+
throw new SessionHandoffError('Workspace not found', 404);
|
|
254
|
+
const configurations = map(row);
|
|
255
|
+
const mission = workspace.initialPrompt ?? workspace.description ?? workspace.name;
|
|
256
|
+
const tasks = JSON.stringify(workspace.tasks.map(({ id, title, status, role, verification, isAcceptanceCriterion }) => ({
|
|
257
|
+
id,
|
|
258
|
+
title,
|
|
259
|
+
status,
|
|
260
|
+
role,
|
|
261
|
+
verification,
|
|
262
|
+
isAcceptanceCriterion,
|
|
263
|
+
})), null, 2);
|
|
264
|
+
const run = getDb().prepare('SELECT * FROM auto_loop_runs WHERE workspace_id = ?').get(row.workspace_id);
|
|
265
|
+
const messages = getDb()
|
|
266
|
+
.prepare("SELECT id, state FROM auto_loop_messages WHERE workspace_id = ? AND state != 'delivered'")
|
|
267
|
+
.all(row.workspace_id);
|
|
268
|
+
return `# Kōbō session handoff\n\nContinue the current mission automatically in this fresh conversation. First verify the actual repository and Kōbō task state, then perform the next unfinished action. Preserve user constraints; this report is context, not authorization to override them. Agent-reported checks do not replace Kōbō verification requirements. Uncertain message deliveries require explicit acknowledgement; do not silently treat them as completed.\n\nSource session: ${row.source_session_id ?? 'none'}\nRead its history using kobo__read_workspace_events_csv with session_id=${row.source_session_id ?? '(no prior session)'}.\n\n## Initial mission\n${mission.slice(0, 32_000)}\n\n## Agent handoff\n${row.report ?? 'No LLM summary was generated. Recover missing decisions from the source conversation.'}\n\n## Current Kōbō tasks and reported evidence\n${tasks.slice(0, 48_000)}\n\n## Auto-loop and pending instructions\nThese are identifiers and statuses only. Do not execute or acknowledge queued instructions during this transfer. Kōbō will dispatch pending instructions separately; unknown deliveries require an explicit user decision.\nIntent: ${workspace.autoLoop}\n${JSON.stringify(run ?? null)}\n${JSON.stringify(messages).slice(0, 24_000)}\n\n${buildEngineHandoff(workspace, configurations.source.engine, configurations.target.engine, row.source_session_id)}`;
|
|
269
|
+
}
|
|
270
|
+
function writeReport(row, content) {
|
|
271
|
+
const workspace = workspaces.getWorkspace(row.workspace_id);
|
|
272
|
+
const directory = ensureDirectoryInside(workspace.worktreePath, '.ai/handoffs');
|
|
273
|
+
// A local ignore file avoids modifying the project's tracked root .gitignore.
|
|
274
|
+
const descriptor = fs.openSync(path.join(directory, '.gitignore'), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW, 0o600);
|
|
275
|
+
try {
|
|
276
|
+
fs.writeFileSync(descriptor, '\n*\n');
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
fs.closeSync(descriptor);
|
|
280
|
+
}
|
|
281
|
+
const relative = `.ai/handoffs/${row.id}-${nanoid(6)}.md`;
|
|
282
|
+
fs.writeFileSync(path.join(directory, path.basename(relative)), content, { flag: 'wx', mode: 0o600 });
|
|
283
|
+
getDb().prepare('UPDATE session_handoffs SET report_path = ? WHERE id = ?').run(relative, row.id);
|
|
284
|
+
return relative;
|
|
285
|
+
}
|
|
286
|
+
async function run(runtime) {
|
|
287
|
+
if (runtime.running || runtime.cancelled)
|
|
288
|
+
return;
|
|
289
|
+
runtime.running = true;
|
|
290
|
+
const row = read(runtime.id);
|
|
291
|
+
try {
|
|
292
|
+
state(row.id, 'stopping');
|
|
293
|
+
assertAgentStopped(await agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff'));
|
|
294
|
+
if (runtime.cancelled)
|
|
295
|
+
return;
|
|
296
|
+
normalizeStoppedWorkspace(row.workspace_id);
|
|
297
|
+
const current = read(row.id);
|
|
298
|
+
if (current.generate_summary && !current.report) {
|
|
299
|
+
const session = workspaces.listSessions(row.workspace_id).find((item) => item.id === row.source_session_id);
|
|
300
|
+
if (!session?.engineSessionId || (session.engine && session.engine !== map(row).source.engine))
|
|
301
|
+
throw new SessionHandoffError('The source conversation cannot be resumed. Continue without an LLM summary.');
|
|
302
|
+
const token = nanoid(40);
|
|
303
|
+
getDb().prepare('UPDATE session_handoffs SET generation_token = ? WHERE id = ?').run(token, row.id);
|
|
304
|
+
state(row.id, 'generating');
|
|
305
|
+
runtime.generationError = undefined;
|
|
306
|
+
const source = map(row).source;
|
|
307
|
+
configure(row.workspace_id, source);
|
|
308
|
+
const prompt = generationPrompt();
|
|
309
|
+
workspaces.updateWorkspaceStatus(row.workspace_id, 'executing');
|
|
310
|
+
agents.startAgent(row.workspace_id, workspaces.getWorkspace(row.workspace_id).worktreePath, prompt, row.source_model ?? source.model, true, source.agentPermissionMode, row.source_session_id, source.reasoningEffort ?? 'auto', undefined, {
|
|
311
|
+
lifecycleOwner: runtime.reservation.owner,
|
|
312
|
+
handoffGeneration: true,
|
|
313
|
+
mcpEnv: { KOBO_HANDOFF_ID: row.id, KOBO_HANDOFF_TOKEN: token },
|
|
314
|
+
onError: (message) => {
|
|
315
|
+
runtime.generationError = message;
|
|
316
|
+
},
|
|
317
|
+
onEnded: (event) => {
|
|
318
|
+
if (runtime.cancelled || read(row.id).state !== 'generating')
|
|
319
|
+
return;
|
|
320
|
+
clearTimer(runtime);
|
|
321
|
+
getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(row.id);
|
|
322
|
+
if (!read(row.id).report || event.reason === 'error' || (event.exitCode !== null && event.exitCode !== 0))
|
|
323
|
+
fail(runtime, runtime.generationError ?? 'The source session ended without a valid handoff report.');
|
|
324
|
+
else
|
|
325
|
+
queueMicrotask(() => {
|
|
326
|
+
void startTarget(runtime).catch((error) => fail(runtime, error));
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
emit(row.workspace_id, 'user:message', { content: prompt, sender: 'system-prompt' }, row.source_session_id);
|
|
331
|
+
runtime.timer = setTimeout(() => {
|
|
332
|
+
void agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff').then((outcome) => {
|
|
333
|
+
if (runtime.cancelled)
|
|
334
|
+
return;
|
|
335
|
+
try {
|
|
336
|
+
assertAgentStopped(outcome);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
fail(runtime, error);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (read(row.id).report)
|
|
343
|
+
void startTarget(runtime).catch((error) => fail(runtime, error));
|
|
344
|
+
else
|
|
345
|
+
fail(runtime, 'Handoff generation timed out. Retry or continue without an LLM summary.');
|
|
346
|
+
});
|
|
347
|
+
}, GENERATION_TIMEOUT_MS);
|
|
348
|
+
runtime.timer.unref?.();
|
|
349
|
+
}
|
|
350
|
+
else
|
|
351
|
+
await startTarget(runtime);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
fail(runtime, error);
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
runtime.running = false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function startTarget(runtime) {
|
|
361
|
+
if (runtime.cancelled)
|
|
362
|
+
return;
|
|
363
|
+
const row = read(runtime.id);
|
|
364
|
+
if (row.state === 'starting' || !isHandoffPending(map(row)))
|
|
365
|
+
return;
|
|
366
|
+
// Both natural completion and deadline recovery converge here; only one may dispatch.
|
|
367
|
+
assertAgentStopped(await agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff'));
|
|
368
|
+
if (runtime.cancelled || read(row.id).state === 'starting')
|
|
369
|
+
return;
|
|
370
|
+
clearTimer(runtime);
|
|
371
|
+
const prompt = buildPrompt(row);
|
|
372
|
+
writeReport(row, prompt);
|
|
373
|
+
configure(row.workspace_id, map(row).target);
|
|
374
|
+
const session = workspaces.createIdleSession(row.workspace_id);
|
|
375
|
+
getDb()
|
|
376
|
+
.prepare('UPDATE session_handoffs SET target_session_id = ?, generation_token = NULL WHERE id = ?')
|
|
377
|
+
.run(session.id, row.id);
|
|
378
|
+
state(row.id, 'starting');
|
|
379
|
+
let startError;
|
|
380
|
+
const completed = () => {
|
|
381
|
+
if (runtime.cancelled || read(row.id).state !== 'starting')
|
|
382
|
+
return;
|
|
383
|
+
if (startError) {
|
|
384
|
+
fail(runtime, startError);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// A restart must never restore the source while its wakeup points at an unconfirmed target.
|
|
388
|
+
getDb().transaction(() => {
|
|
389
|
+
wakeupService.transferSession(row.workspace_id, row.source_session_id, session.id);
|
|
390
|
+
persistState(row.id, 'completed');
|
|
391
|
+
})();
|
|
392
|
+
publish(row.id);
|
|
393
|
+
release(row.workspace_id, runtime);
|
|
394
|
+
};
|
|
395
|
+
workspaces.updateWorkspaceStatus(row.workspace_id, 'executing');
|
|
396
|
+
agents.startAgent(row.workspace_id, workspaces.getWorkspace(row.workspace_id).worktreePath, prompt, map(row).target.model, false, map(row).target.agentPermissionMode, session.id, map(row).target.reasoningEffort ?? 'auto', undefined, {
|
|
397
|
+
lifecycleOwner: runtime.reservation.owner,
|
|
398
|
+
onStarted: completed,
|
|
399
|
+
onError: (message) => {
|
|
400
|
+
startError = message;
|
|
401
|
+
},
|
|
402
|
+
onEnded: (event) => {
|
|
403
|
+
if (event.reason === 'error' || event.exitCode)
|
|
404
|
+
fail(runtime, startError ?? 'The target session failed to start.');
|
|
405
|
+
else
|
|
406
|
+
completed();
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
emit(row.workspace_id, 'user:message', { content: prompt, sender: 'system-prompt', kind: 'engine-handoff' }, session.id);
|
|
410
|
+
}
|
|
411
|
+
export function submitSessionHandoff(workspaceId, id, token, report) {
|
|
412
|
+
const row = read(id);
|
|
413
|
+
const runtime = runtimes.get(workspaceId);
|
|
414
|
+
if (row.workspace_id !== workspaceId ||
|
|
415
|
+
row.state !== 'generating' ||
|
|
416
|
+
!runtime ||
|
|
417
|
+
runtime.cancelled ||
|
|
418
|
+
typeof token !== 'string' ||
|
|
419
|
+
!token ||
|
|
420
|
+
token !== row.generation_token)
|
|
421
|
+
throw new SessionHandoffError('This handoff generation is no longer active');
|
|
422
|
+
if (typeof report !== 'string' || !report.trim() || report.length > HANDOFF_REPORT_MAX_CHARS)
|
|
423
|
+
throw new SessionHandoffError(`A non-empty Markdown report up to ${HANDOFF_REPORT_MAX_CHARS} characters is required`, 400);
|
|
424
|
+
if (row.report && row.report !== report.trim())
|
|
425
|
+
throw new SessionHandoffError('A different report was already submitted');
|
|
426
|
+
getDb()
|
|
427
|
+
.prepare('UPDATE session_handoffs SET report = ?, updated_at = ? WHERE id = ?')
|
|
428
|
+
.run(report.trim(), new Date().toISOString(), id);
|
|
429
|
+
return { accepted: true };
|
|
430
|
+
}
|
|
431
|
+
export async function decideSessionHandoff(workspaceId, id, action) {
|
|
432
|
+
const row = read(id);
|
|
433
|
+
if (row.workspace_id !== workspaceId)
|
|
434
|
+
throw new SessionHandoffError('Session transfer not found', 404);
|
|
435
|
+
if (!['retry', 'skip', 'cancel'].includes(action))
|
|
436
|
+
throw new SessionHandoffError('Invalid transfer decision', 400);
|
|
437
|
+
if (!isHandoffPending(map(row)))
|
|
438
|
+
return map(row);
|
|
439
|
+
const runtime = runtimes.get(workspaceId) ?? reserve(workspaceId, id);
|
|
440
|
+
if (action === 'cancel') {
|
|
441
|
+
assertAgentStopped(await agents.stopAgentAndWait(workspaceId));
|
|
442
|
+
return map(read(id));
|
|
443
|
+
}
|
|
444
|
+
if (runtime.pendingStops > 0)
|
|
445
|
+
throw new SessionHandoffError('Cancellation is still waiting for the agent to stop');
|
|
446
|
+
if (runtime.running || !['failed', 'interrupted'].includes(row.state))
|
|
447
|
+
throw new SessionHandoffError('The transfer is still running');
|
|
448
|
+
runtime.cancelled = false;
|
|
449
|
+
if (action === 'skip')
|
|
450
|
+
getDb().prepare('UPDATE session_handoffs SET generate_summary = 0, report = NULL WHERE id = ?').run(id);
|
|
451
|
+
void run(runtime);
|
|
452
|
+
return map(read(id));
|
|
453
|
+
}
|
|
454
|
+
/** A restart never replays a possibly accepted native turn. Keep automation reserved until a decision. */
|
|
455
|
+
export function reconcileSessionHandoffs() {
|
|
456
|
+
const rows = getDb()
|
|
457
|
+
.prepare("SELECT * FROM session_handoffs WHERE state NOT IN ('completed', 'cancelled')")
|
|
458
|
+
.all();
|
|
459
|
+
for (const row of rows) {
|
|
460
|
+
getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(row.id);
|
|
461
|
+
restoreSource(row);
|
|
462
|
+
state(row.id, 'interrupted', 'Kōbō restarted during this transfer. Delivery may be uncertain; inspect the sessions before retrying.');
|
|
463
|
+
if (!runtimes.has(row.workspace_id))
|
|
464
|
+
reserve(row.workspace_id, row.id);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** Legacy synchronous engine-switch clients still receive a target session id. */
|
|
468
|
+
export async function waitForSessionHandoff(id) {
|
|
469
|
+
const deadline = Date.now() + 45_000;
|
|
470
|
+
while (Date.now() < deadline) {
|
|
471
|
+
const result = map(read(id));
|
|
472
|
+
if (result.state === 'completed')
|
|
473
|
+
return result;
|
|
474
|
+
if (['failed', 'interrupted', 'cancelled'].includes(result.state))
|
|
475
|
+
throw new SessionHandoffError(result.error ?? 'Session transfer cancelled');
|
|
476
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
477
|
+
}
|
|
478
|
+
throw new SessionHandoffError('Transfer is still pending. Consult the current session transfer before retrying.');
|
|
479
|
+
}
|
|
@@ -85,3 +85,8 @@ export const DEFAULT_MODEL_BY_ENGINE = {
|
|
|
85
85
|
'claude-code': 'claude-sonnet-5',
|
|
86
86
|
codex: 'gpt-5.6-terra',
|
|
87
87
|
};
|
|
88
|
+
/** Fresh installs defer model selection to the authenticated engine. Historical migrations retain DEFAULT_MODEL_BY_ENGINE. */
|
|
89
|
+
export const FRESH_MODEL_BY_ENGINE = {
|
|
90
|
+
'claude-code': 'auto',
|
|
91
|
+
codex: 'auto',
|
|
92
|
+
};
|