@loicngr/kobo 1.3.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 (179) 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 +11 -0
  5. package/dist/server/db/schema.js +4 -0
  6. package/dist/server/index.js +58 -7
  7. package/dist/server/routes/workspaces.js +157 -38
  8. package/dist/server/services/agent-manager.js +24 -6
  9. package/dist/server/services/notion-service.js +6 -3
  10. package/dist/server/services/pr-watcher-service.js +27 -6
  11. package/dist/server/services/websocket-service.js +41 -4
  12. package/dist/server/services/workspace-service.js +19 -3
  13. package/dist/server/utils/git-ops.js +172 -4
  14. package/dist/server/utils/paths.js +13 -0
  15. package/dist/server/utils/process-tracker.js +0 -4
  16. package/package.json +4 -3
  17. package/src/client/dist/spa/assets/ActivityFeed-Dxuw_8et.js +60 -0
  18. package/src/client/dist/spa/assets/ActivityFeed-OvgJQL4-.css +1 -0
  19. package/src/client/dist/spa/assets/CreatePage-CTFi3DpD.js +2 -0
  20. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +1 -0
  21. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +1 -0
  22. package/src/client/dist/spa/assets/DiffViewer-DV9gt8DT.js +2 -0
  23. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-k1h7X_-h.woff +0 -0
  24. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-B7du-70m.woff +0 -0
  25. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CoAZ_DKt.woff +0 -0
  26. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-D0406B4n.woff +0 -0
  27. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-CnAg2DeQ.woff +0 -0
  28. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-BG9VWE5v.woff +0 -0
  29. package/src/client/dist/spa/assets/MainLayout-BxqZy-kp.js +2 -0
  30. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +1 -0
  31. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +1 -0
  32. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +1 -0
  33. package/src/client/dist/spa/assets/QExpansionItem-sghN-B7_.js +1 -0
  34. package/src/client/dist/spa/assets/QPage-DL4rY7LD.js +1 -0
  35. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +1 -0
  36. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +1 -0
  37. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +1 -0
  38. package/src/client/dist/spa/assets/SettingsPage-50Nqrcsk.js +1 -0
  39. package/src/client/dist/spa/assets/SettingsPage-DV5avRbc.css +1 -0
  40. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +1 -0
  41. package/src/client/dist/spa/assets/WorkspacePage-L46GJjcy.js +2 -0
  42. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +1 -0
  43. package/src/client/dist/spa/assets/abap-Co3wj02O.js +1 -0
  44. package/src/client/dist/spa/assets/apex-CUKwGs62.js +1 -0
  45. package/src/client/dist/spa/assets/azcli-DMImymmY.js +1 -0
  46. package/src/client/dist/spa/assets/bat--P_y70-E.js +1 -0
  47. package/src/client/dist/spa/assets/bicep-C3w6oSfK.js +2 -0
  48. package/src/client/dist/spa/assets/cameligo-D9NSR4Rj.js +1 -0
  49. package/src/client/dist/spa/assets/clojure-BMcQme0t.js +1 -0
  50. package/src/client/dist/spa/assets/codicon-CgENjH2v.ttf +0 -0
  51. package/src/client/dist/spa/assets/coffee-BbMZaWx7.js +1 -0
  52. package/src/client/dist/spa/assets/cpp-CbrtEGgw.js +1 -0
  53. package/src/client/dist/spa/assets/csharp-Bc0fjUxA.js +1 -0
  54. package/src/client/dist/spa/assets/csp-DmbXuMT0.js +1 -0
  55. package/src/client/dist/spa/assets/css-gdwCt5by.js +3 -0
  56. package/src/client/dist/spa/assets/css.worker-D1piIYC4.js +102 -0
  57. package/src/client/dist/spa/assets/cssMode-DO8hqIpD.js +4 -0
  58. package/src/client/dist/spa/assets/cypher-ocmmfoQr.js +1 -0
  59. package/src/client/dist/spa/assets/dart-DbZ5eklb.js +1 -0
  60. package/src/client/dist/spa/assets/dockerfile-BLaMayDc.js +1 -0
  61. package/src/client/dist/spa/assets/ecl-LxXpHirr.js +1 -0
  62. package/src/client/dist/spa/assets/editor-COGk2gAX.css +1 -0
  63. package/src/client/dist/spa/assets/editor-CS3NEPi9.css +1 -0
  64. package/src/client/dist/spa/assets/editor.api-BZP41lht.js +818 -0
  65. package/src/client/dist/spa/assets/editor.main-BOjf9Jyl.js +53 -0
  66. package/src/client/dist/spa/assets/editor.worker-CJ9iTmkr.js +26 -0
  67. package/src/client/dist/spa/assets/elixir-C_geKt5o.js +1 -0
  68. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-OUIwM9U8.woff +0 -0
  69. package/src/client/dist/spa/assets/flow9-DE2fI2ca.js +1 -0
  70. package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +1 -0
  71. package/src/client/dist/spa/assets/freemarker2-QAd0phKD.js +3 -0
  72. package/src/client/dist/spa/assets/fsharp-CJD6fImD.js +1 -0
  73. package/src/client/dist/spa/assets/go-jUCqQ7bD.js +1 -0
  74. package/src/client/dist/spa/assets/graphql-rw7g9h7D.js +1 -0
  75. package/src/client/dist/spa/assets/handlebars-D40ZA-yu.js +1 -0
  76. package/src/client/dist/spa/assets/hcl-BKX27Mn7.js +1 -0
  77. package/src/client/dist/spa/assets/html-Bzo97Bk0.js +1 -0
  78. package/src/client/dist/spa/assets/html.worker-C4q4XMPn.js +509 -0
  79. package/src/client/dist/spa/assets/htmlMode-7HShfg96.js +4 -0
  80. package/src/client/dist/spa/assets/i18n-BiMAFoN_.js +1 -0
  81. package/src/client/dist/spa/assets/index-CaOiQq0z.js +5 -0
  82. package/src/client/dist/spa/assets/{index-BThMCiY7.css → index-eX_lKHSg.css} +1 -1
  83. package/src/client/dist/spa/assets/ini-CrXjga2H.js +1 -0
  84. package/src/client/dist/spa/assets/java-D4jksGBb.js +1 -0
  85. package/src/client/dist/spa/assets/javascript-DpFlF6yx.js +1 -0
  86. package/src/client/dist/spa/assets/json.worker-C9p7xCYk.js +65 -0
  87. package/src/client/dist/spa/assets/jsonMode-DxEb1VXU.js +10 -0
  88. package/src/client/dist/spa/assets/julia-CbWxfkeS.js +1 -0
  89. package/src/client/dist/spa/assets/kotlin-B26Yx80V.js +1 -0
  90. package/src/client/dist/spa/assets/less-DFzn-zC9.js +2 -0
  91. package/src/client/dist/spa/assets/lexon-C-w-W8Yv.js +1 -0
  92. package/src/client/dist/spa/assets/liquid-IpMvWkVS.js +1 -0
  93. package/src/client/dist/spa/assets/lua-CHuE_HoG.js +1 -0
  94. package/src/client/dist/spa/assets/m3-DEFZN2qS.js +1 -0
  95. package/src/client/dist/spa/assets/markdown-Cbt4TlFt.js +1 -0
  96. package/src/client/dist/spa/assets/mdx-BM5S9XtA.js +1 -0
  97. package/src/client/dist/spa/assets/mips-C6m4XECw.js +1 -0
  98. package/src/client/dist/spa/assets/monaco.contribution-Cpcgk43V.js +2 -0
  99. package/src/client/dist/spa/assets/msdax-un0CFb_S.js +1 -0
  100. package/src/client/dist/spa/assets/mysql-CuAPeiOV.js +1 -0
  101. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +1 -0
  102. package/src/client/dist/spa/assets/objective-c-DLVMdxAC.js +1 -0
  103. package/src/client/dist/spa/assets/pascal-BGCThuPY.js +1 -0
  104. package/src/client/dist/spa/assets/pascaligo-DfxSVpdo.js +1 -0
  105. package/src/client/dist/spa/assets/perl-BOE6y94t.js +1 -0
  106. package/src/client/dist/spa/assets/pgsql-Dn7JkY4F.js +1 -0
  107. package/src/client/dist/spa/assets/php-r1gD0KyT.js +1 -0
  108. package/src/client/dist/spa/assets/pla-CgXknhb0.js +1 -0
  109. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +1 -0
  110. package/src/client/dist/spa/assets/postiats-CsIEtnRB.js +1 -0
  111. package/src/client/dist/spa/assets/powerquery-yNJCmC_6.js +1 -0
  112. package/src/client/dist/spa/assets/powershell-CQcz1SqH.js +1 -0
  113. package/src/client/dist/spa/assets/protobuf-BmC34uvO.js +2 -0
  114. package/src/client/dist/spa/assets/pug-C20znvWM.js +1 -0
  115. package/src/client/dist/spa/assets/python-CBiKH2mZ.js +1 -0
  116. package/src/client/dist/spa/assets/qsharp-B7bnARMS.js +1 -0
  117. package/src/client/dist/spa/assets/r-ClvcLdqC.js +1 -0
  118. package/src/client/dist/spa/assets/razor-BV3hIY51.js +1 -0
  119. package/src/client/dist/spa/assets/redis-DCyda7_S.js +1 -0
  120. package/src/client/dist/spa/assets/redshift-BtWDr4pb.js +1 -0
  121. package/src/client/dist/spa/assets/restructuredtext-CLcnlkhl.js +1 -0
  122. package/src/client/dist/spa/assets/ruby-DY0SOSSZ.js +1 -0
  123. package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +1 -0
  124. package/src/client/dist/spa/assets/rust-JQd-fJZI.js +1 -0
  125. package/src/client/dist/spa/assets/sb-BV2j8yFF.js +1 -0
  126. package/src/client/dist/spa/assets/scala-DwbnREDs.js +1 -0
  127. package/src/client/dist/spa/assets/scheme-CrtA-vei.js +1 -0
  128. package/src/client/dist/spa/assets/scss-VxQz3zmI.js +3 -0
  129. package/src/client/dist/spa/assets/shell-CP9faqFI.js +1 -0
  130. package/src/client/dist/spa/assets/solidity-9IIb0b89.js +1 -0
  131. package/src/client/dist/spa/assets/sophia-D2LQU2AD.js +1 -0
  132. package/src/client/dist/spa/assets/sparql-DONCa5dy.js +1 -0
  133. package/src/client/dist/spa/assets/sql-DaAAHGEt.js +1 -0
  134. package/src/client/dist/spa/assets/st-CRY2V-j3.js +1 -0
  135. package/src/client/dist/spa/assets/swift-BlKbfloF.js +1 -0
  136. package/src/client/dist/spa/assets/systemverilog-B_h9Q_T_.js +1 -0
  137. package/src/client/dist/spa/assets/tcl-C4wN3A6M.js +1 -0
  138. package/src/client/dist/spa/assets/ts.worker-Cj3zTgVE.js +51353 -0
  139. package/src/client/dist/spa/assets/tsMode-DUqyritq.js +11 -0
  140. package/src/client/dist/spa/assets/twig-DDdaBLC9.js +1 -0
  141. package/src/client/dist/spa/assets/typescript-BvZDZzaz.js +1 -0
  142. package/src/client/dist/spa/assets/typespec-Dc1ipt8A.js +1 -0
  143. package/src/client/dist/spa/assets/use-checkbox-Dwcwf6Nj.js +1 -0
  144. package/src/client/dist/spa/assets/use-quasar-DMvrrord.js +1 -0
  145. package/src/client/dist/spa/assets/vb-C4BXIvrh.js +1 -0
  146. package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +3 -0
  147. package/src/client/dist/spa/assets/wgsl-XVg3Pi-r.js +298 -0
  148. package/src/client/dist/spa/assets/xml-BgsHEniP.js +1 -0
  149. package/src/client/dist/spa/assets/yaml-C-Mr6Xov.js +1 -0
  150. package/src/client/dist/spa/index.html +5 -3
  151. package/src/mcp-server/kobo-tasks-server.ts +2 -0
  152. package/src/client/dist/spa/assets/ActivityFeed-Bie-lcn7.js +0 -60
  153. package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +0 -1
  154. package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +0 -1
  155. package/src/client/dist/spa/assets/CreatePage-OC-fnNGP.js +0 -2
  156. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
  157. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
  158. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
  159. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
  160. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
  161. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
  162. package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +0 -1
  163. package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +0 -1
  164. package/src/client/dist/spa/assets/QBadge-DbE3eSf1.js +0 -1
  165. package/src/client/dist/spa/assets/QDialog-Cd_4PvgW.js +0 -1
  166. package/src/client/dist/spa/assets/QExpansionItem-pMQDDRMv.js +0 -1
  167. package/src/client/dist/spa/assets/QPage-lhV4XbI2.js +0 -1
  168. package/src/client/dist/spa/assets/QSpinnerDots-ByNZaBWw.js +0 -1
  169. package/src/client/dist/spa/assets/QTooltip-6GSFtFKP.js +0 -1
  170. package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +0 -1
  171. package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +0 -1
  172. package/src/client/dist/spa/assets/WorkspacePage-Dhkuuhf8.css +0 -1
  173. package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +0 -2
  174. package/src/client/dist/spa/assets/_plugin-vue_export-helper-B6FaNy4R.js +0 -1
  175. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  176. package/src/client/dist/spa/assets/index-BoQWbZtE.js +0 -5
  177. package/src/client/dist/spa/assets/nodes-CXdiSdC2.js +0 -1
  178. package/src/client/dist/spa/assets/use-checkbox-Z9pfihkw.js +0 -1
  179. package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +0 -1
package/AGENTS.md CHANGED
@@ -171,6 +171,19 @@ See the "Notion integration" section of the README for the end-user setup guide.
171
171
 
172
172
  **Dependencies** — root `package.json` covers backend + tests. `src/client/package.json` is a separate npm tree. Install both.
173
173
 
174
+ ## Internationalization (i18n)
175
+
176
+ The frontend uses `vue-i18n` v10 with 5 supported locales: English (`en`), French (`fr`), German (`de`), Spanish (`es`), Italian (`it`). Translation files live in `src/client/src/i18n/`.
177
+
178
+ **Mandatory rules for all frontend code:**
179
+
180
+ - **NEVER hardcode user-visible text** in Vue templates or scripts. Always use `$t('key')` in templates and `t('key')` in `<script setup>` (via `const { t } = useI18n()`).
181
+ - When adding or modifying a text, **update ALL 5 locale files** (`en.ts`, `fr.ts`, `de.ts`, `es.ts`, `it.ts`) with the corresponding translation.
182
+ - Keys follow the pattern `'component.label'` (e.g. `'git.push'`, `'settings.title'`, `'common.save'`). Use the existing key structure as reference.
183
+ - Keep technical terms in English across all locales when that's the industry convention (Git, PR, Push, Diff, Commit, Branch, Tokens, etc.).
184
+ - Placeholders like `{count}`, `{n}`, `{query}` must remain intact in all translations.
185
+ - The language selector is in the Settings page (Global tab). The locale is auto-detected from the browser on first visit and persisted in `localStorage('kobo:locale')`.
186
+
174
187
  ## Testing discipline
175
188
 
176
189
  - **TDD for backend** — write the failing test, confirm it fails for the right reason, implement minimally, confirm it passes, commit. One commit per logical unit. See existing tests in `src/__tests__/workspace-service.test.ts` for the setup pattern (fresh in-memory DB per test via `resetDb()`).
@@ -225,3 +238,4 @@ The human user of this repository prefers French for conversational exchanges. C
225
238
  - Don't introduce ORMs, query builders, or schema validation libraries — the project is small enough for raw prepared statements and hand-written mappers.
226
239
  - Don't break the single-source-of-truth of `CLAUDE.md` → `AGENTS.md` symlink. Edit `AGENTS.md`; `CLAUDE.md` follows automatically.
227
240
  - Don't skip `try/catch` swallowing on best-effort cleanup (agent stop, dev-server stop, worktree removal). These must never break the primary operation.
241
+ - Don't hardcode user-visible text in the frontend. Every string must go through `$t()` / `t()` with keys in all 5 locale files. See [Internationalization (i18n)](#internationalization-i18n).
@@ -20,6 +20,7 @@ let db;
20
20
  try {
21
21
  db = new Database(dbPath, { readonly: false });
22
22
  db.pragma('journal_mode = WAL');
23
+ db.pragma('busy_timeout = 5000');
23
24
  db.pragma('foreign_keys = ON');
24
25
  }
25
26
  catch (err) {
@@ -205,6 +206,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
205
206
  properties: {
206
207
  status: {
207
208
  type: 'string',
209
+ enum: ['idle', 'completed', 'error'],
208
210
  description: 'New status (e.g. idle, completed)',
209
211
  },
210
212
  },
@@ -11,6 +11,7 @@ export function getDb(dbPath) {
11
11
  }
12
12
  instance = new Database(resolvedPath);
13
13
  instance.pragma('journal_mode=WAL');
14
+ instance.pragma('busy_timeout=5000');
14
15
  instance.pragma('foreign_keys=ON');
15
16
  return instance;
16
17
  }
@@ -7,6 +7,17 @@ export const migrations = [
7
7
  db.exec("ALTER TABLE workspaces ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'auto-accept'");
8
8
  },
9
9
  },
10
+ {
11
+ version: 3,
12
+ name: 'add-workspace-id-indexes',
13
+ migrate: (db) => {
14
+ db.exec(`
15
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
16
+ CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
17
+ CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
18
+ `);
19
+ },
20
+ },
10
21
  ];
11
22
  /** Current schema version — always equals the highest migration version. */
12
23
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -46,5 +46,9 @@ export function initSchema(db) {
46
46
  session_id TEXT,
47
47
  created_at TEXT NOT NULL
48
48
  );
49
+
50
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
51
+ CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
52
+ CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
49
53
  `);
50
54
  }
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { serve } from '@hono/node-server';
6
6
  import { Hono } from 'hono';
7
7
  import { WebSocketServer } from 'ws';
8
- import { getDb } from './db/index.js';
8
+ import { closeDb, getDb } from './db/index.js';
9
9
  import { runMigrations } from './db/migrations.js';
10
10
  import devServerRouter from './routes/dev-server.js';
11
11
  import gitRouter from './routes/git.js';
@@ -13,12 +13,13 @@ import imagesRouter from './routes/images.js';
13
13
  import notionRouter from './routes/notion.js';
14
14
  import settingsRouter from './routes/settings.js';
15
15
  import workspacesRouter from './routes/workspaces.js';
16
- import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, } from './services/agent-manager.js';
16
+ import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent-manager.js';
17
17
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
18
+ import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
18
19
  import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
19
20
  import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
20
- import { getClientSpaPath, getKoboHome } from './utils/paths.js';
21
- import { initProcessCleanup } from './utils/process-tracker.js';
21
+ import { getClientSpaPath, getKoboHome, getPackageVersion } from './utils/paths.js';
22
+ import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
22
23
  // 0. Runtime prerequisite check — warn if claude CLI is missing. Don't block
23
24
  // startup: the user may still want to configure settings or browse workspaces
24
25
  // before installing Claude Code.
@@ -35,12 +36,11 @@ runMigrations(db);
35
36
  // 2. Initialize process cleanup, agent watchdog, and PR watcher
36
37
  initProcessCleanup();
37
38
  startWatchdog();
38
- import { startPrWatcher } from './services/pr-watcher-service.js';
39
39
  startPrWatcher();
40
40
  // 3. Create Hono app
41
41
  const app = new Hono();
42
42
  // Health check (root / is handled by the SPA catch-all below)
43
- app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
43
+ app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
44
44
  // 4. Mount route sub-routers
45
45
  app.route('/api/workspaces', workspacesRouter);
46
46
  app.route('/api/workspaces', imagesRouter);
@@ -50,7 +50,7 @@ app.route('/api/settings', settingsRouter);
50
50
  app.route('/api/dev-server', devServerRouter);
51
51
  // Skills endpoint
52
52
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
53
- const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
53
+ const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
54
54
  // 9. Serve static files from the built SPA if present (production mode).
55
55
  // The path is resolved relative to the package install directory, so this
56
56
  // works both in dev (tsx running from src/) and when installed via npm / npx
@@ -182,3 +182,54 @@ server.on('upgrade', (request, socket, head) => {
182
182
  socket.destroy();
183
183
  }
184
184
  });
185
+ // 9. Graceful shutdown handler
186
+ let isShuttingDown = false;
187
+ function gracefulShutdown(signal) {
188
+ if (isShuttingDown)
189
+ return;
190
+ isShuttingDown = true;
191
+ console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
192
+ // 1. Stop accepting new connections
193
+ wss.close(() => {
194
+ console.log('[kobo] WebSocket server closed');
195
+ });
196
+ server.close(() => {
197
+ console.log('[kobo] HTTP server closed');
198
+ });
199
+ // 2. Stop background services
200
+ try {
201
+ stopWatchdog();
202
+ }
203
+ catch {
204
+ // Best-effort
205
+ }
206
+ try {
207
+ stopPrWatcher();
208
+ }
209
+ catch {
210
+ // Best-effort
211
+ }
212
+ // 3. Kill all tracked child processes (agents, dev servers)
213
+ try {
214
+ killAllTrackedProcesses();
215
+ console.log('[kobo] Tracked processes killed');
216
+ }
217
+ catch {
218
+ // Best-effort
219
+ }
220
+ // 4. Close database
221
+ try {
222
+ closeDb();
223
+ console.log('[kobo] Database closed');
224
+ }
225
+ catch {
226
+ // Best-effort
227
+ }
228
+ // 4. Give a short grace period for in-flight requests, then exit
229
+ setTimeout(() => {
230
+ console.log('[kobo] Shutdown complete');
231
+ process.exit(0);
232
+ }, 2000).unref();
233
+ }
234
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
235
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
@@ -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';
@@ -110,9 +116,7 @@ app.post('/', async (c) => {
110
116
  // 4b. Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
111
117
  // itself may contain project files that SHOULD be committed).
112
118
  try {
113
- const fs = await import('node:fs');
114
- const pathMod = await import('node:path');
115
- const gitignorePath = pathMod.default.join(worktreePath, '.gitignore');
119
+ const gitignorePath = path.join(worktreePath, '.gitignore');
116
120
  const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
117
121
  const lines = existing.split('\n').map((l) => l.trim());
118
122
  const toAdd = [];
@@ -134,11 +138,9 @@ app.post('/', async (c) => {
134
138
  const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
135
139
  if (effectiveSettings.gitConventions) {
136
140
  try {
137
- const fs = await import('node:fs');
138
- const path = await import('node:path');
139
- const aiDir = path.default.join(worktreePath, '.ai');
141
+ const aiDir = path.join(worktreePath, '.ai');
140
142
  fs.mkdirSync(aiDir, { recursive: true });
141
- const conventionsPath = path.default.join(aiDir, 'git-conventions.md');
143
+ const conventionsPath = path.join(aiDir, 'git-conventions.md');
142
144
  fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
143
145
  }
144
146
  catch (err) {
@@ -149,16 +151,14 @@ app.post('/', async (c) => {
149
151
  let notionFilePath = null;
150
152
  if (notionContent && body.notionUrl) {
151
153
  try {
152
- const fs = await import('node:fs');
153
- const path = await import('node:path');
154
- const thoughtsDir = path.default.join(worktreePath, '.ai', 'thoughts');
154
+ const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
155
155
  fs.mkdirSync(thoughtsDir, { recursive: true });
156
156
  // Derive filename from title (TK-XXX pattern or slug)
157
157
  const tkMatch = workspace.name.match(/TK-\d+/i);
158
158
  const filename = tkMatch
159
159
  ? `${tkMatch[0]}.md`
160
160
  : `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
161
- notionFilePath = path.default.join(thoughtsDir, filename);
161
+ notionFilePath = path.join(thoughtsDir, filename);
162
162
  const today = new Date().toISOString().split('T')[0];
163
163
  let md = `# ${workspace.name}\n\n`;
164
164
  md += `## Source\n\n`;
@@ -219,8 +219,7 @@ app.post('/', async (c) => {
219
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.`;
220
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.`;
221
221
  // Persist the initial prompt in the feed so it's visible in the chat
222
- const { emit } = await import('../services/websocket-service.js');
223
- emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
222
+ wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
224
223
  try {
225
224
  agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
226
225
  }
@@ -269,7 +268,7 @@ app.post('/:id/refresh-notion', async (c) => {
269
268
  return c.json({ error: 'No Notion URL configured' }, 400);
270
269
  const notionContent = await notionService.extractNotionPage(workspace.notionUrl);
271
270
  // Delete existing tasks and recreate from Notion
272
- const db = (await import('../db/index.js')).getDb();
271
+ const db = getDb();
273
272
  db.prepare('DELETE FROM tasks WHERE workspace_id = ?').run(id);
274
273
  let sortOrder = 0;
275
274
  for (const todo of notionContent.todos) {
@@ -327,7 +326,16 @@ app.post('/:id/tasks', async (c) => {
327
326
  // PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
328
327
  app.patch('/:id/tasks/:taskId', async (c) => {
329
328
  try {
329
+ const id = c.req.param('id');
330
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
+ }
331
339
  const body = await c.req.json();
332
340
  if (body.status === undefined && body.title === undefined) {
333
341
  return c.json({ error: 'At least one of status or title is required' }, 400);
@@ -361,6 +369,10 @@ app.delete('/:id/tasks/:taskId', (c) => {
361
369
  if (!workspace) {
362
370
  return c.json({ error: `Workspace '${id}' not found` }, 404);
363
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
+ }
364
376
  workspaceService.deleteTask(taskId);
365
377
  return new Response(null, { status: 204 });
366
378
  }
@@ -369,6 +381,24 @@ app.delete('/:id/tasks/:taskId', (c) => {
369
381
  return c.json({ error: message }, 500);
370
382
  }
371
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
+ });
372
402
  // POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
373
403
  app.post('/:id/tasks/:taskId/notify-done', (c) => {
374
404
  try {
@@ -386,16 +416,66 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
386
416
  return c.json({ error: message }, 500);
387
417
  }
388
418
  });
389
- // POST /api/workspaces/:id/tasks/notify-updatedbroadcast generic task list change
390
- 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) => {
391
421
  try {
392
422
  const id = c.req.param('id');
393
423
  const workspace = workspaceService.getWorkspace(id);
394
424
  if (!workspace) {
395
425
  return c.json({ error: `Workspace '${id}' not found` }, 404);
396
426
  }
397
- wsService.emit(id, 'task:updated', {});
398
- 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 });
399
479
  }
400
480
  catch (err) {
401
481
  const message = err instanceof Error ? err.message : String(err);
@@ -612,12 +692,12 @@ app.get('/:id/git-stats', async (c) => {
612
692
  if (!workspace) {
613
693
  return c.json({ error: `Workspace '${id}' not found` }, 404);
614
694
  }
615
- const path = await import('node:path');
616
- const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
695
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
617
696
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
618
697
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
619
- const pr = gitOps.getPrStatus(workspace.projectPath, workspace.workingBranch);
620
- const unpushedCount = gitOps.getUnpushedCount(worktreePath);
698
+ const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
699
+ const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath);
700
+ const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
621
701
  return c.json({
622
702
  commitCount,
623
703
  filesChanged: diffStats.filesChanged,
@@ -626,6 +706,7 @@ app.get('/:id/git-stats', async (c) => {
626
706
  prUrl: pr?.url ?? null,
627
707
  prState: pr?.state ?? null,
628
708
  unpushedCount,
709
+ workingTree,
629
710
  });
630
711
  }
631
712
  catch (err) {
@@ -633,6 +714,49 @@ app.get('/:id/git-stats', async (c) => {
633
714
  return c.json({ error: message }, 500);
634
715
  }
635
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
+ });
636
760
  // POST /api/workspaces/:id/push — push working branch to origin
637
761
  app.post('/:id/push', async (c) => {
638
762
  try {
@@ -641,8 +765,7 @@ app.post('/:id/push', async (c) => {
641
765
  if (!workspace) {
642
766
  return c.json({ error: `Workspace '${id}' not found` }, 404);
643
767
  }
644
- const path = await import('node:path');
645
- const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
768
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
646
769
  try {
647
770
  gitOps.pushBranch(worktreePath, workspace.workingBranch);
648
771
  }
@@ -651,10 +774,9 @@ app.post('/:id/push', async (c) => {
651
774
  return c.json({ error: message }, 500);
652
775
  }
653
776
  // Emit a trace into the chat feed so the user sees the action
654
- const { emit } = await import('../services/websocket-service.js');
655
777
  const session = workspaceService.getLatestSession(id);
656
778
  const sessionId = session?.claudeSessionId ?? undefined;
657
- 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);
658
780
  return c.json({ ok: true, branch: workspace.workingBranch });
659
781
  }
660
782
  catch (err) {
@@ -670,15 +792,14 @@ app.post('/:id/open-pr', async (c) => {
670
792
  if (!workspace) {
671
793
  return c.json({ error: `Workspace '${id}' not found` }, 404);
672
794
  }
673
- const path = await import('node:path');
674
- const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
795
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
675
796
  // 1. Check branch is on remote
676
797
  let lsRemoteOut = '';
677
798
  try {
678
- const result = execFileSync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
799
+ const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
679
800
  cwd: worktreePath,
680
801
  });
681
- lsRemoteOut = result.toString();
802
+ lsRemoteOut = stdout;
682
803
  }
683
804
  catch {
684
805
  lsRemoteOut = '';
@@ -688,8 +809,8 @@ app.post('/:id/open-pr', async (c) => {
688
809
  }
689
810
  // 2. Check all local commits are pushed
690
811
  try {
691
- const result = execFileSync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
692
- const countStr = result.toString().trim();
812
+ const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
813
+ const countStr = stdout.trim();
693
814
  const count = parseInt(countStr, 10) || 0;
694
815
  if (count > 0) {
695
816
  return c.json({ error: 'Local commits not pushed', code: 'unpushed_commits' }, 409);
@@ -708,7 +829,7 @@ app.post('/:id/open-pr', async (c) => {
708
829
  let ghOutput;
709
830
  try {
710
831
  const placeholderBody = 'Automated PR — description will be updated by the agent.';
711
- const result = execFileSync('gh', [
832
+ const { stdout } = await execFileAsync('gh', [
712
833
  'pr',
713
834
  'create',
714
835
  '--base',
@@ -720,7 +841,7 @@ app.post('/:id/open-pr', async (c) => {
720
841
  '--body',
721
842
  placeholderBody,
722
843
  ], { cwd: worktreePath });
723
- ghOutput = result.toString();
844
+ ghOutput = stdout;
724
845
  }
725
846
  catch (err) {
726
847
  const message = err instanceof Error ? err.message : String(err);
@@ -741,7 +862,6 @@ app.post('/:id/open-pr', async (c) => {
741
862
  return c.json({ ok: true, prNumber, prUrl, messageSent: false });
742
863
  }
743
864
  // 6. Build context and render the template
744
- const { renderPrTemplate } = await import('../services/pr-template-service.js');
745
865
  const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
746
866
  const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
747
867
  const tasks = workspaceService.listTasks(workspace.id);
@@ -754,10 +874,9 @@ app.post('/:id/open-pr', async (c) => {
754
874
  tasks,
755
875
  });
756
876
  // 7. Emit user:message into the chat feed
757
- const { emit } = await import('../services/websocket-service.js');
758
877
  const session = workspaceService.getLatestSession(workspace.id);
759
878
  const sessionId = session?.claudeSessionId ?? undefined;
760
- emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
879
+ wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
761
880
  // 8. Send to the running agent, or resume the agent with the PR prompt
762
881
  let messageSent = false;
763
882
  try {
@@ -308,7 +308,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
308
308
  const text = data.toString();
309
309
  const lowerText = text.toLowerCase();
310
310
  if (lowerText.includes('rate limit') || lowerText.includes('quota') || lowerText.includes('limit exceeded')) {
311
- handleQuota(workspaceId, workingDir, agent.claudeSessionId);
311
+ handleQuota(workspaceId, agent.claudeSessionId);
312
312
  }
313
313
  // Also emit stderr for visibility
314
314
  emit(workspaceId, 'agent:stderr', { content: text }, agent.claudeSessionId);
@@ -325,7 +325,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
325
325
  // I3: Close readline interface to release the stream reference
326
326
  agent.rl.close();
327
327
  unregisterProcess(workspaceId);
328
- 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
+ }
329
335
  // Clean up retry state and inactivity timer
330
336
  retryCounts.delete(workspaceId);
331
337
  // C2: Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
@@ -382,6 +388,10 @@ export function stopAgent(workspaceId) {
382
388
  throw new Error(`No agent running for workspace '${workspaceId}'`);
383
389
  }
384
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);
385
395
  // Cancel any pending backoff timer
386
396
  const timer = backoffTimers.get(workspaceId);
387
397
  if (timer) {
@@ -404,8 +414,10 @@ export function stopAgent(workspaceId) {
404
414
  }
405
415
  // After 5s timeout, send SIGKILL if still running
406
416
  const killTimer = setTimeout(() => {
407
- // C2: Guard against race with natural exit only act if this exact agent instance is still current
408
- 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) {
409
421
  killTimers.delete(workspaceId);
410
422
  return;
411
423
  }
@@ -446,7 +458,7 @@ export function getAvailableSkills() {
446
458
  return availableSkills;
447
459
  }
448
460
  // ── Quota handling ─────────────────────────────────────────────────────────────
449
- function handleQuota(workspaceId, workingDir, claudeSessionId) {
461
+ function handleQuota(workspaceId, claudeSessionId) {
450
462
  // Update workspace status
451
463
  try {
452
464
  updateWorkspaceStatus(workspaceId, 'quota');
@@ -471,8 +483,14 @@ function handleQuota(workspaceId, workingDir, claudeSessionId) {
471
483
  backoffTimers.delete(workspaceId);
472
484
  // Only restart if not already running or stopped
473
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
+ }
474
491
  try {
475
- 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);
476
494
  }
477
495
  catch {
478
496
  // Agent restart failed