@loicngr/kobo 1.1.1 → 1.3.0
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/dist/mcp-server/kobo-tasks-handlers.js +147 -0
- package/dist/mcp-server/kobo-tasks-server.js +236 -29
- package/dist/server/db/migrations.js +61 -10
- package/dist/server/db/schema.js +1 -0
- package/dist/server/index.js +10 -4
- package/dist/server/routes/images.js +57 -0
- package/dist/server/routes/workspaces.js +80 -19
- package/dist/server/services/agent-manager.js +91 -5
- package/dist/server/services/image-service.js +73 -0
- package/dist/server/services/pr-watcher-service.js +61 -0
- package/dist/server/services/settings-service.js +75 -10
- package/dist/server/services/workspace-service.js +13 -0
- package/dist/server/utils/git-ops.js +39 -0
- package/package.json +3 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CufaRX1M.js → ActivityFeed-Bie-lcn7.js} +7 -7
- package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-OC-fnNGP.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +1 -0
- package/src/client/dist/spa/assets/QBadge-DbE3eSf1.js +1 -0
- package/src/client/dist/spa/assets/QDialog-Cd_4PvgW.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-pMQDDRMv.js +1 -0
- package/src/client/dist/spa/assets/QPage-lhV4XbI2.js +1 -0
- package/src/client/dist/spa/assets/{QSpinnerDots-DcaNq8uL.js → QSpinnerDots-ByNZaBWw.js} +1 -1
- package/src/client/dist/spa/assets/QTooltip-6GSFtFKP.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-Dhkuuhf8.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-B6FaNy4R.js +1 -0
- package/src/client/dist/spa/assets/index-BoQWbZtE.js +5 -0
- package/src/client/dist/spa/assets/{nodes-DeIen-kp.js → nodes-CXdiSdC2.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-Z9pfihkw.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +1 -0
- package/src/client/dist/spa/index.html +2 -2
- package/src/mcp-server/README.md +179 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +238 -0
- package/src/mcp-server/kobo-tasks-server.ts +263 -29
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-Cyl-TRHT.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-D_vxGAPn.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-Dzy0I8lB.js +0 -1
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +0 -1
- package/src/client/dist/spa/assets/QDialog-CMC1Ph52.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-DDjku8zz.js +0 -1
- package/src/client/dist/spa/assets/QPage-DaNo_vcd.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-BKHAAJ2p.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-Blw7Qk7m.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DlnwomOE.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-HtatyhXN.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +0 -1
- package/src/client/dist/spa/assets/index-DJkEmbBM.js +0 -5
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +0 -1
|
@@ -107,7 +107,30 @@ app.post('/', async (c) => {
|
|
|
107
107
|
workspaceService.updateWorkspaceStatus(workspace.id, 'error');
|
|
108
108
|
return c.json({ error: `Failed to create worktree: ${message}` }, 500);
|
|
109
109
|
}
|
|
110
|
-
// 4b.
|
|
110
|
+
// 4b. Ensure Kobo-generated files inside .ai/ are gitignored (the .ai/ dir
|
|
111
|
+
// itself may contain project files that SHOULD be committed).
|
|
112
|
+
try {
|
|
113
|
+
const fs = await import('node:fs');
|
|
114
|
+
const pathMod = await import('node:path');
|
|
115
|
+
const gitignorePath = pathMod.default.join(worktreePath, '.gitignore');
|
|
116
|
+
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf-8') : '';
|
|
117
|
+
const lines = existing.split('\n').map((l) => l.trim());
|
|
118
|
+
const toAdd = [];
|
|
119
|
+
if (!lines.includes('.ai/git-conventions.md'))
|
|
120
|
+
toAdd.push('.ai/git-conventions.md');
|
|
121
|
+
if (!lines.includes('.ai/thoughts/'))
|
|
122
|
+
toAdd.push('.ai/thoughts/');
|
|
123
|
+
if (!lines.includes('.ai/images/'))
|
|
124
|
+
toAdd.push('.ai/images/');
|
|
125
|
+
if (toAdd.length > 0) {
|
|
126
|
+
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
127
|
+
fs.appendFileSync(gitignorePath, `${separator}${toAdd.join('\n')}\n`, 'utf-8');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
console.error('[workspaces] Failed to update .gitignore:', err);
|
|
132
|
+
}
|
|
133
|
+
// 4c. Write git conventions to the worktree if configured
|
|
111
134
|
const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
|
|
112
135
|
if (effectiveSettings.gitConventions) {
|
|
113
136
|
try {
|
|
@@ -363,6 +386,22 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
|
363
386
|
return c.json({ error: message }, 500);
|
|
364
387
|
}
|
|
365
388
|
});
|
|
389
|
+
// POST /api/workspaces/:id/tasks/notify-updated — broadcast generic task list change
|
|
390
|
+
app.post('/:id/tasks/notify-updated', (c) => {
|
|
391
|
+
try {
|
|
392
|
+
const id = c.req.param('id');
|
|
393
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
394
|
+
if (!workspace) {
|
|
395
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
396
|
+
}
|
|
397
|
+
wsService.emit(id, 'task:updated', {});
|
|
398
|
+
return new Response(null, { status: 204 });
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
402
|
+
return c.json({ error: message }, 500);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
366
405
|
// GET /api/workspaces/archived — list archived workspaces (must be before GET /:id)
|
|
367
406
|
app.get('/archived', (c) => {
|
|
368
407
|
try {
|
|
@@ -388,19 +427,32 @@ app.get('/:id', (c) => {
|
|
|
388
427
|
return c.json({ error: message }, 500);
|
|
389
428
|
}
|
|
390
429
|
});
|
|
391
|
-
// PATCH /api/workspaces/:id — update workspace status
|
|
430
|
+
// PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode)
|
|
392
431
|
app.patch('/:id', async (c) => {
|
|
393
432
|
try {
|
|
394
433
|
const id = c.req.param('id');
|
|
395
434
|
const body = await c.req.json();
|
|
396
|
-
if (!body.status) {
|
|
397
|
-
return c.json({ error: 'Missing required field: status' }, 400);
|
|
398
|
-
}
|
|
399
435
|
const workspace = workspaceService.getWorkspace(id);
|
|
400
436
|
if (!workspace) {
|
|
401
437
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
402
438
|
}
|
|
403
|
-
|
|
439
|
+
let updated = workspace;
|
|
440
|
+
if (body.model !== undefined) {
|
|
441
|
+
updated = workspaceService.updateWorkspaceModel(id, body.model);
|
|
442
|
+
}
|
|
443
|
+
if (body.permissionMode !== undefined) {
|
|
444
|
+
const validModes = ['auto-accept', 'plan'];
|
|
445
|
+
if (!validModes.includes(body.permissionMode)) {
|
|
446
|
+
return c.json({ error: `Invalid permission mode. Must be one of: ${validModes.join(', ')}` }, 400);
|
|
447
|
+
}
|
|
448
|
+
updated = workspaceService.updateWorkspacePermissionMode(id, body.permissionMode);
|
|
449
|
+
}
|
|
450
|
+
if (body.status) {
|
|
451
|
+
updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
452
|
+
}
|
|
453
|
+
if (!body.status && body.model === undefined && body.permissionMode === undefined) {
|
|
454
|
+
return c.json({ error: 'Missing field: status, model, or permissionMode' }, 400);
|
|
455
|
+
}
|
|
404
456
|
return c.json(updated);
|
|
405
457
|
}
|
|
406
458
|
catch (err) {
|
|
@@ -543,7 +595,7 @@ app.post('/:id/start', async (c) => {
|
|
|
543
595
|
// Agent may not be running — ignore
|
|
544
596
|
}
|
|
545
597
|
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
546
|
-
agentManager.startAgent(id, worktreePath, prompt, workspace.model);
|
|
598
|
+
agentManager.startAgent(id, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
|
|
547
599
|
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
548
600
|
return c.json({ status: 'started' });
|
|
549
601
|
}
|
|
@@ -564,11 +616,16 @@ app.get('/:id/git-stats', async (c) => {
|
|
|
564
616
|
const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
565
617
|
const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
566
618
|
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
|
|
619
|
+
const pr = gitOps.getPrStatus(workspace.projectPath, workspace.workingBranch);
|
|
620
|
+
const unpushedCount = gitOps.getUnpushedCount(worktreePath);
|
|
567
621
|
return c.json({
|
|
568
622
|
commitCount,
|
|
569
623
|
filesChanged: diffStats.filesChanged,
|
|
570
624
|
insertions: diffStats.insertions,
|
|
571
625
|
deletions: diffStats.deletions,
|
|
626
|
+
prUrl: pr?.url ?? null,
|
|
627
|
+
prState: pr?.state ?? null,
|
|
628
|
+
unpushedCount,
|
|
572
629
|
});
|
|
573
630
|
}
|
|
574
631
|
catch (err) {
|
|
@@ -701,22 +758,26 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
701
758
|
const session = workspaceService.getLatestSession(workspace.id);
|
|
702
759
|
const sessionId = session?.claudeSessionId ?? undefined;
|
|
703
760
|
emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
704
|
-
// 8. Send to the running agent
|
|
761
|
+
// 8. Send to the running agent, or resume the agent with the PR prompt
|
|
762
|
+
let messageSent = false;
|
|
705
763
|
try {
|
|
706
764
|
agentManager.sendMessage(workspace.id, rendered);
|
|
707
|
-
|
|
765
|
+
messageSent = true;
|
|
708
766
|
}
|
|
709
|
-
catch
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
767
|
+
catch {
|
|
768
|
+
// Agent not running — resume it with the PR prompt
|
|
769
|
+
try {
|
|
770
|
+
const worktreePathForResume = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
771
|
+
agentManager.startAgent(workspace.id, worktreePathForResume, rendered, workspace.model, true, workspace.permissionMode);
|
|
772
|
+
workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
|
|
773
|
+
messageSent = true;
|
|
774
|
+
}
|
|
775
|
+
catch (resumeErr) {
|
|
776
|
+
const resumeMsg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
|
|
777
|
+
console.warn(`[workspaces] open-pr: PR created but agent resume failed: ${resumeMsg}`);
|
|
778
|
+
}
|
|
719
779
|
}
|
|
780
|
+
return c.json({ ok: true, prNumber, prUrl, messageSent });
|
|
720
781
|
}
|
|
721
782
|
catch (err) {
|
|
722
783
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -4,11 +4,18 @@ import path from 'node:path';
|
|
|
4
4
|
import readline from 'node:readline';
|
|
5
5
|
import { nanoid } from 'nanoid';
|
|
6
6
|
import { getDb } from '../db/index.js';
|
|
7
|
-
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSkillsPath, } from '../utils/paths.js';
|
|
7
|
+
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../utils/paths.js';
|
|
8
8
|
import { registerProcess, unregisterProcess } from '../utils/process-tracker.js';
|
|
9
|
+
import { getEffectiveSettings } from './settings-service.js';
|
|
9
10
|
import { emit } from './websocket-service.js';
|
|
10
11
|
import { getWorkspace as getWs, listTasks, updateWorkspaceStatus } from './workspace-service.js';
|
|
11
12
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
/** Actual bound port of the running backend — set at startup via setBackendPort() */
|
|
14
|
+
let backendPort = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
|
15
|
+
/** Called from index.ts once the HTTP server is listening so MCP children can reach it. */
|
|
16
|
+
export function setBackendPort(port) {
|
|
17
|
+
backendPort = port;
|
|
18
|
+
}
|
|
12
19
|
/** workspaceId -> agent instance */
|
|
13
20
|
const agents = new Map();
|
|
14
21
|
/** workspaceId -> last Claude session ID (for --resume) */
|
|
@@ -29,16 +36,90 @@ const retryCounts = new Map();
|
|
|
29
36
|
const backoffTimers = new Map();
|
|
30
37
|
/** workspaceId -> pending SIGKILL timer */
|
|
31
38
|
const killTimers = new Map();
|
|
39
|
+
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
40
|
+
// Periodically checks that tracked agent processes are still alive.
|
|
41
|
+
// If a process died without triggering the 'exit' handler (crash, OOM kill,
|
|
42
|
+
// etc.), the watchdog cleans up and updates the workspace status.
|
|
43
|
+
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
44
|
+
let watchdogTimer = null;
|
|
45
|
+
function isProcessAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0); // signal 0 = existence check, doesn't actually kill
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function runWatchdog() {
|
|
55
|
+
for (const [workspaceId, agent] of agents) {
|
|
56
|
+
const pid = agent.process.pid;
|
|
57
|
+
if (pid === undefined || isProcessAlive(pid))
|
|
58
|
+
continue;
|
|
59
|
+
console.error(`[watchdog] Agent process for workspace '${workspaceId}' (PID ${pid}) is dead — cleaning up`);
|
|
60
|
+
// Close readline to release the stream
|
|
61
|
+
try {
|
|
62
|
+
agent.rl.close();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore
|
|
66
|
+
}
|
|
67
|
+
unregisterProcess(workspaceId);
|
|
68
|
+
agents.delete(workspaceId);
|
|
69
|
+
retryCounts.delete(workspaceId);
|
|
70
|
+
// Update DB session
|
|
71
|
+
try {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), agent.agentSessionId);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error('[watchdog] Failed to update agent_sessions:', err);
|
|
77
|
+
}
|
|
78
|
+
// Update workspace status
|
|
79
|
+
try {
|
|
80
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Transition may not be valid — ignore
|
|
84
|
+
}
|
|
85
|
+
emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.claudeSessionId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Start the watchdog (called once from server bootstrap). */
|
|
89
|
+
export function startWatchdog() {
|
|
90
|
+
if (watchdogTimer)
|
|
91
|
+
return;
|
|
92
|
+
watchdogTimer = setInterval(runWatchdog, WATCHDOG_INTERVAL_MS);
|
|
93
|
+
watchdogTimer.unref?.();
|
|
94
|
+
}
|
|
95
|
+
/** Stop the watchdog (for clean shutdown / tests). */
|
|
96
|
+
export function stopWatchdog() {
|
|
97
|
+
if (watchdogTimer) {
|
|
98
|
+
clearInterval(watchdogTimer);
|
|
99
|
+
watchdogTimer = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
32
102
|
// ── Start agent ────────────────────────────────────────────────────────────────
|
|
33
|
-
export function startAgent(workspaceId, workingDir, prompt, model, resume = false) {
|
|
103
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
|
|
34
104
|
// Check if agent already running for this workspace
|
|
35
105
|
if (agents.has(workspaceId)) {
|
|
36
106
|
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
37
107
|
}
|
|
38
108
|
const db = getDb();
|
|
39
109
|
let agentSessionId;
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
let resumedClaudeSessionId;
|
|
111
|
+
// Build CLI args — read dangerouslySkipPermissions from effective settings
|
|
112
|
+
const ws = getWs(workspaceId);
|
|
113
|
+
const effectiveSettings = ws ? getEffectiveSettings(ws.projectPath) : null;
|
|
114
|
+
const skipPermissions = effectiveSettings?.dangerouslySkipPermissions ?? true;
|
|
115
|
+
const args = ['--output-format', 'stream-json', '--verbose'];
|
|
116
|
+
if (skipPermissions) {
|
|
117
|
+
args.push('--dangerously-skip-permissions');
|
|
118
|
+
}
|
|
119
|
+
if (permissionMode === 'plan') {
|
|
120
|
+
// In plan mode, prepend read-only instructions to the prompt
|
|
121
|
+
prompt = `[PLAN MODE] You are in PLAN/READ-ONLY mode. You MUST NOT create, edit, write, or delete any files. Only use read-only tools (Read, Grep, Glob, LS, Bash for read-only commands). Analyze the codebase, plan your approach, and present your findings — but do NOT execute any changes.\n\n${prompt}`;
|
|
122
|
+
}
|
|
42
123
|
if (model && model !== 'auto') {
|
|
43
124
|
args.push('--model', model);
|
|
44
125
|
}
|
|
@@ -48,6 +129,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
48
129
|
.get(workspaceId);
|
|
49
130
|
const claudeSessionId = sessionIds.get(workspaceId) ?? lastSession?.claude_session_id;
|
|
50
131
|
if (claudeSessionId) {
|
|
132
|
+
resumedClaudeSessionId = claudeSessionId;
|
|
51
133
|
args.push('--resume', claudeSessionId, '-p', prompt);
|
|
52
134
|
// Always reuse existing session — find by claude_session_id if lastSession didn't match
|
|
53
135
|
const existingId = lastSession?.id ??
|
|
@@ -86,7 +168,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
86
168
|
env: {
|
|
87
169
|
KOBO_WORKSPACE_ID: workspaceId,
|
|
88
170
|
KOBO_DB_PATH: getDbPath(),
|
|
89
|
-
|
|
171
|
+
KOBO_SETTINGS_PATH: getSettingsPath(),
|
|
172
|
+
KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
|
|
90
173
|
},
|
|
91
174
|
},
|
|
92
175
|
},
|
|
@@ -117,6 +200,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
117
200
|
rl,
|
|
118
201
|
status: 'running',
|
|
119
202
|
agentSessionId,
|
|
203
|
+
claudeSessionId: resumedClaudeSessionId,
|
|
120
204
|
};
|
|
121
205
|
// ── stdout line-by-line (NDJSON) ──
|
|
122
206
|
rl.on('line', (line) => {
|
|
@@ -287,6 +371,8 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
287
371
|
});
|
|
288
372
|
// Store in agents map
|
|
289
373
|
agents.set(workspaceId, agent);
|
|
374
|
+
// Notify frontend that agent is now running
|
|
375
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.claudeSessionId);
|
|
290
376
|
return agent;
|
|
291
377
|
}
|
|
292
378
|
// ── Stop agent ─────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/server/services/image-service.ts
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { nanoid } from 'nanoid';
|
|
5
|
+
const ALLOWED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
|
|
6
|
+
const IMAGES_DIR = '.ai/images';
|
|
7
|
+
const INDEX_FILE = 'index.json';
|
|
8
|
+
// Per-worktree lock to serialize index.json writes
|
|
9
|
+
const locks = new Map();
|
|
10
|
+
function withLock(worktreePath, fn) {
|
|
11
|
+
const prev = locks.get(worktreePath) ?? Promise.resolve();
|
|
12
|
+
// The second argument to .then() means: even if the previous operation in the
|
|
13
|
+
// queue rejected, still run fn — one failure must not block the whole queue.
|
|
14
|
+
const next = prev.then(fn, fn);
|
|
15
|
+
locks.set(worktreePath, next.then(() => { }, () => { }));
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
function readIndex(imagesDir) {
|
|
19
|
+
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
20
|
+
if (!fs.existsSync(indexPath))
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error(`[image-service] Failed to parse ${indexPath}, treating as empty index:`, err);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeIndex(imagesDir, entries) {
|
|
31
|
+
fs.writeFileSync(path.join(imagesDir, INDEX_FILE), JSON.stringify(entries, null, 2));
|
|
32
|
+
}
|
|
33
|
+
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
34
|
+
const ext = path.extname(originalName).toLowerCase().replace('.', '');
|
|
35
|
+
if (!ext) {
|
|
36
|
+
throw new Error(`File has no extension. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
39
|
+
throw new Error(`Unsupported image extension: '${ext}'. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
const uid = nanoid(10);
|
|
42
|
+
const imagesDir = path.join(worktreePath, IMAGES_DIR);
|
|
43
|
+
// mkdirSync is idempotent — safe to call outside the lock
|
|
44
|
+
fs.mkdirSync(imagesDir, { recursive: true });
|
|
45
|
+
const filename = `${uid}.${ext}`;
|
|
46
|
+
await withLock(worktreePath, () => {
|
|
47
|
+
// Write the image file inside the lock so both the file write and index
|
|
48
|
+
// update happen atomically — avoids orphan files on crash between the two.
|
|
49
|
+
fs.writeFileSync(path.join(imagesDir, filename), fileBuffer);
|
|
50
|
+
const entries = readIndex(imagesDir);
|
|
51
|
+
entries.push({ uid, originalName, createdAt: new Date().toISOString() });
|
|
52
|
+
writeIndex(imagesDir, entries);
|
|
53
|
+
});
|
|
54
|
+
return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
|
|
55
|
+
}
|
|
56
|
+
export async function deleteImage(worktreePath, uid) {
|
|
57
|
+
const imagesDir = path.join(worktreePath, IMAGES_DIR);
|
|
58
|
+
await withLock(worktreePath, () => {
|
|
59
|
+
const entries = readIndex(imagesDir);
|
|
60
|
+
const idx = entries.findIndex((e) => e.uid === uid);
|
|
61
|
+
if (idx === -1) {
|
|
62
|
+
throw new Error(`Image '${uid}' not found in index`);
|
|
63
|
+
}
|
|
64
|
+
// Find the file on disk (we need the extension)
|
|
65
|
+
const files = fs.existsSync(imagesDir) ? fs.readdirSync(imagesDir) : [];
|
|
66
|
+
const imageFile = files.find((f) => f.startsWith(`${uid}.`));
|
|
67
|
+
if (imageFile) {
|
|
68
|
+
fs.unlinkSync(path.join(imagesDir, imageFile));
|
|
69
|
+
}
|
|
70
|
+
entries.splice(idx, 1);
|
|
71
|
+
writeIndex(imagesDir, entries);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getPrStatus } from '../utils/git-ops.js';
|
|
2
|
+
import { emitEphemeral } from './websocket-service.js';
|
|
3
|
+
import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
|
|
4
|
+
// ── PR Watcher ────────────────────────────────────────────────────────────────
|
|
5
|
+
// Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
|
|
6
|
+
// automatically archive the corresponding workspace.
|
|
7
|
+
//
|
|
8
|
+
// Only archives on a STATE TRANSITION from OPEN → CLOSED/MERGED.
|
|
9
|
+
// If a PR is already closed/merged when first seen (e.g. after unarchive),
|
|
10
|
+
// it is recorded but NOT acted upon — prevents re-archiving manually
|
|
11
|
+
// unarchived workspaces.
|
|
12
|
+
const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
13
|
+
let timer = null;
|
|
14
|
+
/** Tracks the last known PR state per workspace to detect transitions. */
|
|
15
|
+
const lastKnownState = new Map();
|
|
16
|
+
function checkPrStatuses() {
|
|
17
|
+
const workspaces = listWorkspaces(false); // non-archived only
|
|
18
|
+
// Clean up entries for workspaces that no longer exist
|
|
19
|
+
for (const id of lastKnownState.keys()) {
|
|
20
|
+
if (!workspaces.some((ws) => ws.id === id)) {
|
|
21
|
+
lastKnownState.delete(id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const ws of workspaces) {
|
|
25
|
+
// Only check workspaces that are not actively running an agent
|
|
26
|
+
if (['extracting', 'brainstorming', 'executing'].includes(ws.status))
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const pr = getPrStatus(ws.projectPath, ws.workingBranch);
|
|
30
|
+
if (!pr)
|
|
31
|
+
continue;
|
|
32
|
+
const prev = lastKnownState.get(ws.id);
|
|
33
|
+
lastKnownState.set(ws.id, pr.state);
|
|
34
|
+
// Only archive on a transition FROM OPEN — not on first sight of CLOSED/MERGED
|
|
35
|
+
if (prev === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
|
|
36
|
+
console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
|
|
37
|
+
archiveWorkspace(ws.id);
|
|
38
|
+
lastKnownState.delete(ws.id);
|
|
39
|
+
emitEphemeral(ws.id, 'workspace:archived', {
|
|
40
|
+
reason: `PR ${pr.state.toLowerCase()}`,
|
|
41
|
+
prUrl: pr.url,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function startPrWatcher() {
|
|
51
|
+
if (timer)
|
|
52
|
+
return;
|
|
53
|
+
timer = setInterval(checkPrStatuses, POLL_INTERVAL_MS);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
}
|
|
56
|
+
export function stopPrWatcher() {
|
|
57
|
+
if (timer) {
|
|
58
|
+
clearInterval(timer);
|
|
59
|
+
timer = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -50,7 +50,36 @@ Please:
|
|
|
50
50
|
1. Review the PR description on GitHub and improve it if needed (add a proper summary, screenshots if relevant, a test plan)
|
|
51
51
|
2. Verify that all acceptance criteria are checked
|
|
52
52
|
3. Post a comment on the PR summarizing what was done and any follow-up items
|
|
53
|
+
4. Do NOT add a "Generated with Claude Code" footer or any AI attribution to the PR description
|
|
53
54
|
`;
|
|
55
|
+
const settingsMigrations = [
|
|
56
|
+
{
|
|
57
|
+
version: 1,
|
|
58
|
+
name: 'add-git-conventions',
|
|
59
|
+
migrate: ({ global, projects }) => {
|
|
60
|
+
if (typeof global.gitConventions !== 'string')
|
|
61
|
+
global.gitConventions = '';
|
|
62
|
+
for (const p of projects) {
|
|
63
|
+
if (typeof p.gitConventions !== 'string')
|
|
64
|
+
p.gitConventions = '';
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
version: 2,
|
|
70
|
+
name: 'add-dangerously-skip-permissions',
|
|
71
|
+
migrate: ({ global, projects }) => {
|
|
72
|
+
if (typeof global.dangerouslySkipPermissions !== 'boolean')
|
|
73
|
+
global.dangerouslySkipPermissions = true;
|
|
74
|
+
for (const p of projects) {
|
|
75
|
+
if (typeof p.dangerouslySkipPermissions !== 'boolean')
|
|
76
|
+
p.dangerouslySkipPermissions = true;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
/** Current settings schema version — always equals the highest migration version. */
|
|
82
|
+
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
54
83
|
let settingsFilePath = getSettingsPath();
|
|
55
84
|
/** Override the settings file path (used by tests). */
|
|
56
85
|
export function _setSettingsPath(p) {
|
|
@@ -58,8 +87,10 @@ export function _setSettingsPath(p) {
|
|
|
58
87
|
}
|
|
59
88
|
function defaultSettings() {
|
|
60
89
|
return {
|
|
90
|
+
schemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
61
91
|
global: {
|
|
62
92
|
defaultModel: 'auto',
|
|
93
|
+
dangerouslySkipPermissions: true,
|
|
63
94
|
prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
|
|
64
95
|
gitConventions: DEFAULT_GIT_CONVENTIONS,
|
|
65
96
|
},
|
|
@@ -72,6 +103,7 @@ function defaultProjectSettings(projectPath) {
|
|
|
72
103
|
displayName: '',
|
|
73
104
|
defaultSourceBranch: '',
|
|
74
105
|
defaultModel: '',
|
|
106
|
+
dangerouslySkipPermissions: true,
|
|
75
107
|
prPromptTemplate: '',
|
|
76
108
|
gitConventions: '',
|
|
77
109
|
devServer: {
|
|
@@ -83,6 +115,29 @@ function defaultProjectSettings(projectPath) {
|
|
|
83
115
|
function pickKnownKeys(data, allowedKeys) {
|
|
84
116
|
return Object.fromEntries(Object.entries(data).filter(([key]) => allowedKeys.includes(key)));
|
|
85
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Apply migrations sequentially to bring an older settings object up to
|
|
120
|
+
* SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
|
|
121
|
+
* The returned object carries the bumped schemaVersion; callers persist it.
|
|
122
|
+
*/
|
|
123
|
+
export function runSettingsMigrations(raw) {
|
|
124
|
+
const current = raw;
|
|
125
|
+
if (!current.global || typeof current.global !== 'object') {
|
|
126
|
+
current.global = {};
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(current.projects)) {
|
|
129
|
+
current.projects = [];
|
|
130
|
+
}
|
|
131
|
+
let version = typeof current.schemaVersion === 'number' ? current.schemaVersion : 0;
|
|
132
|
+
for (const m of settingsMigrations) {
|
|
133
|
+
if (version < m.version) {
|
|
134
|
+
m.migrate({ global: current.global, projects: current.projects });
|
|
135
|
+
version = m.version;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
current.schemaVersion = version;
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
86
141
|
function readSettings() {
|
|
87
142
|
if (!fs.existsSync(settingsFilePath)) {
|
|
88
143
|
const defaults = defaultSettings();
|
|
@@ -90,21 +145,28 @@ function readSettings() {
|
|
|
90
145
|
return defaults;
|
|
91
146
|
}
|
|
92
147
|
const raw = fs.readFileSync(settingsFilePath, 'utf-8');
|
|
93
|
-
|
|
94
|
-
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
95
153
|
const defaults = defaultSettings();
|
|
96
154
|
writeSettings(defaults);
|
|
97
155
|
return defaults;
|
|
98
156
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
157
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
158
|
+
const defaults = defaultSettings();
|
|
159
|
+
writeSettings(defaults);
|
|
160
|
+
return defaults;
|
|
102
161
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
162
|
+
const originalVersion = parsed.schemaVersion;
|
|
163
|
+
const migrated = runSettingsMigrations(parsed);
|
|
164
|
+
// If migrations bumped the version, persist the upgraded settings so the
|
|
165
|
+
// next process doesn't re-run them on every load.
|
|
166
|
+
if (migrated.schemaVersion !== originalVersion) {
|
|
167
|
+
writeSettings(migrated);
|
|
106
168
|
}
|
|
107
|
-
return
|
|
169
|
+
return migrated;
|
|
108
170
|
}
|
|
109
171
|
function writeSettings(settings) {
|
|
110
172
|
const tmpPath = `${settingsFilePath}.tmp`;
|
|
@@ -131,6 +193,7 @@ export function getEffectiveSettings(projectPath) {
|
|
|
131
193
|
if (!project) {
|
|
132
194
|
return {
|
|
133
195
|
model: settings.global.defaultModel,
|
|
196
|
+
dangerouslySkipPermissions: settings.global.dangerouslySkipPermissions,
|
|
134
197
|
prPromptTemplate: settings.global.prPromptTemplate,
|
|
135
198
|
gitConventions: settings.global.gitConventions,
|
|
136
199
|
sourceBranch: '',
|
|
@@ -139,6 +202,7 @@ export function getEffectiveSettings(projectPath) {
|
|
|
139
202
|
}
|
|
140
203
|
return {
|
|
141
204
|
model: project.defaultModel || settings.global.defaultModel,
|
|
205
|
+
dangerouslySkipPermissions: project.dangerouslySkipPermissions ?? settings.global.dangerouslySkipPermissions,
|
|
142
206
|
prPromptTemplate: project.prPromptTemplate || settings.global.prPromptTemplate,
|
|
143
207
|
gitConventions: project.gitConventions || settings.global.gitConventions,
|
|
144
208
|
sourceBranch: project.defaultSourceBranch,
|
|
@@ -147,7 +211,7 @@ export function getEffectiveSettings(projectPath) {
|
|
|
147
211
|
}
|
|
148
212
|
export function updateGlobalSettings(data) {
|
|
149
213
|
const settings = readSettings();
|
|
150
|
-
const allowedGlobalKeys = ['defaultModel', 'prPromptTemplate', 'gitConventions'];
|
|
214
|
+
const allowedGlobalKeys = ['defaultModel', 'dangerouslySkipPermissions', 'prPromptTemplate', 'gitConventions'];
|
|
151
215
|
const filtered = pickKnownKeys(data, allowedGlobalKeys);
|
|
152
216
|
settings.global = { ...settings.global, ...filtered };
|
|
153
217
|
writeSettings(settings);
|
|
@@ -158,6 +222,7 @@ export function upsertProject(projectPath, data) {
|
|
|
158
222
|
'displayName',
|
|
159
223
|
'defaultSourceBranch',
|
|
160
224
|
'defaultModel',
|
|
225
|
+
'dangerouslySkipPermissions',
|
|
161
226
|
'prPromptTemplate',
|
|
162
227
|
'gitConventions',
|
|
163
228
|
'devServer',
|
|
@@ -22,6 +22,7 @@ function mapWorkspace(row) {
|
|
|
22
22
|
notionUrl: row.notion_url,
|
|
23
23
|
notionPageId: row.notion_page_id,
|
|
24
24
|
model: row.model,
|
|
25
|
+
permissionMode: (row.permission_mode ?? 'auto-accept'),
|
|
25
26
|
devServerStatus: row.dev_server_status,
|
|
26
27
|
archivedAt: row.archived_at,
|
|
27
28
|
createdAt: row.created_at,
|
|
@@ -90,6 +91,18 @@ export function updateWorkspaceName(id, name) {
|
|
|
90
91
|
db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(name, now, id);
|
|
91
92
|
return getWorkspace(id);
|
|
92
93
|
}
|
|
94
|
+
export function updateWorkspaceModel(id, model) {
|
|
95
|
+
const db = getDb();
|
|
96
|
+
const now = new Date().toISOString();
|
|
97
|
+
db.prepare('UPDATE workspaces SET model = ?, updated_at = ? WHERE id = ?').run(model, now, id);
|
|
98
|
+
return getWorkspace(id);
|
|
99
|
+
}
|
|
100
|
+
export function updateWorkspacePermissionMode(id, permissionMode) {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
db.prepare('UPDATE workspaces SET permission_mode = ?, updated_at = ? WHERE id = ?').run(permissionMode, now, id);
|
|
104
|
+
return getWorkspace(id);
|
|
105
|
+
}
|
|
93
106
|
export function updateDevServerStatus(id, status) {
|
|
94
107
|
const db = getDb();
|
|
95
108
|
db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
|
|
@@ -125,6 +125,45 @@ export function getCommitsBetween(repoPath, base, head) {
|
|
|
125
125
|
return '';
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
export function getPrUrl(repoPath, branchName) {
|
|
129
|
+
try {
|
|
130
|
+
return (execFileSync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
|
|
131
|
+
cwd: repoPath,
|
|
132
|
+
encoding: 'utf-8',
|
|
133
|
+
}).trim() || null);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function getPrStatus(repoPath, branchName) {
|
|
140
|
+
try {
|
|
141
|
+
const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
|
|
142
|
+
cwd: repoPath,
|
|
143
|
+
encoding: 'utf-8',
|
|
144
|
+
}).trim();
|
|
145
|
+
if (!raw)
|
|
146
|
+
return null;
|
|
147
|
+
const parsed = JSON.parse(raw);
|
|
148
|
+
return { state: parsed.state, url: parsed.url };
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Count commits ahead of upstream. Returns -1 if no upstream is set. */
|
|
155
|
+
export function getUnpushedCount(repoPath) {
|
|
156
|
+
try {
|
|
157
|
+
const output = execFileSync('git', ['rev-list', '@{u}..HEAD', '--count'], {
|
|
158
|
+
cwd: repoPath,
|
|
159
|
+
encoding: 'utf-8',
|
|
160
|
+
}).trim();
|
|
161
|
+
return parseInt(output, 10) || 0;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return -1; // no upstream
|
|
165
|
+
}
|
|
166
|
+
}
|
|
128
167
|
export function getDiffStatsBetween(repoPath, base, head) {
|
|
129
168
|
try {
|
|
130
169
|
return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
|