@loicngr/kobo 1.6.12 → 1.6.13

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 (78) hide show
  1. package/README.md +10 -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 +207 -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 +22 -4
  15. package/dist/server/services/dev-server-service.js +2 -5
  16. package/dist/server/services/settings-service.js +18 -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 +46 -8
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/{ActivityFeed-6Xg7qNfy.js → ActivityFeed-BsY3-q5d.js} +1 -1
  30. package/src/client/dist/spa/assets/CreatePage-Cdhkkx-X.js +2 -0
  31. package/src/client/dist/spa/assets/CreatePage-PRvhol1N.css +1 -0
  32. package/src/client/dist/spa/assets/{DiffViewer-T111s7BH.js → DiffViewer-DXcoEtVq.js} +2 -2
  33. package/src/client/dist/spa/assets/{HealthPage-1VakQ0x_.js → HealthPage-BSyGqDRu.js} +1 -1
  34. package/src/client/dist/spa/assets/{MainLayout-Ci-CETJi.css → MainLayout-D2SfvksB.css} +1 -1
  35. package/src/client/dist/spa/assets/{MainLayout-w7DoW3yz.js → MainLayout-EYaLqjJx.js} +17 -17
  36. package/src/client/dist/spa/assets/{SearchPage-CcldJX8i.js → SearchPage-Bgx02GOH.js} +1 -1
  37. package/src/client/dist/spa/assets/SettingsPage-BTSOovDV.js +1 -0
  38. package/src/client/dist/spa/assets/SettingsPage-CwLELxfl.css +1 -0
  39. package/src/client/dist/spa/assets/WorkspacePage-C5MZx1sZ.css +1 -0
  40. package/src/client/dist/spa/assets/WorkspacePage-C8dJWu-n.js +4 -0
  41. package/src/client/dist/spa/assets/{build-path-tree-DbuI5yRz.js → build-path-tree-D-2LpB2J.js} +1 -1
  42. package/src/client/dist/spa/assets/{cssMode-DhpmJAZc.js → cssMode-DVBmJp-B.js} +1 -1
  43. package/src/client/dist/spa/assets/{documents-fVD9RJth.js → documents-Ck8VwvpQ.js} +1 -1
  44. package/src/client/dist/spa/assets/{editor.api-DCvwHsju.js → editor.api-DgbPJaK4.js} +1 -1
  45. package/src/client/dist/spa/assets/{editor.main-CRtPC0iL.js → editor.main-BqqoRfAU.js} +3 -3
  46. package/src/client/dist/spa/assets/{expand-template-BIra7NIw.js → expand-template-bkCTc78P.js} +1 -1
  47. package/src/client/dist/spa/assets/{freemarker2-C9UOErQw.js → freemarker2-CgaW0Q0y.js} +1 -1
  48. package/src/client/dist/spa/assets/{handlebars-DmZ2-ZcJ.js → handlebars-BSs5PdXe.js} +1 -1
  49. package/src/client/dist/spa/assets/{html-ButyxlXG.js → html-C9wlJaMs.js} +1 -1
  50. package/src/client/dist/spa/assets/{htmlMode-C-defy1b.js → htmlMode-DaRssGJk.js} +1 -1
  51. package/src/client/dist/spa/assets/i18n-BSNIShFg.js +1 -0
  52. package/src/client/dist/spa/assets/index-odgA9x8A.js +2 -0
  53. package/src/client/dist/spa/assets/{javascript-B6zVweIF.js → javascript-D0VYhsc-.js} +1 -1
  54. package/src/client/dist/spa/assets/{jsonMode-CttMw-EY.js → jsonMode-B57EaUNS.js} +1 -1
  55. package/src/client/dist/spa/assets/kobo-commands-D-9dbM70.js +11 -0
  56. package/src/client/dist/spa/assets/{liquid-tGpdE1YW.js → liquid-gP2gg7sw.js} +1 -1
  57. package/src/client/dist/spa/assets/{mdx-Cy5mpQoy.js → mdx-HhXcZn_S.js} +1 -1
  58. package/src/client/dist/spa/assets/{models-DdAQDnqk.js → models-CJC61gWE.js} +1 -1
  59. package/src/client/dist/spa/assets/{monaco.contribution-DtdkkTgR.js → monaco.contribution-ChJg8bwd.js} +2 -2
  60. package/src/client/dist/spa/assets/{python-hLOxMbm9.js → python-DM6FfMV3.js} +1 -1
  61. package/src/client/dist/spa/assets/{razor-tqHFRROa.js → razor-XifsxhTG.js} +1 -1
  62. package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
  63. package/src/client/dist/spa/assets/{tsMode-MJKgZYsJ.js → tsMode-B8gurPqG.js} +1 -1
  64. package/src/client/dist/spa/assets/{typescript-CWTqB5lb.js → typescript-CZKTCOjl.js} +1 -1
  65. package/src/client/dist/spa/assets/{xml-ByDBLBVa.js → xml-CtZPkb7Q.js} +1 -1
  66. package/src/client/dist/spa/assets/{yaml-BiTCWZ38.js → yaml-D5IEE5M-.js} +1 -1
  67. package/src/client/dist/spa/index.html +1 -1
  68. package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
  69. package/src/client/dist/spa/assets/CreatePage-BQu7mQjm.css +0 -1
  70. package/src/client/dist/spa/assets/CreatePage-CrRGDs5V.js +0 -2
  71. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
  72. package/src/client/dist/spa/assets/SettingsPage-CMyeaz63.js +0 -1
  73. package/src/client/dist/spa/assets/WorkspacePage-Bw9xhTDR.js +0 -4
  74. package/src/client/dist/spa/assets/WorkspacePage-_1mty_a4.css +0 -1
  75. package/src/client/dist/spa/assets/i18n-C8aJvuyS.js +0 -1
  76. package/src/client/dist/spa/assets/index-DAbX631s.js +0 -2
  77. package/src/client/dist/spa/assets/kobo-commands-CD7ERFxp.js +0 -10
  78. 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,20 @@ 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: '' };
462
525
  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
526
 
464
527
  Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
465
528
 
466
- ${AUTO_LOOP_GROOMING_STEPS}
467
- 5. Output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
529
+ ${buildAutoLoopGroomingSteps(e2eSettings)}
530
+
531
+ When the steps above are complete, output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
468
532
 
469
533
  ${AUTO_LOOP_HARD_RULES}`;
470
534
  }
@@ -908,6 +972,29 @@ app.get('/archived', (c) => {
908
972
  return c.json({ error: message }, 500);
909
973
  }
910
974
  });
975
+ // GET /:id/prep-autoloop-prompt — compose the project-aware grooming
976
+ // prompt. Used by the "Prepare for auto-loop" button. Place BEFORE
977
+ // `app.get('/:id', ...)` so the more-specific path wins.
978
+ app.get('/:id/prep-autoloop-prompt', (c) => {
979
+ try {
980
+ const id = c.req.param('id');
981
+ const workspace = workspaceService.getWorkspace(id);
982
+ if (!workspace)
983
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
984
+ const projectSettings = settingsService.getProjectSettings(workspace.projectPath);
985
+ const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
986
+ const prompt = `${PREP_AUTOLOOP_INTRO}
987
+
988
+ ${buildAutoLoopGroomingSteps(e2eSettings)}
989
+
990
+ ${AUTO_LOOP_HARD_RULES}`;
991
+ return c.json({ prompt });
992
+ }
993
+ catch (err) {
994
+ const message = err instanceof Error ? err.message : String(err);
995
+ return c.json({ error: message }, 500);
996
+ }
997
+ });
911
998
  // GET /api/workspaces/:id — get workspace details with tasks
912
999
  app.get('/:id', (c) => {
913
1000
  try {
@@ -1038,7 +1125,7 @@ app.post('/:id/open-editor', (c) => {
1038
1125
  if (!globalSettings.editorCommand) {
1039
1126
  return c.json({ error: 'No editor command configured' }, 400);
1040
1127
  }
1041
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1128
+ const worktreePath = workspace.worktreePath;
1042
1129
  if (!fs.existsSync(worktreePath)) {
1043
1130
  return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
1044
1131
  }
@@ -1046,6 +1133,15 @@ app.post('/:id/open-editor', (c) => {
1046
1133
  detached: true,
1047
1134
  stdio: 'ignore',
1048
1135
  });
1136
+ // spawn errors fire async on the ChildProcess (ENOENT etc.) — without a
1137
+ // handler the unhandled 'error' event crashes the whole Node process.
1138
+ child.on('error', (err) => {
1139
+ console.error(`[open-editor] spawn '${globalSettings.editorCommand}' failed:`, err.message);
1140
+ wsService.emitEphemeral(workspace.id, 'editor:open-failed', {
1141
+ command: globalSettings.editorCommand,
1142
+ message: err.message,
1143
+ });
1144
+ });
1049
1145
  child.unref();
1050
1146
  return c.json({ success: true });
1051
1147
  }
@@ -1077,7 +1173,7 @@ app.post('/:id/run-setup-script', async (c) => {
1077
1173
  if (!effectiveSettings.setupScript) {
1078
1174
  return c.json({ error: 'No setup script configured' }, 400);
1079
1175
  }
1080
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1176
+ const worktreePath = workspace.worktreePath;
1081
1177
  if (!fs.existsSync(worktreePath)) {
1082
1178
  return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
1083
1179
  }
@@ -1194,20 +1290,25 @@ app.delete('/:id', migrationGuard, async (c) => {
1194
1290
  // Docker leaves root-owned files inside the worktree, git worktree
1195
1291
  // remove fails with EACCES.
1196
1292
  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);
1293
+ // Remove worktree (only if owned — for attached external worktrees we
1294
+ // never created the dir, so we must not delete it on the user's behalf).
1295
+ const worktreePath = workspace.worktreePath;
1296
+ if (workspace.worktreeOwned) {
1297
+ try {
1298
+ worktreeService.removeWorktree(workspace.projectPath, worktreePath);
1299
+ }
1300
+ catch (err) {
1301
+ const message = err instanceof Error ? err.message : String(err);
1302
+ console.error(`[workspaces] Failed to remove worktree: ${message}`);
1303
+ warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1304
+ `Fix manually:\n` +
1305
+ ` sudo rm -rf '${worktreePath}'\n` +
1306
+ ` cd '${workspace.projectPath}' && git worktree prune\n` +
1307
+ `Reason: ${message}`);
1308
+ }
1202
1309
  }
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}`);
1310
+ else {
1311
+ console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
1211
1312
  }
1212
1313
  // Delete local branch if requested
1213
1314
  if (body.deleteLocalBranch) {
@@ -1283,7 +1384,7 @@ app.post('/:id/start', migrationGuard, async (c) => {
1283
1384
  catch {
1284
1385
  // Agent may not be running — ignore
1285
1386
  }
1286
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
1387
+ const worktreePath = workspace.worktreePath;
1287
1388
  const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId, workspace.reasoningEffort);
1288
1389
  workspaceService.updateWorkspaceStatus(id, 'executing');
1289
1390
  // Persist the user prompt so it survives page refresh.
@@ -1307,7 +1408,7 @@ app.get('/:id/git-stats', async (c) => {
1307
1408
  if (!workspace) {
1308
1409
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1309
1410
  }
1310
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1411
+ const worktreePath = workspace.worktreePath;
1311
1412
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1312
1413
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1313
1414
  const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
@@ -1342,7 +1443,7 @@ app.get('/:id/diff', (c) => {
1342
1443
  if (!workspace) {
1343
1444
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1344
1445
  }
1345
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1446
+ const worktreePath = workspace.worktreePath;
1346
1447
  const files = mode === 'unpushed'
1347
1448
  ? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
1348
1449
  : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
@@ -1376,7 +1477,7 @@ app.get('/:id/diff-file', (c) => {
1376
1477
  if (!workspace) {
1377
1478
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1378
1479
  }
1379
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1480
+ const worktreePath = workspace.worktreePath;
1380
1481
  const baseRef = mode === 'unpushed' ? `origin/${workspace.workingBranch}` : workspace.sourceBranch;
1381
1482
  const original = gitOps.getFileAtRef(worktreePath, baseRef, filePath);
1382
1483
  const modified = gitOps.getFileContent(worktreePath, filePath);
@@ -1399,7 +1500,7 @@ app.get('/:id/commits', (c) => {
1399
1500
  }
1400
1501
  const limitRaw = c.req.query('limit');
1401
1502
  const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
1402
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1503
+ const worktreePath = workspace.worktreePath;
1403
1504
  const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
1404
1505
  c.header('Cache-Control', 'no-store');
1405
1506
  return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
@@ -1429,11 +1530,23 @@ app.post('/:id/rename-branch', async (c) => {
1429
1530
  if (!workspace) {
1430
1531
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1431
1532
  }
1533
+ if (!workspace.worktreeOwned) {
1534
+ return c.json({
1535
+ error: 'Rename is not available for attached external worktrees. Manage the branch name with git directly.',
1536
+ }, 400);
1537
+ }
1432
1538
  if (newName === workspace.workingBranch) {
1433
1539
  return c.json(workspace); // no-op
1434
1540
  }
1435
- const oldWorktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1436
- const newWorktreePath = path.join(workspace.projectPath, '.worktrees', newName);
1541
+ const oldWorktreePath = workspace.worktreePath;
1542
+ // Sibling rename: keep the same worktrees-root, swap the branch leaf.
1543
+ // Cannot use `path.dirname` directly because branches with slashes
1544
+ // (e.g. `feature/x`) make the dirname end one level too deep.
1545
+ const oldSuffix = `/${workspace.workingBranch}`;
1546
+ const worktreesRoot = oldWorktreePath.endsWith(oldSuffix)
1547
+ ? oldWorktreePath.slice(0, -oldSuffix.length)
1548
+ : path.join(workspace.projectPath, '.worktrees');
1549
+ const newWorktreePath = path.join(worktreesRoot, newName);
1437
1550
  // Reject early if the target name is already in use — either as a local
1438
1551
  // branch or on origin. Avoids git's generic "already exists" error and
1439
1552
  // protects against the same silent-fallback trap the create flow has.
@@ -1453,9 +1566,11 @@ app.post('/:id/rename-branch', async (c) => {
1453
1566
  // not the dir, for git operations.
1454
1567
  try {
1455
1568
  gitOps.moveWorktree(workspace.projectPath, oldWorktreePath, newWorktreePath);
1569
+ workspaceService.updateWorktreePath(id, newWorktreePath);
1456
1570
  }
1457
1571
  catch (err) {
1458
1572
  console.error('[workspaces] Failed to move worktree dir (branch renamed anyway):', err);
1573
+ // worktree_path stays at oldWorktreePath, which still exists on disk
1459
1574
  }
1460
1575
  const updated = workspaceService.updateWorkingBranch(id, newName);
1461
1576
  return c.json(updated);
@@ -1476,7 +1591,12 @@ app.post('/:id/resync-branch', (c) => {
1476
1591
  if (!workspace) {
1477
1592
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1478
1593
  }
1479
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1594
+ if (!workspace.worktreeOwned) {
1595
+ return c.json({
1596
+ error: 'Resync-branch is not available for attached external worktrees.',
1597
+ }, 400);
1598
+ }
1599
+ const worktreePath = workspace.worktreePath;
1480
1600
  let actual;
1481
1601
  try {
1482
1602
  actual = gitOps.getCurrentBranch(worktreePath).trim();
@@ -1490,18 +1610,24 @@ app.post('/:id/resync-branch', (c) => {
1490
1610
  return c.json({ ok: true, changed: false, workingBranch: workspace.workingBranch });
1491
1611
  }
1492
1612
  // 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);
1613
+ // worktree directory is still at <worktrees-root>/<old-name>; move it so it
1614
+ // matches the new ref, otherwise Kōbō's path resolver breaks and
1615
+ // subsequent session spawns fail with ENOENT on .mcp.json. Best-effort:
1616
+ // if the move fails (dir already moved, lockfile, dirty tree), we still
1617
+ // update the DB so git ops stay aligned with the current ref name the
1618
+ // user can repair the dir manually.
1619
+ const oldSuffix = `/${workspace.workingBranch}`;
1620
+ const worktreesRoot = worktreePath.endsWith(oldSuffix)
1621
+ ? worktreePath.slice(0, -oldSuffix.length)
1622
+ : path.join(workspace.projectPath, '.worktrees');
1623
+ const newWorktreePath = path.join(worktreesRoot, actual);
1500
1624
  try {
1501
1625
  gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
1626
+ workspaceService.updateWorktreePath(id, newWorktreePath);
1502
1627
  }
1503
1628
  catch (err) {
1504
1629
  console.error('[workspaces] resync-branch: moveWorktree failed (DB update proceeds):', err);
1630
+ // worktree_path stays at the old path; DB update for working branch still proceeds
1505
1631
  }
1506
1632
  const updated = workspaceService.updateWorkingBranch(id, actual);
1507
1633
  return c.json({ ok: true, changed: true, workingBranch: updated.workingBranch });
@@ -1521,7 +1647,7 @@ app.post('/:id/push', async (c) => {
1521
1647
  }
1522
1648
  const body = await c.req.json().catch(() => ({}));
1523
1649
  const force = body?.force === true;
1524
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1650
+ const worktreePath = workspace.worktreePath;
1525
1651
  try {
1526
1652
  // Only pass an options arg when force is requested — keeps the
1527
1653
  // no-options call shape identical to before for callers/tests that
@@ -1555,7 +1681,7 @@ app.post('/:id/pull', (c) => {
1555
1681
  if (!workspace) {
1556
1682
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1557
1683
  }
1558
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1684
+ const worktreePath = workspace.worktreePath;
1559
1685
  try {
1560
1686
  gitOps.pullBranch(worktreePath, workspace.workingBranch);
1561
1687
  }
@@ -1580,7 +1706,7 @@ app.post('/:id/rebase', (c) => {
1580
1706
  const workspace = workspaceService.getWorkspace(id);
1581
1707
  if (!workspace)
1582
1708
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1583
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1709
+ const worktreePath = workspace.worktreePath;
1584
1710
  gitOps.rebaseBranch(worktreePath, workspace.sourceBranch);
1585
1711
  return c.json({ success: true });
1586
1712
  }
@@ -1599,7 +1725,7 @@ app.post('/:id/merge', (c) => {
1599
1725
  const workspace = workspaceService.getWorkspace(id);
1600
1726
  if (!workspace)
1601
1727
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1602
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1728
+ const worktreePath = workspace.worktreePath;
1603
1729
  gitOps.mergeBranch(worktreePath, workspace.sourceBranch);
1604
1730
  return c.json({ success: true });
1605
1731
  }
@@ -1618,7 +1744,7 @@ app.post('/:id/git/abort', (c) => {
1618
1744
  const workspace = workspaceService.getWorkspace(id);
1619
1745
  if (!workspace)
1620
1746
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1621
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1747
+ const worktreePath = workspace.worktreePath;
1622
1748
  const aborted = gitOps.abortOngoingGitOperation(worktreePath);
1623
1749
  return c.json({ success: true, aborted });
1624
1750
  }
@@ -1635,7 +1761,7 @@ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
1635
1761
  if (!workspace)
1636
1762
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1637
1763
  const body = (await c.req.json().catch(() => ({})));
1638
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1764
+ const worktreePath = workspace.worktreePath;
1639
1765
  const operation = body.operation ?? gitOps.getOngoingGitOperation(worktreePath) ?? 'merge';
1640
1766
  const files = body.files && body.files.length > 0 ? body.files : gitOps.getConflictedFiles(worktreePath);
1641
1767
  if (files.length === 0) {
@@ -1706,7 +1832,7 @@ app.post('/:id/change-pr-base', async (c) => {
1706
1832
  const workspace = workspaceService.getWorkspace(id);
1707
1833
  if (!workspace)
1708
1834
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1709
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1835
+ const worktreePath = workspace.worktreePath;
1710
1836
  await execFileAsync('gh', ['pr', 'edit', '--base', body.base], { cwd: worktreePath });
1711
1837
  return c.json({ success: true });
1712
1838
  }
@@ -1723,7 +1849,7 @@ app.post('/:id/open-pr', async (c) => {
1723
1849
  if (!workspace) {
1724
1850
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1725
1851
  }
1726
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1852
+ const worktreePath = workspace.worktreePath;
1727
1853
  // Verify branch exists on remote
1728
1854
  let lsRemoteOut = '';
1729
1855
  try {
@@ -1818,7 +1944,7 @@ app.post('/:id/open-pr', async (c) => {
1818
1944
  catch {
1819
1945
  // Agent not running — resume it with the PR prompt
1820
1946
  try {
1821
- const worktreePathForResume = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
1947
+ const worktreePathForResume = workspace.worktreePath;
1822
1948
  agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
1823
1949
  workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
1824
1950
  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 } 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
+ {e2eBlock}
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,20 @@ function spawnNextIteration(workspaceId, opts = {}) {
216
225
  return;
217
226
  }
218
227
  const iterationNumber = computeIterationNumber(workspaceId);
228
+ // E2E iteration block: only injected for tasks whose title starts with the
229
+ // exact `[E2E] ` prefix (case-sensitive, trailing space required) AND when
230
+ // the project has an E2E framework configured. Empty string otherwise so
231
+ // the placeholder collapses cleanly in PROMPT_TEMPLATE.
232
+ const projectSettings = settingsService.getProjectSettings(row.project_path);
233
+ const e2eSettings = projectSettings?.e2e ?? { framework: '', skill: '', prompt: '' };
234
+ const isE2eTask = task.title.startsWith('[E2E] ');
235
+ const e2eBlock = isE2eTask && e2eSettings.framework ? buildE2eIterationBlock(e2eSettings) : '';
219
236
  const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
220
237
  .replaceAll('{taskId}', task.id)
221
238
  .replaceAll('{taskTitle}', task.title)
222
- .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion));
223
- const worktreePath = path.join(row.project_path, '.worktrees', row.working_branch);
239
+ .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
240
+ .replaceAll('{e2eBlock}', e2eBlock);
241
+ const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
224
242
  // Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
225
243
  // tools (kobo__mark_task_done, etc.) and Edit/Write/Bash — everything the
226
244
  // 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);