@loicngr/kobo 1.2.0 → 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 (38) hide show
  1. package/dist/server/db/migrations.js +61 -10
  2. package/dist/server/db/schema.js +1 -0
  3. package/dist/server/index.js +7 -4
  4. package/dist/server/routes/workspaces.js +56 -19
  5. package/dist/server/services/agent-manager.js +77 -3
  6. package/dist/server/services/pr-watcher-service.js +61 -0
  7. package/dist/server/services/settings-service.js +41 -22
  8. package/dist/server/services/workspace-service.js +7 -0
  9. package/dist/server/utils/git-ops.js +28 -0
  10. package/package.json +1 -1
  11. package/src/client/dist/spa/assets/{ActivityFeed-CPfYmybV.js → ActivityFeed-Bie-lcn7.js} +9 -9
  12. package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +1 -0
  13. package/src/client/dist/spa/assets/{CreatePage-C_c3Gr0F.js → CreatePage-OC-fnNGP.js} +1 -1
  14. package/src/client/dist/spa/assets/MainLayout-91cUoVYa.css +1 -0
  15. package/src/client/dist/spa/assets/MainLayout-BIQNJixM.js +1 -0
  16. package/src/client/dist/spa/assets/{QBadge-CNojh9Rl.js → QBadge-DbE3eSf1.js} +1 -1
  17. package/src/client/dist/spa/assets/{QDialog-DgR7t6Vf.js → QDialog-Cd_4PvgW.js} +1 -1
  18. package/src/client/dist/spa/assets/{QExpansionItem-VVjlYOIT.js → QExpansionItem-pMQDDRMv.js} +1 -1
  19. package/src/client/dist/spa/assets/{QPage-DX4g-Dpe.js → QPage-lhV4XbI2.js} +1 -1
  20. package/src/client/dist/spa/assets/{QSpinnerDots-DeCf9Lr-.js → QSpinnerDots-ByNZaBWw.js} +1 -1
  21. package/src/client/dist/spa/assets/{QTooltip-DKYJ8kVW.js → QTooltip-6GSFtFKP.js} +1 -1
  22. package/src/client/dist/spa/assets/SettingsPage-BPH70mno.css +1 -0
  23. package/src/client/dist/spa/assets/SettingsPage-s2WJBreM.js +1 -0
  24. package/src/client/dist/spa/assets/{WorkspacePage-DkM58caD.css → WorkspacePage-Dhkuuhf8.css} +1 -1
  25. package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +2 -0
  26. package/src/client/dist/spa/assets/{_plugin-vue_export-helper-C6NdfBK4.js → _plugin-vue_export-helper-B6FaNy4R.js} +1 -1
  27. package/src/client/dist/spa/assets/{index-C4WDJfjD.js → index-BoQWbZtE.js} +4 -4
  28. package/src/client/dist/spa/assets/{nodes-irfhA8FK.js → nodes-CXdiSdC2.js} +1 -1
  29. package/src/client/dist/spa/assets/{use-checkbox-BS9cbwg_.js → use-checkbox-Z9pfihkw.js} +1 -1
  30. package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +1 -0
  31. package/src/client/dist/spa/index.html +2 -2
  32. package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +0 -1
  33. package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +0 -1
  34. package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +0 -1
  35. package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +0 -1
  36. package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +0 -1
  37. package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +0 -2
  38. package/src/client/dist/spa/assets/use-quasar-CH0pSHUf.js +0 -1
@@ -1,20 +1,71 @@
1
1
  import { initSchema } from './schema.js';
2
- export const SCHEMA_VERSION = 1;
2
+ export const migrations = [
3
+ {
4
+ version: 2,
5
+ name: 'add-permission-mode',
6
+ migrate: (db) => {
7
+ db.exec("ALTER TABLE workspaces ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'auto-accept'");
8
+ },
9
+ },
10
+ ];
11
+ /** Current schema version — always equals the highest migration version. */
12
+ export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
3
13
  export function runMigrations(db) {
14
+ // Create the history table (replaces the old single-row schema_version table).
4
15
  db.exec(`
5
- CREATE TABLE IF NOT EXISTS schema_version (
6
- version INTEGER NOT NULL
16
+ CREATE TABLE IF NOT EXISTS schema_migrations (
17
+ version INTEGER PRIMARY KEY,
18
+ name TEXT NOT NULL,
19
+ applied_at TEXT NOT NULL
7
20
  )
8
21
  `);
9
- const row = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
10
- const currentVersion = row?.version ?? 0;
11
- if (currentVersion < 1) {
22
+ // ── Backward compat: migrate from legacy schema_version table ──────────────
23
+ const hasLegacy = db.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='schema_version'").get().c > 0;
24
+ if (hasLegacy) {
25
+ const legacyRow = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
26
+ const legacyVersion = legacyRow?.version ?? 0;
27
+ // Back-fill history for all migrations that were already applied under the old system.
28
+ if (legacyVersion >= 1) {
29
+ const now = new Date().toISOString();
30
+ // Version 1 = initSchema (always applied if legacyVersion >= 1)
31
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
32
+ for (const m of migrations) {
33
+ if (m.version <= legacyVersion) {
34
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
35
+ }
36
+ }
37
+ }
38
+ db.exec('DROP TABLE schema_version');
39
+ }
40
+ // ── Determine current state ────────────────────────────────────────────────
41
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
42
+ // Fresh install — no migrations applied yet.
43
+ if (!applied.has(1)) {
12
44
  initSchema(db);
13
- if (currentVersion === 0) {
14
- db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(1);
45
+ const now = new Date().toISOString();
46
+ db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
47
+ // Mark all incremental migrations as applied (initSchema creates the latest shape).
48
+ for (const m of migrations) {
49
+ db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
15
50
  }
16
- else {
17
- db.prepare('UPDATE schema_version SET version = ?').run(1);
51
+ return;
52
+ }
53
+ // Apply pending migrations sequentially.
54
+ for (const m of migrations) {
55
+ if (!applied.has(m.version)) {
56
+ m.migrate(db);
57
+ db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
18
58
  }
19
59
  }
20
60
  }
61
+ /** Return the full migration history (for diagnostics / admin UI). */
62
+ export function getMigrationHistory(db) {
63
+ try {
64
+ return db
65
+ .prepare('SELECT version, name, applied_at FROM schema_migrations ORDER BY version')
66
+ .all();
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ }
@@ -10,6 +10,7 @@ export function initSchema(db) {
10
10
  notion_url TEXT,
11
11
  notion_page_id TEXT,
12
12
  model TEXT NOT NULL DEFAULT 'claude-opus-4-6',
13
+ permission_mode TEXT NOT NULL DEFAULT 'auto-accept',
13
14
  dev_server_status TEXT NOT NULL DEFAULT 'stopped',
14
15
  archived_at TEXT,
15
16
  created_at TEXT NOT NULL,
@@ -13,7 +13,7 @@ import imagesRouter from './routes/images.js';
13
13
  import notionRouter from './routes/notion.js';
14
14
  import settingsRouter from './routes/settings.js';
15
15
  import workspacesRouter from './routes/workspaces.js';
16
- import { getAvailableSkills, sendMessage, setBackendPort, startAgent, stopAgent } from './services/agent-manager.js';
16
+ import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, } from './services/agent-manager.js';
17
17
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
18
18
  import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
19
19
  import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
@@ -32,8 +32,11 @@ console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
32
32
  // 1. Initialize DB + run migrations
33
33
  const db = getDb();
34
34
  runMigrations(db);
35
- // 2. Initialize process cleanup
35
+ // 2. Initialize process cleanup, agent watchdog, and PR watcher
36
36
  initProcessCleanup();
37
+ startWatchdog();
38
+ import { startPrWatcher } from './services/pr-watcher-service.js';
39
+ startPrWatcher();
37
40
  // 3. Create Hono app
38
41
  const app = new Hono();
39
42
  // Health check (root / is handled by the SPA catch-all below)
@@ -118,7 +121,7 @@ setMessageHandler((type, payload) => {
118
121
  const workspace = getWorkspace(p.workspaceId);
119
122
  if (workspace) {
120
123
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
121
- startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true);
124
+ startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, workspace.permissionMode);
122
125
  updateWorkspaceStatus(p.workspaceId, 'executing');
123
126
  }
124
127
  }
@@ -136,7 +139,7 @@ setMessageHandler((type, payload) => {
136
139
  }
137
140
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
138
141
  const prompt = p.prompt ?? 'Continue the previous task where you left off.';
139
- startAgent(p.workspaceId, worktreePath, prompt, workspace.model);
142
+ startAgent(p.workspaceId, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
140
143
  }
141
144
  catch (err) {
142
145
  console.error('[ws] Failed to start agent:', err);
@@ -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 {
@@ -404,7 +427,7 @@ app.get('/:id', (c) => {
404
427
  return c.json({ error: message }, 500);
405
428
  }
406
429
  });
407
- // PATCH /api/workspaces/:id — update workspace fields (status, model)
430
+ // PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode)
408
431
  app.patch('/:id', async (c) => {
409
432
  try {
410
433
  const id = c.req.param('id');
@@ -417,11 +440,18 @@ app.patch('/:id', async (c) => {
417
440
  if (body.model !== undefined) {
418
441
  updated = workspaceService.updateWorkspaceModel(id, body.model);
419
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
+ }
420
450
  if (body.status) {
421
451
  updated = workspaceService.updateWorkspaceStatus(id, body.status);
422
452
  }
423
- if (!body.status && body.model === undefined) {
424
- return c.json({ error: 'Missing field: status or model' }, 400);
453
+ if (!body.status && body.model === undefined && body.permissionMode === undefined) {
454
+ return c.json({ error: 'Missing field: status, model, or permissionMode' }, 400);
425
455
  }
426
456
  return c.json(updated);
427
457
  }
@@ -565,7 +595,7 @@ app.post('/:id/start', async (c) => {
565
595
  // Agent may not be running — ignore
566
596
  }
567
597
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
568
- agentManager.startAgent(id, worktreePath, prompt, workspace.model);
598
+ agentManager.startAgent(id, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
569
599
  workspaceService.updateWorkspaceStatus(id, 'executing');
570
600
  return c.json({ status: 'started' });
571
601
  }
@@ -586,13 +616,16 @@ app.get('/:id/git-stats', async (c) => {
586
616
  const worktreePath = path.default.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
587
617
  const commitCount = gitOps.getCommitCount(worktreePath, workspace.sourceBranch, workspace.workingBranch);
588
618
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
589
- const prUrl = gitOps.getPrUrl(workspace.projectPath, workspace.workingBranch);
619
+ const pr = gitOps.getPrStatus(workspace.projectPath, workspace.workingBranch);
620
+ const unpushedCount = gitOps.getUnpushedCount(worktreePath);
590
621
  return c.json({
591
622
  commitCount,
592
623
  filesChanged: diffStats.filesChanged,
593
624
  insertions: diffStats.insertions,
594
625
  deletions: diffStats.deletions,
595
- prUrl,
626
+ prUrl: pr?.url ?? null,
627
+ prState: pr?.state ?? null,
628
+ unpushedCount,
596
629
  });
597
630
  }
598
631
  catch (err) {
@@ -725,22 +758,26 @@ app.post('/:id/open-pr', async (c) => {
725
758
  const session = workspaceService.getLatestSession(workspace.id);
726
759
  const sessionId = session?.claudeSessionId ?? undefined;
727
760
  emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
728
- // 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;
729
763
  try {
730
764
  agentManager.sendMessage(workspace.id, rendered);
731
- return c.json({ ok: true, prNumber, prUrl, messageSent: true });
765
+ messageSent = true;
732
766
  }
733
- catch (err) {
734
- const message = err instanceof Error ? err.message : String(err);
735
- console.warn(`[workspaces] open-pr: PR created but sendMessage failed: ${message}`);
736
- return c.json({
737
- ok: true,
738
- prNumber,
739
- prUrl,
740
- messageSent: false,
741
- warning: `Agent is not active — message was not sent (${message})`,
742
- });
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
+ }
743
779
  }
780
+ return c.json({ ok: true, prNumber, prUrl, messageSent });
744
781
  }
745
782
  catch (err) {
746
783
  const message = err instanceof Error ? err.message : String(err);
@@ -6,6 +6,7 @@ import { nanoid } from 'nanoid';
6
6
  import { getDb } from '../db/index.js';
7
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 ──────────────────────────────────────────────────────────────────────
@@ -35,8 +36,71 @@ const retryCounts = new Map();
35
36
  const backoffTimers = new Map();
36
37
  /** workspaceId -> pending SIGKILL timer */
37
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
+ }
38
102
  // ── Start agent ────────────────────────────────────────────────────────────────
39
- export function startAgent(workspaceId, workingDir, prompt, model, resume = false) {
103
+ export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
40
104
  // Check if agent already running for this workspace
41
105
  if (agents.has(workspaceId)) {
42
106
  throw new Error(`Agent already running for workspace '${workspaceId}'`);
@@ -44,8 +108,18 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
44
108
  const db = getDb();
45
109
  let agentSessionId;
46
110
  let resumedClaudeSessionId;
47
- // Build CLI args
48
- const args = ['--dangerously-skip-permissions', '--output-format', 'stream-json', '--verbose'];
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
+ }
49
123
  if (model && model !== 'auto') {
50
124
  args.push('--model', model);
51
125
  }
@@ -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,13 +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
  `;
54
- /**
55
- * Bump when adding/removing/renaming fields in Settings that require a migration.
56
- * Each bump must come with a corresponding entry in `runSettingsMigrations()`.
57
- * Append-only — never renumber shipped versions.
58
- */
59
- export const SETTINGS_SCHEMA_VERSION = 1;
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;
60
83
  let settingsFilePath = getSettingsPath();
61
84
  /** Override the settings file path (used by tests). */
62
85
  export function _setSettingsPath(p) {
@@ -67,6 +90,7 @@ function defaultSettings() {
67
90
  schemaVersion: SETTINGS_SCHEMA_VERSION,
68
91
  global: {
69
92
  defaultModel: 'auto',
93
+ dangerouslySkipPermissions: true,
70
94
  prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
71
95
  gitConventions: DEFAULT_GIT_CONVENTIONS,
72
96
  },
@@ -79,6 +103,7 @@ function defaultProjectSettings(projectPath) {
79
103
  displayName: '',
80
104
  defaultSourceBranch: '',
81
105
  defaultModel: '',
106
+ dangerouslySkipPermissions: true,
82
107
  prPromptTemplate: '',
83
108
  gitConventions: '',
84
109
  devServer: {
@@ -92,12 +117,10 @@ function pickKnownKeys(data, allowedKeys) {
92
117
  }
93
118
  /**
94
119
  * Apply migrations sequentially to bring an older settings object up to
95
- * SETTINGS_SCHEMA_VERSION. Each migration is append-only — never edit or
96
- * reorder shipped migrations. The returned object carries the bumped
97
- * schemaVersion; callers should persist it back to disk.
120
+ * SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
121
+ * The returned object carries the bumped schemaVersion; callers persist it.
98
122
  */
99
123
  export function runSettingsMigrations(raw) {
100
- // Ensure a baseline shape so we can safely read .global and .projects
101
124
  const current = raw;
102
125
  if (!current.global || typeof current.global !== 'object') {
103
126
  current.global = {};
@@ -105,20 +128,13 @@ export function runSettingsMigrations(raw) {
105
128
  if (!Array.isArray(current.projects)) {
106
129
  current.projects = [];
107
130
  }
108
- // Detect legacy (pre-versioned) settings as v0
109
131
  let version = typeof current.schemaVersion === 'number' ? current.schemaVersion : 0;
110
- // ── v0 v1: ensure gitConventions field exists on global and every project
111
- if (version < 1) {
112
- if (typeof current.global.gitConventions !== 'string') {
113
- current.global.gitConventions = '';
114
- }
115
- for (const p of current.projects) {
116
- if (typeof p.gitConventions !== 'string')
117
- p.gitConventions = '';
132
+ for (const m of settingsMigrations) {
133
+ if (version < m.version) {
134
+ m.migrate({ global: current.global, projects: current.projects });
135
+ version = m.version;
118
136
  }
119
- version = 1;
120
137
  }
121
- // Future migrations go here — increment SETTINGS_SCHEMA_VERSION in lockstep.
122
138
  current.schemaVersion = version;
123
139
  return current;
124
140
  }
@@ -177,6 +193,7 @@ export function getEffectiveSettings(projectPath) {
177
193
  if (!project) {
178
194
  return {
179
195
  model: settings.global.defaultModel,
196
+ dangerouslySkipPermissions: settings.global.dangerouslySkipPermissions,
180
197
  prPromptTemplate: settings.global.prPromptTemplate,
181
198
  gitConventions: settings.global.gitConventions,
182
199
  sourceBranch: '',
@@ -185,6 +202,7 @@ export function getEffectiveSettings(projectPath) {
185
202
  }
186
203
  return {
187
204
  model: project.defaultModel || settings.global.defaultModel,
205
+ dangerouslySkipPermissions: project.dangerouslySkipPermissions ?? settings.global.dangerouslySkipPermissions,
188
206
  prPromptTemplate: project.prPromptTemplate || settings.global.prPromptTemplate,
189
207
  gitConventions: project.gitConventions || settings.global.gitConventions,
190
208
  sourceBranch: project.defaultSourceBranch,
@@ -193,7 +211,7 @@ export function getEffectiveSettings(projectPath) {
193
211
  }
194
212
  export function updateGlobalSettings(data) {
195
213
  const settings = readSettings();
196
- const allowedGlobalKeys = ['defaultModel', 'prPromptTemplate', 'gitConventions'];
214
+ const allowedGlobalKeys = ['defaultModel', 'dangerouslySkipPermissions', 'prPromptTemplate', 'gitConventions'];
197
215
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
198
216
  settings.global = { ...settings.global, ...filtered };
199
217
  writeSettings(settings);
@@ -204,6 +222,7 @@ export function upsertProject(projectPath, data) {
204
222
  'displayName',
205
223
  'defaultSourceBranch',
206
224
  'defaultModel',
225
+ 'dangerouslySkipPermissions',
207
226
  'prPromptTemplate',
208
227
  'gitConventions',
209
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,
@@ -96,6 +97,12 @@ export function updateWorkspaceModel(id, model) {
96
97
  db.prepare('UPDATE workspaces SET model = ?, updated_at = ? WHERE id = ?').run(model, now, id);
97
98
  return getWorkspace(id);
98
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
+ }
99
106
  export function updateDevServerStatus(id, status) {
100
107
  const db = getDb();
101
108
  db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
@@ -136,6 +136,34 @@ export function getPrUrl(repoPath, branchName) {
136
136
  return null;
137
137
  }
138
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
+ }
139
167
  export function getDiffStatsBetween(repoPath, base, head) {
140
168
  try {
141
169
  return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",