@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.
- package/dist/server/db/migrations.js +61 -10
- package/dist/server/db/schema.js +1 -0
- package/dist/server/index.js +7 -4
- package/dist/server/routes/workspaces.js +56 -19
- package/dist/server/services/agent-manager.js +77 -3
- package/dist/server/services/pr-watcher-service.js +61 -0
- package/dist/server/services/settings-service.js +41 -22
- package/dist/server/services/workspace-service.js +7 -0
- package/dist/server/utils/git-ops.js +28 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CPfYmybV.js → ActivityFeed-Bie-lcn7.js} +9 -9
- package/src/client/dist/spa/assets/ActivityFeed-D88GOO2z.css +1 -0
- package/src/client/dist/spa/assets/{CreatePage-C_c3Gr0F.js → CreatePage-OC-fnNGP.js} +1 -1
- 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-CNojh9Rl.js → QBadge-DbE3eSf1.js} +1 -1
- package/src/client/dist/spa/assets/{QDialog-DgR7t6Vf.js → QDialog-Cd_4PvgW.js} +1 -1
- package/src/client/dist/spa/assets/{QExpansionItem-VVjlYOIT.js → QExpansionItem-pMQDDRMv.js} +1 -1
- package/src/client/dist/spa/assets/{QPage-DX4g-Dpe.js → QPage-lhV4XbI2.js} +1 -1
- package/src/client/dist/spa/assets/{QSpinnerDots-DeCf9Lr-.js → QSpinnerDots-ByNZaBWw.js} +1 -1
- package/src/client/dist/spa/assets/{QTooltip-DKYJ8kVW.js → QTooltip-6GSFtFKP.js} +1 -1
- 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-DkM58caD.css → WorkspacePage-Dhkuuhf8.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-XT26aCJE.js +2 -0
- package/src/client/dist/spa/assets/{_plugin-vue_export-helper-C6NdfBK4.js → _plugin-vue_export-helper-B6FaNy4R.js} +1 -1
- package/src/client/dist/spa/assets/{index-C4WDJfjD.js → index-BoQWbZtE.js} +4 -4
- package/src/client/dist/spa/assets/{nodes-irfhA8FK.js → nodes-CXdiSdC2.js} +1 -1
- package/src/client/dist/spa/assets/{use-checkbox-BS9cbwg_.js → use-checkbox-Z9pfihkw.js} +1 -1
- package/src/client/dist/spa/assets/use-quasar-CtCe3LQU.js +1 -0
- package/src/client/dist/spa/index.html +2 -2
- package/src/client/dist/spa/assets/ActivityFeed-DBljh9rq.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-BMxEROm4.css +0 -1
- package/src/client/dist/spa/assets/MainLayout-QtbVbbnd.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-DjWKsLC-.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-Yv31Z9aG.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-EAh91w9s.js +0 -2
- 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
|
|
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
|
|
6
|
-
version
|
|
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
|
-
|
|
10
|
-
const
|
|
11
|
-
if (
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
+
}
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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,
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
765
|
+
messageSent = true;
|
|
732
766
|
}
|
|
733
|
-
catch
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
96
|
-
*
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
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",
|