@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.
- package/AGENTS.md +227 -0
- package/LICENSE +674 -0
- package/README.md +199 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +27 -0
- package/dist/mcp-server/kobo-tasks-server.js +116 -0
- package/dist/server/db/index.js +22 -0
- package/dist/server/db/migrations.js +20 -0
- package/dist/server/db/schema.js +49 -0
- package/dist/server/index.js +178 -0
- package/dist/server/routes/dev-server.js +74 -0
- package/dist/server/routes/git.js +20 -0
- package/dist/server/routes/notion.js +24 -0
- package/dist/server/routes/settings.js +92 -0
- package/dist/server/routes/workspaces.js +730 -0
- package/dist/server/services/agent-manager.js +435 -0
- package/dist/server/services/dev-server-service.js +298 -0
- package/dist/server/services/notion-service.js +369 -0
- package/dist/server/services/pr-template-service.js +38 -0
- package/dist/server/services/settings-service.js +205 -0
- package/dist/server/services/websocket-service.js +212 -0
- package/dist/server/services/workspace-service.js +208 -0
- package/dist/server/services/worktree-service.js +117 -0
- package/dist/server/utils/git-ops.js +117 -0
- package/dist/server/utils/paths.js +95 -0
- package/dist/server/utils/process-tracker.js +46 -0
- package/package.json +84 -0
- package/src/client/dist/spa/assets/ActivityFeed-BveJRagX.js +60 -0
- package/src/client/dist/spa/assets/ActivityFeed-DBNn62g_.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-BlgXsrJO.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-wbOkBwYU.js +2 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BepdiOnY.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-4ZhHFPot.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-CNa4tw4G.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CHKg1YId.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-yBxCyPWP.woff +0 -0
- package/src/client/dist/spa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-3fZ6d7DD.woff +0 -0
- package/src/client/dist/spa/assets/MainLayout-6hzaLlYO.js +1 -0
- package/src/client/dist/spa/assets/MainLayout-D0OU6djX.css +1 -0
- package/src/client/dist/spa/assets/QBadge-Cb92Ia8-.js +1 -0
- package/src/client/dist/spa/assets/QDialog-B5H6ayTp.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-DJgnAZg_.js +1 -0
- package/src/client/dist/spa/assets/QPage-CLk9i9z8.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-DcaNq8uL.js +1 -0
- package/src/client/dist/spa/assets/QTabPanels-DlG5TZhP.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-637ruGFc.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-B9VYIQs-.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-KEqbLZUA.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-BFuHLjou.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-D0Hm21LY.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-CHpmshS7.js +1 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/spa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ-D-x-0Q06.woff2 +0 -0
- package/src/client/dist/spa/assets/index-BThMCiY7.css +1 -0
- package/src/client/dist/spa/assets/index-CMvo3OTb.js +5 -0
- package/src/client/dist/spa/assets/nodes-DeIen-kp.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-Dq-Vjx_2.js +1 -0
- package/src/client/dist/spa/index.html +4 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +54 -0
- 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
|
+
});
|