@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.
Files changed (55) hide show
  1. package/dist/mcp-server/kobo-tasks-handlers.js +147 -0
  2. package/dist/mcp-server/kobo-tasks-server.js +236 -29
  3. package/dist/server/db/migrations.js +61 -10
  4. package/dist/server/db/schema.js +1 -0
  5. package/dist/server/index.js +10 -4
  6. package/dist/server/routes/images.js +57 -0
  7. package/dist/server/routes/workspaces.js +80 -19
  8. package/dist/server/services/agent-manager.js +91 -5
  9. package/dist/server/services/image-service.js +73 -0
  10. package/dist/server/services/pr-watcher-service.js +61 -0
  11. package/dist/server/services/settings-service.js +75 -10
  12. package/dist/server/services/workspace-service.js +13 -0
  13. package/dist/server/utils/git-ops.js +39 -0
  14. package/package.json +3 -1
  15. package/src/client/dist/spa/assets/{ActivityFeed-CufaRX1M.js → ActivityFeed-Bie-lcn7.js} +7 -7
  16. package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +1 -0
  17. package/src/client/dist/spa/assets/CreatePage-OC-fnNGP.js +2 -0
  18. package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +1 -0
  19. package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +1 -0
  20. package/src/client/dist/spa/assets/QBadge-DbE3eSf1.js +1 -0
  21. package/src/client/dist/spa/assets/QDialog-Cd_4PvgW.js +1 -0
  22. package/src/client/dist/spa/assets/QExpansionItem-pMQDDRMv.js +1 -0
  23. package/src/client/dist/spa/assets/QPage-lhV4XbI2.js +1 -0
  24. package/src/client/dist/spa/assets/{QSpinnerDots-DcaNq8uL.js → QSpinnerDots-ByNZaBWw.js} +1 -1
  25. package/src/client/dist/spa/assets/QTooltip-6GSFtFKP.js +1 -0
  26. package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +1 -0
  27. package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +1 -0
  28. package/src/client/dist/spa/assets/WorkspacePage-Dhkuuhf8.css +1 -0
  29. package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +2 -0
  30. package/src/client/dist/spa/assets/_plugin-vue_export-helper-B6FaNy4R.js +1 -0
  31. package/src/client/dist/spa/assets/index-BoQWbZtE.js +5 -0
  32. package/src/client/dist/spa/assets/{nodes-DeIen-kp.js → nodes-CXdiSdC2.js} +1 -1
  33. package/src/client/dist/spa/assets/use-checkbox-Z9pfihkw.js +1 -0
  34. package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +1 -0
  35. package/src/client/dist/spa/index.html +2 -2
  36. package/src/mcp-server/README.md +179 -0
  37. package/src/mcp-server/kobo-tasks-handlers.ts +238 -0
  38. package/src/mcp-server/kobo-tasks-server.ts +263 -29
  39. package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +0 -1
  40. package/src/client/dist/spa/assets/CreatePage-Cyl-TRHT.js +0 -2
  41. package/src/client/dist/spa/assets/MainLayout-D_vxGAPn.css +0 -1
  42. package/src/client/dist/spa/assets/MainLayout-Dzy0I8lB.js +0 -1
  43. package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +0 -1
  44. package/src/client/dist/spa/assets/QDialog-CMC1Ph52.js +0 -1
  45. package/src/client/dist/spa/assets/QExpansionItem-DDjku8zz.js +0 -1
  46. package/src/client/dist/spa/assets/QPage-DaNo_vcd.js +0 -1
  47. package/src/client/dist/spa/assets/QTabPanels-BKHAAJ2p.js +0 -1
  48. package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +0 -1
  49. package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +0 -1
  50. package/src/client/dist/spa/assets/SettingsPage-Blw7Qk7m.js +0 -1
  51. package/src/client/dist/spa/assets/WorkspacePage-DlnwomOE.js +0 -2
  52. package/src/client/dist/spa/assets/WorkspacePage-HtatyhXN.css +0 -1
  53. package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +0 -1
  54. package/src/client/dist/spa/assets/index-DJkEmbBM.js +0 -5
  55. 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. Write git conventions to the worktree if configured
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
- const updated = workspaceService.updateWorkspaceStatus(id, body.status);
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 (degrade on failure)
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
- return c.json({ ok: true, prNumber, prUrl, messageSent: true });
765
+ messageSent = true;
708
766
  }
709
- catch (err) {
710
- const message = err instanceof Error ? err.message : String(err);
711
- console.warn(`[workspaces] open-pr: PR created but sendMessage failed: ${message}`);
712
- return c.json({
713
- ok: true,
714
- prNumber,
715
- prUrl,
716
- messageSent: false,
717
- warning: `Agent is not active — message was not sent (${message})`,
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
- // Build CLI args
41
- const args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
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
- KOBO_BACKEND_URL: `http://localhost:${process.env.PORT ?? '3000'}`,
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
- const parsed = JSON.parse(raw);
94
- if (!parsed || typeof parsed.global !== 'object' || !Array.isArray(parsed.projects)) {
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
- // Backfill missing fields on load (forward compat for old settings.json)
100
- if (typeof parsed.global.gitConventions !== 'string') {
101
- parsed.global.gitConventions = '';
157
+ if (!parsed || typeof parsed !== 'object') {
158
+ const defaults = defaultSettings();
159
+ writeSettings(defaults);
160
+ return defaults;
102
161
  }
103
- for (const p of parsed.projects) {
104
- if (typeof p.gitConventions !== 'string')
105
- p.gitConventions = '';
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 parsed;
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}`]);