@loicngr/kobo 1.6.15 → 1.7.1
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/README.md +13 -7
- package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
- package/dist/mcp-server/kobo-tasks-server.js +51 -0
- package/dist/server/db/migrations.js +40 -0
- package/dist/server/db/schema.js +7 -5
- package/dist/server/index.js +12 -11
- package/dist/server/routes/health.js +2 -2
- package/dist/server/routes/settings.js +2 -1
- package/dist/server/routes/workspaces.js +165 -32
- package/dist/server/services/agent/engines/claude-code/capabilities.js +1 -1
- package/dist/server/services/agent/engines/claude-code/engine.js +237 -132
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +234 -0
- package/dist/server/services/agent/engines/claude-code/options-builder.js +68 -0
- package/dist/server/services/agent/engines/claude-code/precompact-hook.js +27 -0
- package/dist/server/services/agent/engines/types.js +1 -0
- package/dist/server/services/agent/orchestrator.js +536 -94
- package/dist/server/services/agent/session-controller.js +14 -43
- package/dist/server/services/auto-loop-service.js +19 -8
- package/dist/server/services/content-migration-service.js +24 -94
- package/dist/server/services/settings-service.js +35 -1
- package/dist/server/services/wakeup-service.js +10 -11
- package/dist/server/services/workspace-service.js +42 -37
- package/dist/server/services/worktree-service.js +17 -7
- package/dist/server/utils/worktree-paths.js +134 -0
- package/dist/shared/consts.js +1 -0
- package/package.json +2 -1
- package/src/client/dist/spa/assets/ActivityFeed-Chn8aZvi.js +7 -0
- package/src/client/dist/spa/assets/ActivityFeed-LXnbg3ff.css +1 -0
- package/src/client/dist/spa/assets/{ClosePopup-D7BBEcaf.js → ClosePopup-BUlGXTqh.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-BE3xfQsC.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-BGtqoZ8d.js +2 -0
- package/src/client/dist/spa/assets/{DiffViewer-BJZADilo.js → DiffViewer-qjJ-biOw.js} +3 -3
- package/src/client/dist/spa/assets/HealthPage-CKyf7ky6.js +1 -0
- package/src/client/dist/spa/assets/MainLayout-B07zv82Z.css +1 -0
- package/src/client/dist/spa/assets/{MainLayout-CFHf3zKv.js → MainLayout-Br3jmaOw.js} +17 -17
- package/src/client/dist/spa/assets/QCheckbox-CcY7ZSk9.js +1 -0
- package/src/client/dist/spa/assets/{QChip-D905z6BM.js → QChip-BhT0W2Dg.js} +1 -1
- package/src/client/dist/spa/assets/QExpansionItem-BnIPCzXR.js +1 -0
- package/src/client/dist/spa/assets/{QInput-6U0_avSY.js → QInput-D4WJro4e.js} +1 -1
- package/src/client/dist/spa/assets/{QItemSection-Cloi4ErY.js → QItemSection-KFAnxzMK.js} +1 -1
- package/src/client/dist/spa/assets/QMenu-0LsqhRZT.js +1 -0
- package/src/client/dist/spa/assets/{QPage-C6h_ah5z.js → QPage-Cu7zkfc6.js} +1 -1
- package/src/client/dist/spa/assets/QRadio-DaZhdLCg.js +1 -0
- package/src/client/dist/spa/assets/{touch-DBLw8vQK.js → QResizeObserver-Cf79V-VZ.js} +1 -1
- package/src/client/dist/spa/assets/QScrollArea-BDCKOKuE.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-Ctnrqvp9.js +1 -0
- package/src/client/dist/spa/assets/QToggle-CGpiJLDJ.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-B3CmRx4j.js +1 -0
- package/src/client/dist/spa/assets/SearchPage-Ce8Uc7Ol.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-8N0X7B7o.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BaaSJ3eJ.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-DQxGe62K.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-wZUUTDzp.js +4 -0
- package/src/client/dist/spa/assets/build-path-tree-DRViYT3t.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-QQTtBrD_.js → cssMode-uAfRqG2Q.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-YqpktRoe.js → editor.api-5GUlxvcL.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-DDGqfxYm.js → editor.main-CSTJjBIa.js} +3 -3
- package/src/client/dist/spa/assets/expand-template-zA3pTyIP.js +1 -0
- package/src/client/dist/spa/assets/{formatters-DWeOzSfw.js → formatters-ejxELb0M.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-BC_Lt7t3.js → freemarker2-BxBnI8Nb.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BphhRg2c.js → handlebars-DrbIsXmT.js} +1 -1
- package/src/client/dist/spa/assets/{html-C84Ufc1n.js → html-DH7u_g5l.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-CIlyKZJ4.js → htmlMode-BlY9QO3f.js} +1 -1
- package/src/client/dist/spa/assets/i18n-B41j--A3.js +1 -0
- package/src/client/dist/spa/assets/index-DoYBJtQA.js +2 -0
- package/src/client/dist/spa/assets/{javascript-D5LTZTWn.js → javascript-B-AL31ke.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-YBOBMJNl.js → jsonMode-Dx7CA4ag.js} +1 -1
- package/src/client/dist/spa/assets/{kobo-commands-DFflpxts.js → kobo-commands-DiUm1Y34.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-UNCP2Jl6.js → liquid--H7Vomnm.js} +1 -1
- package/src/client/dist/spa/assets/{marked.esm-D7ibHC_y.js → marked.esm-DLCrAGtO.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-CsHyBm_B.js → mdx-BOackeU6.js} +1 -1
- package/src/client/dist/spa/assets/{models-tXWASlTL.js → models-BPfFBcxr.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Bv79M2zD.js → monaco.contribution-ydrMjZwK.js} +2 -2
- package/src/client/dist/spa/assets/{python-B3h-WTW0.js → python-BWGSV-nk.js} +1 -1
- package/src/client/dist/spa/assets/{razor-Cs79ULMl.js → razor-BGnl83cS.js} +1 -1
- package/src/client/dist/spa/assets/settings-lT4GB-uB.js +1 -0
- package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +1 -0
- package/src/client/dist/spa/assets/touch-Co9pfjUU.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-238NR35q.js → tsMode-Chjqq1f3.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-C93UakWa.js → typescript-By7Y7PAP.js} +1 -1
- package/src/client/dist/spa/assets/{use-checkbox-w-raiu10.js → use-checkbox-DzHmcu7s.js} +1 -1
- package/src/client/dist/spa/assets/use-panel-DWX2aNMM.js +1 -0
- package/src/client/dist/spa/assets/{xml-24CcVrVJ.js → xml-DoAeCRiy.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BLhB8_OL.js → yaml-DlT7YOhG.js} +1 -1
- package/src/client/dist/spa/index.html +10 -7
- package/src/mcp-server/kobo-tasks-handlers.ts +2 -1
- package/src/mcp-server/kobo-tasks-server.ts +60 -1
- package/dist/server/services/agent/engines/claude-code/args-builder.js +0 -57
- package/dist/server/services/agent/engines/claude-code/mcp-config.js +0 -23
- package/dist/server/services/agent/engines/claude-code/stream-parser.js +0 -386
- package/src/client/dist/spa/assets/ActivityFeed-BHdMJRwS.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-D7MF6IK1.js +0 -8
- package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-rp-9_jOF.js +0 -2
- package/src/client/dist/spa/assets/HealthPage-CZQB2pvh.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-Db3dwSTM.css +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-CUXuOfeR.js +0 -1
- package/src/client/dist/spa/assets/QMenu-BPzgTm2k.js +0 -1
- package/src/client/dist/spa/assets/QScrollArea-N10UpHIf.js +0 -1
- package/src/client/dist/spa/assets/QSlideTransition-BMX92yUu.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-PPompnxw.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DLT8jCHz.js +0 -1
- package/src/client/dist/spa/assets/SearchPage-CfYy4vGJ.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-ONWYC-Bn.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-B2VAbf6l.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +0 -1
- package/src/client/dist/spa/assets/build-path-tree-DETFP2lL.js +0 -1
- package/src/client/dist/spa/assets/expand-template-CZkefibF.js +0 -1
- package/src/client/dist/spa/assets/i18n-CNdSgNP6.js +0 -1
- package/src/client/dist/spa/assets/index-pGAaG7Rh.js +0 -2
- package/src/client/dist/spa/assets/settings-Cw4mtk9x.js +0 -1
- package/src/client/dist/spa/assets/stats-BrLStQKj.js +0 -1
- package/src/client/dist/spa/assets/symbols-TAFELniU.js +0 -1
- /package/src/client/dist/spa/assets/{QBadge-BUkmTO0P.js → QBadge-fsQ2AokU.js} +0 -0
- /package/src/client/dist/spa/assets/{QBtn-CyzfM9-_.js → QBtn-DHwAb18J.js} +0 -0
- /package/src/client/dist/spa/assets/{QItemLabel-DwnV_S8y.js → QItemLabel-DWwenW2S.js} +0 -0
- /package/src/client/dist/spa/assets/{QList-DZfpUv3n.js → QList-NmIE6Rd9.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpace-PlDK6Fg3.js → QSpace-COlmM_4F.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpinnerDots-D7bo_KgI.js → QSpinnerDots-DwtnRN2r.js} +0 -0
- /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-CpNzZuug.js → _plugin-vue_export-helper-B8bB5DBd.js} +0 -0
- /package/src/client/dist/spa/assets/{abap-DrZwwXZX.js → abap-DzK-OTGh.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-CrCz0btt.js → apex-Bj60_dRt.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-BapzKHay.js → azcli-B6NwaBAZ.js} +0 -0
- /package/src/client/dist/spa/assets/{bat-C_NRAiA1.js → bat-bf7wXV68.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-C7pp2CNk.js → bicep-C_bg8UgA.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-BhhK9vxZ.js → cameligo-CTWw4D4B.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-D0ujmUyE.js → clojure-CgdPoH0r.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-DHEl7Jbb.js → coffee-gHQfdA5M.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-Iil-3nzZ.js → cpp-BM4Jj4aW.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-Dh0Ee7SY.js → csharp-D8-bh4Cd.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-mwzjw0JL.js → csp-CXBxRx0n.js} +0 -0
- /package/src/client/dist/spa/assets/{css-COIa8ZTR.js → css-DKjIxrmY.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-GVc17FC4.js → cypher-C5e5inIh.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-phiCaE7_.js → dart-BhRHHm4x.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-BMaDhdim.js → dockerfile-DW5REF8E.js} +0 -0
- /package/src/client/dist/spa/assets/{documents-BMdAS6h8.js → documents-D6A3wRry.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-Cj47kvqp.js → ecl-Bw4Hg3n_.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-DBbstcE1.js → elixir-DHmoBvpZ.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-ChHb1adO.js → flow9-BsFExz3v.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-CDI_AxQw.js → fsharp-BaeLhgfq.js} +0 -0
- /package/src/client/dist/spa/assets/{go-DmsC2k-Y.js → go-Bd-NFKIC.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-C8hjT6Ki.js → graphql-DZVerJfy.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-C15cAQOZ.js → hcl-CAVzrZfH.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-CKrAe0ag.js → ini-CyXdX58t.js} +0 -0
- /package/src/client/dist/spa/assets/{java-BVhjILyl.js → java-B5pNgvhy.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-BzPDHDOG.js → julia-XRhmV3AN.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-DQMAn-b6.js → kotlin-DOd3J5vr.js} +0 -0
- /package/src/client/dist/spa/assets/{less-428mfr1h.js → less-veZSnyw6.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-B09dCO6A.js → lexon-QWGkuK0H.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-CVQ0BJif.js → lua-CYGpjuO5.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-CiPQ1ljw.js → m3-yNnrZkdc.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown--G0dqL-7.js → markdown-BCSWEPSX.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-BaboCM3T.js → mips-OpYmcC30.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-DUaqkqre.js → msdax-2oxoTO9Z.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-CUE6XF4r.js → mysql-5KlC-K_9.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-C4MUnzeT.js → objective-c-CcDCgtLx.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-CWMUMx__.js → pascal-BZGsbaEV.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-DLCVutek.js → pascaligo-DtD5qU3G.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-JYoirQpx.js → perl-C1jNNS3E.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-BqOy7sqx.js → pgsql-CT0fhiZa.js} +0 -0
- /package/src/client/dist/spa/assets/{php-PZqsysO1.js → php-D6DrXoPM.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-BiwqVlg6.js → pla-b3-HN2pF.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-COxQtXCD.js → postiats-Bin2ApVS.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-DdXUmaWa.js → powerquery-7ASnn-ZG.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-D05yu9sz.js → powershell-t4p7sU1H.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-BDsm0ZB_.js → protobuf-BUGeWa_j.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-3CmTiGoi.js → pug-BuKcgC9s.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-C4eHfCpJ.js → qsharp-DSMtI_O7.js} +0 -0
- /package/src/client/dist/spa/assets/{r-Decg_RIU.js → r-DMlFgn7A.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-Cl3EBA4R.js → redis-cXItkC5u.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-5ZsNLhOp.js → redshift-BZVbW7HE.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-BulNNF_e.js → restructuredtext-BzjxwS8h.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-D3Axi_9w.js → ruby-C5nyLV4l.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-Csys1Tos.js → rust-BcmMsHdf.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-C_iBPphi.js → sb-Dnb1iy6B.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-Cg4p-EZ2.js → scala-anMIFYpA.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-BlVnEL_j.js → scheme-BItQTe08.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-CmLW8ojr.js → scss-BOv51BJ5.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-B1DV_gpl.js → shell-BsRYRTNN.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-glFpNhe3.js → solidity-BtuLgGDx.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-D9j4cFkA.js → sophia-B0Vkc5MF.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-DV5Ux9cO.js → sparql-B7lvkZQM.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-K8tNKFcf.js → sql-DvP5MpA3.js} +0 -0
- /package/src/client/dist/spa/assets/{st-BhIdE2hj.js → st-GVUeyB3U.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-B0pzSmmx.js → swift-DSPIoCjm.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-CeBgixbN.js → systemverilog-Icj2-k23.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-B0Ji3IbZ.js → tcl-Cd8KQcm-.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-KUgPCP41.js → twig-CBHmt8z3.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-ryrhjid6.js → typespec-Ckc037mq.js} +0 -0
- /package/src/client/dist/spa/assets/{use-quasar-Clv5nVxk.js → use-quasar-Cc4smfg5.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-Z68-YtMY.js → vb-B97GW9Wb.js} +0 -0
- /package/src/client/dist/spa/assets/{vue-i18n-BVrBmgZa.js → vue-i18n-eUDnMrPl.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-bH-W-d_T.js → wgsl-DIKmb3YH.js} +0 -0
|
@@ -6,8 +6,8 @@ import { unregisterProcess } from '../../utils/process-tracker.js';
|
|
|
6
6
|
import * as autoLoopService from '../auto-loop-service.js';
|
|
7
7
|
import { getEffectiveSettings } from '../settings-service.js';
|
|
8
8
|
import * as wakeupService from '../wakeup-service.js';
|
|
9
|
-
import { emitEphemeral } from '../websocket-service.js';
|
|
10
|
-
import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus } from '../workspace-service.js';
|
|
9
|
+
import { emit, emitEphemeral } from '../websocket-service.js';
|
|
10
|
+
import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } from '../workspace-service.js';
|
|
11
11
|
import { resolveEngine } from './engines/registry.js';
|
|
12
12
|
import { routeEvent } from './event-router.js';
|
|
13
13
|
import { SessionController } from './session-controller.js';
|
|
@@ -22,6 +22,107 @@ export function setBackendPort(port) {
|
|
|
22
22
|
const controllers = new Map();
|
|
23
23
|
/** workspaceId -> last engine session ID (for resume) */
|
|
24
24
|
const sessionIds = new Map();
|
|
25
|
+
/** workspaceId -> FIFO queue of pending items */
|
|
26
|
+
const pendingQueue = new Map();
|
|
27
|
+
/**
|
|
28
|
+
* workspaceId -> the workspace status held BEFORE we transitioned to
|
|
29
|
+
* `awaiting-user` because the SDK is awaiting an answer via canUseTool.
|
|
30
|
+
* Restored when the user answers so an agent paused mid-`brainstorming`
|
|
31
|
+
* returns to that status instead of being yanked to `executing`.
|
|
32
|
+
*/
|
|
33
|
+
const preAwaitStatus = new Map();
|
|
34
|
+
function enqueuePending(workspaceId, item) {
|
|
35
|
+
const arr = pendingQueue.get(workspaceId) ?? [];
|
|
36
|
+
arr.push(item);
|
|
37
|
+
pendingQueue.set(workspaceId, arr);
|
|
38
|
+
}
|
|
39
|
+
function peekPending(workspaceId) {
|
|
40
|
+
return pendingQueue.get(workspaceId)?.[0];
|
|
41
|
+
}
|
|
42
|
+
function dequeuePending(workspaceId) {
|
|
43
|
+
const arr = pendingQueue.get(workspaceId);
|
|
44
|
+
if (!arr || arr.length === 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const head = arr.shift();
|
|
47
|
+
if (arr.length === 0)
|
|
48
|
+
pendingQueue.delete(workspaceId);
|
|
49
|
+
return head;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Remove persisted `session:user-input-requested` events for a given
|
|
53
|
+
* toolCallId from `ws_events`, so a future F5 / WS reconnect doesn't
|
|
54
|
+
* resurrect a question the user has already answered or cancelled.
|
|
55
|
+
*/
|
|
56
|
+
function purgePersistedUserInputRequest(workspaceId, toolCallId) {
|
|
57
|
+
try {
|
|
58
|
+
const db = getDb();
|
|
59
|
+
db.prepare(`DELETE FROM ws_events
|
|
60
|
+
WHERE workspace_id = ?
|
|
61
|
+
AND type = 'agent:event'
|
|
62
|
+
AND json_extract(payload, '$.kind') = 'session:user-input-requested'
|
|
63
|
+
AND json_extract(payload, '$.toolCallId') = ?`).run(workspaceId, toolCallId);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
console.error('[orchestrator] Failed to purge persisted user-input-requested:', err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Remove every persisted `session:user-input-requested` event tied to a
|
|
71
|
+
* specific session — used when the session is killed (stopAgent / archive /
|
|
72
|
+
* delete) so a future F5 doesn't resurrect panels that no longer have a live
|
|
73
|
+
* canUseTool callback to resolve.
|
|
74
|
+
*/
|
|
75
|
+
function purgeAllPersistedUserInputRequests(workspaceId, agentSessionId) {
|
|
76
|
+
try {
|
|
77
|
+
const db = getDb();
|
|
78
|
+
db.prepare(`DELETE FROM ws_events
|
|
79
|
+
WHERE workspace_id = ?
|
|
80
|
+
AND session_id = ?
|
|
81
|
+
AND type = 'agent:event'
|
|
82
|
+
AND json_extract(payload, '$.kind') = 'session:user-input-requested'`).run(workspaceId, agentSessionId);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
console.error('[orchestrator] Failed to purge persisted user-input-requested (session-wide):', err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Snapshot the workspace's current status so that on resolve we can restore
|
|
90
|
+
* it. Idempotent: when called while already in `awaiting-user` we keep the
|
|
91
|
+
* FIRST pre-await status (defensive against multiple requests before reply).
|
|
92
|
+
*/
|
|
93
|
+
function rememberPreAwaitStatus(workspaceId) {
|
|
94
|
+
if (preAwaitStatus.has(workspaceId))
|
|
95
|
+
return;
|
|
96
|
+
const ws = getWs(workspaceId);
|
|
97
|
+
if (!ws)
|
|
98
|
+
return;
|
|
99
|
+
if (ws.status === 'awaiting-user')
|
|
100
|
+
return;
|
|
101
|
+
preAwaitStatus.set(workspaceId, ws.status);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Pop the snapshotted status from the pre-await map. Returns `'executing'`
|
|
105
|
+
* if no snapshot exists — that's the safe default for a session that started
|
|
106
|
+
* in `executing` and asked the user immediately.
|
|
107
|
+
*/
|
|
108
|
+
function consumePreAwaitStatus(workspaceId) {
|
|
109
|
+
const remembered = preAwaitStatus.get(workspaceId);
|
|
110
|
+
preAwaitStatus.delete(workspaceId);
|
|
111
|
+
return remembered ?? 'executing';
|
|
112
|
+
}
|
|
113
|
+
function clearPendingForSession(workspaceId, agentSessionId) {
|
|
114
|
+
const arr = pendingQueue.get(workspaceId);
|
|
115
|
+
if (arr) {
|
|
116
|
+
const filtered = arr.filter((item) => item.agentSessionId !== agentSessionId);
|
|
117
|
+
if (filtered.length === 0)
|
|
118
|
+
pendingQueue.delete(workspaceId);
|
|
119
|
+
else
|
|
120
|
+
pendingQueue.set(workspaceId, filtered);
|
|
121
|
+
}
|
|
122
|
+
if (!pendingQueue.has(workspaceId)) {
|
|
123
|
+
preAwaitStatus.delete(workspaceId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
25
126
|
/** Cached list of available slash commands — persisted to <KOBO_HOME>/skills.json */
|
|
26
127
|
let availableSkills = (() => {
|
|
27
128
|
try {
|
|
@@ -53,7 +154,12 @@ function isProcessAlive(pid) {
|
|
|
53
154
|
function runWatchdog() {
|
|
54
155
|
for (const [workspaceId, ctrl] of controllers) {
|
|
55
156
|
const pid = ctrl.pid;
|
|
56
|
-
|
|
157
|
+
// SDK-backed engines have no pid — query the engine's optional `isAlive`
|
|
158
|
+
// probe instead so the watchdog isn't blind on those engines. Legacy
|
|
159
|
+
// engines without `isAlive` fall back to the pid-based check.
|
|
160
|
+
const ep = ctrl.engineProcess;
|
|
161
|
+
const alive = ep && typeof ep.isAlive === 'function' ? ep.isAlive() : pid !== undefined && isProcessAlive(pid);
|
|
162
|
+
if (alive)
|
|
57
163
|
continue;
|
|
58
164
|
console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
|
|
59
165
|
// Emit an error + session:ended AgentEvent pair so clients can react uniformly
|
|
@@ -111,24 +217,43 @@ export function reconcileOrphanSessions() {
|
|
|
111
217
|
try {
|
|
112
218
|
const db = getDb();
|
|
113
219
|
const rows = db.prepare("SELECT id, pid FROM agent_sessions WHERE status = 'running'").all();
|
|
114
|
-
if (rows.length
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
220
|
+
if (rows.length > 0) {
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
const update = db.prepare("UPDATE agent_sessions SET status = 'error', ended_at = ? WHERE id = ?");
|
|
223
|
+
let fixed = 0;
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
226
|
+
continue; // genuine leftover from a graceful restart — skip
|
|
227
|
+
update.run(now, row.id);
|
|
228
|
+
fixed++;
|
|
229
|
+
}
|
|
230
|
+
if (fixed > 0) {
|
|
231
|
+
console.log(`[orchestrator] Reconciled ${fixed} orphan agent_sessions row(s) at boot.`);
|
|
232
|
+
}
|
|
127
233
|
}
|
|
128
234
|
}
|
|
129
235
|
catch (err) {
|
|
130
236
|
console.error('[orchestrator] Failed to reconcile orphan agent_sessions at boot:', err);
|
|
131
237
|
}
|
|
238
|
+
// Workspaces stuck in `awaiting-user` after a server kill have no live
|
|
239
|
+
// controller to resolve canUseTool, so the chat input is disabled forever.
|
|
240
|
+
// Drop them back to `idle` so the user can interact (start fresh, etc).
|
|
241
|
+
// We bypass `updateWorkspaceStatus` here because the orchestrator/workspace
|
|
242
|
+
// module pair is circular and the reference may not be initialised at boot
|
|
243
|
+
// time when this runs; a raw SQL update is safe — the awaiting-user → idle
|
|
244
|
+
// transition is allowed by VALID_TRANSITIONS.
|
|
245
|
+
try {
|
|
246
|
+
const db = getDb();
|
|
247
|
+
const result = db
|
|
248
|
+
.prepare("UPDATE workspaces SET status = 'idle', updated_at = ? WHERE status = 'awaiting-user' AND archived_at IS NULL")
|
|
249
|
+
.run(new Date().toISOString());
|
|
250
|
+
if (result.changes > 0) {
|
|
251
|
+
console.log(`[orchestrator] Reconciled ${result.changes} awaiting-user workspace(s) at boot.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
console.error('[orchestrator] Failed to reconcile awaiting-user workspaces at boot:', err);
|
|
256
|
+
}
|
|
132
257
|
}
|
|
133
258
|
/** Start the watchdog (called once from server bootstrap). */
|
|
134
259
|
export function startWatchdog() {
|
|
@@ -261,16 +386,40 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
|
|
|
261
386
|
*/
|
|
262
387
|
const tasksDoneSnapshot = new Map();
|
|
263
388
|
function getDoneTaskCount(workspaceId) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
389
|
+
try {
|
|
390
|
+
const db = getDb();
|
|
391
|
+
const row = db
|
|
392
|
+
.prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
|
|
393
|
+
.get(workspaceId, 'done');
|
|
394
|
+
return row.c;
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
// Best-effort: DB closed during async teardown, or missing schema. Fall back
|
|
398
|
+
// to 0 so auto-loop's done-delta stays correct (no progress).
|
|
399
|
+
console.warn('[orchestrator] getDoneTaskCount failed, returning 0:', err);
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
269
402
|
}
|
|
270
403
|
/** Clear the in-memory done-count snapshot for a workspace (called on delete). */
|
|
271
404
|
export function forgetTasksDoneSnapshot(workspaceId) {
|
|
272
405
|
tasksDoneSnapshot.delete(workspaceId);
|
|
273
406
|
}
|
|
407
|
+
/** Drop the resume-failed flag for a workspace (called on delete). */
|
|
408
|
+
export function forgetResumeFailed(workspaceId) {
|
|
409
|
+
resumeFailedSet.delete(workspaceId);
|
|
410
|
+
}
|
|
411
|
+
/** Drop the pending question/permission queue for a workspace (called on delete). */
|
|
412
|
+
export function forgetPendingQueue(workspaceId) {
|
|
413
|
+
pendingQueue.delete(workspaceId);
|
|
414
|
+
}
|
|
415
|
+
/** Drop the pre-await status snapshot for a workspace (called on delete). */
|
|
416
|
+
export function forgetPreAwaitStatus(workspaceId) {
|
|
417
|
+
preAwaitStatus.delete(workspaceId);
|
|
418
|
+
}
|
|
419
|
+
/** Drop the cached engine session id for a workspace (called on delete). */
|
|
420
|
+
export function forgetSessionId(workspaceId) {
|
|
421
|
+
sessionIds.delete(workspaceId);
|
|
422
|
+
}
|
|
274
423
|
function handleEvent(workspaceId, agentSessionId, ev) {
|
|
275
424
|
routeEvent(workspaceId, agentSessionId, ev);
|
|
276
425
|
if (ev.kind === 'rate_limit') {
|
|
@@ -281,13 +430,18 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
281
430
|
if (ev.kind === 'session:started') {
|
|
282
431
|
tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
|
|
283
432
|
}
|
|
433
|
+
// Legacy fallback: the built-in `ScheduleWakeup` tool (CLI tradition) isn't
|
|
434
|
+
// declared by the SDK, so we intercept the tool:call event and apply the
|
|
435
|
+
// side-effect ourselves. Agents should prefer `kobo__schedule_wakeup` —
|
|
436
|
+
// logged here so we can monitor remaining usage.
|
|
284
437
|
if (ev.kind === 'tool:call' && ev.name === 'ScheduleWakeup') {
|
|
285
438
|
const input = ev.input;
|
|
286
439
|
const delay = typeof input?.delaySeconds === 'number' ? input.delaySeconds : 0;
|
|
287
440
|
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
288
441
|
const reason = typeof input?.reason === 'string' ? input.reason : undefined;
|
|
289
442
|
if (delay > 0 && prompt) {
|
|
290
|
-
|
|
443
|
+
console.warn(`[orchestrator] Legacy ScheduleWakeup intercepted for workspace '${workspaceId}' — agent should use kobo__schedule_wakeup instead.`);
|
|
444
|
+
wakeupService.schedule(workspaceId, delay, prompt, reason, agentSessionId);
|
|
291
445
|
}
|
|
292
446
|
}
|
|
293
447
|
if (ev.kind === 'skills:discovered') {
|
|
@@ -319,27 +473,49 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
319
473
|
clearStaleEngineSessionId(workspaceId);
|
|
320
474
|
}
|
|
321
475
|
if (ev.kind === 'session:ended') {
|
|
322
|
-
// Pop the resume_failed flag before any cleanup so both onSessionEnded paths see it.
|
|
323
476
|
const isResumeFailed = resumeFailedSet.delete(workspaceId);
|
|
324
|
-
// Compute the auto-loop done-delta BEFORE the internal cleanup because
|
|
325
|
-
// onSessionEnded(internal) may throw / trigger follow-ups; also read the
|
|
326
|
-
// snapshot FIRST so a later re-entry can't overwrite it.
|
|
327
477
|
const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
|
|
328
478
|
const after = getDoneTaskCount(workspaceId);
|
|
329
479
|
const delta = Math.max(0, after - before);
|
|
330
480
|
tasksDoneSnapshot.delete(workspaceId);
|
|
331
|
-
|
|
332
|
-
// BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
|
|
333
|
-
// otherwise startAgent throws "Agent already running" because
|
|
334
|
-
// just-ended controller is still in the map.
|
|
335
|
-
onSessionEnded(workspaceId, agentSessionId, ev.exitCode, isResumeFailed);
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// auto-loop continues without disabling.
|
|
481
|
+
clearPendingForSession(workspaceId, agentSessionId);
|
|
482
|
+
// Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
|
|
483
|
+
// startAgent, otherwise startAgent throws "Agent already running" because
|
|
484
|
+
// the just-ended controller is still in the map.
|
|
485
|
+
onSessionEnded(workspaceId, agentSessionId, ev.exitCode, ev.reason, isResumeFailed);
|
|
486
|
+
// resume_failed exits with an error but the workspace is fine (stale id
|
|
487
|
+
// cleared, next iteration will start fresh) — report 'completed' to
|
|
488
|
+
// auto-loop so it continues.
|
|
340
489
|
const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
|
|
341
490
|
autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
|
|
342
491
|
}
|
|
492
|
+
if (ev.kind === 'session:user-input-requested') {
|
|
493
|
+
if (ev.requestKind === 'question') {
|
|
494
|
+
enqueuePending(workspaceId, {
|
|
495
|
+
kind: 'question',
|
|
496
|
+
agentSessionId,
|
|
497
|
+
toolCallId: ev.toolCallId,
|
|
498
|
+
toolName: ev.toolName,
|
|
499
|
+
input: ev.payload,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
enqueuePending(workspaceId, {
|
|
504
|
+
kind: 'permission',
|
|
505
|
+
agentSessionId,
|
|
506
|
+
toolCallId: ev.toolCallId,
|
|
507
|
+
toolName: ev.toolName,
|
|
508
|
+
toolInput: ev.payload,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
rememberPreAwaitStatus(workspaceId);
|
|
512
|
+
try {
|
|
513
|
+
updateWorkspaceStatus(workspaceId, 'awaiting-user');
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
console.warn('[orchestrator] Failed to transition to awaiting-user:', err);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
343
519
|
if (ev.kind === 'session:started' && ev.engineSessionId) {
|
|
344
520
|
sessionIds.set(workspaceId, ev.engineSessionId);
|
|
345
521
|
try {
|
|
@@ -349,11 +525,9 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
349
525
|
catch (err) {
|
|
350
526
|
console.error('[orchestrator] Failed to persist engine session id:', err);
|
|
351
527
|
}
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// Transition from a terminal state (completed/idle/error/quota) to
|
|
356
|
-
// executing so the UI reflects that a new turn is happening.
|
|
528
|
+
// Transition terminal states (completed/idle/error/quota) → executing so
|
|
529
|
+
// the frontend's `sessionActive` flips and streaming messages get the
|
|
530
|
+
// typing spinner.
|
|
357
531
|
try {
|
|
358
532
|
const ws = getWs(workspaceId);
|
|
359
533
|
if (ws && (ws.status === 'completed' || ws.status === 'idle' || ws.status === 'error' || ws.status === 'quota')) {
|
|
@@ -366,7 +540,7 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
366
540
|
}
|
|
367
541
|
}
|
|
368
542
|
}
|
|
369
|
-
function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = false) {
|
|
543
|
+
function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFailed = false) {
|
|
370
544
|
const currentWorkspace = getWs(workspaceId);
|
|
371
545
|
const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
|
|
372
546
|
const ctrl = controllers.get(workspaceId);
|
|
@@ -389,11 +563,8 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = fa
|
|
|
389
563
|
catch (err) {
|
|
390
564
|
console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
|
|
391
565
|
}
|
|
392
|
-
if (wasStopping)
|
|
393
|
-
// session:ended with reason='killed' already emitted by the engine covers
|
|
394
|
-
// the "stopped" status. No legacy emit needed.
|
|
566
|
+
if (wasStopping)
|
|
395
567
|
return;
|
|
396
|
-
}
|
|
397
568
|
// When the session hit quota, handleQuota() already transitioned the
|
|
398
569
|
// workspace to `quota` and armed the retry timer. Keep that timer alive
|
|
399
570
|
// and preserve the `quota` status so auto-loop can resume after reset.
|
|
@@ -414,35 +585,23 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = fa
|
|
|
414
585
|
}
|
|
415
586
|
return;
|
|
416
587
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
try {
|
|
425
|
-
markWorkspaceUnread(workspaceId);
|
|
426
|
-
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
// best-effort
|
|
430
|
-
}
|
|
588
|
+
// `reason` is authoritative (with the SDK engine `exitCode` is often null,
|
|
589
|
+
// so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
|
|
590
|
+
// `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
|
|
591
|
+
const isErrorOutcome = !resumeFailed && (reason === 'error' || (exitCode !== null && exitCode !== 0));
|
|
592
|
+
const targetStatus = isErrorOutcome ? 'error' : 'completed';
|
|
593
|
+
try {
|
|
594
|
+
updateWorkspaceStatus(workspaceId, targetStatus);
|
|
431
595
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
442
|
-
}
|
|
443
|
-
catch {
|
|
444
|
-
// best-effort
|
|
445
|
-
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
console.error('[orchestrator] Failed to update workspace status on exit:', err);
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
markWorkspaceUnread(workspaceId);
|
|
601
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// best-effort
|
|
446
605
|
}
|
|
447
606
|
}
|
|
448
607
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
@@ -452,18 +611,45 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = fa
|
|
|
452
611
|
* after `engine.start` resolves — callers should subscribe to WS events or
|
|
453
612
|
* query the controller via `_getControllers()` for tests.
|
|
454
613
|
*/
|
|
455
|
-
export function startAgent(workspaceId, workingDir, prompt, model, resume = false,
|
|
456
|
-
|
|
457
|
-
|
|
614
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
|
|
615
|
+
// Zombie detection: an SDK iterator hung on a never-resolved canUseTool
|
|
616
|
+
// callback can leave its controller in the map after the workspace is
|
|
617
|
+
// logically idle. Evict it instead of refusing the new session.
|
|
618
|
+
const existingCtrl = controllers.get(workspaceId);
|
|
619
|
+
if (existingCtrl) {
|
|
620
|
+
const wsForCheck = getWs(workspaceId);
|
|
621
|
+
const status = wsForCheck?.status;
|
|
622
|
+
const isLogicallyDone = status === 'idle' || status === 'completed' || status === 'error' || status === 'quota';
|
|
623
|
+
if (isLogicallyDone) {
|
|
624
|
+
console.warn(`[orchestrator] Evicting zombie controller for workspace '${workspaceId}' (status=${status}) before starting fresh session`);
|
|
625
|
+
void existingCtrl.stop().catch(() => { });
|
|
626
|
+
// Drop any queued pending items + persisted user-input-requested events
|
|
627
|
+
// tied to the zombie's agentSessionId so the new session doesn't inherit
|
|
628
|
+
// a stale queue and so a future sync replay doesn't resurrect them.
|
|
629
|
+
try {
|
|
630
|
+
clearPendingForSession(workspaceId, existingCtrl.agentSessionId);
|
|
631
|
+
preAwaitStatus.delete(workspaceId);
|
|
632
|
+
const db = getDb();
|
|
633
|
+
db.prepare(`DELETE FROM ws_events
|
|
634
|
+
WHERE workspace_id = ?
|
|
635
|
+
AND session_id = ?
|
|
636
|
+
AND type = 'agent:event'
|
|
637
|
+
AND json_extract(payload, '$.kind') = 'session:user-input-requested'`).run(workspaceId, existingCtrl.agentSessionId);
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
console.warn('[orchestrator] Failed to purge zombie pending state:', err);
|
|
641
|
+
}
|
|
642
|
+
controllers.delete(workspaceId);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
646
|
+
}
|
|
458
647
|
}
|
|
459
648
|
const ws = getWs(workspaceId);
|
|
460
649
|
const engineId = readWorkspaceEngineId(workspaceId);
|
|
461
650
|
const engine = resolveEngine(engineId);
|
|
462
651
|
let agentSessionId;
|
|
463
652
|
let resumeFromEngineSessionId;
|
|
464
|
-
// Note: plan-mode prompt prefixing is an engine-specific concern handled by
|
|
465
|
-
// the Claude Code engine's args-builder. Do NOT prepend it here — that would
|
|
466
|
-
// double-prepend the marker when the engine applies its own prefix.
|
|
467
653
|
if (resume) {
|
|
468
654
|
const r = resolveSessionForResume(workspaceId, existingSessionId);
|
|
469
655
|
agentSessionId = r.agentSessionId;
|
|
@@ -479,11 +665,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
479
665
|
prompt,
|
|
480
666
|
model,
|
|
481
667
|
effort: reasoningEffort,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
// strict). Defaults to 'bypass' — the pre-existing behavior — when the
|
|
485
|
-
// column is missing (fresh boot before migration landed) or unknown.
|
|
486
|
-
permissionProfile: ws?.permissionProfile === 'strict' ? 'strict' : 'bypass',
|
|
668
|
+
// Cascade: explicit caller override → workspace setting → 'bypass'.
|
|
669
|
+
agentPermissionMode: agentPermissionMode ?? ws?.agentPermissionMode ?? 'bypass',
|
|
487
670
|
resumeFromEngineSessionId,
|
|
488
671
|
backendUrl: `http://127.0.0.1:${backendPort}`,
|
|
489
672
|
koboHome: (() => {
|
|
@@ -499,8 +682,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
499
682
|
};
|
|
500
683
|
const controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, ev));
|
|
501
684
|
controllers.set(workspaceId, controller);
|
|
502
|
-
// "Agent running" is signalled via the engine's session:started event.
|
|
503
|
-
// The legacy `agent:status { status: 'executing' }` emit is gone.
|
|
504
685
|
// Kick off engine.start asynchronously. Errors surface as error events.
|
|
505
686
|
void controller
|
|
506
687
|
.start(options)
|
|
@@ -557,9 +738,29 @@ export function stopAgent(workspaceId) {
|
|
|
557
738
|
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
558
739
|
}
|
|
559
740
|
wakeupService.cancel(workspaceId, 'stopped');
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
741
|
+
// If the session was waiting on a question/permission, normalize the state
|
|
742
|
+
// synchronously so callers (archive, delete, manual stop) see a clean
|
|
743
|
+
// workspace immediately — without waiting for the async controller.stop()
|
|
744
|
+
// → session:ended round-trip. We:
|
|
745
|
+
// 1. drop queued pending items for this session,
|
|
746
|
+
// 2. purge persisted `session:user-input-requested` events so a F5 can't
|
|
747
|
+
// resurrect zombie panels,
|
|
748
|
+
// 3. transition the workspace out of `awaiting-user` (→ idle) so badges
|
|
749
|
+
// and unarchive don't leave a stuck status.
|
|
750
|
+
const wsBefore = getWs(workspaceId);
|
|
751
|
+
if (wsBefore?.status === 'awaiting-user') {
|
|
752
|
+
clearPendingForSession(workspaceId, ctrl.agentSessionId);
|
|
753
|
+
purgeAllPersistedUserInputRequests(workspaceId, ctrl.agentSessionId);
|
|
754
|
+
try {
|
|
755
|
+
updateWorkspaceStatus(workspaceId, 'idle');
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
console.warn('[orchestrator] Failed to normalize awaiting-user → idle on stop:', err);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Remove from the map immediately so a fresh startAgent can proceed. The
|
|
762
|
+
// session:ended handler checks identity before removing, so a new controller
|
|
763
|
+
// started in the meantime is preserved.
|
|
563
764
|
controllers.delete(workspaceId);
|
|
564
765
|
const timer = backoffTimers.get(workspaceId);
|
|
565
766
|
if (timer) {
|
|
@@ -580,6 +781,248 @@ export function sendMessage(workspaceId, content) {
|
|
|
580
781
|
wakeupService.cancel(workspaceId, 'user-message');
|
|
581
782
|
ctrl.sendMessage(content);
|
|
582
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Render the user's answer to an AskUserQuestion as a markdown chat
|
|
786
|
+
* message. Each question becomes a bullet line `**<question>** → <answer>`.
|
|
787
|
+
* Empty answers are skipped — questions the user didn't fill won't appear.
|
|
788
|
+
*/
|
|
789
|
+
function formatDeferredAnswerForChat(questions, answers) {
|
|
790
|
+
if (!Array.isArray(questions))
|
|
791
|
+
return '';
|
|
792
|
+
const lines = [];
|
|
793
|
+
for (const q of questions) {
|
|
794
|
+
if (!q || typeof q !== 'object')
|
|
795
|
+
continue;
|
|
796
|
+
const questionText = typeof q.question === 'string' ? q.question : null;
|
|
797
|
+
if (!questionText)
|
|
798
|
+
continue;
|
|
799
|
+
const answer = answers[questionText];
|
|
800
|
+
if (!answer)
|
|
801
|
+
continue;
|
|
802
|
+
lines.push(`- **${questionText}** → ${answer}`);
|
|
803
|
+
}
|
|
804
|
+
return lines.length > 0 ? lines.join('\n') : '';
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Answer a pending AskUserQuestion by resolving the engine's `canUseTool`
|
|
808
|
+
* callback with the user's answers. The SDK iterator continues on its own
|
|
809
|
+
* once the callback resolves — no resume / re-spawn needed.
|
|
810
|
+
*/
|
|
811
|
+
export async function answerPendingQuestion(workspaceId, answers, expectedToolCallId) {
|
|
812
|
+
const head = peekPending(workspaceId);
|
|
813
|
+
if (!head) {
|
|
814
|
+
// Self-heal an orphan `awaiting-user` (queue empty but status not restored,
|
|
815
|
+
// typically after a server restart). Default to `idle` rather than
|
|
816
|
+
// `executing` since there's no live agent here.
|
|
817
|
+
try {
|
|
818
|
+
const ws = getWs(workspaceId);
|
|
819
|
+
if (ws?.status === 'awaiting-user') {
|
|
820
|
+
const remembered = preAwaitStatus.get(workspaceId);
|
|
821
|
+
preAwaitStatus.delete(workspaceId);
|
|
822
|
+
const restoreTo = remembered ?? 'idle';
|
|
823
|
+
updateWorkspaceStatus(workspaceId, restoreTo);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
console.warn('[orchestrator] Self-heal awaiting-user → idle failed:', err);
|
|
828
|
+
}
|
|
829
|
+
throw new Error(`No deferred tool use pending for workspace '${workspaceId}'`);
|
|
830
|
+
}
|
|
831
|
+
if (head.kind !== 'question') {
|
|
832
|
+
throw new Error(`Expected a deferred question at the head of the queue, got '${head.kind}'`);
|
|
833
|
+
}
|
|
834
|
+
// Race protection: head may have rotated between the panel opening and
|
|
835
|
+
// submit (previous defer cancelled, new one queued).
|
|
836
|
+
if (expectedToolCallId && head.toolCallId !== expectedToolCallId) {
|
|
837
|
+
throw new Error(`Pending question changed: expected toolCallId '${expectedToolCallId}', current head is '${head.toolCallId}'`);
|
|
838
|
+
}
|
|
839
|
+
const ws = getWs(workspaceId);
|
|
840
|
+
if (!ws) {
|
|
841
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
842
|
+
}
|
|
843
|
+
const ctrl = controllers.get(workspaceId);
|
|
844
|
+
if (!ctrl) {
|
|
845
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
846
|
+
}
|
|
847
|
+
const engineProcess = ctrl.engineProcess;
|
|
848
|
+
if (!engineProcess) {
|
|
849
|
+
throw new Error(`Agent for workspace '${workspaceId}' has no active engine process`);
|
|
850
|
+
}
|
|
851
|
+
const resolved = engineProcess.resolvePendingUserInput(head.toolCallId, { kind: 'question', answers });
|
|
852
|
+
if (!resolved) {
|
|
853
|
+
throw new Error(`No pending callback for toolCallId '${head.toolCallId}'`);
|
|
854
|
+
}
|
|
855
|
+
dequeuePending(workspaceId);
|
|
856
|
+
purgePersistedUserInputRequest(workspaceId, head.toolCallId);
|
|
857
|
+
const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
|
|
858
|
+
try {
|
|
859
|
+
updateWorkspaceStatus(workspaceId, restoreTo);
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
|
|
863
|
+
}
|
|
864
|
+
const questions = head.input?.questions;
|
|
865
|
+
try {
|
|
866
|
+
const formatted = formatDeferredAnswerForChat(questions, answers);
|
|
867
|
+
if (formatted) {
|
|
868
|
+
emit(workspaceId, 'user:message', { content: formatted, sender: 'user' }, head.agentSessionId);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
console.error('[orchestrator] Failed to emit user:message for question answer:', err);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Answer a pending interactive permission request by resolving the engine's
|
|
877
|
+
* `canUseTool` callback with allow/deny.
|
|
878
|
+
*/
|
|
879
|
+
export async function answerPendingPermission(workspaceId, decision) {
|
|
880
|
+
const head = peekPending(workspaceId);
|
|
881
|
+
if (!head) {
|
|
882
|
+
// Self-heal an orphan `awaiting-user` (see answerPendingQuestion).
|
|
883
|
+
try {
|
|
884
|
+
const ws = getWs(workspaceId);
|
|
885
|
+
if (ws?.status === 'awaiting-user') {
|
|
886
|
+
const remembered = preAwaitStatus.get(workspaceId);
|
|
887
|
+
preAwaitStatus.delete(workspaceId);
|
|
888
|
+
updateWorkspaceStatus(workspaceId, remembered ?? 'idle');
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
console.warn('[orchestrator] Self-heal awaiting-user → idle failed:', err);
|
|
893
|
+
}
|
|
894
|
+
throw new Error(`No deferred tool use pending for workspace '${workspaceId}'`);
|
|
895
|
+
}
|
|
896
|
+
if (head.kind !== 'permission') {
|
|
897
|
+
throw new Error(`Expected a deferred permission at the head of the queue, got '${head.kind}'`);
|
|
898
|
+
}
|
|
899
|
+
if (head.toolCallId !== decision.toolCallId) {
|
|
900
|
+
throw new Error(`Decision toolCallId '${decision.toolCallId}' does not match head toolCallId '${head.toolCallId}'`);
|
|
901
|
+
}
|
|
902
|
+
const ws = getWs(workspaceId);
|
|
903
|
+
if (!ws) {
|
|
904
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
905
|
+
}
|
|
906
|
+
const ctrl = controllers.get(workspaceId);
|
|
907
|
+
if (!ctrl) {
|
|
908
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
909
|
+
}
|
|
910
|
+
const engineProcess = ctrl.engineProcess;
|
|
911
|
+
if (!engineProcess) {
|
|
912
|
+
throw new Error(`Agent for workspace '${workspaceId}' has no active engine process`);
|
|
913
|
+
}
|
|
914
|
+
const response = decision.decision === 'allow'
|
|
915
|
+
? { kind: 'permission-allow' }
|
|
916
|
+
: { kind: 'permission-deny', reason: decision.reason };
|
|
917
|
+
const resolved = engineProcess.resolvePendingUserInput(decision.toolCallId, response);
|
|
918
|
+
if (!resolved) {
|
|
919
|
+
throw new Error(`No pending callback for toolCallId '${decision.toolCallId}'`);
|
|
920
|
+
}
|
|
921
|
+
dequeuePending(workspaceId);
|
|
922
|
+
purgePersistedUserInputRequest(workspaceId, head.toolCallId);
|
|
923
|
+
const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
|
|
924
|
+
try {
|
|
925
|
+
updateWorkspaceStatus(workspaceId, restoreTo);
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Cancel a pending question without answering: resolves the SDK callback
|
|
933
|
+
* with `behavior: 'deny'` so the agent receives an error tool_result and
|
|
934
|
+
* can adapt (proceed with defaults, re-ask, or abandon). The session
|
|
935
|
+
* keeps running — Cancel ≠ Stop.
|
|
936
|
+
*/
|
|
937
|
+
export async function cancelPendingQuestion(workspaceId, reason, expectedToolCallId) {
|
|
938
|
+
const head = peekPending(workspaceId);
|
|
939
|
+
if (!head) {
|
|
940
|
+
// Self-heal an orphan `awaiting-user` (see answerPendingQuestion).
|
|
941
|
+
try {
|
|
942
|
+
const ws = getWs(workspaceId);
|
|
943
|
+
if (ws?.status === 'awaiting-user') {
|
|
944
|
+
const remembered = preAwaitStatus.get(workspaceId);
|
|
945
|
+
preAwaitStatus.delete(workspaceId);
|
|
946
|
+
updateWorkspaceStatus(workspaceId, remembered ?? 'idle');
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
catch (err) {
|
|
950
|
+
console.warn('[orchestrator] Self-heal awaiting-user → idle failed:', err);
|
|
951
|
+
}
|
|
952
|
+
throw new Error(`No deferred tool use pending for workspace '${workspaceId}'`);
|
|
953
|
+
}
|
|
954
|
+
if (head.kind !== 'question') {
|
|
955
|
+
throw new Error(`Expected a deferred question at the head of the queue, got '${head.kind}'`);
|
|
956
|
+
}
|
|
957
|
+
// toolCallId mismatch on cancel is logged but NOT fatal: the user clicked
|
|
958
|
+
// Cancel on whatever was visible. Worst case is a benign deny on a question
|
|
959
|
+
// the agent was about to ask anyway. (Mismatch on submit IS fatal — wrong
|
|
960
|
+
// answers would be applied to the wrong question.)
|
|
961
|
+
if (expectedToolCallId && head.toolCallId !== expectedToolCallId) {
|
|
962
|
+
console.warn(`[orchestrator] cancel toolCallId mismatch — expected '${expectedToolCallId}', head is '${head.toolCallId}'. Cancelling head anyway.`);
|
|
963
|
+
}
|
|
964
|
+
const ctrl = controllers.get(workspaceId);
|
|
965
|
+
if (!ctrl) {
|
|
966
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
967
|
+
}
|
|
968
|
+
const engineProcess = ctrl.engineProcess;
|
|
969
|
+
if (!engineProcess) {
|
|
970
|
+
throw new Error(`Agent for workspace '${workspaceId}' has no active engine process`);
|
|
971
|
+
}
|
|
972
|
+
const resolved = engineProcess.resolvePendingUserInput(head.toolCallId, {
|
|
973
|
+
kind: 'question-cancel',
|
|
974
|
+
reason,
|
|
975
|
+
});
|
|
976
|
+
if (!resolved) {
|
|
977
|
+
throw new Error(`No pending callback for toolCallId '${head.toolCallId}'`);
|
|
978
|
+
}
|
|
979
|
+
dequeuePending(workspaceId);
|
|
980
|
+
purgePersistedUserInputRequest(workspaceId, head.toolCallId);
|
|
981
|
+
const restoreTo = peekPending(workspaceId) ? 'awaiting-user' : consumePreAwaitStatus(workspaceId);
|
|
982
|
+
try {
|
|
983
|
+
updateWorkspaceStatus(workspaceId, restoreTo);
|
|
984
|
+
}
|
|
985
|
+
catch (err) {
|
|
986
|
+
console.warn(`[orchestrator] Failed to transition awaiting-user → ${restoreTo}:`, err);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
/** @deprecated use `answerPendingQuestion` instead. Kept for legacy callers/tests. */
|
|
990
|
+
export async function resumeDeferredQuestion(workspaceId, answers) {
|
|
991
|
+
return answerPendingQuestion(workspaceId, answers);
|
|
992
|
+
}
|
|
993
|
+
/** @deprecated use `answerPendingPermission` instead. Kept for legacy callers/tests. */
|
|
994
|
+
export async function resumeDeferredPermission(workspaceId, decision) {
|
|
995
|
+
return answerPendingPermission(workspaceId, decision);
|
|
996
|
+
}
|
|
997
|
+
/** @deprecated alias kept for older tests. */
|
|
998
|
+
export async function resumeDeferredToolUse(workspaceId, answers) {
|
|
999
|
+
return answerPendingQuestion(workspaceId, answers);
|
|
1000
|
+
}
|
|
1001
|
+
/** @internal test-only */
|
|
1002
|
+
export function _getPendingQueue() {
|
|
1003
|
+
return pendingQueue;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* @internal test-only — legacy shim. Returns a Map<workspaceId, PendingItem>
|
|
1007
|
+
* containing only the head of each queue (question kind only) flattened to the
|
|
1008
|
+
* pre-queue shape so older tests keep passing without rewriting. New tests
|
|
1009
|
+
* should use `_getPendingQueue` instead.
|
|
1010
|
+
*/
|
|
1011
|
+
export function _getPendingDeferred() {
|
|
1012
|
+
const out = new Map();
|
|
1013
|
+
for (const [wid, arr] of pendingQueue) {
|
|
1014
|
+
const head = arr[0];
|
|
1015
|
+
if (!head || head.kind !== 'question')
|
|
1016
|
+
continue;
|
|
1017
|
+
out.set(wid, {
|
|
1018
|
+
toolCallId: head.toolCallId,
|
|
1019
|
+
toolName: head.toolName,
|
|
1020
|
+
input: head.input,
|
|
1021
|
+
agentSessionId: head.agentSessionId,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
return out;
|
|
1025
|
+
}
|
|
583
1026
|
/** In-memory status of the agent for a workspace, or null if not running. */
|
|
584
1027
|
export function getAgentStatus(workspaceId) {
|
|
585
1028
|
return controllers.get(workspaceId)?.status ?? null;
|
|
@@ -588,6 +1031,10 @@ export function getAgentStatus(workspaceId) {
|
|
|
588
1031
|
export function hasController(workspaceId) {
|
|
589
1032
|
return controllers.has(workspaceId);
|
|
590
1033
|
}
|
|
1034
|
+
/** The agent_session_id of the active controller for the workspace, if any. */
|
|
1035
|
+
export function getActiveSessionId(workspaceId) {
|
|
1036
|
+
return controllers.get(workspaceId)?.agentSessionId;
|
|
1037
|
+
}
|
|
591
1038
|
/** Number of currently running controllers. */
|
|
592
1039
|
export function getRunningCount() {
|
|
593
1040
|
return controllers.size;
|
|
@@ -675,11 +1122,6 @@ function handleQuota(workspaceId, _agentSessionId) {
|
|
|
675
1122
|
catch {
|
|
676
1123
|
// May fail if transition is not valid
|
|
677
1124
|
}
|
|
678
|
-
// The quota state is already signalled by the `error { category: 'quota' }`
|
|
679
|
-
// AgentEvent that triggered this handler. No legacy `agent:status { quota }`
|
|
680
|
-
// emit needed.
|
|
681
|
-
// Prefer the actual resetsAt from the last rate_limit event; fall back to
|
|
682
|
-
// the 15/30/60min exponential schedule when that info isn't usable.
|
|
683
1125
|
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
684
1126
|
const { delayMs, resetsAt, source } = computeQuotaBackoffMs(workspaceId, retryCount);
|
|
685
1127
|
const backoffMs = delayMs;
|