@loicngr/kobo 1.6.11 → 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 (80) 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 +31 -0
  4. package/dist/server/db/schema.js +11 -0
  5. package/dist/server/index.js +32 -5
  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 +231 -146
  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/notion-service.js +15 -3
  17. package/dist/server/services/settings-service.js +18 -2
  18. package/dist/server/services/usage/db.js +29 -0
  19. package/dist/server/services/usage/index.js +2 -0
  20. package/dist/server/services/usage/poller.js +52 -0
  21. package/dist/server/services/usage/providers/claude-code.js +93 -0
  22. package/dist/server/services/usage/types.js +1 -0
  23. package/dist/server/services/wakeup-service.js +2 -2
  24. package/dist/server/services/websocket-service.js +14 -0
  25. package/dist/server/services/workspace-service.js +29 -3
  26. package/dist/server/services/worktree-service.js +50 -0
  27. package/dist/server/utils/mcp-client.js +7 -0
  28. package/dist/shared/auto-loop-prompts.js +46 -8
  29. package/package.json +1 -1
  30. package/src/client/dist/spa/assets/{ActivityFeed-Cv2cHAob.js → ActivityFeed-BsY3-q5d.js} +1 -1
  31. package/src/client/dist/spa/assets/CreatePage-Cdhkkx-X.js +2 -0
  32. package/src/client/dist/spa/assets/CreatePage-PRvhol1N.css +1 -0
  33. package/src/client/dist/spa/assets/{DiffViewer-xp8A1R-2.js → DiffViewer-DXcoEtVq.js} +2 -2
  34. package/src/client/dist/spa/assets/{HealthPage-iPEmaIxf.js → HealthPage-BSyGqDRu.js} +1 -1
  35. package/src/client/dist/spa/assets/MainLayout-D2SfvksB.css +1 -0
  36. package/src/client/dist/spa/assets/{MainLayout-CLSGgDp_.js → MainLayout-EYaLqjJx.js} +17 -17
  37. package/src/client/dist/spa/assets/{SearchPage-C_Z_-mPY.js → SearchPage-Bgx02GOH.js} +1 -1
  38. package/src/client/dist/spa/assets/SettingsPage-BTSOovDV.js +1 -0
  39. package/src/client/dist/spa/assets/SettingsPage-CwLELxfl.css +1 -0
  40. package/src/client/dist/spa/assets/WorkspacePage-C5MZx1sZ.css +1 -0
  41. package/src/client/dist/spa/assets/WorkspacePage-C8dJWu-n.js +4 -0
  42. package/src/client/dist/spa/assets/{build-path-tree-BAbslBF6.js → build-path-tree-D-2LpB2J.js} +1 -1
  43. package/src/client/dist/spa/assets/{cssMode-COLGl5q-.js → cssMode-DVBmJp-B.js} +1 -1
  44. package/src/client/dist/spa/assets/{documents-BlJv_G6j.js → documents-Ck8VwvpQ.js} +1 -1
  45. package/src/client/dist/spa/assets/{editor.api-CmWAkEBP.js → editor.api-DgbPJaK4.js} +1 -1
  46. package/src/client/dist/spa/assets/{editor.main-B50pvEsj.js → editor.main-BqqoRfAU.js} +3 -3
  47. package/src/client/dist/spa/assets/{expand-template-CVF0qYFz.js → expand-template-bkCTc78P.js} +1 -1
  48. package/src/client/dist/spa/assets/{freemarker2-x6p1HdGv.js → freemarker2-CgaW0Q0y.js} +1 -1
  49. package/src/client/dist/spa/assets/{handlebars-DVdpHx-F.js → handlebars-BSs5PdXe.js} +1 -1
  50. package/src/client/dist/spa/assets/{html-CVBXIMk9.js → html-C9wlJaMs.js} +1 -1
  51. package/src/client/dist/spa/assets/{htmlMode-bcy_B_7M.js → htmlMode-DaRssGJk.js} +1 -1
  52. package/src/client/dist/spa/assets/i18n-BSNIShFg.js +1 -0
  53. package/src/client/dist/spa/assets/index-odgA9x8A.js +2 -0
  54. package/src/client/dist/spa/assets/{javascript-C8NntD-t.js → javascript-D0VYhsc-.js} +1 -1
  55. package/src/client/dist/spa/assets/{jsonMode-CKmO44kP.js → jsonMode-B57EaUNS.js} +1 -1
  56. package/src/client/dist/spa/assets/kobo-commands-D-9dbM70.js +11 -0
  57. package/src/client/dist/spa/assets/{liquid-JGbytnvM.js → liquid-gP2gg7sw.js} +1 -1
  58. package/src/client/dist/spa/assets/{mdx-8xDuI4Ra.js → mdx-HhXcZn_S.js} +1 -1
  59. package/src/client/dist/spa/assets/{models-IFgNVQuG.js → models-CJC61gWE.js} +1 -1
  60. package/src/client/dist/spa/assets/{monaco.contribution-DxgwWnvV.js → monaco.contribution-ChJg8bwd.js} +2 -2
  61. package/src/client/dist/spa/assets/{python-DntkvJJn.js → python-DM6FfMV3.js} +1 -1
  62. package/src/client/dist/spa/assets/{razor-CBMu7MSu.js → razor-XifsxhTG.js} +1 -1
  63. package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
  64. package/src/client/dist/spa/assets/{tsMode-qUgDyCdk.js → tsMode-B8gurPqG.js} +1 -1
  65. package/src/client/dist/spa/assets/{typescript-Bp6APJ3s.js → typescript-CZKTCOjl.js} +1 -1
  66. package/src/client/dist/spa/assets/{xml-D1_1t5sz.js → xml-CtZPkb7Q.js} +1 -1
  67. package/src/client/dist/spa/assets/{yaml-T38tRjC8.js → yaml-D5IEE5M-.js} +1 -1
  68. package/src/client/dist/spa/index.html +1 -1
  69. package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
  70. package/src/client/dist/spa/assets/CreatePage-DrGARGo5.js +0 -2
  71. package/src/client/dist/spa/assets/CreatePage-d0Qp-PnO.css +0 -1
  72. package/src/client/dist/spa/assets/MainLayout-CDR4Le5c.css +0 -1
  73. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
  74. package/src/client/dist/spa/assets/SettingsPage-DETFZXCZ.js +0 -1
  75. package/src/client/dist/spa/assets/WorkspacePage-BAkCj4gZ.js +0 -4
  76. package/src/client/dist/spa/assets/WorkspacePage-Bo1GW3wo.css +0 -1
  77. package/src/client/dist/spa/assets/i18n-DncqzfKK.js +0 -1
  78. package/src/client/dist/spa/assets/index-Dl8rTFls.js +0 -2
  79. package/src/client/dist/spa/assets/kobo-commands-30GNdCpd.js +0 -10
  80. 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,10 +67,100 @@ 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
+ }
109
+ // Pre-flight: extract Notion / Sentry before any DB write. A throw here
110
+ // must not leave a half-built workspace behind, so we run extraction
111
+ // before createWorkspace and surface failures as 422.
112
+ let notionContent = null;
113
+ if (body.notionUrl) {
114
+ try {
115
+ notionContent = await notionService.extractNotionPage(body.notionUrl);
116
+ }
117
+ catch (err) {
118
+ const message = err instanceof Error ? err.message : String(err);
119
+ return c.json({ error: `Failed to extract Notion page: ${message}` }, 422);
120
+ }
121
+ }
122
+ let sentryContent = null;
123
+ if (body.sentryUrl) {
124
+ try {
125
+ sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
126
+ }
127
+ catch (err) {
128
+ const message = err instanceof Error ? err.message : String(err);
129
+ return c.json({ error: `Failed to extract Sentry issue: ${message}` }, 422);
130
+ }
131
+ }
65
132
  // Create workspace record
66
133
  const globalSettings = settingsService.getGlobalSettings();
67
- // workingBranch may be updated after Notion extraction to inject the ticket ID
68
- 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
+ }
69
164
  let workspace = workspaceService.createWorkspace({
70
165
  name: body.name,
71
166
  projectPath: body.projectPath,
@@ -73,13 +168,13 @@ app.post('/', migrationGuard, async (c) => {
73
168
  workingBranch,
74
169
  notionUrl: body.notionUrl,
75
170
  notionPageId: body.notionPageId,
171
+ sentryUrl: body.sentryUrl,
172
+ ...(useReusedWorktree ? { worktreePath: body.worktreePath, worktreeOwned: false } : {}),
76
173
  model: body.model,
77
174
  reasoningEffort: body.reasoningEffort,
78
175
  permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
79
176
  engine: body.engine,
80
177
  });
81
- let notionContent = null;
82
- let sentryContent = null;
83
178
  // Auto-tag the workspace based on its creation source — `notion` when
84
179
  // imported from a Notion page, `sentry` when bootstrapped from a Sentry
85
180
  // issue URL. Pre-seeded in the global tag catalogue via migration v9.
@@ -101,29 +196,6 @@ app.post('/', migrationGuard, async (c) => {
101
196
  console.error('[workspaces] Failed to apply auto tags:', err);
102
197
  }
103
198
  }
104
- // Extract Notion page content if a URL was provided
105
- if (body.notionUrl) {
106
- workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
107
- try {
108
- notionContent = await notionService.extractNotionPage(body.notionUrl);
109
- }
110
- catch (err) {
111
- const message = err instanceof Error ? err.message : String(err);
112
- console.error(`[workspaces] Failed to extract Notion page: ${message}`);
113
- }
114
- }
115
- // Extract Sentry issue content if a URL was provided. Done early (before
116
- // worktree creation) so the issue ID can be injected into the branch name.
117
- if (body.sentryUrl) {
118
- workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
119
- try {
120
- sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
121
- }
122
- catch (err) {
123
- const message = err instanceof Error ? err.message : String(err);
124
- console.error(`[workspaces] Failed to extract Sentry issue: ${message}`);
125
- }
126
- }
127
199
  // Update workspace name with Sentry issue title if the user did not provide
128
200
  // a custom name and Notion hasn't already filled it.
129
201
  if (sentryContent?.title && !notionContent?.title && workspace.name === 'workspace') {
@@ -177,41 +249,21 @@ app.post('/', migrationGuard, async (c) => {
177
249
  }
178
250
  }
179
251
  }
180
- // Inject ticket ID into the working branch.
181
- // Works with or without Notion: ticket ID comes from Notion extraction first,
182
- // then falls back to a TK-XXXX pattern anywhere in the workspace name.
183
- // The worktree has not been created yet, so a DB update is sufficient.
184
- {
185
- // Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
186
- // which is what Sentry auto-close recognises in commit messages.
187
- const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
188
- if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
189
- const ticketPrefix = detectedTicketId.toUpperCase();
190
- const slashIdx = workingBranch.indexOf('/');
191
- const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
192
- // Use Notion/Sentry title or workspace name for the slug — all have proper accented
193
- // characters that NFD normalization can transliterate (é→e, ç→c, etc.)
194
- const titleSource = notionContent?.title || sentryContent?.title || workspace.name;
195
- const titleSlug = titleSource
196
- .normalize('NFD')
197
- .replace(/[\u0300-\u036f]/g, '')
198
- .toLowerCase()
199
- .replace(/[^a-z0-9]+/g, '-')
200
- .replace(/^-|-$/g, '')
201
- .substring(0, 50);
202
- workingBranch = `${typePrefix}${ticketPrefix}--${titleSlug}`;
203
- workspace = workspaceService.updateWorkingBranch(workspace.id, workingBranch);
204
- }
205
- }
206
- // 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.
207
254
  let worktreePath;
208
- try {
209
- worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
255
+ if (useReusedWorktree) {
256
+ worktreePath = body.worktreePath;
210
257
  }
211
- catch (err) {
212
- const message = err instanceof Error ? err.message : String(err);
213
- workspaceService.updateWorkspaceStatus(workspace.id, 'error');
214
- 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
+ }
215
267
  }
216
268
  // Ensure Kobo-generated files are gitignored. Check both the root
217
269
  // .gitignore and .ai/.gitignore to avoid duplicate entries.
@@ -260,7 +312,10 @@ app.post('/', migrationGuard, async (c) => {
260
312
  }
261
313
  // Run setup script if configured and not skipped
262
314
  let setupScriptFailed = false;
263
- 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) {
264
319
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
265
320
  wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
266
321
  try {
@@ -460,12 +515,20 @@ app.post('/', migrationGuard, async (c) => {
460
515
  // NOT with implementation. The auto-loop will drive implementation after.
461
516
  // The grooming steps + hard rules are shared with the PREP_AUTOLOOP_PROMPT
462
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: '' };
463
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.
464
526
 
465
527
  Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
466
528
 
467
- ${AUTO_LOOP_GROOMING_STEPS}
468
- 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.
469
532
 
470
533
  ${AUTO_LOOP_HARD_RULES}`;
471
534
  }
@@ -688,46 +751,6 @@ app.patch('/:id/sessions/:sessionId', async (c) => {
688
751
  return c.json({ error: message }, 500);
689
752
  }
690
753
  });
691
- // POST /api/workspaces/:id/refresh-notion — re-extract Notion page and update tasks
692
- app.post('/:id/refresh-notion', async (c) => {
693
- try {
694
- const id = c.req.param('id');
695
- const workspace = workspaceService.getWorkspace(id);
696
- if (!workspace)
697
- return c.json({ error: `Workspace '${id}' not found` }, 404);
698
- if (!workspace.notionUrl)
699
- return c.json({ error: 'No Notion URL configured' }, 400);
700
- const notionContent = await notionService.extractNotionPage(workspace.notionUrl);
701
- // Delete existing tasks and recreate from Notion
702
- const db = getDb();
703
- db.prepare('DELETE FROM tasks WHERE workspace_id = ?').run(id);
704
- let sortOrder = 0;
705
- for (const todo of notionContent.todos) {
706
- workspaceService.createTask(id, {
707
- title: todo.title,
708
- isAcceptanceCriterion: false,
709
- sortOrder: sortOrder++,
710
- });
711
- }
712
- for (const feature of notionContent.gherkinFeatures) {
713
- workspaceService.createTask(id, {
714
- title: feature,
715
- isAcceptanceCriterion: true,
716
- sortOrder: sortOrder++,
717
- });
718
- }
719
- // Update name if it was the default
720
- if (notionContent.title && workspace.name === 'workspace') {
721
- workspaceService.updateWorkspaceName(id, notionContent.title);
722
- }
723
- const updated = workspaceService.getWorkspaceWithTasks(id);
724
- return c.json(updated);
725
- }
726
- catch (err) {
727
- const message = err instanceof Error ? err.message : String(err);
728
- return c.json({ error: message }, 500);
729
- }
730
- });
731
754
  // POST /api/workspaces/:id/tasks — create a new task
732
755
  app.post('/:id/tasks', async (c) => {
733
756
  try {
@@ -949,6 +972,29 @@ app.get('/archived', (c) => {
949
972
  return c.json({ error: message }, 500);
950
973
  }
951
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
+ });
952
998
  // GET /api/workspaces/:id — get workspace details with tasks
953
999
  app.get('/:id', (c) => {
954
1000
  try {
@@ -1079,7 +1125,7 @@ app.post('/:id/open-editor', (c) => {
1079
1125
  if (!globalSettings.editorCommand) {
1080
1126
  return c.json({ error: 'No editor command configured' }, 400);
1081
1127
  }
1082
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1128
+ const worktreePath = workspace.worktreePath;
1083
1129
  if (!fs.existsSync(worktreePath)) {
1084
1130
  return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
1085
1131
  }
@@ -1087,6 +1133,15 @@ app.post('/:id/open-editor', (c) => {
1087
1133
  detached: true,
1088
1134
  stdio: 'ignore',
1089
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
+ });
1090
1145
  child.unref();
1091
1146
  return c.json({ success: true });
1092
1147
  }
@@ -1118,7 +1173,7 @@ app.post('/:id/run-setup-script', async (c) => {
1118
1173
  if (!effectiveSettings.setupScript) {
1119
1174
  return c.json({ error: 'No setup script configured' }, 400);
1120
1175
  }
1121
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1176
+ const worktreePath = workspace.worktreePath;
1122
1177
  if (!fs.existsSync(worktreePath)) {
1123
1178
  return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
1124
1179
  }
@@ -1235,20 +1290,25 @@ app.delete('/:id', migrationGuard, async (c) => {
1235
1290
  // Docker leaves root-owned files inside the worktree, git worktree
1236
1291
  // remove fails with EACCES.
1237
1292
  const warnings = [];
1238
- // Remove worktree
1239
- const worktreesDir = `${workspace.projectPath}/.worktrees`;
1240
- const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
1241
- try {
1242
- 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
+ }
1243
1309
  }
1244
- catch (err) {
1245
- const message = err instanceof Error ? err.message : String(err);
1246
- console.error(`[workspaces] Failed to remove worktree: ${message}`);
1247
- warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
1248
- `Fix manually:\n` +
1249
- ` sudo rm -rf '${worktreePath}'\n` +
1250
- ` cd '${workspace.projectPath}' && git worktree prune\n` +
1251
- `Reason: ${message}`);
1310
+ else {
1311
+ console.log(`[workspaces] keeping reused worktree on delete: ${worktreePath}`);
1252
1312
  }
1253
1313
  // Delete local branch if requested
1254
1314
  if (body.deleteLocalBranch) {
@@ -1324,7 +1384,7 @@ app.post('/:id/start', migrationGuard, async (c) => {
1324
1384
  catch {
1325
1385
  // Agent may not be running — ignore
1326
1386
  }
1327
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
1387
+ const worktreePath = workspace.worktreePath;
1328
1388
  const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId, workspace.reasoningEffort);
1329
1389
  workspaceService.updateWorkspaceStatus(id, 'executing');
1330
1390
  // Persist the user prompt so it survives page refresh.
@@ -1348,7 +1408,7 @@ app.get('/:id/git-stats', async (c) => {
1348
1408
  if (!workspace) {
1349
1409
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1350
1410
  }
1351
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1411
+ const worktreePath = workspace.worktreePath;
1352
1412
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1353
1413
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1354
1414
  const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
@@ -1383,7 +1443,7 @@ app.get('/:id/diff', (c) => {
1383
1443
  if (!workspace) {
1384
1444
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1385
1445
  }
1386
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1446
+ const worktreePath = workspace.worktreePath;
1387
1447
  const files = mode === 'unpushed'
1388
1448
  ? gitOps.getUnpushedChangedFiles(worktreePath, workspace.workingBranch)
1389
1449
  : gitOps.getChangedFiles(worktreePath, workspace.sourceBranch);
@@ -1417,7 +1477,7 @@ app.get('/:id/diff-file', (c) => {
1417
1477
  if (!workspace) {
1418
1478
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1419
1479
  }
1420
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1480
+ const worktreePath = workspace.worktreePath;
1421
1481
  const baseRef = mode === 'unpushed' ? `origin/${workspace.workingBranch}` : workspace.sourceBranch;
1422
1482
  const original = gitOps.getFileAtRef(worktreePath, baseRef, filePath);
1423
1483
  const modified = gitOps.getFileContent(worktreePath, filePath);
@@ -1440,7 +1500,7 @@ app.get('/:id/commits', (c) => {
1440
1500
  }
1441
1501
  const limitRaw = c.req.query('limit');
1442
1502
  const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '50', 10) || 50), 200);
1443
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1503
+ const worktreePath = workspace.worktreePath;
1444
1504
  const commits = gitOps.listBranchCommits(worktreePath, workspace.sourceBranch, workspace.workingBranch, limit);
1445
1505
  c.header('Cache-Control', 'no-store');
1446
1506
  return c.json({ commits, sourceBranch: workspace.sourceBranch, workingBranch: workspace.workingBranch });
@@ -1470,11 +1530,23 @@ app.post('/:id/rename-branch', async (c) => {
1470
1530
  if (!workspace) {
1471
1531
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1472
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
+ }
1473
1538
  if (newName === workspace.workingBranch) {
1474
1539
  return c.json(workspace); // no-op
1475
1540
  }
1476
- const oldWorktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1477
- 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);
1478
1550
  // Reject early if the target name is already in use — either as a local
1479
1551
  // branch or on origin. Avoids git's generic "already exists" error and
1480
1552
  // protects against the same silent-fallback trap the create flow has.
@@ -1494,9 +1566,11 @@ app.post('/:id/rename-branch', async (c) => {
1494
1566
  // not the dir, for git operations.
1495
1567
  try {
1496
1568
  gitOps.moveWorktree(workspace.projectPath, oldWorktreePath, newWorktreePath);
1569
+ workspaceService.updateWorktreePath(id, newWorktreePath);
1497
1570
  }
1498
1571
  catch (err) {
1499
1572
  console.error('[workspaces] Failed to move worktree dir (branch renamed anyway):', err);
1573
+ // worktree_path stays at oldWorktreePath, which still exists on disk
1500
1574
  }
1501
1575
  const updated = workspaceService.updateWorkingBranch(id, newName);
1502
1576
  return c.json(updated);
@@ -1517,7 +1591,12 @@ app.post('/:id/resync-branch', (c) => {
1517
1591
  if (!workspace) {
1518
1592
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1519
1593
  }
1520
- 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;
1521
1600
  let actual;
1522
1601
  try {
1523
1602
  actual = gitOps.getCurrentBranch(worktreePath).trim();
@@ -1531,18 +1610,24 @@ app.post('/:id/resync-branch', (c) => {
1531
1610
  return c.json({ ok: true, changed: false, workingBranch: workspace.workingBranch });
1532
1611
  }
1533
1612
  // Branch was renamed in-place by the agent (`git branch -m ...`). The
1534
- // worktree directory is still at .worktrees/<old-name>; move it so it
1535
- // matches the new ref, otherwise Kōbō's path resolver (projectPath +
1536
- // .worktrees + workingBranch) breaks and subsequent session spawns fail
1537
- // with ENOENT on .mcp.json. Best-effort: if the move fails (dir already
1538
- // moved, lockfile, dirty tree), we still update the DB so git ops stay
1539
- // aligned with the current ref name — the user can repair the dir manually.
1540
- 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);
1541
1624
  try {
1542
1625
  gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
1626
+ workspaceService.updateWorktreePath(id, newWorktreePath);
1543
1627
  }
1544
1628
  catch (err) {
1545
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
1546
1631
  }
1547
1632
  const updated = workspaceService.updateWorkingBranch(id, actual);
1548
1633
  return c.json({ ok: true, changed: true, workingBranch: updated.workingBranch });
@@ -1562,7 +1647,7 @@ app.post('/:id/push', async (c) => {
1562
1647
  }
1563
1648
  const body = await c.req.json().catch(() => ({}));
1564
1649
  const force = body?.force === true;
1565
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1650
+ const worktreePath = workspace.worktreePath;
1566
1651
  try {
1567
1652
  // Only pass an options arg when force is requested — keeps the
1568
1653
  // no-options call shape identical to before for callers/tests that
@@ -1596,7 +1681,7 @@ app.post('/:id/pull', (c) => {
1596
1681
  if (!workspace) {
1597
1682
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1598
1683
  }
1599
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1684
+ const worktreePath = workspace.worktreePath;
1600
1685
  try {
1601
1686
  gitOps.pullBranch(worktreePath, workspace.workingBranch);
1602
1687
  }
@@ -1621,7 +1706,7 @@ app.post('/:id/rebase', (c) => {
1621
1706
  const workspace = workspaceService.getWorkspace(id);
1622
1707
  if (!workspace)
1623
1708
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1624
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1709
+ const worktreePath = workspace.worktreePath;
1625
1710
  gitOps.rebaseBranch(worktreePath, workspace.sourceBranch);
1626
1711
  return c.json({ success: true });
1627
1712
  }
@@ -1640,7 +1725,7 @@ app.post('/:id/merge', (c) => {
1640
1725
  const workspace = workspaceService.getWorkspace(id);
1641
1726
  if (!workspace)
1642
1727
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1643
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1728
+ const worktreePath = workspace.worktreePath;
1644
1729
  gitOps.mergeBranch(worktreePath, workspace.sourceBranch);
1645
1730
  return c.json({ success: true });
1646
1731
  }
@@ -1659,7 +1744,7 @@ app.post('/:id/git/abort', (c) => {
1659
1744
  const workspace = workspaceService.getWorkspace(id);
1660
1745
  if (!workspace)
1661
1746
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1662
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1747
+ const worktreePath = workspace.worktreePath;
1663
1748
  const aborted = gitOps.abortOngoingGitOperation(worktreePath);
1664
1749
  return c.json({ success: true, aborted });
1665
1750
  }
@@ -1676,7 +1761,7 @@ app.post('/:id/git/resolve-with-agent', migrationGuard, async (c) => {
1676
1761
  if (!workspace)
1677
1762
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1678
1763
  const body = (await c.req.json().catch(() => ({})));
1679
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1764
+ const worktreePath = workspace.worktreePath;
1680
1765
  const operation = body.operation ?? gitOps.getOngoingGitOperation(worktreePath) ?? 'merge';
1681
1766
  const files = body.files && body.files.length > 0 ? body.files : gitOps.getConflictedFiles(worktreePath);
1682
1767
  if (files.length === 0) {
@@ -1747,7 +1832,7 @@ app.post('/:id/change-pr-base', async (c) => {
1747
1832
  const workspace = workspaceService.getWorkspace(id);
1748
1833
  if (!workspace)
1749
1834
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1750
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1835
+ const worktreePath = workspace.worktreePath;
1751
1836
  await execFileAsync('gh', ['pr', 'edit', '--base', body.base], { cwd: worktreePath });
1752
1837
  return c.json({ success: true });
1753
1838
  }
@@ -1764,7 +1849,7 @@ app.post('/:id/open-pr', async (c) => {
1764
1849
  if (!workspace) {
1765
1850
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1766
1851
  }
1767
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1852
+ const worktreePath = workspace.worktreePath;
1768
1853
  // Verify branch exists on remote
1769
1854
  let lsRemoteOut = '';
1770
1855
  try {
@@ -1859,7 +1944,7 @@ app.post('/:id/open-pr', async (c) => {
1859
1944
  catch {
1860
1945
  // Agent not running — resume it with the PR prompt
1861
1946
  try {
1862
- const worktreePathForResume = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
1947
+ const worktreePathForResume = workspace.worktreePath;
1863
1948
  agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
1864
1949
  workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
1865
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
  }