@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.
Files changed (74) hide show
  1. package/AGENTS.md +16 -10
  2. package/README.md +5 -1
  3. package/dist/mcp-server/kobo-tasks-server.js +1 -1
  4. package/dist/server/db/migrations.js +31 -0
  5. package/dist/server/db/schema.js +2 -1
  6. package/dist/server/index.js +119 -6
  7. package/dist/server/routes/plans.js +89 -0
  8. package/dist/server/routes/templates.js +82 -0
  9. package/dist/server/routes/workspaces.js +121 -11
  10. package/dist/server/services/agent-manager.js +74 -21
  11. package/dist/server/services/templates-service.js +173 -0
  12. package/dist/server/services/terminal-service.js +44 -0
  13. package/dist/server/services/workspace-service.js +49 -0
  14. package/dist/server/utils/git-ops.js +10 -0
  15. package/dist/server/utils/paths.js +7 -0
  16. package/package.json +2 -1
  17. package/src/client/dist/spa/assets/ActivityFeed-CgZWB0-0.js +10 -0
  18. package/src/client/dist/spa/assets/ActivityFeed-ko_rO-2M.css +1 -0
  19. package/src/client/dist/spa/assets/ClosePopup-jSuaV6dg.js +1 -0
  20. package/src/client/dist/spa/assets/{CreatePage-3gOfOT7H.js → CreatePage-PV9pH1-i.js} +2 -2
  21. package/src/client/dist/spa/assets/DiffViewer-eROI3K7I.js +2 -0
  22. package/src/client/dist/spa/assets/MainLayout-BKRJcjMN.css +1 -0
  23. package/src/client/dist/spa/assets/MainLayout-w7f2vykG.js +37 -0
  24. package/src/client/dist/spa/assets/QExpansionItem-KaA2BBuV.js +1 -0
  25. package/src/client/dist/spa/assets/{QList-CwbqTC_9.js → QList-lLY9TMz0.js} +1 -1
  26. package/src/client/dist/spa/assets/QMenu-IaCFq9U1.js +1 -0
  27. package/src/client/dist/spa/assets/QPage-D5bnOxz0.js +1 -0
  28. package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +1 -0
  29. package/src/client/dist/spa/assets/SettingsPage-tK2WhdGt.js +1 -0
  30. package/src/client/dist/spa/assets/{TouchPan-Dc-xrSIS.js → TouchPan-DuISf80E.js} +1 -1
  31. package/src/client/dist/spa/assets/WorkspacePage-B7LByhzW.js +4 -0
  32. package/src/client/dist/spa/assets/WorkspacePage-XUHKUmuQ.css +1 -0
  33. package/src/client/dist/spa/assets/_plugin-vue_export-helper-fkfRoKj2.js +1 -0
  34. package/src/client/dist/spa/assets/{cssMode-CWtayDKW.js → cssMode-DxID_rwU.js} +1 -1
  35. package/src/client/dist/spa/assets/{editor.api-DeuH5Z7J.js → editor.api-6HHa4uDw.js} +1 -1
  36. package/src/client/dist/spa/assets/{editor.main-CHvDgl6E.js → editor.main-BY_NEoTM.js} +3 -3
  37. package/src/client/dist/spa/assets/{freemarker2-3a7cf3Uc.js → freemarker2-ChUzpgbV.js} +1 -1
  38. package/src/client/dist/spa/assets/{handlebars-BjDKZgtm.js → handlebars-BEqiXFje.js} +1 -1
  39. package/src/client/dist/spa/assets/{html-CxHAAoPw.js → html-BMS_L0RV.js} +1 -1
  40. package/src/client/dist/spa/assets/{htmlMode-DhXtvpCN.js → htmlMode-C68CMSi2.js} +1 -1
  41. package/src/client/dist/spa/assets/i18n-C9sStiuf.js +1 -0
  42. package/src/client/dist/spa/assets/i18n-DAQcMG9d.js +1 -0
  43. package/src/client/dist/spa/assets/index-DSx45TKH.js +5 -0
  44. package/src/client/dist/spa/assets/{javascript-CFSWGGGJ.js → javascript-BKkV2qqm.js} +1 -1
  45. package/src/client/dist/spa/assets/{jsonMode-k2An8_pW.js → jsonMode-Bk1XwYG8.js} +1 -1
  46. package/src/client/dist/spa/assets/{liquid-Cq4t-XTr.js → liquid-QLO1u8by.js} +1 -1
  47. package/src/client/dist/spa/assets/marked.esm-CtWESN18.js +60 -0
  48. package/src/client/dist/spa/assets/{mdx-UB8RrpeD.js → mdx-Cgznl219.js} +1 -1
  49. package/src/client/dist/spa/assets/{monaco.contribution-B9W_lLWp.js → monaco.contribution-CDhgtrdW.js} +2 -2
  50. package/src/client/dist/spa/assets/{python-n8QHA1-s.js → python-DCFlVqvF.js} +1 -1
  51. package/src/client/dist/spa/assets/{razor-DQrmL_Eu.js → razor-BHddvwQ2.js} +1 -1
  52. package/src/client/dist/spa/assets/{tsMode-DqHf0yz_.js → tsMode-BZkc6l5_.js} +1 -1
  53. package/src/client/dist/spa/assets/{typescript-C2OgYZBp.js → typescript-B0g45tCE.js} +1 -1
  54. package/src/client/dist/spa/assets/{xml-BD4Y8hNI.js → xml-CfmQqdO8.js} +1 -1
  55. package/src/client/dist/spa/assets/{yaml-dxD-q88Q.js → yaml-B1F9_T4A.js} +1 -1
  56. package/src/client/dist/spa/index.html +2 -2
  57. package/src/mcp-server/kobo-tasks-server.ts +1 -1
  58. package/src/client/dist/spa/assets/ActivityFeed-5m7j8KXA.css +0 -1
  59. package/src/client/dist/spa/assets/ActivityFeed-CWcNkgNz.js +0 -68
  60. package/src/client/dist/spa/assets/DiffViewer-BB0uMnDB.js +0 -2
  61. package/src/client/dist/spa/assets/MainLayout-Di4zIHM4.js +0 -2
  62. package/src/client/dist/spa/assets/MainLayout-Drv4zYbW.css +0 -1
  63. package/src/client/dist/spa/assets/QExpansionItem-dnmzWHId.js +0 -1
  64. package/src/client/dist/spa/assets/QMenu-C_LDF5uF.js +0 -1
  65. package/src/client/dist/spa/assets/QPage-0Qoiu-SJ.js +0 -1
  66. package/src/client/dist/spa/assets/QTooltip-DKxEdudV.js +0 -1
  67. package/src/client/dist/spa/assets/SettingsPage-2D1mycBC.js +0 -1
  68. package/src/client/dist/spa/assets/SettingsPage-CdWgxXBN.css +0 -1
  69. package/src/client/dist/spa/assets/WorkspacePage-D6ilyDGt.js +0 -4
  70. package/src/client/dist/spa/assets/WorkspacePage-DrDOZvHX.css +0 -1
  71. package/src/client/dist/spa/assets/_plugin-vue_export-helper-CLHv3piE.js +0 -1
  72. package/src/client/dist/spa/assets/i18n-BG0I8c3l.js +0 -1
  73. package/src/client/dist/spa/assets/i18n-BvyUMouA.js +0 -1
  74. 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.json().catch(() => ({ prompt: undefined }));
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, false, workspace.permissionMode);
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.getLatestSession(id);
923
- const sessionId = session?.claudeSessionId ?? undefined;
924
- wsService.emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, sessionId);
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.getLatestSession(workspace.id);
1058
- const sessionId = session?.claudeSessionId ?? undefined;
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.claudeSessionId);
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
- const lastSession = db
136
- .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')
137
- .get(workspaceId);
138
- const claudeSessionId = sessionIds.get(workspaceId) ?? lastSession?.claude_session_id;
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
- agentSessionId = nanoid();
164
- db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, started_at) VALUES (?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', new Date().toISOString());
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.claudeSessionId);
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.claudeSessionId);
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.claudeSessionId);
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.claudeSessionId);
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.claudeSessionId);
347
+ handleQuota(workspaceId, agent.agentSessionId);
319
348
  }
320
349
  // Also emit stderr for visibility
321
- emit(workspaceId, 'agent:stderr', { content: text }, agent.claudeSessionId);
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.claudeSessionId);
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.claudeSessionId);
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.claudeSessionId);
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.claudeSessionId);
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, claudeSessionId) {
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' }, claudeSessionId);
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
- }, claudeSessionId);
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
+ }