@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.
- package/README.md +10 -6
- package/dist/mcp-server/kobo-tasks-handlers.js +2 -2
- package/dist/server/db/migrations.js +24 -0
- package/dist/server/db/schema.js +10 -0
- package/dist/server/index.js +27 -4
- package/dist/server/routes/documents.js +2 -2
- package/dist/server/routes/git.js +21 -0
- package/dist/server/routes/health.js +2 -2
- package/dist/server/routes/images.js +3 -3
- package/dist/server/routes/usage.js +18 -0
- package/dist/server/routes/workspaces.js +207 -81
- package/dist/server/services/agent/engines/claude-code/args-builder.js +2 -0
- package/dist/server/services/agent/orchestrator.js +1 -1
- package/dist/server/services/auto-loop-service.js +22 -4
- package/dist/server/services/dev-server-service.js +2 -5
- package/dist/server/services/settings-service.js +18 -2
- package/dist/server/services/usage/db.js +29 -0
- package/dist/server/services/usage/index.js +2 -0
- package/dist/server/services/usage/poller.js +52 -0
- package/dist/server/services/usage/providers/claude-code.js +93 -0
- package/dist/server/services/usage/types.js +1 -0
- package/dist/server/services/wakeup-service.js +2 -2
- package/dist/server/services/websocket-service.js +14 -0
- package/dist/server/services/workspace-service.js +28 -3
- package/dist/server/services/worktree-service.js +50 -0
- package/dist/server/utils/mcp-client.js +7 -0
- package/dist/shared/auto-loop-prompts.js +46 -8
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-6Xg7qNfy.js → ActivityFeed-BsY3-q5d.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Cdhkkx-X.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-PRvhol1N.css +1 -0
- package/src/client/dist/spa/assets/{DiffViewer-T111s7BH.js → DiffViewer-DXcoEtVq.js} +2 -2
- package/src/client/dist/spa/assets/{HealthPage-1VakQ0x_.js → HealthPage-BSyGqDRu.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-Ci-CETJi.css → MainLayout-D2SfvksB.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-w7DoW3yz.js → MainLayout-EYaLqjJx.js} +17 -17
- package/src/client/dist/spa/assets/{SearchPage-CcldJX8i.js → SearchPage-Bgx02GOH.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-BTSOovDV.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-CwLELxfl.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-C5MZx1sZ.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-C8dJWu-n.js +4 -0
- package/src/client/dist/spa/assets/{build-path-tree-DbuI5yRz.js → build-path-tree-D-2LpB2J.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-DhpmJAZc.js → cssMode-DVBmJp-B.js} +1 -1
- package/src/client/dist/spa/assets/{documents-fVD9RJth.js → documents-Ck8VwvpQ.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DCvwHsju.js → editor.api-DgbPJaK4.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-CRtPC0iL.js → editor.main-BqqoRfAU.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-BIra7NIw.js → expand-template-bkCTc78P.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-C9UOErQw.js → freemarker2-CgaW0Q0y.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-DmZ2-ZcJ.js → handlebars-BSs5PdXe.js} +1 -1
- package/src/client/dist/spa/assets/{html-ButyxlXG.js → html-C9wlJaMs.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C-defy1b.js → htmlMode-DaRssGJk.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BSNIShFg.js +1 -0
- package/src/client/dist/spa/assets/index-odgA9x8A.js +2 -0
- package/src/client/dist/spa/assets/{javascript-B6zVweIF.js → javascript-D0VYhsc-.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-CttMw-EY.js → jsonMode-B57EaUNS.js} +1 -1
- package/src/client/dist/spa/assets/kobo-commands-D-9dbM70.js +11 -0
- package/src/client/dist/spa/assets/{liquid-tGpdE1YW.js → liquid-gP2gg7sw.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-Cy5mpQoy.js → mdx-HhXcZn_S.js} +1 -1
- package/src/client/dist/spa/assets/{models-DdAQDnqk.js → models-CJC61gWE.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-DtdkkTgR.js → monaco.contribution-ChJg8bwd.js} +2 -2
- package/src/client/dist/spa/assets/{python-hLOxMbm9.js → python-DM6FfMV3.js} +1 -1
- package/src/client/dist/spa/assets/{razor-tqHFRROa.js → razor-XifsxhTG.js} +1 -1
- package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-MJKgZYsJ.js → tsMode-B8gurPqG.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-CWTqB5lb.js → typescript-CZKTCOjl.js} +1 -1
- package/src/client/dist/spa/assets/{xml-ByDBLBVa.js → xml-CtZPkb7Q.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BiTCWZ38.js → yaml-D5IEE5M-.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
- package/src/client/dist/spa/assets/CreatePage-BQu7mQjm.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-CrRGDs5V.js +0 -2
- package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CMyeaz63.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-Bw9xhTDR.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-_1mty_a4.css +0 -1
- package/src/client/dist/spa/assets/i18n-C8aJvuyS.js +0 -1
- package/src/client/dist/spa/assets/index-DAbX631s.js +0 -2
- package/src/client/dist/spa/assets/kobo-commands-CD7ERFxp.js +0 -10
- 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 {
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
180
|
-
//
|
|
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
|
-
|
|
208
|
-
worktreePath =
|
|
255
|
+
if (useReusedWorktree) {
|
|
256
|
+
worktreePath = body.worktreePath;
|
|
209
257
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
467
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1199
|
-
const worktreePath =
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1436
|
-
|
|
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
|
-
|
|
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
|
|
1494
|
-
// matches the new ref, otherwise Kōbō's path resolver
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
const
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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);
|