@loicngr/kobo 1.2.0 → 1.4.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 (180) hide show
  1. package/AGENTS.md +14 -0
  2. package/dist/mcp-server/kobo-tasks-server.js +2 -0
  3. package/dist/server/db/index.js +1 -0
  4. package/dist/server/db/migrations.js +72 -10
  5. package/dist/server/db/schema.js +5 -0
  6. package/dist/server/index.js +63 -9
  7. package/dist/server/routes/workspaces.js +208 -52
  8. package/dist/server/services/agent-manager.js +101 -9
  9. package/dist/server/services/notion-service.js +6 -3
  10. package/dist/server/services/pr-watcher-service.js +82 -0
  11. package/dist/server/services/settings-service.js +41 -22
  12. package/dist/server/services/websocket-service.js +41 -4
  13. package/dist/server/services/workspace-service.js +25 -2
  14. package/dist/server/utils/git-ops.js +200 -4
  15. package/dist/server/utils/paths.js +13 -0
  16. package/dist/server/utils/process-tracker.js +0 -4
  17. package/package.json +4 -3
  18. package/src/client/dist/spa/assets/ActivityFeed-Dxuw_8et.js +60 -0
  19. package/src/client/dist/spa/assets/ActivityFeed-OvgJQL4-.css +1 -0
  20. package/src/client/dist/spa/assets/CreatePage-CTFi3DpD.js +2 -0
  21. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +1 -0
  22. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +1 -0
  23. package/src/client/dist/spa/assets/DiffViewer-DV9gt8DT.js +2 -0
  24. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
  25. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
  26. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
  27. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
  28. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
  29. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
  30. package/src/client/dist/spa/assets/MainLayout-BxqZy-kp.js +2 -0
  31. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +1 -0
  32. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +1 -0
  33. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +1 -0
  34. package/src/client/dist/spa/assets/QExpansionItem-sghN-B7_.js +1 -0
  35. package/src/client/dist/spa/assets/QPage-DL4rY7LD.js +1 -0
  36. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +1 -0
  37. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +1 -0
  38. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +1 -0
  39. package/src/client/dist/spa/assets/SettingsPage-50Nqrcsk.js +1 -0
  40. package/src/client/dist/spa/assets/SettingsPage-DV5avRbc.css +1 -0
  41. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +1 -0
  42. package/src/client/dist/spa/assets/WorkspacePage-L46GJjcy.js +2 -0
  43. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +1 -0
  44. package/src/client/dist/spa/assets/abap-Co3wj02O.js +1 -0
  45. package/src/client/dist/spa/assets/apex-CUKwGs62.js +1 -0
  46. package/src/client/dist/spa/assets/azcli-DMImymmY.js +1 -0
  47. package/src/client/dist/spa/assets/bat--P_y70-E.js +1 -0
  48. package/src/client/dist/spa/assets/bicep-C3w6oSfK.js +2 -0
  49. package/src/client/dist/spa/assets/cameligo-D9NSR4Rj.js +1 -0
  50. package/src/client/dist/spa/assets/clojure-BMcQme0t.js +1 -0
  51. package/src/client/dist/spa/assets/codicon-CgENjH2v.ttf +0 -0
  52. package/src/client/dist/spa/assets/coffee-BbMZaWx7.js +1 -0
  53. package/src/client/dist/spa/assets/cpp-CbrtEGgw.js +1 -0
  54. package/src/client/dist/spa/assets/csharp-Bc0fjUxA.js +1 -0
  55. package/src/client/dist/spa/assets/csp-DmbXuMT0.js +1 -0
  56. package/src/client/dist/spa/assets/css-gdwCt5by.js +3 -0
  57. package/src/client/dist/spa/assets/css.worker-D1piIYC4.js +102 -0
  58. package/src/client/dist/spa/assets/cssMode-DO8hqIpD.js +4 -0
  59. package/src/client/dist/spa/assets/cypher-ocmmfoQr.js +1 -0
  60. package/src/client/dist/spa/assets/dart-DbZ5eklb.js +1 -0
  61. package/src/client/dist/spa/assets/dockerfile-BLaMayDc.js +1 -0
  62. package/src/client/dist/spa/assets/ecl-LxXpHirr.js +1 -0
  63. package/src/client/dist/spa/assets/editor-COGk2gAX.css +1 -0
  64. package/src/client/dist/spa/assets/editor-CS3NEPi9.css +1 -0
  65. package/src/client/dist/spa/assets/editor.api-BZP41lht.js +818 -0
  66. package/src/client/dist/spa/assets/editor.main-BOjf9Jyl.js +53 -0
  67. package/src/client/dist/spa/assets/editor.worker-CJ9iTmkr.js +26 -0
  68. package/src/client/dist/spa/assets/elixir-C_geKt5o.js +1 -0
  69. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
  70. package/src/client/dist/spa/assets/flow9-DE2fI2ca.js +1 -0
  71. package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +1 -0
  72. package/src/client/dist/spa/assets/freemarker2-QAd0phKD.js +3 -0
  73. package/src/client/dist/spa/assets/fsharp-CJD6fImD.js +1 -0
  74. package/src/client/dist/spa/assets/go-jUCqQ7bD.js +1 -0
  75. package/src/client/dist/spa/assets/graphql-rw7g9h7D.js +1 -0
  76. package/src/client/dist/spa/assets/handlebars-D40ZA-yu.js +1 -0
  77. package/src/client/dist/spa/assets/hcl-BKX27Mn7.js +1 -0
  78. package/src/client/dist/spa/assets/html-Bzo97Bk0.js +1 -0
  79. package/src/client/dist/spa/assets/html.worker-C4q4XMPn.js +509 -0
  80. package/src/client/dist/spa/assets/htmlMode-7HShfg96.js +4 -0
  81. package/src/client/dist/spa/assets/i18n-BiMAFoN_.js +1 -0
  82. package/src/client/dist/spa/assets/index-CaOiQq0z.js +5 -0
  83. package/src/client/dist/spa/assets/{index-BThMCiY7.css → index-eX_lKHSg.css} +1 -1
  84. package/src/client/dist/spa/assets/ini-CrXjga2H.js +1 -0
  85. package/src/client/dist/spa/assets/java-D4jksGBb.js +1 -0
  86. package/src/client/dist/spa/assets/javascript-DpFlF6yx.js +1 -0
  87. package/src/client/dist/spa/assets/json.worker-C9p7xCYk.js +65 -0
  88. package/src/client/dist/spa/assets/jsonMode-DxEb1VXU.js +10 -0
  89. package/src/client/dist/spa/assets/julia-CbWxfkeS.js +1 -0
  90. package/src/client/dist/spa/assets/kotlin-B26Yx80V.js +1 -0
  91. package/src/client/dist/spa/assets/less-DFzn-zC9.js +2 -0
  92. package/src/client/dist/spa/assets/lexon-C-w-W8Yv.js +1 -0
  93. package/src/client/dist/spa/assets/liquid-IpMvWkVS.js +1 -0
  94. package/src/client/dist/spa/assets/lua-CHuE_HoG.js +1 -0
  95. package/src/client/dist/spa/assets/m3-DEFZN2qS.js +1 -0
  96. package/src/client/dist/spa/assets/markdown-Cbt4TlFt.js +1 -0
  97. package/src/client/dist/spa/assets/mdx-BM5S9XtA.js +1 -0
  98. package/src/client/dist/spa/assets/mips-C6m4XECw.js +1 -0
  99. package/src/client/dist/spa/assets/monaco.contribution-Cpcgk43V.js +2 -0
  100. package/src/client/dist/spa/assets/msdax-un0CFb_S.js +1 -0
  101. package/src/client/dist/spa/assets/mysql-CuAPeiOV.js +1 -0
  102. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +1 -0
  103. package/src/client/dist/spa/assets/objective-c-DLVMdxAC.js +1 -0
  104. package/src/client/dist/spa/assets/pascal-BGCThuPY.js +1 -0
  105. package/src/client/dist/spa/assets/pascaligo-DfxSVpdo.js +1 -0
  106. package/src/client/dist/spa/assets/perl-BOE6y94t.js +1 -0
  107. package/src/client/dist/spa/assets/pgsql-Dn7JkY4F.js +1 -0
  108. package/src/client/dist/spa/assets/php-r1gD0KyT.js +1 -0
  109. package/src/client/dist/spa/assets/pla-CgXknhb0.js +1 -0
  110. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +1 -0
  111. package/src/client/dist/spa/assets/postiats-CsIEtnRB.js +1 -0
  112. package/src/client/dist/spa/assets/powerquery-yNJCmC_6.js +1 -0
  113. package/src/client/dist/spa/assets/powershell-CQcz1SqH.js +1 -0
  114. package/src/client/dist/spa/assets/protobuf-BmC34uvO.js +2 -0
  115. package/src/client/dist/spa/assets/pug-C20znvWM.js +1 -0
  116. package/src/client/dist/spa/assets/python-CBiKH2mZ.js +1 -0
  117. package/src/client/dist/spa/assets/qsharp-B7bnARMS.js +1 -0
  118. package/src/client/dist/spa/assets/r-ClvcLdqC.js +1 -0
  119. package/src/client/dist/spa/assets/razor-BV3hIY51.js +1 -0
  120. package/src/client/dist/spa/assets/redis-DCyda7_S.js +1 -0
  121. package/src/client/dist/spa/assets/redshift-BtWDr4pb.js +1 -0
  122. package/src/client/dist/spa/assets/restructuredtext-CLcnlkhl.js +1 -0
  123. package/src/client/dist/spa/assets/ruby-DY0SOSSZ.js +1 -0
  124. package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +1 -0
  125. package/src/client/dist/spa/assets/rust-JQd-fJZI.js +1 -0
  126. package/src/client/dist/spa/assets/sb-BV2j8yFF.js +1 -0
  127. package/src/client/dist/spa/assets/scala-DwbnREDs.js +1 -0
  128. package/src/client/dist/spa/assets/scheme-CrtA-vei.js +1 -0
  129. package/src/client/dist/spa/assets/scss-VxQz3zmI.js +3 -0
  130. package/src/client/dist/spa/assets/shell-CP9faqFI.js +1 -0
  131. package/src/client/dist/spa/assets/solidity-9IIb0b89.js +1 -0
  132. package/src/client/dist/spa/assets/sophia-D2LQU2AD.js +1 -0
  133. package/src/client/dist/spa/assets/sparql-DONCa5dy.js +1 -0
  134. package/src/client/dist/spa/assets/sql-DaAAHGEt.js +1 -0
  135. package/src/client/dist/spa/assets/st-CRY2V-j3.js +1 -0
  136. package/src/client/dist/spa/assets/swift-BlKbfloF.js +1 -0
  137. package/src/client/dist/spa/assets/systemverilog-B_h9Q_T_.js +1 -0
  138. package/src/client/dist/spa/assets/tcl-C4wN3A6M.js +1 -0
  139. package/src/client/dist/spa/assets/ts.worker-Cj3zTgVE.js +51353 -0
  140. package/src/client/dist/spa/assets/tsMode-DUqyritq.js +11 -0
  141. package/src/client/dist/spa/assets/twig-DDdaBLC9.js +1 -0
  142. package/src/client/dist/spa/assets/typescript-BvZDZzaz.js +1 -0
  143. package/src/client/dist/spa/assets/typespec-Dc1ipt8A.js +1 -0
  144. package/src/client/dist/spa/assets/use-checkbox-Dwcwf6Nj.js +1 -0
  145. package/src/client/dist/spa/assets/use-quasar-DMvrrord.js +1 -0
  146. package/src/client/dist/spa/assets/vb-C4BXIvrh.js +1 -0
  147. package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +3 -0
  148. package/src/client/dist/spa/assets/wgsl-XVg3Pi-r.js +298 -0
  149. package/src/client/dist/spa/assets/xml-BgsHEniP.js +1 -0
  150. package/src/client/dist/spa/assets/yaml-C-Mr6Xov.js +1 -0
  151. package/src/client/dist/spa/index.html +5 -3
  152. package/src/mcp-server/kobo-tasks-server.ts +2 -0
  153. package/src/client/dist/spa/assets/ActivityFeed-CPfYmybV.js +0 -60
  154. package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +0 -1
  155. package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +0 -1
  156. package/src/client/dist/spa/assets/CreatePage-C_c3Gr0F.js +0 -2
  157. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
  158. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
  159. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
  160. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
  161. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
  162. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
  163. package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +0 -1
  164. package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +0 -1
  165. package/src/client/dist/spa/assets/QBadge-CNojh9Rl.js +0 -1
  166. package/src/client/dist/spa/assets/QDialog-DgR7t6Vf.js +0 -1
  167. package/src/client/dist/spa/assets/QExpansionItem-VVjlYOIT.js +0 -1
  168. package/src/client/dist/spa/assets/QPage-DX4g-Dpe.js +0 -1
  169. package/src/client/dist/spa/assets/QSpinnerDots-DeCf9Lr-.js +0 -1
  170. package/src/client/dist/spa/assets/QTooltip-DKYJ8kVW.js +0 -1
  171. package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +0 -1
  172. package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +0 -1
  173. package/src/client/dist/spa/assets/WorkspacePage-DkM58caD.css +0 -1
  174. package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +0 -2
  175. package/src/client/dist/spa/assets/_plugin-vue_export-helper-C6NdfBK4.js +0 -1
  176. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  177. package/src/client/dist/spa/assets/index-C4WDJfjD.js +0 -5
  178. package/src/client/dist/spa/assets/nodes-irfhA8FK.js +0 -1
  179. package/src/client/dist/spa/assets/use-checkbox-BS9cbwg_.js +0 -1
  180. package/src/client/dist/spa/assets/use-quasar-CH0pSHUf.js +0 -1
@@ -1,8 +1,14 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFileCb);
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
2
6
  import { Hono } from 'hono';
7
+ import { getDb } from '../db/index.js';
3
8
  import * as agentManager from '../services/agent-manager.js';
4
9
  import * as devServerService from '../services/dev-server-service.js';
5
10
  import * as notionService from '../services/notion-service.js';
11
+ import { renderPrTemplate } from '../services/pr-template-service.js';
6
12
  import * as settingsService from '../services/settings-service.js';
7
13
  import * as wsService from '../services/websocket-service.js';
8
14
  import * as workspaceService from '../services/workspace-service.js';
@@ -107,15 +113,34 @@ app.post('/', async (c) => {
107
113
  workspaceService.updateWorkspaceStatus(workspace.id, 'error');
108
114
  return c.json({ error: `Failed to create worktree: ${message}` }, 500);
109
115
  }
110
- // 4b. Write git conventions to the worktree if configured
116
+ // 4b. Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
117
+ // itself may contain project files that SHOULD be committed).
118
+ try {
119
+ const gitignorePath = path.join(worktreePath, '.gitignore');
120
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
121
+ const lines = existing.split('\n').map((l) => l.trim());
122
+ const toAdd = [];
123
+ if (!lines.includes('.ai/git-conventions.md'))
124
+ toAdd.push('.ai/git-conventions.md');
125
+ if (!lines.includes('.ai/thoughts/'))
126
+ toAdd.push('.ai/thoughts/');
127
+ if (!lines.includes('.ai/images/'))
128
+ toAdd.push('.ai/images/');
129
+ if (toAdd.length > 0) {
130
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
131
+ fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
132
+ }
133
+ }
134
+ catch (err) {
135
+ console.error('[workspaces] Failed to update .gitignore:', err);
136
+ }
137
+ // 4c. Write git conventions to the worktree if configured
111
138
  const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
112
139
  if (effectiveSettings.gitConventions) {
113
140
  try {
114
- const fs = await import('node:fs');
115
- const path = await import('node:path');
116
- const aiDir = path.default.join(worktreePath, '.ai');
141
+ const aiDir = path.join(worktreePath, '.ai');
117
142
  fs.mkdirSync(aiDir, { recursive: true });
118
- const conventionsPath = path.default.join(aiDir, 'git-conventions.md');
143
+ const conventionsPath = path.join(aiDir, 'git-conventions.md');
119
144
  fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
120
145
  }
121
146
  catch (err) {
@@ -126,16 +151,14 @@ app.post('/', async (c) => {
126
151
  let notionFilePath = null;
127
152
  if (notionContent && body.notionUrl) {
128
153
  try {
129
- const fs = await import('node:fs');
130
- const path = await import('node:path');
131
- const thoughtsDir = path.default.join(worktreePath, '.ai', 'thoughts');
154
+ const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
132
155
  fs.mkdirSync(thoughtsDir, { recursive: true });
133
156
  // Derive filename from title (TK-XXX pattern or slug)
134
157
  const tkMatch = workspace.name.match(/TK-\d+/i);
135
158
  const filename = tkMatch
136
159
  ? `${tkMatch[0]}.md`
137
160
  : `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
138
- notionFilePath = path.default.join(thoughtsDir, filename);
161
+ notionFilePath = path.join(thoughtsDir, filename);
139
162
  const today = new Date().toISOString().split('T')[0];
140
163
  let md = `# ${workspace.name}\n\n`;
141
164
  md += `## Source\n\n`;
@@ -196,8 +219,7 @@ app.post('/', async (c) => {
196
219
  brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
197
220
  brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
198
221
  // Persist the initial prompt in the feed so it's visible in the chat
199
- const { emit } = await import('../services/websocket-service.js');
200
- emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
222
+ wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
201
223
  try {
202
224
  agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
203
225
  }
@@ -246,7 +268,7 @@ app.post('/:id/refresh-notion', async (c) => {
246
268
  return c.json({ error: 'No Notion URL configured' }, 400);
247
269
  const notionContent = await notionService.extractNotionPage(workspace.notionUrl);
248
270
  // Delete existing tasks and recreate from Notion
249
- const db = (await import('../db/index.js')).getDb();
271
+ const db = getDb();
250
272
  db.prepare('DELETE FROM tasks WHERE workspace_id = ?').run(id);
251
273
  let sortOrder = 0;
252
274
  for (const todo of notionContent.todos) {
@@ -304,7 +326,16 @@ app.post('/:id/tasks', async (c) => {
304
326
  // PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
305
327
  app.patch('/:id/tasks/:taskId', async (c) => {
306
328
  try {
329
+ const id = c.req.param('id');
307
330
  const taskId = c.req.param('taskId');
331
+ const workspace = workspaceService.getWorkspace(id);
332
+ if (!workspace) {
333
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
334
+ }
335
+ const task = workspaceService.getTask(taskId, id);
336
+ if (!task) {
337
+ return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
338
+ }
308
339
  const body = await c.req.json();
309
340
  if (body.status === undefined && body.title === undefined) {
310
341
  return c.json({ error: 'At least one of status or title is required' }, 400);
@@ -338,6 +369,10 @@ app.delete('/:id/tasks/:taskId', (c) => {
338
369
  if (!workspace) {
339
370
  return c.json({ error: `Workspace '${id}' not found` }, 404);
340
371
  }
372
+ const task = workspaceService.getTask(taskId, id);
373
+ if (!task) {
374
+ return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
375
+ }
341
376
  workspaceService.deleteTask(taskId);
342
377
  return new Response(null, { status: 204 });
343
378
  }
@@ -346,6 +381,24 @@ app.delete('/:id/tasks/:taskId', (c) => {
346
381
  return c.json({ error: message }, 500);
347
382
  }
348
383
  });
384
+ // POST /api/workspaces/:id/tasks/notify-updated — broadcast generic task list change
385
+ // Must be declared BEFORE /:id/tasks/:taskId/notify-done so Hono doesn't capture
386
+ // "notify-updated" as a :taskId parameter.
387
+ app.post('/:id/tasks/notify-updated', (c) => {
388
+ try {
389
+ const id = c.req.param('id');
390
+ const workspace = workspaceService.getWorkspace(id);
391
+ if (!workspace) {
392
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
393
+ }
394
+ wsService.emit(id, 'task:updated', {});
395
+ return new Response(null, { status: 204 });
396
+ }
397
+ catch (err) {
398
+ const message = err instanceof Error ? err.message : String(err);
399
+ return c.json({ error: message }, 500);
400
+ }
401
+ });
349
402
  // POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
350
403
  app.post('/:id/tasks/:taskId/notify-done', (c) => {
351
404
  try {
@@ -363,16 +416,66 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
363
416
  return c.json({ error: message }, 500);
364
417
  }
365
418
  });
366
- // POST /api/workspaces/:id/tasks/notify-updatedbroadcast generic task list change
367
- app.post('/:id/tasks/notify-updated', (c) => {
419
+ // GET /api/workspaces/:id/eventspaginated event history (must be before GET /:id for route ordering)
420
+ app.get('/:id/events', (c) => {
368
421
  try {
369
422
  const id = c.req.param('id');
370
423
  const workspace = workspaceService.getWorkspace(id);
371
424
  if (!workspace) {
372
425
  return c.json({ error: `Workspace '${id}' not found` }, 404);
373
426
  }
374
- wsService.emit(id, 'task:updated', {});
375
- return new Response(null, { status: 204 });
427
+ const before = c.req.query('before'); // event ID cursor
428
+ const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
429
+ const db = getDb();
430
+ let rows;
431
+ if (before) {
432
+ // Get the rowid of the cursor event
433
+ const cursorRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(before);
434
+ if (!cursorRow) {
435
+ return c.json({ events: [], hasMore: false });
436
+ }
437
+ rows = db
438
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
439
+ .all(id, cursorRow.rowid, limit);
440
+ }
441
+ else {
442
+ // No cursor — return the oldest events
443
+ rows = db
444
+ .prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
445
+ .all(id, limit);
446
+ }
447
+ // Reverse to chronological order (we queried DESC for "before" pagination)
448
+ if (before)
449
+ rows.reverse();
450
+ const events = rows.map((row) => {
451
+ let parsedPayload;
452
+ try {
453
+ parsedPayload = JSON.parse(row.payload);
454
+ }
455
+ catch {
456
+ parsedPayload = row.payload;
457
+ }
458
+ return {
459
+ id: row.id,
460
+ workspaceId: row.workspace_id,
461
+ type: row.type,
462
+ payload: parsedPayload,
463
+ sessionId: row.session_id,
464
+ createdAt: row.created_at,
465
+ };
466
+ });
467
+ // Check if there are more older events beyond what we returned
468
+ let hasMore = false;
469
+ if (before && rows.length > 0) {
470
+ const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
471
+ if (firstRow) {
472
+ const older = db
473
+ .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
474
+ .get(id, firstRow.rowid);
475
+ hasMore = older.c > 0;
476
+ }
477
+ }
478
+ return c.json({ events, hasMore });
376
479
  }
377
480
  catch (err) {
378
481
  const message = err instanceof Error ? err.message : String(err);
@@ -404,7 +507,7 @@ app.get('/:id', (c) => {
404
507
  return c.json({ error: message }, 500);
405
508
  }
406
509
  });
407
- // PATCH /api/workspaces/:id — update workspace fields (status, model)
510
+ // PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode)
408
511
  app.patch('/:id', async (c) => {
409
512
  try {
410
513
  const id = c.req.param('id');
@@ -417,11 +520,18 @@ app.patch('/:id', async (c) => {
417
520
  if (body.model !== undefined) {
418
521
  updated = workspaceService.updateWorkspaceModel(id, body.model);
419
522
  }
523
+ if (body.permissionMode !== undefined) {
524
+ const validModes = ['auto-accept', 'plan'];
525
+ if (!validModes.includes(body.permissionMode)) {
526
+ return c.json({ error: `Invalid permission mode. Must be one of: ${validModes.join(', ')}` }, 400);
527
+ }
528
+ updated = workspaceService.updateWorkspacePermissionMode(id, body.permissionMode);
529
+ }
420
530
  if (body.status) {
421
531
  updated = workspaceService.updateWorkspaceStatus(id, body.status);
422
532
  }
423
- if (!body.status && body.model === undefined) {
424
- return c.json({ error: 'Missing field: status or model' }, 400);
533
+ if (!body.status && body.model === undefined && body.permissionMode === undefined) {
534
+ return c.json({ error: 'Missing field: status, model, or permissionMode' }, 400);
425
535
  }
426
536
  return c.json(updated);
427
537
  }
@@ -565,7 +675,7 @@ app.post('/:id/start', async (c) => {
565
675
  // Agent may not be running — ignore
566
676
  }
567
677
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
568
- agentManager.startAgent(id, worktreePath, prompt, workspace.model);
678
+ agentManager.startAgent(id, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
569
679
  workspaceService.updateWorkspaceStatus(id, 'executing');
570
680
  return c.json({ status: 'started' });
571
681
  }
@@ -582,17 +692,21 @@ app.get('/:id/git-stats', async (c) => {
582
692
  if (!workspace) {
583
693
  return c.json({ error: `Workspace '${id}' not found` }, 404);
584
694
  }
585
- const path = await import('node:path');
586
- const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
695
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
587
696
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
588
697
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
589
- const prUrl = gitOps.getPrUrl(workspace.projectPath, workspace.workingBranch);
698
+ const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
699
+ const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath);
700
+ const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
590
701
  return c.json({
591
702
  commitCount,
592
703
  filesChanged: diffStats.filesChanged,
593
704
  insertions: diffStats.insertions,
594
705
  deletions: diffStats.deletions,
595
- prUrl,
706
+ prUrl: pr?.url ?? null,
707
+ prState: pr?.state ?? null,
708
+ unpushedCount,
709
+ workingTree,
596
710
  });
597
711
  }
598
712
  catch (err) {
@@ -600,6 +714,49 @@ app.get('/:id/git-stats', async (c) => {
600
714
  return c.json({ error: message }, 500);
601
715
  }
602
716
  });
717
+ // GET /api/workspaces/:id/diff — list changed files
718
+ app.get('/:id/diff', (c) => {
719
+ try {
720
+ const id = c.req.param('id');
721
+ const workspace = workspaceService.getWorkspace(id);
722
+ if (!workspace) {
723
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
724
+ }
725
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
726
+ const files = gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
727
+ return c.json({
728
+ files,
729
+ sourceBranch: workspace.sourceBranch,
730
+ workingBranch: workspace.workingBranch,
731
+ });
732
+ }
733
+ catch (err) {
734
+ const message = err instanceof Error ? err.message : String(err);
735
+ return c.json({ error: message }, 500);
736
+ }
737
+ });
738
+ // GET /api/workspaces/:id/diff/:filePath — get original and modified content for a file
739
+ app.get('/:id/diff-file', (c) => {
740
+ try {
741
+ const id = c.req.param('id');
742
+ const filePath = c.req.query('path');
743
+ if (!filePath) {
744
+ return c.json({ error: 'Missing path query parameter' }, 400);
745
+ }
746
+ const workspace = workspaceService.getWorkspace(id);
747
+ if (!workspace) {
748
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
749
+ }
750
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
751
+ const original = gitOps.getFileAtRef(worktreePath, workspace.sourceBranch, filePath);
752
+ const modified = gitOps.getFileContent(worktreePath, filePath);
753
+ return c.json({ original: original ?? '', modified: modified ?? '', filePath });
754
+ }
755
+ catch (err) {
756
+ const message = err instanceof Error ? err.message : String(err);
757
+ return c.json({ error: message }, 500);
758
+ }
759
+ });
603
760
  // POST /api/workspaces/:id/push — push working branch to origin
604
761
  app.post('/:id/push', async (c) => {
605
762
  try {
@@ -608,8 +765,7 @@ app.post('/:id/push', async (c) => {
608
765
  if (!workspace) {
609
766
  return c.json({ error: `Workspace '${id}' not found` }, 404);
610
767
  }
611
- const path = await import('node:path');
612
- const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
768
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
613
769
  try {
614
770
  gitOps.pushBranch(worktreePath, workspace.workingBranch);
615
771
  }
@@ -618,10 +774,9 @@ app.post('/:id/push', async (c) => {
618
774
  return c.json({ error: message }, 500);
619
775
  }
620
776
  // Emit a trace into the chat feed so the user sees the action
621
- const { emit } = await import('../services/websocket-service.js');
622
777
  const session = workspaceService.getLatestSession(id);
623
778
  const sessionId = session?.claudeSessionId ?? undefined;
624
- emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
779
+ wsService.emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
625
780
  return c.json({ ok: true, branch: workspace.workingBranch });
626
781
  }
627
782
  catch (err) {
@@ -637,15 +792,14 @@ app.post('/:id/open-pr', async (c) => {
637
792
  if (!workspace) {
638
793
  return c.json({ error: `Workspace '${id}' not found` }, 404);
639
794
  }
640
- const path = await import('node:path');
641
- const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
795
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
642
796
  // 1. Check branch is on remote
643
797
  let lsRemoteOut = '';
644
798
  try {
645
- const result = execFileSync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
799
+ const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
646
800
  cwd: worktreePath,
647
801
  });
648
- lsRemoteOut = result.toString();
802
+ lsRemoteOut = stdout;
649
803
  }
650
804
  catch {
651
805
  lsRemoteOut = '';
@@ -655,8 +809,8 @@ app.post('/:id/open-pr', async (c) => {
655
809
  }
656
810
  // 2. Check all local commits are pushed
657
811
  try {
658
- const result = execFileSync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
659
- const countStr = result.toString().trim();
812
+ const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
813
+ const countStr = stdout.trim();
660
814
  const count = parseInt(countStr, 10) || 0;
661
815
  if (count > 0) {
662
816
  return c.json({ error: 'Local commits not pushed', code: 'unpushed_commits' }, 409);
@@ -675,7 +829,7 @@ app.post('/:id/open-pr', async (c) => {
675
829
  let ghOutput;
676
830
  try {
677
831
  const placeholderBody = 'Automated PR — description will be updated by the agent.';
678
- const result = execFileSync('gh', [
832
+ const { stdout } = await execFileAsync('gh', [
679
833
  'pr',
680
834
  'create',
681
835
  '--base',
@@ -687,7 +841,7 @@ app.post('/:id/open-pr', async (c) => {
687
841
  '--body',
688
842
  placeholderBody,
689
843
  ], { cwd: worktreePath });
690
- ghOutput = result.toString();
844
+ ghOutput = stdout;
691
845
  }
692
846
  catch (err) {
693
847
  const message = err instanceof Error ? err.message : String(err);
@@ -708,7 +862,6 @@ app.post('/:id/open-pr', async (c) => {
708
862
  return c.json({ ok: true, prNumber, prUrl, messageSent: false });
709
863
  }
710
864
  // 6. Build context and render the template
711
- const { renderPrTemplate } = await import('../services/pr-template-service.js');
712
865
  const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
713
866
  const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
714
867
  const tasks = workspaceService.listTasks(workspace.id);
@@ -721,26 +874,29 @@ app.post('/:id/open-pr', async (c) => {
721
874
  tasks,
722
875
  });
723
876
  // 7. Emit user:message into the chat feed
724
- const { emit } = await import('../services/websocket-service.js');
725
877
  const session = workspaceService.getLatestSession(workspace.id);
726
878
  const sessionId = session?.claudeSessionId ?? undefined;
727
- emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
728
- // 8. Send to the running agent (degrade on failure)
879
+ wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
880
+ // 8. Send to the running agent, or resume the agent with the PR prompt
881
+ let messageSent = false;
729
882
  try {
730
883
  agentManager.sendMessage(workspace.id, rendered);
731
- return c.json({ ok: true, prNumber, prUrl, messageSent: true });
884
+ messageSent = true;
732
885
  }
733
- catch (err) {
734
- const message = err instanceof Error ? err.message : String(err);
735
- console.warn(`[workspaces] open-pr: PR created but sendMessage failed: ${message}`);
736
- return c.json({
737
- ok: true,
738
- prNumber,
739
- prUrl,
740
- messageSent: false,
741
- warning: `Agent is not active — message was not sent (${message})`,
742
- });
886
+ catch {
887
+ // Agent not running resume it with the PR prompt
888
+ try {
889
+ const worktreePathForResume = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
890
+ agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode);
891
+ workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
892
+ messageSent = true;
893
+ }
894
+ catch (resumeErr) {
895
+ const resumeMsg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
896
+ console.warn(`[workspaces] open-pr: PR created but agent resume failed: ${resumeMsg}`);
897
+ }
743
898
  }
899
+ return c.json({ ok: true, prNumber, prUrl, messageSent });
744
900
  }
745
901
  catch (err) {
746
902
  const message = err instanceof Error ? err.message : String(err);
@@ -6,6 +6,7 @@ import { nanoid } from 'nanoid';
6
6
  import { getDb } from '../db/index.js';
7
7
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../utils/paths.js';
8
8
  import { registerProcess, unregisterProcess } from '../utils/process-tracker.js';
9
+ import { getEffectiveSettings } from './settings-service.js';
9
10
  import { emit } from './websocket-service.js';
10
11
  import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
11
12
  // ── State ──────────────────────────────────────────────────────────────────────
@@ -35,8 +36,71 @@ const retryCounts = new Map();
35
36
  const backoffTimers = new Map();
36
37
  /** workspaceId -> pending SIGKILL timer */
37
38
  const killTimers = new Map();
39
+ // ── Watchdog ──────────────────────────────────────────────────────────────────
40
+ // Periodically checks that tracked agent processes are still alive.
41
+ // If a process died without triggering the 'exit' handler (crash, OOM kill,
42
+ // etc.), the watchdog cleans up and updates the workspace status.
43
+ const WATCHDOG_INTERVAL_MS = 30_000;
44
+ let watchdogTimer = null;
45
+ function isProcessAlive(pid) {
46
+ try {
47
+ process.kill(pid, 0); // signal 0 = existence check, doesn't actually kill
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function runWatchdog() {
55
+ for (const [workspaceId, agent] of agents) {
56
+ const pid = agent.process.pid;
57
+ if (pid === undefined || isProcessAlive(pid))
58
+ continue;
59
+ console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
60
+ // Close readline to release the stream
61
+ try {
62
+ agent.rl.close();
63
+ }
64
+ catch {
65
+ // Ignore
66
+ }
67
+ unregisterProcess(workspaceId);
68
+ agents.delete(workspaceId);
69
+ retryCounts.delete(workspaceId);
70
+ // Update DB session
71
+ try {
72
+ const db = getDb();
73
+ db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), agent.agentSessionId);
74
+ }
75
+ catch (err) {
76
+ console.error('[watchdog] Failed to update agent_sessions:', err);
77
+ }
78
+ // Update workspace status
79
+ try {
80
+ updateWorkspaceStatus(workspaceId, 'error');
81
+ }
82
+ catch {
83
+ // Transition may not be valid — ignore
84
+ }
85
+ emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.claudeSessionId);
86
+ }
87
+ }
88
+ /** Start the watchdog (called once from server bootstrap). */
89
+ export function startWatchdog() {
90
+ if (watchdogTimer)
91
+ return;
92
+ watchdogTimer = setInterval(runWatchdog, WATCHDOG_INTERVAL_MS);
93
+ watchdogTimer.unref?.();
94
+ }
95
+ /** Stop the watchdog (for clean shutdown / tests). */
96
+ export function stopWatchdog() {
97
+ if (watchdogTimer) {
98
+ clearInterval(watchdogTimer);
99
+ watchdogTimer = null;
100
+ }
101
+ }
38
102
  // ── Start agent ────────────────────────────────────────────────────────────────
39
- export function startAgent(workspaceId, workingDir, prompt, model, resume = false) {
103
+ export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
40
104
  // Check if agent already running for this workspace
41
105
  if (agents.has(workspaceId)) {
42
106
  throw new Error(`Agent already running for workspace '${workspaceId}'`);
@@ -44,8 +108,18 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
44
108
  const db = getDb();
45
109
  let agentSessionId;
46
110
  let resumedClaudeSessionId;
47
- // Build CLI args
48
- const args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
111
+ // Build CLI args — read dangerouslySkipPermissions from effective settings
112
+ const ws = getWs(workspaceId);
113
+ const effectiveSettings = ws ? getEffectiveSettings(ws.projectPath) : null;
114
+ const skipPermissions = effectiveSettings?.dangerouslySkipPermissions ?? true;
115
+ const args = ['--output-format', 'stream-json', '--verbose'];
116
+ if (skipPermissions) {
117
+ args.push('--dangerously-skip-permissions');
118
+ }
119
+ if (permissionMode === 'plan') {
120
+ // In plan mode, prepend read-only instructions to the prompt
121
+ prompt = `[PLAN MODE] You are in PLAN/READ-ONLY mode. You MUST NOT create, edit, write, or delete any files. Only use read-only tools (Read, Grep, Glob, LS, Bash for read-only commands). Analyze the codebase, plan your approach, and present your findings — but do NOT execute any changes.\n\n${prompt}`;
122
+ }
49
123
  if (model && model !== 'auto') {
50
124
  args.push('--model', model);
51
125
  }
@@ -234,7 +308,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
234
308
  const text = data.toString();
235
309
  const lowerText = text.toLowerCase();
236
310
  if (lowerText.includes('rate limit') || lowerText.includes('quota') || lowerText.includes('limit exceeded')) {
237
- handleQuota(workspaceId, workingDir, agent.claudeSessionId);
311
+ handleQuota(workspaceId, agent.claudeSessionId);
238
312
  }
239
313
  // Also emit stderr for visibility
240
314
  emit(workspaceId, 'agent:stderr', { content: text }, agent.claudeSessionId);
@@ -251,7 +325,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
251
325
  // I3: Close readline interface to release the stream reference
252
326
  agent.rl.close();
253
327
  unregisterProcess(workspaceId);
254
- agents.delete(workspaceId);
328
+ // Only remove from the map if this exact agent instance is still current.
329
+ // stopAgent() eagerly removes the entry so startAgent() can proceed
330
+ // immediately; if a new agent was started in the meantime, we must not
331
+ // remove it.
332
+ if (agents.get(workspaceId) === agent) {
333
+ agents.delete(workspaceId);
334
+ }
255
335
  // Clean up retry state and inactivity timer
256
336
  retryCounts.delete(workspaceId);
257
337
  // C2: Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
@@ -308,6 +388,10 @@ export function stopAgent(workspaceId) {
308
388
  throw new Error(`No agent running for workspace '${workspaceId}'`);
309
389
  }
310
390
  agent.status = 'stopping';
391
+ // Remove from the map immediately so startAgent() can be called right after
392
+ // without hitting "Agent already running". The exit handler checks identity
393
+ // before removing, so a new agent started in the meantime won't be affected.
394
+ agents.delete(workspaceId);
311
395
  // Cancel any pending backoff timer
312
396
  const timer = backoffTimers.get(workspaceId);
313
397
  if (timer) {
@@ -330,8 +414,10 @@ export function stopAgent(workspaceId) {
330
414
  }
331
415
  // After 5s timeout, send SIGKILL if still running
332
416
  const killTimer = setTimeout(() => {
333
- // C2: Guard against race with natural exit only act if this exact agent instance is still current
334
- if (agents.get(workspaceId) !== agent) {
417
+ // C2: If a new agent has been started for this workspace in the meantime,
418
+ // don't kill the old process — it's handled by the new lifecycle.
419
+ const currentAgent = agents.get(workspaceId);
420
+ if (currentAgent && currentAgent !== agent) {
335
421
  killTimers.delete(workspaceId);
336
422
  return;
337
423
  }
@@ -372,7 +458,7 @@ export function getAvailableSkills() {
372
458
  return availableSkills;
373
459
  }
374
460
  // ── Quota handling ─────────────────────────────────────────────────────────────
375
- function handleQuota(workspaceId, workingDir, claudeSessionId) {
461
+ function handleQuota(workspaceId, claudeSessionId) {
376
462
  // Update workspace status
377
463
  try {
378
464
  updateWorkspaceStatus(workspaceId, 'quota');
@@ -397,8 +483,14 @@ function handleQuota(workspaceId, workingDir, claudeSessionId) {
397
483
  backoffTimers.delete(workspaceId);
398
484
  // Only restart if not already running or stopped
399
485
  if (!agents.has(workspaceId)) {
486
+ // Re-read workspace from DB — it may have been deleted or archived during backoff
487
+ const freshWs = getWs(workspaceId);
488
+ if (!freshWs || freshWs.archivedAt !== null || freshWs.status !== 'quota') {
489
+ return;
490
+ }
400
491
  try {
401
- startAgent(workspaceId, workingDir, 'Continue the previous task where you left off.', undefined, true);
492
+ const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
493
+ startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
402
494
  }
403
495
  catch {
404
496
  // Agent restart failed
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
+ import { getPackageVersion } from '../utils/paths.js';
3
4
  // Gherkin keywords (French and English)
4
5
  const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
5
6
  // C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
@@ -53,10 +54,11 @@ export async function callMcpTool(mcpProcess, toolName, args) {
53
54
  return;
54
55
  }
55
56
  let buffer = '';
56
- // C1: 30s timeout
57
+ // C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
57
58
  const timeout = setTimeout(() => {
58
59
  mcpProcess.stdout?.removeListener('data', onData);
59
60
  mcpProcess.stdout?.removeListener('error', onError);
61
+ mcpProcess.kill();
60
62
  reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
61
63
  }, 30_000);
62
64
  const onData = (chunk) => {
@@ -155,7 +157,7 @@ async function initializeMcp(mcpProcess) {
155
157
  params: {
156
158
  protocolVersion: '2024-11-05',
157
159
  capabilities: {},
158
- clientInfo: { name: 'kobo', version: '0.1.0' },
160
+ clientInfo: { name: 'kobo', version: getPackageVersion() },
159
161
  },
160
162
  });
161
163
  await new Promise((resolve, reject) => {
@@ -164,9 +166,10 @@ async function initializeMcp(mcpProcess) {
164
166
  return;
165
167
  }
166
168
  let buffer = '';
167
- // C1: 10s timeout for initialization
169
+ // C1: 10s timeout for initialization — I7: kill the MCP process on timeout
168
170
  const timeout = setTimeout(() => {
169
171
  mcpProcess.stdout?.removeListener('data', onData);
172
+ mcpProcess.kill();
170
173
  reject(new Error('initializeMcp timed out after 10s'));
171
174
  }, 10_000);
172
175
  const onData = (chunk) => {