@loicngr/kobo 1.4.5 → 1.4.7

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 +1 -1
  2. package/README.md +2 -2
  3. package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
  4. package/dist/mcp-server/kobo-tasks-server.js +37 -2
  5. package/dist/server/db/index.js +5 -0
  6. package/dist/server/db/migrations.js +9 -0
  7. package/dist/server/db/schema.js +2 -0
  8. package/dist/server/index.js +17 -18
  9. package/dist/server/routes/dev-server.js +1 -0
  10. package/dist/server/routes/git.js +1 -0
  11. package/dist/server/routes/images.js +3 -0
  12. package/dist/server/routes/notion.js +1 -0
  13. package/dist/server/routes/settings.js +1 -0
  14. package/dist/server/routes/workspaces.js +166 -51
  15. package/dist/server/services/agent-manager.js +35 -9
  16. package/dist/server/services/image-service.js +2 -1
  17. package/dist/server/services/notion-service.js +14 -18
  18. package/dist/server/services/pr-watcher-service.js +2 -0
  19. package/dist/server/services/settings-service.js +33 -6
  20. package/dist/server/services/setup-script-service.js +1 -0
  21. package/dist/server/services/websocket-service.js +8 -9
  22. package/dist/server/services/workspace-service.js +33 -2
  23. package/dist/server/services/worktree-service.js +4 -2
  24. package/dist/server/utils/git-ops.js +19 -5
  25. package/dist/server/utils/process-tracker.js +7 -0
  26. package/package.json +1 -1
  27. package/src/client/dist/spa/assets/ActivityFeed-BXrsWU-N.css +1 -0
  28. package/src/client/dist/spa/assets/ActivityFeed-Zg4aFWHr.js +68 -0
  29. package/src/client/dist/spa/assets/CreatePage-B4NEzk8h.js +2 -0
  30. package/src/client/dist/spa/assets/CreatePage-Cb9tgQ57.css +1 -0
  31. package/src/client/dist/spa/assets/DiffViewer-CSwamnmH.js +2 -0
  32. package/src/client/dist/spa/assets/DiffViewer-DiHFLSk4.css +1 -0
  33. package/src/client/dist/spa/assets/MainLayout-DYu8fNdb.css +1 -0
  34. package/src/client/dist/spa/assets/MainLayout-To97f8bb.js +2 -0
  35. package/src/client/dist/spa/assets/QBadge-BBHYbjmH.js +1 -0
  36. package/src/client/dist/spa/assets/QCheckbox-C3OW_sBe.js +1 -0
  37. package/src/client/dist/spa/assets/QExpansionItem--CUnMqsJ.js +1 -0
  38. package/src/client/dist/spa/assets/QPage-DfVYLit9.js +1 -0
  39. package/src/client/dist/spa/assets/QSpinnerDots-DxFX2A_Y.js +1 -0
  40. package/src/client/dist/spa/assets/QTabPanels-CQiX55Zs.js +1 -0
  41. package/src/client/dist/spa/assets/QTooltip-DDQMcKoX.js +1 -0
  42. package/src/client/dist/spa/assets/SettingsPage-CEJA3OTN.css +1 -0
  43. package/src/client/dist/spa/assets/SettingsPage-DHVfZP9g.js +1 -0
  44. package/src/client/dist/spa/assets/TouchPan-DmgViFM9.js +1 -0
  45. package/src/client/dist/spa/assets/WorkspacePage-BV77y-1V.css +1 -0
  46. package/src/client/dist/spa/assets/WorkspacePage-BfUdWLME.js +2 -0
  47. package/src/client/dist/spa/assets/_plugin-vue_export-helper-D5gezOWD.js +1 -0
  48. package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-4AF4Bwjq.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Ctxw4rqS.js} +1 -1
  50. package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-BZM2ZVF-.js} +3 -3
  51. package/src/client/dist/spa/assets/format-DhM1gNfW.js +1 -0
  52. package/src/client/dist/spa/assets/formatters-BpjOVbqs.js +6 -0
  53. package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-nlLLg2wF.js} +1 -1
  54. package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-D1p1nhG6.js} +1 -1
  55. package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-B6WFBXSs.js} +1 -1
  56. package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-DLQXzxD7.js} +1 -1
  57. package/src/client/dist/spa/assets/i18n-CrbCJQHz.js +1 -0
  58. package/src/client/dist/spa/assets/i18n-uUJIn7l1.js +1 -0
  59. package/src/client/dist/spa/assets/index-CkkvRhkB.js +5 -0
  60. package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-Cuk4WgU2.js} +1 -1
  61. package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-CvIzW7YR.js} +1 -1
  62. package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BESgd7r_.js} +1 -1
  63. package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CjeTpIfQ.js} +1 -1
  64. package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-BdsaXuzN.js} +2 -2
  65. package/src/client/dist/spa/assets/nodes-Bj1I9JfN.js +1 -0
  66. package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-DjonvvLh.js} +1 -1
  67. package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CYj_TCQ0.js} +1 -1
  68. package/src/client/dist/spa/assets/settings-DQXlzOR-.js +1 -0
  69. package/src/client/dist/spa/assets/touch-Dm5n4n0E.js +1 -0
  70. package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-C8aV_UsX.js} +1 -1
  71. package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-Clp9vIWN.js} +1 -1
  72. package/src/client/dist/spa/assets/use-checkbox-Cfet_Yar.js +1 -0
  73. package/src/client/dist/spa/assets/use-quasar-Da_Py0Ib.js +1 -0
  74. package/src/client/dist/spa/assets/vue-i18n-nv59vAyH.js +3 -0
  75. package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-6gY0RxjJ.js} +1 -1
  76. package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-IQGDhYaJ.js} +1 -1
  77. package/src/client/dist/spa/index.html +5 -4
  78. package/src/client/dist/spa/notification.mp3 +0 -0
  79. package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
  80. package/src/mcp-server/kobo-tasks-server.ts +39 -2
  81. package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
  82. package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
  83. package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
  84. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
  85. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
  86. package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
  87. package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
  88. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
  89. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
  90. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
  91. package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
  92. package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
  93. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
  94. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
  95. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
  96. package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
  97. package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
  98. package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
  99. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
  100. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
  101. package/src/client/dist/spa/assets/formatters-CXx5Gzsp.js +0 -1
  102. package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
  103. package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
  104. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
  105. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
  106. package/src/client/dist/spa/assets/runtime-core.esm-bundler-BLPLlWMG.js +0 -1
  107. package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
  108. package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
  109. package/src/client/dist/spa/assets/vue-i18n-CoZsbeQK.js +0 -3
  110. /package/src/client/dist/spa/assets/{abap-Co3wj02O.js → abap-DVDKJwpW.js} +0 -0
  111. /package/src/client/dist/spa/assets/{apex-CUKwGs62.js → apex-DjF5nFAs.js} +0 -0
  112. /package/src/client/dist/spa/assets/{azcli-DMImymmY.js → azcli-HEPMaiDV.js} +0 -0
  113. /package/src/client/dist/spa/assets/{bat--P_y70-E.js → bat-D6epFECU.js} +0 -0
  114. /package/src/client/dist/spa/assets/{bicep-C3w6oSfK.js → bicep-BspG10fo.js} +0 -0
  115. /package/src/client/dist/spa/assets/{cameligo-D9NSR4Rj.js → cameligo-DM9kSiq7.js} +0 -0
  116. /package/src/client/dist/spa/assets/{clojure-BMcQme0t.js → clojure-CZn9pzfW.js} +0 -0
  117. /package/src/client/dist/spa/assets/{coffee-BbMZaWx7.js → coffee-Bu_NglwI.js} +0 -0
  118. /package/src/client/dist/spa/assets/{cpp-CbrtEGgw.js → cpp-0KJLHDue.js} +0 -0
  119. /package/src/client/dist/spa/assets/{csharp-Bc0fjUxA.js → csharp-DNzOiVZu.js} +0 -0
  120. /package/src/client/dist/spa/assets/{csp-DmbXuMT0.js → csp-CUkzJlR0.js} +0 -0
  121. /package/src/client/dist/spa/assets/{css-gdwCt5by.js → css-Uav73wXk.js} +0 -0
  122. /package/src/client/dist/spa/assets/{cypher-ocmmfoQr.js → cypher-CP6eoQBS.js} +0 -0
  123. /package/src/client/dist/spa/assets/{dart-DbZ5eklb.js → dart-Bdl32fSd.js} +0 -0
  124. /package/src/client/dist/spa/assets/{dockerfile-BLaMayDc.js → dockerfile-BIRJ7ZNM.js} +0 -0
  125. /package/src/client/dist/spa/assets/{ecl-LxXpHirr.js → ecl-Di24nx2U.js} +0 -0
  126. /package/src/client/dist/spa/assets/{elixir-C_geKt5o.js → elixir-DzFG1iYF.js} +0 -0
  127. /package/src/client/dist/spa/assets/{flow9-DE2fI2ca.js → flow9-DNgNh1TU.js} +0 -0
  128. /package/src/client/dist/spa/assets/{fsharp-CJD6fImD.js → fsharp-CuO6_Oy9.js} +0 -0
  129. /package/src/client/dist/spa/assets/{go-jUCqQ7bD.js → go-Pj7ToRvM.js} +0 -0
  130. /package/src/client/dist/spa/assets/{graphql-rw7g9h7D.js → graphql-BoaedU4s.js} +0 -0
  131. /package/src/client/dist/spa/assets/{hcl-BKX27Mn7.js → hcl-olXtyJcc.js} +0 -0
  132. /package/src/client/dist/spa/assets/{ini-CrXjga2H.js → ini-BvGNUo-D.js} +0 -0
  133. /package/src/client/dist/spa/assets/{java-D4jksGBb.js → java-Z9-7Isu7.js} +0 -0
  134. /package/src/client/dist/spa/assets/{julia-CbWxfkeS.js → julia-Bdcb8Lkm.js} +0 -0
  135. /package/src/client/dist/spa/assets/{kotlin-B26Yx80V.js → kotlin-DR_I1UW_.js} +0 -0
  136. /package/src/client/dist/spa/assets/{less-DFzn-zC9.js → less-DZxcoWKd.js} +0 -0
  137. /package/src/client/dist/spa/assets/{lexon-C-w-W8Yv.js → lexon-s17AK9YH.js} +0 -0
  138. /package/src/client/dist/spa/assets/{lua-CHuE_HoG.js → lua-BzLfjAeg.js} +0 -0
  139. /package/src/client/dist/spa/assets/{m3-DEFZN2qS.js → m3-DN3Xgolo.js} +0 -0
  140. /package/src/client/dist/spa/assets/{markdown-Cbt4TlFt.js → markdown-DCCTbSQf.js} +0 -0
  141. /package/src/client/dist/spa/assets/{mips-C6m4XECw.js → mips-lh5qv6lw.js} +0 -0
  142. /package/src/client/dist/spa/assets/{msdax-un0CFb_S.js → msdax-ikHtaqdR.js} +0 -0
  143. /package/src/client/dist/spa/assets/{mysql-CuAPeiOV.js → mysql-yyeeFEN0.js} +0 -0
  144. /package/src/client/dist/spa/assets/{objective-c-DLVMdxAC.js → objective-c-BCpwk8Ct.js} +0 -0
  145. /package/src/client/dist/spa/assets/{pascal-BGCThuPY.js → pascal-PL6H1Gn7.js} +0 -0
  146. /package/src/client/dist/spa/assets/{pascaligo-DfxSVpdo.js → pascaligo-BCtUX6M4.js} +0 -0
  147. /package/src/client/dist/spa/assets/{perl-BOE6y94t.js → perl-DuluA5AL.js} +0 -0
  148. /package/src/client/dist/spa/assets/{pgsql-Dn7JkY4F.js → pgsql-B4LkSOFV.js} +0 -0
  149. /package/src/client/dist/spa/assets/{php-r1gD0KyT.js → php-B3Ske963.js} +0 -0
  150. /package/src/client/dist/spa/assets/{pla-CgXknhb0.js → pla-B0vdKSVA.js} +0 -0
  151. /package/src/client/dist/spa/assets/{postiats-CsIEtnRB.js → postiats-lYIY9h0z.js} +0 -0
  152. /package/src/client/dist/spa/assets/{powerquery-yNJCmC_6.js → powerquery-e_CNZlRH.js} +0 -0
  153. /package/src/client/dist/spa/assets/{powershell-CQcz1SqH.js → powershell-B7ny7eNr.js} +0 -0
  154. /package/src/client/dist/spa/assets/{protobuf-BmC34uvO.js → protobuf-T5b6INIm.js} +0 -0
  155. /package/src/client/dist/spa/assets/{pug-C20znvWM.js → pug-DB5iDfTd.js} +0 -0
  156. /package/src/client/dist/spa/assets/{qsharp-B7bnARMS.js → qsharp-DwB29woK.js} +0 -0
  157. /package/src/client/dist/spa/assets/{r-ClvcLdqC.js → r-DGAconUr.js} +0 -0
  158. /package/src/client/dist/spa/assets/{redis-DCyda7_S.js → redis-CBlUxt29.js} +0 -0
  159. /package/src/client/dist/spa/assets/{redshift-BtWDr4pb.js → redshift-DUdydw8b.js} +0 -0
  160. /package/src/client/dist/spa/assets/{restructuredtext-CLcnlkhl.js → restructuredtext-CpFidj3o.js} +0 -0
  161. /package/src/client/dist/spa/assets/{ruby-DY0SOSSZ.js → ruby-DSI1pDHV.js} +0 -0
  162. /package/src/client/dist/spa/assets/{rust-JQd-fJZI.js → rust-DuAjdlB2.js} +0 -0
  163. /package/src/client/dist/spa/assets/{sb-BV2j8yFF.js → sb-DHK18yUj.js} +0 -0
  164. /package/src/client/dist/spa/assets/{scala-DwbnREDs.js → scala-OUOqN_hV.js} +0 -0
  165. /package/src/client/dist/spa/assets/{scheme-CrtA-vei.js → scheme-1CW-bJnQ.js} +0 -0
  166. /package/src/client/dist/spa/assets/{scss-VxQz3zmI.js → scss-C5o7X-EM.js} +0 -0
  167. /package/src/client/dist/spa/assets/{shell-CP9faqFI.js → shell-CtGzMV7Q.js} +0 -0
  168. /package/src/client/dist/spa/assets/{solidity-9IIb0b89.js → solidity-DPkz2VqZ.js} +0 -0
  169. /package/src/client/dist/spa/assets/{sophia-D2LQU2AD.js → sophia-nnptfdLN.js} +0 -0
  170. /package/src/client/dist/spa/assets/{sparql-DONCa5dy.js → sparql-C6bexdnc.js} +0 -0
  171. /package/src/client/dist/spa/assets/{sql-DaAAHGEt.js → sql-CcLlvkTm.js} +0 -0
  172. /package/src/client/dist/spa/assets/{st-CRY2V-j3.js → st-C7Y7CLp_.js} +0 -0
  173. /package/src/client/dist/spa/assets/{swift-BlKbfloF.js → swift-7Jj4IMNp.js} +0 -0
  174. /package/src/client/dist/spa/assets/{systemverilog-B_h9Q_T_.js → systemverilog-BBh4Cn1v.js} +0 -0
  175. /package/src/client/dist/spa/assets/{tcl-C4wN3A6M.js → tcl-BivXXY1v.js} +0 -0
  176. /package/src/client/dist/spa/assets/{twig-DDdaBLC9.js → twig-CuImSpsA.js} +0 -0
  177. /package/src/client/dist/spa/assets/{typespec-Dc1ipt8A.js → typespec-s1tz7uxH.js} +0 -0
  178. /package/src/client/dist/spa/assets/{vb-C4BXIvrh.js → vb-AURJL6ia.js} +0 -0
  179. /package/src/client/dist/spa/assets/{wgsl-XVg3Pi-r.js → wgsl-Dq1-vM4I.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { execFile as execFileCb } from 'node:child_process';
1
+ import { execFile as execFileCb, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  const execFileAsync = promisify(execFileCb);
4
4
  import fs from 'node:fs';
@@ -15,8 +15,10 @@ import * as wsService from '../services/websocket-service.js';
15
15
  import * as workspaceService from '../services/workspace-service.js';
16
16
  import * as worktreeService from '../services/worktree-service.js';
17
17
  import * as gitOps from '../utils/git-ops.js';
18
+ /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
18
19
  const app = new Hono();
19
- // GET /api/workspaces list all workspaces
20
+ /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
21
+ const setupScriptRunning = new Set();
20
22
  app.get('/', (c) => {
21
23
  try {
22
24
  const workspaces = workspaceService.listWorkspaces();
@@ -34,7 +36,7 @@ app.post('/', async (c) => {
34
36
  if (!body.name || !body.projectPath || !body.sourceBranch || !body.workingBranch) {
35
37
  return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
36
38
  }
37
- // 1. Create workspace
39
+ // Create workspace record
38
40
  let workspace = workspaceService.createWorkspace({
39
41
  name: body.name,
40
42
  projectPath: body.projectPath,
@@ -45,7 +47,7 @@ app.post('/', async (c) => {
45
47
  model: body.model,
46
48
  });
47
49
  let notionContent = null;
48
- // 2. If notionUrl provided, extract Notion page
50
+ // Extract Notion page content if a URL was provided
49
51
  if (body.notionUrl) {
50
52
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
51
53
  try {
@@ -56,7 +58,7 @@ app.post('/', async (c) => {
56
58
  console.error(`[workspaces] Failed to extract Notion page: ${message}`);
57
59
  }
58
60
  }
59
- // 3. Create tasks from extracted data
61
+ // Create tasks from extracted Notion data
60
62
  if (notionContent) {
61
63
  let sortOrder = 0;
62
64
  for (const todo of notionContent.todos) {
@@ -104,7 +106,7 @@ app.post('/', async (c) => {
104
106
  }
105
107
  }
106
108
  }
107
- // 4. Create worktree
109
+ // Create git worktree for the working branch
108
110
  let worktreePath;
109
111
  try {
110
112
  worktreePath = worktreeService.createWorktree(body.projectPath, body.workingBranch, body.sourceBranch);
@@ -114,45 +116,54 @@ app.post('/', async (c) => {
114
116
  workspaceService.updateWorkspaceStatus(workspace.id, 'error');
115
117
  return c.json({ error: `Failed to create worktree: ${message}` }, 500);
116
118
  }
117
- // 4b. Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
118
- // itself may contain project files that SHOULD be committed).
119
+ // Ensure Kobo-generated files are gitignored. Check both the root
120
+ // .gitignore and .ai/.gitignore to avoid duplicate entries.
119
121
  try {
120
- const gitignorePath = path.join(worktreePath, '.gitignore');
121
- const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
122
- const lines = existing.split('\n').map((l) => l.trim());
122
+ const rootGitignorePath = path.join(worktreePath, '.gitignore');
123
+ const aiGitignorePath = path.join(worktreePath, '.ai', '.gitignore');
124
+ const rootContent = fs.existsSync(rootGitignorePath) ? fs.readFileSync(rootGitignorePath, 'utf-8') : '';
125
+ const rootLines = rootContent.split('\n').map((l) => l.trim());
126
+ const aiContent = fs.existsSync(aiGitignorePath) ? fs.readFileSync(aiGitignorePath, 'utf-8') : '';
127
+ const aiLines = aiContent.split('\n').map((l) => l.trim());
128
+ // Each entry: [pattern for root .gitignore, equivalent pattern in .ai/.gitignore]
129
+ const entries = [
130
+ ['.ai/.git-conventions.md', '.git-conventions.md'],
131
+ ['.ai/thoughts/', 'thoughts/'],
132
+ ['.ai/images/', 'images/'],
133
+ ['.ai/.setup-script.tmp', '.setup-script.tmp'],
134
+ ['.mcp.json', ''],
135
+ ];
123
136
  const toAdd = [];
124
- if (!lines.includes('.ai/git-conventions.md'))
125
- toAdd.push('.ai/git-conventions.md');
126
- if (!lines.includes('.ai/thoughts/'))
127
- toAdd.push('.ai/thoughts/');
128
- if (!lines.includes('.ai/images/'))
129
- toAdd.push('.ai/images/');
130
- if (!lines.includes('.ai/.setup-script.tmp'))
131
- toAdd.push('.ai/.setup-script.tmp');
137
+ for (const [rootPattern, aiPattern] of entries) {
138
+ const inRoot = rootLines.includes(rootPattern);
139
+ const inAi = aiPattern && aiLines.includes(aiPattern);
140
+ if (!inRoot && !inAi)
141
+ toAdd.push(rootPattern);
142
+ }
132
143
  if (toAdd.length > 0) {
133
- const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
134
- fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
144
+ const separator = rootContent.length > 0 && !rootContent.endsWith('\n') ? '\n' : '';
145
+ fs.appendFileSync(rootGitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
135
146
  }
136
147
  }
137
148
  catch (err) {
138
149
  console.error('[workspaces] Failed to update .gitignore:', err);
139
150
  }
140
- // 4c. Write git conventions to the worktree if configured
151
+ // Write git conventions to the worktree if configured
141
152
  const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
142
153
  if (effectiveSettings.gitConventions) {
143
154
  try {
144
155
  const aiDir = path.join(worktreePath, '.ai');
145
156
  fs.mkdirSync(aiDir, { recursive: true });
146
- const conventionsPath = path.join(aiDir, 'git-conventions.md');
157
+ const conventionsPath = path.join(aiDir, '.git-conventions.md');
147
158
  fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
148
159
  }
149
160
  catch (err) {
150
- console.error('[workspaces] Failed to write git-conventions.md:', err);
161
+ console.error('[workspaces] Failed to write .git-conventions.md:', err);
151
162
  }
152
163
  }
153
- // 4d. Run setup script if configured
164
+ // Run setup script if configured and not skipped
154
165
  let setupScriptFailed = false;
155
- if (effectiveSettings.setupScript) {
166
+ if (effectiveSettings.setupScript && !body.skipSetupScript) {
156
167
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
157
168
  wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
158
169
  try {
@@ -174,17 +185,20 @@ app.post('/', async (c) => {
174
185
  setupScriptFailed = true;
175
186
  }
176
187
  }
177
- // 5. Save Notion content as markdown in worktree
188
+ // Save Notion content as markdown in worktree
178
189
  let notionFilePath = null;
179
190
  if (notionContent && body.notionUrl) {
180
191
  try {
181
192
  const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
182
193
  fs.mkdirSync(thoughtsDir, { recursive: true });
183
- // Derive filename from title (TK-XXX pattern or slug)
184
- const tkMatch = workspace.name.match(/TK-\d+/i);
185
- const filename = tkMatch
186
- ? `${tkMatch[0]}.md`
187
- : `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
194
+ // Derive filename from Notion ticket ID, or fallback to branch/name pattern
195
+ const notionTicketId = notionContent.ticketId;
196
+ const fallbackMatch = `${workspace.name} ${body.workingBranch}`.match(/TK-\d+/i);
197
+ const filename = notionTicketId
198
+ ? `${notionTicketId.toUpperCase()}.md`
199
+ : fallbackMatch
200
+ ? `${fallbackMatch[0].toUpperCase()}.md`
201
+ : `PAGE-${notionService.parseNotionUrl(body.notionUrl).replace(/-/g, '')}.md`;
188
202
  notionFilePath = path.join(thoughtsDir, filename);
189
203
  const today = new Date().toISOString().split('T')[0];
190
204
  let md = `# ${workspace.name}\n\n`;
@@ -215,17 +229,22 @@ app.post('/', async (c) => {
215
229
  }
216
230
  // Skip agent launch if setup script failed — workspace stays in 'error' status
217
231
  if (!setupScriptFailed) {
218
- // 6. Update workspace status to 'brainstorming'
232
+ // Transition to brainstorming and build the initial agent prompt
219
233
  workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
220
- // 6. Build prompt with tasks and acceptance criteria
234
+ // Build prompt with tasks and acceptance criteria
221
235
  const allTasks = workspaceService.listTasks(workspace.id);
222
236
  const todos = allTasks.filter((t) => !t.isAcceptanceCriterion);
223
237
  const criteria = allTasks.filter((t) => t.isAcceptanceCriterion);
224
238
  let brainstormPrompt = `You are working on: ${workspace.name}\n`;
239
+ // Include ticket ID if found so the agent uses the correct reference
240
+ const ticketId = notionContent?.ticketId || `${workspace.name} ${body.workingBranch}`.match(/TK-\d+/i)?.[0];
241
+ if (ticketId) {
242
+ brainstormPrompt += `Ticket: ${ticketId.toUpperCase()}\n`;
243
+ }
225
244
  if (notionContent?.goal) {
226
245
  brainstormPrompt += `\nGoal: ${notionContent.goal}\n`;
227
246
  }
228
- brainstormPrompt += `\nBranch: ${body.workingBranch}\n`;
247
+ brainstormPrompt += `\nBranch: ${body.workingBranch}\nSource branch: ${body.sourceBranch}\nIMPORTANT: When creating a pull request, always use --base ${body.sourceBranch} to target the correct source branch.\n`;
229
248
  if (notionFilePath) {
230
249
  brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
231
250
  brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
@@ -236,14 +255,17 @@ app.post('/', async (c) => {
236
255
  if (criteria.length > 0) {
237
256
  brainstormPrompt += `\nAcceptance criteria:\n${criteria.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
238
257
  }
258
+ brainstormPrompt += `\nYou have access to MCP tools via the 'kobo-tasks' server:\n`;
239
259
  if (criteria.length > 0 || todos.length > 0) {
240
- brainstormPrompt += `\nYou have access to MCP tools via the 'kobo-tasks' server:\n`;
241
260
  brainstormPrompt += `- list_tasks() — list all tasks and criteria with their IDs and current status\n`;
242
261
  brainstormPrompt += `- mark_task_done(task_id) — mark a task or criterion as done\n`;
243
- brainstormPrompt += `\nAs you implement the work and validate each criterion, call mark_task_done with the corresponding task_id. Call list_tasks first to see the current IDs.\n`;
262
+ brainstormPrompt += `\nAs you work, keep the task list up to date: call mark_task_done(task_id) as soon as you complete a task or validate a criterion — don't wait until the end. Call list_tasks() first to see the current IDs.\n`;
263
+ }
264
+ if (body.notionUrl) {
265
+ brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
244
266
  }
245
267
  if (effectiveSettings.gitConventions) {
246
- brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
268
+ brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
247
269
  }
248
270
  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.`;
249
271
  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.`;
@@ -576,6 +598,82 @@ app.patch('/:id', async (c) => {
576
598
  return c.json({ error: message }, 500);
577
599
  }
578
600
  });
601
+ /** Open the workspace worktree in the user's configured editor. */
602
+ app.post('/:id/open-editor', (c) => {
603
+ try {
604
+ const id = c.req.param('id');
605
+ const workspace = workspaceService.getWorkspace(id);
606
+ if (!workspace)
607
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
608
+ const globalSettings = settingsService.getGlobalSettings();
609
+ if (!globalSettings.editorCommand) {
610
+ return c.json({ error: 'No editor command configured' }, 400);
611
+ }
612
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
613
+ if (!fs.existsSync(worktreePath)) {
614
+ return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
615
+ }
616
+ const child = spawn(globalSettings.editorCommand, [worktreePath], {
617
+ detached: true,
618
+ stdio: 'ignore',
619
+ });
620
+ child.unref();
621
+ return c.json({ success: true });
622
+ }
623
+ catch (err) {
624
+ const message = err instanceof Error ? err.message : String(err);
625
+ return c.json({ error: message }, 500);
626
+ }
627
+ });
628
+ /** Re-run the project setup script in the workspace worktree. */
629
+ app.post('/:id/run-setup-script', async (c) => {
630
+ try {
631
+ const id = c.req.param('id');
632
+ const workspace = workspaceService.getWorkspace(id);
633
+ if (!workspace)
634
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
635
+ if (setupScriptRunning.has(id)) {
636
+ return c.json({ error: 'Setup script is already running for this workspace' }, 409);
637
+ }
638
+ // Stop the running agent before re-running the setup script
639
+ try {
640
+ if (agentManager.getAgentStatus(id)) {
641
+ agentManager.stopAgent(id);
642
+ }
643
+ }
644
+ catch {
645
+ /* best-effort — agent may already be stopped */
646
+ }
647
+ const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
648
+ if (!effectiveSettings.setupScript) {
649
+ return c.json({ error: 'No setup script configured' }, 400);
650
+ }
651
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
652
+ if (!fs.existsSync(worktreePath)) {
653
+ return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
654
+ }
655
+ setupScriptRunning.add(id);
656
+ try {
657
+ const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
658
+ workspaceName: workspace.name,
659
+ branchName: workspace.workingBranch,
660
+ sourceBranch: workspace.sourceBranch,
661
+ projectPath: workspace.projectPath,
662
+ });
663
+ if (result.exitCode !== 0) {
664
+ return c.json({ error: `Setup script failed with exit code ${result.exitCode}` }, 500);
665
+ }
666
+ return c.json({ success: true });
667
+ }
668
+ finally {
669
+ setupScriptRunning.delete(id);
670
+ }
671
+ }
672
+ catch (err) {
673
+ const message = err instanceof Error ? err.message : String(err);
674
+ return c.json({ error: message }, 500);
675
+ }
676
+ });
579
677
  // POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
580
678
  app.post('/:id/archive', (c) => {
581
679
  try {
@@ -641,14 +739,14 @@ app.delete('/:id', async (c) => {
641
739
  const body = await c.req
642
740
  .json()
643
741
  .catch(() => ({}));
644
- // 1. Stop agent if running
742
+ // Stop agent if running (best-effort)
645
743
  try {
646
744
  agentManager.stopAgent(id);
647
745
  }
648
746
  catch {
649
747
  // Agent may not be running — ignore
650
748
  }
651
- // 2. Remove worktree
749
+ // Remove worktree
652
750
  const worktreesDir = `${workspace.projectPath}/.worktrees`;
653
751
  const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
654
752
  try {
@@ -658,7 +756,7 @@ app.delete('/:id', async (c) => {
658
756
  const message = err instanceof Error ? err.message : String(err);
659
757
  console.error(`[workspaces] Failed to remove worktree: ${message}`);
660
758
  }
661
- // 3. Delete local branch if requested
759
+ // Delete local branch if requested
662
760
  if (body.deleteLocalBranch) {
663
761
  try {
664
762
  gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
@@ -668,7 +766,7 @@ app.delete('/:id', async (c) => {
668
766
  console.error(`[workspaces] Failed to delete local branch: ${message}`);
669
767
  }
670
768
  }
671
- // 4. Delete remote branch if requested
769
+ // Delete remote branch if requested
672
770
  if (body.deleteRemoteBranch) {
673
771
  try {
674
772
  gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
@@ -678,7 +776,7 @@ app.delete('/:id', async (c) => {
678
776
  console.error(`[workspaces] Failed to delete remote branch: ${message}`);
679
777
  }
680
778
  }
681
- // 5. Delete workspace from DB
779
+ // Delete workspace from DB (cascades to tasks, sessions, events)
682
780
  workspaceService.deleteWorkspace(id);
683
781
  return new Response(null, { status: 204 });
684
782
  }
@@ -823,7 +921,7 @@ app.post('/:id/open-pr', async (c) => {
823
921
  return c.json({ error: `Workspace '${id}' not found` }, 404);
824
922
  }
825
923
  const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
826
- // 1. Check branch is on remote
924
+ // Verify branch exists on remote
827
925
  let lsRemoteOut = '';
828
926
  try {
829
927
  const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
@@ -837,7 +935,7 @@ app.post('/:id/open-pr', async (c) => {
837
935
  if (!lsRemoteOut.trim()) {
838
936
  return c.json({ error: 'Branch is not on remote', code: 'branch_not_pushed' }, 409);
839
937
  }
840
- // 2. Check all local commits are pushed
938
+ // Ensure all local commits are pushed
841
939
  try {
842
940
  const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
843
941
  const countStr = stdout.trim();
@@ -855,7 +953,7 @@ app.post('/:id/open-pr', async (c) => {
855
953
  }
856
954
  return c.json({ error: `Failed to check branch state: ${message}` }, 500);
857
955
  }
858
- // 3. Create PR via gh
956
+ // Create PR via GitHub CLI
859
957
  let ghOutput;
860
958
  try {
861
959
  const placeholderBody = 'Automated PR — description will be updated by the agent.';
@@ -878,7 +976,7 @@ app.post('/:id/open-pr', async (c) => {
878
976
  const stderr = err.stderr?.toString() ?? '';
879
977
  return c.json({ error: `gh pr create failed: ${message} ${stderr}`.trim() }, 500);
880
978
  }
881
- // 4. Parse PR URL and number
979
+ // Parse PR URL and number from gh output
882
980
  const urlMatch = ghOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
883
981
  if (!urlMatch) {
884
982
  return c.json({ error: 'Could not parse PR URL from gh output' }, 500);
@@ -886,12 +984,12 @@ app.post('/:id/open-pr', async (c) => {
886
984
  const prUrl = urlMatch[0];
887
985
  const prNumber = parseInt(urlMatch[1], 10);
888
986
  // ── From here on, PR exists. No more 5xx responses. ──
889
- // 5. Resolve the template; skip message steps if empty
987
+ // Resolve the PR prompt template; skip message steps if empty
890
988
  const effective = settingsService.getEffectiveSettings(workspace.projectPath);
891
989
  if (!effective.prPromptTemplate) {
892
990
  return c.json({ ok: true, prNumber, prUrl, messageSent: false });
893
991
  }
894
- // 6. Build context and render the template
992
+ // Build context and render the PR prompt template
895
993
  const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
896
994
  const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
897
995
  const tasks = workspaceService.listTasks(workspace.id);
@@ -903,11 +1001,11 @@ app.post('/:id/open-pr', async (c) => {
903
1001
  diffStats,
904
1002
  tasks,
905
1003
  });
906
- // 7. Emit user:message into the chat feed
1004
+ // Emit user:message into the chat feed
907
1005
  const session = workspaceService.getLatestSession(workspace.id);
908
1006
  const sessionId = session?.claudeSessionId ?? undefined;
909
1007
  wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
910
- // 8. Send to the running agent, or resume the agent with the PR prompt
1008
+ // Send to the running agent, or resume the agent with the PR prompt
911
1009
  let messageSent = false;
912
1010
  try {
913
1011
  agentManager.sendMessage(workspace.id, rendered);
@@ -933,6 +1031,23 @@ app.post('/:id/open-pr', async (c) => {
933
1031
  return c.json({ error: message }, 500);
934
1032
  }
935
1033
  });
1034
+ /** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
1035
+ app.post('/:id/mark-read', (c) => {
1036
+ try {
1037
+ const id = c.req.param('id');
1038
+ const workspace = workspaceService.getWorkspace(id);
1039
+ if (!workspace) {
1040
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1041
+ }
1042
+ workspaceService.markWorkspaceRead(id);
1043
+ wsService.emitEphemeral(id, 'workspace:unread', { hasUnread: false });
1044
+ return c.json({ success: true });
1045
+ }
1046
+ catch (err) {
1047
+ const message = err instanceof Error ? err.message : String(err);
1048
+ return c.json({ error: message }, 500);
1049
+ }
1050
+ });
936
1051
  // POST /api/workspaces/:id/stop — stop agent
937
1052
  app.post('/:id/stop', (c) => {
938
1053
  try {
@@ -7,8 +7,8 @@ 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
9
  import { getEffectiveSettings } from './settings-service.js';
10
- import { emit } from './websocket-service.js';
11
- import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
10
+ import { emit, emitEphemeral } from './websocket-service.js';
11
+ import { getWorkspace as getWs, listTasks, markWorkspaceUnread, updateWorkspaceStatus } from './workspace-service.js';
12
12
  // ── State ──────────────────────────────────────────────────────────────────────
13
13
  /** Actual bound port of the running backend — set at startup via setBackendPort() */
14
14
  let backendPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
@@ -82,6 +82,13 @@ function runWatchdog() {
82
82
  catch {
83
83
  // Transition may not be valid — ignore
84
84
  }
85
+ try {
86
+ markWorkspaceUnread(workspaceId);
87
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
88
+ }
89
+ catch {
90
+ // best-effort
91
+ }
85
92
  emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.claudeSessionId);
86
93
  }
87
94
  }
@@ -100,6 +107,7 @@ export function stopWatchdog() {
100
107
  }
101
108
  }
102
109
  // ── Start agent ────────────────────────────────────────────────────────────────
110
+ /** Spawn a Claude Code CLI process for a workspace and wire up stdout/stderr/exit handling. */
103
111
  export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
104
112
  // Check if agent already running for this workspace
105
113
  if (agents.has(workspaceId)) {
@@ -301,7 +309,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
301
309
  });
302
310
  // ── stderr — detect quota / rate limit errors ──
303
311
  proc.stderr?.on('data', (data) => {
304
- // I1: Don't process quota errors if the agent is already stopping or gone
305
312
  const currentAgent = agents.get(workspaceId);
306
313
  if (!currentAgent || currentAgent.status === 'stopping')
307
314
  return;
@@ -322,7 +329,6 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
322
329
  catch {
323
330
  // File may not exist (spawn failed) — ignore
324
331
  }
325
- // I3: Close readline interface to release the stream reference
326
332
  agent.rl.close();
327
333
  unregisterProcess(workspaceId);
328
334
  // Only remove from the map if this exact agent instance is still current.
@@ -334,7 +340,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
334
340
  }
335
341
  // Clean up retry state and inactivity timer
336
342
  retryCounts.delete(workspaceId);
337
- // C2: Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
343
+ // Clear the kill timer if it's still pending (process exited naturally before SIGKILL)
338
344
  const pendingKillTimer = killTimers.get(workspaceId);
339
345
  if (pendingKillTimer) {
340
346
  clearTimeout(pendingKillTimer);
@@ -350,7 +356,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
350
356
  emit(workspaceId, 'agent:status', { status: 'stopped' }, agent.claudeSessionId);
351
357
  return;
352
358
  }
353
- // C1: Also clear backoff timers on non-stopping exit
359
+ // Also clear backoff timers on non-stopping exit
354
360
  const pendingBackoff = backoffTimers.get(workspaceId);
355
361
  if (pendingBackoff) {
356
362
  clearTimeout(pendingBackoff);
@@ -363,6 +369,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
363
369
  catch (err) {
364
370
  console.error('[agent] Failed to update workspace status on exit:', err);
365
371
  }
372
+ try {
373
+ markWorkspaceUnread(workspaceId);
374
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
375
+ }
376
+ catch {
377
+ // best-effort
378
+ }
366
379
  emit(workspaceId, 'agent:status', { status: 'error', exitCode: code }, agent.claudeSessionId);
367
380
  }
368
381
  else {
@@ -372,6 +385,13 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
372
385
  catch (err) {
373
386
  console.error('[agent] Failed to update workspace status on exit:', err);
374
387
  }
388
+ try {
389
+ markWorkspaceUnread(workspaceId);
390
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
391
+ }
392
+ catch {
393
+ // best-effort
394
+ }
375
395
  emit(workspaceId, 'agent:status', { status: 'completed' }, agent.claudeSessionId);
376
396
  }
377
397
  });
@@ -382,6 +402,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
382
402
  return agent;
383
403
  }
384
404
  // ── Stop agent ─────────────────────────────────────────────────────────────────
405
+ /** Gracefully stop an agent (SIGTERM, then SIGKILL after 5s). */
385
406
  export function stopAgent(workspaceId) {
386
407
  const agent = agents.get(workspaceId);
387
408
  if (!agent) {
@@ -398,7 +419,6 @@ export function stopAgent(workspaceId) {
398
419
  clearTimeout(timer);
399
420
  backoffTimers.delete(workspaceId);
400
421
  }
401
- // I3: Close readline interface now that we're stopping
402
422
  try {
403
423
  agent.rl.close();
404
424
  }
@@ -414,7 +434,7 @@ export function stopAgent(workspaceId) {
414
434
  }
415
435
  // After 5s timeout, send SIGKILL if still running
416
436
  const killTimer = setTimeout(() => {
417
- // C2: If a new agent has been started for this workspace in the meantime,
437
+ // If a new agent has been started for this workspace in the meantime,
418
438
  // don't kill the old process — it's handled by the new lifecycle.
419
439
  const currentAgent = agents.get(workspaceId);
420
440
  if (currentAgent && currentAgent !== agent) {
@@ -436,6 +456,7 @@ export function stopAgent(workspaceId) {
436
456
  killTimers.set(workspaceId, killTimer);
437
457
  }
438
458
  // ── Send message to agent stdin ────────────────────────────────────────────────
459
+ /** Write a user message to the running agent's stdin. */
439
460
  export function sendMessage(workspaceId, content) {
440
461
  const agent = agents.get(workspaceId);
441
462
  if (!agent) {
@@ -447,15 +468,20 @@ export function sendMessage(workspaceId, content) {
447
468
  agent.process.stdin.write(`${content}\n`);
448
469
  }
449
470
  // ── Status queries ─────────────────────────────────────────────────────────────
471
+ /** Get the in-memory status of the agent for a workspace, or null if not running. */
450
472
  export function getAgentStatus(workspaceId) {
451
473
  const agent = agents.get(workspaceId);
452
474
  return agent?.status ?? null;
453
475
  }
476
+ /** Return the number of currently running agents. */
454
477
  export function getRunningCount() {
455
478
  return agents.size;
456
479
  }
480
+ /** Kobo built-in slash commands injected into the skill list (without leading /). */
481
+ const KOBO_COMMANDS = ['kobo-check-progress'];
482
+ /** Return the cached list of slash commands discovered from the last agent init, plus Kobo built-in commands. */
457
483
  export function getAvailableSkills() {
458
- return availableSkills;
484
+ return [...KOBO_COMMANDS, ...availableSkills];
459
485
  }
460
486
  // ── Quota handling ─────────────────────────────────────────────────────────────
461
487
  function handleQuota(workspaceId, claudeSessionId) {
@@ -1,4 +1,3 @@
1
- // src/server/services/image-service.ts
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { nanoid } from 'nanoid';
@@ -30,6 +29,7 @@ function readIndex(imagesDir) {
30
29
  function writeIndex(imagesDir, entries) {
31
30
  fs.writeFileSync(path.join(imagesDir, INDEX_FILE), JSON.stringify(entries, null, 2));
32
31
  }
32
+ /** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
33
33
  export async function saveImage(worktreePath, fileBuffer, originalName) {
34
34
  const ext = path.extname(originalName).toLowerCase().replace('.', '');
35
35
  if (!ext) {
@@ -53,6 +53,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
53
53
  });
54
54
  return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
55
55
  }
56
+ /** Delete an image by UID from disk and the index. */
56
57
  export async function deleteImage(worktreePath, uid) {
57
58
  const imagesDir = path.join(worktreePath, IMAGES_DIR);
58
59
  await withLock(worktreePath, () => {