@loicngr/kobo 1.6.0 → 1.6.2
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 +6 -1
- package/README.md +43 -29
- package/dist/server/db/index.js +17 -0
- package/dist/server/db/migrations.js +10 -0
- package/dist/server/db/schema.js +2 -1
- package/dist/server/index.js +26 -5
- package/dist/server/middleware/migration-guard.js +15 -0
- package/dist/server/routes/dev-server.js +3 -2
- package/dist/server/routes/documents.js +113 -0
- package/dist/server/routes/engines.js +9 -0
- package/dist/server/routes/migration.js +5 -0
- package/dist/server/routes/workspaces.js +35 -11
- package/dist/server/services/agent/engines/claude-code/args-builder.js +22 -0
- package/dist/server/services/agent/engines/claude-code/capabilities.js +17 -0
- package/dist/server/services/agent/engines/claude-code/engine.js +163 -0
- package/dist/server/services/agent/engines/claude-code/mcp-config.js +23 -0
- package/dist/server/services/agent/engines/claude-code/stream-parser.js +224 -0
- package/dist/server/services/agent/engines/registry.js +21 -0
- package/dist/server/services/agent/engines/types.js +18 -0
- package/dist/server/services/agent/event-router.js +4 -0
- package/dist/server/services/agent/orchestrator.js +582 -0
- package/dist/server/services/agent/session-controller.js +79 -0
- package/dist/server/services/content-migration-service.js +155 -0
- package/dist/server/services/db-backup-service.js +15 -0
- package/dist/server/services/websocket-service.js +81 -50
- package/dist/server/services/workspace-service.js +11 -5
- package/dist/server/utils/paths.js +1 -1
- package/dist/shared/models.js +50 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-CFuT6H5u.js +7 -0
- package/src/client/dist/spa/assets/ActivityFeed-DXYafbn4.css +1 -0
- package/src/client/dist/spa/assets/ClosePopup-DhM1C4Zw.js +1 -0
- package/src/client/dist/spa/assets/CreatePage-sGrkfyOm.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-yu2IH7GW.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-BVU58ujc.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-BwSRtVRI.js +2 -0
- package/src/client/dist/spa/assets/HealthPage-BO-bMpEu.js +1 -0
- package/src/client/dist/spa/assets/MainLayout-BiBJtDTk.css +1 -0
- package/src/client/dist/spa/assets/{MainLayout-_oPM07ln.js → MainLayout-BpqOChIX.js} +17 -17
- package/src/client/dist/spa/assets/QBadge-DqtcDv8D.js +1 -0
- package/src/client/dist/spa/assets/QBtn-CyzfM9-_.js +1 -0
- package/src/client/dist/spa/assets/QChip-KJoHYE6F.js +1 -0
- package/src/client/dist/spa/assets/QDialog-DQeAxY3-.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-DCRks-Ra.js +1 -0
- package/src/client/dist/spa/assets/{QSpinner-CliSLjf8.js → QIcon-B0-pH3Qs.js} +1 -1
- package/src/client/dist/spa/assets/QItemLabel-Codqjisk.js +1 -0
- package/src/client/dist/spa/assets/QItemSection-CGpX7GcL.js +1 -0
- package/src/client/dist/spa/assets/QList-B-MkPF7n.js +1 -0
- package/src/client/dist/spa/assets/QPage-yqdKDG7-.js +1 -0
- package/src/client/dist/spa/assets/QScrollArea-e5qTqwcb.js +1 -0
- package/src/client/dist/spa/assets/QSeparator-rkjCbX2M.js +1 -0
- package/src/client/dist/spa/assets/QSlideTransition-BQxI8l5r.js +1 -0
- package/src/client/dist/spa/assets/QSpace-BNr0AftG.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DEiRooBD.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels--6cYe2US.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-C4CPesBX.js +1 -0
- package/src/client/dist/spa/assets/SearchPage-BrUbbtgI.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B3elO1PX.js +1 -0
- package/src/client/dist/spa/assets/TouchPan-BT6phK1f.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BHl17_tY.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-DPGiH02q.css +1 -0
- package/src/client/dist/spa/assets/build-path-tree-Bgl2q74t.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-DMX8jq8u.js → cssMode-B1wQ-79R.js} +1 -1
- package/src/client/dist/spa/assets/documents-CHc8t22V.js +60 -0
- package/src/client/dist/spa/assets/{editor.api-DirOkGGg.js → editor.api-CRb_5Zw6.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-DC4ezIu0.js → editor.main-C5sdCvGW.js} +3 -3
- package/src/client/dist/spa/assets/format-Bttc9ToS.js +1 -0
- package/src/client/dist/spa/assets/{formatters-BzaS4w0I.js → formatters-BDadphwz.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-DI9xJfj0.js → freemarker2-CVSnsZk-.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-B9F-pScn.js → handlebars-uL_pucGI.js} +1 -1
- package/src/client/dist/spa/assets/{html-DTe2v8Q8.js → html-CatZVwWp.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-F_XLjWfJ.js → htmlMode-DTDzEngo.js} +1 -1
- package/src/client/dist/spa/assets/i18n-D-VdPLEh.js +1 -0
- package/src/client/dist/spa/assets/index-CUI-zN26.js +2 -0
- package/src/client/dist/spa/assets/{javascript-B9xJRPC6.js → javascript-DeHBpolA.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-DTZ6j6UO.js → jsonMode-Bma_YGGm.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-BjU5MtW6.js → liquid-CW7xQEG_.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-BMUpG7Be.js → mdx-BsYUhMzF.js} +1 -1
- package/src/client/dist/spa/assets/models-BbSRHL9b.js +1 -0
- package/src/client/dist/spa/assets/{monaco.contribution-D7JUf8DP.js → monaco.contribution-Du0atePv.js} +2 -2
- package/src/client/dist/spa/assets/private.use-form-D1RuEt2P.js +1 -0
- package/src/client/dist/spa/assets/{python-Dz0D4uSk.js → python-D7DQWXZm.js} +1 -1
- package/src/client/dist/spa/assets/{razor-D7CFxuwR.js → razor-B2ZxF301.js} +1 -1
- package/src/client/dist/spa/assets/scroll-JVVkg2Ng.js +1 -0
- package/src/client/dist/spa/assets/touch-CBLrR6_z.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-DjscaxpS.js → tsMode-B4Xul5xA.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-DozCWZl2.js → typescript-CdsKQuLT.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-DYiZQsbF.js +1 -0
- package/src/client/dist/spa/assets/use-id-CeduaJbU.js +1 -0
- package/src/client/dist/spa/assets/use-portal-DBe4lcC2.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Ch82z8H5.js +1 -0
- package/src/client/dist/spa/assets/{xml-DFOJMT39.js → xml-Wap00dMv.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-yEefnsXm.js → yaml-BxRDHC24.js} +1 -1
- package/src/client/dist/spa/index.html +12 -14
- package/src/mcp-server/README.md +1 -1
- package/dist/server/routes/plans.js +0 -89
- package/dist/server/services/agent-manager.js +0 -621
- package/src/client/dist/spa/assets/ActivityFeed-0GR1zPoc.js +0 -10
- package/src/client/dist/spa/assets/ActivityFeed-CfsKExt9.css +0 -1
- package/src/client/dist/spa/assets/ClosePopup-CdSn7HO8.js +0 -1
- package/src/client/dist/spa/assets/CreatePage-dMi4xVYN.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-je_7dC5I.js +0 -2
- package/src/client/dist/spa/assets/DiffViewer-DREYX-8k.js +0 -2
- package/src/client/dist/spa/assets/DiffViewer-DiHFLSk4.css +0 -1
- package/src/client/dist/spa/assets/HealthPage-Do8QZdxw.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-B5poKEy_.css +0 -1
- package/src/client/dist/spa/assets/QBadge-Bvh-hQ8K.js +0 -1
- package/src/client/dist/spa/assets/QBtn-BsD8vrWq.js +0 -1
- package/src/client/dist/spa/assets/QDialog-CkbLS1If.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-UgkE560c.js +0 -1
- package/src/client/dist/spa/assets/QList-D80ms7bw.js +0 -1
- package/src/client/dist/spa/assets/QMenu-DU-wiY_A.js +0 -1
- package/src/client/dist/spa/assets/QPage-BKY2-sf-.js +0 -1
- package/src/client/dist/spa/assets/QSpace-C5Ebr0vq.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-Dp12eHrB.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-C7lWp1yU.js +0 -1
- package/src/client/dist/spa/assets/QToggle-B0HvuNEg.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-kLXuUa_m.js +0 -1
- package/src/client/dist/spa/assets/SearchPage-CCfyqBKh.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CmyIsV-S.js +0 -1
- package/src/client/dist/spa/assets/TouchPan-CVMnGs0y.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-CWRMLYs-.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-Cl7YrG51.js +0 -4
- package/src/client/dist/spa/assets/focus-manager-DYbz9jFW.js +0 -1
- package/src/client/dist/spa/assets/format-Cyg8IgRi.js +0 -1
- package/src/client/dist/spa/assets/i18n-B13zBh1H.js +0 -1
- package/src/client/dist/spa/assets/i18n-CCWLBc0p.js +0 -1
- package/src/client/dist/spa/assets/index-DoNZ_5QK.js +0 -5
- package/src/client/dist/spa/assets/marked.esm-DCmk6NO8.js +0 -60
- package/src/client/dist/spa/assets/models-B8fzv7K4.js +0 -1
- package/src/client/dist/spa/assets/pinia-C3JsrLkB.js +0 -1
- package/src/client/dist/spa/assets/private.use-form-BhKyDtO7.js +0 -1
- package/src/client/dist/spa/assets/scroll-CLibRGI-.js +0 -1
- package/src/client/dist/spa/assets/settings-B69lIVX0.js +0 -1
- package/src/client/dist/spa/assets/touch-ChrvzrnI.js +0 -1
- package/src/client/dist/spa/assets/use-dark-DnuCB6tC.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-DBoizHBW.js +0 -1
- /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cxt1D8wE.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
- /package/src/client/dist/spa/assets/{abap-CFuyUYKP.js → abap-Bgec7Keq.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-Ctq_xcrv.js → apex-VBlPwEoQ.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-BBQSVn-C.js → azcli-DKqrEFBx.js} +0 -0
- /package/src/client/dist/spa/assets/{bat-DbnqAfvr.js → bat-DdgQWy_0.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-BtDlIXop.js → bicep-CRMM43EB.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-BLeJgKTj.js → cameligo-UatALtML.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-aZUQIUKP.js → clojure-D8JU08RA.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-Secadq9U.js → coffee-C56wu358.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-JicRPTRv.js → cpp-CyZLvhJG.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-C7NSOZyj.js → csharp-BJl3ixva.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-CIje7830.js → csp-CxEKxmO-.js} +0 -0
- /package/src/client/dist/spa/assets/{css-G0bm1q_M.js → css-B0t_muXd.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-CldD5D0u.js → cypher-D1hqiMFD.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-DIK3l8YT.js → dart-Bz550Pyv.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-czxaGh2L.js → dockerfile-CIXgVAuA.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-BqdYhwmw.js → ecl-D9qbvZoA.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-m52LePTW.js → elixir-b2M38fAy.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-B5QJ9GvZ.js → flow9-Dq1UYMkt.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-B15czHsH.js → fsharp-CFNadkg7.js} +0 -0
- /package/src/client/dist/spa/assets/{go-BkoQxDo1.js → go-dSur1iB2.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-BnI6uRa_.js → graphql-qyhAo11d.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-CAwwENT7.js → hcl-DFzjMyzm.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-BHM5zh1H.js → ini-TdzA8TIl.js} +0 -0
- /package/src/client/dist/spa/assets/{java-B5i95QvQ.js → java-CSGA9pkE.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-DPDm885q.js → julia-9izz5OsY.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-qoccd5BP.js → kotlin-DuPK7AtF.js} +0 -0
- /package/src/client/dist/spa/assets/{less-B6RU166D.js → less-B8d93iCg.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-YfUeoL1V.js → lexon-DWtEIyu7.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-BIUI5y9b.js → lua-Ciq0OGgt.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-D5SAbSdU.js → m3-Cki6JWj_.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown-CVJLwHzJ.js → markdown-Cu47xwU0.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-R-FZ3zOR.js → mips-BM8ui995.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-Blveyl9r.js → msdax-DqLio0_c.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-D4mY1AFx.js → mysql-v1wbjJOq.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-BmXrLr4h.js → objective-c-CQl3PGSB.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-yxckoyvV.js → pascal-D4iW0ZtD.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-Q5JCwXMI.js → pascaligo-BdC9CZdj.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-BF1Rrs5h.js → perl-BL10m4XD.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-CnYB97wm.js → pgsql-Be_oqVo3.js} +0 -0
- /package/src/client/dist/spa/assets/{php-CdDfQfSg.js → php-BtvXSFRI.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-whj-d71F.js → pla-B2vUy15C.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-ClfLr4I-.js → postiats-CbmTTfXr.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-iRaBhuuk.js → powerquery-DszLhJGx.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-DjiEt5xK.js → powershell-B0dYktF6.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-B6dcIEUr.js → protobuf-CZvaj1VX.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-DtmHnjM9.js → pug-CPDx1B3S.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-CELCyd79.js → qsharp-CDP9TFLl.js} +0 -0
- /package/src/client/dist/spa/assets/{r-ZpJXWV-o.js → r-8DbbFX2l.js} +0 -0
- /package/src/client/dist/spa/assets/{rate-limit-labels-dCPVjS61.js → rate-limit-labels-BoDORKFj.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-BiHSNkAl.js → redis-DRWj9MtJ.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-DzuwYCHP.js → redshift-C6cElE_5.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-YOT94bbS.js → restructuredtext-W9pS9n3m.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-BfiHr6Uu.js → ruby-BKnzWnk-.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-JZ-uOoYM.js → rust-YPCclWwe.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-CBglP1-t.js → sb-BgM4DTFb.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-C9l41paw.js → scala-fz1OPLMl.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-B-InQ6hy.js → scheme-8Uz1RIbu.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-v6OmJRN9.js → scss-Djo3IYXr.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-Dyp6iwB6.js → shell-CINF5Tx_.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-D5epNWue.js → solidity-GgiNEuUm.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-Eva-79sB.js → sophia-Culj97P9.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-gvALLO1w.js → sparql-C2ZlpxOY.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-COdamZYI.js → sql-BEf5Pg7Y.js} +0 -0
- /package/src/client/dist/spa/assets/{st-eMoImIwE.js → st-CT6UUoeH.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-7R_T9RYH.js → swift-B5g0xTG3.js} +0 -0
- /package/src/client/dist/spa/assets/{symbols-CAg-nBkV.js → symbols-DCYodwb2.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-1pCEfaHU.js → systemverilog-CEgQz9DR.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-B_KgnhfE.js → tcl-D0qL2L0I.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-CFZUJxb9.js → twig-BFUAVf1E.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-B1ZgHlud.js → typespec-CjVVcNKm.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-DKdun5tL.js → vb-CZJr-DQz.js} +0 -0
- /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-CeG0hR0Z.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-CzNaxTrn.js → wgsl-ivoXUo2e.js} +0 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
import { getDb } from '../../db/index.js';
|
|
4
|
+
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
|
|
5
|
+
import { unregisterProcess } from '../../utils/process-tracker.js';
|
|
6
|
+
import { getEffectiveSettings } from '../settings-service.js';
|
|
7
|
+
import { emitEphemeral } from '../websocket-service.js';
|
|
8
|
+
import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus } from '../workspace-service.js';
|
|
9
|
+
import { resolveEngine } from './engines/registry.js';
|
|
10
|
+
import { routeEvent } from './event-router.js';
|
|
11
|
+
import { SessionController } from './session-controller.js';
|
|
12
|
+
// ── State ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
/** Actual bound port of the running backend — set at startup via setBackendPort() */
|
|
14
|
+
let backendPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
|
|
15
|
+
/** Called from index.ts once the HTTP server is listening so MCP children can reach it. */
|
|
16
|
+
export function setBackendPort(port) {
|
|
17
|
+
backendPort = port;
|
|
18
|
+
}
|
|
19
|
+
/** workspaceId -> SessionController */
|
|
20
|
+
const controllers = new Map();
|
|
21
|
+
/** workspaceId -> last engine session ID (for resume) */
|
|
22
|
+
const sessionIds = new Map();
|
|
23
|
+
/** Cached list of available slash commands — persisted to <KOBO_HOME>/skills.json */
|
|
24
|
+
let availableSkills = (() => {
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(readFileSync(getSkillsPath(), 'utf-8'));
|
|
27
|
+
return Array.isArray(data) ? data : [];
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
/** workspaceId -> retry count (for quota backoff) */
|
|
34
|
+
const retryCounts = new Map();
|
|
35
|
+
/** workspaceId -> backoff timer */
|
|
36
|
+
const backoffTimers = new Map();
|
|
37
|
+
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
38
|
+
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
39
|
+
let watchdogTimer = null;
|
|
40
|
+
function isProcessAlive(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function runWatchdog() {
|
|
50
|
+
for (const [workspaceId, ctrl] of controllers) {
|
|
51
|
+
const pid = ctrl.pid;
|
|
52
|
+
if (pid === undefined || isProcessAlive(pid))
|
|
53
|
+
continue;
|
|
54
|
+
console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
|
|
55
|
+
// Emit an error + session:ended AgentEvent pair so clients can react uniformly
|
|
56
|
+
try {
|
|
57
|
+
routeEvent(workspaceId, ctrl.agentSessionId, {
|
|
58
|
+
kind: 'error',
|
|
59
|
+
category: 'other',
|
|
60
|
+
message: 'Agent process died unexpectedly',
|
|
61
|
+
});
|
|
62
|
+
routeEvent(workspaceId, ctrl.agentSessionId, {
|
|
63
|
+
kind: 'session:ended',
|
|
64
|
+
reason: 'killed',
|
|
65
|
+
exitCode: null,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.warn('[watchdog] Failed to route death notification events:', err);
|
|
70
|
+
}
|
|
71
|
+
unregisterProcess(workspaceId);
|
|
72
|
+
if (controllers.get(workspaceId) === ctrl)
|
|
73
|
+
controllers.delete(workspaceId);
|
|
74
|
+
retryCounts.delete(workspaceId);
|
|
75
|
+
try {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
console.error('[watchdog] Failed to update agent_sessions:', err);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.warn('[watchdog] Failed to transition workspace to error (likely invalid transition):', err);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
markWorkspaceUnread(workspaceId);
|
|
90
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.warn('[watchdog] Failed to mark workspace unread:', err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* On server start, any `agent_sessions` row still in `running` status is
|
|
99
|
+
* necessarily orphaned — the process that owned it died with the previous
|
|
100
|
+
* server run. Mark those rows as `error` (or `completed` if the PID is
|
|
101
|
+
* somehow still alive and reachable) so the health check stops complaining
|
|
102
|
+
* and the UI doesn't show ghost agents.
|
|
103
|
+
*
|
|
104
|
+
* Called once at boot, BEFORE `startWatchdog`.
|
|
105
|
+
*/
|
|
106
|
+
export function reconcileOrphanSessions() {
|
|
107
|
+
try {
|
|
108
|
+
const db = getDb();
|
|
109
|
+
const rows = db.prepare("SELECT id, pid FROM agent_sessions WHERE status = 'running'").all();
|
|
110
|
+
if (rows.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
const update = db.prepare("UPDATE agent_sessions SET status = 'error', ended_at = ? WHERE id = ?");
|
|
114
|
+
let fixed = 0;
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
117
|
+
continue; // genuine leftover from a graceful restart — skip
|
|
118
|
+
update.run(now, row.id);
|
|
119
|
+
fixed++;
|
|
120
|
+
}
|
|
121
|
+
if (fixed > 0) {
|
|
122
|
+
console.log(`[orchestrator] Reconciled ${fixed} orphan agent_sessions row(s) at boot.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error('[orchestrator] Failed to reconcile orphan agent_sessions at boot:', err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Start the watchdog (called once from server bootstrap). */
|
|
130
|
+
export function startWatchdog() {
|
|
131
|
+
if (watchdogTimer)
|
|
132
|
+
return;
|
|
133
|
+
watchdogTimer = setInterval(runWatchdog, WATCHDOG_INTERVAL_MS);
|
|
134
|
+
watchdogTimer.unref?.();
|
|
135
|
+
}
|
|
136
|
+
/** Stop the watchdog (for clean shutdown / tests). */
|
|
137
|
+
export function stopWatchdog() {
|
|
138
|
+
if (watchdogTimer) {
|
|
139
|
+
clearInterval(watchdogTimer);
|
|
140
|
+
watchdogTimer = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── Engine + settings helpers ─────────────────────────────────────────────────
|
|
144
|
+
function readWorkspaceEngineId(workspaceId) {
|
|
145
|
+
const db = getDb();
|
|
146
|
+
try {
|
|
147
|
+
const row = db
|
|
148
|
+
.prepare('SELECT engine FROM workspaces WHERE id = ?')
|
|
149
|
+
.get(workspaceId);
|
|
150
|
+
return row?.engine ?? 'claude-code';
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
// Guard against a test DB or mid-migration DB where the column doesn't
|
|
154
|
+
// exist yet. Only treat "no such column" as a benign fallback; every
|
|
155
|
+
// other DB error propagates so we don't silently mask real failures.
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
if (message.includes('no such column: engine')) {
|
|
158
|
+
console.warn(`[orchestrator] 'engine' column missing on workspaces, defaulting to claude-code`);
|
|
159
|
+
return 'claude-code';
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function readEffectiveSettingsSafe(projectPath) {
|
|
165
|
+
try {
|
|
166
|
+
return getEffectiveSettings(projectPath);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
console.warn('[orchestrator] Failed to load settings, using defaults:', err);
|
|
170
|
+
return {
|
|
171
|
+
model: 'claude-opus-4-7',
|
|
172
|
+
dangerouslySkipPermissions: true,
|
|
173
|
+
prPromptTemplate: '',
|
|
174
|
+
gitConventions: '',
|
|
175
|
+
sourceBranch: 'main',
|
|
176
|
+
devServer: null,
|
|
177
|
+
setupScript: '',
|
|
178
|
+
notionStatusProperty: '',
|
|
179
|
+
notionInProgressStatus: '',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function buildMcpServers(workspaceId) {
|
|
184
|
+
const mcpServerCompiled = getCompiledMcpServerPath();
|
|
185
|
+
const mcpServerSource = getMcpServerSourcePath();
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
name: 'kobo-tasks',
|
|
189
|
+
command: mcpServerCompiled ? 'node' : 'npx',
|
|
190
|
+
args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
|
|
191
|
+
env: {
|
|
192
|
+
KOBO_WORKSPACE_ID: workspaceId,
|
|
193
|
+
KOBO_DB_PATH: getDbPath(),
|
|
194
|
+
KOBO_SETTINGS_PATH: getSettingsPath(),
|
|
195
|
+
KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
function resolveSessionForResume(workspaceId, existingSessionId) {
|
|
201
|
+
const db = getDb();
|
|
202
|
+
let lastSession;
|
|
203
|
+
if (existingSessionId) {
|
|
204
|
+
lastSession = db
|
|
205
|
+
.prepare('SELECT id, engine_session_id FROM agent_sessions WHERE id = ? AND workspace_id = ? AND engine_session_id IS NOT NULL LIMIT 1')
|
|
206
|
+
.get(existingSessionId, workspaceId);
|
|
207
|
+
if (!lastSession) {
|
|
208
|
+
throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
|
|
209
|
+
'session not found or has no associated engine conversation');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
lastSession = db
|
|
214
|
+
.prepare('SELECT id, engine_session_id FROM agent_sessions WHERE workspace_id = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
|
|
215
|
+
.get(workspaceId);
|
|
216
|
+
}
|
|
217
|
+
const engineSessionId = lastSession?.engine_session_id ?? (existingSessionId ? undefined : sessionIds.get(workspaceId));
|
|
218
|
+
if (engineSessionId) {
|
|
219
|
+
const existingId = lastSession?.id ??
|
|
220
|
+
db
|
|
221
|
+
.prepare('SELECT id FROM agent_sessions WHERE engine_session_id = ? ORDER BY started_at DESC LIMIT 1')
|
|
222
|
+
.get(engineSessionId)?.id;
|
|
223
|
+
const agentSessionId = existingId ?? nanoid();
|
|
224
|
+
if (existingId) {
|
|
225
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = NULL WHERE id = ?').run('running', agentSessionId);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, engine_session_id, started_at) VALUES (?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', engineSessionId, new Date().toISOString());
|
|
229
|
+
}
|
|
230
|
+
return { agentSessionId, engineSessionId, existed: Boolean(existingId) };
|
|
231
|
+
}
|
|
232
|
+
// No engine session to resume — fall through to fresh session creation
|
|
233
|
+
const agentSessionId = nanoid();
|
|
234
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
|
|
235
|
+
return { agentSessionId, engineSessionId: undefined, existed: false };
|
|
236
|
+
}
|
|
237
|
+
function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
|
|
238
|
+
const db = getDb();
|
|
239
|
+
if (existingSessionId) {
|
|
240
|
+
const result = db
|
|
241
|
+
.prepare('UPDATE agent_sessions SET status = ?, started_at = ?, ended_at = NULL WHERE id = ? AND workspace_id = ?')
|
|
242
|
+
.run('running', new Date().toISOString(), existingSessionId, workspaceId);
|
|
243
|
+
if (result.changes === 0) {
|
|
244
|
+
throw new Error(`Agent session '${existingSessionId}' not found for workspace '${workspaceId}'`);
|
|
245
|
+
}
|
|
246
|
+
return existingSessionId;
|
|
247
|
+
}
|
|
248
|
+
const agentSessionId = nanoid();
|
|
249
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
|
|
250
|
+
return agentSessionId;
|
|
251
|
+
}
|
|
252
|
+
// ── Event handler ─────────────────────────────────────────────────────────────
|
|
253
|
+
function handleEvent(workspaceId, agentSessionId, ev) {
|
|
254
|
+
routeEvent(workspaceId, agentSessionId, ev);
|
|
255
|
+
if (ev.kind === 'skills:discovered') {
|
|
256
|
+
availableSkills = ev.skills;
|
|
257
|
+
try {
|
|
258
|
+
ensureKoboHome();
|
|
259
|
+
writeFileSync(getSkillsPath(), JSON.stringify(availableSkills));
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
console.error('[orchestrator] Failed to persist skills:', err);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (ev.kind === 'session:brainstorm-complete') {
|
|
266
|
+
try {
|
|
267
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
console.error('[orchestrator] Failed to transition to executing:', err);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (ev.kind === 'error' && ev.category === 'quota') {
|
|
274
|
+
handleQuota(workspaceId, agentSessionId);
|
|
275
|
+
}
|
|
276
|
+
if (ev.kind === 'session:ended') {
|
|
277
|
+
onSessionEnded(workspaceId, agentSessionId, ev.exitCode);
|
|
278
|
+
}
|
|
279
|
+
if (ev.kind === 'session:started' && ev.engineSessionId) {
|
|
280
|
+
sessionIds.set(workspaceId, ev.engineSessionId);
|
|
281
|
+
try {
|
|
282
|
+
const db = getDb();
|
|
283
|
+
db.prepare('UPDATE agent_sessions SET engine_session_id = ? WHERE id = ?').run(ev.engineSessionId, agentSessionId);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
console.error('[orchestrator] Failed to persist engine session id:', err);
|
|
287
|
+
}
|
|
288
|
+
// The workspace must be in an active status while the agent is
|
|
289
|
+
// running — otherwise the frontend's `sessionActive` check stays
|
|
290
|
+
// false and streaming messages render without the "typing" spinner.
|
|
291
|
+
// Transition from a terminal state (completed/idle/error/quota) to
|
|
292
|
+
// executing so the UI reflects that a new turn is happening.
|
|
293
|
+
try {
|
|
294
|
+
const ws = getWs(workspaceId);
|
|
295
|
+
if (ws && (ws.status === 'completed' || ws.status === 'idle' || ws.status === 'error' || ws.status === 'quota')) {
|
|
296
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
// Transition may be invalid for some edge states — best-effort.
|
|
301
|
+
console.warn('[orchestrator] Could not transition workspace to executing on session:started:', err);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
306
|
+
const ctrl = controllers.get(workspaceId);
|
|
307
|
+
const wasStopping = ctrl?.status === 'stopping';
|
|
308
|
+
// Identity-preserving cleanup: only remove the controller if the map still
|
|
309
|
+
// points to this exact instance (a new controller may have been started in
|
|
310
|
+
// the meantime via stop-then-start).
|
|
311
|
+
if (ctrl && controllers.get(workspaceId) === ctrl) {
|
|
312
|
+
controllers.delete(workspaceId);
|
|
313
|
+
}
|
|
314
|
+
unregisterProcess(workspaceId);
|
|
315
|
+
retryCounts.delete(workspaceId);
|
|
316
|
+
// Update the agent_sessions row
|
|
317
|
+
try {
|
|
318
|
+
const db = getDb();
|
|
319
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run(exitCode === 0 ? 'completed' : 'error', new Date().toISOString(), agentSessionId);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
|
|
323
|
+
}
|
|
324
|
+
if (wasStopping) {
|
|
325
|
+
// session:ended with reason='killed' already emitted by the engine covers
|
|
326
|
+
// the "stopped" status. No legacy emit needed.
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Clear any pending backoff timer on non-stopping exits
|
|
330
|
+
const pendingBackoff = backoffTimers.get(workspaceId);
|
|
331
|
+
if (pendingBackoff) {
|
|
332
|
+
clearTimeout(pendingBackoff);
|
|
333
|
+
backoffTimers.delete(workspaceId);
|
|
334
|
+
}
|
|
335
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
336
|
+
try {
|
|
337
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
console.error('[orchestrator] Failed to update workspace status on exit:', err);
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
markWorkspaceUnread(workspaceId);
|
|
344
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// best-effort
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
try {
|
|
352
|
+
updateWorkspaceStatus(workspaceId, 'completed');
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
console.error('[orchestrator] Failed to update workspace status on exit:', err);
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
markWorkspaceUnread(workspaceId);
|
|
359
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// best-effort
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
367
|
+
/**
|
|
368
|
+
* Spawn an agent (via the resolved engine) for a workspace. Returns
|
|
369
|
+
* synchronously with the DB agent session id. The PID becomes available only
|
|
370
|
+
* after `engine.start` resolves — callers should subscribe to WS events or
|
|
371
|
+
* query the controller via `_getControllers()` for tests.
|
|
372
|
+
*/
|
|
373
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept', existingSessionId, reasoningEffort) {
|
|
374
|
+
if (controllers.has(workspaceId)) {
|
|
375
|
+
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
376
|
+
}
|
|
377
|
+
const ws = getWs(workspaceId);
|
|
378
|
+
const engineId = readWorkspaceEngineId(workspaceId);
|
|
379
|
+
const engine = resolveEngine(engineId);
|
|
380
|
+
let agentSessionId;
|
|
381
|
+
let resumeFromEngineSessionId;
|
|
382
|
+
// Note: plan-mode prompt prefixing is an engine-specific concern handled by
|
|
383
|
+
// the Claude Code engine's args-builder. Do NOT prepend it here — that would
|
|
384
|
+
// double-prepend the marker when the engine applies its own prefix.
|
|
385
|
+
if (resume) {
|
|
386
|
+
const r = resolveSessionForResume(workspaceId, existingSessionId);
|
|
387
|
+
agentSessionId = r.agentSessionId;
|
|
388
|
+
resumeFromEngineSessionId = r.engineSessionId;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
agentSessionId = reuseOrCreateFreshSession(workspaceId, existingSessionId);
|
|
392
|
+
}
|
|
393
|
+
const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
|
|
394
|
+
const options = {
|
|
395
|
+
workspaceId,
|
|
396
|
+
workingDir,
|
|
397
|
+
prompt,
|
|
398
|
+
model,
|
|
399
|
+
effort: reasoningEffort,
|
|
400
|
+
permissionMode,
|
|
401
|
+
resumeFromEngineSessionId,
|
|
402
|
+
backendUrl: `http://127.0.0.1:${backendPort}`,
|
|
403
|
+
koboHome: (() => {
|
|
404
|
+
try {
|
|
405
|
+
return getKoboHome();
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return '';
|
|
409
|
+
}
|
|
410
|
+
})(),
|
|
411
|
+
settings,
|
|
412
|
+
mcpServers: buildMcpServers(workspaceId),
|
|
413
|
+
};
|
|
414
|
+
const controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, ev));
|
|
415
|
+
controllers.set(workspaceId, controller);
|
|
416
|
+
// "Agent running" is signalled via the engine's session:started event.
|
|
417
|
+
// The legacy `agent:status { status: 'executing' }` emit is gone.
|
|
418
|
+
// Kick off engine.start asynchronously. Errors surface as error events.
|
|
419
|
+
void controller
|
|
420
|
+
.start(options)
|
|
421
|
+
.then(() => {
|
|
422
|
+
const pid = controller.pid;
|
|
423
|
+
if (pid !== undefined) {
|
|
424
|
+
try {
|
|
425
|
+
const db = getDb();
|
|
426
|
+
db.prepare('UPDATE agent_sessions SET pid = ? WHERE id = ?').run(pid, agentSessionId);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
console.error('[orchestrator] Failed to update pid:', err);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
.catch((err) => {
|
|
434
|
+
console.error('[orchestrator] engine.start failed:', err);
|
|
435
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
436
|
+
handleEvent(workspaceId, agentSessionId, {
|
|
437
|
+
kind: 'error',
|
|
438
|
+
category: 'spawn_failed',
|
|
439
|
+
message,
|
|
440
|
+
});
|
|
441
|
+
handleEvent(workspaceId, agentSessionId, {
|
|
442
|
+
kind: 'session:ended',
|
|
443
|
+
reason: 'error',
|
|
444
|
+
exitCode: null,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
return { agentSessionId, pid: undefined };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Soft-interrupt the running agent by sending SIGINT. The session remains
|
|
451
|
+
* alive — the current tool call is aborted and the agent waits for the next
|
|
452
|
+
* user message.
|
|
453
|
+
*/
|
|
454
|
+
export function interruptAgent(workspaceId) {
|
|
455
|
+
const ctrl = controllers.get(workspaceId);
|
|
456
|
+
if (!ctrl) {
|
|
457
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
ctrl.interrupt();
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
464
|
+
throw new Error(`Failed to interrupt agent for workspace '${workspaceId}': ${message}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
|
|
468
|
+
export function stopAgent(workspaceId) {
|
|
469
|
+
const ctrl = controllers.get(workspaceId);
|
|
470
|
+
if (!ctrl) {
|
|
471
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
472
|
+
}
|
|
473
|
+
// Remove from the map immediately so startAgent can proceed right away.
|
|
474
|
+
// The session:ended handler checks identity before removing, so a new
|
|
475
|
+
// controller started in the meantime is preserved.
|
|
476
|
+
controllers.delete(workspaceId);
|
|
477
|
+
const timer = backoffTimers.get(workspaceId);
|
|
478
|
+
if (timer) {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
backoffTimers.delete(workspaceId);
|
|
481
|
+
}
|
|
482
|
+
// Fire-and-forget: controller.stop is async but we don't block callers.
|
|
483
|
+
void ctrl.stop().catch((err) => {
|
|
484
|
+
console.error('[orchestrator] controller.stop failed:', err);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
/** Write a user message to the running agent. */
|
|
488
|
+
export function sendMessage(workspaceId, content) {
|
|
489
|
+
const ctrl = controllers.get(workspaceId);
|
|
490
|
+
if (!ctrl) {
|
|
491
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
492
|
+
}
|
|
493
|
+
ctrl.sendMessage(content);
|
|
494
|
+
}
|
|
495
|
+
/** In-memory status of the agent for a workspace, or null if not running. */
|
|
496
|
+
export function getAgentStatus(workspaceId) {
|
|
497
|
+
return controllers.get(workspaceId)?.status ?? null;
|
|
498
|
+
}
|
|
499
|
+
/** Number of currently running controllers. */
|
|
500
|
+
export function getRunningCount() {
|
|
501
|
+
return controllers.size;
|
|
502
|
+
}
|
|
503
|
+
/** Kobo built-in slash commands injected into the skill list (without leading /). */
|
|
504
|
+
const KOBO_COMMANDS = ['kobo-check-progress'];
|
|
505
|
+
/** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
|
|
506
|
+
export function getAvailableSkills() {
|
|
507
|
+
return [...KOBO_COMMANDS, ...availableSkills];
|
|
508
|
+
}
|
|
509
|
+
// ── Quota handling ────────────────────────────────────────────────────────────
|
|
510
|
+
function handleQuota(workspaceId, _agentSessionId) {
|
|
511
|
+
try {
|
|
512
|
+
updateWorkspaceStatus(workspaceId, 'quota');
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// May fail if transition is not valid
|
|
516
|
+
}
|
|
517
|
+
// The quota state is already signalled by the `error { category: 'quota' }`
|
|
518
|
+
// AgentEvent that triggered this handler. No legacy `agent:status { quota }`
|
|
519
|
+
// emit needed.
|
|
520
|
+
// 15min first, then 30min, then 60min cap
|
|
521
|
+
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
522
|
+
const backoffMinutes = Math.min(15 * 2 ** retryCount, 60);
|
|
523
|
+
const backoffMs = backoffMinutes * 60 * 1000;
|
|
524
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
525
|
+
// Surface the backoff schedule as an ephemeral event so the UI can display
|
|
526
|
+
// retry count / wait time without polluting the persistent event log.
|
|
527
|
+
emitEphemeral(workspaceId, 'agent:quota-backoff', {
|
|
528
|
+
retryCount: retryCount + 1,
|
|
529
|
+
backoffMinutes,
|
|
530
|
+
});
|
|
531
|
+
const timer = setTimeout(() => {
|
|
532
|
+
backoffTimers.delete(workspaceId);
|
|
533
|
+
if (!controllers.has(workspaceId)) {
|
|
534
|
+
const freshWs = getWs(workspaceId);
|
|
535
|
+
if (!freshWs || freshWs.archivedAt !== null || freshWs.status !== 'quota') {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
|
|
540
|
+
startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
|
|
544
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
545
|
+
try {
|
|
546
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// transition may not be valid
|
|
550
|
+
}
|
|
551
|
+
routeEvent(workspaceId, '', {
|
|
552
|
+
kind: 'error',
|
|
553
|
+
category: 'other',
|
|
554
|
+
message: `Quota retry failed: ${msg}`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}, backoffMs);
|
|
559
|
+
timer.unref?.();
|
|
560
|
+
backoffTimers.set(workspaceId, timer);
|
|
561
|
+
}
|
|
562
|
+
// ── Testing utilities ─────────────────────────────────────────────────────────
|
|
563
|
+
/** @internal test-only */
|
|
564
|
+
export function _getControllers() {
|
|
565
|
+
return controllers;
|
|
566
|
+
}
|
|
567
|
+
/** @internal test-only */
|
|
568
|
+
export function _getRetryCounts() {
|
|
569
|
+
return retryCounts;
|
|
570
|
+
}
|
|
571
|
+
/** @internal test-only */
|
|
572
|
+
export function _getBackoffTimers() {
|
|
573
|
+
return backoffTimers;
|
|
574
|
+
}
|
|
575
|
+
/** @internal test-only */
|
|
576
|
+
export function _getSessionIds() {
|
|
577
|
+
return sessionIds;
|
|
578
|
+
}
|
|
579
|
+
/** @internal test-only — runs a single watchdog sweep synchronously. */
|
|
580
|
+
export function _runWatchdogForTest() {
|
|
581
|
+
runWatchdog();
|
|
582
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { getWorkspace, listTasks } from '../workspace-service.js';
|
|
2
|
+
export class SessionController {
|
|
3
|
+
workspaceId;
|
|
4
|
+
agentSessionId;
|
|
5
|
+
engine;
|
|
6
|
+
onEvent;
|
|
7
|
+
engineProcess;
|
|
8
|
+
_status = 'running';
|
|
9
|
+
constructor(workspaceId, agentSessionId, engine, onEvent) {
|
|
10
|
+
this.workspaceId = workspaceId;
|
|
11
|
+
this.agentSessionId = agentSessionId;
|
|
12
|
+
this.engine = engine;
|
|
13
|
+
this.onEvent = onEvent;
|
|
14
|
+
}
|
|
15
|
+
async start(options) {
|
|
16
|
+
if (this.engineProcess)
|
|
17
|
+
throw new Error('SessionController already started');
|
|
18
|
+
this.engineProcess = await this.engine.start(options, (ev) => this.handle(ev));
|
|
19
|
+
this._status = 'running';
|
|
20
|
+
}
|
|
21
|
+
sendMessage(content) {
|
|
22
|
+
if (!this.engineProcess)
|
|
23
|
+
throw new Error('SessionController not started');
|
|
24
|
+
this.engineProcess.sendMessage(content);
|
|
25
|
+
}
|
|
26
|
+
interrupt() {
|
|
27
|
+
if (!this.engineProcess)
|
|
28
|
+
throw new Error('SessionController not started');
|
|
29
|
+
this.engineProcess.interrupt();
|
|
30
|
+
}
|
|
31
|
+
async stop() {
|
|
32
|
+
this._status = 'stopping';
|
|
33
|
+
if (this.engineProcess)
|
|
34
|
+
await this.engineProcess.stop();
|
|
35
|
+
}
|
|
36
|
+
get status() {
|
|
37
|
+
return this._status;
|
|
38
|
+
}
|
|
39
|
+
get pid() {
|
|
40
|
+
return this.engineProcess?.pid;
|
|
41
|
+
}
|
|
42
|
+
get engineSessionId() {
|
|
43
|
+
return this.engineProcess?.engineSessionId;
|
|
44
|
+
}
|
|
45
|
+
handle(ev) {
|
|
46
|
+
if (ev.kind === 'session:compacted') {
|
|
47
|
+
try {
|
|
48
|
+
this.injectPostCompactReminder();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error('[session-controller] post-compact reminder failed:', err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
this.onEvent(ev);
|
|
55
|
+
}
|
|
56
|
+
injectPostCompactReminder() {
|
|
57
|
+
if (!this.engineProcess)
|
|
58
|
+
return;
|
|
59
|
+
const ws = getWorkspace(this.workspaceId);
|
|
60
|
+
const tasks = listTasks(this.workspaceId);
|
|
61
|
+
const criteria = tasks.filter((t) => t.isAcceptanceCriterion);
|
|
62
|
+
const todos = tasks.filter((t) => !t.isAcceptanceCriterion);
|
|
63
|
+
if (criteria.length === 0 && todos.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
let reminder = `\n--- Context reminder after compaction ---\n`;
|
|
66
|
+
reminder += `Task: ${ws?.name ?? this.workspaceId}\n`;
|
|
67
|
+
if (todos.length > 0) {
|
|
68
|
+
reminder += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
|
|
69
|
+
}
|
|
70
|
+
if (criteria.length > 0) {
|
|
71
|
+
reminder += `\nAcceptance criteria:\n${criteria
|
|
72
|
+
.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`)
|
|
73
|
+
.join('\n')}\n`;
|
|
74
|
+
reminder += `\nWhen you complete a criterion, tell me which one so I can mark it as done.\n`;
|
|
75
|
+
}
|
|
76
|
+
reminder += `--- End of reminder ---\n`;
|
|
77
|
+
this.engineProcess.sendMessage(reminder);
|
|
78
|
+
}
|
|
79
|
+
}
|