@loicngr/kobo 1.4.9 → 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.
- 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 +14 -5
- package/dist/server/routes/plans.js +89 -0
- package/dist/server/routes/templates.js +82 -0
- package/dist/server/routes/workspaces.js +110 -11
- package/dist/server/services/agent-manager.js +74 -21
- package/dist/server/services/settings-service.js +10 -0
- package/dist/server/services/templates-service.js +173 -0
- package/dist/server/services/workspace-service.js +52 -3
- package/dist/server/utils/git-ops.js +10 -0
- package/dist/server/utils/paths.js +7 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-C8UZFjUM.css +1 -0
- package/src/client/dist/spa/assets/ActivityFeed-CahLjg_a.js +10 -0
- package/src/client/dist/spa/assets/ClosePopup-jSuaV6dg.js +1 -0
- package/src/client/dist/spa/assets/CreatePage-DByy8YPu.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-DvenHBbs.js +2 -0
- package/src/client/dist/spa/assets/DiffViewer-BNB89NC_.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-8hdHhbFX.css +1 -0
- package/src/client/dist/spa/assets/MainLayout-CRZ_pSUI.js +2 -0
- package/src/client/dist/spa/assets/QExpansionItem-D6zpEqBV.js +1 -0
- package/src/client/dist/spa/assets/{QList-tXJCQz3K.js → QList-oHuiVWr0.js} +1 -1
- package/src/client/dist/spa/assets/QMenu-67GqYx0C.js +1 -0
- package/src/client/dist/spa/assets/QPage-DE75SzRI.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BIAu1N7t.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +1 -0
- package/src/client/dist/spa/assets/{TouchPan-Dc-xrSIS.js → TouchPan-DuISf80E.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-ZtCuDedw.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-l-yPyCj4.css +1 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-fkfRoKj2.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-hVzqeL3Y.js → cssMode-CLNBNUPN.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-CDy2oNdA.js → editor.api-BOAfcnN4.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-By6Nm4Uo.js → editor.main-DHOeskpn.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-DsZ7OE8f.js → freemarker2-BtrSj5Xy.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BwM3WWoa.js → handlebars-Dc1FuhPx.js} +1 -1
- package/src/client/dist/spa/assets/{html-BevRIlIZ.js → html-CRa_T0ab.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-B1A5H5QY.js → htmlMode-DiZaznA3.js} +1 -1
- package/src/client/dist/spa/assets/i18n-Chh7fA86.js +1 -0
- package/src/client/dist/spa/assets/i18n-EPkNuLUF.js +1 -0
- package/src/client/dist/spa/assets/index-BIsYlO92.js +5 -0
- package/src/client/dist/spa/assets/{javascript-Cbwf_WZd.js → javascript-BmWQzw3l.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-DXxkqMuk.js → jsonMode-CxTgSexO.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CMWSadqH.js → liquid-BUs2kSUC.js} +1 -1
- package/src/client/dist/spa/assets/marked.esm-BjjOHIBz.js +60 -0
- package/src/client/dist/spa/assets/{mdx-CXFMxs0m.js → mdx-Cnd0lVUN.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-DojwMy68.js → monaco.contribution-DdEdriCG.js} +2 -2
- package/src/client/dist/spa/assets/{python-Ct1-RX08.js → python-DEYsKpRr.js} +1 -1
- package/src/client/dist/spa/assets/{razor-BLUxJqNg.js → razor-D5u_QaVY.js} +1 -1
- package/src/client/dist/spa/assets/{settings-B0JkmJA1.js → settings-CuK-S6HH.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-DfoQS6sD.js → tsMode-DWHAMuFy.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-Ce2TQPtr.js → typescript-miqJki5_.js} +1 -1
- package/src/client/dist/spa/assets/{xml-C9QcFOCj.js → xml-TsOUFKqU.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-CpUUJmRX.js → yaml-Bn4aEJci.js} +1 -1
- package/src/client/dist/spa/index.html +3 -3
- 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-jhus97Cf.js +0 -68
- package/src/client/dist/spa/assets/CreatePage-BErPWWmP.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-CgqSs5JM.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-BmAd7k-Q.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-BW7daPf7.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-Drv4zYbW.css +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-Ch5P6bzC.js +0 -1
- package/src/client/dist/spa/assets/QMenu-BE-OO3N4.js +0 -1
- package/src/client/dist/spa/assets/QPage-CkDrNXyM.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DKxEdudV.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-B5j-Zv91.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BzsaegMp.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DrDOZvHX.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-LkjOnfKV.js +0 -4
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CLHv3piE.js +0 -1
- package/src/client/dist/spa/assets/i18n-BLHRwr9N.js +0 -1
- package/src/client/dist/spa/assets/i18n-yBCUfaG0.js +0 -1
- package/src/client/dist/spa/assets/index-BxBqipDT.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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -37,6 +37,7 @@ app.post('/', async (c) => {
|
|
|
37
37
|
return c.json({ error: 'Missing required fields: name, projectPath, sourceBranch, workingBranch' }, 400);
|
|
38
38
|
}
|
|
39
39
|
// Create workspace record
|
|
40
|
+
const globalSettings = settingsService.getGlobalSettings();
|
|
40
41
|
let workspace = workspaceService.createWorkspace({
|
|
41
42
|
name: body.name,
|
|
42
43
|
projectPath: body.projectPath,
|
|
@@ -45,6 +46,7 @@ app.post('/', async (c) => {
|
|
|
45
46
|
notionUrl: body.notionUrl,
|
|
46
47
|
notionPageId: body.notionPageId,
|
|
47
48
|
model: body.model,
|
|
49
|
+
permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
|
|
48
50
|
});
|
|
49
51
|
let notionContent = null;
|
|
50
52
|
// Extract Notion page content if a URL was provided
|
|
@@ -284,10 +286,11 @@ app.post('/', async (c) => {
|
|
|
284
286
|
}
|
|
285
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.`;
|
|
286
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.`;
|
|
287
|
-
// Persist the initial prompt in the feed so it's visible in the chat
|
|
288
|
-
wsService.emit(workspace.id, 'user:message', { content: brainstormPrompt, sender: 'system-prompt' });
|
|
289
289
|
try {
|
|
290
|
-
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);
|
|
291
294
|
}
|
|
292
295
|
catch (err) {
|
|
293
296
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -309,6 +312,28 @@ app.post('/', async (c) => {
|
|
|
309
312
|
return c.json({ error: message }, 500);
|
|
310
313
|
}
|
|
311
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
|
+
});
|
|
312
337
|
// GET /api/workspaces/:id/sessions — list sessions for a workspace
|
|
313
338
|
app.get('/:id/sessions', (c) => {
|
|
314
339
|
try {
|
|
@@ -324,6 +349,30 @@ app.get('/:id/sessions', (c) => {
|
|
|
324
349
|
return c.json({ error: message }, 500);
|
|
325
350
|
}
|
|
326
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
|
+
});
|
|
327
376
|
// POST /api/workspaces/:id/refresh-notion — re-extract Notion page and update tasks
|
|
328
377
|
app.post('/:id/refresh-notion', async (c) => {
|
|
329
378
|
try {
|
|
@@ -808,8 +857,12 @@ app.post('/:id/start', async (c) => {
|
|
|
808
857
|
if (!workspace) {
|
|
809
858
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
810
859
|
}
|
|
811
|
-
const body = await c.req
|
|
860
|
+
const body = await c.req
|
|
861
|
+
.json()
|
|
862
|
+
.catch(() => ({ prompt: undefined, agentSessionId: undefined, resume: undefined }));
|
|
812
863
|
const prompt = body.prompt ?? 'Continue the previous task where you left off.';
|
|
864
|
+
const agentSessionId = body.agentSessionId;
|
|
865
|
+
const resume = body.resume === true;
|
|
813
866
|
// Stop existing agent if running
|
|
814
867
|
try {
|
|
815
868
|
agentManager.stopAgent(id);
|
|
@@ -818,8 +871,14 @@ app.post('/:id/start', async (c) => {
|
|
|
818
871
|
// Agent may not be running — ignore
|
|
819
872
|
}
|
|
820
873
|
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
821
|
-
agentManager.startAgent(id, worktreePath, prompt, workspace.model,
|
|
874
|
+
const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.permissionMode, agentSessionId);
|
|
822
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
|
+
}
|
|
823
882
|
return c.json({ status: 'started' });
|
|
824
883
|
}
|
|
825
884
|
catch (err) {
|
|
@@ -917,9 +976,34 @@ app.post('/:id/push', async (c) => {
|
|
|
917
976
|
return c.json({ error: message }, 500);
|
|
918
977
|
}
|
|
919
978
|
// Emit a trace into the chat feed so the user sees the action
|
|
920
|
-
const session = workspaceService.
|
|
921
|
-
|
|
922
|
-
|
|
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);
|
|
923
1007
|
return c.json({ ok: true, branch: workspace.workingBranch });
|
|
924
1008
|
}
|
|
925
1009
|
catch (err) {
|
|
@@ -1052,9 +1136,8 @@ app.post('/:id/open-pr', async (c) => {
|
|
|
1052
1136
|
tasks,
|
|
1053
1137
|
});
|
|
1054
1138
|
// Emit user:message into the chat feed
|
|
1055
|
-
const session = workspaceService.
|
|
1056
|
-
|
|
1057
|
-
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);
|
|
1058
1141
|
// Send to the running agent, or resume the agent with the PR prompt
|
|
1059
1142
|
let messageSent = false;
|
|
1060
1143
|
try {
|
|
@@ -1126,4 +1209,20 @@ app.post('/:id/stop', (c) => {
|
|
|
1126
1209
|
return c.json({ error: message }, 500);
|
|
1127
1210
|
}
|
|
1128
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
|
+
});
|
|
1129
1228
|
export default app;
|