@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
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 (
@@ -4,20 +4,23 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { serve } from '@hono/node-server';
6
6
  import { Hono } from 'hono';
7
- import { WebSocketServer } from 'ws';
7
+ import WebSocket, { WebSocketServer } from 'ws';
8
8
  import { closeDb, getDb } from './db/index.js';
9
9
  import { runMigrations } from './db/migrations.js';
10
10
  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';
21
+ import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
19
22
  import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
20
- import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
23
+ import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
21
24
  import { getClientSpaPath, getKoboHome, getPackageVersion } from './utils/paths.js';
22
25
  import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
23
26
  // Runtime prerequisite check — warn if the claude CLI is missing. Don't block
@@ -47,6 +50,8 @@ app.route('/api/notion', notionRouter);
47
50
  app.route('/api/git', gitRouter);
48
51
  app.route('/api/settings', settingsRouter);
49
52
  app.route('/api/dev-server', devServerRouter);
53
+ app.route('/api/templates', templatesRouter);
54
+ app.route('/api/workspaces', plansRouter);
50
55
  // Skills endpoint
51
56
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
52
57
  const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
@@ -100,27 +105,117 @@ const server = serve({
100
105
  });
101
106
  // Create WebSocketServer attached to the HTTP server
102
107
  const wss = new WebSocketServer({ noServer: true });
108
+ const terminalWss = new WebSocketServer({ noServer: true });
103
109
  // Wire WebSocket connections to websocket-service.handleConnection()
104
110
  wss.on('connection', (ws) => {
105
111
  handleConnection(ws);
106
112
  });
113
+ // Wire terminal WebSocket connections
114
+ terminalWss.on('connection', (ws, workspaceId) => {
115
+ let currentPty = getTerminal(workspaceId);
116
+ let dataDisposable = null;
117
+ let exitDisposable = null;
118
+ function attachListeners(ptyInstance) {
119
+ // Dispose previous listeners to avoid stacking on reconnect
120
+ dataDisposable?.dispose();
121
+ exitDisposable?.dispose();
122
+ dataDisposable = ptyInstance.onData((output) => {
123
+ if (ws.readyState === WebSocket.OPEN) {
124
+ ws.send(Buffer.from(output), { binary: true });
125
+ }
126
+ });
127
+ exitDisposable = ptyInstance.onExit(({ exitCode }) => {
128
+ if (ws.readyState === WebSocket.OPEN) {
129
+ ws.send(JSON.stringify({ type: 'exited', code: exitCode }));
130
+ ws.close();
131
+ }
132
+ });
133
+ }
134
+ ws.on('close', () => {
135
+ dataDisposable?.dispose();
136
+ exitDisposable?.dispose();
137
+ dataDisposable = null;
138
+ exitDisposable = null;
139
+ });
140
+ ws.on('error', (err) => {
141
+ console.error(`[terminal] WebSocket error for workspace ${workspaceId}:`, err);
142
+ });
143
+ ws.on('message', (data, isBinary) => {
144
+ if (isBinary) {
145
+ if (currentPty) {
146
+ currentPty.write(data.toString());
147
+ }
148
+ return;
149
+ }
150
+ let msg;
151
+ try {
152
+ msg = JSON.parse(data.toString());
153
+ }
154
+ catch {
155
+ return; // Invalid JSON — ignore
156
+ }
157
+ if (msg.type === 'create') {
158
+ if (!currentPty) {
159
+ const workspace = getWorkspace(workspaceId);
160
+ if (!workspace) {
161
+ ws.send(JSON.stringify({ type: 'error', message: 'Workspace not found' }));
162
+ return;
163
+ }
164
+ if (workspace.archivedAt) {
165
+ ws.send(JSON.stringify({ type: 'error', message: 'Workspace is archived' }));
166
+ return;
167
+ }
168
+ const cwd = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
169
+ try {
170
+ currentPty = createTerminal(workspaceId, cwd);
171
+ }
172
+ catch (err) {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ ws.send(JSON.stringify({ type: 'error', message }));
175
+ return;
176
+ }
177
+ }
178
+ attachListeners(currentPty);
179
+ ws.send(JSON.stringify({ type: 'ready' }));
180
+ return;
181
+ }
182
+ if (msg.type === 'resize' && msg.cols && msg.rows) {
183
+ if (currentPty) {
184
+ const cols = Math.max(1, Math.floor(msg.cols));
185
+ const rows = Math.max(1, Math.floor(msg.rows));
186
+ try {
187
+ currentPty.resize(cols, rows);
188
+ }
189
+ catch (err) {
190
+ console.error(`[terminal] resize failed for workspace ${workspaceId}:`, err);
191
+ }
192
+ }
193
+ return;
194
+ }
195
+ });
196
+ });
107
197
  // Wire websocket-service message handler to agent-manager
108
198
  setMessageHandler((type, payload) => {
109
199
  const p = payload;
110
200
  if (type === 'chat:message' && p?.workspaceId && p?.content) {
201
+ // Prefer the session explicitly selected by the client (sessionId hint),
202
+ // falling back to the running/most-recent non-idle session so idle sessions
203
+ // never steal the tagging.
204
+ const activeSession = getActiveSession(p.workspaceId);
205
+ const sessionTag = p.sessionId ?? activeSession?.id ?? undefined;
111
206
  // 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);
207
+ emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, sessionTag);
114
208
  try {
115
209
  sendMessage(p.workspaceId, p.content);
116
210
  }
117
211
  catch {
118
- // Agent not running — resume the existing session
212
+ // Agent not running — resume the session hinted by the client if any,
213
+ // otherwise the most-recent active session.
119
214
  try {
120
215
  const workspace = getWorkspace(p.workspaceId);
121
216
  if (workspace) {
122
217
  const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
123
- startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, workspace.permissionMode);
218
+ startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, workspace.permissionMode, p.sessionId);
124
219
  updateWorkspaceStatus(p.workspaceId, 'executing');
125
220
  }
126
221
  }
@@ -177,6 +272,16 @@ server.on('upgrade', (request, socket, head) => {
177
272
  wss.emit('connection', ws, request);
178
273
  });
179
274
  }
275
+ else if (pathname.startsWith('/ws/terminal/')) {
276
+ const workspaceId = pathname.slice('/ws/terminal/'.length);
277
+ if (!workspaceId) {
278
+ socket.destroy();
279
+ return;
280
+ }
281
+ terminalWss.handleUpgrade(request, socket, head, (ws) => {
282
+ terminalWss.emit('connection', ws, workspaceId);
283
+ });
284
+ }
180
285
  else {
181
286
  socket.destroy();
182
287
  }
@@ -192,6 +297,14 @@ function gracefulShutdown(signal) {
192
297
  wss.close(() => {
193
298
  console.log('[kobo] WebSocket server closed');
194
299
  });
300
+ terminalWss.close();
301
+ try {
302
+ destroyAllTerminals();
303
+ console.log('[kobo] Terminals killed');
304
+ }
305
+ catch {
306
+ // Best-effort
307
+ }
195
308
  server.close(() => {
196
309
  console.log('[kobo] HTTP server closed');
197
310
  });
@@ -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;