@loicngr/kobo 1.6.12 → 1.6.14

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 (87) hide show
  1. package/README.md +11 -6
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -2
  3. package/dist/server/db/migrations.js +24 -0
  4. package/dist/server/db/schema.js +10 -0
  5. package/dist/server/index.js +27 -4
  6. package/dist/server/routes/documents.js +2 -2
  7. package/dist/server/routes/git.js +21 -0
  8. package/dist/server/routes/health.js +2 -2
  9. package/dist/server/routes/images.js +3 -3
  10. package/dist/server/routes/usage.js +18 -0
  11. package/dist/server/routes/workspaces.js +209 -81
  12. package/dist/server/services/agent/engines/claude-code/args-builder.js +2 -0
  13. package/dist/server/services/agent/orchestrator.js +1 -1
  14. package/dist/server/services/auto-loop-service.js +29 -4
  15. package/dist/server/services/dev-server-service.js +2 -5
  16. package/dist/server/services/settings-service.js +55 -2
  17. package/dist/server/services/usage/db.js +29 -0
  18. package/dist/server/services/usage/index.js +2 -0
  19. package/dist/server/services/usage/poller.js +52 -0
  20. package/dist/server/services/usage/providers/claude-code.js +93 -0
  21. package/dist/server/services/usage/types.js +1 -0
  22. package/dist/server/services/wakeup-service.js +2 -2
  23. package/dist/server/services/websocket-service.js +14 -0
  24. package/dist/server/services/workspace-service.js +28 -3
  25. package/dist/server/services/worktree-service.js +50 -0
  26. package/dist/server/utils/mcp-client.js +7 -0
  27. package/dist/shared/auto-loop-prompts.js +58 -8
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/ActivityFeed-Be0QQryJ.css +1 -0
  30. package/src/client/dist/spa/assets/{ActivityFeed-6Xg7qNfy.js → ActivityFeed-BtIOkIy6.js} +3 -3
  31. package/src/client/dist/spa/assets/CreatePage-D6Q3nxkX.js +2 -0
  32. package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-1s165rFm.css +1 -0
  34. package/src/client/dist/spa/assets/{DiffViewer-T111s7BH.js → DiffViewer-D5u9p7il.js} +2 -2
  35. package/src/client/dist/spa/assets/{HealthPage-1VakQ0x_.js → HealthPage-Cr7aAUy6.js} +1 -1
  36. package/src/client/dist/spa/assets/{MainLayout-w7DoW3yz.js → MainLayout-C3TUaYvQ.js} +17 -17
  37. package/src/client/dist/spa/assets/MainLayout-CBnSwSfy.css +1 -0
  38. package/src/client/dist/spa/assets/{SearchPage-CcldJX8i.js → SearchPage-CavRaij6.js} +1 -1
  39. package/src/client/dist/spa/assets/SearchPage-cVwt0DaQ.css +1 -0
  40. package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +1 -0
  41. package/src/client/dist/spa/assets/SettingsPage-C13T1l_t.js +1 -0
  42. package/src/client/dist/spa/assets/WorkspacePage-BEqEuPrb.js +4 -0
  43. package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +1 -0
  44. package/src/client/dist/spa/assets/{build-path-tree-DbuI5yRz.js → build-path-tree-BeAS10oa.js} +1 -1
  45. package/src/client/dist/spa/assets/{cssMode-DhpmJAZc.js → cssMode-wNaxOrgG.js} +1 -1
  46. package/src/client/dist/spa/assets/{documents-fVD9RJth.js → documents-Cw05r3zs.js} +1 -1
  47. package/src/client/dist/spa/assets/{editor.api-DCvwHsju.js → editor.api-CcDntllS.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.main-CRtPC0iL.js → editor.main-Chu4hc0J.js} +3 -3
  49. package/src/client/dist/spa/assets/{expand-template-BIra7NIw.js → expand-template-CcQus77v.js} +1 -1
  50. package/src/client/dist/spa/assets/expand-template-D2yUa54D.css +1 -0
  51. package/src/client/dist/spa/assets/{freemarker2-C9UOErQw.js → freemarker2-CO_b202E.js} +1 -1
  52. package/src/client/dist/spa/assets/{handlebars-DmZ2-ZcJ.js → handlebars-CJnTWNLs.js} +1 -1
  53. package/src/client/dist/spa/assets/{html-ButyxlXG.js → html-DeArYseI.js} +1 -1
  54. package/src/client/dist/spa/assets/{htmlMode-C-defy1b.js → htmlMode-BnNgEgdx.js} +1 -1
  55. package/src/client/dist/spa/assets/i18n-CuT4b7ns.js +1 -0
  56. package/src/client/dist/spa/assets/index-CZA4BFN5.js +2 -0
  57. package/src/client/dist/spa/assets/{javascript-B6zVweIF.js → javascript-C0pxfNu4.js} +1 -1
  58. package/src/client/dist/spa/assets/{jsonMode-CttMw-EY.js → jsonMode-ety87201.js} +1 -1
  59. package/src/client/dist/spa/assets/kobo-commands-Cpl4IFon.js +11 -0
  60. package/src/client/dist/spa/assets/{liquid-tGpdE1YW.js → liquid-kanevKvC.js} +1 -1
  61. package/src/client/dist/spa/assets/{mdx-Cy5mpQoy.js → mdx-DkmtbRD7.js} +1 -1
  62. package/src/client/dist/spa/assets/{models-DdAQDnqk.js → models-CPFeBEQS.js} +1 -1
  63. package/src/client/dist/spa/assets/{monaco.contribution-DtdkkTgR.js → monaco.contribution-DsZsua59.js} +2 -2
  64. package/src/client/dist/spa/assets/{python-hLOxMbm9.js → python-DrxH1xl7.js} +1 -1
  65. package/src/client/dist/spa/assets/{razor-tqHFRROa.js → razor-CU4khv8N.js} +1 -1
  66. package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
  67. package/src/client/dist/spa/assets/{tsMode-MJKgZYsJ.js → tsMode-CQ5yxoz_.js} +1 -1
  68. package/src/client/dist/spa/assets/{typescript-CWTqB5lb.js → typescript-CSwKmP7l.js} +1 -1
  69. package/src/client/dist/spa/assets/{xml-ByDBLBVa.js → xml-9bnWANPJ.js} +1 -1
  70. package/src/client/dist/spa/assets/{yaml-BiTCWZ38.js → yaml-sUtDJGxo.js} +1 -1
  71. package/src/client/dist/spa/index.html +1 -1
  72. package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
  73. package/src/client/dist/spa/assets/ActivityFeed-BHDJ5lUn.css +0 -1
  74. package/src/client/dist/spa/assets/CreatePage-BQu7mQjm.css +0 -1
  75. package/src/client/dist/spa/assets/CreatePage-CrRGDs5V.js +0 -2
  76. package/src/client/dist/spa/assets/DiffViewer-BC81-2me.css +0 -1
  77. package/src/client/dist/spa/assets/MainLayout-Ci-CETJi.css +0 -1
  78. package/src/client/dist/spa/assets/SearchPage-DWglAeQv.css +0 -1
  79. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
  80. package/src/client/dist/spa/assets/SettingsPage-CMyeaz63.js +0 -1
  81. package/src/client/dist/spa/assets/WorkspacePage-Bw9xhTDR.js +0 -4
  82. package/src/client/dist/spa/assets/WorkspacePage-_1mty_a4.css +0 -1
  83. package/src/client/dist/spa/assets/expand-template-hbnn7St6.css +0 -1
  84. package/src/client/dist/spa/assets/i18n-C8aJvuyS.js +0 -1
  85. package/src/client/dist/spa/assets/index-DAbX631s.js +0 -2
  86. package/src/client/dist/spa/assets/kobo-commands-CD7ERFxp.js +0 -10
  87. package/src/client/dist/spa/assets/rate-limit-labels-BaD9dQtl.js +0 -1
@@ -1,10 +1,10 @@
1
- import { execFile as execFileCb, spawn } from 'node:child_process';
1
+ import { execFile as execFileCb, execFileSync, 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';
5
5
  import path from 'node:path';
6
6
  import { Hono } from 'hono';
7
- import { AUTO_LOOP_GROOMING_STEPS, AUTO_LOOP_HARD_RULES } from '../../shared/auto-loop-prompts.js';
7
+ import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, PREP_AUTOLOOP_INTRO, } from '../../shared/auto-loop-prompts.js';
8
8
  import { getDb } from '../db/index.js';
9
9
  import { migrationGuard } from '../middleware/migration-guard.js';
10
10
  import { listEngines } from '../services/agent/engines/registry.js';
@@ -41,8 +41,13 @@ app.get('/', (c) => {
41
41
  app.post('/', migrationGuard, async (c) => {
42
42
  try {
43
43
  const body = await c.req.json();
44
- if (!body.name || !body.projectPath || !body.sourceBranch || !body.workingBranch) {
45
- return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
44
+ // workingBranch is derived from git when worktreePath is provided, so
45
+ // it's not required in that flow. The other 3 fields stay mandatory.
46
+ if (!body.name || !body.projectPath || !body.sourceBranch) {
47
+ return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch' }, 400);
48
+ }
49
+ if (!body.worktreePath && !body.workingBranch) {
50
+ return c.json({ error: 'Missing required field: workingBranch' }, 400);
46
51
  }
47
52
  // Validate the engine id (if provided) against the registry. An unknown
48
53
  // engine is rejected up-front so we don't create orphan workspaces that
@@ -62,6 +67,45 @@ app.post('/', migrationGuard, async (c) => {
62
67
  const message = err instanceof Error ? err.message : String(err);
63
68
  return c.json({ error: message }, 422);
64
69
  }
70
+ // Reuse-existing-worktree path. When the caller passes `worktreePath`,
71
+ // Kobo "attaches" to a pre-existing worktree on disk instead of creating
72
+ // a new one. We validate four invariants up-front (path exists, belongs
73
+ // to this repo, is on a real branch, isn't already attached) and derive
74
+ // the working branch from git itself — the body.workingBranch is ignored.
75
+ let useReusedWorktree = false;
76
+ let reusedDerivedBranch = null;
77
+ if (body.worktreePath) {
78
+ if (!fs.existsSync(body.worktreePath)) {
79
+ return c.json({ error: `Worktree path does not exist: ${body.worktreePath}` }, 422);
80
+ }
81
+ try {
82
+ const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
83
+ encoding: 'utf-8',
84
+ }).trim();
85
+ const expectedCommonDir = path.join(body.projectPath, '.git');
86
+ if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
87
+ return c.json({ error: `Worktree '${body.worktreePath}' belongs to a different repository` }, 422);
88
+ }
89
+ const branch = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
90
+ encoding: 'utf-8',
91
+ }).trim();
92
+ if (!branch || branch === 'HEAD') {
93
+ return c.json({ error: 'Worktree is in detached HEAD state and cannot be attached' }, 422);
94
+ }
95
+ reusedDerivedBranch = branch;
96
+ }
97
+ catch (err) {
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ return c.json({ error: `Failed to inspect worktree: ${message}` }, 422);
100
+ }
101
+ // Validate the worktree isn't already attached to another workspace.
102
+ const dbForCheck = getDb();
103
+ const existing = dbForCheck.prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
104
+ if (existing) {
105
+ return c.json({ error: 'This worktree is already attached to another Kōbō workspace' }, 422);
106
+ }
107
+ useReusedWorktree = true;
108
+ }
65
109
  // Pre-flight: extract Notion / Sentry before any DB write. A throw here
66
110
  // must not leave a half-built workspace behind, so we run extraction
67
111
  // before createWorkspace and surface failures as 422.
@@ -87,8 +131,36 @@ app.post('/', migrationGuard, async (c) => {
87
131
  }
88
132
  // Create workspace record
89
133
  const globalSettings = settingsService.getGlobalSettings();
90
- // workingBranch may be updated after Notion extraction to inject the ticket ID
91
- let workingBranch = body.workingBranch;
134
+ // workingBranch may be updated after Notion extraction to inject the ticket ID,
135
+ // OR overridden by the branch derived from the existing worktree (reuse mode).
136
+ let workingBranch = useReusedWorktree && reusedDerivedBranch ? reusedDerivedBranch : body.workingBranch;
137
+ // Inject ticket ID into the working branch BEFORE creating the workspace,
138
+ // so the worktree_path recorded in the DB reflects the FINAL branch name.
139
+ // Works with or without Notion: ticket ID comes from Notion extraction first,
140
+ // then Sentry, then falls back to a TK-XXXX pattern anywhere in the body.name.
141
+ // Skip when reusing an existing worktree — its branch is already real on disk
142
+ // and we MUST NOT rename it.
143
+ if (!useReusedWorktree) {
144
+ // Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
145
+ // which is what Sentry auto-close recognises in commit messages.
146
+ const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || body.name.match(/[A-Z]+-\d+/i)?.[0];
147
+ if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
148
+ const ticketPrefix = detectedTicketId.toUpperCase();
149
+ const slashIdx = workingBranch.indexOf('/');
150
+ const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
151
+ // Use Notion/Sentry title or body name for the slug — all have proper accented
152
+ // characters that NFD normalization can transliterate (é→e, ç→c, etc.)
153
+ const titleSource = notionContent?.title || sentryContent?.title || body.name;
154
+ const titleSlug = titleSource
155
+ .normalize('NFD')
156
+ .replace(/[\u0300-\u036f]/g, '')
157
+ .toLowerCase()
158
+ .replace(/[^a-z0-9]+/g, '-')
159
+ .replace(/^-|-$/g, '')
160
+ .substring(0, 50);
161
+ workingBranch = `${typePrefix}${ticketPrefix}--${titleSlug}`;
162
+ }
163
+ }
92
164
  let workspace = workspaceService.createWorkspace({
93
165
  name: body.name,
94
166
  projectPath: body.projectPath,
@@ -97,6 +169,7 @@ app.post('/', migrationGuard, async (c) => {
97
169
  notionUrl: body.notionUrl,
98
170
  notionPageId: body.notionPageId,
99
171
  sentryUrl: body.sentryUrl,
172
+ ...(useReusedWorktree ? { worktreePath: body.worktreePath, worktreeOwned: false } : {}),
100
173
  model: body.model,
101
174
  reasoningEffort: body.reasoningEffort,
102
175
  permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
@@ -176,41 +249,21 @@ app.post('/', migrationGuard, async (c) => {
176
249
  }
177
250
  }
178
251
  }
179
- // Inject ticket ID into the working branch.
180
- // Works with or without Notion: ticket ID comes from Notion extraction first,
181
- // then falls back to a TK-XXXX pattern anywhere in the workspace name.
182
- // The worktree has not been created yet, so a DB update is sufficient.
183
- {
184
- // Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
185
- // which is what Sentry auto-close recognises in commit messages.
186
- const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
187
- if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
188
- const ticketPrefix = detectedTicketId.toUpperCase();
189
- const slashIdx = workingBranch.indexOf('/');
190
- const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
191
- // Use Notion/Sentry title or workspace name for the slug — all have proper accented
192
- // characters that NFD normalization can transliterate (é→e, ç→c, etc.)
193
- const titleSource = notionContent?.title || sentryContent?.title || workspace.name;
194
- const titleSlug = titleSource
195
- .normalize('NFD')
196
- .replace(/[\u0300-\u036f]/g, '')
197
- .toLowerCase()
198
- .replace(/[^a-z0-9]+/g, '-')
199
- .replace(/^-|-$/g, '')
200
- .substring(0, 50);
201
- workingBranch = `${typePrefix}${ticketPrefix}--${titleSlug}`;
202
- workspace = workspaceService.updateWorkingBranch(workspace.id, workingBranch);
203
- }
204
- }
205
- // Create git worktree for the working branch
252
+ // Create git worktree for the working branch — unless we're reusing an
253
+ // existing one, in which case the path is taken straight from the body.
206
254
  let worktreePath;
207
- try {
208
- worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
255
+ if (useReusedWorktree) {
256
+ worktreePath = body.worktreePath;
209
257
  }
210
- catch (err) {
211
- const message = err instanceof Error ? err.message : String(err);
212
- workspaceService.updateWorkspaceStatus(workspace.id, 'error');
213
- return c.json({ error: `Failed to create worktree: ${message}` }, 500);
258
+ else {
259
+ try {
260
+ worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
261
+ }
262
+ catch (err) {
263
+ const message = err instanceof Error ? err.message : String(err);
264
+ workspaceService.updateWorkspaceStatus(workspace.id, 'error');
265
+ return c.json({ error: `Failed to create worktree: ${message}` }, 500);
266
+ }
214
267
  }
215
268
  // Ensure Kobo-generated files are gitignored. Check both the root
216
269
  // .gitignore and .ai/.gitignore to avoid duplicate entries.
@@ -259,7 +312,10 @@ app.post('/', migrationGuard, async (c) => {
259
312
  }
260
313
  // Run setup script if configured and not skipped
261
314
  let setupScriptFailed = false;
262
- if (effectiveSettings.setupScript && !body.skipSetupScript) {
315
+ // Skip the setup script when reusing an existing worktree — the user
316
+ // already has the environment set up there and rerunning it could be
317
+ // destructive (drop a node_modules they curated, etc.).
318
+ if (effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree) {
263
319
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
264
320
  wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
265
321
  try {
@@ -459,12 +515,21 @@ app.post('/', migrationGuard, async (c) => {
459
515
  // NOT with implementation. The auto-loop will drive implementation after.
460
516
  // The grooming steps + hard rules are shared with the PREP_AUTOLOOP_PROMPT
461
517
  // sent by the "Prepare for auto-loop" button (src/shared/auto-loop-prompts.ts).
518
+ // Read per-project E2E settings so the grooming steps can include the
519
+ // E2E review pass when configured. We deliberately use
520
+ // `getProjectSettings` (NOT `getEffectiveSettings`) here because only
521
+ // project-level settings carry the `e2e` shape; if the project hasn't
522
+ // been registered yet, the empty default below is correct.
523
+ const projectSettingsForE2e = settingsService.getProjectSettings(body.projectPath);
524
+ const e2eSettings = projectSettingsForE2e?.e2e ?? { framework: '', skill: '', prompt: '' };
525
+ const finalizationSettings = projectSettingsForE2e?.finalization ?? { prompt: '' };
462
526
  brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you have a clear plan, create a plan file.
463
527
 
464
528
  Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
465
529
 
466
- ${AUTO_LOOP_GROOMING_STEPS}
467
- 5. Output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
530
+ ${buildAutoLoopGroomingSteps(e2eSettings, finalizationSettings)}
531
+
532
+ When the steps above are complete, output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
468
533
 
469
534
  ${AUTO_LOOP_HARD_RULES}`;
470
535
  }
@@ -908,6 +973,30 @@ app.get('/archived', (c) => {
908
973
  return c.json({ error: message }, 500);
909
974
  }
910
975
  });
976
+ // GET /:id/prep-autoloop-prompt — compose the project-aware grooming
977
+ // prompt. Used by the "Prepare for auto-loop" button. Place BEFORE
978
+ // `app.get('/:id', ...)` so the more-specific path wins.
979
+ app.get('/:id/prep-autoloop-prompt', (c) => {
980
+ try {
981
+ const id = c.req.param('id');
982
+ const workspace = workspaceService.getWorkspace(id);
983
+ if (!workspace)
984
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
985
+ const projectSettings = settingsService.getProjectSettings(workspace.projectPath);
986
+ const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
987
+ const finalizationSettings = projectSettings?.finalization ?? { prompt: '' };
988
+ const prompt = `${PREP_AUTOLOOP_INTRO}
989
+
990
+ ${buildAutoLoopGroomingSteps(e2eSettings, finalizationSettings)}
991
+
992
+ ${AUTO_LOOP_HARD_RULES}`;
993
+ return c.json({ prompt });
994
+ }
995
+ catch (err) {
996
+ const message = err instanceof Error ? err.message : String(err);
997
+ return c.json({ error: message }, 500);
998
+ }
999
+ });
911
1000
  // GET /api/workspaces/:id — get workspace details with tasks
912
1001
  app.get('/:id', (c) => {
913
1002
  try {
@@ -1038,7 +1127,7 @@ app.post('/:id/open-editor', (c) => {
1038
1127
  if (!globalSettings.editorCommand) {
1039
1128
  return c.json({ error: 'No editor command configured' }, 400);
1040
1129
  }
1041
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1130
+ const worktreePath = workspace.worktreePath;
1042
1131
  if (!fs.existsSync(worktreePath)) {
1043
1132
  return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
1044
1133
  }
@@ -1046,6 +1135,15 @@ app.post('/:id/open-editor', (c) => {
1046
1135
  detached: true,
1047
1136
  stdio: 'ignore',
1048
1137
  });
1138
+ // spawn errors fire async on the ChildProcess (ENOENT etc.) — without a
1139
+ // handler the unhandled 'error' event crashes the whole Node process.
1140
+ child.on('error', (err) => {
1141
+ console.error(`[open-editor] spawn '${globalSettings.editorCommand}' failed:`, err.message);
1142
+ wsService.emitEphemeral(workspace.id, 'editor:open-failed', {
1143
+ command: globalSettings.editorCommand,
1144
+ message: err.message,
1145
+ });
1146
+ });
1049
1147
  child.unref();
1050
1148
  return c.json({ success: true });
1051
1149
  }
@@ -1077,7 +1175,7 @@ app.post('/:id/run-setup-script', async (c) => {
1077
1175
  if (!effectiveSettings.setupScript) {
1078
1176
  return c.json({ error: 'No setup script configured' }, 400);
1079
1177
  }
1080
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1178
+ const worktreePath = workspace.worktreePath;
1081
1179
  if (!fs.existsSync(worktreePath)) {
1082
1180
  return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
1083
1181
  }
@@ -1194,20 +1292,25 @@ app.delete('/:id', migrationGuard, async (c) => {
1194
1292
  // Docker leaves root-owned files inside the worktree, git worktree
1195
1293
  // remove fails with EACCES.
1196
1294
  const warnings = [];
1197
- // Remove worktree
1198
- const worktreesDir = `${workspace.projectPath}/.worktrees`;
1199
- const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
1200
- try {
1201
- worktreeService.removeWorktree(workspace.projectPath, worktreePath);
1295
+ // Remove worktree (only if owned — for attached external worktrees we
1296
+ // never created the dir, so we must not delete it on the user's behalf).
1297
+ const worktreePath = workspace.worktreePath;
1298
+ if (workspace.worktreeOwned) {
1299
+ try {
1300
+ worktreeService.removeWorktree(workspace.projectPath, worktreePath);
1301
+ }
1302
+ catch (err) {
1303
+ const message = err instanceof Error ? err.message : String(err);
1304
+ console.error(`[workspaces] Failed to remove worktree: ${message}`);
1305
+ warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1306
+ `Fix manually:\n` +
1307
+ ` sudo rm -rf '${worktreePath}'\n` +
1308
+ ` cd '${workspace.projectPath}' && git worktree prune\n` +
1309
+ `Reason: ${message}`);
1310
+ }
1202
1311
  }
1203
- catch (err) {
1204
- const message = err instanceof Error ? err.message : String(err);
1205
- console.error(`[workspaces] Failed to remove worktree: ${message}`);
1206
- warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1207
- `Fix manually:\n` +
1208
- ` sudo rm -rf '${worktreePath}'\n` +
1209
- ` cd '${workspace.projectPath}' && git worktree prune\n` +
1210
- `Reason: ${message}`);
1312
+ else {
1313
+ console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
1211
1314
  }
1212
1315
  // Delete local branch if requested
1213
1316
  if (body.deleteLocalBranch) {
@@ -1283,7 +1386,7 @@ app.post('/:id/start', migrationGuard, async (c) => {
1283
1386
  catch {
1284
1387
  // Agent may not be running — ignore
1285
1388
  }
1286
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
1389
+ const worktreePath = workspace.worktreePath;
1287
1390
  const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId, workspace.reasoningEffort);
1288
1391
  workspaceService.updateWorkspaceStatus(id, 'executing');
1289
1392
  // Persist the user prompt so it survives page refresh.
@@ -1307,7 +1410,7 @@ app.get('/:id/git-stats', async (c) => {
1307
1410
  if (!workspace) {
1308
1411
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1309
1412
  }
1310
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1413
+ const worktreePath = workspace.worktreePath;
1311
1414
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1312
1415
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1313
1416
  const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
@@ -1342,7 +1445,7 @@ app.get('/:id/diff', (c) => {
1342
1445
  if (!workspace) {
1343
1446
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1344
1447
  }
1345
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1448
+ const worktreePath = workspace.worktreePath;
1346
1449
  const files = mode === 'unpushed'
1347
1450
  ? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
1348
1451
  : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
@@ -1376,7 +1479,7 @@ app.get('/:id/diff-file', (c) => {
1376
1479
  if (!workspace) {
1377
1480
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1378
1481
  }
1379
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1482
+ const worktreePath = workspace.worktreePath;
1380
1483
  const baseRef = mode === 'unpushed' ? `origin/${workspace.workingBranch}` : workspace.sourceBranch;
1381
1484
  const original = gitOps.getFileAtRef(worktreePath, baseRef, filePath);
1382
1485
  const modified = gitOps.getFileContent(worktreePath, filePath);
@@ -1399,7 +1502,7 @@ app.get('/:id/commits', (c) => {
1399
1502
  }
1400
1503
  const limitRaw = c.req.query('limit');
1401
1504
  const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
1402
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1505
+ const worktreePath = workspace.worktreePath;
1403
1506
  const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
1404
1507
  c.header('Cache-Control', 'no-store');
1405
1508
  return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
@@ -1429,11 +1532,23 @@ app.post('/:id/rename-branch', async (c) => {
1429
1532
  if (!workspace) {
1430
1533
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1431
1534
  }
1535
+ if (!workspace.worktreeOwned) {
1536
+ return c.json({
1537
+ error: 'Rename is not available for attached external worktrees. Manage the branch name with git directly.',
1538
+ }, 400);
1539
+ }
1432
1540
  if (newName === workspace.workingBranch) {
1433
1541
  return c.json(workspace); // no-op
1434
1542
  }
1435
- const oldWorktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1436
- const newWorktreePath = path.join(workspace.projectPath, '.worktrees', newName);
1543
+ const oldWorktreePath = workspace.worktreePath;
1544
+ // Sibling rename: keep the same worktrees-root, swap the branch leaf.
1545
+ // Cannot use `path.dirname` directly because branches with slashes
1546
+ // (e.g. `feature/x`) make the dirname end one level too deep.
1547
+ const oldSuffix = `/${workspace.workingBranch}`;
1548
+ const worktreesRoot = oldWorktreePath.endsWith(oldSuffix)
1549
+ ? oldWorktreePath.slice(0, -oldSuffix.length)
1550
+ : path.join(workspace.projectPath, '.worktrees');
1551
+ const newWorktreePath = path.join(worktreesRoot, newName);
1437
1552
  // Reject early if the target name is already in use — either as a local
1438
1553
  // branch or on origin. Avoids git's generic "already exists" error and
1439
1554
  // protects against the same silent-fallback trap the create flow has.
@@ -1453,9 +1568,11 @@ app.post('/:id/rename-branch', async (c) => {
1453
1568
  // not the dir, for git operations.
1454
1569
  try {
1455
1570
  gitOps.moveWorktree(workspace.projectPath, oldWorktreePath, newWorktreePath);
1571
+ workspaceService.updateWorktreePath(id, newWorktreePath);
1456
1572
  }
1457
1573
  catch (err) {
1458
1574
  console.error('[workspaces] Failed to move worktree dir (branch renamed anyway):', err);
1575
+ // worktree_path stays at oldWorktreePath, which still exists on disk
1459
1576
  }
1460
1577
  const updated = workspaceService.updateWorkingBranch(id, newName);
1461
1578
  return c.json(updated);
@@ -1476,7 +1593,12 @@ app.post('/:id/resync-branch', (c) => {
1476
1593
  if (!workspace) {
1477
1594
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1478
1595
  }
1479
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1596
+ if (!workspace.worktreeOwned) {
1597
+ return c.json({
1598
+ error: 'Resync-branch is not available for attached external worktrees.',
1599
+ }, 400);
1600
+ }
1601
+ const worktreePath = workspace.worktreePath;
1480
1602
  let actual;
1481
1603
  try {
1482
1604
  actual = gitOps.getCurrentBranch(worktreePath).trim();
@@ -1490,18 +1612,24 @@ app.post('/:id/resync-branch', (c) => {
1490
1612
  return c.json({ ok: true, changed: false, workingBranch: workspace.workingBranch });
1491
1613
  }
1492
1614
  // Branch was renamed in-place by the agent (`git branch -m ...`). The
1493
- // worktree directory is still at .worktrees/<old-name>; move it so it
1494
- // matches the new ref, otherwise Kōbō's path resolver (projectPath +
1495
- // .worktrees + workingBranch) breaks and subsequent session spawns fail
1496
- // with ENOENT on .mcp.json. Best-effort: if the move fails (dir already
1497
- // moved, lockfile, dirty tree), we still update the DB so git ops stay
1498
- // aligned with the current ref name — the user can repair the dir manually.
1499
- const newWorktreePath = path.join(workspace.projectPath, '.worktrees', actual);
1615
+ // worktree directory is still at <worktrees-root>/<old-name>; move it so it
1616
+ // matches the new ref, otherwise Kōbō's path resolver breaks and
1617
+ // subsequent session spawns fail with ENOENT on .mcp.json. Best-effort:
1618
+ // if the move fails (dir already moved, lockfile, dirty tree), we still
1619
+ // update the DB so git ops stay aligned with the current ref name the
1620
+ // user can repair the dir manually.
1621
+ const oldSuffix = `/${workspace.workingBranch}`;
1622
+ const worktreesRoot = worktreePath.endsWith(oldSuffix)
1623
+ ? worktreePath.slice(0, -oldSuffix.length)
1624
+ : path.join(workspace.projectPath, '.worktrees');
1625
+ const newWorktreePath = path.join(worktreesRoot, actual);
1500
1626
  try {
1501
1627
  gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
1628
+ workspaceService.updateWorktreePath(id, newWorktreePath);
1502
1629
  }
1503
1630
  catch (err) {
1504
1631
  console.error('[workspaces] resync-branch: moveWorktree failed (DB update proceeds):', err);
1632
+ // worktree_path stays at the old path; DB update for working branch still proceeds
1505
1633
  }
1506
1634
  const updated = workspaceService.updateWorkingBranch(id, actual);
1507
1635
  return c.json({ ok: true, changed: true, workingBranch: updated.workingBranch });
@@ -1521,7 +1649,7 @@ app.post('/:id/push', async (c) => {
1521
1649
  }
1522
1650
  const body = await c.req.json().catch(() => ({}));
1523
1651
  const force = body?.force === true;
1524
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1652
+ const worktreePath = workspace.worktreePath;
1525
1653
  try {
1526
1654
  // Only pass an options arg when force is requested — keeps the
1527
1655
  // no-options call shape identical to before for callers/tests that
@@ -1555,7 +1683,7 @@ app.post('/:id/pull', (c) => {
1555
1683
  if (!workspace) {
1556
1684
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1557
1685
  }
1558
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1686
+ const worktreePath = workspace.worktreePath;
1559
1687
  try {
1560
1688
  gitOps.pullBranch(worktreePath, workspace.workingBranch);
1561
1689
  }
@@ -1580,7 +1708,7 @@ app.post('/:id/rebase', (c) => {
1580
1708
  const workspace = workspaceService.getWorkspace(id);
1581
1709
  if (!workspace)
1582
1710
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1583
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1711
+ const worktreePath = workspace.worktreePath;
1584
1712
  gitOps.rebaseBranch(worktreePath, workspace.sourceBranch);
1585
1713
  return c.json({ success: true });
1586
1714
  }
@@ -1599,7 +1727,7 @@ app.post('/:id/merge', (c) => {
1599
1727
  const workspace = workspaceService.getWorkspace(id);
1600
1728
  if (!workspace)
1601
1729
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1602
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1730
+ const worktreePath = workspace.worktreePath;
1603
1731
  gitOps.mergeBranch(worktreePath, workspace.sourceBranch);
1604
1732
  return c.json({ success: true });
1605
1733
  }
@@ -1618,7 +1746,7 @@ app.post('/:id/git/abort', (c) => {
1618
1746
  const workspace = workspaceService.getWorkspace(id);
1619
1747
  if (!workspace)
1620
1748
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1621
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1749
+ const worktreePath = workspace.worktreePath;
1622
1750
  const aborted = gitOps.abortOngoingGitOperation(worktreePath);
1623
1751
  return c.json({ success: true, aborted });
1624
1752
  }
@@ -1635,7 +1763,7 @@ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
1635
1763
  if (!workspace)
1636
1764
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1637
1765
  const body = (await c.req.json().catch(() => ({})));
1638
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1766
+ const worktreePath = workspace.worktreePath;
1639
1767
  const operation = body.operation ?? gitOps.getOngoingGitOperation(worktreePath) ?? 'merge';
1640
1768
  const files = body.files && body.files.length > 0 ? body.files : gitOps.getConflictedFiles(worktreePath);
1641
1769
  if (files.length === 0) {
@@ -1706,7 +1834,7 @@ app.post('/:id/change-pr-base', async (c) => {
1706
1834
  const workspace = workspaceService.getWorkspace(id);
1707
1835
  if (!workspace)
1708
1836
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1709
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1837
+ const worktreePath = workspace.worktreePath;
1710
1838
  await execFileAsync('gh', ['pr', 'edit', '--base', body.base], { cwd: worktreePath });
1711
1839
  return c.json({ success: true });
1712
1840
  }
@@ -1723,7 +1851,7 @@ app.post('/:id/open-pr', async (c) => {
1723
1851
  if (!workspace) {
1724
1852
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1725
1853
  }
1726
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1854
+ const worktreePath = workspace.worktreePath;
1727
1855
  // Verify branch exists on remote
1728
1856
  let lsRemoteOut = '';
1729
1857
  try {
@@ -1818,7 +1946,7 @@ app.post('/:id/open-pr', async (c) => {
1818
1946
  catch {
1819
1947
  // Agent not running — resume it with the PR prompt
1820
1948
  try {
1821
- const worktreePathForResume = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
1949
+ const worktreePathForResume = workspace.worktreePath;
1822
1950
  agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
1823
1951
  workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
1824
1952
  messageSent = true;
@@ -5,6 +5,8 @@
5
5
  */
6
6
  const KOBO_MCP_BRIEF = [
7
7
  '[Kōbō MCP] This workspace exposes a dedicated MCP server with tools prefixed `kobo__`.',
8
+ "Non-interactive mode: this session runs via `claude -p`. Tools requiring a synchronous human reply (e.g. `AskUserQuestion`) won't complete — never call them. If you need user input, end the turn with a plain-text question; the user replies asynchronously via the chat UI.",
9
+ 'Plan mode: when running with `--permission-mode plan`, the read-only restriction applies to MCP tools too, not just built-ins. For Kōbō: `kobo__list_*`, `kobo__read_document`, `kobo__search_codebase`, `kobo__get_*` are fine; `kobo__mark_task_done`, `kobo__log_thought`, `kobo__set_workspace_status` are mutations and must wait until the plan is approved.',
8
10
  'Conventions — read these BEFORE starting work, not as a fallback:',
9
11
  '• `kobo__list_tasks` first on any non-trivial turn, then `kobo__mark_task_done` as each item completes.',
10
12
  '• `kobo__list_documents` / `kobo__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
@@ -704,7 +704,7 @@ function handleQuota(workspaceId, _agentSessionId) {
704
704
  autoLoopService.onQuotaBackoffExpired(workspaceId);
705
705
  }
706
706
  else {
707
- const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
707
+ const freshWorkingDir = freshWs.worktreePath;
708
708
  startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
709
709
  }
710
710
  }
@@ -1,14 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
3
4
  import { getDb } from '../db/index.js';
4
5
  import * as orchestrator from './agent/orchestrator.js';
6
+ import * as settingsService from './settings-service.js';
5
7
  import { emit, emitEphemeral } from './websocket-service.js';
6
8
  import { listTasks } from './workspace-service.js';
7
9
  const NO_PROGRESS_STALL_THRESHOLD = 3;
8
10
  function getRow(workspaceId) {
9
11
  const db = getDb();
10
12
  const row = db
11
- .prepare(`SELECT id, project_path, working_branch, model, permission_mode, reasoning_effort,
13
+ .prepare(`SELECT id, project_path, working_branch, worktree_path, model, permission_mode, reasoning_effort,
12
14
  status, auto_loop, auto_loop_ready, no_progress_streak, archived_at
13
15
  FROM workspaces WHERE id = ?`)
14
16
  .get(workspaceId);
@@ -137,6 +139,13 @@ export function rehydrate() {
137
139
  try {
138
140
  if (orchestrator.hasController(id))
139
141
  continue;
142
+ // Workspaces still in grooming (ready=0) have their session killed by
143
+ // the server reload. Don't disable — the user can re-trigger grooming
144
+ // manually. Auto-disable on missing pending tasks would also fire here
145
+ // if the agent hadn't yet seeded any task before the reload.
146
+ const row = getRow(id);
147
+ if (row?.auto_loop_ready !== 1)
148
+ continue;
140
149
  if (countPendingTasks(id) === 0) {
141
150
  disable(id, 'completed');
142
151
  continue;
@@ -163,7 +172,7 @@ Current pending task (highest priority, non-acceptance-criterion first):
163
172
  - Task ID: {taskId}
164
173
  - Title: {taskTitle}
165
174
  - Is acceptance criterion: {isAcceptanceCriterion}
166
-
175
+ {overrideBlock}
167
176
  Your job this iteration:
168
177
  1. Read \`kobo__list_tasks\` to see all tasks and the big picture.
169
178
  2. Implement the SINGLE task above and nothing else. Do not pick a different task.
@@ -216,11 +225,27 @@ function spawnNextIteration(workspaceId, opts = {}) {
216
225
  return;
217
226
  }
218
227
  const iterationNumber = computeIterationNumber(workspaceId);
228
+ // Override block: replaces the standard iteration prompt body when the task
229
+ // title carries a recognized prefix (case-sensitive, trailing space required).
230
+ // Empty string otherwise so the placeholder collapses cleanly in PROMPT_TEMPLATE.
231
+ // A title cannot literally start with both prefixes, so the order of these
232
+ // branches is purely cosmetic.
233
+ const projectSettings = settingsService.getProjectSettings(row.project_path);
234
+ const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
235
+ const finalizationSettings = projectSettings?.finalization ?? { prompt: '' };
236
+ let overrideBlock = '';
237
+ if (task.title.startsWith('[FINAL] ')) {
238
+ overrideBlock = buildFinalizationIterationBlock(finalizationSettings);
239
+ }
240
+ else if (task.title.startsWith('[E2E] ') && e2eSettings.framework) {
241
+ overrideBlock = buildE2eIterationBlock(e2eSettings);
242
+ }
219
243
  const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
220
244
  .replaceAll('{taskId}', task.id)
221
245
  .replaceAll('{taskTitle}', task.title)
222
- .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion));
223
- const worktreePath = path.join(row.project_path, '.worktrees', row.working_branch);
246
+ .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
247
+ .replaceAll('{overrideBlock}', overrideBlock);
248
+ const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
224
249
  // Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
225
250
  // tools (kobo__mark_task_done, etc.) and Edit/Write/Bash — everything the
226
251
  // iteration needs — so honoring a 'plan' setting here would deadlock the loop.
@@ -5,9 +5,6 @@ import { getProjectSettings } from './settings-service.js';
5
5
  import { emitEphemeral } from './websocket-service.js';
6
6
  import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
7
7
  // ── Helpers ───────────────────────────────────────────────────────────────────
8
- function getWorktreePath(projectPath, workingBranch) {
9
- return path.join(projectPath, '.worktrees', workingBranch);
10
- }
11
8
  /** Build a clean env for child processes, stripping Kobo-specific variables. */
12
9
  function cleanEnv() {
13
10
  const { PORT, SERVER_PORT, ...rest } = process.env;
@@ -166,7 +163,7 @@ export function startDevServer(workspaceId) {
166
163
  }
167
164
  const instanceName = sanitizeBranchName(workspace.workingBranch);
168
165
  // Execute as bash script (supports multi-line scripts)
169
- const worktreePath = getWorktreePath(workspace.projectPath, workspace.workingBranch);
166
+ const worktreePath = workspace.worktreePath;
170
167
  const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
171
168
  const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
172
169
  cwd,
@@ -232,7 +229,7 @@ export function stopDevServer(workspaceId) {
232
229
  }
233
230
  const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
234
231
  const instanceName = config?.instanceName ?? sanitizeBranchName(workspace.workingBranch);
235
- const worktreePath = getWorktreePath(workspace.projectPath, workspace.workingBranch);
232
+ const worktreePath = workspace.worktreePath;
236
233
  const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
237
234
  // Kill tracked process first (covers Node servers and any spawned process)
238
235
  const tracked = trackedProcesses.get(workspaceId);