@loicngr/kobo 1.4.10 → 1.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +16 -10
- package/README.md +5 -1
- package/dist/mcp-server/kobo-tasks-server.js +1 -1
- package/dist/server/db/migrations.js +31 -0
- package/dist/server/db/schema.js +2 -1
- package/dist/server/index.js +119 -6
- package/dist/server/routes/plans.js +89 -0
- package/dist/server/routes/templates.js +82 -0
- package/dist/server/routes/workspaces.js +121 -11
- package/dist/server/services/agent-manager.js +74 -21
- package/dist/server/services/templates-service.js +173 -0
- package/dist/server/services/terminal-service.js +44 -0
- package/dist/server/services/workspace-service.js +49 -0
- package/dist/server/utils/git-ops.js +10 -0
- package/dist/server/utils/paths.js +7 -0
- package/package.json +2 -1
- package/src/client/dist/spa/assets/ActivityFeed-CgZWB0-0.js +10 -0
- package/src/client/dist/spa/assets/ActivityFeed-ko_rO-2M.css +1 -0
- package/src/client/dist/spa/assets/ClosePopup-jSuaV6dg.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-3gOfOT7H.js → CreatePage-PV9pH1-i.js} +2 -2
- package/src/client/dist/spa/assets/DiffViewer-eROI3K7I.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-BKRJcjMN.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-w7f2vykG.js +37 -0
- package/src/client/dist/spa/assets/QExpansionItem-KaA2BBuV.js +1 -0
- package/src/client/dist/spa/assets/{QList-CwbqTC_9.js → QList-lLY9TMz0.js} +1 -1
- package/src/client/dist/spa/assets/QMenu-IaCFq9U1.js +1 -0
- package/src/client/dist/spa/assets/QPage-D5bnOxz0.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-tK2WhdGt.js +1 -0
- package/src/client/dist/spa/assets/{TouchPan-Dc-xrSIS.js → TouchPan-DuISf80E.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-B7LByhzW.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-XUHKUmuQ.css +1 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-fkfRoKj2.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-CWtayDKW.js → cssMode-DxID_rwU.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DeuH5Z7J.js → editor.api-6HHa4uDw.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-CHvDgl6E.js → editor.main-BY_NEoTM.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-3a7cf3Uc.js → freemarker2-ChUzpgbV.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BjDKZgtm.js → handlebars-BEqiXFje.js} +1 -1
- package/src/client/dist/spa/assets/{html-CxHAAoPw.js → html-BMS_L0RV.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-DhXtvpCN.js → htmlMode-C68CMSi2.js} +1 -1
- package/src/client/dist/spa/assets/i18n-C9sStiuf.js +1 -0
- package/src/client/dist/spa/assets/i18n-DAQcMG9d.js +1 -0
- package/src/client/dist/spa/assets/index-DSx45TKH.js +5 -0
- package/src/client/dist/spa/assets/{javascript-CFSWGGGJ.js → javascript-BKkV2qqm.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-k2An8_pW.js → jsonMode-Bk1XwYG8.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-Cq4t-XTr.js → liquid-QLO1u8by.js} +1 -1
- package/src/client/dist/spa/assets/marked.esm-CtWESN18.js +60 -0
- package/src/client/dist/spa/assets/{mdx-UB8RrpeD.js → mdx-Cgznl219.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-B9W_lLWp.js → monaco.contribution-CDhgtrdW.js} +2 -2
- package/src/client/dist/spa/assets/{python-n8QHA1-s.js → python-DCFlVqvF.js} +1 -1
- package/src/client/dist/spa/assets/{razor-DQrmL_Eu.js → razor-BHddvwQ2.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-DqHf0yz_.js → tsMode-BZkc6l5_.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-C2OgYZBp.js → typescript-B0g45tCE.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BD4Y8hNI.js → xml-CfmQqdO8.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-dxD-q88Q.js → yaml-B1F9_T4A.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- package/src/mcp-server/kobo-tasks-server.ts +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-5m7j8KXA.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-CWcNkgNz.js +0 -68
- package/src/client/dist/spa/assets/DiffViewer-BB0uMnDB.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-Di4zIHM4.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-Drv4zYbW.css +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-dnmzWHId.js +0 -1
- package/src/client/dist/spa/assets/QMenu-C_LDF5uF.js +0 -1
- package/src/client/dist/spa/assets/QPage-0Qoiu-SJ.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DKxEdudV.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-2D1mycBC.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CdWgxXBN.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-D6ilyDGt.js +0 -4
- package/src/client/dist/spa/assets/WorkspacePage-DrDOZvHX.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CLHv3piE.js +0 -1
- package/src/client/dist/spa/assets/i18n-BG0I8c3l.js +0 -1
- package/src/client/dist/spa/assets/i18n-BvyUMouA.js +0 -1
- package/src/client/dist/spa/assets/index-Dh7opLow.js +0 -5
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 (
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
- **
|
|
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')
|
|
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;
|
package/dist/server/db/schema.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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;
|