@loicngr/kobo 0.1.1

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 (59) hide show
  1. package/AGENTS.md +227 -0
  2. package/LICENSE +674 -0
  3. package/README.md +199 -0
  4. package/dist/mcp-server/kobo-tasks-handlers.js +27 -0
  5. package/dist/mcp-server/kobo-tasks-server.js +116 -0
  6. package/dist/server/db/index.js +22 -0
  7. package/dist/server/db/migrations.js +20 -0
  8. package/dist/server/db/schema.js +49 -0
  9. package/dist/server/index.js +178 -0
  10. package/dist/server/routes/dev-server.js +74 -0
  11. package/dist/server/routes/git.js +20 -0
  12. package/dist/server/routes/notion.js +24 -0
  13. package/dist/server/routes/settings.js +92 -0
  14. package/dist/server/routes/workspaces.js +730 -0
  15. package/dist/server/services/agent-manager.js +435 -0
  16. package/dist/server/services/dev-server-service.js +298 -0
  17. package/dist/server/services/notion-service.js +369 -0
  18. package/dist/server/services/pr-template-service.js +38 -0
  19. package/dist/server/services/settings-service.js +205 -0
  20. package/dist/server/services/websocket-service.js +212 -0
  21. package/dist/server/services/workspace-service.js +208 -0
  22. package/dist/server/services/worktree-service.js +117 -0
  23. package/dist/server/utils/git-ops.js +117 -0
  24. package/dist/server/utils/paths.js +95 -0
  25. package/dist/server/utils/process-tracker.js +46 -0
  26. package/package.json +84 -0
  27. package/src/client/dist/spa/assets/ActivityFeed-BveJRagX.js +60 -0
  28. package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +1 -0
  29. package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +1 -0
  30. package/src/client/dist/spa/assets/CreatePage-wbOkBwYU.js +2 -0
  31. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
  32. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
  33. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
  34. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
  35. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
  36. package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
  37. package/src/client/dist/spa/assets/MainLayout-6hzaLlYO.js +1 -0
  38. package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +1 -0
  39. package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +1 -0
  40. package/src/client/dist/spa/assets/QDialog-B5H6ayTp.js +1 -0
  41. package/src/client/dist/spa/assets/QExpansionItem-DJgnAZg_.js +1 -0
  42. package/src/client/dist/spa/assets/QPage-CLk9i9z8.js +1 -0
  43. package/src/client/dist/spa/assets/QSpinnerDots-DcaNq8uL.js +1 -0
  44. package/src/client/dist/spa/assets/QTabPanels-DlG5TZhP.js +1 -0
  45. package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +1 -0
  46. package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +1 -0
  47. package/src/client/dist/spa/assets/SettingsPage-KEqbLZUA.js +1 -0
  48. package/src/client/dist/spa/assets/WorkspacePage-BFuHLjou.css +1 -0
  49. package/src/client/dist/spa/assets/WorkspacePage-D0Hm21LY.js +2 -0
  50. package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +1 -0
  51. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  52. package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2 +0 -0
  53. package/src/client/dist/spa/assets/index-BThMCiY7.css +1 -0
  54. package/src/client/dist/spa/assets/index-CMvo3OTb.js +5 -0
  55. package/src/client/dist/spa/assets/nodes-DeIen-kp.js +1 -0
  56. package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +1 -0
  57. package/src/client/dist/spa/index.html +4 -0
  58. package/src/mcp-server/kobo-tasks-handlers.ts +54 -0
  59. package/src/mcp-server/kobo-tasks-server.ts +128 -0
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # Kōbō
2
+
3
+ > **Kōbō** (工房) — Japanese for *workshop*. A multi-workspace agent manager for [Claude Code](https://claude.com/claude-code).
4
+
5
+ Kōbō lets you delegate multiple coding missions to Claude Code agents in parallel. Each workspace lives in its own isolated git worktree with its own branch, its own Claude session, optionally its own dev server, and a custom MCP tools server the agent uses to track progress. A Vue 3 dashboard shows live agent output, tasks, acceptance criteria, and git state across every workspace.
6
+
7
+ Think of it as an apprentice's hall: you hand out missions, each apprentice sets up their own workbench, and you watch them work from a single control surface.
8
+
9
+ ## Features
10
+
11
+ - **Isolated git worktrees** — every workspace runs on its own branch in its own directory, so concurrent Claude sessions never step on each other
12
+ - **Live agent output** — stream `stdout`/`stderr` from Claude Code to the browser via WebSocket, with persisted event replay on reconnect
13
+ - **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
14
+ - **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria
15
+ - **Per-workspace dev servers** — start/stop Docker or Node dev servers scoped to each branch, with log streaming
16
+ - **Conventional-commit enforcement** — project-level git conventions are written to `.ai/git-conventions.md` inside every workspace so Claude follows them during commits
17
+ - **Pull request automation** — one-click `push` and `open-pr` endpoints integrate with the GitHub CLI, using a configurable prompt template
18
+ - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
19
+
20
+ ## Tech stack
21
+
22
+ - **Backend** — Node.js ≥ 20, [Hono](https://hono.dev/), [better-sqlite3](https://github.com/WiseLibs/better-sqlite3), [ws](https://github.com/websockets/ws), [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk)
23
+ - **Frontend** — [Vue 3](https://vuejs.org/), [Quasar 2](https://quasar.dev/), [Pinia](https://pinia.vuejs.org/), `vue-router`
24
+ - **Tooling** — TypeScript, [Vitest](https://vitest.dev/), [Biome](https://biomejs.dev/) (lint + format), `tsx` for dev
25
+ - **Storage** — single SQLite file (`~/.config/kobo/kobo.db` by default, overridable via `KOBO_HOME`) with WAL mode
26
+
27
+ ## Quick start
28
+
29
+ ### Prerequisites
30
+
31
+ - Node.js ≥ 20
32
+ - [Claude Code CLI](https://claude.com/claude-code) installed and authenticated (`claude` on your `PATH`)
33
+ - Git
34
+ - Optional: Docker (if you configure per-workspace dev servers)
35
+ - Optional: `gh` CLI (if you use the PR automation)
36
+ - Optional: a Notion integration token (only if you want to import workspace missions from Notion pages — see [Notion integration](#notion-integration))
37
+
38
+ ### Run via `npx` (recommended)
39
+
40
+ ```bash
41
+ PORT=9999 npx @loicngr/kobo@latest
42
+ ```
43
+
44
+ That's it. npm downloads the package, installs dependencies, starts the Kōbō server on the port you specified, and serves the web UI at `http://localhost:9999`. Data is persisted to `~/.config/kobo/` (overridable via `KOBO_HOME`).
45
+
46
+ On first launch Kōbō creates `~/.config/kobo/` if it doesn't exist. If the `claude` CLI is missing from your `PATH` you will see a warning in the terminal — install Claude Code before creating your first workspace.
47
+
48
+ ### Run from source (contributors)
49
+
50
+ ```bash
51
+ git clone https://github.com/loicngr/kobo.git
52
+ cd kobo
53
+ npm install
54
+ (cd src/client && npm install)
55
+ ```
56
+
57
+ ### Run (development)
58
+
59
+ ```bash
60
+ npm run dev:all
61
+ ```
62
+
63
+ This starts the Hono backend on port `3000` (via `tsx watch`, with `KOBO_HOME=./data` so dev uses the repo-local data directory and never touches your real `~/.config/kobo/`) and the Quasar dev server on port `8080` concurrently. Open <http://localhost:8080> in your browser.
64
+
65
+ You can run a production-installed Kōbō (`npx @loicngr/kobo`) alongside a dev server without any conflict — they use different data directories by design.
66
+
67
+ To run them separately:
68
+
69
+ ```bash
70
+ npm run dev # backend only (KOBO_HOME=./data automatically)
71
+ npm run dev:client # frontend only
72
+ ```
73
+
74
+ ### Build (production)
75
+
76
+ ```bash
77
+ npm run build # builds client + server
78
+ npm start # runs the compiled server
79
+ ```
80
+
81
+ ### Test & lint
82
+
83
+ ```bash
84
+ npm test # full vitest suite (366+ tests)
85
+ npm run lint # biome check (lint + format verification)
86
+ npm run lint:fix # biome check with safe auto-fixes
87
+ npm run format # biome format --write
88
+ npx tsc --noEmit # server type check
89
+ ```
90
+
91
+ ## Notion integration
92
+
93
+ Kōbō can pull the content of a Notion page (title, body, checklists) and turn it into tasks and acceptance criteria when you create a workspace. **This feature is opt-in and requires you to configure your own Notion credentials** — Kōbō does not ship an API key.
94
+
95
+ Under the hood, Kōbō spawns the official [`@notionhq/notion-mcp-server`](https://github.com/makenotion/notion-mcp-server) as a child process and talks to it over stdio using the Model Context Protocol. The package is fetched via `npx -y @notionhq/notion-mcp-server` the first time you trigger an import, so there is nothing to install manually — only a token to provide.
96
+
97
+ ### Getting a Notion integration token
98
+
99
+ 1. Go to <https://www.notion.so/profile/integrations> and create a new internal integration
100
+ 2. Give it a name (e.g. `kobo`) and the capabilities you need (at minimum: *Read content*)
101
+ 3. Copy the internal integration secret (format `ntn_...` or `secret_...`)
102
+ 4. Open the Notion page you want to import, click **…** → **Connections** → **Add connection** → select your integration. Kōbō can only read pages that are explicitly shared with the integration.
103
+
104
+ ### Giving the token to Kōbō
105
+
106
+ Kōbō reads the token from the first source available, in this order:
107
+
108
+ 1. `NOTION_API_TOKEN` environment variable
109
+ 2. `NOTION_TOKEN` environment variable
110
+ 3. `~/.claude.json` — if you already have the Notion MCP configured for Claude Code, Kōbō reads the token from `mcpServers.notion.env.NOTION_TOKEN` (or `NOTION_API_TOKEN`). **This is the recommended setup** — one token configured once, shared by both Claude Code and Kōbō.
111
+
112
+ Example: configure Notion MCP in Claude Code (one-time setup that also unlocks Kōbō's Notion import):
113
+
114
+ ```bash
115
+ claude mcp add notion -s user -e NOTION_TOKEN=ntn_your_token_here -- npx -y @notionhq/notion-mcp-server
116
+ ```
117
+
118
+ Or launch Kōbō with the token inline:
119
+
120
+ ```bash
121
+ NOTION_API_TOKEN=ntn_your_token_here PORT=9999 npx @loicngr/kobo@latest
122
+ ```
123
+
124
+ ### Advanced: overriding the MCP command
125
+
126
+ If you need to pin a specific version of the Notion MCP server, use a fork, or avoid `npx`, set these env vars before launching Kōbō:
127
+
128
+ - `NOTION_MCP_COMMAND` — the binary to run (default: `npx`)
129
+ - `NOTION_MCP_ARGS` — space-separated arguments (default: `-y @notionhq/notion-mcp-server`)
130
+
131
+ Without a valid token configured, the Notion import field in the workspace creation form will return an error when you click **Refresh** or submit a Notion URL — the rest of Kōbō (workspaces, agents, tasks, Git integration) keeps working independently.
132
+
133
+ ## Architecture
134
+
135
+ ```
136
+ src/
137
+ ├── server/ # Hono backend
138
+ │ ├── index.ts # app bootstrap + WS upgrade
139
+ │ ├── db/ # SQLite schema and singleton
140
+ │ ├── services/ # business logic (workspace, agent, dev-server, ws, notion, settings, pr-template)
141
+ │ ├── routes/ # Hono handlers
142
+ │ └── utils/ # git-ops, process-tracker
143
+ ├── client/ # Vue 3 + Quasar SPA
144
+ │ └── src/
145
+ │ ├── stores/ # Pinia state management
146
+ │ ├── components/ # WorkspaceList, NotionPanel, ChatInput, GitPanel, …
147
+ │ ├── pages/ # WorkspacePage, CreatePage, SettingsPage
148
+ │ └── router/
149
+ ├── mcp-server/ # Standalone MCP server spawned per workspace
150
+ │ ├── kobo-tasks-server.ts # entry point, registers list_tasks & mark_task_done
151
+ │ └── kobo-tasks-handlers.ts # pure handlers over SQLite
152
+ └── __tests__/ # Vitest suite
153
+ ```
154
+
155
+ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, WebSocket protocol, and contribution guidelines.
156
+
157
+ ## Data model
158
+
159
+ | Table | Purpose |
160
+ |---|---|
161
+ | `workspaces` | the unit of work — branch, status, Notion link, model, `archived_at`, … |
162
+ | `tasks` | workspace sub-items — tasks and acceptance criteria |
163
+ | `agent_sessions` | Claude Code CLI invocations — `claude_session_id`, pid, lifecycle |
164
+ | `ws_events` | persisted WebSocket events for replay on reconnect |
165
+
166
+ ## MCP server
167
+
168
+ Each workspace spawns its own `kobo-tasks` MCP server as a child process of the Claude Code agent. It exposes two tools:
169
+
170
+ - `list_tasks()` — returns all tasks & acceptance criteria for the current workspace with their IDs and status
171
+ - `mark_task_done(task_id)` — marks a task as done and notifies the backend over HTTP so the UI updates live
172
+
173
+ The MCP server reads and writes the same SQLite database as the main backend. Isolation between workspaces is enforced via the `KOBO_WORKSPACE_ID` environment variable passed at spawn time and validated on every query.
174
+
175
+ ## Configuration
176
+
177
+ Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings cascade into per-project overrides:
178
+
179
+ - `defaultModel` — Claude model to use (e.g. `claude-opus-4-6`)
180
+ - `prPromptTemplate` — template rendered when opening a PR via the `/open-pr` endpoint; supports `{{pr_number}}`, `{{pr_url}}`, `{{branch_name}}`, `{{diff_stats}}`, `{{commits}}`, etc.
181
+ - `gitConventions` — markdown-formatted git conventions written to `.ai/git-conventions.md` in every workspace so the agent follows them when committing
182
+ - `devServer` — per-project `startCommand` / `stopCommand` for launching workspace-scoped dev servers
183
+
184
+ ## Contributing
185
+
186
+ This is a personal tool, but PRs and issues are welcome. Before submitting:
187
+
188
+ 1. Read [`AGENTS.md`](./AGENTS.md) — it covers the commit rules, branching model, and code conventions
189
+ 2. Run `npm run lint`, `npx tsc --noEmit`, and `npm test` locally
190
+ 3. Base your branch on `develop` (not `main`); PRs target `develop`
191
+ 4. **Do not add `Co-Authored-By` trailers** in commits, even for AI-assisted work
192
+
193
+ CI runs lint + type check + tests on every PR to `develop`.
194
+
195
+ ## License
196
+
197
+ GNU General Public License v3.0 or later. See [`LICENSE`](./LICENSE) for the full text.
198
+
199
+ Kōbō links against [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3), [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk), [Vue](https://vuejs.org/), [Quasar](https://quasar.dev/), and other open-source libraries — see `package.json` for the full list.
@@ -0,0 +1,27 @@
1
+ function rowToDto(row) {
2
+ return {
3
+ id: row.id,
4
+ title: row.title,
5
+ status: row.status,
6
+ is_acceptance_criterion: row.is_acceptance_criterion === 1,
7
+ };
8
+ }
9
+ export function listTasksHandler(db, workspaceId) {
10
+ const rows = db
11
+ .prepare('SELECT id, title, status, is_acceptance_criterion FROM tasks WHERE workspace_id = ? ORDER BY sort_order ASC')
12
+ .all(workspaceId);
13
+ return rows.map(rowToDto);
14
+ }
15
+ export function markTaskDoneHandler(db, workspaceId, taskId) {
16
+ const now = new Date().toISOString();
17
+ const result = db
18
+ .prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?')
19
+ .run('done', now, taskId, workspaceId);
20
+ if (result.changes === 0) {
21
+ throw new Error(`Task '${taskId}' not found in workspace '${workspaceId}'`);
22
+ }
23
+ const row = db
24
+ .prepare('SELECT id, title, status, is_acceptance_criterion FROM tasks WHERE id = ?')
25
+ .get(taskId);
26
+ return { success: true, task: rowToDto(row) };
27
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import Database from 'better-sqlite3';
6
+ import { listTasksHandler, markTaskDoneHandler } from './kobo-tasks-handlers.js';
7
+ const workspaceId = process.env.KOBO_WORKSPACE_ID;
8
+ const dbPath = process.env.KOBO_DB_PATH;
9
+ const backendUrl = process.env.KOBO_BACKEND_URL ?? 'http://localhost:3000';
10
+ if (!workspaceId) {
11
+ console.error('[kobo-tasks-server] KOBO_WORKSPACE_ID env var is required');
12
+ process.exit(1);
13
+ }
14
+ if (!dbPath) {
15
+ console.error('[kobo-tasks-server] KOBO_DB_PATH env var is required');
16
+ process.exit(1);
17
+ }
18
+ let db;
19
+ try {
20
+ db = new Database(dbPath, { readonly: false });
21
+ db.pragma('journal_mode = WAL');
22
+ db.pragma('foreign_keys = ON');
23
+ }
24
+ catch (err) {
25
+ console.error('[kobo-tasks-server] Failed to open database:', err);
26
+ process.exit(1);
27
+ }
28
+ async function notifyBackend(taskId) {
29
+ try {
30
+ const url = `${backendUrl}/api/workspaces/${workspaceId}/tasks/${taskId}/notify-done`;
31
+ const res = await fetch(url, { method: 'POST' });
32
+ if (!res.ok) {
33
+ console.error(`[kobo-tasks-server] notify-done HTTP ${res.status}`);
34
+ }
35
+ }
36
+ catch (err) {
37
+ console.error('[kobo-tasks-server] notify-done failed:', err);
38
+ }
39
+ }
40
+ const server = new Server({ name: 'kobo-tasks', version: '1.0.0' }, { capabilities: { tools: {} } });
41
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
42
+ tools: [
43
+ {
44
+ name: 'list_tasks',
45
+ description: 'List all tasks and acceptance criteria for the current workspace with their IDs and current status. Call this first to discover task IDs before calling mark_task_done.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {},
49
+ required: [],
50
+ },
51
+ },
52
+ {
53
+ name: 'mark_task_done',
54
+ description: 'Mark a task or acceptance criterion as done. Use this when you have completed the work for a criterion and validated it.',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ task_id: {
59
+ type: 'string',
60
+ description: 'The ID of the task to mark as done (obtained from list_tasks)',
61
+ },
62
+ },
63
+ required: ['task_id'],
64
+ },
65
+ },
66
+ ],
67
+ }));
68
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
69
+ const { name, arguments: args } = request.params;
70
+ if (name === 'list_tasks') {
71
+ const tasks = listTasksHandler(db, workspaceId);
72
+ return {
73
+ content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
74
+ };
75
+ }
76
+ if (name === 'mark_task_done') {
77
+ const taskId = args?.task_id;
78
+ if (!taskId) {
79
+ return {
80
+ content: [{ type: 'text', text: 'Error: task_id parameter is required' }],
81
+ isError: true,
82
+ };
83
+ }
84
+ try {
85
+ const result = markTaskDoneHandler(db, workspaceId, taskId);
86
+ void notifyBackend(taskId);
87
+ return {
88
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
89
+ };
90
+ }
91
+ catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ return {
94
+ content: [{ type: 'text', text: `Error: ${message}` }],
95
+ isError: true,
96
+ };
97
+ }
98
+ }
99
+ return {
100
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
101
+ isError: true,
102
+ };
103
+ });
104
+ const transport = new StdioServerTransport();
105
+ server.connect(transport).catch((err) => {
106
+ console.error('[kobo-tasks-server] Fatal:', err);
107
+ process.exit(1);
108
+ });
109
+ process.on('SIGTERM', () => {
110
+ db.close();
111
+ process.exit(0);
112
+ });
113
+ process.on('SIGINT', () => {
114
+ db.close();
115
+ process.exit(0);
116
+ });
@@ -0,0 +1,22 @@
1
+ import Database from 'better-sqlite3';
2
+ import { ensureKoboHome, getDbPath } from '../utils/paths.js';
3
+ let instance = null;
4
+ export function getDb(dbPath) {
5
+ if (instance)
6
+ return instance;
7
+ let resolvedPath = dbPath;
8
+ if (!resolvedPath) {
9
+ ensureKoboHome();
10
+ resolvedPath = getDbPath();
11
+ }
12
+ instance = new Database(resolvedPath);
13
+ instance.pragma('journal_mode=WAL');
14
+ instance.pragma('foreign_keys=ON');
15
+ return instance;
16
+ }
17
+ export function closeDb() {
18
+ if (instance) {
19
+ instance.close();
20
+ instance = null;
21
+ }
22
+ }
@@ -0,0 +1,20 @@
1
+ import { initSchema } from './schema.js';
2
+ export const SCHEMA_VERSION = 1;
3
+ export function runMigrations(db) {
4
+ db.exec(`
5
+ CREATE TABLE IF NOT EXISTS schema_version (
6
+ version INTEGER NOT NULL
7
+ )
8
+ `);
9
+ const row = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
10
+ const currentVersion = row?.version ?? 0;
11
+ if (currentVersion < 1) {
12
+ initSchema(db);
13
+ if (currentVersion === 0) {
14
+ db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(1);
15
+ }
16
+ else {
17
+ db.prepare('UPDATE schema_version SET version = ?').run(1);
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,49 @@
1
+ export function initSchema(db) {
2
+ db.exec(`
3
+ CREATE TABLE IF NOT EXISTS workspaces (
4
+ id TEXT PRIMARY KEY,
5
+ name TEXT NOT NULL,
6
+ project_path TEXT NOT NULL,
7
+ source_branch TEXT NOT NULL,
8
+ working_branch TEXT NOT NULL,
9
+ status TEXT NOT NULL DEFAULT 'created',
10
+ notion_url TEXT,
11
+ notion_page_id TEXT,
12
+ model TEXT NOT NULL DEFAULT 'claude-opus-4-6',
13
+ dev_server_status TEXT NOT NULL DEFAULT 'stopped',
14
+ archived_at TEXT,
15
+ created_at TEXT NOT NULL,
16
+ updated_at TEXT NOT NULL
17
+ );
18
+
19
+ CREATE TABLE IF NOT EXISTS tasks (
20
+ id TEXT PRIMARY KEY,
21
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
22
+ title TEXT NOT NULL,
23
+ status TEXT NOT NULL DEFAULT 'pending',
24
+ is_acceptance_criterion INTEGER DEFAULT 0,
25
+ sort_order INTEGER DEFAULT 0,
26
+ created_at TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS agent_sessions (
31
+ id TEXT PRIMARY KEY,
32
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
33
+ pid INTEGER,
34
+ claude_session_id TEXT,
35
+ status TEXT NOT NULL DEFAULT 'running',
36
+ started_at TEXT NOT NULL,
37
+ ended_at TEXT
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS ws_events (
41
+ id TEXT PRIMARY KEY,
42
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
43
+ type TEXT NOT NULL,
44
+ payload TEXT NOT NULL,
45
+ session_id TEXT,
46
+ created_at TEXT NOT NULL
47
+ );
48
+ `);
49
+ }
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { serve } from '@hono/node-server';
6
+ import { Hono } from 'hono';
7
+ import { WebSocketServer } from 'ws';
8
+ import { getDb } from './db/index.js';
9
+ import { runMigrations } from './db/migrations.js';
10
+ import devServerRouter from './routes/dev-server.js';
11
+ import gitRouter from './routes/git.js';
12
+ import notionRouter from './routes/notion.js';
13
+ import settingsRouter from './routes/settings.js';
14
+ import workspacesRouter from './routes/workspaces.js';
15
+ import { getAvailableSkills, sendMessage, startAgent, stopAgent } from './services/agent-manager.js';
16
+ import { startDevServer, stopDevServer } from './services/dev-server-service.js';
17
+ import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
18
+ import { getLatestSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
19
+ import { getClientSpaPath, getKoboHome } from './utils/paths.js';
20
+ import { initProcessCleanup } from './utils/process-tracker.js';
21
+ // 0. Runtime prerequisite check — warn if claude CLI is missing. Don't block
22
+ // startup: the user may still want to configure settings or browse workspaces
23
+ // before installing Claude Code.
24
+ {
25
+ const check = spawnSync('claude', ['--version'], { stdio: 'ignore' });
26
+ if (check.error && check.error.code === 'ENOENT') {
27
+ console.warn("[kobo] WARNING: 'claude' CLI not found on PATH. Kōbō will fail to spawn agents until Claude Code is installed. See https://claude.com/claude-code");
28
+ }
29
+ }
30
+ console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
31
+ // 1. Initialize DB + run migrations
32
+ const db = getDb();
33
+ runMigrations(db);
34
+ // 2. Initialize process cleanup
35
+ initProcessCleanup();
36
+ // 3. Create Hono app
37
+ const app = new Hono();
38
+ // Health check (root / is handled by the SPA catch-all below)
39
+ app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
40
+ // 4. Mount route sub-routers
41
+ app.route('/api/workspaces', workspacesRouter);
42
+ app.route('/api/notion', notionRouter);
43
+ app.route('/api/git', gitRouter);
44
+ app.route('/api/settings', settingsRouter);
45
+ app.route('/api/dev-server', devServerRouter);
46
+ // Skills endpoint
47
+ app.get('/api/skills', (c) => c.json(getAvailableSkills()));
48
+ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
49
+ // 9. Serve static files from the built SPA if present (production mode).
50
+ // The path is resolved relative to the package install directory, so this
51
+ // works both in dev (tsx running from src/) and when installed via npm / npx
52
+ // (node running from dist/).
53
+ const clientDistPath = getClientSpaPath();
54
+ if (clientDistPath) {
55
+ app.get('*', async (c) => {
56
+ const url = new URL(c.req.url);
57
+ let filePath = path.join(clientDistPath, url.pathname);
58
+ // Prevent path traversal
59
+ if (!path.resolve(filePath).startsWith(clientDistPath)) {
60
+ return c.notFound();
61
+ }
62
+ // Serve index.html for non-asset routes (SPA fallback)
63
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
64
+ filePath = path.join(clientDistPath, 'index.html');
65
+ }
66
+ if (!fs.existsSync(filePath)) {
67
+ return c.notFound();
68
+ }
69
+ const content = fs.readFileSync(filePath);
70
+ const ext = path.extname(filePath);
71
+ const mimeTypes = {
72
+ '.html': 'text/html',
73
+ '.js': 'application/javascript',
74
+ '.css': 'text/css',
75
+ '.json': 'application/json',
76
+ '.png': 'image/png',
77
+ '.jpg': 'image/jpeg',
78
+ '.svg': 'image/svg+xml',
79
+ '.ico': 'image/x-icon',
80
+ '.woff': 'font/woff',
81
+ '.woff2': 'font/woff2',
82
+ };
83
+ const contentType = mimeTypes[ext] ?? 'application/octet-stream';
84
+ return new Response(content, {
85
+ headers: { 'Content-Type': contentType },
86
+ });
87
+ });
88
+ }
89
+ // 5. Create HTTP server via @hono/node-server
90
+ const server = serve({
91
+ fetch: app.fetch,
92
+ port: PORT,
93
+ }, (info) => {
94
+ console.log(`Server running at http://localhost:${info.port}`);
95
+ });
96
+ // 6. Create WebSocketServer attached to the HTTP server
97
+ const wss = new WebSocketServer({ noServer: true });
98
+ // 7. Wire WebSocket connections to websocket-service.handleConnection()
99
+ wss.on('connection', (ws) => {
100
+ handleConnection(ws);
101
+ });
102
+ // 8. Wire websocket-service message handler to agent-manager
103
+ setMessageHandler((type, payload) => {
104
+ const p = payload;
105
+ if (type === 'chat:message' && p?.workspaceId && p?.content) {
106
+ // Persist user message so it survives page refresh
107
+ const latestSession = getLatestSession(p.workspaceId);
108
+ emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, latestSession?.claudeSessionId ?? undefined);
109
+ try {
110
+ sendMessage(p.workspaceId, p.content);
111
+ }
112
+ catch {
113
+ // Agent not running — resume the existing session
114
+ try {
115
+ const workspace = getWorkspace(p.workspaceId);
116
+ if (workspace) {
117
+ const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
118
+ startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true);
119
+ updateWorkspaceStatus(p.workspaceId, 'executing');
120
+ }
121
+ }
122
+ catch (restartErr) {
123
+ console.error('[ws] Failed to resume agent:', restartErr instanceof Error ? restartErr.message : restartErr);
124
+ }
125
+ }
126
+ }
127
+ if (type === 'workspace:start' && p?.workspaceId) {
128
+ try {
129
+ const workspace = getWorkspace(p.workspaceId);
130
+ if (!workspace) {
131
+ console.error(`[ws] workspace:start — workspace '${p.workspaceId}' not found`);
132
+ return;
133
+ }
134
+ const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
135
+ const prompt = p.prompt ?? 'Continue the previous task where you left off.';
136
+ startAgent(p.workspaceId, worktreePath, prompt, workspace.model);
137
+ }
138
+ catch (err) {
139
+ console.error('[ws] Failed to start agent:', err);
140
+ }
141
+ }
142
+ if (type === 'workspace:stop' && p?.workspaceId) {
143
+ try {
144
+ stopAgent(p.workspaceId);
145
+ }
146
+ catch (err) {
147
+ console.error('[ws] Failed to stop agent:', err);
148
+ }
149
+ }
150
+ if (type === 'devserver:start' && p?.workspaceId) {
151
+ try {
152
+ startDevServer(p.workspaceId);
153
+ }
154
+ catch (err) {
155
+ console.error('[ws] Failed to start dev-server:', err);
156
+ }
157
+ }
158
+ if (type === 'devserver:stop' && p?.workspaceId) {
159
+ try {
160
+ stopDevServer(p.workspaceId);
161
+ }
162
+ catch (err) {
163
+ console.error('[ws] Failed to stop dev-server:', err);
164
+ }
165
+ }
166
+ });
167
+ // Handle WebSocket upgrade requests on /ws path
168
+ server.on('upgrade', (request, socket, head) => {
169
+ const { pathname } = new URL(request.url ?? '/', `http://localhost:${PORT}`);
170
+ if (pathname === '/ws') {
171
+ wss.handleUpgrade(request, socket, head, (ws) => {
172
+ wss.emit('connection', ws, request);
173
+ });
174
+ }
175
+ else {
176
+ socket.destroy();
177
+ }
178
+ });