@loicngr/kobo 1.6.8 → 1.6.10

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 (168) hide show
  1. package/AGENTS.md +4 -1
  2. package/README.md +4 -1
  3. package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
  4. package/dist/mcp-server/kobo-tasks-server.js +27 -1
  5. package/dist/server/db/migrations.js +22 -0
  6. package/dist/server/db/schema.js +4 -0
  7. package/dist/server/index.js +2 -0
  8. package/dist/server/routes/images.js +59 -0
  9. package/dist/server/routes/workspaces.js +211 -21
  10. package/dist/server/services/agent/engines/claude-code/args-builder.js +6 -0
  11. package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
  12. package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
  13. package/dist/server/services/agent/orchestrator.js +171 -18
  14. package/dist/server/services/auto-loop-service.js +311 -0
  15. package/dist/server/services/workspace-service.js +47 -0
  16. package/dist/server/utils/git-ops.js +13 -4
  17. package/dist/shared/auto-loop-prompts.js +28 -0
  18. package/package.json +1 -1
  19. package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-DtM6pJvz.css} +1 -1
  20. package/src/client/dist/spa/assets/ActivityFeed-jxfDBgtk.js +7 -0
  21. package/src/client/dist/spa/assets/{ClosePopup-DTgXzcoa.js → ClosePopup-DkLittac.js} +1 -1
  22. package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
  23. package/src/client/dist/spa/assets/CreatePage-uBHjVyx5.js +2 -0
  24. package/src/client/dist/spa/assets/DiffViewer-B5spOKjh.js +2 -0
  25. package/src/client/dist/spa/assets/{HealthPage-BNv_dnMz.js → HealthPage-DnUDXD7f.js} +1 -1
  26. package/src/client/dist/spa/assets/MainLayout-CDR4Le5c.css +1 -0
  27. package/src/client/dist/spa/assets/{MainLayout-NzuypipH.js → MainLayout-Cu2p6Yzp.js} +17 -17
  28. package/src/client/dist/spa/assets/QChip-bl3YRhax.js +1 -0
  29. package/src/client/dist/spa/assets/{QExpansionItem-HLBjHx-0.js → QExpansionItem-CWw6ZujM.js} +1 -1
  30. package/src/client/dist/spa/assets/{QItemSection-BzWLL-V-.js → QItemSection-CiY_LK5Y.js} +1 -1
  31. package/src/client/dist/spa/assets/{QScrollArea-CBW6shMb.js → QScrollArea-DpCqRRE0.js} +1 -1
  32. package/src/client/dist/spa/assets/QTabPanels-C4bZGqml.js +1 -0
  33. package/src/client/dist/spa/assets/{QTooltip-DbEBexRN.js → QTooltip-BIDjo2hJ.js} +1 -1
  34. package/src/client/dist/spa/assets/{SearchPage-B3m_OWli.js → SearchPage-BL03e4yO.js} +1 -1
  35. package/src/client/dist/spa/assets/SettingsPage-DODqugln.js +1 -0
  36. package/src/client/dist/spa/assets/{TouchPan-Y_Bxzun2.js → TouchPan-vsl78kxF.js} +1 -1
  37. package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
  38. package/src/client/dist/spa/assets/WorkspacePage-CvR1wkIu.js +4 -0
  39. package/src/client/dist/spa/assets/{build-path-tree-DOPXkGhj.js → build-path-tree-BOfvTwdg.js} +1 -1
  40. package/src/client/dist/spa/assets/{cssMode-BPObkLMQ.js → cssMode-CoOgcS9Q.js} +1 -1
  41. package/src/client/dist/spa/assets/{documents-DMvdjtPf.js → documents-Capxg1Is.js} +1 -1
  42. package/src/client/dist/spa/assets/{editor.api-BpCtstKS.js → editor.api-BXQZAhGS.js} +1 -1
  43. package/src/client/dist/spa/assets/{editor.main-C2h6FfOt.js → editor.main-DFavPtYi.js} +3 -3
  44. package/src/client/dist/spa/assets/{formatters-D7eTm7uK.js → formatters-CX2gvLFv.js} +1 -1
  45. package/src/client/dist/spa/assets/{freemarker2-DUmHGv4C.js → freemarker2-CxnHsTrj.js} +1 -1
  46. package/src/client/dist/spa/assets/{handlebars-BU6pjzPg.js → handlebars-MdkEOy37.js} +1 -1
  47. package/src/client/dist/spa/assets/{html-A5-15bWl.js → html-BWqDGW4J.js} +1 -1
  48. package/src/client/dist/spa/assets/{htmlMode-C3KkomG3.js → htmlMode-CO3tFPX5.js} +1 -1
  49. package/src/client/dist/spa/assets/i18n-BshFP-3_.js +1 -0
  50. package/src/client/dist/spa/assets/index-ljurK0Xv.js +2 -0
  51. package/src/client/dist/spa/assets/is-DUKatk8N.js +1 -0
  52. package/src/client/dist/spa/assets/{javascript-ggaOKiy5.js → javascript-I8UtlP5w.js} +1 -1
  53. package/src/client/dist/spa/assets/{jsonMode-Bk-QMPGJ.js → jsonMode-Z4_dv7Ex.js} +1 -1
  54. package/src/client/dist/spa/assets/{liquid-CJdzn-JB.js → liquid-MmYIYsxN.js} +1 -1
  55. package/src/client/dist/spa/assets/{mdx-D5wRO-st.js → mdx-05Yi5ibq.js} +1 -1
  56. package/src/client/dist/spa/assets/models-BWwzb9Qz.js +1 -0
  57. package/src/client/dist/spa/assets/{monaco.contribution-CPqJifAu.js → monaco.contribution-BcmbPJhi.js} +2 -2
  58. package/src/client/dist/spa/assets/{python-DHI9rQDm.js → python-DApFIC6r.js} +1 -1
  59. package/src/client/dist/spa/assets/rate-limit-labels-BeAbIcPH.js +10 -0
  60. package/src/client/dist/spa/assets/{razor-CzQWNzhW.js → razor-IqeohLNL.js} +1 -1
  61. package/src/client/dist/spa/assets/{scroll-C-Vz5BD9.js → scroll-CYWyxBdv.js} +1 -1
  62. package/src/client/dist/spa/assets/settings-CAILUJXO.js +1 -0
  63. package/src/client/dist/spa/assets/{tsMode-DPkpdkNr.js → tsMode-B6nLj3Ks.js} +1 -1
  64. package/src/client/dist/spa/assets/{typescript-Dgm0x_-O.js → typescript-DHsUK_D5.js} +1 -1
  65. package/src/client/dist/spa/assets/{use-checkbox-BduGd8xg.js → use-checkbox-B_o-iLG2.js} +1 -1
  66. package/src/client/dist/spa/assets/{xml-BeXyffrj.js → xml-B_o_LoiA.js} +1 -1
  67. package/src/client/dist/spa/assets/{yaml-D4UE_1wU.js → yaml-mPCNKMRE.js} +1 -1
  68. package/src/client/dist/spa/index.html +9 -8
  69. package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
  70. package/src/mcp-server/kobo-tasks-server.ts +29 -0
  71. package/src/client/dist/spa/assets/ActivityFeed-Bn9tpyLw.js +0 -7
  72. package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
  73. package/src/client/dist/spa/assets/CreatePage-DDPmb3I-.js +0 -2
  74. package/src/client/dist/spa/assets/DiffViewer-CM3g7W7U.js +0 -2
  75. package/src/client/dist/spa/assets/MainLayout-BeKCjOA2.css +0 -1
  76. package/src/client/dist/spa/assets/QChip-1nQ_KMFF.js +0 -1
  77. package/src/client/dist/spa/assets/QDialog-G448EJG4.js +0 -1
  78. package/src/client/dist/spa/assets/QTabPanels-Cw4nnIbR.js +0 -1
  79. package/src/client/dist/spa/assets/SettingsPage-CpQm15XA.js +0 -1
  80. package/src/client/dist/spa/assets/WorkspacePage-BQzk5qfr.js +0 -4
  81. package/src/client/dist/spa/assets/i18n-CIduhxS0.js +0 -1
  82. package/src/client/dist/spa/assets/index-QcUb2Iwh.js +0 -2
  83. package/src/client/dist/spa/assets/models-CwWSex3X.js +0 -1
  84. package/src/client/dist/spa/assets/rate-limit-labels-Su-L56A2.js +0 -6
  85. /package/src/client/dist/spa/assets/{QBadge-Di02fu2H.js → QBadge-DqtcDv8D.js} +0 -0
  86. /package/src/client/dist/spa/assets/{QBtn-CyzfM9-_.js → QBtn-DHwAb18J.js} +0 -0
  87. /package/src/client/dist/spa/assets/{QItemLabel-Czw5g0px.js → QItemLabel-Codqjisk.js} +0 -0
  88. /package/src/client/dist/spa/assets/{QList-D2GuTeLl.js → QList-Bl9824vi.js} +0 -0
  89. /package/src/client/dist/spa/assets/{QPage-BTzNQlb1.js → QPage-Dn4E3GHB.js} +0 -0
  90. /package/src/client/dist/spa/assets/{QSlideTransition-s6ZkYsLs.js → QSlideTransition-BQxI8l5r.js} +0 -0
  91. /package/src/client/dist/spa/assets/{QSpace-0zdF1m5x.js → QSpace-BNr0AftG.js} +0 -0
  92. /package/src/client/dist/spa/assets/{QSpinnerDots-By20ptst.js → QSpinnerDots-DEiRooBD.js} +0 -0
  93. /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cj6tcsj6.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
  94. /package/src/client/dist/spa/assets/{abap-DiwvWnMr.js → abap-Bgec7Keq.js} +0 -0
  95. /package/src/client/dist/spa/assets/{apex-CmtZjKlf.js → apex-VBlPwEoQ.js} +0 -0
  96. /package/src/client/dist/spa/assets/{azcli-DL2My_i-.js → azcli-DKqrEFBx.js} +0 -0
  97. /package/src/client/dist/spa/assets/{bat-B-nC98wG.js → bat-DdgQWy_0.js} +0 -0
  98. /package/src/client/dist/spa/assets/{bicep-Ju5MwOgh.js → bicep-CRMM43EB.js} +0 -0
  99. /package/src/client/dist/spa/assets/{cameligo-8Eu1TyBr.js → cameligo-UatALtML.js} +0 -0
  100. /package/src/client/dist/spa/assets/{clojure-u-RpMkH3.js → clojure-D8JU08RA.js} +0 -0
  101. /package/src/client/dist/spa/assets/{coffee-CdA7bbTe.js → coffee-C56wu358.js} +0 -0
  102. /package/src/client/dist/spa/assets/{cpp-CzNFP8ks.js → cpp-CyZLvhJG.js} +0 -0
  103. /package/src/client/dist/spa/assets/{csharp-j1LThmcE.js → csharp-BJl3ixva.js} +0 -0
  104. /package/src/client/dist/spa/assets/{csp-CLRC61y6.js → csp-CxEKxmO-.js} +0 -0
  105. /package/src/client/dist/spa/assets/{css-r6rC_7P2.js → css-B0t_muXd.js} +0 -0
  106. /package/src/client/dist/spa/assets/{cypher-CW08XVUh.js → cypher-D1hqiMFD.js} +0 -0
  107. /package/src/client/dist/spa/assets/{dart-Cs9aL5T_.js → dart-Bz550Pyv.js} +0 -0
  108. /package/src/client/dist/spa/assets/{dockerfile-BWM0M184.js → dockerfile-CIXgVAuA.js} +0 -0
  109. /package/src/client/dist/spa/assets/{ecl-MJJuer5P.js → ecl-D9qbvZoA.js} +0 -0
  110. /package/src/client/dist/spa/assets/{elixir-D2AIuXqn.js → elixir-b2M38fAy.js} +0 -0
  111. /package/src/client/dist/spa/assets/{flow9-B2H24giC.js → flow9-Dq1UYMkt.js} +0 -0
  112. /package/src/client/dist/spa/assets/{fsharp-CMk2OIJN.js → fsharp-CFNadkg7.js} +0 -0
  113. /package/src/client/dist/spa/assets/{go-BrMkuJg0.js → go-dSur1iB2.js} +0 -0
  114. /package/src/client/dist/spa/assets/{graphql-PSR1UKGv.js → graphql-qyhAo11d.js} +0 -0
  115. /package/src/client/dist/spa/assets/{hcl-DAQrbDOW.js → hcl-DFzjMyzm.js} +0 -0
  116. /package/src/client/dist/spa/assets/{ini-0TG5BxW0.js → ini-TdzA8TIl.js} +0 -0
  117. /package/src/client/dist/spa/assets/{java-rgorz17v.js → java-CSGA9pkE.js} +0 -0
  118. /package/src/client/dist/spa/assets/{julia-C8VMdHm8.js → julia-9izz5OsY.js} +0 -0
  119. /package/src/client/dist/spa/assets/{kotlin-CllWo3gX.js → kotlin-DuPK7AtF.js} +0 -0
  120. /package/src/client/dist/spa/assets/{less-Cgca25AP.js → less-B8d93iCg.js} +0 -0
  121. /package/src/client/dist/spa/assets/{lexon-D0GHdBaw.js → lexon-DWtEIyu7.js} +0 -0
  122. /package/src/client/dist/spa/assets/{lua-DmRsNG-P.js → lua-Ciq0OGgt.js} +0 -0
  123. /package/src/client/dist/spa/assets/{m3-BgL5dNKT.js → m3-Cki6JWj_.js} +0 -0
  124. /package/src/client/dist/spa/assets/{markdown-BuJfycGS.js → markdown-Cu47xwU0.js} +0 -0
  125. /package/src/client/dist/spa/assets/{mips-C9m_93PR.js → mips-BM8ui995.js} +0 -0
  126. /package/src/client/dist/spa/assets/{msdax-CpFHC9OI.js → msdax-DqLio0_c.js} +0 -0
  127. /package/src/client/dist/spa/assets/{mysql-qFvltsqN.js → mysql-v1wbjJOq.js} +0 -0
  128. /package/src/client/dist/spa/assets/{objective-c-Bnmr858J.js → objective-c-CQl3PGSB.js} +0 -0
  129. /package/src/client/dist/spa/assets/{pascal-WP0_D5AO.js → pascal-D4iW0ZtD.js} +0 -0
  130. /package/src/client/dist/spa/assets/{pascaligo-Blom4Rij.js → pascaligo-BdC9CZdj.js} +0 -0
  131. /package/src/client/dist/spa/assets/{perl-B-vk8g64.js → perl-BL10m4XD.js} +0 -0
  132. /package/src/client/dist/spa/assets/{pgsql-Cgvz6v67.js → pgsql-Be_oqVo3.js} +0 -0
  133. /package/src/client/dist/spa/assets/{php-8a3Lrw9m.js → php-BtvXSFRI.js} +0 -0
  134. /package/src/client/dist/spa/assets/{pla-DuFqEZ8V.js → pla-B2vUy15C.js} +0 -0
  135. /package/src/client/dist/spa/assets/{postiats-DkLtSgkp.js → postiats-CbmTTfXr.js} +0 -0
  136. /package/src/client/dist/spa/assets/{powerquery-BJ1aNepW.js → powerquery-DszLhJGx.js} +0 -0
  137. /package/src/client/dist/spa/assets/{powershell-rE98k687.js → powershell-B0dYktF6.js} +0 -0
  138. /package/src/client/dist/spa/assets/{private.use-form-C5G_3nU5.js → private.use-form-Dlb0iQZh.js} +0 -0
  139. /package/src/client/dist/spa/assets/{protobuf-CUheFacr.js → protobuf-CZvaj1VX.js} +0 -0
  140. /package/src/client/dist/spa/assets/{pug-LDcAMD8w.js → pug-CPDx1B3S.js} +0 -0
  141. /package/src/client/dist/spa/assets/{qsharp-DUKSQoR1.js → qsharp-CDP9TFLl.js} +0 -0
  142. /package/src/client/dist/spa/assets/{r-D-QApv87.js → r-8DbbFX2l.js} +0 -0
  143. /package/src/client/dist/spa/assets/{redis-SXdDyWR9.js → redis-DRWj9MtJ.js} +0 -0
  144. /package/src/client/dist/spa/assets/{redshift-Y6lsCryn.js → redshift-C6cElE_5.js} +0 -0
  145. /package/src/client/dist/spa/assets/{restructuredtext-edObr9a8.js → restructuredtext-W9pS9n3m.js} +0 -0
  146. /package/src/client/dist/spa/assets/{ruby-CNnUfF-8.js → ruby-BKnzWnk-.js} +0 -0
  147. /package/src/client/dist/spa/assets/{rust-IHUZWzBr.js → rust-YPCclWwe.js} +0 -0
  148. /package/src/client/dist/spa/assets/{sb-DrUvY44N.js → sb-BgM4DTFb.js} +0 -0
  149. /package/src/client/dist/spa/assets/{scala-B4hbXGLM.js → scala-fz1OPLMl.js} +0 -0
  150. /package/src/client/dist/spa/assets/{scheme-BGrd12j3.js → scheme-8Uz1RIbu.js} +0 -0
  151. /package/src/client/dist/spa/assets/{scss-x5G1ES4U.js → scss-Djo3IYXr.js} +0 -0
  152. /package/src/client/dist/spa/assets/{shell-DOehe2Y8.js → shell-CINF5Tx_.js} +0 -0
  153. /package/src/client/dist/spa/assets/{solidity-BeRvcwWV.js → solidity-GgiNEuUm.js} +0 -0
  154. /package/src/client/dist/spa/assets/{sophia-DZbkUNjy.js → sophia-Culj97P9.js} +0 -0
  155. /package/src/client/dist/spa/assets/{sparql-B7_oi5-h.js → sparql-C2ZlpxOY.js} +0 -0
  156. /package/src/client/dist/spa/assets/{sql-CTlsFWVE.js → sql-BEf5Pg7Y.js} +0 -0
  157. /package/src/client/dist/spa/assets/{st-DJVEJdPE.js → st-CT6UUoeH.js} +0 -0
  158. /package/src/client/dist/spa/assets/{swift-CwhT3fYa.js → swift-B5g0xTG3.js} +0 -0
  159. /package/src/client/dist/spa/assets/{systemverilog-BQN63pkN.js → systemverilog-CEgQz9DR.js} +0 -0
  160. /package/src/client/dist/spa/assets/{tcl-DqwfpskA.js → tcl-D0qL2L0I.js} +0 -0
  161. /package/src/client/dist/spa/assets/{touch-B2uuAH_y.js → touch-Bj_Fr4kC.js} +0 -0
  162. /package/src/client/dist/spa/assets/{twig-BiyenUgc.js → twig-BFUAVf1E.js} +0 -0
  163. /package/src/client/dist/spa/assets/{typespec-CWOJribt.js → typespec-CjVVcNKm.js} +0 -0
  164. /package/src/client/dist/spa/assets/{use-id-BmXMngYX.js → use-id-C93QQwrt.js} +0 -0
  165. /package/src/client/dist/spa/assets/{use-quasar-BBrzedjR.js → use-quasar-Cc4smfg5.js} +0 -0
  166. /package/src/client/dist/spa/assets/{vb-Cq5F87m3.js → vb-CZJr-DQz.js} +0 -0
  167. /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BJlZEYnA.js} +0 -0
  168. /package/src/client/dist/spa/assets/{wgsl-BAvW2lVr.js → wgsl-ivoXUo2e.js} +0 -0
@@ -21,6 +21,7 @@ export function createClaudeCodeEngine() {
21
21
  effort: options.effort,
22
22
  permissionMode: options.permissionMode ?? 'auto-accept',
23
23
  skipPermissions: options.settings.dangerouslySkipPermissions ?? true,
24
+ permissionProfile: options.permissionProfile,
24
25
  resumeFromEngineSessionId: options.resumeFromEngineSessionId,
25
26
  mcpConfigPath,
26
27
  });
@@ -70,9 +71,14 @@ export function createClaudeCodeEngine() {
70
71
  lower.includes('rate_limit_exceeded') ||
71
72
  (lower.includes('429') && lower.includes('rate')) ||
72
73
  lower.includes('quota exceeded');
74
+ const isResumeFailed = lower.includes('no conversation found with session id');
73
75
  if (isQuota) {
74
76
  onEvent({ kind: 'error', category: 'quota', message: line });
75
77
  }
78
+ else if (isResumeFailed) {
79
+ onEvent({ kind: 'error', category: 'resume_failed', message: line });
80
+ console.warn(`[claude-engine stderr] ${line}`);
81
+ }
76
82
  else if (line.trim().length > 0 && !isBenignStderr(line)) {
77
83
  console.warn(`[claude-engine stderr] ${line}`);
78
84
  }
@@ -26,6 +26,161 @@ function makeBucket(id, source) {
26
26
  const details = used !== undefined && limit !== undefined ? `${String(used)} / ${String(limit)}` : undefined;
27
27
  return { id, label, usedPct: Math.max(0, Math.min(100, usedPct)), resetsAt, details };
28
28
  }
29
+ // ── Text-based quota detection ────────────────────────────────────────────────
30
+ // When the `claude -p` CLI hits a rate limit mid-turn, it often does NOT emit
31
+ // a structured `rate_limit_event` nor an error on stderr — it just writes a
32
+ // plain-text message like:
33
+ // "You've hit your limit · resets 1:20pm (Europe/Paris)"
34
+ // and exits cleanly. Without the pattern below we'd see `session:ended` with
35
+ // `reason: 'completed'` and the auto-loop would spawn the next iteration
36
+ // immediately, only to hit the same wall again, N times, until stall kicks in.
37
+ //
38
+ // The regex captures the reset time (e.g. "1:20pm") and the timezone in
39
+ // parentheses (e.g. "Europe/Paris") so we can convert to an ISO 8601
40
+ // timestamp and feed handleQuota the same way a structured event would.
41
+ const QUOTA_TEXT_PATTERN = /you(?:'ve|\s+have)\s+hit\s+your\s+limit[^.\n]*?resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
42
+ function extractZonedDateTimeParts(date, timeZone) {
43
+ try {
44
+ const formatter = new Intl.DateTimeFormat('en-CA', {
45
+ timeZone,
46
+ year: 'numeric',
47
+ month: '2-digit',
48
+ day: '2-digit',
49
+ hour: '2-digit',
50
+ minute: '2-digit',
51
+ second: '2-digit',
52
+ hour12: false,
53
+ });
54
+ const parts = formatter.formatToParts(date);
55
+ const read = (type) => {
56
+ const value = parts.find((part) => part.type === type)?.value;
57
+ if (!value)
58
+ return null;
59
+ const parsed = Number.parseInt(value, 10);
60
+ return Number.isNaN(parsed) ? null : parsed;
61
+ };
62
+ const year = read('year');
63
+ const month = read('month');
64
+ const day = read('day');
65
+ const hour = read('hour');
66
+ const minute = read('minute');
67
+ const second = read('second');
68
+ if ([year, month, day, hour, minute, second].some((value) => value === null))
69
+ return null;
70
+ return {
71
+ year: year,
72
+ month: month,
73
+ day: day,
74
+ hour: hour,
75
+ minute: minute,
76
+ second: second,
77
+ };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function dateTimePartsToUtcMs(parts) {
84
+ return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
85
+ }
86
+ function zonedDateTimeToUtc(parts, timeZone) {
87
+ let guess = new Date(dateTimePartsToUtcMs(parts));
88
+ for (let i = 0; i < 4; i += 1) {
89
+ const observed = extractZonedDateTimeParts(guess, timeZone);
90
+ if (!observed)
91
+ return undefined;
92
+ const diffMs = dateTimePartsToUtcMs(parts) - dateTimePartsToUtcMs(observed);
93
+ if (diffMs === 0)
94
+ return guess.toISOString();
95
+ guess = new Date(guess.getTime() + diffMs);
96
+ }
97
+ const observed = extractZonedDateTimeParts(guess, timeZone);
98
+ if (observed &&
99
+ observed.year === parts.year &&
100
+ observed.month === parts.month &&
101
+ observed.day === parts.day &&
102
+ observed.hour === parts.hour &&
103
+ observed.minute === parts.minute) {
104
+ return guess.toISOString();
105
+ }
106
+ return undefined;
107
+ }
108
+ /**
109
+ * Parse the time string Claude outputs (e.g. "1:20pm" + "Europe/Paris")
110
+ * into an ISO 8601 UTC timestamp. Returns `undefined` if anything looks
111
+ * off so handleQuota falls back to its ladder.
112
+ */
113
+ function parseQuotaResetsAt(hourStr, minuteStr, ampm, tz, now = new Date()) {
114
+ const hour24 = (() => {
115
+ const h = Number.parseInt(hourStr, 10);
116
+ if (Number.isNaN(h) || h < 0 || h > 23)
117
+ return null;
118
+ if (!ampm)
119
+ return h;
120
+ const pm = ampm.toLowerCase() === 'pm';
121
+ if (h === 12)
122
+ return pm ? 12 : 0;
123
+ return pm ? h + 12 : h;
124
+ })();
125
+ if (hour24 === null)
126
+ return undefined;
127
+ const minute = minuteStr ? Number.parseInt(minuteStr, 10) : 0;
128
+ if (Number.isNaN(minute) || minute < 0 || minute > 59)
129
+ return undefined;
130
+ if (tz) {
131
+ const zonedNow = extractZonedDateTimeParts(now, tz);
132
+ if (zonedNow) {
133
+ const targetDate = new Date(Date.UTC(zonedNow.year, zonedNow.month - 1, zonedNow.day));
134
+ const nowInZoneMs = dateTimePartsToUtcMs(zonedNow);
135
+ const targetTodayMs = Date.UTC(zonedNow.year, zonedNow.month - 1, zonedNow.day, hour24, minute, 0, 0);
136
+ if (targetTodayMs < nowInZoneMs - 60_000) {
137
+ targetDate.setUTCDate(targetDate.getUTCDate() + 1);
138
+ }
139
+ const zonedIso = zonedDateTimeToUtc({
140
+ year: targetDate.getUTCFullYear(),
141
+ month: targetDate.getUTCMonth() + 1,
142
+ day: targetDate.getUTCDate(),
143
+ hour: hour24,
144
+ minute,
145
+ second: 0,
146
+ }, tz);
147
+ if (zonedIso)
148
+ return zonedIso;
149
+ }
150
+ }
151
+ // Fallback for missing/invalid timezone names: interpret the wall-clock time
152
+ // in the local machine timezone so quota handling still works on plain
153
+ // "resets 11am" messages.
154
+ const target = new Date(now);
155
+ target.setHours(hour24, minute, 0, 0);
156
+ if (target.getTime() < now.getTime() - 60_000) {
157
+ target.setDate(target.getDate() + 1);
158
+ }
159
+ return target.toISOString();
160
+ }
161
+ /**
162
+ * Scan a text block for a quota announcement. Returns synthetic
163
+ * `rate_limit` + `error { category: 'quota' }` events when the pattern
164
+ * matches, otherwise `null`. The caller pushes both events into the
165
+ * stream so handleQuota picks up the reset time and schedules the
166
+ * retry at the parsed moment.
167
+ */
168
+ function extractQuotaFromText(text) {
169
+ const match = QUOTA_TEXT_PATTERN.exec(text);
170
+ if (!match)
171
+ return null;
172
+ const [, hourStr, minuteStr, ampm, tz] = match;
173
+ const resetsAt = parseQuotaResetsAt(hourStr, minuteStr, ampm, tz);
174
+ const bucket = {
175
+ id: 'text-detected',
176
+ usedPct: 100,
177
+ resetsAt,
178
+ };
179
+ return {
180
+ error: { kind: 'error', category: 'quota', message: match[0] },
181
+ rateLimit: { kind: 'rate_limit', info: { buckets: [bucket] } },
182
+ };
183
+ }
29
184
  function normalizeRateLimitInfo(info) {
30
185
  const buckets = [];
31
186
  if (typeof info.rateLimitType === 'string') {
@@ -149,6 +304,13 @@ export function parseClaudeLine(line, state) {
149
304
  if (blockType === 'text' && typeof block.text === 'string') {
150
305
  events.push({ kind: 'message:text', messageId, text: block.text, streaming: true });
151
306
  msgState.sawText = true;
307
+ // Synthesise quota events when the CLI reports the limit in prose.
308
+ // See extractQuotaFromText above for why this is needed in `-p` mode.
309
+ const synth = extractQuotaFromText(block.text);
310
+ if (synth) {
311
+ events.push(synth.rateLimit);
312
+ events.push(synth.error);
313
+ }
152
314
  }
153
315
  if (blockType === 'tool_use') {
154
316
  events.push({
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../../db/index.js';
4
4
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
5
  import { unregisterProcess } from '../../utils/process-tracker.js';
6
+ import * as autoLoopService from '../auto-loop-service.js';
6
7
  import { getEffectiveSettings } from '../settings-service.js';
7
8
  import * as wakeupService from '../wakeup-service.js';
8
9
  import { emitEphemeral } from '../websocket-service.js';
@@ -33,6 +34,8 @@ let availableSkills = (() => {
33
34
  })();
34
35
  /** workspaceId -> retry count (for quota backoff) */
35
36
  const retryCounts = new Map();
37
+ /** Tracks workspaces where the current session failed due to a stale --resume session ID. */
38
+ const resumeFailedSet = new Set();
36
39
  /** workspaceId -> backoff timer */
37
40
  const backoffTimers = new Map();
38
41
  // ── Watchdog ──────────────────────────────────────────────────────────────────
@@ -251,8 +254,33 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
251
254
  return agentSessionId;
252
255
  }
253
256
  // ── Event handler ─────────────────────────────────────────────────────────────
257
+ /**
258
+ * Snapshot of the `tasks` done-count at `session:started`, read back and
259
+ * cleared at `session:ended` to compute the per-session delta. Used by
260
+ * `auto-loop-service.onSessionEnded` for stall detection.
261
+ */
262
+ const tasksDoneSnapshot = new Map();
263
+ 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;
269
+ }
270
+ /** Clear the in-memory done-count snapshot for a workspace (called on delete). */
271
+ export function forgetTasksDoneSnapshot(workspaceId) {
272
+ tasksDoneSnapshot.delete(workspaceId);
273
+ }
254
274
  function handleEvent(workspaceId, agentSessionId, ev) {
255
275
  routeEvent(workspaceId, agentSessionId, ev);
276
+ if (ev.kind === 'rate_limit') {
277
+ latestRateLimitInfo.set(workspaceId, ev.info);
278
+ }
279
+ // Snapshot the done-count at session start so the session:ended hook below
280
+ // can compute a delta for auto-loop stall detection.
281
+ if (ev.kind === 'session:started') {
282
+ tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
283
+ }
256
284
  if (ev.kind === 'tool:call' && ev.name === 'ScheduleWakeup') {
257
285
  const input = ev.input;
258
286
  const delay = typeof input?.delaySeconds === 'number' ? input.delaySeconds : 0;
@@ -274,7 +302,10 @@ function handleEvent(workspaceId, agentSessionId, ev) {
274
302
  }
275
303
  if (ev.kind === 'session:brainstorm-complete') {
276
304
  try {
277
- updateWorkspaceStatus(workspaceId, 'executing');
305
+ const ws = getWs(workspaceId);
306
+ if (ws && ws.status !== 'executing') {
307
+ updateWorkspaceStatus(workspaceId, 'executing');
308
+ }
278
309
  }
279
310
  catch (err) {
280
311
  console.error('[orchestrator] Failed to transition to executing:', err);
@@ -283,8 +314,31 @@ function handleEvent(workspaceId, agentSessionId, ev) {
283
314
  if (ev.kind === 'error' && ev.category === 'quota') {
284
315
  handleQuota(workspaceId, agentSessionId);
285
316
  }
317
+ if (ev.kind === 'error' && ev.category === 'resume_failed') {
318
+ resumeFailedSet.add(workspaceId);
319
+ clearStaleEngineSessionId(workspaceId);
320
+ }
286
321
  if (ev.kind === 'session:ended') {
287
- onSessionEnded(workspaceId, agentSessionId, ev.exitCode);
322
+ // Pop the resume_failed flag before any cleanup so both onSessionEnded paths see it.
323
+ 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
+ const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
328
+ const after = getDoneTaskCount(workspaceId);
329
+ const delta = Math.max(0, after - before);
330
+ 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 workspace — the 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.
340
+ const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
341
+ autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
288
342
  }
289
343
  if (ev.kind === 'session:started' && ev.engineSessionId) {
290
344
  sessionIds.set(workspaceId, ev.engineSessionId);
@@ -312,7 +366,9 @@ function handleEvent(workspaceId, agentSessionId, ev) {
312
366
  }
313
367
  }
314
368
  }
315
- function onSessionEnded(workspaceId, agentSessionId, exitCode) {
369
+ function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = false) {
370
+ const currentWorkspace = getWs(workspaceId);
371
+ const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
316
372
  const ctrl = controllers.get(workspaceId);
317
373
  const wasStopping = ctrl?.status === 'stopping';
318
374
  // Identity-preserving cleanup: only remove the controller if the map still
@@ -322,7 +378,9 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
322
378
  controllers.delete(workspaceId);
323
379
  }
324
380
  unregisterProcess(workspaceId);
325
- retryCounts.delete(workspaceId);
381
+ if (!preserveQuotaBackoff) {
382
+ retryCounts.delete(workspaceId);
383
+ }
326
384
  // Update the agent_sessions row
327
385
  try {
328
386
  const db = getDb();
@@ -336,13 +394,27 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
336
394
  // the "stopped" status. No legacy emit needed.
337
395
  return;
338
396
  }
339
- // Clear any pending backoff timer on non-stopping exits
340
- const pendingBackoff = backoffTimers.get(workspaceId);
341
- if (pendingBackoff) {
342
- clearTimeout(pendingBackoff);
343
- backoffTimers.delete(workspaceId);
397
+ // When the session hit quota, handleQuota() already transitioned the
398
+ // workspace to `quota` and armed the retry timer. Keep that timer alive
399
+ // and preserve the `quota` status so auto-loop can resume after reset.
400
+ if (!preserveQuotaBackoff) {
401
+ const pendingBackoff = backoffTimers.get(workspaceId);
402
+ if (pendingBackoff) {
403
+ clearTimeout(pendingBackoff);
404
+ backoffTimers.delete(workspaceId);
405
+ }
406
+ }
407
+ if (preserveQuotaBackoff) {
408
+ try {
409
+ markWorkspaceUnread(workspaceId);
410
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
411
+ }
412
+ catch {
413
+ // best-effort
414
+ }
415
+ return;
344
416
  }
345
- if (exitCode !== null && exitCode !== 0) {
417
+ if (exitCode !== null && exitCode !== 0 && !resumeFailed) {
346
418
  try {
347
419
  updateWorkspaceStatus(workspaceId, 'error');
348
420
  }
@@ -408,6 +480,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
408
480
  model,
409
481
  effort: reasoningEffort,
410
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',
411
487
  resumeFromEngineSessionId,
412
488
  backendUrl: `http://127.0.0.1:${backendPort}`,
413
489
  koboHome: (() => {
@@ -517,12 +593,81 @@ export function getRunningCount() {
517
593
  return controllers.size;
518
594
  }
519
595
  /** Kobo built-in slash commands injected into the skill list (without leading /). */
520
- const KOBO_COMMANDS = ['kobo-check-progress'];
596
+ const KOBO_COMMANDS = ['kobo-check-progress', 'kobo-prep-autoloop'];
521
597
  /** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
522
598
  export function getAvailableSkills() {
523
599
  return [...KOBO_COMMANDS, ...availableSkills];
524
600
  }
525
601
  // ── Quota handling ────────────────────────────────────────────────────────────
602
+ /**
603
+ * Last `rate_limit.info` received per workspace. Used by handleQuota to
604
+ * schedule the backoff at the actual reset time instead of a hardcoded
605
+ * exponential. In-memory only — rebuilt on the next rate_limit event after
606
+ * a server restart.
607
+ */
608
+ const latestRateLimitInfo = new Map();
609
+ /** Clear the rate-limit info cache for a workspace (called on deleteWorkspace). */
610
+ export function forgetRateLimitInfo(workspaceId) {
611
+ latestRateLimitInfo.delete(workspaceId);
612
+ }
613
+ /**
614
+ * Null out the engine_session_id on all agent_sessions rows for a workspace
615
+ * and clear the in-memory sessionIds cache. Called when a --resume attempt
616
+ * fails ("No conversation found with session ID") so that the next startAgent
617
+ * call starts a fresh conversation instead of retrying the stale ID.
618
+ */
619
+ function clearStaleEngineSessionId(workspaceId) {
620
+ try {
621
+ const db = getDb();
622
+ db.prepare('UPDATE agent_sessions SET engine_session_id = NULL WHERE workspace_id = ?').run(workspaceId);
623
+ sessionIds.delete(workspaceId);
624
+ }
625
+ catch (err) {
626
+ console.error('[orchestrator] Failed to clear stale engine session ID:', err);
627
+ }
628
+ }
629
+ const QUOTA_SAFETY_MARGIN_MS = 30_000;
630
+ const QUOTA_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
631
+ const QUOTA_SATURATION_THRESHOLD_PCT = 95;
632
+ /**
633
+ * Fallback backoff ladder (in minutes) used when the `rate_limit` info
634
+ * isn't usable. Indexed by `retryCount`; anything past the last entry
635
+ * clamps to the final value (5 h) — long enough to cross a weekly
636
+ * bucket reset if the rate_limit info truly never arrives.
637
+ */
638
+ const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
639
+ /**
640
+ * Compute the delay before retrying a workspace hit by quota.
641
+ *
642
+ * Prefers the `resetsAt` of the saturated bucket with the furthest-future
643
+ * reset (a tighter bucket will unlock by then anyway). Falls back to a
644
+ * fixed ladder (15 → 30 → 60 → 180 → 300 min) whenever the rate_limit
645
+ * info is missing, malformed, or implausible.
646
+ */
647
+ export function computeQuotaBackoffMs(workspaceId, retryCount) {
648
+ const info = latestRateLimitInfo.get(workspaceId);
649
+ if (info?.buckets?.length) {
650
+ const candidates = info.buckets
651
+ .filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && typeof b.resetsAt === 'string')
652
+ .map((b) => ({ resetsAt: b.resetsAt, ts: new Date(b.resetsAt).getTime() }))
653
+ .filter((x) => !Number.isNaN(x.ts))
654
+ .sort((a, b) => b.ts - a.ts);
655
+ const chosen = candidates[0];
656
+ if (chosen) {
657
+ const delta = chosen.ts - Date.now() + QUOTA_SAFETY_MARGIN_MS;
658
+ if (delta > 0 && delta <= QUOTA_MAX_BACKOFF_MS) {
659
+ return { delayMs: delta, resetsAt: chosen.resetsAt, source: 'rate_limit_info' };
660
+ }
661
+ }
662
+ }
663
+ const idx = Math.min(Math.max(0, retryCount), QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
664
+ const backoffMinutes = QUOTA_FALLBACK_LADDER_MINUTES[idx];
665
+ return { delayMs: backoffMinutes * 60 * 1000, source: 'exponential_fallback' };
666
+ }
667
+ /** @internal Test-only. */
668
+ export function _test_setRateLimitInfo(workspaceId, info) {
669
+ latestRateLimitInfo.set(workspaceId, info);
670
+ }
526
671
  function handleQuota(workspaceId, _agentSessionId) {
527
672
  try {
528
673
  updateWorkspaceStatus(workspaceId, 'quota');
@@ -533,16 +678,19 @@ function handleQuota(workspaceId, _agentSessionId) {
533
678
  // The quota state is already signalled by the `error { category: 'quota' }`
534
679
  // AgentEvent that triggered this handler. No legacy `agent:status { quota }`
535
680
  // emit needed.
536
- // 15min first, then 30min, then 60min cap
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.
537
683
  const retryCount = retryCounts.get(workspaceId) ?? 0;
538
- const backoffMinutes = Math.min(15 * 2 ** retryCount, 60);
539
- const backoffMs = backoffMinutes * 60 * 1000;
684
+ const { delayMs, resetsAt, source } = computeQuotaBackoffMs(workspaceId, retryCount);
685
+ const backoffMs = delayMs;
540
686
  retryCounts.set(workspaceId, retryCount + 1);
541
687
  // Surface the backoff schedule as an ephemeral event so the UI can display
542
688
  // retry count / wait time without polluting the persistent event log.
543
689
  emitEphemeral(workspaceId, 'agent:quota-backoff', {
544
690
  retryCount: retryCount + 1,
545
- backoffMinutes,
691
+ backoffMinutes: Math.round(delayMs / 60_000),
692
+ resetsAt,
693
+ source,
546
694
  });
547
695
  const timer = setTimeout(() => {
548
696
  backoffTimers.delete(workspaceId);
@@ -552,8 +700,13 @@ function handleQuota(workspaceId, _agentSessionId) {
552
700
  return;
553
701
  }
554
702
  try {
555
- const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
556
- startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
703
+ if (freshWs.autoLoop) {
704
+ autoLoopService.onQuotaBackoffExpired(workspaceId);
705
+ }
706
+ else {
707
+ const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
708
+ startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
709
+ }
557
710
  }
558
711
  catch (err) {
559
712
  console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
@@ -597,4 +750,4 @@ export function _runWatchdogForTest() {
597
750
  runWatchdog();
598
751
  }
599
752
  /** Test-only export. Not part of the public module API. */
600
- export const __test__ = { handleEvent };
753
+ export const __test__ = { handleEvent, handleQuota };