@loicngr/kobo 1.4.4 → 1.4.6

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 (100) 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 +14 -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 +128 -31
  15. package/dist/server/services/agent-manager.js +32 -8
  16. package/dist/server/services/dev-server-service.js +7 -2
  17. package/dist/server/services/image-service.js +2 -1
  18. package/dist/server/services/notion-service.js +3 -16
  19. package/dist/server/services/pr-watcher-service.js +2 -0
  20. package/dist/server/services/settings-service.js +17 -6
  21. package/dist/server/services/setup-script-service.js +1 -0
  22. package/dist/server/services/websocket-service.js +8 -9
  23. package/dist/server/services/workspace-service.js +33 -2
  24. package/dist/server/services/worktree-service.js +4 -2
  25. package/dist/server/utils/git-ops.js +19 -5
  26. package/dist/server/utils/process-tracker.js +7 -0
  27. package/package.json +1 -1
  28. package/src/client/dist/spa/assets/ActivityFeed-CLqD89Cm.css +1 -0
  29. package/src/client/dist/spa/assets/ActivityFeed-DTH_Ij7C.js +68 -0
  30. package/src/client/dist/spa/assets/CreatePage-BB7McDfT.js +2 -0
  31. package/src/client/dist/spa/assets/CreatePage-DAZADSsw.css +1 -0
  32. package/src/client/dist/spa/assets/DiffViewer-DpdWjInq.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-DsVIBuje.js +2 -0
  34. package/src/client/dist/spa/assets/MainLayout-BVmmyrJW.js +2 -0
  35. package/src/client/dist/spa/assets/MainLayout-POre8X3k.css +1 -0
  36. package/src/client/dist/spa/assets/QBadge-C_R3Tjb9.js +1 -0
  37. package/src/client/dist/spa/assets/QExpansionItem-BPtEjGYj.js +1 -0
  38. package/src/client/dist/spa/assets/QPage-BX_DOfKi.js +1 -0
  39. package/src/client/dist/spa/assets/QSeparator-y-UWrZSp.js +1 -0
  40. package/src/client/dist/spa/assets/QSpinnerDots-vpiOHlmN.js +1 -0
  41. package/src/client/dist/spa/assets/QTooltip-D5Om2o3Y.js +1 -0
  42. package/src/client/dist/spa/assets/SettingsPage-BKDbZp9_.js +1 -0
  43. package/src/client/dist/spa/assets/SettingsPage-Dv9gCOw8.css +1 -0
  44. package/src/client/dist/spa/assets/WorkspacePage-Cxt0YZv0.css +1 -0
  45. package/src/client/dist/spa/assets/WorkspacePage-g-Y3BuoI.js +2 -0
  46. package/src/client/dist/spa/assets/_plugin-vue_export-helper-BZV6EEeb.js +1 -0
  47. package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-CbCp8SFU.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Bm9nrcuM.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-DCE1BHWQ.js} +3 -3
  50. package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-BEroNSFG.js} +1 -1
  51. package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-C8QC92C9.js} +1 -1
  52. package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-Dh4rYZTt.js} +1 -1
  53. package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-BUP5qnTw.js} +1 -1
  54. package/src/client/dist/spa/assets/i18n-CvzmE5dV.js +1 -0
  55. package/src/client/dist/spa/assets/index-BRIQl1ry.js +5 -0
  56. package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-C3VBJatM.js} +1 -1
  57. package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-fNrYYkV5.js} +1 -1
  58. package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BnBhHusK.js} +1 -1
  59. package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CTu1vGiw.js} +1 -1
  60. package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-Byxe-pOH.js} +2 -2
  61. package/src/client/dist/spa/assets/nodes-THUz-Chh.js +1 -0
  62. package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-CBrfgeGm.js} +1 -1
  63. package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CaMsFgrW.js} +1 -1
  64. package/src/client/dist/spa/assets/settings-CYWSNYAA.js +1 -0
  65. package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-CrwxLvLR.js} +1 -1
  66. package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-BOH3igZy.js} +1 -1
  67. package/src/client/dist/spa/assets/use-checkbox-ts4I7GAt.js +1 -0
  68. package/src/client/dist/spa/assets/use-quasar-CIVlxSZ-.js +1 -0
  69. package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-BtA9_-M9.js} +1 -1
  70. package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-vYOBELrg.js} +1 -1
  71. package/src/client/dist/spa/index.html +3 -3
  72. package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
  73. package/src/mcp-server/kobo-tasks-server.ts +14 -2
  74. package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
  75. package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
  76. package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
  77. package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
  78. package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
  79. package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
  80. package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
  81. package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
  82. package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
  83. package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
  84. package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
  85. package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
  86. package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
  87. package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
  88. package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
  89. package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
  90. package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
  91. package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
  92. package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
  93. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
  94. package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
  95. package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
  96. package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
  97. package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
  98. package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
  99. package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
  100. /package/src/client/dist/spa/assets/{formatters-CXx5Gzsp.js → formatters-B3FG1fMI.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,21 +116,23 @@ 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 inside .ai/ are gitignored (the .ai/ dir
120
+ // itself may contain project files that SHOULD be committed).
119
121
  try {
120
122
  const gitignorePath = path.join(worktreePath, '.gitignore');
121
123
  const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
122
124
  const lines = existing.split('\n').map((l) => l.trim());
123
125
  const toAdd = [];
124
- if (!lines.includes('.ai/git-conventions.md'))
125
- toAdd.push('.ai/git-conventions.md');
126
+ if (!lines.includes('.ai/.git-conventions.md'))
127
+ toAdd.push('.ai/.git-conventions.md');
126
128
  if (!lines.includes('.ai/thoughts/'))
127
129
  toAdd.push('.ai/thoughts/');
128
130
  if (!lines.includes('.ai/images/'))
129
131
  toAdd.push('.ai/images/');
130
132
  if (!lines.includes('.ai/.setup-script.tmp'))
131
133
  toAdd.push('.ai/.setup-script.tmp');
134
+ if (!lines.includes('.mcp.json'))
135
+ toAdd.push('.mcp.json');
132
136
  if (toAdd.length > 0) {
133
137
  const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
134
138
  fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
@@ -137,20 +141,20 @@ app.post('/', async (c) => {
137
141
  catch (err) {
138
142
  console.error('[workspaces] Failed to update .gitignore:', err);
139
143
  }
140
- // 4c. Write git conventions to the worktree if configured
144
+ // Write git conventions to the worktree if configured
141
145
  const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
142
146
  if (effectiveSettings.gitConventions) {
143
147
  try {
144
148
  const aiDir = path.join(worktreePath, '.ai');
145
149
  fs.mkdirSync(aiDir, { recursive: true });
146
- const conventionsPath = path.join(aiDir, 'git-conventions.md');
150
+ const conventionsPath = path.join(aiDir, '.git-conventions.md');
147
151
  fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
148
152
  }
149
153
  catch (err) {
150
- console.error('[workspaces] Failed to write git-conventions.md:', err);
154
+ console.error('[workspaces] Failed to write .git-conventions.md:', err);
151
155
  }
152
156
  }
153
- // 4d. Run setup script if configured
157
+ // Run setup script if configured
154
158
  let setupScriptFailed = false;
155
159
  if (effectiveSettings.setupScript) {
156
160
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
@@ -174,7 +178,7 @@ app.post('/', async (c) => {
174
178
  setupScriptFailed = true;
175
179
  }
176
180
  }
177
- // 5. Save Notion content as markdown in worktree
181
+ // Save Notion content as markdown in worktree
178
182
  let notionFilePath = null;
179
183
  if (notionContent && body.notionUrl) {
180
184
  try {
@@ -215,9 +219,9 @@ app.post('/', async (c) => {
215
219
  }
216
220
  // Skip agent launch if setup script failed — workspace stays in 'error' status
217
221
  if (!setupScriptFailed) {
218
- // 6. Update workspace status to 'brainstorming'
222
+ // Transition to brainstorming and build the initial agent prompt
219
223
  workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
220
- // 6. Build prompt with tasks and acceptance criteria
224
+ // Build prompt with tasks and acceptance criteria
221
225
  const allTasks = workspaceService.listTasks(workspace.id);
222
226
  const todos = allTasks.filter((t) => !t.isAcceptanceCriterion);
223
227
  const criteria = allTasks.filter((t) => t.isAcceptanceCriterion);
@@ -243,7 +247,7 @@ app.post('/', async (c) => {
243
247
  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`;
244
248
  }
245
249
  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`;
250
+ 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
251
  }
248
252
  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
253
  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 +580,82 @@ app.patch('/:id', async (c) => {
576
580
  return c.json({ error: message }, 500);
577
581
  }
578
582
  });
583
+ /** Open the workspace worktree in the user's configured editor. */
584
+ app.post('/:id/open-editor', (c) => {
585
+ try {
586
+ const id = c.req.param('id');
587
+ const workspace = workspaceService.getWorkspace(id);
588
+ if (!workspace)
589
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
590
+ const globalSettings = settingsService.getGlobalSettings();
591
+ if (!globalSettings.editorCommand) {
592
+ return c.json({ error: 'No editor command configured' }, 400);
593
+ }
594
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
595
+ if (!fs.existsSync(worktreePath)) {
596
+ return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
597
+ }
598
+ const child = spawn(globalSettings.editorCommand, [worktreePath], {
599
+ detached: true,
600
+ stdio: 'ignore',
601
+ });
602
+ child.unref();
603
+ return c.json({ success: true });
604
+ }
605
+ catch (err) {
606
+ const message = err instanceof Error ? err.message : String(err);
607
+ return c.json({ error: message }, 500);
608
+ }
609
+ });
610
+ /** Re-run the project setup script in the workspace worktree. */
611
+ app.post('/:id/run-setup-script', async (c) => {
612
+ try {
613
+ const id = c.req.param('id');
614
+ const workspace = workspaceService.getWorkspace(id);
615
+ if (!workspace)
616
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
617
+ if (setupScriptRunning.has(id)) {
618
+ return c.json({ error: 'Setup script is already running for this workspace' }, 409);
619
+ }
620
+ // Stop the running agent before re-running the setup script
621
+ try {
622
+ if (agentManager.getAgentStatus(id)) {
623
+ agentManager.stopAgent(id);
624
+ }
625
+ }
626
+ catch {
627
+ /* best-effort — agent may already be stopped */
628
+ }
629
+ const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
630
+ if (!effectiveSettings.setupScript) {
631
+ return c.json({ error: 'No setup script configured' }, 400);
632
+ }
633
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
634
+ if (!fs.existsSync(worktreePath)) {
635
+ return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
636
+ }
637
+ setupScriptRunning.add(id);
638
+ try {
639
+ const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
640
+ workspaceName: workspace.name,
641
+ branchName: workspace.workingBranch,
642
+ sourceBranch: workspace.sourceBranch,
643
+ projectPath: workspace.projectPath,
644
+ });
645
+ if (result.exitCode !== 0) {
646
+ return c.json({ error: `Setup script failed with exit code ${result.exitCode}` }, 500);
647
+ }
648
+ return c.json({ success: true });
649
+ }
650
+ finally {
651
+ setupScriptRunning.delete(id);
652
+ }
653
+ }
654
+ catch (err) {
655
+ const message = err instanceof Error ? err.message : String(err);
656
+ return c.json({ error: message }, 500);
657
+ }
658
+ });
579
659
  // POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
580
660
  app.post('/:id/archive', (c) => {
581
661
  try {
@@ -641,14 +721,14 @@ app.delete('/:id', async (c) => {
641
721
  const body = await c.req
642
722
  .json()
643
723
  .catch(() => ({}));
644
- // 1. Stop agent if running
724
+ // Stop agent if running (best-effort)
645
725
  try {
646
726
  agentManager.stopAgent(id);
647
727
  }
648
728
  catch {
649
729
  // Agent may not be running — ignore
650
730
  }
651
- // 2. Remove worktree
731
+ // Remove worktree
652
732
  const worktreesDir = `${workspace.projectPath}/.worktrees`;
653
733
  const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
654
734
  try {
@@ -658,7 +738,7 @@ app.delete('/:id', async (c) => {
658
738
  const message = err instanceof Error ? err.message : String(err);
659
739
  console.error(`[workspaces] Failed to remove worktree: ${message}`);
660
740
  }
661
- // 3. Delete local branch if requested
741
+ // Delete local branch if requested
662
742
  if (body.deleteLocalBranch) {
663
743
  try {
664
744
  gitOps.deleteLocalBranch(workspace.projectPath, workspace.workingBranch);
@@ -668,7 +748,7 @@ app.delete('/:id', async (c) => {
668
748
  console.error(`[workspaces] Failed to delete local branch: ${message}`);
669
749
  }
670
750
  }
671
- // 4. Delete remote branch if requested
751
+ // Delete remote branch if requested
672
752
  if (body.deleteRemoteBranch) {
673
753
  try {
674
754
  gitOps.deleteRemoteBranch(workspace.projectPath, workspace.workingBranch);
@@ -678,7 +758,7 @@ app.delete('/:id', async (c) => {
678
758
  console.error(`[workspaces] Failed to delete remote branch: ${message}`);
679
759
  }
680
760
  }
681
- // 5. Delete workspace from DB
761
+ // Delete workspace from DB (cascades to tasks, sessions, events)
682
762
  workspaceService.deleteWorkspace(id);
683
763
  return new Response(null, { status: 204 });
684
764
  }
@@ -823,7 +903,7 @@ app.post('/:id/open-pr', async (c) => {
823
903
  return c.json({ error: `Workspace '${id}' not found` }, 404);
824
904
  }
825
905
  const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
826
- // 1. Check branch is on remote
906
+ // Verify branch exists on remote
827
907
  let lsRemoteOut = '';
828
908
  try {
829
909
  const { stdout } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', workspace.workingBranch], {
@@ -837,7 +917,7 @@ app.post('/:id/open-pr', async (c) => {
837
917
  if (!lsRemoteOut.trim()) {
838
918
  return c.json({ error: 'Branch is not on remote', code: 'branch_not_pushed' }, 409);
839
919
  }
840
- // 2. Check all local commits are pushed
920
+ // Ensure all local commits are pushed
841
921
  try {
842
922
  const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], { cwd: worktreePath });
843
923
  const countStr = stdout.trim();
@@ -855,7 +935,7 @@ app.post('/:id/open-pr', async (c) => {
855
935
  }
856
936
  return c.json({ error: `Failed to check branch state: ${message}` }, 500);
857
937
  }
858
- // 3. Create PR via gh
938
+ // Create PR via GitHub CLI
859
939
  let ghOutput;
860
940
  try {
861
941
  const placeholderBody = 'Automated PR — description will be updated by the agent.';
@@ -878,7 +958,7 @@ app.post('/:id/open-pr', async (c) => {
878
958
  const stderr = err.stderr?.toString() ?? '';
879
959
  return c.json({ error: `gh pr create failed: ${message} ${stderr}`.trim() }, 500);
880
960
  }
881
- // 4. Parse PR URL and number
961
+ // Parse PR URL and number from gh output
882
962
  const urlMatch = ghOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
883
963
  if (!urlMatch) {
884
964
  return c.json({ error: 'Could not parse PR URL from gh output' }, 500);
@@ -886,12 +966,12 @@ app.post('/:id/open-pr', async (c) => {
886
966
  const prUrl = urlMatch[0];
887
967
  const prNumber = parseInt(urlMatch[1], 10);
888
968
  // ── From here on, PR exists. No more 5xx responses. ──
889
- // 5. Resolve the template; skip message steps if empty
969
+ // Resolve the PR prompt template; skip message steps if empty
890
970
  const effective = settingsService.getEffectiveSettings(workspace.projectPath);
891
971
  if (!effective.prPromptTemplate) {
892
972
  return c.json({ ok: true, prNumber, prUrl, messageSent: false });
893
973
  }
894
- // 6. Build context and render the template
974
+ // Build context and render the PR prompt template
895
975
  const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
896
976
  const diffStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
897
977
  const tasks = workspaceService.listTasks(workspace.id);
@@ -903,11 +983,11 @@ app.post('/:id/open-pr', async (c) => {
903
983
  diffStats,
904
984
  tasks,
905
985
  });
906
- // 7. Emit user:message into the chat feed
986
+ // Emit user:message into the chat feed
907
987
  const session = workspaceService.getLatestSession(workspace.id);
908
988
  const sessionId = session?.claudeSessionId ?? undefined;
909
989
  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
990
+ // Send to the running agent, or resume the agent with the PR prompt
911
991
  let messageSent = false;
912
992
  try {
913
993
  agentManager.sendMessage(workspace.id, rendered);
@@ -933,6 +1013,23 @@ app.post('/:id/open-pr', async (c) => {
933
1013
  return c.json({ error: message }, 500);
934
1014
  }
935
1015
  });
1016
+ /** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
1017
+ app.post('/:id/mark-read', (c) => {
1018
+ try {
1019
+ const id = c.req.param('id');
1020
+ const workspace = workspaceService.getWorkspace(id);
1021
+ if (!workspace) {
1022
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1023
+ }
1024
+ workspaceService.markWorkspaceRead(id);
1025
+ wsService.emitEphemeral(id, 'workspace:unread', { hasUnread: false });
1026
+ return c.json({ success: true });
1027
+ }
1028
+ catch (err) {
1029
+ const message = err instanceof Error ? err.message : String(err);
1030
+ return c.json({ error: message }, 500);
1031
+ }
1032
+ });
936
1033
  // POST /api/workspaces/:id/stop — stop agent
937
1034
  app.post('/:id/stop', (c) => {
938
1035
  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,13 +468,16 @@ 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
+ /** Return the cached list of slash commands discovered from the last agent init. */
457
481
  export function getAvailableSkills() {
458
482
  return availableSkills;
459
483
  }
@@ -8,6 +8,11 @@ import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
8
8
  function getWorktreePath(projectPath, workingBranch) {
9
9
  return path.join(projectPath, '.worktrees', workingBranch);
10
10
  }
11
+ /** Build a clean env for child processes, stripping Kobo-specific variables. */
12
+ function cleanEnv() {
13
+ const { PORT, SERVER_PORT, ...rest } = process.env;
14
+ return rest;
15
+ }
11
16
  // ── State ──────────────────────────────────────────────────────────────────────
12
17
  /** workspaceId -> spawned dev-server process */
13
18
  const trackedProcesses = new Map();
@@ -143,7 +148,7 @@ export function startDevServer(workspaceId) {
143
148
  const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
144
149
  cwd,
145
150
  env: {
146
- ...process.env,
151
+ ...cleanEnv(),
147
152
  INSTANCE: instanceName,
148
153
  DEV_DOCKER_NO_FOLLOW: '1',
149
154
  },
@@ -229,7 +234,7 @@ export function stopDevServer(workspaceId) {
229
234
  execSync(settings.devServer.stopCommand, {
230
235
  cwd,
231
236
  env: {
232
- ...process.env,
237
+ ...cleanEnv(),
233
238
  INSTANCE: instanceName,
234
239
  PROJECT_NAME: config?.projectName ?? '',
235
240
  },
@@ -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, () => {
@@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs';
3
3
  import { getPackageVersion } from '../utils/paths.js';
4
4
  // Gherkin keywords (French and English)
5
5
  const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
6
- // C2: rpcIdCounter encapsulated in a closure to avoid module-level mutable state
7
6
  const nextRpcId = (() => {
8
7
  let counter = 1;
9
8
  return () => counter++;
@@ -32,11 +31,7 @@ export function parseNotionUrl(url) {
32
31
  // Convert 32 hex chars to UUID format: 8-4-4-4-12
33
32
  return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
34
33
  }
35
- /**
36
- * Send a JSON-RPC request to the MCP process and read the response.
37
- * M4: parameter renamed from `process` to `mcpProcess` to avoid shadowing global `process`.
38
- * C1: 30s timeout added to prevent hanging indefinitely.
39
- */
34
+ /** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
40
35
  export async function callMcpTool(mcpProcess, toolName, args) {
41
36
  const id = nextRpcId();
42
37
  const request = JSON.stringify({
@@ -54,7 +49,6 @@ export async function callMcpTool(mcpProcess, toolName, args) {
54
49
  return;
55
50
  }
56
51
  let buffer = '';
57
- // C1: 30s timeout — I7: kill the MCP process on timeout to avoid zombie
58
52
  const timeout = setTimeout(() => {
59
53
  mcpProcess.stdout?.removeListener('data', onData);
60
54
  mcpProcess.stdout?.removeListener('error', onError);
@@ -142,12 +136,7 @@ function spawnMcpProcess() {
142
136
  });
143
137
  return mcpProcess;
144
138
  }
145
- /**
146
- * Initialize the MCP server by sending an initialize request.
147
- * I1: notifications/initialized is sent after receiving the initialize response.
148
- * I4: onData listener is removed in the reject path.
149
- * C1: 10s timeout added.
150
- */
139
+ /** Initialize the MCP server by sending an initialize handshake (10s timeout). */
151
140
  async function initializeMcp(mcpProcess) {
152
141
  const id = nextRpcId();
153
142
  const request = JSON.stringify({
@@ -166,7 +155,6 @@ async function initializeMcp(mcpProcess) {
166
155
  return;
167
156
  }
168
157
  let buffer = '';
169
- // C1: 10s timeout for initialization — I7: kill the MCP process on timeout
170
158
  const timeout = setTimeout(() => {
171
159
  mcpProcess.stdout?.removeListener('data', onData);
172
160
  mcpProcess.kill();
@@ -185,7 +173,6 @@ async function initializeMcp(mcpProcess) {
185
173
  if (parsed.id === id) {
186
174
  clearTimeout(timeout);
187
175
  mcpProcess.stdout?.removeListener('data', onData);
188
- // I1: Send notifications/initialized AFTER receiving the initialize response
189
176
  const initialized = JSON.stringify({
190
177
  jsonrpc: '2.0',
191
178
  method: 'notifications/initialized',
@@ -199,7 +186,6 @@ async function initializeMcp(mcpProcess) {
199
186
  }
200
187
  }
201
188
  };
202
- // I4: onError handler to clean up listener on error
203
189
  const onError = (err) => {
204
190
  clearTimeout(timeout);
205
191
  mcpProcess.stdout?.removeListener('data', onData);
@@ -244,6 +230,7 @@ function extractTextFromRichText(richText) {
244
230
  })
245
231
  .join('');
246
232
  }
233
+ /** Parse Notion block children into structured goal, todos, and Gherkin features. */
247
234
  export function parseBlocks(blocks) {
248
235
  const todos = [];
249
236
  const gherkinFeatures = [];
@@ -69,11 +69,13 @@ function scheduleNext() {
69
69
  }, POLL_INTERVAL_MS);
70
70
  timer.unref?.();
71
71
  }
72
+ /** Start polling GitHub for merged/closed PRs to auto-archive workspaces. */
72
73
  export function startPrWatcher() {
73
74
  if (timer)
74
75
  return;
75
76
  scheduleNext();
76
77
  }
78
+ /** Stop the PR watcher polling loop. */
77
79
  export function stopPrWatcher() {
78
80
  if (timer) {
79
81
  clearTimeout(timer);