@loicngr/kobo 1.6.14 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +4 -3
  2. package/dist/mcp-server/kobo-tasks-server.js +51 -0
  3. package/dist/server/db/migrations.js +40 -0
  4. package/dist/server/db/schema.js +7 -5
  5. package/dist/server/index.js +12 -11
  6. package/dist/server/routes/workspaces.js +207 -26
  7. package/dist/server/services/agent/engines/claude-code/capabilities.js +1 -1
  8. package/dist/server/services/agent/engines/claude-code/engine.js +237 -132
  9. package/dist/server/services/agent/engines/claude-code/event-mapper.js +234 -0
  10. package/dist/server/services/agent/engines/claude-code/options-builder.js +68 -0
  11. package/dist/server/services/agent/engines/claude-code/precompact-hook.js +27 -0
  12. package/dist/server/services/agent/engines/types.js +1 -0
  13. package/dist/server/services/agent/orchestrator.js +536 -94
  14. package/dist/server/services/agent/session-controller.js +14 -43
  15. package/dist/server/services/auto-loop-service.js +17 -6
  16. package/dist/server/services/content-migration-service.js +24 -94
  17. package/dist/server/services/usage/poller.js +4 -1
  18. package/dist/server/services/wakeup-service.js +8 -9
  19. package/dist/server/services/workspace-service.js +40 -36
  20. package/dist/server/utils/git-ops.js +67 -5
  21. package/package.json +2 -1
  22. package/src/client/dist/spa/assets/ActivityFeed-CIJPN8TH.js +7 -0
  23. package/src/client/dist/spa/assets/ActivityFeed-LXnbg3ff.css +1 -0
  24. package/src/client/dist/spa/assets/ClosePopup-DzB3mDtj.js +1 -0
  25. package/src/client/dist/spa/assets/CreatePage-BE3xfQsC.css +1 -0
  26. package/src/client/dist/spa/assets/CreatePage-U6TtJzNe.js +2 -0
  27. package/src/client/dist/spa/assets/DiffViewer-D1Sdu307.css +1 -0
  28. package/src/client/dist/spa/assets/DiffViewer-Di85TBIi.js +7 -0
  29. package/src/client/dist/spa/assets/HealthPage-B7aWFxAZ.js +1 -0
  30. package/src/client/dist/spa/assets/{MainLayout-C3TUaYvQ.js → MainLayout-BHBrz4c9.js} +17 -17
  31. package/src/client/dist/spa/assets/MainLayout-Dba6SdpU.css +1 -0
  32. package/src/client/dist/spa/assets/QCheckbox-CcY7ZSk9.js +1 -0
  33. package/src/client/dist/spa/assets/QChip-BhT0W2Dg.js +1 -0
  34. package/src/client/dist/spa/assets/QExpansionItem-VS4b4eY6.js +1 -0
  35. package/src/client/dist/spa/assets/QInput-D4WJro4e.js +1 -0
  36. package/src/client/dist/spa/assets/QMenu-CchbRXbp.js +1 -0
  37. package/src/client/dist/spa/assets/{QPage-yqdKDG7-.js → QPage-Cu7zkfc6.js} +1 -1
  38. package/src/client/dist/spa/assets/QRadio-DaZhdLCg.js +1 -0
  39. package/src/client/dist/spa/assets/QResizeObserver-Cf79V-VZ.js +1 -0
  40. package/src/client/dist/spa/assets/QScrollArea-DrVTDLU0.js +1 -0
  41. package/src/client/dist/spa/assets/QTabPanels-HXz-evuj.js +1 -0
  42. package/src/client/dist/spa/assets/QToggle-CGpiJLDJ.js +1 -0
  43. package/src/client/dist/spa/assets/QTooltip-DjJYMTkN.js +1 -0
  44. package/src/client/dist/spa/assets/SearchPage-CVm-sqxH.js +1 -0
  45. package/src/client/dist/spa/assets/SettingsPage-ayDKGo9H.js +1 -0
  46. package/src/client/dist/spa/assets/SettingsPage-wTBCvK6t.css +1 -0
  47. package/src/client/dist/spa/assets/WorkspacePage-DQxGe62K.css +1 -0
  48. package/src/client/dist/spa/assets/WorkspacePage-xaVy8s5i.js +4 -0
  49. package/src/client/dist/spa/assets/build-path-tree-CdY1A6aP.js +1 -0
  50. package/src/client/dist/spa/assets/{cssMode-wNaxOrgG.js → cssMode-BVNBMOxh.js} +1 -1
  51. package/src/client/dist/spa/assets/documents-D6A3wRry.js +1 -0
  52. package/src/client/dist/spa/assets/{editor.api-CcDntllS.js → editor.api-D6Vfp5yv.js} +1 -1
  53. package/src/client/dist/spa/assets/{editor.main-Chu4hc0J.js → editor.main-CTCYF6V4.js} +3 -3
  54. package/src/client/dist/spa/assets/{expand-template-CcQus77v.js → expand-template-vHV2iwXf.js} +1 -1
  55. package/src/client/dist/spa/assets/{formatters-CX2gvLFv.js → formatters-ejxELb0M.js} +1 -1
  56. package/src/client/dist/spa/assets/{freemarker2-CO_b202E.js → freemarker2-nmzwPmzi.js} +1 -1
  57. package/src/client/dist/spa/assets/{handlebars-CJnTWNLs.js → handlebars-CI9lR7Ef.js} +1 -1
  58. package/src/client/dist/spa/assets/{html-DeArYseI.js → html-BQ21REnv.js} +1 -1
  59. package/src/client/dist/spa/assets/{htmlMode-BnNgEgdx.js → htmlMode-io5J5Qr1.js} +1 -1
  60. package/src/client/dist/spa/assets/i18n-Do8Kn8n0.js +1 -0
  61. package/src/client/dist/spa/assets/index-C_e7KOYh.js +2 -0
  62. package/src/client/dist/spa/assets/{javascript-C0pxfNu4.js → javascript--u9PDBCv.js} +1 -1
  63. package/src/client/dist/spa/assets/{jsonMode-ety87201.js → jsonMode-DBG5llk4.js} +1 -1
  64. package/src/client/dist/spa/assets/{kobo-commands-Cpl4IFon.js → kobo-commands-DiUm1Y34.js} +1 -1
  65. package/src/client/dist/spa/assets/{liquid-kanevKvC.js → liquid-DxAS4nYF.js} +1 -1
  66. package/src/client/dist/spa/assets/marked.esm-DuOsJx63.js +60 -0
  67. package/src/client/dist/spa/assets/{mdx-DkmtbRD7.js → mdx-BNXTiODW.js} +1 -1
  68. package/src/client/dist/spa/assets/models-DNYEhFF7.js +1 -0
  69. package/src/client/dist/spa/assets/{monaco.contribution-DsZsua59.js → monaco.contribution-CT3LAK0J.js} +2 -2
  70. package/src/client/dist/spa/assets/{python-DrxH1xl7.js → python-DztNww13.js} +1 -1
  71. package/src/client/dist/spa/assets/{razor-CU4khv8N.js → razor-Cyr82NZF.js} +1 -1
  72. package/src/client/dist/spa/assets/settings-Dbx1_ksA.js +1 -0
  73. package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +1 -0
  74. package/src/client/dist/spa/assets/touch-Co9pfjUU.js +1 -0
  75. package/src/client/dist/spa/assets/{tsMode-CQ5yxoz_.js → tsMode-CbQVgsIP.js} +1 -1
  76. package/src/client/dist/spa/assets/{typescript-CSwKmP7l.js → typescript-UHOe4d1S.js} +1 -1
  77. package/src/client/dist/spa/assets/use-checkbox-DzHmcu7s.js +1 -0
  78. package/src/client/dist/spa/assets/use-id-CDuXkR0Z.js +1 -0
  79. package/src/client/dist/spa/assets/use-panel-Br8QNRMk.js +1 -0
  80. package/src/client/dist/spa/assets/{xml-9bnWANPJ.js → xml-DC88eFpV.js} +1 -1
  81. package/src/client/dist/spa/assets/{yaml-sUtDJGxo.js → yaml-DSTsIRJr.js} +1 -1
  82. package/src/client/dist/spa/index.html +11 -10
  83. package/src/mcp-server/kobo-tasks-server.ts +60 -1
  84. package/dist/server/services/agent/engines/claude-code/args-builder.js +0 -57
  85. package/dist/server/services/agent/engines/claude-code/mcp-config.js +0 -23
  86. package/dist/server/services/agent/engines/claude-code/stream-parser.js +0 -386
  87. package/src/client/dist/spa/assets/ActivityFeed-Be0QQryJ.css +0 -1
  88. package/src/client/dist/spa/assets/ActivityFeed-BtIOkIy6.js +0 -8
  89. package/src/client/dist/spa/assets/ClosePopup-DkLittac.js +0 -1
  90. package/src/client/dist/spa/assets/CreatePage-D6Q3nxkX.js +0 -2
  91. package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +0 -1
  92. package/src/client/dist/spa/assets/DiffViewer-1s165rFm.css +0 -1
  93. package/src/client/dist/spa/assets/DiffViewer-D5u9p7il.js +0 -2
  94. package/src/client/dist/spa/assets/HealthPage-Cr7aAUy6.js +0 -1
  95. package/src/client/dist/spa/assets/MainLayout-CBnSwSfy.css +0 -1
  96. package/src/client/dist/spa/assets/QChip-bl3YRhax.js +0 -1
  97. package/src/client/dist/spa/assets/QExpansionItem-CWw6ZujM.js +0 -1
  98. package/src/client/dist/spa/assets/QScrollArea-DpCqRRE0.js +0 -1
  99. package/src/client/dist/spa/assets/QSeparator-DNSiXYrN.js +0 -1
  100. package/src/client/dist/spa/assets/QSlideTransition-BQxI8l5r.js +0 -1
  101. package/src/client/dist/spa/assets/QTabPanels-C4bZGqml.js +0 -1
  102. package/src/client/dist/spa/assets/QTooltip-BIDjo2hJ.js +0 -1
  103. package/src/client/dist/spa/assets/SearchPage-CavRaij6.js +0 -1
  104. package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +0 -1
  105. package/src/client/dist/spa/assets/SettingsPage-C13T1l_t.js +0 -1
  106. package/src/client/dist/spa/assets/TouchPan-vsl78kxF.js +0 -1
  107. package/src/client/dist/spa/assets/WorkspacePage-BEqEuPrb.js +0 -4
  108. package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +0 -1
  109. package/src/client/dist/spa/assets/build-path-tree-BeAS10oa.js +0 -1
  110. package/src/client/dist/spa/assets/documents-Cw05r3zs.js +0 -60
  111. package/src/client/dist/spa/assets/i18n-CuT4b7ns.js +0 -1
  112. package/src/client/dist/spa/assets/index-CZA4BFN5.js +0 -2
  113. package/src/client/dist/spa/assets/models-CPFeBEQS.js +0 -1
  114. package/src/client/dist/spa/assets/private.use-form-Dlb0iQZh.js +0 -1
  115. package/src/client/dist/spa/assets/scroll-CYWyxBdv.js +0 -1
  116. package/src/client/dist/spa/assets/settings-CAILUJXO.js +0 -1
  117. package/src/client/dist/spa/assets/stats-C3n1k51k.js +0 -1
  118. package/src/client/dist/spa/assets/symbols-DCYodwb2.js +0 -1
  119. package/src/client/dist/spa/assets/touch-Bj_Fr4kC.js +0 -1
  120. package/src/client/dist/spa/assets/use-checkbox-B_o-iLG2.js +0 -1
  121. package/src/client/dist/spa/assets/use-id-C93QQwrt.js +0 -1
  122. /package/src/client/dist/spa/assets/{QBadge-DqtcDv8D.js → QBadge-fsQ2AokU.js} +0 -0
  123. /package/src/client/dist/spa/assets/{QItemLabel-Codqjisk.js → QItemLabel-DWwenW2S.js} +0 -0
  124. /package/src/client/dist/spa/assets/{QItemSection-CiY_LK5Y.js → QItemSection-KFAnxzMK.js} +0 -0
  125. /package/src/client/dist/spa/assets/{QList-Bl9824vi.js → QList-NmIE6Rd9.js} +0 -0
  126. /package/src/client/dist/spa/assets/{QSpace-BNr0AftG.js → QSpace-COlmM_4F.js} +0 -0
  127. /package/src/client/dist/spa/assets/{QSpinnerDots-DEiRooBD.js → QSpinnerDots-DwtnRN2r.js} +0 -0
  128. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-r4mAJOHR.js → _plugin-vue_export-helper-B8bB5DBd.js} +0 -0
  129. /package/src/client/dist/spa/assets/{abap-Bgec7Keq.js → abap-DzK-OTGh.js} +0 -0
  130. /package/src/client/dist/spa/assets/{apex-VBlPwEoQ.js → apex-Bj60_dRt.js} +0 -0
  131. /package/src/client/dist/spa/assets/{azcli-DKqrEFBx.js → azcli-B6NwaBAZ.js} +0 -0
  132. /package/src/client/dist/spa/assets/{bat-DdgQWy_0.js → bat-bf7wXV68.js} +0 -0
  133. /package/src/client/dist/spa/assets/{bicep-CRMM43EB.js → bicep-C_bg8UgA.js} +0 -0
  134. /package/src/client/dist/spa/assets/{cameligo-UatALtML.js → cameligo-CTWw4D4B.js} +0 -0
  135. /package/src/client/dist/spa/assets/{clojure-D8JU08RA.js → clojure-CgdPoH0r.js} +0 -0
  136. /package/src/client/dist/spa/assets/{coffee-C56wu358.js → coffee-gHQfdA5M.js} +0 -0
  137. /package/src/client/dist/spa/assets/{cpp-CyZLvhJG.js → cpp-BM4Jj4aW.js} +0 -0
  138. /package/src/client/dist/spa/assets/{csharp-BJl3ixva.js → csharp-D8-bh4Cd.js} +0 -0
  139. /package/src/client/dist/spa/assets/{csp-CxEKxmO-.js → csp-CXBxRx0n.js} +0 -0
  140. /package/src/client/dist/spa/assets/{css-B0t_muXd.js → css-DKjIxrmY.js} +0 -0
  141. /package/src/client/dist/spa/assets/{cypher-D1hqiMFD.js → cypher-C5e5inIh.js} +0 -0
  142. /package/src/client/dist/spa/assets/{dart-Bz550Pyv.js → dart-BhRHHm4x.js} +0 -0
  143. /package/src/client/dist/spa/assets/{dockerfile-CIXgVAuA.js → dockerfile-DW5REF8E.js} +0 -0
  144. /package/src/client/dist/spa/assets/{ecl-D9qbvZoA.js → ecl-Bw4Hg3n_.js} +0 -0
  145. /package/src/client/dist/spa/assets/{elixir-b2M38fAy.js → elixir-DHmoBvpZ.js} +0 -0
  146. /package/src/client/dist/spa/assets/{flow9-Dq1UYMkt.js → flow9-BsFExz3v.js} +0 -0
  147. /package/src/client/dist/spa/assets/{fsharp-CFNadkg7.js → fsharp-BaeLhgfq.js} +0 -0
  148. /package/src/client/dist/spa/assets/{go-dSur1iB2.js → go-Bd-NFKIC.js} +0 -0
  149. /package/src/client/dist/spa/assets/{graphql-qyhAo11d.js → graphql-DZVerJfy.js} +0 -0
  150. /package/src/client/dist/spa/assets/{hcl-DFzjMyzm.js → hcl-CAVzrZfH.js} +0 -0
  151. /package/src/client/dist/spa/assets/{ini-TdzA8TIl.js → ini-CyXdX58t.js} +0 -0
  152. /package/src/client/dist/spa/assets/{is-DUKatk8N.js → is-BbsvEMaT.js} +0 -0
  153. /package/src/client/dist/spa/assets/{java-CSGA9pkE.js → java-B5pNgvhy.js} +0 -0
  154. /package/src/client/dist/spa/assets/{julia-9izz5OsY.js → julia-XRhmV3AN.js} +0 -0
  155. /package/src/client/dist/spa/assets/{kotlin-DuPK7AtF.js → kotlin-DOd3J5vr.js} +0 -0
  156. /package/src/client/dist/spa/assets/{less-B8d93iCg.js → less-veZSnyw6.js} +0 -0
  157. /package/src/client/dist/spa/assets/{lexon-DWtEIyu7.js → lexon-QWGkuK0H.js} +0 -0
  158. /package/src/client/dist/spa/assets/{lua-Ciq0OGgt.js → lua-CYGpjuO5.js} +0 -0
  159. /package/src/client/dist/spa/assets/{m3-Cki6JWj_.js → m3-yNnrZkdc.js} +0 -0
  160. /package/src/client/dist/spa/assets/{markdown-Cu47xwU0.js → markdown-BCSWEPSX.js} +0 -0
  161. /package/src/client/dist/spa/assets/{mips-BM8ui995.js → mips-OpYmcC30.js} +0 -0
  162. /package/src/client/dist/spa/assets/{msdax-DqLio0_c.js → msdax-2oxoTO9Z.js} +0 -0
  163. /package/src/client/dist/spa/assets/{mysql-v1wbjJOq.js → mysql-5KlC-K_9.js} +0 -0
  164. /package/src/client/dist/spa/assets/{objective-c-CQl3PGSB.js → objective-c-CcDCgtLx.js} +0 -0
  165. /package/src/client/dist/spa/assets/{pascal-D4iW0ZtD.js → pascal-BZGsbaEV.js} +0 -0
  166. /package/src/client/dist/spa/assets/{pascaligo-BdC9CZdj.js → pascaligo-DtD5qU3G.js} +0 -0
  167. /package/src/client/dist/spa/assets/{perl-BL10m4XD.js → perl-C1jNNS3E.js} +0 -0
  168. /package/src/client/dist/spa/assets/{pgsql-Be_oqVo3.js → pgsql-CT0fhiZa.js} +0 -0
  169. /package/src/client/dist/spa/assets/{php-BtvXSFRI.js → php-D6DrXoPM.js} +0 -0
  170. /package/src/client/dist/spa/assets/{pla-B2vUy15C.js → pla-b3-HN2pF.js} +0 -0
  171. /package/src/client/dist/spa/assets/{postiats-CbmTTfXr.js → postiats-Bin2ApVS.js} +0 -0
  172. /package/src/client/dist/spa/assets/{powerquery-DszLhJGx.js → powerquery-7ASnn-ZG.js} +0 -0
  173. /package/src/client/dist/spa/assets/{powershell-B0dYktF6.js → powershell-t4p7sU1H.js} +0 -0
  174. /package/src/client/dist/spa/assets/{protobuf-CZvaj1VX.js → protobuf-BUGeWa_j.js} +0 -0
  175. /package/src/client/dist/spa/assets/{pug-CPDx1B3S.js → pug-BuKcgC9s.js} +0 -0
  176. /package/src/client/dist/spa/assets/{qsharp-CDP9TFLl.js → qsharp-DSMtI_O7.js} +0 -0
  177. /package/src/client/dist/spa/assets/{r-8DbbFX2l.js → r-DMlFgn7A.js} +0 -0
  178. /package/src/client/dist/spa/assets/{redis-DRWj9MtJ.js → redis-cXItkC5u.js} +0 -0
  179. /package/src/client/dist/spa/assets/{redshift-C6cElE_5.js → redshift-BZVbW7HE.js} +0 -0
  180. /package/src/client/dist/spa/assets/{restructuredtext-W9pS9n3m.js → restructuredtext-BzjxwS8h.js} +0 -0
  181. /package/src/client/dist/spa/assets/{ruby-BKnzWnk-.js → ruby-C5nyLV4l.js} +0 -0
  182. /package/src/client/dist/spa/assets/{rust-YPCclWwe.js → rust-BcmMsHdf.js} +0 -0
  183. /package/src/client/dist/spa/assets/{sb-BgM4DTFb.js → sb-Dnb1iy6B.js} +0 -0
  184. /package/src/client/dist/spa/assets/{scala-fz1OPLMl.js → scala-anMIFYpA.js} +0 -0
  185. /package/src/client/dist/spa/assets/{scheme-8Uz1RIbu.js → scheme-BItQTe08.js} +0 -0
  186. /package/src/client/dist/spa/assets/{scss-Djo3IYXr.js → scss-BOv51BJ5.js} +0 -0
  187. /package/src/client/dist/spa/assets/{shell-CINF5Tx_.js → shell-BsRYRTNN.js} +0 -0
  188. /package/src/client/dist/spa/assets/{solidity-GgiNEuUm.js → solidity-BtuLgGDx.js} +0 -0
  189. /package/src/client/dist/spa/assets/{sophia-Culj97P9.js → sophia-B0Vkc5MF.js} +0 -0
  190. /package/src/client/dist/spa/assets/{sparql-C2ZlpxOY.js → sparql-B7lvkZQM.js} +0 -0
  191. /package/src/client/dist/spa/assets/{sql-BEf5Pg7Y.js → sql-DvP5MpA3.js} +0 -0
  192. /package/src/client/dist/spa/assets/{st-CT6UUoeH.js → st-GVUeyB3U.js} +0 -0
  193. /package/src/client/dist/spa/assets/{swift-B5g0xTG3.js → swift-DSPIoCjm.js} +0 -0
  194. /package/src/client/dist/spa/assets/{systemverilog-CEgQz9DR.js → systemverilog-Icj2-k23.js} +0 -0
  195. /package/src/client/dist/spa/assets/{tcl-D0qL2L0I.js → tcl-Cd8KQcm-.js} +0 -0
  196. /package/src/client/dist/spa/assets/{twig-BFUAVf1E.js → twig-CBHmt8z3.js} +0 -0
  197. /package/src/client/dist/spa/assets/{typespec-CjVVcNKm.js → typespec-Ckc037mq.js} +0 -0
  198. /package/src/client/dist/spa/assets/{vb-CZJr-DQz.js → vb-B97GW9Wb.js} +0 -0
  199. /package/src/client/dist/spa/assets/{vue-i18n-BJlZEYnA.js → vue-i18n-eUDnMrPl.js} +0 -0
  200. /package/src/client/dist/spa/assets/{wgsl-ivoXUo2e.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
- if (pid === undefined || isProcessAlive(pid))
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 === 0)
115
- return;
116
- const now = new Date().toISOString();
117
- const update = db.prepare("UPDATE agent_sessions SET status = 'error', ended_at = ? WHERE id = ?");
118
- let fixed = 0;
119
- for (const row of rows) {
120
- if (row.pid && isProcessAlive(row.pid))
121
- continue; // genuine leftover from a graceful restart — skip
122
- update.run(now, row.id);
123
- fixed++;
124
- }
125
- if (fixed > 0) {
126
- console.log(`[orchestrator] Reconciled ${fixed} orphan agent_sessions row(s) at boot.`);
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
- const db = getDb();
265
- const row = db
266
- .prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
267
- .get(workspaceId, 'done');
268
- return row.c;
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
- wakeupService.schedule(workspaceId, delay, prompt, reason);
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
- // Internal cleanup REMOVES the controller from the map. This must run
332
- // BEFORE autoLoopService.onSessionEnded → spawnNextIteration → startAgent,
333
- // otherwise startAgent throws "Agent already running" because the
334
- // just-ended controller is still in the map.
335
- onSessionEnded(workspaceId, agentSessionId, ev.exitCode, isResumeFailed);
336
- // When a resume failed the session exited with an error but there's
337
- // nothing wrong with the workspacethe stale session ID has been cleared
338
- // and the next iteration will start fresh. Treat it as 'completed' so
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
- // The workspace must be in an active status while the agent is
353
- // running — otherwise the frontend's `sessionActive` check stays
354
- // false and streaming messages render without the "typing" spinner.
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
- if (exitCode !== null && exitCode !== 0 && !resumeFailed) {
418
- try {
419
- updateWorkspaceStatus(workspaceId, 'error');
420
- }
421
- catch (err) {
422
- console.error('[orchestrator] Failed to update workspace status on exit:', err);
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
- else {
433
- try {
434
- updateWorkspaceStatus(workspaceId, 'completed');
435
- }
436
- catch (err) {
437
- console.error('[orchestrator] Failed to update workspace status on exit:', err);
438
- }
439
- try {
440
- markWorkspaceUnread(workspaceId);
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, permissionMode = 'auto-accept', existingSessionId, reasoningEffort) {
456
- if (controllers.has(workspaceId)) {
457
- throw new Error(`Agent already running for workspace '${workspaceId}'`);
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
- permissionMode,
483
- // Propagate the workspace's permission_profile to the engine (bypass vs
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
- // Remove from the map immediately so startAgent can proceed right away.
561
- // The session:ended handler checks identity before removing, so a new
562
- // controller started in the meantime is preserved.
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;