@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,8 +1,12 @@
|
|
|
1
1
|
import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import { nanoid } from 'nanoid';
|
|
4
|
+
import { isWorkflowPolicy, resolveWorkflowPolicy, WORKFLOW_ACTIONS, } from '../../shared/workflow-policy.js';
|
|
3
5
|
import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
|
|
6
|
+
import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
|
|
4
7
|
import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
|
|
5
8
|
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
9
|
+
import { isWorkspaceLifecycleBusy, workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
|
|
6
10
|
import autoLoopMessagesRoutes from './auto-loop-messages.js';
|
|
7
11
|
const execFileAsync = promisify(execFileCb);
|
|
8
12
|
import fs from 'node:fs';
|
|
@@ -59,11 +63,42 @@ import { WorkspaceLifecycleBusyError, withWorkspaceLifecycleGuard } from '../uti
|
|
|
59
63
|
import { resolveExtractedName } from '../utils/workspace-name.js';
|
|
60
64
|
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
61
65
|
/** Lifecycle conflicts can be retried once the current owner has stopped. */
|
|
66
|
+
/** Symlink-resolved path, or the lexically resolved one when it does not exist. */
|
|
67
|
+
function realPathOrResolved(p) {
|
|
68
|
+
try {
|
|
69
|
+
return fs.realpathSync(p);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return path.resolve(p);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
62
75
|
function workspaceErrorStatus(err) {
|
|
63
76
|
return err instanceof AgentStopError || err instanceof WorkspaceLifecycleBusyError ? 409 : 500;
|
|
64
77
|
}
|
|
65
78
|
/** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
|
|
66
79
|
const app = new Hono();
|
|
80
|
+
// Transfers reserve the writer across multiple HTTP requests and engine invocations.
|
|
81
|
+
app.use('/:id/*', async (c, next) => {
|
|
82
|
+
const id = c.req.param('id');
|
|
83
|
+
const route = c.req.path.split(`/api/workspaces/${id}/`)[1] ?? '';
|
|
84
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) &&
|
|
85
|
+
workspaceLifecycleReason(id) === 'session-handoff' &&
|
|
86
|
+
!route.startsWith('session-handoffs') &&
|
|
87
|
+
![
|
|
88
|
+
'stop',
|
|
89
|
+
'mark-read',
|
|
90
|
+
'chat-history',
|
|
91
|
+
'deferred-tool-use/answer',
|
|
92
|
+
'deferred-tool-use/cancel',
|
|
93
|
+
'deferred-permission/decision',
|
|
94
|
+
].includes(route)) {
|
|
95
|
+
return c.json({
|
|
96
|
+
error: 'A session transfer is pending. Finish or cancel it before changing this workspace.',
|
|
97
|
+
code: 'workspace-busy',
|
|
98
|
+
}, 409);
|
|
99
|
+
}
|
|
100
|
+
await next();
|
|
101
|
+
});
|
|
67
102
|
// These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
|
|
68
103
|
for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
|
|
69
104
|
app.use(route, async (c, next) => {
|
|
@@ -297,6 +332,44 @@ app.get('/', (c) => {
|
|
|
297
332
|
return c.json({ error: message }, workspaceErrorStatus(err));
|
|
298
333
|
}
|
|
299
334
|
});
|
|
335
|
+
function handoffError(c, error) {
|
|
336
|
+
return c.json({ error: error instanceof Error ? error.message : 'Session transfer failed' }, error instanceof SessionHandoffError
|
|
337
|
+
? error.status
|
|
338
|
+
: error instanceof SyntaxError
|
|
339
|
+
? 400
|
|
340
|
+
: workspaceErrorStatus(error));
|
|
341
|
+
}
|
|
342
|
+
app.post('/:id/session-handoffs', migrationGuard, async (c) => {
|
|
343
|
+
try {
|
|
344
|
+
return c.json({ handoff: createSessionHandoff(c.req.param('id'), await c.req.json()) }, 202);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
return handoffError(c, error);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
app.get('/:id/session-handoffs/current', (c) => {
|
|
351
|
+
if (!workspaceService.getWorkspace(c.req.param('id')))
|
|
352
|
+
return c.json({ error: 'Workspace not found' }, 404);
|
|
353
|
+
return c.json({ handoff: getCurrentSessionHandoff(c.req.param('id')) });
|
|
354
|
+
});
|
|
355
|
+
app.post('/:id/session-handoffs/:handoffId/decision', migrationGuard, async (c) => {
|
|
356
|
+
try {
|
|
357
|
+
const body = await c.req.json();
|
|
358
|
+
return c.json({ handoff: await decideSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.action) });
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
return handoffError(c, error);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
app.post('/:id/session-handoffs/:handoffId/report', migrationGuard, async (c) => {
|
|
365
|
+
try {
|
|
366
|
+
const body = await c.req.json();
|
|
367
|
+
return c.json(submitSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.token, body.report));
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
return handoffError(c, error);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
300
373
|
// POST /api/workspaces/:id/engine-handoff-preview — build an editable, deterministic handoff.
|
|
301
374
|
app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
|
|
302
375
|
try {
|
|
@@ -348,15 +421,14 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
|
|
|
348
421
|
error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
|
|
349
422
|
}, 409);
|
|
350
423
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: session.agentSessionId });
|
|
424
|
+
const operation = createSessionHandoff(id, {
|
|
425
|
+
requestId: nanoid(),
|
|
426
|
+
sourceSessionId: workspaceService.getActiveSession(id)?.id ?? null,
|
|
427
|
+
generateSummary: false,
|
|
428
|
+
target: { engine: body.engine, model, reasoningEffort: effort, agentPermissionMode: body.agentPermissionMode },
|
|
429
|
+
}, handoff);
|
|
430
|
+
const completed = await waitForSessionHandoff(operation.id);
|
|
431
|
+
return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: completed.targetSessionId });
|
|
360
432
|
}
|
|
361
433
|
catch (err) {
|
|
362
434
|
return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
|
|
@@ -390,6 +462,8 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
390
462
|
let setupScriptConfigured = false;
|
|
391
463
|
try {
|
|
392
464
|
const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
|
|
465
|
+
if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
|
|
466
|
+
return c.json({ error: 'Invalid workflowPolicy' }, 400);
|
|
393
467
|
// workingBranch is derived from git when worktreePath is provided, so
|
|
394
468
|
// it's not required in that flow. The other 3 fields stay mandatory.
|
|
395
469
|
if (!body.name || !body.projectPath || !body.sourceBranch) {
|
|
@@ -490,11 +564,10 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
490
564
|
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
491
565
|
}
|
|
492
566
|
try {
|
|
493
|
-
const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
|
|
567
|
+
const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf-8' }).trim();
|
|
568
|
+
// Git reports the common dir symlink-resolved while the project path may be
|
|
569
|
+
// reached through a link (macOS temp dirs: /var -> /private/var).
|
|
570
|
+
if (realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git'))) {
|
|
498
571
|
const failure = `Worktree '${body.worktreePath}' belongs to a different repository`;
|
|
499
572
|
emitCreateFailed(creationId, 'inspect-worktree', failure);
|
|
500
573
|
return c.json({ error: failure, step: 'inspect-worktree' }, 422);
|
|
@@ -732,6 +805,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
732
805
|
model: body.model,
|
|
733
806
|
brainstormModel: body.brainstormModel,
|
|
734
807
|
reasoningEffort: body.reasoningEffort,
|
|
808
|
+
workflowPolicy: resolveWorkflowPolicy(globalSettings.workflowPolicy, projectSettings?.workflowPolicy, body.workflowPolicy),
|
|
735
809
|
agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
|
|
736
810
|
engine: body.engine,
|
|
737
811
|
// Set by the engine-comparison flow, which posts here once per engine
|
|
@@ -1047,9 +1121,9 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
1047
1121
|
`## Additional Context\n${extra}\n\n` +
|
|
1048
1122
|
`## MCP Tools for deeper analysis\n` +
|
|
1049
1123
|
`If you need more context, the following Sentry MCP tools are available:\n` +
|
|
1050
|
-
`- \`
|
|
1051
|
-
`- \`
|
|
1052
|
-
`- \`
|
|
1124
|
+
`- \`get_sentry_resource(url, resourceType) [use the Kōbō-managed Sentry namespace supplied for this session]\` — fetch the issue, breadcrumbs, replay, or trace\n` +
|
|
1125
|
+
`- \`search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
|
|
1126
|
+
`- \`get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
|
|
1053
1127
|
fs.writeFileSync(sentryFilePath, md, 'utf-8');
|
|
1054
1128
|
workspaceService.createTask(workspace.id, {
|
|
1055
1129
|
title: `Fix: ${sentryContent.title || sentryContent.issueId || `Sentry #${sentryContent.issueNumericId}`}`,
|
|
@@ -1170,9 +1244,9 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
|
1170
1244
|
`5. Confirm the test passes, run related tests\n` +
|
|
1171
1245
|
`6. Commit referencing the Sentry Short-ID (e.g. "fix(scope): description (${sentryContent.issueId})") — Sentry auto-closes the issue when the commit is merged\n` +
|
|
1172
1246
|
`\nIf you need more context, Sentry MCP tools are available:\n` +
|
|
1173
|
-
`-
|
|
1174
|
-
`-
|
|
1175
|
-
`-
|
|
1247
|
+
`- get_sentry_resource(url, resourceType) [use the Kōbō-managed Sentry namespace supplied for this session] — fetch the issue, breadcrumbs, replay or trace\n` +
|
|
1248
|
+
`- search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
|
|
1249
|
+
`- get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
|
|
1176
1250
|
if (sentryTpl.trim().length > 0) {
|
|
1177
1251
|
const renderedSentry = renderSentryInitialPrompt(sentryTpl, {
|
|
1178
1252
|
issueId: sentryContent.issueId,
|
|
@@ -1618,6 +1692,13 @@ app.get('/auto-loop-states', (c) => {
|
|
|
1618
1692
|
});
|
|
1619
1693
|
app.route('/', autoLoopMessagesRoutes);
|
|
1620
1694
|
// GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
|
|
1695
|
+
// Admission inspection never changes deadlines, pending schedules or lifecycle state.
|
|
1696
|
+
app.get('/:id/automatic-admission', (c) => {
|
|
1697
|
+
const id = c.req.param('id');
|
|
1698
|
+
if (!workspaceService.getWorkspace(id))
|
|
1699
|
+
return c.json({ error: 'Workspace not found' }, 404);
|
|
1700
|
+
return c.json(autoLoopService.getAutomaticAdmissionStatus(id));
|
|
1701
|
+
});
|
|
1621
1702
|
app.get('/:id/auto-loop', (c) => {
|
|
1622
1703
|
try {
|
|
1623
1704
|
const id = c.req.param('id');
|
|
@@ -2629,6 +2710,8 @@ app.put('/:id/tags', async (c) => {
|
|
|
2629
2710
|
});
|
|
2630
2711
|
// PATCH /api/workspaces/:id — update workspace fields (status, model, agentPermissionMode, name)
|
|
2631
2712
|
app.patch('/:id', migrationGuard, async (c) => {
|
|
2713
|
+
if (workspaceLifecycleReason(c.req.param('id')) === 'session-handoff')
|
|
2714
|
+
return c.json({ error: 'A session transfer is pending', code: 'workspace-busy' }, 409);
|
|
2632
2715
|
try {
|
|
2633
2716
|
const id = c.req.param('id');
|
|
2634
2717
|
const parsed = await c.req.json();
|
|
@@ -2644,6 +2727,20 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
2644
2727
|
if ('agent_description' in body) {
|
|
2645
2728
|
return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
|
|
2646
2729
|
}
|
|
2730
|
+
if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
|
|
2731
|
+
return c.json({ error: 'Invalid workflowPolicy' }, 400);
|
|
2732
|
+
if (body.workflowPolicy !== undefined) {
|
|
2733
|
+
const policy = resolveWorkflowPolicy(workspace.workflowPolicy, body.workflowPolicy);
|
|
2734
|
+
const changed = WORKFLOW_ACTIONS.some((action) => policy[action] !== workspace.workflowPolicy[action]);
|
|
2735
|
+
// A live engine already received its policy. Persisting a different one cannot
|
|
2736
|
+
// revoke that prompt; wait for confirmed closure before accepting the change.
|
|
2737
|
+
if (changed && (agentManager.hasController(id) || isWorkspaceLifecycleBusy(id))) {
|
|
2738
|
+
return c.json({
|
|
2739
|
+
error: 'Stop the agent and wait for workspace operations to finish before changing workflow preferences.',
|
|
2740
|
+
code: 'workspace-busy',
|
|
2741
|
+
}, 409);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2647
2744
|
if (body.model !== undefined && (typeof body.model !== 'string' || !body.model.trim())) {
|
|
2648
2745
|
return c.json({ error: 'model must be a non-empty string' }, 400);
|
|
2649
2746
|
}
|
|
@@ -2685,6 +2782,7 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
2685
2782
|
}
|
|
2686
2783
|
}
|
|
2687
2784
|
if (body.status === undefined &&
|
|
2785
|
+
body.workflowPolicy === undefined &&
|
|
2688
2786
|
body.model === undefined &&
|
|
2689
2787
|
body.reasoningEffort === undefined &&
|
|
2690
2788
|
body.agentPermissionMode === undefined &&
|
|
@@ -240,11 +240,25 @@ export function createClaudeCodeEngine() {
|
|
|
240
240
|
sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
|
|
241
241
|
const inputStream = new ClaudeInputStream(effectivePrompt);
|
|
242
242
|
const q = query({ prompt: inputStream, options: sdkOptions });
|
|
243
|
+
let resolveReady;
|
|
244
|
+
let rejectReady;
|
|
245
|
+
const ready = new Promise((resolve, reject) => {
|
|
246
|
+
resolveReady = resolve;
|
|
247
|
+
rejectReady = reject;
|
|
248
|
+
});
|
|
249
|
+
// Legacy callers do not await readiness; still observe an early rejection.
|
|
250
|
+
void ready.catch(() => { });
|
|
243
251
|
let discoveredSessionId;
|
|
244
252
|
// A throwing onEvent handler (e.g. DB query against a closed connection
|
|
245
253
|
// during async test teardown) must not escape as an unhandled rejection.
|
|
246
254
|
const emitDirect = (ev) => {
|
|
247
255
|
try {
|
|
256
|
+
if (ev.kind === 'session:started')
|
|
257
|
+
resolveReady();
|
|
258
|
+
if (ev.kind === 'error')
|
|
259
|
+
rejectReady(new Error(ev.message));
|
|
260
|
+
if (ev.kind === 'session:ended')
|
|
261
|
+
rejectReady(new Error('Claude ended before confirming session initialization'));
|
|
248
262
|
onEvent(ev);
|
|
249
263
|
}
|
|
250
264
|
catch (err) {
|
|
@@ -588,6 +602,7 @@ export function createClaudeCodeEngine() {
|
|
|
588
602
|
}
|
|
589
603
|
})();
|
|
590
604
|
const engineProcess = {
|
|
605
|
+
ready,
|
|
591
606
|
closed: iteratorPromise.then(() => { }),
|
|
592
607
|
get pid() {
|
|
593
608
|
return undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
const requireFn = createRequire(import.meta.url);
|
|
4
5
|
export function resolveCodexBinary() {
|
|
5
6
|
try {
|
|
@@ -7,8 +8,7 @@ export function resolveCodexBinary() {
|
|
|
7
8
|
const pkg = requireFn(pkgPath);
|
|
8
9
|
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex;
|
|
9
10
|
if (binRel) {
|
|
10
|
-
|
|
11
|
-
return url.pathname;
|
|
11
|
+
return path.resolve(path.dirname(pkgPath), binRel);
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
catch {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import { readClaudeMcpEntry } from '../../utils/mcp-client.js';
|
|
3
|
+
import { getIntegrationConfig } from '../integration-config-service.js';
|
|
4
|
+
import { buildNotionMcpConfig } from '../notion-service.js';
|
|
5
|
+
import { readSentryMcpConfig } from '../sentry-service.js';
|
|
6
|
+
/** Availability only: no provider process or credential-bearing response. */
|
|
7
|
+
export function hasIntegrationMcpConfig(integration, selectedKey) {
|
|
8
|
+
if (getIntegrationConfig(integration))
|
|
9
|
+
return true;
|
|
10
|
+
if (integration === 'notion' &&
|
|
11
|
+
(process.env.NOTION_API_TOKEN || process.env.NOTION_TOKEN || process.env.OPENAPI_MCP_HEADERS))
|
|
12
|
+
return true;
|
|
13
|
+
const key = selectedKey?.trim();
|
|
14
|
+
return Boolean(readClaudeMcpEntry((name) => key ? name === key : integration === 'notion' ? name === 'notion' : /sentry/i.test(name)));
|
|
15
|
+
}
|
|
16
|
+
/** Resolve at every launch/resume; never write credentials into a project file. */
|
|
17
|
+
export function buildIntegrationMcpServers(settings, onUnavailable) {
|
|
18
|
+
const servers = [];
|
|
19
|
+
// Fresh names avoid Codex recursively merging stale native config fields.
|
|
20
|
+
const launch = nanoid(12);
|
|
21
|
+
for (const integration of ['notion', 'sentry']) {
|
|
22
|
+
if (!settings[`${integration}Enabled`])
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
const key = settings[`${integration}McpKey`];
|
|
26
|
+
if (!hasIntegrationMcpConfig(integration, key))
|
|
27
|
+
continue;
|
|
28
|
+
const config = integration === 'notion' ? buildNotionMcpConfig(key, false) : readSentryMcpConfig(key, false);
|
|
29
|
+
servers.push({ name: `kobo-${integration}-${launch}`, ...config });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Optional connections cannot block ordinary missions. Never expose raw
|
|
33
|
+
// config/parse errors, which may contain credential material.
|
|
34
|
+
onUnavailable?.(integration);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return servers;
|
|
38
|
+
}
|
|
39
|
+
export function integrationMcpPrompt(servers, unavailable = []) {
|
|
40
|
+
if (!servers.length && !unavailable.length)
|
|
41
|
+
return '';
|
|
42
|
+
return [
|
|
43
|
+
'[Kōbō managed integrations]',
|
|
44
|
+
...unavailable.map((name) => `${name}: Kōbō could not load this connection. Ask the user to check Settings if needed; do not silently substitute personal credentials.`),
|
|
45
|
+
'For these integrations, use the MCP servers listed below, configured in Kōbō Settings. Prefer them over personal/native servers and namespaces from earlier turns. Discover their available tools; do not assume all servers implement the same tools. If unavailable, report the connection problem instead of silently switching credentials.',
|
|
46
|
+
...servers.map((server) => `${server.name.startsWith('kobo-notion-') ? 'Notion' : 'Sentry'}: ${server.name}`),
|
|
47
|
+
'These connections do not grant permission to modify remote data. Follow the user request and engine permissions.',
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { nanoid } from 'nanoid';
|
|
3
|
+
import { renderWorkflowPolicy } from '../../../shared/workflow-policy.js';
|
|
3
4
|
import { getDb } from '../../db/index.js';
|
|
4
5
|
import { assertAgentStopped } from '../../utils/agent-stop-result.js';
|
|
5
6
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
|
|
6
|
-
import { assertWorkspaceLifecycleAvailable } from '../../utils/workspace-lifecycle-guard.js';
|
|
7
|
+
import { assertWorkspaceLifecycleAvailable, workspaceLifecycleReason } from '../../utils/workspace-lifecycle-guard.js';
|
|
7
8
|
import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
|
|
8
9
|
import * as autoLoopService from '../auto-loop-service.js';
|
|
9
10
|
import * as cleanupScriptService from '../cleanup-script-service.js';
|
|
@@ -12,6 +13,8 @@ import { resolveForge } from '../forge/resolve.js';
|
|
|
12
13
|
import * as lifecycleHookService from '../lifecycle-hook-service.js';
|
|
13
14
|
import * as quotaBackoffService from '../quota-backoff-service.js';
|
|
14
15
|
import { buildReviewReturnPrompt, getReviewReturn, restoreReviewConfiguration } from '../review-return-service.js';
|
|
16
|
+
import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
|
|
17
|
+
import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
|
|
15
18
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
16
19
|
import { refreshNow } from '../usage/poller.js';
|
|
17
20
|
import * as wakeupService from '../wakeup-service.js';
|
|
@@ -21,6 +24,7 @@ import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } fr
|
|
|
21
24
|
import { resolveEngine } from './engines/registry.js';
|
|
22
25
|
import { AGENT_NO_LONGER_RUNNING_TEXT, } from './engines/types.js';
|
|
23
26
|
import { routeEvent } from './event-router.js';
|
|
27
|
+
import { buildIntegrationMcpServers, integrationMcpPrompt } from './integration-mcp.js';
|
|
24
28
|
import { SessionController } from './session-controller.js';
|
|
25
29
|
export class InterruptAgentError extends Error {
|
|
26
30
|
code;
|
|
@@ -474,7 +478,7 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
474
478
|
};
|
|
475
479
|
}
|
|
476
480
|
}
|
|
477
|
-
function buildMcpServers(workspaceId) {
|
|
481
|
+
function buildMcpServers(workspaceId, extraEnv) {
|
|
478
482
|
const mcpServerCompiled = getCompiledMcpServerPath();
|
|
479
483
|
const mcpServerSource = getMcpServerSourcePath();
|
|
480
484
|
return [
|
|
@@ -484,6 +488,7 @@ function buildMcpServers(workspaceId) {
|
|
|
484
488
|
args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
|
|
485
489
|
env: {
|
|
486
490
|
KOBO_WORKSPACE_ID: workspaceId,
|
|
491
|
+
...extraEnv,
|
|
487
492
|
KOBO_DB_PATH: getDbPath(),
|
|
488
493
|
KOBO_SETTINGS_PATH: getSettingsPath(),
|
|
489
494
|
KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
|
|
@@ -540,7 +545,7 @@ function resolveSessionForResume(workspaceId, existingSessionId, model, engineId
|
|
|
540
545
|
}
|
|
541
546
|
else {
|
|
542
547
|
lastSession = db
|
|
543
|
-
.prepare(
|
|
548
|
+
.prepare(`SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND COALESCE(engine, 'claude-code') = ? AND engine_session_id IS NOT NULL AND activation_order >= 0 ORDER BY ${SESSION_RECENCY_ORDER} LIMIT 1`)
|
|
544
549
|
.get(workspaceId, engineId);
|
|
545
550
|
}
|
|
546
551
|
// A workspace can now contain conversations from multiple engines. The
|
|
@@ -841,6 +846,13 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
|
|
|
841
846
|
return;
|
|
842
847
|
const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
|
|
843
848
|
routeEvent(workspaceId, agentSessionId, routedEvent);
|
|
849
|
+
const launch = sourceController ? launchOptions.get(sourceController) : undefined;
|
|
850
|
+
if (ev.kind === 'error')
|
|
851
|
+
launch?.onError?.(ev.message);
|
|
852
|
+
// Summary turns can read and report, but must not drive loop/quota/scheduler side effects.
|
|
853
|
+
if (launch?.handoffGeneration &&
|
|
854
|
+
!['session:started', 'session:ended', 'session:user-input-requested'].includes(ev.kind))
|
|
855
|
+
return;
|
|
844
856
|
if (sourceController && !sourceControllerIsStopping) {
|
|
845
857
|
if (ev.kind === 'session:compacting' && ev.active) {
|
|
846
858
|
const status = getWs(workspaceId)?.status;
|
|
@@ -958,9 +970,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
|
|
|
958
970
|
console.error('[orchestrator] Failed to transition to executing:', err);
|
|
959
971
|
}
|
|
960
972
|
}
|
|
973
|
+
const transferring = workspaceLifecycleReason(workspaceId) === 'session-handoff';
|
|
961
974
|
const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
|
|
962
975
|
const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
|
|
963
|
-
if (quotaError && !sourceControllerIsStopping && !returningReview) {
|
|
976
|
+
if (quotaError && !sourceControllerIsStopping && !returningReview && !transferring) {
|
|
964
977
|
void handleQuota(workspaceId, agentSessionId);
|
|
965
978
|
}
|
|
966
979
|
if (ev.kind === 'error' &&
|
|
@@ -968,6 +981,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
|
|
|
968
981
|
ev.category !== 'resume_failed' &&
|
|
969
982
|
TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
|
|
970
983
|
!returningReview &&
|
|
984
|
+
!transferring &&
|
|
971
985
|
getWs(workspaceId)?.autoLoop &&
|
|
972
986
|
!sourceControllerIsStopping) {
|
|
973
987
|
scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
|
|
@@ -989,6 +1003,22 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
|
|
|
989
1003
|
// its persisted prompts too, otherwise a reconnect resurrects a panel
|
|
990
1004
|
// whose backend callback has already been discarded.
|
|
991
1005
|
purgeAllPersistedUserInputRequests(workspaceId, agentSessionId);
|
|
1006
|
+
if (transferring) {
|
|
1007
|
+
onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
|
|
1008
|
+
launch?.onEnded?.(ev);
|
|
1009
|
+
if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
|
|
1010
|
+
if (!launch?.handoffGeneration) {
|
|
1011
|
+
settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed');
|
|
1012
|
+
if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown'))
|
|
1013
|
+
autoLoopService.block(workspaceId, 'message-delivery-unknown');
|
|
1014
|
+
}
|
|
1015
|
+
notifyCapacityAvailable(workspaceId);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
// A target can finish before its ready/start promise settles. Its callback
|
|
1019
|
+
// completed the transfer and released ownership: process this actual work
|
|
1020
|
+
// session through the ordinary terminal/auto-loop path below exactly once.
|
|
1021
|
+
}
|
|
992
1022
|
// A watchdog end means Kōbō forced the stream closed after the engine
|
|
993
1023
|
// failed to drain. It is neither a clean completion nor evidence of an
|
|
994
1024
|
// agent stall. Move auto-loop work onto the existing persisted, bounded
|
|
@@ -1190,6 +1220,11 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1190
1220
|
preCompactionStatus.delete(sourceController);
|
|
1191
1221
|
if (isSuperseded)
|
|
1192
1222
|
return false;
|
|
1223
|
+
if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
|
|
1224
|
+
if (registeredController === sourceController && sourceController?.status !== 'stopping')
|
|
1225
|
+
controllers.delete(workspaceId);
|
|
1226
|
+
return false;
|
|
1227
|
+
}
|
|
1193
1228
|
// A superseded end (above) is a session that was replaced, not one that
|
|
1194
1229
|
// ended; everything past this point is a real end the user may want to act
|
|
1195
1230
|
// on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
|
|
@@ -1254,6 +1289,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1254
1289
|
}
|
|
1255
1290
|
return true;
|
|
1256
1291
|
}
|
|
1292
|
+
const launchOptions = new WeakMap();
|
|
1257
1293
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
1258
1294
|
/**
|
|
1259
1295
|
* Spawn an agent (via the resolved engine) for a workspace. Returns
|
|
@@ -1261,10 +1297,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
1261
1297
|
* after `engine.start` resolves — callers should subscribe to WS events or
|
|
1262
1298
|
* query the controller via `_getControllers()` for tests.
|
|
1263
1299
|
*/
|
|
1264
|
-
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch) {
|
|
1300
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
|
|
1265
1301
|
if (shuttingDown)
|
|
1266
1302
|
throw new Error('Cannot start an agent while the server is shutting down');
|
|
1267
|
-
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1303
|
+
assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
|
|
1268
1304
|
const workspace = getWs(workspaceId);
|
|
1269
1305
|
if (!workspace)
|
|
1270
1306
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -1334,12 +1370,15 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1334
1370
|
}
|
|
1335
1371
|
// Persist before the asynchronous engine can emit its first event. This
|
|
1336
1372
|
// survives a Kōbō restart and does not depend on SDK system:init delivery.
|
|
1373
|
+
activateSession(workspaceId, agentSessionId);
|
|
1337
1374
|
captureTaskProgressBaseline(workspaceId, agentSessionId);
|
|
1338
1375
|
const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
|
|
1376
|
+
const unavailableIntegrations = [];
|
|
1377
|
+
const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), (name) => unavailableIntegrations.push(name));
|
|
1339
1378
|
const options = {
|
|
1340
1379
|
workspaceId,
|
|
1341
1380
|
workingDir,
|
|
1342
|
-
prompt: buildAgentPrompt(
|
|
1381
|
+
prompt: buildAgentPrompt(`${ws ? renderWorkflowPolicy(ws.workflowPolicy) : ''}\n${integrationMcpPrompt(integrationServers, unavailableIntegrations)}\n${prompt}`, ws?.projectPath),
|
|
1343
1382
|
model,
|
|
1344
1383
|
effort: reasoningEffort,
|
|
1345
1384
|
// Cascade: explicit caller override → workspace setting → 'bypass'.
|
|
@@ -1355,12 +1394,14 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1355
1394
|
}
|
|
1356
1395
|
})(),
|
|
1357
1396
|
settings,
|
|
1358
|
-
mcpServers: buildMcpServers(workspaceId),
|
|
1397
|
+
mcpServers: [...buildMcpServers(workspaceId, launch?.mcpEnv), ...integrationServers],
|
|
1359
1398
|
env: ws ? buildAgentEnv(ws.projectPath) : undefined,
|
|
1360
1399
|
};
|
|
1361
1400
|
beforeDispatch?.();
|
|
1362
1401
|
let controller;
|
|
1363
1402
|
controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
|
|
1403
|
+
if (launch)
|
|
1404
|
+
launchOptions.set(controller, launch);
|
|
1364
1405
|
registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
|
|
1365
1406
|
if (!existingCtrl)
|
|
1366
1407
|
controllers.set(workspaceId, controller);
|
|
@@ -1377,7 +1418,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1377
1418
|
return;
|
|
1378
1419
|
if (pendingStarts.get(workspaceId) === controller)
|
|
1379
1420
|
pendingStarts.delete(workspaceId);
|
|
1380
|
-
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1421
|
+
assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
|
|
1381
1422
|
const current = getWs(workspaceId);
|
|
1382
1423
|
if (!current || current.archivedAt || current.worktreePurgedAt)
|
|
1383
1424
|
throw new Error('Workspace is no longer available');
|
|
@@ -1387,7 +1428,11 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
1387
1428
|
controllers.set(workspaceId, controller);
|
|
1388
1429
|
return controller.start(options);
|
|
1389
1430
|
})
|
|
1390
|
-
.then(() => {
|
|
1431
|
+
.then(async () => {
|
|
1432
|
+
if (launch?.onStarted)
|
|
1433
|
+
await controller.engineProcess?.ready;
|
|
1434
|
+
if (controller.status !== 'stopping')
|
|
1435
|
+
launch?.onStarted?.();
|
|
1391
1436
|
const pid = controller.pid;
|
|
1392
1437
|
if (pid !== undefined) {
|
|
1393
1438
|
try {
|
|
@@ -1524,6 +1569,9 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
|
|
|
1524
1569
|
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
|
|
1525
1570
|
/** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
|
|
1526
1571
|
cause = 'user') {
|
|
1572
|
+
const finishHandoffStop = ['user', 'delete', 'purge', 'archive'].includes(cause)
|
|
1573
|
+
? requestHandoffStop(workspaceId)
|
|
1574
|
+
: undefined;
|
|
1527
1575
|
const pending = pendingStarts.get(workspaceId);
|
|
1528
1576
|
let pendingStop = Promise.resolve();
|
|
1529
1577
|
if (pending) {
|
|
@@ -1544,7 +1592,8 @@ cause = 'user') {
|
|
|
1544
1592
|
// Shutdown only suspends timers: persisted intent belongs to the next boot.
|
|
1545
1593
|
const cancelSchedules = new Promise((resolve) => {
|
|
1546
1594
|
if (cause !== 'shutdown') {
|
|
1547
|
-
|
|
1595
|
+
if (cause !== 'handoff')
|
|
1596
|
+
wakeupService.cancel(workspaceId, 'stopped');
|
|
1548
1597
|
quotaBackoffService.cancel(workspaceId, 'user');
|
|
1549
1598
|
}
|
|
1550
1599
|
resolve();
|
|
@@ -1561,6 +1610,7 @@ cause = 'user') {
|
|
|
1561
1610
|
if (outcome === 'timeout') {
|
|
1562
1611
|
console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
|
|
1563
1612
|
}
|
|
1613
|
+
finishHandoffStop?.(outcome);
|
|
1564
1614
|
return outcome;
|
|
1565
1615
|
}
|
|
1566
1616
|
/**
|
|
@@ -1584,6 +1634,7 @@ export async function stopAllAgents(timeoutMs = 3_000) {
|
|
|
1584
1634
|
wakeupService.suspendForShutdown();
|
|
1585
1635
|
cronService.suspendForShutdown();
|
|
1586
1636
|
shuttingDown = true;
|
|
1637
|
+
suspendHandoffTransfers();
|
|
1587
1638
|
if (capacityTimer)
|
|
1588
1639
|
clearTimeout(capacityTimer);
|
|
1589
1640
|
capacityTimer = undefined;
|
|
@@ -1603,6 +1654,8 @@ export function isShuttingDown() {
|
|
|
1603
1654
|
}
|
|
1604
1655
|
/** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
|
|
1605
1656
|
export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
1657
|
+
if (workspaceLifecycleReason(workspaceId) === 'session-handoff')
|
|
1658
|
+
return false;
|
|
1606
1659
|
const ctrl = controllers.get(workspaceId);
|
|
1607
1660
|
if (ctrl?.status !== 'running')
|
|
1608
1661
|
return false;
|
|
@@ -1620,6 +1673,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
|
|
|
1620
1673
|
}
|
|
1621
1674
|
/** Write a user message to the running agent. */
|
|
1622
1675
|
export async function sendMessage(workspaceId, content, expectedSessionId, beforeDispatch) {
|
|
1676
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1623
1677
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1624
1678
|
const ctrl = controllers.get(workspaceId);
|
|
1625
1679
|
if (!ctrl) {
|
|
@@ -1633,6 +1687,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
|
|
|
1633
1687
|
await ctrl.sendMessage(content);
|
|
1634
1688
|
}
|
|
1635
1689
|
export async function sendMessageForFallback(workspaceId, content) {
|
|
1690
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1636
1691
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1637
1692
|
const capturedController = controllers.get(workspaceId);
|
|
1638
1693
|
wakeupService.cancel(workspaceId, 'user-message');
|
|
@@ -1643,6 +1698,7 @@ export async function sendMessageForFallback(workspaceId, content) {
|
|
|
1643
1698
|
return { status: 'sent', sessionId: capturedController.agentSessionId };
|
|
1644
1699
|
}
|
|
1645
1700
|
catch {
|
|
1701
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1646
1702
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1647
1703
|
const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
|
|
1648
1704
|
while (controllers.get(workspaceId) === capturedController) {
|
|
@@ -1657,6 +1713,7 @@ export async function sendMessageForFallback(workspaceId, content) {
|
|
|
1657
1713
|
const replacementController = controllers.get(workspaceId);
|
|
1658
1714
|
if (!replacementController)
|
|
1659
1715
|
return { status: 'stopped' };
|
|
1716
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
1660
1717
|
assertWorkspaceNotCompacting(workspaceId);
|
|
1661
1718
|
await replacementController.sendMessage(content);
|
|
1662
1719
|
return { status: 'sent', sessionId: replacementController.agentSessionId };
|