@loicngr/kobo 1.4.10 → 1.4.12
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/AGENTS.md +16 -10
- package/README.md +5 -1
- package/dist/mcp-server/kobo-tasks-server.js +1 -1
- package/dist/server/db/migrations.js +31 -0
- package/dist/server/db/schema.js +2 -1
- package/dist/server/index.js +119 -6
- package/dist/server/routes/plans.js +89 -0
- package/dist/server/routes/templates.js +82 -0
- package/dist/server/routes/workspaces.js +121 -11
- package/dist/server/services/agent-manager.js +74 -21
- package/dist/server/services/templates-service.js +173 -0
- package/dist/server/services/terminal-service.js +44 -0
- package/dist/server/services/workspace-service.js +49 -0
- package/dist/server/utils/git-ops.js +10 -0
- package/dist/server/utils/paths.js +7 -0
- package/package.json +2 -1
- package/src/client/dist/spa/assets/ActivityFeed-CgZWB0-0.js +10 -0
- package/src/client/dist/spa/assets/ActivityFeed-ko_rO-2M.css +1 -0
- package/src/client/dist/spa/assets/ClosePopup-jSuaV6dg.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-3gOfOT7H.js → CreatePage-PV9pH1-i.js} +2 -2
- package/src/client/dist/spa/assets/DiffViewer-eROI3K7I.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-BKRJcjMN.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-w7f2vykG.js +37 -0
- package/src/client/dist/spa/assets/QExpansionItem-KaA2BBuV.js +1 -0
- package/src/client/dist/spa/assets/{QList-CwbqTC_9.js → QList-lLY9TMz0.js} +1 -1
- package/src/client/dist/spa/assets/QMenu-IaCFq9U1.js +1 -0
- package/src/client/dist/spa/assets/QPage-D5bnOxz0.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-tK2WhdGt.js +1 -0
- package/src/client/dist/spa/assets/{TouchPan-Dc-xrSIS.js → TouchPan-DuISf80E.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-B7LByhzW.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-XUHKUmuQ.css +1 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-fkfRoKj2.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-CWtayDKW.js → cssMode-DxID_rwU.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DeuH5Z7J.js → editor.api-6HHa4uDw.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-CHvDgl6E.js → editor.main-BY_NEoTM.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-3a7cf3Uc.js → freemarker2-ChUzpgbV.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BjDKZgtm.js → handlebars-BEqiXFje.js} +1 -1
- package/src/client/dist/spa/assets/{html-CxHAAoPw.js → html-BMS_L0RV.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-DhXtvpCN.js → htmlMode-C68CMSi2.js} +1 -1
- package/src/client/dist/spa/assets/i18n-C9sStiuf.js +1 -0
- package/src/client/dist/spa/assets/i18n-DAQcMG9d.js +1 -0
- package/src/client/dist/spa/assets/index-DSx45TKH.js +5 -0
- package/src/client/dist/spa/assets/{javascript-CFSWGGGJ.js → javascript-BKkV2qqm.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-k2An8_pW.js → jsonMode-Bk1XwYG8.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-Cq4t-XTr.js → liquid-QLO1u8by.js} +1 -1
- package/src/client/dist/spa/assets/marked.esm-CtWESN18.js +60 -0
- package/src/client/dist/spa/assets/{mdx-UB8RrpeD.js → mdx-Cgznl219.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-B9W_lLWp.js → monaco.contribution-CDhgtrdW.js} +2 -2
- package/src/client/dist/spa/assets/{python-n8QHA1-s.js → python-DCFlVqvF.js} +1 -1
- package/src/client/dist/spa/assets/{razor-DQrmL_Eu.js → razor-BHddvwQ2.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-DqHf0yz_.js → tsMode-BZkc6l5_.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-C2OgYZBp.js → typescript-B0g45tCE.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BD4Y8hNI.js → xml-CfmQqdO8.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-dxD-q88Q.js → yaml-B1F9_T4A.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- package/src/mcp-server/kobo-tasks-server.ts +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-5m7j8KXA.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-CWcNkgNz.js +0 -68
- package/src/client/dist/spa/assets/DiffViewer-BB0uMnDB.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-Di4zIHM4.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-Drv4zYbW.css +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-dnmzWHId.js +0 -1
- package/src/client/dist/spa/assets/QMenu-C_LDF5uF.js +0 -1
- package/src/client/dist/spa/assets/QPage-0Qoiu-SJ.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DKxEdudV.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-2D1mycBC.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CdWgxXBN.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-D6ilyDGt.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-DrDOZvHX.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CLHv3piE.js +0 -1
- package/src/client/dist/spa/assets/i18n-BG0I8c3l.js +0 -1
- package/src/client/dist/spa/assets/i18n-BvyUMouA.js +0 -1
- package/src/client/dist/spa/assets/index-Dh7opLow.js +0 -5
|
@@ -11,6 +11,7 @@ import * as notionService from '../services/notion-service.js';
|
|
|
11
11
|
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
12
12
|
import * as settingsService from '../services/settings-service.js';
|
|
13
13
|
import { runSetupScript } from '../services/setup-script-service.js';
|
|
14
|
+
import * as terminalService from '../services/terminal-service.js';
|
|
14
15
|
import * as wsService from '../services/websocket-service.js';
|
|
15
16
|
import * as workspaceService from '../services/workspace-service.js';
|
|
16
17
|
import * as worktreeService from '../services/worktree-service.js';
|
|
@@ -286,10 +287,11 @@ app.post('/', async (c) => {
|
|
|
286
287
|
}
|
|
287
288
|
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
288
289
|
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
289
|
-
// Persist the initial prompt in the feed so it's visible in the chat
|
|
290
|
-
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
291
290
|
try {
|
|
292
|
-
agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
|
|
291
|
+
const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
|
|
292
|
+
// Persist the initial prompt in the feed so it's visible in the chat,
|
|
293
|
+
// tagged with the freshly created session id so the strict session filter shows it.
|
|
294
|
+
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
|
|
293
295
|
}
|
|
294
296
|
catch (err) {
|
|
295
297
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -311,6 +313,28 @@ app.post('/', async (c) => {
|
|
|
311
313
|
return c.json({ error: message }, 500);
|
|
312
314
|
}
|
|
313
315
|
});
|
|
316
|
+
// POST /api/workspaces/:id/sessions — create a new idle agent session
|
|
317
|
+
app.post('/:id/sessions', (c) => {
|
|
318
|
+
try {
|
|
319
|
+
const id = c.req.param('id');
|
|
320
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
321
|
+
if (!workspace) {
|
|
322
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
323
|
+
}
|
|
324
|
+
if (workspace.archivedAt) {
|
|
325
|
+
return c.json({ error: `Workspace '${id}' is archived` }, 400);
|
|
326
|
+
}
|
|
327
|
+
if (agentManager.getAgentStatus(id) !== null) {
|
|
328
|
+
return c.json({ error: 'An agent is already running for this workspace' }, 409);
|
|
329
|
+
}
|
|
330
|
+
const session = workspaceService.createIdleSession(id);
|
|
331
|
+
return c.json(session, 201);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
335
|
+
return c.json({ error: message }, 500);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
314
338
|
// GET /api/workspaces/:id/sessions — list sessions for a workspace
|
|
315
339
|
app.get('/:id/sessions', (c) => {
|
|
316
340
|
try {
|
|
@@ -326,6 +350,30 @@ app.get('/:id/sessions', (c) => {
|
|
|
326
350
|
return c.json({ error: message }, 500);
|
|
327
351
|
}
|
|
328
352
|
});
|
|
353
|
+
// PATCH /api/workspaces/:id/sessions/:sessionId — rename a session
|
|
354
|
+
app.patch('/:id/sessions/:sessionId', async (c) => {
|
|
355
|
+
try {
|
|
356
|
+
const id = c.req.param('id');
|
|
357
|
+
const sessionId = c.req.param('sessionId');
|
|
358
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
359
|
+
if (!workspace) {
|
|
360
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
361
|
+
}
|
|
362
|
+
const body = await c.req.json().catch(() => ({}));
|
|
363
|
+
if (!body.name?.trim()) {
|
|
364
|
+
return c.json({ error: 'name is required and must not be empty' }, 400);
|
|
365
|
+
}
|
|
366
|
+
const updated = workspaceService.renameSession(sessionId, id, body.name.trim());
|
|
367
|
+
if (!updated) {
|
|
368
|
+
return c.json({ error: `Session '${sessionId}' not found` }, 404);
|
|
369
|
+
}
|
|
370
|
+
return c.json(updated);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
374
|
+
return c.json({ error: message }, 500);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
329
377
|
// POST /api/workspaces/:id/refresh-notion — re-extract Notion page and update tasks
|
|
330
378
|
app.post('/:id/refresh-notion', async (c) => {
|
|
331
379
|
try {
|
|
@@ -715,6 +763,12 @@ app.post('/:id/archive', (c) => {
|
|
|
715
763
|
const message = err instanceof Error ? err.message : String(err);
|
|
716
764
|
console.error(`[workspaces] stopDevServer during archive failed: ${message}`);
|
|
717
765
|
}
|
|
766
|
+
try {
|
|
767
|
+
terminalService.destroyTerminal(id);
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
// Terminal may not exist — ignore
|
|
771
|
+
}
|
|
718
772
|
const updated = workspaceService.archiveWorkspace(id);
|
|
719
773
|
wsService.emitEphemeral(id, 'workspace:archived', { workspace: updated });
|
|
720
774
|
return c.json(updated);
|
|
@@ -763,6 +817,12 @@ app.delete('/:id', async (c) => {
|
|
|
763
817
|
catch {
|
|
764
818
|
// Agent may not be running — ignore
|
|
765
819
|
}
|
|
820
|
+
try {
|
|
821
|
+
terminalService.destroyTerminal(id);
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
// Terminal may not exist — ignore
|
|
825
|
+
}
|
|
766
826
|
// Remove worktree
|
|
767
827
|
const worktreesDir = `${workspace.projectPath}/.worktrees`;
|
|
768
828
|
const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
|
|
@@ -810,8 +870,12 @@ app.post('/:id/start', async (c) => {
|
|
|
810
870
|
if (!workspace) {
|
|
811
871
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
812
872
|
}
|
|
813
|
-
const body = await c.req
|
|
873
|
+
const body = await c.req
|
|
874
|
+
.json()
|
|
875
|
+
.catch(() => ({ prompt: undefined, agentSessionId: undefined, resume: undefined }));
|
|
814
876
|
const prompt = body.prompt ?? 'Continue the previous task where you left off.';
|
|
877
|
+
const agentSessionId = body.agentSessionId;
|
|
878
|
+
const resume = body.resume === true;
|
|
815
879
|
// Stop existing agent if running
|
|
816
880
|
try {
|
|
817
881
|
agentManager.stopAgent(id);
|
|
@@ -820,8 +884,14 @@ app.post('/:id/start', async (c) => {
|
|
|
820
884
|
// Agent may not be running — ignore
|
|
821
885
|
}
|
|
822
886
|
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
823
|
-
agentManager.startAgent(id, worktreePath, prompt, workspace.model,
|
|
887
|
+
const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId);
|
|
824
888
|
workspaceService.updateWorkspaceStatus(id, 'executing');
|
|
889
|
+
// Persist the user prompt so it survives page refresh.
|
|
890
|
+
// When agentSessionId is provided (idle-session flow), the prompt was typed
|
|
891
|
+
// by the user in the chat input; otherwise it's the workspace start prompt.
|
|
892
|
+
if (body.prompt) {
|
|
893
|
+
wsService.emit(id, 'user:message', { content: body.prompt, sender: 'user' }, agent.agentSessionId);
|
|
894
|
+
}
|
|
825
895
|
return c.json({ status: 'started' });
|
|
826
896
|
}
|
|
827
897
|
catch (err) {
|
|
@@ -919,9 +989,34 @@ app.post('/:id/push', async (c) => {
|
|
|
919
989
|
return c.json({ error: message }, 500);
|
|
920
990
|
}
|
|
921
991
|
// Emit a trace into the chat feed so the user sees the action
|
|
922
|
-
const session = workspaceService.
|
|
923
|
-
|
|
924
|
-
|
|
992
|
+
const session = workspaceService.getActiveSession(id);
|
|
993
|
+
wsService.emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, session?.id ?? undefined);
|
|
994
|
+
return c.json({ ok: true, branch: workspace.workingBranch });
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
998
|
+
return c.json({ error: message }, 500);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
// POST /api/workspaces/:id/pull — pull working branch from origin (fast-forward only)
|
|
1002
|
+
app.post('/:id/pull', (c) => {
|
|
1003
|
+
try {
|
|
1004
|
+
const id = c.req.param('id');
|
|
1005
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1006
|
+
if (!workspace) {
|
|
1007
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1008
|
+
}
|
|
1009
|
+
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
1010
|
+
try {
|
|
1011
|
+
gitOps.pullBranch(worktreePath, workspace.workingBranch);
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1015
|
+
return c.json({ error: message }, 500);
|
|
1016
|
+
}
|
|
1017
|
+
// Emit a trace into the chat feed so the user sees the action
|
|
1018
|
+
const session = workspaceService.getActiveSession(id);
|
|
1019
|
+
wsService.emit(id, 'user:message', { content: `Pulled branch ${workspace.workingBranch} from origin`, sender: 'system-prompt' }, session?.id ?? undefined);
|
|
925
1020
|
return c.json({ ok: true, branch: workspace.workingBranch });
|
|
926
1021
|
}
|
|
927
1022
|
catch (err) {
|
|
@@ -1054,9 +1149,8 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
1054
1149
|
tasks,
|
|
1055
1150
|
});
|
|
1056
1151
|
// Emit user:message into the chat feed
|
|
1057
|
-
const session = workspaceService.
|
|
1058
|
-
|
|
1059
|
-
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, sessionId);
|
|
1152
|
+
const session = workspaceService.getActiveSession(workspace.id);
|
|
1153
|
+
wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, session?.id ?? undefined);
|
|
1060
1154
|
// Send to the running agent, or resume the agent with the PR prompt
|
|
1061
1155
|
let messageSent = false;
|
|
1062
1156
|
try {
|
|
@@ -1128,4 +1222,20 @@ app.post('/:id/stop', (c) => {
|
|
|
1128
1222
|
return c.json({ error: message }, 500);
|
|
1129
1223
|
}
|
|
1130
1224
|
});
|
|
1225
|
+
// POST /api/workspaces/:id/interrupt — soft-interrupt agent (SIGINT, like Escape in Claude Code)
|
|
1226
|
+
app.post('/:id/interrupt', (c) => {
|
|
1227
|
+
try {
|
|
1228
|
+
const id = c.req.param('id');
|
|
1229
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1230
|
+
if (!workspace) {
|
|
1231
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1232
|
+
}
|
|
1233
|
+
agentManager.interruptAgent(id);
|
|
1234
|
+
return c.json({ status: 'interrupted' });
|
|
1235
|
+
}
|
|
1236
|
+
catch (err) {
|
|
1237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1238
|
+
return c.json({ error: message }, 500);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1131
1241
|
export default app;
|
|
@@ -89,7 +89,7 @@ function runWatchdog() {
|
|
|
89
89
|
catch {
|
|
90
90
|
// best-effort
|
|
91
91
|
}
|
|
92
|
-
emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.
|
|
92
|
+
emit(workspaceId, 'agent:status', { status: 'error', message: 'Agent process died unexpectedly' }, agent.agentSessionId);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
/** Start the watchdog (called once from server bootstrap). */
|
|
@@ -108,7 +108,7 @@ export function stopWatchdog() {
|
|
|
108
108
|
}
|
|
109
109
|
// ── Start agent ────────────────────────────────────────────────────────────────
|
|
110
110
|
/** Spawn a Claude Code CLI process for a workspace and wire up stdout/stderr/exit handling. */
|
|
111
|
-
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept') {
|
|
111
|
+
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, permissionMode = 'auto-accept', existingSessionId) {
|
|
112
112
|
// Check if agent already running for this workspace
|
|
113
113
|
if (agents.has(workspaceId)) {
|
|
114
114
|
throw new Error(`Agent already running for workspace '${workspaceId}'`);
|
|
@@ -132,10 +132,28 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
132
132
|
args.push('--model', model);
|
|
133
133
|
}
|
|
134
134
|
if (resume) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
// Prefer resuming the specific session requested by the caller (existingSessionId).
|
|
136
|
+
// Otherwise fall back to the most recent session for the workspace.
|
|
137
|
+
let lastSession;
|
|
138
|
+
if (existingSessionId) {
|
|
139
|
+
lastSession = db
|
|
140
|
+
.prepare('SELECT id, claude_session_id FROM agent_sessions WHERE id = ? AND workspace_id = ? AND claude_session_id IS NOT NULL LIMIT 1')
|
|
141
|
+
.get(existingSessionId, workspaceId);
|
|
142
|
+
// If the caller explicitly asked to resume a specific session, fail loudly
|
|
143
|
+
// when it cannot be resumed (no claude_session_id, wrong workspace, or
|
|
144
|
+
// missing row). Silently creating a new session would orphan the target.
|
|
145
|
+
if (!lastSession) {
|
|
146
|
+
throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
|
|
147
|
+
'session not found or has no associated Claude conversation');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
lastSession = db
|
|
152
|
+
.prepare('SELECT id, claude_session_id FROM agent_sessions WHERE workspace_id = ? AND claude_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
|
|
153
|
+
.get(workspaceId);
|
|
154
|
+
}
|
|
155
|
+
// The in-memory cache is workspace-scoped, only useful when no specific session was requested.
|
|
156
|
+
const claudeSessionId = lastSession?.claude_session_id ?? (existingSessionId ? undefined : sessionIds.get(workspaceId));
|
|
139
157
|
if (claudeSessionId) {
|
|
140
158
|
resumedClaudeSessionId = claudeSessionId;
|
|
141
159
|
args.push('--resume', claudeSessionId, '-p', prompt);
|
|
@@ -160,8 +178,19 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
160
178
|
}
|
|
161
179
|
else {
|
|
162
180
|
args.push('-p', prompt);
|
|
163
|
-
|
|
164
|
-
|
|
181
|
+
if (existingSessionId) {
|
|
182
|
+
const result = db
|
|
183
|
+
.prepare('UPDATE agent_sessions SET status = ?, started_at = ?, ended_at = NULL WHERE id = ? AND workspace_id = ?')
|
|
184
|
+
.run('running', new Date().toISOString(), existingSessionId, workspaceId);
|
|
185
|
+
if (result.changes === 0) {
|
|
186
|
+
throw new Error(`Agent session '${existingSessionId}' not found for workspace '${workspaceId}'`);
|
|
187
|
+
}
|
|
188
|
+
agentSessionId = existingSessionId;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
agentSessionId = nanoid();
|
|
192
|
+
db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
|
|
193
|
+
}
|
|
165
194
|
}
|
|
166
195
|
// Write .mcp.json to workingDir so claude picks up the kobo-tasks MCP server
|
|
167
196
|
const mcpConfigPath = path.join(workingDir, '.mcp.json');
|
|
@@ -220,12 +249,12 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
220
249
|
}
|
|
221
250
|
catch {
|
|
222
251
|
// Parsing failed — emit raw line
|
|
223
|
-
emit(workspaceId, 'agent:output', { type: 'raw', content: line }, agent.
|
|
252
|
+
emit(workspaceId, 'agent:output', { type: 'raw', content: line }, agent.agentSessionId);
|
|
224
253
|
// Check for BRAINSTORM_COMPLETE marker in raw lines
|
|
225
254
|
if (line.includes('[BRAINSTORM_COMPLETE]')) {
|
|
226
255
|
try {
|
|
227
256
|
updateWorkspaceStatus(workspaceId, 'executing');
|
|
228
|
-
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.
|
|
257
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.agentSessionId);
|
|
229
258
|
}
|
|
230
259
|
catch (err) {
|
|
231
260
|
console.error('[agent] Failed to transition to executing:', err);
|
|
@@ -289,7 +318,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
289
318
|
if (msgType === 'user') {
|
|
290
319
|
return;
|
|
291
320
|
}
|
|
292
|
-
emit(workspaceId, 'agent:output', parsed, agent.
|
|
321
|
+
emit(workspaceId, 'agent:output', parsed, agent.agentSessionId);
|
|
293
322
|
// Detect brainstorming completion from parsed output
|
|
294
323
|
if (msgType === 'assistant' && Array.isArray(p.content)) {
|
|
295
324
|
const hasMarker = p.content.some((block) => {
|
|
@@ -299,7 +328,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
299
328
|
if (hasMarker) {
|
|
300
329
|
try {
|
|
301
330
|
updateWorkspaceStatus(workspaceId, 'executing');
|
|
302
|
-
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.
|
|
331
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.agentSessionId);
|
|
303
332
|
}
|
|
304
333
|
catch (err) {
|
|
305
334
|
console.error('[agent] Failed to transition to executing:', err);
|
|
@@ -315,10 +344,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
315
344
|
const text = data.toString();
|
|
316
345
|
const lowerText = text.toLowerCase();
|
|
317
346
|
if (lowerText.includes('rate limit') || lowerText.includes('quota') || lowerText.includes('limit exceeded')) {
|
|
318
|
-
handleQuota(workspaceId, agent.
|
|
347
|
+
handleQuota(workspaceId, agent.agentSessionId);
|
|
319
348
|
}
|
|
320
349
|
// Also emit stderr for visibility
|
|
321
|
-
emit(workspaceId, 'agent:stderr', { content: text }, agent.
|
|
350
|
+
emit(workspaceId, 'agent:stderr', { content: text }, agent.agentSessionId);
|
|
322
351
|
});
|
|
323
352
|
// ── process exit ──
|
|
324
353
|
proc.on('exit', (code) => {
|
|
@@ -353,7 +382,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
353
382
|
}
|
|
354
383
|
if (agent.status === 'stopping') {
|
|
355
384
|
// Clean stop requested
|
|
356
|
-
emit(workspaceId, 'agent:status', { status: 'stopped' }, agent.
|
|
385
|
+
emit(workspaceId, 'agent:status', { status: 'stopped' }, agent.agentSessionId);
|
|
357
386
|
return;
|
|
358
387
|
}
|
|
359
388
|
// Also clear backoff timers on non-stopping exit
|
|
@@ -376,7 +405,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
376
405
|
catch {
|
|
377
406
|
// best-effort
|
|
378
407
|
}
|
|
379
|
-
emit(workspaceId, 'agent:status', { status: 'error', exitCode: code }, agent.
|
|
408
|
+
emit(workspaceId, 'agent:status', { status: 'error', exitCode: code }, agent.agentSessionId);
|
|
380
409
|
}
|
|
381
410
|
else {
|
|
382
411
|
try {
|
|
@@ -392,15 +421,39 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
392
421
|
catch {
|
|
393
422
|
// best-effort
|
|
394
423
|
}
|
|
395
|
-
emit(workspaceId, 'agent:status', { status: 'completed' }, agent.
|
|
424
|
+
emit(workspaceId, 'agent:status', { status: 'completed' }, agent.agentSessionId);
|
|
396
425
|
}
|
|
397
426
|
});
|
|
398
427
|
// Store in agents map
|
|
399
428
|
agents.set(workspaceId, agent);
|
|
400
429
|
// Notify frontend that agent is now running
|
|
401
|
-
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.
|
|
430
|
+
emit(workspaceId, 'agent:status', { status: 'executing' }, agent.agentSessionId);
|
|
402
431
|
return agent;
|
|
403
432
|
}
|
|
433
|
+
// ── Interrupt agent ────────────────────────────────────────────────────────────
|
|
434
|
+
/**
|
|
435
|
+
* Soft-interrupt the running agent by sending SIGINT. This mirrors pressing
|
|
436
|
+
* Escape in Claude Code CLI: the current tool call is aborted but the process
|
|
437
|
+
* stays alive and waits for the next user message. The agent session remains
|
|
438
|
+
* in 'running' status.
|
|
439
|
+
*/
|
|
440
|
+
export function interruptAgent(workspaceId) {
|
|
441
|
+
const agent = agents.get(workspaceId);
|
|
442
|
+
if (!agent) {
|
|
443
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
444
|
+
}
|
|
445
|
+
const pid = agent.process.pid;
|
|
446
|
+
if (pid === undefined) {
|
|
447
|
+
throw new Error(`Agent process has no PID for workspace '${workspaceId}'`);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
process.kill(pid, 'SIGINT');
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
454
|
+
throw new Error(`Failed to interrupt agent for workspace '${workspaceId}': ${message}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
404
457
|
// ── Stop agent ─────────────────────────────────────────────────────────────────
|
|
405
458
|
/** Gracefully stop an agent (SIGTERM, then SIGKILL after 5s). */
|
|
406
459
|
export function stopAgent(workspaceId) {
|
|
@@ -484,7 +537,7 @@ export function getAvailableSkills() {
|
|
|
484
537
|
return [...KOBO_COMMANDS, ...availableSkills];
|
|
485
538
|
}
|
|
486
539
|
// ── Quota handling ─────────────────────────────────────────────────────────────
|
|
487
|
-
function handleQuota(workspaceId,
|
|
540
|
+
function handleQuota(workspaceId, agentSessionId) {
|
|
488
541
|
// Update workspace status
|
|
489
542
|
try {
|
|
490
543
|
updateWorkspaceStatus(workspaceId, 'quota');
|
|
@@ -493,7 +546,7 @@ function handleQuota(workspaceId, claudeSessionId) {
|
|
|
493
546
|
// May fail if transition is not valid
|
|
494
547
|
}
|
|
495
548
|
// Emit status event
|
|
496
|
-
emit(workspaceId, 'agent:status', { status: 'quota' },
|
|
549
|
+
emit(workspaceId, 'agent:status', { status: 'quota' }, agentSessionId);
|
|
497
550
|
// Calculate backoff: 15min first, then 30min, then 60min cap
|
|
498
551
|
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
499
552
|
const backoffMinutes = Math.min(15 * 2 ** retryCount, 60);
|
|
@@ -503,7 +556,7 @@ function handleQuota(workspaceId, claudeSessionId) {
|
|
|
503
556
|
status: 'quota:backoff',
|
|
504
557
|
retryCount: retryCount + 1,
|
|
505
558
|
backoffMinutes,
|
|
506
|
-
},
|
|
559
|
+
}, agentSessionId);
|
|
507
560
|
// Set timer to restart agent
|
|
508
561
|
const timer = setTimeout(() => {
|
|
509
562
|
backoffTimers.delete(workspaceId);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getTemplatesPath } from '../utils/paths.js';
|
|
4
|
+
const CURRENT_FILE_VERSION = 1;
|
|
5
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
6
|
+
const MAX_CONTENT_LENGTH = 4096;
|
|
7
|
+
const MAX_DESCRIPTION_LENGTH = 120;
|
|
8
|
+
/**
|
|
9
|
+
* Read the templates list from disk. Seeds with defaults if the file does
|
|
10
|
+
* not exist yet. Returns an empty array (with a logged error) on corruption.
|
|
11
|
+
*/
|
|
12
|
+
export function listTemplates() {
|
|
13
|
+
const filePath = getTemplatesPath();
|
|
14
|
+
if (!existsSync(filePath)) {
|
|
15
|
+
seedTemplates();
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (parsed.version !== CURRENT_FILE_VERSION) {
|
|
21
|
+
console.warn(`[templates-service] templates.json has version ${parsed.version}, expected ${CURRENT_FILE_VERSION}. Reading best-effort.`);
|
|
22
|
+
}
|
|
23
|
+
return Array.isArray(parsed.templates) ? parsed.templates : [];
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error('[templates-service] Failed to read templates.json:', err);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Create a new template. Throws on invalid input or duplicate slug. */
|
|
31
|
+
export function createTemplate(input) {
|
|
32
|
+
validateTemplateInput(input);
|
|
33
|
+
const templates = listTemplates();
|
|
34
|
+
if (templates.some((t) => t.slug === input.slug)) {
|
|
35
|
+
throw new Error(`Template '${input.slug}' already exists`);
|
|
36
|
+
}
|
|
37
|
+
const now = new Date().toISOString();
|
|
38
|
+
const template = {
|
|
39
|
+
slug: input.slug,
|
|
40
|
+
description: input.description,
|
|
41
|
+
content: input.content,
|
|
42
|
+
createdAt: now,
|
|
43
|
+
updatedAt: now,
|
|
44
|
+
};
|
|
45
|
+
writeTemplates([...templates, template]);
|
|
46
|
+
return template;
|
|
47
|
+
}
|
|
48
|
+
/** Update an existing template. Returns null if slug not found. */
|
|
49
|
+
export function updateTemplate(slug, updates) {
|
|
50
|
+
const templates = listTemplates();
|
|
51
|
+
const idx = templates.findIndex((t) => t.slug === slug);
|
|
52
|
+
if (idx < 0)
|
|
53
|
+
return null;
|
|
54
|
+
const current = templates[idx];
|
|
55
|
+
const next = {
|
|
56
|
+
...current,
|
|
57
|
+
description: updates.description ?? current.description,
|
|
58
|
+
content: updates.content ?? current.content,
|
|
59
|
+
updatedAt: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
validateTemplateInput({ slug: next.slug, description: next.description, content: next.content });
|
|
62
|
+
templates[idx] = next;
|
|
63
|
+
writeTemplates(templates);
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
/** Delete a template. Returns true if deleted, false if not found. */
|
|
67
|
+
export function deleteTemplate(slug) {
|
|
68
|
+
const templates = listTemplates();
|
|
69
|
+
const next = templates.filter((t) => t.slug !== slug);
|
|
70
|
+
if (next.length === templates.length)
|
|
71
|
+
return false;
|
|
72
|
+
writeTemplates(next);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// ── Internals ──────────────────────────────────────────────────────────────
|
|
76
|
+
function validateTemplateInput(input) {
|
|
77
|
+
if (!SLUG_PATTERN.test(input.slug)) {
|
|
78
|
+
throw new Error(`Invalid slug '${input.slug}': must match ${SLUG_PATTERN} (lowercase letters, digits, hyphens; 1–64 chars)`);
|
|
79
|
+
}
|
|
80
|
+
// Consistent rule for both description and content:
|
|
81
|
+
// - reject if empty after trim (all-whitespace is not valid)
|
|
82
|
+
// - reject if raw length exceeds the max (trailing whitespace still counts)
|
|
83
|
+
const rawDescription = input.description ?? '';
|
|
84
|
+
if (rawDescription.trim().length === 0 || rawDescription.length > MAX_DESCRIPTION_LENGTH) {
|
|
85
|
+
throw new Error(`Invalid description: must be 1..${MAX_DESCRIPTION_LENGTH} chars`);
|
|
86
|
+
}
|
|
87
|
+
const rawContent = input.content ?? '';
|
|
88
|
+
if (rawContent.trim().length === 0 || rawContent.length > MAX_CONTENT_LENGTH) {
|
|
89
|
+
throw new Error(`Invalid content: must be 1..${MAX_CONTENT_LENGTH} chars`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function writeTemplates(templates) {
|
|
93
|
+
const filePath = getTemplatesPath();
|
|
94
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
95
|
+
const file = { version: CURRENT_FILE_VERSION, templates };
|
|
96
|
+
writeFileSync(filePath, JSON.stringify(file, null, 2), 'utf-8');
|
|
97
|
+
}
|
|
98
|
+
function seedTemplates() {
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
const seed = [
|
|
101
|
+
{
|
|
102
|
+
slug: 'review-quality',
|
|
103
|
+
description: 'Code quality review',
|
|
104
|
+
content: 'Review the recently modified code in {working_branch} for:\n- Logic bugs\n- Missing error handling\n- Style issues\n\nReport only high-confidence findings.',
|
|
105
|
+
createdAt: now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
slug: 'add-tests',
|
|
110
|
+
description: 'Add unit tests following existing patterns',
|
|
111
|
+
content: 'Add unit tests for the recently modified code. Follow the existing test patterns in this project. Focus on:\n- Happy paths\n- Edge cases\n- Error handling',
|
|
112
|
+
createdAt: now,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
slug: 'explain',
|
|
117
|
+
description: 'Explain the recent changes',
|
|
118
|
+
content: 'Explain what the recently modified code does in {working_branch}, focusing on the non-obvious parts.',
|
|
119
|
+
createdAt: now,
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
slug: 'refactor',
|
|
124
|
+
description: 'Safe refactoring',
|
|
125
|
+
content: 'Refactor the selected code to improve readability without changing its behavior. Explain your reasoning as you go.',
|
|
126
|
+
createdAt: now,
|
|
127
|
+
updatedAt: now,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
slug: 'plan-tasks',
|
|
131
|
+
description: 'Break work into kobo tasks',
|
|
132
|
+
content: 'Break down the work for this workspace ({workspace_name}) into concrete tasks. Use the kobo-tasks MCP tool `create_task` to register each one with a short, actionable title. Start with a high-level analysis of what needs to happen.',
|
|
133
|
+
createdAt: now,
|
|
134
|
+
updatedAt: now,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
slug: 'show-tasks',
|
|
138
|
+
description: 'List current kobo tasks',
|
|
139
|
+
content: 'List the current tasks for this workspace using the kobo-tasks MCP tool `list_tasks`. Show their status and highlight what is still pending.',
|
|
140
|
+
createdAt: now,
|
|
141
|
+
updatedAt: now,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
slug: 'mark-done',
|
|
145
|
+
description: 'Mark completed kobo tasks',
|
|
146
|
+
content: 'Review the work completed so far. Identify which tasks from the kobo-tasks list are now done, and mark them using the `mark_task_done` MCP tool.',
|
|
147
|
+
createdAt: now,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
slug: 'sync-tasks',
|
|
152
|
+
description: 'Sync kobo tasks with the codebase',
|
|
153
|
+
content: 'Compare the current state of the codebase against the kobo-tasks list. Create missing tasks with `create_task`, mark completed ones with `mark_task_done`, and delete stale ones with `delete_task`. Explain each change before making it.',
|
|
154
|
+
createdAt: now,
|
|
155
|
+
updatedAt: now,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
slug: 'pr-review-comments',
|
|
159
|
+
description: 'List PR review comments requesting changes',
|
|
160
|
+
content: 'Check if a pull request exists for branch {working_branch}.\n\nIf a PR exists (PR {pr_url}):\n1. Use the GitHub MCP tools to fetch the PR reviews and comments\n2. Filter for reviews with status "CHANGES_REQUESTED"\n3. List each review comment with:\n - The reviewer name\n - The file and line referenced\n - The comment body\n - Whether it has been resolved\n4. Summarize the outstanding requested changes that still need to be addressed\n\nIf no PR exists, say so and suggest pushing the branch first.',
|
|
161
|
+
createdAt: now,
|
|
162
|
+
updatedAt: now,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
slug: 'ci-status',
|
|
166
|
+
description: 'Check GitHub Actions status on PR',
|
|
167
|
+
content: 'Check the CI/CD status for the pull request on branch {working_branch}.\n\nIf a PR exists (PR {pr_url}):\n1. Use the GitHub MCP tools to list the check runs / status checks on the latest commit of the PR\n2. For each check, report:\n - Check name\n - Status (queued, in_progress, completed)\n - Conclusion (success, failure, neutral, skipped, etc.)\n - Duration if available\n3. If any checks failed, fetch the logs or annotations and summarize what went wrong\n4. Give an overall summary: all green, some failing, or still running\n\nIf no PR exists, say so and suggest creating one first.',
|
|
168
|
+
createdAt: now,
|
|
169
|
+
updatedAt: now,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
writeTemplates(seed);
|
|
173
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import * as pty from 'node-pty';
|
|
3
|
+
const terminals = new Map();
|
|
4
|
+
export function createTerminal(workspaceId, cwd) {
|
|
5
|
+
if (terminals.has(workspaceId)) {
|
|
6
|
+
return terminals.get(workspaceId).pty;
|
|
7
|
+
}
|
|
8
|
+
if (!fs.existsSync(cwd)) {
|
|
9
|
+
throw new Error(`Worktree directory does not exist: ${cwd}`);
|
|
10
|
+
}
|
|
11
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
12
|
+
const term = pty.spawn(shell, [], {
|
|
13
|
+
name: 'xterm-256color',
|
|
14
|
+
cols: 80,
|
|
15
|
+
rows: 24,
|
|
16
|
+
cwd,
|
|
17
|
+
env: process.env,
|
|
18
|
+
});
|
|
19
|
+
terminals.set(workspaceId, { pty: term });
|
|
20
|
+
term.onExit(() => {
|
|
21
|
+
terminals.delete(workspaceId);
|
|
22
|
+
});
|
|
23
|
+
return term;
|
|
24
|
+
}
|
|
25
|
+
export function getTerminal(workspaceId) {
|
|
26
|
+
return terminals.get(workspaceId)?.pty ?? null;
|
|
27
|
+
}
|
|
28
|
+
export function destroyTerminal(workspaceId) {
|
|
29
|
+
const instance = terminals.get(workspaceId);
|
|
30
|
+
if (instance) {
|
|
31
|
+
try {
|
|
32
|
+
instance.pty.kill();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.error(`[terminal] Failed to kill PTY for workspace ${workspaceId}:`, err);
|
|
36
|
+
}
|
|
37
|
+
terminals.delete(workspaceId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function destroyAllTerminals() {
|
|
41
|
+
for (const [id] of terminals) {
|
|
42
|
+
destroyTerminal(id);
|
|
43
|
+
}
|
|
44
|
+
}
|