@loicngr/kobo 1.4.10 → 1.4.11

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 (73) 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 +14 -5
  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 +108 -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/workspace-service.js +49 -0
  13. package/dist/server/utils/git-ops.js +10 -0
  14. package/dist/server/utils/paths.js +7 -0
  15. package/package.json +1 -1
  16. package/src/client/dist/spa/assets/ActivityFeed-C8UZFjUM.css +1 -0
  17. package/src/client/dist/spa/assets/ActivityFeed-CahLjg_a.js +10 -0
  18. package/src/client/dist/spa/assets/ClosePopup-jSuaV6dg.js +1 -0
  19. package/src/client/dist/spa/assets/{CreatePage-3gOfOT7H.js → CreatePage-DvenHBbs.js} +2 -2
  20. package/src/client/dist/spa/assets/DiffViewer-BNB89NC_.js +2 -0
  21. package/src/client/dist/spa/assets/MainLayout-8hdHhbFX.css +1 -0
  22. package/src/client/dist/spa/assets/MainLayout-CRZ_pSUI.js +2 -0
  23. package/src/client/dist/spa/assets/QExpansionItem-D6zpEqBV.js +1 -0
  24. package/src/client/dist/spa/assets/{QList-CwbqTC_9.js → QList-oHuiVWr0.js} +1 -1
  25. package/src/client/dist/spa/assets/QMenu-67GqYx0C.js +1 -0
  26. package/src/client/dist/spa/assets/QPage-DE75SzRI.js +1 -0
  27. package/src/client/dist/spa/assets/SettingsPage-BIAu1N7t.js +1 -0
  28. package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +1 -0
  29. package/src/client/dist/spa/assets/{TouchPan-Dc-xrSIS.js → TouchPan-DuISf80E.js} +1 -1
  30. package/src/client/dist/spa/assets/WorkspacePage-ZtCuDedw.js +4 -0
  31. package/src/client/dist/spa/assets/WorkspacePage-l-yPyCj4.css +1 -0
  32. package/src/client/dist/spa/assets/_plugin-vue_export-helper-fkfRoKj2.js +1 -0
  33. package/src/client/dist/spa/assets/{cssMode-CWtayDKW.js → cssMode-CLNBNUPN.js} +1 -1
  34. package/src/client/dist/spa/assets/{editor.api-DeuH5Z7J.js → editor.api-BOAfcnN4.js} +1 -1
  35. package/src/client/dist/spa/assets/{editor.main-CHvDgl6E.js → editor.main-DHOeskpn.js} +3 -3
  36. package/src/client/dist/spa/assets/{freemarker2-3a7cf3Uc.js → freemarker2-BtrSj5Xy.js} +1 -1
  37. package/src/client/dist/spa/assets/{handlebars-BjDKZgtm.js → handlebars-Dc1FuhPx.js} +1 -1
  38. package/src/client/dist/spa/assets/{html-CxHAAoPw.js → html-CRa_T0ab.js} +1 -1
  39. package/src/client/dist/spa/assets/{htmlMode-DhXtvpCN.js → htmlMode-DiZaznA3.js} +1 -1
  40. package/src/client/dist/spa/assets/i18n-Chh7fA86.js +1 -0
  41. package/src/client/dist/spa/assets/i18n-EPkNuLUF.js +1 -0
  42. package/src/client/dist/spa/assets/index-BIsYlO92.js +5 -0
  43. package/src/client/dist/spa/assets/{javascript-CFSWGGGJ.js → javascript-BmWQzw3l.js} +1 -1
  44. package/src/client/dist/spa/assets/{jsonMode-k2An8_pW.js → jsonMode-CxTgSexO.js} +1 -1
  45. package/src/client/dist/spa/assets/{liquid-Cq4t-XTr.js → liquid-BUs2kSUC.js} +1 -1
  46. package/src/client/dist/spa/assets/marked.esm-BjjOHIBz.js +60 -0
  47. package/src/client/dist/spa/assets/{mdx-UB8RrpeD.js → mdx-Cnd0lVUN.js} +1 -1
  48. package/src/client/dist/spa/assets/{monaco.contribution-B9W_lLWp.js → monaco.contribution-DdEdriCG.js} +2 -2
  49. package/src/client/dist/spa/assets/{python-n8QHA1-s.js → python-DEYsKpRr.js} +1 -1
  50. package/src/client/dist/spa/assets/{razor-DQrmL_Eu.js → razor-D5u_QaVY.js} +1 -1
  51. package/src/client/dist/spa/assets/{tsMode-DqHf0yz_.js → tsMode-DWHAMuFy.js} +1 -1
  52. package/src/client/dist/spa/assets/{typescript-C2OgYZBp.js → typescript-miqJki5_.js} +1 -1
  53. package/src/client/dist/spa/assets/{xml-BD4Y8hNI.js → xml-TsOUFKqU.js} +1 -1
  54. package/src/client/dist/spa/assets/{yaml-dxD-q88Q.js → yaml-Bn4aEJci.js} +1 -1
  55. package/src/client/dist/spa/index.html +2 -2
  56. package/src/mcp-server/kobo-tasks-server.ts +1 -1
  57. package/src/client/dist/spa/assets/ActivityFeed-5m7j8KXA.css +0 -1
  58. package/src/client/dist/spa/assets/ActivityFeed-CWcNkgNz.js +0 -68
  59. package/src/client/dist/spa/assets/DiffViewer-BB0uMnDB.js +0 -2
  60. package/src/client/dist/spa/assets/MainLayout-Di4zIHM4.js +0 -2
  61. package/src/client/dist/spa/assets/MainLayout-Drv4zYbW.css +0 -1
  62. package/src/client/dist/spa/assets/QExpansionItem-dnmzWHId.js +0 -1
  63. package/src/client/dist/spa/assets/QMenu-C_LDF5uF.js +0 -1
  64. package/src/client/dist/spa/assets/QPage-0Qoiu-SJ.js +0 -1
  65. package/src/client/dist/spa/assets/QTooltip-DKxEdudV.js +0 -1
  66. package/src/client/dist/spa/assets/SettingsPage-2D1mycBC.js +0 -1
  67. package/src/client/dist/spa/assets/SettingsPage-CdWgxXBN.css +0 -1
  68. package/src/client/dist/spa/assets/WorkspacePage-D6ilyDGt.js +0 -4
  69. package/src/client/dist/spa/assets/WorkspacePage-DrDOZvHX.css +0 -1
  70. package/src/client/dist/spa/assets/_plugin-vue_export-helper-CLHv3piE.js +0 -1
  71. package/src/client/dist/spa/assets/i18n-BG0I8c3l.js +0 -1
  72. package/src/client/dist/spa/assets/i18n-BvyUMouA.js +0 -1
  73. package/src/client/dist/spa/assets/index-Dh7opLow.js +0 -5
package/AGENTS.md CHANGED
@@ -16,9 +16,9 @@ Single-user, single-machine dev tool. No auth, no multi-tenant concerns.
16
16
 
17
17
  **Database** — Single SQLite file under the **Kōbō home directory** (`~/.config/kobo/kobo.db` by default, overridable via `KOBO_HOME`). Fresh-install schema lives in `src/server/db/schema.ts` (`initSchema`); incremental migrations live in `src/server/db/migrations.ts`. **The project is in production** — every schema change MUST ship as a migration that preserves data, never as a breaking change to `initSchema` alone. See [Database migrations](#database-migrations) below.
18
18
 
19
- **Kōbō home directory** — `KOBO_HOME` env var overrides everything. Otherwise `$XDG_CONFIG_HOME/kobo/`, else `~/.config/kobo/`. Contains `kobo.db`, `settings.json`, `skills.json`. **Development uses `./data/`** via the `KOBO_HOME=./data` prefix in the `dev` npm script, so local dev never touches your real `~/.config/kobo/` and can run in parallel with a production-installed Kōbō (`npx @loicngr/kobo`). See `src/server/utils/paths.ts`.
19
+ **Kōbō home directory** — `KOBO_HOME` env var overrides everything. Otherwise `$XDG_CONFIG_HOME/kobo/`, else `~/.config/kobo/`. Contains `kobo.db`, `settings.json`, `skills.json`, `templates.json`. **Development uses `./data/`** via the `KOBO_HOME=./data` prefix in the `dev` npm script, so local dev never touches your real `~/.config/kobo/` and can run in parallel with a production-installed Kōbō (`npx @loicngr/kobo`). See `src/server/utils/paths.ts`.
20
20
 
21
- **Tests** — vitest (18 test files, 355+ tests at time of writing). No frontend test infrastructure.
21
+ **Tests** — vitest (24 backend test files, 544+ tests; 5 frontend test files, 45+ tests at time of writing). Frontend tests cover Pinia stores and pure utility modules; Vue components are not tested (type-check + manual smoke only).
22
22
 
23
23
  ## Commands
24
24
 
@@ -33,8 +33,10 @@ npm run dev:client # frontend only (quasar dev)
33
33
  npm run dev:all # both concurrently
34
34
 
35
35
  # Check & test
36
- npx tsc --noEmit # type check (the project's primary quality gate)
37
- npm test # run full vitest suite
36
+ npm run lint # biome check (linting + formatting)
37
+ npx tsc --noEmit # type check backend (the project's primary quality gate)
38
+ npm test # run full vitest suite (backend + client tests)
39
+ (cd src/client && npm test) # client tests only (stores, utils)
38
40
  npm run test:watch # vitest in watch mode
39
41
 
40
42
  # Build & run
@@ -56,7 +58,8 @@ src/
56
58
  │ │ └── migrations.ts # incremental migrations, bumped per feature
57
59
  │ ├── services/ # business logic — pure functions over db + external processes
58
60
  │ │ ├── workspace-service.ts # workspaces + tasks + agent_sessions CRUD
59
- │ │ ├── agent-manager.ts # spawns Claude Code CLI, streams stdout, tracks sessions
61
+ │ │ ├── agent-manager.ts # spawns Claude Code CLI, streams stdout, tracks sessions, interrupt/stop
62
+ │ │ ├── templates-service.ts # prompt templates CRUD (JSON file persistence, seeding)
60
63
  │ │ ├── dev-server-service.ts # per-workspace dev server lifecycle (docker or npm process)
61
64
  │ │ ├── websocket-service.ts # emit / emitEphemeral to subscribed clients
62
65
  │ │ ├── worktree-service.ts # git worktree create/remove
@@ -65,14 +68,17 @@ src/
65
68
  │ │ └── pr-template-service.ts # pure template variable substitution
66
69
  │ ├── routes/ # Hono handlers, thin layer over services
67
70
  │ │ ├── workspaces.ts # /api/workspaces/* — the main surface
71
+ │ │ ├── templates.ts # /api/templates — prompt templates CRUD
72
+ │ │ ├── plans.ts # /api/workspaces/:id/plans — plan file browser (read-only)
68
73
  │ │ ├── dev-server.ts, git.ts, notion.ts, settings.ts
69
74
  │ ├── utils/
70
- │ │ ├── git-ops.ts # pushBranch, getCommitsBetween, delete{Local,Remote}Branch…
75
+ │ │ ├── git-ops.ts # pushBranch, pullBranch, getCommitsBetween, delete{Local,Remote}Branch…
71
76
  │ │ └── process-tracker.ts # per-workspace spawned-process map
72
77
  ├── client/ # Vue 3 + Quasar SPA
73
78
  │ └── src/
74
- │ ├── stores/ # pinia: workspace, websocket, settings, dev-server
75
- │ ├── components/ # WorkspaceList, NotionPanel, AcceptancePanel, ChatInput, GitPanel…
79
+ │ ├── stores/ # pinia: workspace, websocket, settings, dev-server, templates
80
+ │ ├── components/ # WorkspaceList, NotionPanel, AcceptancePanel, ChatInput, GitPanel, PlansPanel
81
+ │ ├── utils/ # expand-template (template variable substitution), formatters…
76
82
  │ ├── pages/ # WorkspacePage, CreatePage, SettingsPage
77
83
  │ └── router/
78
84
  ├── mcp-server/ # standalone MCP server spawned per workspace
@@ -87,7 +93,7 @@ src/
87
93
  |---|---|
88
94
  | `workspaces` | the unit of work — id, name, project_path, source_branch, working_branch, status, notion_url, model, dev_server_status, `archived_at`, timestamps |
89
95
  | `tasks` | workspace sub-items — title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
90
- | `agent_sessions` | Claude Code CLI invocations — pid, `claude_session_id`, status, started_at, ended_at |
96
+ | `agent_sessions` | Claude Code CLI invocations — pid, `claude_session_id`, status, started_at, ended_at, `name` |
91
97
  | `ws_events` | persisted WebSocket events for replay on reconnect — type, payload, session_id, created_at |
92
98
 
93
99
  `status` enum: `created | extracting | brainstorming | executing | completed | idle | error | quota`. Transitions are validated in `updateWorkspaceStatus` against `VALID_TRANSITIONS`.
@@ -188,7 +194,7 @@ The frontend uses `vue-i18n` v10 with 5 supported locales: English (`en`), Frenc
188
194
 
189
195
  - **TDD for backend** — write the failing test, confirm it fails for the right reason, implement minimally, confirm it passes, commit. One commit per logical unit. See existing tests in `src/__tests__/workspace-service.test.ts` for the setup pattern (fresh in-memory DB per test via `resetDb()`).
190
196
  - **Route tests** use `vi.mock()` on service modules before imports (see `src/__tests__/routes-workspaces.test.ts`). Keep mocks complete — missing exports cause obscure failures.
191
- - **No frontend test infra.** Type-check via `npx tsc --noEmit` is the frontend gate. Manual smoke testing covers UI behavior.
197
+ - **Frontend tests** cover Pinia stores (`workspace-store`, `settings-store`, `templates-store`) and pure utility modules (`expand-template`). Run with `cd src/client && npm test`. Vue components are **not** tested — type-check via `npx tsc --noEmit` + manual smoke testing covers UI behavior.
192
198
  - **`beforeEach(() => vi.clearAllMocks())`** is the convention for all route test files.
193
199
 
194
200
  ## Git workflow
package/README.md CHANGED
@@ -17,7 +17,11 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
17
17
  - **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria
18
18
  - **Per-workspace dev servers** — start/stop Docker or Node dev servers scoped to each branch, with log streaming
19
19
  - **Conventional-commit enforcement** — project-level git conventions are written to `.ai/.git-conventions.md` inside every workspace so Claude follows them during commits
20
- - **Pull request automation** — one-click `push` and `open-pr` endpoints integrate with the GitHub CLI, using a configurable prompt template
20
+ - **Pull request automation** — one-click `push`, `pull`, and `open-pr` endpoints integrate with the GitHub CLI, using a configurable prompt template
21
+ - **Multi-session support** — create multiple Claude agent sessions per workspace, each with its own chat history; resume completed sessions via `--resume`; sessions are named and persisted in localStorage
22
+ - **Prompt templates** — personal library of reusable prompts with variable substitution (`{working_branch}`, `{commit_count}`, etc.), insertable from the chat input via `/` autocomplete; editable in Settings > Templates
23
+ - **Plan browser** — read-only viewer for markdown plan files produced by agents, rendered directly in the right-side panel
24
+ - **Soft interrupt** — pause an agent mid-execution (SIGINT, like pressing Escape in Claude Code) without killing the process; the agent stops the current tool and waits for the next message
21
25
  - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
22
26
 
23
27
  ## Tech stack
@@ -319,7 +319,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
319
319
  if (fs.existsSync(thoughtsDir)) {
320
320
  const files = fs.readdirSync(thoughtsDir).filter((f) => f.endsWith('.md'));
321
321
  for (const file of files) {
322
- ticketContent += fs.readFileSync(path.join(thoughtsDir, file), 'utf-8') + '\n';
322
+ ticketContent += `${fs.readFileSync(path.join(thoughtsDir, file), 'utf-8')}\n`;
323
323
  }
324
324
  }
325
325
  return ok({
@@ -26,6 +26,37 @@ export const migrations = [
26
26
  db.prepare('ALTER TABLE workspaces ADD COLUMN has_unread INTEGER NOT NULL DEFAULT 0').run();
27
27
  },
28
28
  },
29
+ {
30
+ version: 5,
31
+ name: 'add-agent-session-name',
32
+ migrate: (db) => {
33
+ db.exec('ALTER TABLE agent_sessions ADD COLUMN name TEXT');
34
+ },
35
+ },
36
+ {
37
+ version: 6,
38
+ name: 'backfill-ws-events-session-id',
39
+ migrate: (db) => {
40
+ // Before this release, ws_events.session_id stored the Claude-generated
41
+ // session ID. The frontend now filters by agent_sessions.id (internal nanoid),
42
+ // so existing rows are invisible in the activity feed. Rewrite any
43
+ // ws_events.session_id that matches an agent_sessions.claude_session_id to
44
+ // the corresponding agent_sessions.id.
45
+ db.exec(`
46
+ UPDATE ws_events
47
+ SET session_id = (
48
+ SELECT a.id FROM agent_sessions a
49
+ WHERE a.claude_session_id = ws_events.session_id
50
+ LIMIT 1
51
+ )
52
+ WHERE session_id IS NOT NULL
53
+ AND EXISTS (
54
+ SELECT 1 FROM agent_sessions a
55
+ WHERE a.claude_session_id = ws_events.session_id
56
+ )
57
+ `);
58
+ },
59
+ },
29
60
  ];
30
61
  /** Current schema version — always equals the highest migration version. */
31
62
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -37,7 +37,8 @@ export function initSchema(db) {
37
37
  claude_session_id TEXT,
38
38
  status TEXT NOT NULL DEFAULT 'running',
39
39
  started_at TEXT NOT NULL,
40
- ended_at TEXT
40
+ ended_at TEXT,
41
+ name TEXT
41
42
  );
42
43
 
43
44
  CREATE TABLE IF NOT EXISTS ws_events (
@@ -11,13 +11,15 @@ import devServerRouter from './routes/dev-server.js';
11
11
  import gitRouter from './routes/git.js';
12
12
  import imagesRouter from './routes/images.js';
13
13
  import notionRouter from './routes/notion.js';
14
+ import plansRouter from './routes/plans.js';
14
15
  import settingsRouter from './routes/settings.js';
16
+ import templatesRouter from './routes/templates.js';
15
17
  import workspacesRouter from './routes/workspaces.js';
16
18
  import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent-manager.js';
17
19
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
18
20
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
19
21
  import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
20
- import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
22
+ import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
21
23
  import { getClientSpaPath, getKoboHome, getPackageVersion } from './utils/paths.js';
22
24
  import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
23
25
  // Runtime prerequisite check — warn if the claude CLI is missing. Don't block
@@ -47,6 +49,8 @@ app.route('/api/notion', notionRouter);
47
49
  app.route('/api/git', gitRouter);
48
50
  app.route('/api/settings', settingsRouter);
49
51
  app.route('/api/dev-server', devServerRouter);
52
+ app.route('/api/templates', templatesRouter);
53
+ app.route('/api/workspaces', plansRouter);
50
54
  // Skills endpoint
51
55
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
52
56
  const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
@@ -108,19 +112,24 @@ wss.on('connection', (ws) => {
108
112
  setMessageHandler((type, payload) => {
109
113
  const p = payload;
110
114
  if (type === 'chat:message' && p?.workspaceId && p?.content) {
115
+ // Prefer the session explicitly selected by the client (sessionId hint),
116
+ // falling back to the running/most-recent non-idle session so idle sessions
117
+ // never steal the tagging.
118
+ const activeSession = getActiveSession(p.workspaceId);
119
+ const sessionTag = p.sessionId ?? activeSession?.id ?? undefined;
111
120
  // Persist user message so it survives page refresh
112
- const latestSession = getLatestSession(p.workspaceId);
113
- emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, latestSession?.claudeSessionId ?? undefined);
121
+ emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, sessionTag);
114
122
  try {
115
123
  sendMessage(p.workspaceId, p.content);
116
124
  }
117
125
  catch {
118
- // Agent not running — resume the existing session
126
+ // Agent not running — resume the session hinted by the client if any,
127
+ // otherwise the most-recent active session.
119
128
  try {
120
129
  const workspace = getWorkspace(p.workspaceId);
121
130
  if (workspace) {
122
131
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
123
- startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, workspace.permissionMode);
132
+ startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, workspace.permissionMode, p.sessionId);
124
133
  updateWorkspaceStatus(p.workspaceId, 'executing');
125
134
  }
126
135
  }
@@ -0,0 +1,89 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Hono } from 'hono';
4
+ import * as workspaceService from '../services/workspace-service.js';
5
+ /** Hono sub-router for workspace plan file browsing (read-only). */
6
+ const app = new Hono();
7
+ /** Directories inside the worktree where plan files may live. */
8
+ const PLAN_DIRS = ['docs/plans', 'docs/superpowers/plans'];
9
+ /** Only .md files are listed. */
10
+ const MD_EXT = '.md';
11
+ // GET /:id/plans — list plan files in the workspace worktree
12
+ app.get('/:id/plans', (c) => {
13
+ try {
14
+ const id = c.req.param('id');
15
+ const workspace = workspaceService.getWorkspace(id);
16
+ if (!workspace) {
17
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
18
+ }
19
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
20
+ const plans = [];
21
+ for (const dir of PLAN_DIRS) {
22
+ const absDir = path.join(worktreePath, dir);
23
+ if (!existsSync(absDir))
24
+ continue;
25
+ try {
26
+ const entries = readdirSync(absDir);
27
+ for (const entry of entries) {
28
+ if (!entry.endsWith(MD_EXT))
29
+ continue;
30
+ try {
31
+ const absFile = path.join(absDir, entry);
32
+ const stat = statSync(absFile);
33
+ if (!stat.isFile())
34
+ continue;
35
+ plans.push({
36
+ path: `${dir}/${entry}`,
37
+ name: entry,
38
+ modifiedAt: stat.mtime.toISOString(),
39
+ });
40
+ }
41
+ catch {
42
+ // Skip files we can't stat
43
+ }
44
+ }
45
+ }
46
+ catch {
47
+ // Skip directories we can't read
48
+ }
49
+ }
50
+ // Sort by modifiedAt descending (most recent first)
51
+ plans.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
52
+ return c.json({ plans });
53
+ }
54
+ catch (err) {
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ return c.json({ error: message }, 500);
57
+ }
58
+ });
59
+ // GET /:id/plan-file?path=<relative> — read a single plan file
60
+ app.get('/:id/plan-file', (c) => {
61
+ try {
62
+ const id = c.req.param('id');
63
+ const filePath = c.req.query('path');
64
+ if (!filePath) {
65
+ return c.json({ error: 'Missing path query parameter' }, 400);
66
+ }
67
+ const workspace = workspaceService.getWorkspace(id);
68
+ if (!workspace) {
69
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
70
+ }
71
+ // Security: normalize the path and verify it stays within allowed directories
72
+ const normalized = path.normalize(filePath);
73
+ if (normalized.includes('..') || !PLAN_DIRS.some((dir) => normalized.startsWith(dir))) {
74
+ return c.json({ error: 'Invalid path: must be under docs/plans/ or docs/superpowers/plans/' }, 400);
75
+ }
76
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
77
+ const absPath = path.join(worktreePath, normalized);
78
+ if (!existsSync(absPath)) {
79
+ return c.json({ error: `Plan file not found: ${normalized}` }, 404);
80
+ }
81
+ const content = readFileSync(absPath, 'utf-8');
82
+ return c.json({ content, path: normalized });
83
+ }
84
+ catch (err) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ return c.json({ error: message }, 500);
87
+ }
88
+ });
89
+ export default app;
@@ -0,0 +1,82 @@
1
+ import { Hono } from 'hono';
2
+ import * as templatesService from '../services/templates-service.js';
3
+ /** Hono sub-router for prompt templates CRUD. */
4
+ const app = new Hono();
5
+ // GET /api/templates — list all templates
6
+ app.get('/', (c) => {
7
+ try {
8
+ return c.json({ templates: templatesService.listTemplates() });
9
+ }
10
+ catch (err) {
11
+ const message = err instanceof Error ? err.message : String(err);
12
+ return c.json({ error: message }, 500);
13
+ }
14
+ });
15
+ /**
16
+ * Classify a service error into the correct HTTP status based on the error
17
+ * message markers exported by templates-service.ts. Unknown / I/O errors
18
+ * fall through to 500 so disk-full or permission failures are not disguised
19
+ * as client errors.
20
+ */
21
+ function statusForServiceError(message) {
22
+ if (message.includes('already exists'))
23
+ return 409;
24
+ if (message.startsWith('Invalid '))
25
+ return 400;
26
+ return 500;
27
+ }
28
+ // POST /api/templates — create a new template
29
+ app.post('/', async (c) => {
30
+ try {
31
+ const body = await c.req
32
+ .json()
33
+ .catch(() => ({}));
34
+ if (!body.slug || !body.description || !body.content) {
35
+ return c.json({ error: 'slug, description, and content are required' }, 400);
36
+ }
37
+ const template = templatesService.createTemplate({
38
+ slug: body.slug,
39
+ description: body.description,
40
+ content: body.content,
41
+ });
42
+ return c.json(template, 201);
43
+ }
44
+ catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ return c.json({ error: message }, statusForServiceError(message));
47
+ }
48
+ });
49
+ // PATCH /api/templates/:slug — update description and/or content
50
+ app.patch('/:slug', async (c) => {
51
+ try {
52
+ const slug = c.req.param('slug');
53
+ const body = await c.req
54
+ .json()
55
+ .catch(() => ({}));
56
+ const updated = templatesService.updateTemplate(slug, body);
57
+ if (!updated) {
58
+ return c.json({ error: `Template '${slug}' not found` }, 404);
59
+ }
60
+ return c.json(updated);
61
+ }
62
+ catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ return c.json({ error: message }, statusForServiceError(message));
65
+ }
66
+ });
67
+ // DELETE /api/templates/:slug — delete a template
68
+ app.delete('/:slug', (c) => {
69
+ try {
70
+ const slug = c.req.param('slug');
71
+ const ok = templatesService.deleteTemplate(slug);
72
+ if (!ok) {
73
+ return c.json({ error: `Template '${slug}' not found` }, 404);
74
+ }
75
+ return c.json({ ok: true });
76
+ }
77
+ catch (err) {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ return c.json({ error: message }, 500);
80
+ }
81
+ });
82
+ export default app;
@@ -286,10 +286,11 @@ app.post('/', async (c) => {
286
286
  }
287
287
  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
288
  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
289
  try {
292
- agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
290
+ const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model);
291
+ // Persist the initial prompt in the feed so it's visible in the chat,
292
+ // tagged with the freshly created session id so the strict session filter shows it.
293
+ wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' }, agent.agentSessionId);
293
294
  }
294
295
  catch (err) {
295
296
  const message = err instanceof Error ? err.message : String(err);
@@ -311,6 +312,28 @@ app.post('/', async (c) => {
311
312
  return c.json({ error: message }, 500);
312
313
  }
313
314
  });
315
+ // POST /api/workspaces/:id/sessions — create a new idle agent session
316
+ app.post('/:id/sessions', (c) => {
317
+ try {
318
+ const id = c.req.param('id');
319
+ const workspace = workspaceService.getWorkspace(id);
320
+ if (!workspace) {
321
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
322
+ }
323
+ if (workspace.archivedAt) {
324
+ return c.json({ error: `Workspace '${id}' is archived` }, 400);
325
+ }
326
+ if (agentManager.getAgentStatus(id) !== null) {
327
+ return c.json({ error: 'An agent is already running for this workspace' }, 409);
328
+ }
329
+ const session = workspaceService.createIdleSession(id);
330
+ return c.json(session, 201);
331
+ }
332
+ catch (err) {
333
+ const message = err instanceof Error ? err.message : String(err);
334
+ return c.json({ error: message }, 500);
335
+ }
336
+ });
314
337
  // GET /api/workspaces/:id/sessions — list sessions for a workspace
315
338
  app.get('/:id/sessions', (c) => {
316
339
  try {
@@ -326,6 +349,30 @@ app.get('/:id/sessions', (c) => {
326
349
  return c.json({ error: message }, 500);
327
350
  }
328
351
  });
352
+ // PATCH /api/workspaces/:id/sessions/:sessionId — rename a session
353
+ app.patch('/:id/sessions/:sessionId', async (c) => {
354
+ try {
355
+ const id = c.req.param('id');
356
+ const sessionId = c.req.param('sessionId');
357
+ const workspace = workspaceService.getWorkspace(id);
358
+ if (!workspace) {
359
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
360
+ }
361
+ const body = await c.req.json().catch(() => ({}));
362
+ if (!body.name?.trim()) {
363
+ return c.json({ error: 'name is required and must not be empty' }, 400);
364
+ }
365
+ const updated = workspaceService.renameSession(sessionId, id, body.name.trim());
366
+ if (!updated) {
367
+ return c.json({ error: `Session '${sessionId}' not found` }, 404);
368
+ }
369
+ return c.json(updated);
370
+ }
371
+ catch (err) {
372
+ const message = err instanceof Error ? err.message : String(err);
373
+ return c.json({ error: message }, 500);
374
+ }
375
+ });
329
376
  // POST /api/workspaces/:id/refresh-notion — re-extract Notion page and update tasks
330
377
  app.post('/:id/refresh-notion', async (c) => {
331
378
  try {
@@ -810,8 +857,12 @@ app.post('/:id/start', async (c) => {
810
857
  if (!workspace) {
811
858
  return c.json({ error: `Workspace '${id}' not found` }, 404);
812
859
  }
813
- const body = await c.req.json().catch(() => ({ prompt: undefined }));
860
+ const body = await c.req
861
+ .json()
862
+ .catch(() => ({ prompt: undefined, agentSessionId: undefined, resume: undefined }));
814
863
  const prompt = body.prompt ?? 'Continue the previous task where you left off.';
864
+ const agentSessionId = body.agentSessionId;
865
+ const resume = body.resume === true;
815
866
  // Stop existing agent if running
816
867
  try {
817
868
  agentManager.stopAgent(id);
@@ -820,8 +871,14 @@ app.post('/:id/start', async (c) => {
820
871
  // Agent may not be running — ignore
821
872
  }
822
873
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
823
- agentManager.startAgent(id, worktreePath, prompt, workspace.model, false, workspace.permissionMode);
874
+ const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId);
824
875
  workspaceService.updateWorkspaceStatus(id, 'executing');
876
+ // Persist the user prompt so it survives page refresh.
877
+ // When agentSessionId is provided (idle-session flow), the prompt was typed
878
+ // by the user in the chat input; otherwise it's the workspace start prompt.
879
+ if (body.prompt) {
880
+ wsService.emit(id, 'user:message', { content: body.prompt, sender: 'user' }, agent.agentSessionId);
881
+ }
825
882
  return c.json({ status: 'started' });
826
883
  }
827
884
  catch (err) {
@@ -919,9 +976,34 @@ app.post('/:id/push', async (c) => {
919
976
  return c.json({ error: message }, 500);
920
977
  }
921
978
  // 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);
979
+ const session = workspaceService.getActiveSession(id);
980
+ wsService.emit(id, 'user:message', { content: `Pushed branch ${workspace.workingBranch} to origin`, sender: 'system-prompt' }, session?.id ?? undefined);
981
+ return c.json({ ok: true, branch: workspace.workingBranch });
982
+ }
983
+ catch (err) {
984
+ const message = err instanceof Error ? err.message : String(err);
985
+ return c.json({ error: message }, 500);
986
+ }
987
+ });
988
+ // POST /api/workspaces/:id/pull — pull working branch from origin (fast-forward only)
989
+ app.post('/:id/pull', (c) => {
990
+ try {
991
+ const id = c.req.param('id');
992
+ const workspace = workspaceService.getWorkspace(id);
993
+ if (!workspace) {
994
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
995
+ }
996
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
997
+ try {
998
+ gitOps.pullBranch(worktreePath, workspace.workingBranch);
999
+ }
1000
+ catch (err) {
1001
+ const message = err instanceof Error ? err.message : String(err);
1002
+ return c.json({ error: message }, 500);
1003
+ }
1004
+ // Emit a trace into the chat feed so the user sees the action
1005
+ const session = workspaceService.getActiveSession(id);
1006
+ wsService.emit(id, 'user:message', { content: `Pulled branch ${workspace.workingBranch} from origin`, sender: 'system-prompt' }, session?.id ?? undefined);
925
1007
  return c.json({ ok: true, branch: workspace.workingBranch });
926
1008
  }
927
1009
  catch (err) {
@@ -1054,9 +1136,8 @@ app.post('/:id/open-pr', async (c) => {
1054
1136
  tasks,
1055
1137
  });
1056
1138
  // 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);
1139
+ const session = workspaceService.getActiveSession(workspace.id);
1140
+ wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, session?.id ?? undefined);
1060
1141
  // Send to the running agent, or resume the agent with the PR prompt
1061
1142
  let messageSent = false;
1062
1143
  try {
@@ -1128,4 +1209,20 @@ app.post('/:id/stop', (c) => {
1128
1209
  return c.json({ error: message }, 500);
1129
1210
  }
1130
1211
  });
1212
+ // POST /api/workspaces/:id/interrupt — soft-interrupt agent (SIGINT, like Escape in Claude Code)
1213
+ app.post('/:id/interrupt', (c) => {
1214
+ try {
1215
+ const id = c.req.param('id');
1216
+ const workspace = workspaceService.getWorkspace(id);
1217
+ if (!workspace) {
1218
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1219
+ }
1220
+ agentManager.interruptAgent(id);
1221
+ return c.json({ status: 'interrupted' });
1222
+ }
1223
+ catch (err) {
1224
+ const message = err instanceof Error ? err.message : String(err);
1225
+ return c.json({ error: message }, 500);
1226
+ }
1227
+ });
1131
1228
  export default app;