@loicngr/kobo 1.11.40 → 1.11.41
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 +89 -32
- package/CHANGELOG.md +4 -0
- package/README.md +6 -4
- package/dist/server/routes/images.js +65 -5
- package/dist/server/routes/workspaces.js +25 -7
- package/dist/server/services/attachment-service.js +122 -0
- package/dist/server/services/image-service.js +16 -7
- package/dist/shared/attachments.js +58 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-MqsC-95i.js → ActivityFeed-DTIe6O9o.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-BxURWYxa.js → ChangelogPage-BWeu0YeI.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-DjiMDAUi.js → ComparisonPanel-o_EHlTwV.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-mA5RZf1t.js → DashboardPage-CIo_gesD.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-Ci7ucmy3.js → DiffViewer-DEcf48KI.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-RZTvcJkT.js → DocumentsPanel-D9vcB91W.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-CTOc3yA0.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-_kL2gMoy.js → GitPanel-DCmB2R5i.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-D3__hKJ5.js → HealthPage-_q3vy_xn.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-B3JKUm3n.js → MainLayout-Duwnq3cc.js} +2 -2
- package/src/client/dist/pwa/assets/{QBtnDropdown-td0RAVPx.js → QBtnDropdown-BeiZIHVA.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-BkyFPV7a.js → QDrawer-Fj3M9QGD.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-WspK3KC7.js → QSelect-DtSUuK8t.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-7pw7iuV0.js → QTooltip-DwkX8eQ6.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-CLNM13oo.js → SchedulePanel-BlZDd7TK.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-CmRgIvu4.js → SearchPage-BGi1yRxU.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-Cj6Y_TlN.js → SettingsPage-DnO-ImWE.js} +1 -1
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-CCHGEsKf.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-ijmsYk-2.js → TerminalPanel-IdPIGp2_.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-DuQFjlQF.js → TouchPan-DTJqxVp9.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-C5RF0lOK.js → TourReplayButton-Bj2GGyhU.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BR_XElog.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
- package/src/client/dist/pwa/assets/{browser-DUqzdkbg.js → browser-BFj5wwbS.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-ImiSpzfI.js → build-path-tree-C-HSiySk.js} +1 -1
- package/src/client/dist/pwa/assets/{de-CaHID9mW.js → de-BdLe7apJ.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-2C1hn1pw.js → documents-C2Xnjh9Z.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-Bm1vVaBr.js → editor.api-BbKfnNmv.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-_D8DFYXo.js → efforts-BM7fdaaL.js} +1 -1
- package/src/client/dist/pwa/assets/{engineFeatures-CZ9qfBYq.js → engineFeatures-b74YRODT.js} +1 -1
- package/src/client/dist/pwa/assets/{es-ZhzlCmer.js → es-C1LM-3aq.js} +1 -1
- package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
- package/src/client/dist/pwa/assets/{format-pPImdFUy.js → format-CnbUxAmw.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-dTX38ZsV.js → formatters-B2uUIbAY.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-BfXw7L7J.js → fr-KCK6r5ga.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-DGluDnAm.js → freemarker2-Cu1vGY0x.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BgviTnpD.js → handlebars-CETuqLyz.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CV-MrunL.js → html-BTNpr39P.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
- package/src/client/dist/pwa/assets/{index-9tiw3NjK.js → index-mvh5w1cM.js} +3 -3
- package/src/client/dist/pwa/assets/{it-CHAurcTF.js → it-BM6t0IGU.js} +1 -1
- package/src/client/dist/pwa/assets/{javascript-C2VFcHf2.js → javascript-C7Ncb6V7.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Dg9ZhbP5.js → liquid-D0hUdg4x.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-ImgrPYNl.js → mdx-BK5H1Xst.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-waWZXS1T.js → monaco-contributions-BBW6JNgz.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-BB2rtCHM.js → monaco.contribution-1LZrAlY4.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-h6O0qCMA.js → network-auth-CVGS__6a.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-Dyg-FGI6.js → notify-theme-DIKLxr8F.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-Dfpg92li.js → pending-diff-open-B02zuH1j.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-DDWmNBW1.js → permissionModes-C3otN1Vk.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-XUfY_zbw.js → project-color-BUcW_eZA.js} +1 -1
- package/src/client/dist/pwa/assets/{python-DVLs-DBA.js → python-DEeUVvnp.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-D34BqMwB.js → razor-CG6cL8ya.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DwUkPy-O.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-C7-UpNXx.js → typescript-CSQAg5UC.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-DfE-kwrX.js → use-tours-_6HwJ_Rp.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-DwwdOEAq.js → workspace-templates-B8WWsWEI.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-Cx1oQhEr.js → xml-BSB4-DXR.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BQOiJsdx.js → yaml-CSGKph85.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +51 -7
- package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +0 -2
- package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-CqcMweNL.css +0 -1
- package/src/client/dist/pwa/assets/expand-template-CbDNnTPP.js +0 -1
- package/src/client/dist/pwa/assets/i18n-CmXBenUp.js +0 -1
package/AGENTS.md
CHANGED
|
@@ -4,9 +4,9 @@ Guidance for AI coding agents (Claude Code, Cursor, etc.) working on this reposi
|
|
|
4
4
|
|
|
5
5
|
## What this project is
|
|
6
6
|
|
|
7
|
-
**Kōbō** (工房, Japanese for "workshop") orchestrates
|
|
7
|
+
**Kōbō** (工房, Japanese for "workshop") orchestrates Claude Code and OpenAI Codex agents across isolated git worktrees. Each "workspace" is a self-contained mission with its own worktree, branch, agent sessions, optional dev server, optional Notion/Sentry context, and a dedicated MCP tools server. A Vue 3 UI lets the human track progress, read live agent output, review Git changes, and manage the lifecycle. A workspace can switch engines through a handoff to a fresh session while preserving its worktree and history.
|
|
8
8
|
|
|
9
|
-
Single-user dev tool, local by default. The server binds `127.0.0.1` unless the opt-in "network access" setting is enabled, which binds all interfaces and gates non-loopback
|
|
9
|
+
Single-user dev tool, local by default. The server binds `127.0.0.1` unless the opt-in "network access" setting is enabled, which binds all interfaces and gates non-loopback API/WS requests behind a shared token. Behind-a-reverse-proxy mode also requires the token for loopback API/WS requests; `/api/health` stays exempt. Host/Origin checks apply separately. No multi-tenant concerns.
|
|
10
10
|
|
|
11
11
|
## Tech stack
|
|
12
12
|
|
|
@@ -16,9 +16,9 @@ Single-user dev tool, local by default. The server binds `127.0.0.1` unless the
|
|
|
16
16
|
|
|
17
17
|
**Database**: a 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**, so 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`, `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`.
|
|
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`, and `workspace-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 covers backend services/routes and frontend stores
|
|
21
|
+
**Tests**: Vitest covers backend services/routes and frontend stores, utilities, composables, and selected Vue components. The client suite uses Vue Test Utils and `happy-dom`; type-checking and manual smoke tests complement it. `npm test` runs the backend suite only; `npm run test:all` runs both suites. The three dependency trees (root, client, PWA) have separate lockfiles.
|
|
22
22
|
|
|
23
23
|
## Commands
|
|
24
24
|
|
|
@@ -36,8 +36,12 @@ npm run dev:all # both concurrently
|
|
|
36
36
|
# Check & test
|
|
37
37
|
npm run lint # biome check (linting + formatting)
|
|
38
38
|
npx tsc --noEmit # type check backend (the project's primary quality gate)
|
|
39
|
-
npm
|
|
40
|
-
(cd src/client && npm
|
|
39
|
+
npm run typecheck:tests # type check backend test fixtures and tests
|
|
40
|
+
(cd src/client && npm run type-check) # vue-tsc: client + Vue components
|
|
41
|
+
npm test # backend vitest suite
|
|
42
|
+
(cd src/client && npm test) # client stores, utilities, composables and components
|
|
43
|
+
npm run test:all # backend + client suites
|
|
44
|
+
make ci # install, audit, lint, type checks, build, both suites
|
|
41
45
|
npm run test:watch # vitest in watch mode
|
|
42
46
|
|
|
43
47
|
# Build & run
|
|
@@ -56,15 +60,15 @@ src/
|
|
|
56
60
|
│ ├── db/
|
|
57
61
|
│ │ ├── index.ts # singleton getDb / closeDb
|
|
58
62
|
│ │ ├── schema.ts # initSchema — CREATE TABLE IF NOT EXISTS …
|
|
59
|
-
│ │ └── migrations.ts #
|
|
63
|
+
│ │ └── migrations.ts # append-only migration registry + transactional runner
|
|
60
64
|
│ ├── services/ # business logic — pure functions over db + external processes
|
|
61
65
|
│ │ ├── workspace-service.ts # workspaces + tasks + agent_sessions CRUD
|
|
62
66
|
│ │ ├── agent/ # agent engine abstraction (replaces former agent-manager.ts)
|
|
63
67
|
│ │ │ ├── orchestrator.ts # per-workspace engine map, retry/quota handling, watchdog, public API
|
|
64
68
|
│ │ │ ├── session-controller.ts # lifecycle wrapper around an AgentEngine instance
|
|
65
|
-
│ │ │ ├── event-router.ts #
|
|
69
|
+
│ │ │ ├── event-router.ts # persists/broadcasts normalized AgentEvent via WebSocket
|
|
66
70
|
│ │ │ └── engines/{claude-code,codex}/ # per-engine adapters implementing the AgentEngine contract
|
|
67
|
-
│ │ ├── forge/ #
|
|
71
|
+
│ │ ├── forge/ # github / gitlab / bitbucket-community / none, registry + auto-resolve
|
|
68
72
|
│ │ ├── change-source-branch-service.ts # re-target a workspace onto a new source branch (built-in cherry-pick or custom bash)
|
|
69
73
|
│ │ ├── git-stats-service.ts # pure compute of commit/ahead-behind/diff stats + forge availability for a workspace
|
|
70
74
|
│ │ ├── settings-defaults.ts # DEFAULT_* constants for opt-in settings (e.g. change-source-branch script)
|
|
@@ -83,7 +87,8 @@ src/
|
|
|
83
87
|
│ │ ├── workspaces.ts # /api/workspaces/* — the main surface
|
|
84
88
|
│ │ ├── templates.ts # /api/templates — prompt templates CRUD
|
|
85
89
|
│ │ ├── workspace-templates.ts # /api/workspace-templates - saved create-form presets
|
|
86
|
-
│ │ ├──
|
|
90
|
+
│ │ ├── documents.ts # workspace plans, specs and thoughts browser (read-only)
|
|
91
|
+
│ │ ├── mcp.ts # /api/mcp — external discovery and conversation tools
|
|
87
92
|
│ │ ├── dev-server.ts, git.ts, notion.ts, settings.ts
|
|
88
93
|
│ ├── utils/
|
|
89
94
|
│ │ ├── git-ops.ts # pushBranch, pullBranch, getCommitsBetween, delete{Local,Remote}Branch…
|
|
@@ -91,7 +96,7 @@ src/
|
|
|
91
96
|
├── client/ # Vue 3 + Quasar PWA
|
|
92
97
|
│ └── src/
|
|
93
98
|
│ ├── stores/ # pinia: workspace, websocket, settings, dev-server, templates
|
|
94
|
-
│ ├── components/ # WorkspaceList,
|
|
99
|
+
│ ├── components/ # WorkspaceList, AcceptancePanel, ChatInput, GitPanel, DocumentsPanel, WorkspaceAttentionLabels, HelpMenu…
|
|
95
100
|
│ ├── utils/ # expand-template, formatters, workspace-attention (CI failure / changes-requested derivation)…
|
|
96
101
|
│ ├── tours/ # declarative onboarding tours, one file per zone + registry
|
|
97
102
|
│ ├── pages/ # WorkspacePage, CreatePage, SettingsPage
|
|
@@ -99,7 +104,8 @@ src/
|
|
|
99
104
|
├── mcp-server/ # standalone MCP server spawned per workspace
|
|
100
105
|
│ ├── kobo-tasks-server.ts # entrypoint, registers tools
|
|
101
106
|
│ └── kobo-tasks-handlers.ts # pure handlers (list_tasks, mark_task_done)
|
|
102
|
-
|
|
107
|
+
├── shared/ # model catalogues, prompts, dialogue contracts, shared utilities
|
|
108
|
+
└── __tests__/ # backend Vitest suites; client tests live under client/src/__tests__/
|
|
103
109
|
```
|
|
104
110
|
|
|
105
111
|
## Data model (SQLite)
|
|
@@ -110,7 +116,14 @@ src/
|
|
|
110
116
|
| `tasks` | workspace sub-items: title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
|
|
111
117
|
| `agent_sessions` | agent-engine sessions: pid where applicable, engine session id, status, timestamps, model, and name |
|
|
112
118
|
| `ws_events` | persisted WebSocket events for replay on reconnect: type, payload, session_id, created_at |
|
|
113
|
-
| `pending_wakeups` | one-row-per-workspace scheduler for the `
|
|
119
|
+
| `pending_wakeups` | one-row-per-workspace scheduler for the `schedule_wakeup` MCP tool: target_at (ISO UTC), prompt, reason; CASCADE DELETE on workspace |
|
|
120
|
+
| `pending_quota_backoffs` | persisted quota retries and their attempt counts |
|
|
121
|
+
| `pending_crons` | recurring workspace schedules and their next run |
|
|
122
|
+
| `session_event_metrics` / `usage_snapshots` | session event counters and provider usage snapshots |
|
|
123
|
+
| `workspace_activity` | significant lifecycle/PR events for the absence digest |
|
|
124
|
+
| `search_*` | derived logical-message search index and indexing progress; see `search-schema.ts` |
|
|
125
|
+
| `mcp_message_requests` | durable external-message idempotency ledger; independent of event retention |
|
|
126
|
+
| `schema_migrations` | applied migration versions, names and timestamps |
|
|
114
127
|
| `workspace_chat_history` | chat-input history per workspace: message text + `created_at`, ordered by autoincrement id, capped at 200 entries by the service; CASCADE DELETE on workspace |
|
|
115
128
|
| `workspace_permission_rules` | remembered per-workspace tool approvals, scoped to an exact operation or every invocation of a tool; CASCADE DELETE on workspace |
|
|
116
129
|
|
|
@@ -132,8 +145,8 @@ src/
|
|
|
132
145
|
|
|
133
146
|
### The two files and their roles
|
|
134
147
|
|
|
135
|
-
- **`src/server/db/schema.ts`**: `initSchema(db)`
|
|
136
|
-
- **`src/server/db/migrations.ts`**: `runMigrations(db)`
|
|
148
|
+
- **`src/server/db/schema.ts`**: `initSchema(db)` creates the current application schema for **fresh installs only**, including the search and MCP-message schemas through their helpers.
|
|
149
|
+
- **`src/server/db/migrations.ts`**: the ordered `migrations` registry contains `{ version, name, migrate(db) }` entries. `SCHEMA_VERSION` is derived from its last entry. `runMigrations(db)` records applied entries in `schema_migrations(version, name, applied_at)` and applies each pending entry in its own transaction. A fresh installation runs `initSchema` and records all entries as applied in one transaction. Legacy `schema_version` databases are converted to the history table automatically.
|
|
137
150
|
|
|
138
151
|
Both files must be kept in sync: after adding a migration, update `initSchema` so fresh installs get the same final shape without replaying migrations.
|
|
139
152
|
|
|
@@ -141,10 +154,10 @@ Both files must be kept in sync: after adding a migration, update `initSchema` s
|
|
|
141
154
|
|
|
142
155
|
Every feature that touches the schema:
|
|
143
156
|
|
|
144
|
-
1.
|
|
145
|
-
2.
|
|
157
|
+
1. Append an entry to `migrations` with the next version and a descriptive name. `SCHEMA_VERSION` updates automatically; do not replace its derived expression with a literal.
|
|
158
|
+
2. Implement the entry's `migrate(db)` using raw SQL, such as `ALTER TABLE workspaces ADD COLUMN new_field TEXT`. The runner owns the transaction and skips versions already recorded.
|
|
146
159
|
3. Update `initSchema` in `schema.ts` so the fresh-install shape matches (e.g. add the new column to the `CREATE TABLE` statement)
|
|
147
|
-
4.
|
|
160
|
+
4. Let the runner record the new entry in `schema_migrations`; do not manually update a legacy `schema_version` row.
|
|
148
161
|
5. Add a test in `src/__tests__/migrations.test.ts` that verifies:
|
|
149
162
|
- A database at the previous version can be upgraded without data loss
|
|
150
163
|
- The new version matches `SCHEMA_VERSION`
|
|
@@ -157,7 +170,8 @@ Every feature that touches the schema:
|
|
|
157
170
|
- **Always idempotent where possible.** Use `IF NOT EXISTS`, check for column existence before altering, etc. Prefer migrations that can be safely re-run.
|
|
158
171
|
- **`ALTER TABLE ADD COLUMN` is safe in SQLite** (even on large tables). For more invasive changes (rename, drop, change type), use the [12-step SQLite pattern](https://sqlite.org/lang_altertable.html#otheralter) within a transaction.
|
|
159
172
|
|
|
160
|
-
- **Run migrations on every backend start.** `
|
|
173
|
+
- **Run migrations on every backend start.** `src/server/index.ts` opens the connection with `getDb()`, checks for pending migrations, attempts a pre-migration backup, then calls `runMigrations(db)`. `getDb()` only opens/configures SQLite; it does not initialize or migrate the schema. Tests creating their own database must initialize it explicitly.
|
|
174
|
+
- **Settings migrations are separate.** The JSON settings migration versions in `settings-service.ts` are independent of SQLite's `SCHEMA_VERSION`.
|
|
161
175
|
- **Test upgrades, not just fresh installs.** The `migrations.test.ts` suite must exercise "old DB → new DB" paths.
|
|
162
176
|
|
|
163
177
|
## WebSocket protocol
|
|
@@ -180,7 +194,7 @@ Clients subscribe to individual workspace ids. The server sends `WsEvent` object
|
|
|
180
194
|
{ id, workspaceId, type, payload, sessionId?, createdAt }
|
|
181
195
|
```
|
|
182
196
|
|
|
183
|
-
|
|
197
|
+
Agent engines emit a normalized `AgentEvent` union, carried by `agent:event`. Common outer types include `user:message`, `task:updated`, `devserver:status`, `workspace:status`, `workspace:archived`, `workspace:unarchived`, and `sync:response`. Legacy `agent:output` rows remain supported through content migration. Keep the backend union in `services/agent/engines/types.ts` and its client mirror in `client/src/types/agent-event.ts` synchronized.
|
|
184
198
|
|
|
185
199
|
Two emit flavors in `websocket-service.ts`:
|
|
186
200
|
- `emit(workspaceId, type, payload)` persists to `ws_events` for later replay via `sync:request` on reconnect
|
|
@@ -190,7 +204,7 @@ Two emit flavors in `websocket-service.ts`:
|
|
|
190
204
|
|
|
191
205
|
### Forge providers
|
|
192
206
|
|
|
193
|
-
`src/server/services/forge/` implements
|
|
207
|
+
`src/server/services/forge/` implements `ForgeProvider` for `github` (`gh`), `gitlab` (`glab`), `bitbucket-community` (`bkt`), and `none` (disables PR/MR features). `getForgeProvider(id)` resolves a provider; `resolveForge(projectPath)` first reads the explicit project setting, then classifies the `origin` URL by `github.com`, `gitlab`, or `bitbucket`, falling back to `none`. Successful remote reads are cached for five minutes. PR routes and the watcher use the resolved provider. **Kōbō ships no forge credentials**: users authenticate `gh`/`glab` themselves; Bitbucket uses the email and API token entered in Settings, passed to `bkt` and excluded from settings exports. Missing or unauthenticated CLIs disable PR/MR actions with an explanation.
|
|
194
208
|
|
|
195
209
|
### Notion (opt-in, user-provided credentials)
|
|
196
210
|
|
|
@@ -200,7 +214,7 @@ Two emit flavors in `websocket-service.ts`:
|
|
|
200
214
|
2. `NOTION_TOKEN` env var
|
|
201
215
|
3. `~/.claude.json` → `mcpServers.notion.env.NOTION_TOKEN` / `NOTION_API_TOKEN` (Claude Code's MCP config: the recommended path, the same token shared with Claude Code)
|
|
202
216
|
|
|
203
|
-
The
|
|
217
|
+
The selected Claude config entry defaults to the enabled key named exactly `notion`; `global.notionMcpKey` selects another exact key. Its command/args are reused unless `NOTION_MCP_COMMAND` / `NOTION_MCP_ARGS` override them; otherwise the fallback is `npx -y @notionhq/notion-mcp-server`. Existing `OPENAPI_MCP_HEADERS` are preserved; when absent, the resolved token is used to construct them.
|
|
204
218
|
|
|
205
219
|
When adding features touching `notion-service.ts`, remember: **no token = no feature**. The rest of Kōbō must keep working if the Notion token is absent; only the explicit Notion import endpoints should fail with a clear error. Do not throw at server startup.
|
|
206
220
|
|
|
@@ -210,7 +224,7 @@ See the "Notion integration" section of the README for the end-user setup guide.
|
|
|
210
224
|
|
|
211
225
|
Two engines live under `src/server/services/agent/engines/`, both implementing the `AgentEngine` contract in `types.ts`:
|
|
212
226
|
|
|
213
|
-
**Claude Code** (`claude-code/`):
|
|
227
|
+
**Claude Code** (`claude-code/`): consumes the `@anthropic-ai/claude-agent-sdk` async iterator. The SDK manages the Claude runtime; Kōbō does not launch a separate `claude` CLI command. Authentication reuses the user's Claude login or `ANTHROPIC_API_KEY`. The engine arms a **15 s result-drain watchdog** when the SDK emits its `result` message: if the async iterator does not close cleanly within the window, `session:ended` is force-emitted so the orchestrator and auto-loop never hang on a stuck generator. The watchdog is idempotent via a `sessionEndedEmitted` guard and the timer is cleared in `finally`.
|
|
214
228
|
|
|
215
229
|
**OpenAI Codex** (`codex/`): uses the **`codex app-server` JSON-RPC protocol** (line-delimited JSON over stdio with a long-lived `codex` subprocess). The engine layers are:
|
|
216
230
|
- `jsonrpc/transport.ts` + `jsonrpc/peer.ts`: generic JSON-RPC 2.0 stdio peer (request correlation, notifications, server-initiated requests)
|
|
@@ -232,17 +246,60 @@ Background: the engine was migrated from `@openai/codex-sdk` (one-shot `codex ex
|
|
|
232
246
|
- **Permission mode vs collaboration mode are independent.** Sandbox + approvalPolicy control *what the agent may do at OS level* (read-only / workspace-write, never / on-request / unless-trusted). `collaborationMode` is a separate session-level flag that gates internal Codex behaviour (notably interactive Q&A). Kōbō hides both behind a single "permission mode" selector and maps them together.
|
|
233
247
|
- **Sub-agents map to `collabAgentToolCall`.** Codex's analogue of Claude's Task tool is `collabAgentToolCall` (`spawnAgent` / `sendInput` / `resumeAgent` / `wait` / `closeAgent`). The mapper emits **both** a `tool:call` named `Task` (chat card) and a `subagent:progress` event (right-hand panel) per call, the same dual-emission Claude does. See `event-mapper.ts` `handleItemStarted` / `handleItemCompleted` for the `collabAgentToolCall` branch.
|
|
234
248
|
- **`fileChange` items carry a unified-diff blob.** The protocol shape is `{ path, kind: PatchChangeKind, diff: string }` per change; `kind` is a discriminated union, not a string. The mapper flattens the first change into a Claude-style Edit input (`{ file_path, diff, change_kind, move_path? }`) so the existing `ToolCallItem` renderer picks it up. The client parses the unified diff into `DiffLine[]` via `parseUnifiedDiff` in `inline-diff.ts`.
|
|
235
|
-
- **Streaming bursts trip auto-scroll.**
|
|
249
|
+
- **Streaming bursts trip auto-scroll.** Both engines can emit token deltas; Claude enables SDK partial messages as well. The naive `eventCount` watcher in `ActivityFeed.vue` triggered an animated `scrollToBottom(180)` per event, causing stacked animations and visible jank. Scroll requests are coalesced through `requestAnimationFrame`; preserve burst handling when changing the feed.
|
|
236
250
|
- **`MCP tools` need `default_tools_approval_mode: 'auto'` in `config.mcp_servers`.** Without it Codex flags every MCP tool call as needing user approval ("user cancelled MCP tool call"). Kōbō trusts every tool it spawns, so the options-builder pre-approves the namespace.
|
|
237
251
|
|
|
238
252
|
## Workspace operations
|
|
239
253
|
|
|
254
|
+
### Attachments during creation
|
|
255
|
+
|
|
256
|
+
`CreationAttachments.vue` keeps `File[]` in the create form, with image-only
|
|
257
|
+
object-URL previews revoked on removal/unmount. The workspace store sends
|
|
258
|
+
existing JSON requests unchanged when there are no files; attachments use
|
|
259
|
+
multipart fields `workspace` (one JSON string) and repeated `attachments`.
|
|
260
|
+
The legacy `images` field is accepted too; both fields share validation limits.
|
|
261
|
+
`attachment-service.ts` validates before workspace/git side effects,
|
|
262
|
+
then saves after the worktree exists and before setup/agent launch. Images use
|
|
263
|
+
`image-service.ts`; documents use generated filenames in `.ai/attachments/`,
|
|
264
|
+
exclusive writes and the existing directory-containment helper. File ownership
|
|
265
|
+
is recorded immediately after exclusive open so partial writes are cleaned on
|
|
266
|
+
failure. Creation excludes `.ai/attachments/` from Git; document uploads also
|
|
267
|
+
ensure that exclusion for existing workspaces. Never use an
|
|
268
|
+
uploaded filename as a storage path. The initial prompt stores image references
|
|
269
|
+
and document names/paths for normal starts and setup retries. Documents are
|
|
270
|
+
copied byte-for-byte, with no PDF extraction or OCR.
|
|
271
|
+
Limits and supported formats live in `src/shared/attachments.ts`:
|
|
272
|
+
10 files, 50 MiB each, 50 MiB total; the creation route limits the body to 51 MiB.
|
|
273
|
+
Document validation uses supported filename extensions because browser MIME
|
|
274
|
+
labels can be empty or generic. A failed creation cleans its own uploaded files
|
|
275
|
+
from reused worktrees, preserving existing files; setup failures retain the
|
|
276
|
+
workspace, files and initial prompt. Comparison requests copy the same browser
|
|
277
|
+
files independently. Attachments are not persisted in form presets.
|
|
278
|
+
See [creation attachments](CONFIGURATION.md#attachments-in-the-creation-description).
|
|
279
|
+
|
|
280
|
+
### Chat attachments
|
|
281
|
+
|
|
282
|
+
`POST /api/workspaces/:id/attachments` in `routes/images.ts` accepts one multipart
|
|
283
|
+
`attachment`, validates it with the shared `attachments.ts` policy and returns
|
|
284
|
+
`uid`, `kind`, `path`, `originalName`, and `reference`. It uses the same storage as
|
|
285
|
+
creation and a lifecycle guard against concurrent purge/delete. Document deletion
|
|
286
|
+
accepts only the generated filename at `DELETE /:id/attachments/:filename`; images
|
|
287
|
+
retain their existing retrieval/deletion endpoints. The legacy image upload also
|
|
288
|
+
uses the 50 MiB limit.
|
|
289
|
+
|
|
290
|
+
`use-chat-attachments.ts` owns pending upload state, draft-level limits and serial
|
|
291
|
+
uploads. `ChatInput.vue` transfers file ownership when sending/queuing, restores
|
|
292
|
+
badges on failed sends, and discards unsent files on workspace/session changes.
|
|
293
|
+
Late upload receipts are cleaned up using the captured original workspace id.
|
|
294
|
+
Never delete handed-off files when clearing a draft or changing conversations.
|
|
295
|
+
See [chat attachments](CONFIGURATION.md#attachments-in-workspace-chat).
|
|
296
|
+
|
|
240
297
|
### Change source branch
|
|
241
298
|
|
|
242
299
|
`change-source-branch-service.ts` re-targets a workspace onto a new source branch. The default path is a cherry-pick of the branch-proper commits (commits in the working branch but in **neither** the old nor the new base), inspired by the sekur `deploy-preprod-rebase.yml` workflow. The route is `POST /api/workspaces/:id/change-source-branch` and returns a discriminated status: `done | aligned | conflict | too-many | dirty`.
|
|
243
300
|
|
|
244
301
|
- **Built-in cherry-pick**: `fetchAllBranches` → `listProperCommits` → `stashPush` (if dirty + aligned) → backup branch (`kobo-backup/<branch>-<unix-ts>`) → `reset --hard origin/<new>` → cherry-pick replay → optional force-push prompt → forge PR-base update via `provider.changePrBase`. Conflicts leave the worktree in a cherry-pick state for the user/agent to resolve via `POST /:id/git/resolve-with-agent`. `GitConflictError` carries an `operation: 'rebase' | 'merge' | 'cherry-pick'` discriminator.
|
|
245
|
-
- **Custom bash override**: if `effective.changeSourceBranchScript` is non-empty (per-project override or global default), the script **replaces** the built-in flow. Spawned with `bash -c`, cwd = worktree, 5 min timeout, stderr captured (last 8 KB). Exit 0 → Kōbō updates the source-branch metadata; any non-zero exit → the stderr tail is propagated as a clean error. The user-facing menu item only shows when the resolved script is non-empty;
|
|
302
|
+
- **Custom bash override**: if `effective.changeSourceBranchScript` is non-empty (per-project override or global default), the script **replaces** the built-in flow. Spawned with `bash -c`, cwd = worktree, 5 min timeout, stderr captured (last 8 KB). Exit 0 → Kōbō updates the source-branch metadata; any non-zero exit → the stderr tail is propagated as a clean error. The user-facing menu item only shows when the resolved script is non-empty; clearing the effective script hides the action; the global default is the bundled Kōbō script.
|
|
246
303
|
- **Custom-script env vars**: `KOBO_NEW_BASE`, `KOBO_OLD_BASE`, `KOBO_WORKING_BRANCH`, `KOBO_WORKTREE_PATH`, `KOBO_PROJECT_PATH`, `KOBO_PROJECT_NAME`, `KOBO_WORKSPACE_ID`, `KOBO_WORKSPACE_NAME`, `KOBO_FORGE`, `KOBO_PR_NUMBER` (empty when no PR/MR is open). The default script lives in `settings-defaults.ts` and is seeded into `global.changeSourceBranchScript` by settings migration v33; the client reads it through `GET /api/settings/defaults` for the "Reset to Kōbō default" button. See [CONFIGURATION.md → Custom change-source-branch script](CONFIGURATION.md#custom-change-source-branch-script).
|
|
247
304
|
|
|
248
305
|
### Lifecycle hooks
|
|
@@ -275,9 +332,9 @@ One task, two engines, two sibling worktrees. `POST /api/workspaces` accepts a `
|
|
|
275
332
|
|
|
276
333
|
`src/server/services/worktree-purge-service.ts` removes a workspace's worktree from disk while preserving the chat history and PR metadata. Triggered manually via `POST /api/workspaces/:id/purge-worktree` from the workspace context menu, or automatically by the pr-watcher when a PR transitions to MERGED **and** `global.autoPurgeOnPrMerged` is enabled (Settings → Worktrees toggle, settings migration v36).
|
|
277
334
|
|
|
278
|
-
Sequence: `captureRestoreData` (best-effort forge lookup for PR number / URL / merge SHA) → stop agent + dev server + terminal → `archiveWorkspace` → `removeWorktree` → `markWorktreePurged(restoreData)` → emit `workspace:worktree-purged`. Permission errors on removal (EACCES / EPERM, typically Docker-owned files in `node_modules` / `vendor`)
|
|
335
|
+
Sequence: `captureRestoreData` (best-effort forge lookup for PR number / URL / merge SHA) → stop agent + dev server + terminal → `archiveWorkspace` → `removeWorktree` → `markWorktreePurged(restoreData)` → emit `workspace:worktree-purged`. Permission errors on removal (EACCES / EPERM, typically Docker-owned files in `node_modules` / `vendor`) trigger an attempt to reclaim ownership through a temporary Docker container and retry removal. If recovery fails, the UI offers manual cleanup guidance and prevention tips (Docker `USER`, default ACLs). Agent and dev-server shutdown must be confirmed before removal; shutdown failures leave the worktree in place. See [CONFIGURATION.md → Auto-purge worktree on PR merged](CONFIGURATION.md#auto-purge-worktree-on-pr-merged).
|
|
279
336
|
|
|
280
|
-
**Auto-restore on manual recreation.** When the user manually recreates the worktree folder (`gh pr checkout <pr-number>` or `git worktree add <path> <branch>`), the pr-watcher detects the folder reappearing on its next 30 s tick via `autoRestoreManuallyRecreatedWorktrees()`: it iterates archived workspaces with `worktreePurgedAt`,
|
|
337
|
+
**Auto-restore on manual recreation.** When the user manually recreates the worktree folder (`gh pr checkout <pr-number>` or `git worktree add <path> <branch>`), the pr-watcher detects the folder reappearing on its next 30 s tick via `autoRestoreManuallyRecreatedWorktrees()`: it iterates archived workspaces with `worktreePurgedAt`, validates the exact worktree root, repository and working branch via `isMatchingWorkspaceWorktree`, and on a match calls `restoreWorktreeFromDisk(id)` which clears `worktree_purged_at` + `worktree_purge_restore_data` + `archived_at` in one transaction, then emits `workspace:worktree-restored`. The client websocket store reuses the same handler as `workspace:archived`/`unarchived` to refresh both the active and archived workspace lists. No UI action needed.
|
|
281
338
|
|
|
282
339
|
**Restore**: `POST /api/workspaces/:id/restore-worktree` calls `worktree-restore-service.ts` to recreate the exact checkout and only then clear purge/archive metadata via `restoreWorktreeFromDisk`. Source order: surviving local branch → optional `headCommitSha` in the existing restore JSON → exact branch fetched from `origin`. No SQL migration is needed for the optional JSON key; older records remain supported. No reset, overwrite, agent/dev-server start or setup script. `workspace-lifecycle-guard.ts` excludes overlapping restore/purge/delete operations, while the existing common-Git-directory lock serializes Git mutations. The watcher shares `isMatchingWorkspaceWorktree` validation and invalidates in-flight PR checks on restoration; delayed auto-purges carry their expected archive timestamp to avoid purging a workspace restored in the meantime. The context menu and purged banner expose the action; HTTP and the existing `workspace:worktree-restored` event reconcile client lists. Discarded uncommitted/ignored files and dependencies are not recoverable.
|
|
283
340
|
|
|
@@ -307,7 +364,7 @@ The right panel of `DiffViewer.vue` is editable when the workspace agent is stop
|
|
|
307
364
|
|
|
308
365
|
**Route layer** is thin: always wrap the handler body in `try / catch` and return `c.json({ error: message }, status)`. Match the existing shape in `src/server/routes/workspaces.ts`.
|
|
309
366
|
|
|
310
|
-
**
|
|
367
|
+
**Best-effort failures** may be logged and swallowed for non-critical side effects such as lifecycle-hook reporting. Process shutdown is a precondition for destructive operations: use `stopAgentAndWait` with `assertAgentStopped`, propagate dev-server shutdown failures, and retain the worktree when shutdown is unconfirmed. See [Audit remediation contracts](#audit-remediation-contracts-september-2026).
|
|
311
368
|
|
|
312
369
|
**Route ordering matters** in Hono. Static paths (`GET /archived`) MUST be declared **before** dynamic segments (`GET /:id`) or the dynamic segment captures them. There's a regression test locking this invariant in `src/__tests__/routes-workspaces.test.ts`.
|
|
313
370
|
|
|
@@ -317,7 +374,7 @@ The right panel of `DiffViewer.vue` is editable when the workspace agent is stop
|
|
|
317
374
|
|
|
318
375
|
## Internationalization (i18n)
|
|
319
376
|
|
|
320
|
-
The frontend uses `vue-i18n`
|
|
377
|
+
The frontend uses `vue-i18n` with 5 supported locales: English (`en`), French (`fr`), German (`de`), Spanish (`es`), Italian (`it`). Translation files live in `src/client/src/i18n/`; the dependency version is declared in `src/client/package.json`.
|
|
321
378
|
|
|
322
379
|
**Mandatory rules for all frontend code:**
|
|
323
380
|
|
|
@@ -330,9 +387,9 @@ The frontend uses `vue-i18n` v10 with 5 supported locales: English (`en`), Frenc
|
|
|
330
387
|
|
|
331
388
|
## Testing discipline
|
|
332
389
|
|
|
333
|
-
- **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
|
|
390
|
+
- **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 `src/__tests__/workspace-service.test.ts` for the setup pattern: `resetDb()` creates a fresh database in a temporary directory, initializes its schema, and closes/removes it after each test.
|
|
334
391
|
- **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.
|
|
335
|
-
- **Frontend tests** cover Pinia stores
|
|
392
|
+
- **Frontend tests** cover Pinia stores, pure utilities, composables, and selected Vue components (for example `ActivityFeed`, `TurnCard`, and `WorkspaceCard`). Run with `cd src/client && npm test`; use Vue Test Utils and the existing `happy-dom` setup for component regressions. Run `cd src/client && npm run type-check` for Vue/TypeScript checking, and use manual smoke tests for browser behavior that the DOM test environment cannot exercise.
|
|
336
393
|
- **`beforeEach(() => vi.clearAllMocks())`** is the convention for all route test files.
|
|
337
394
|
|
|
338
395
|
## Git workflow
|
|
@@ -397,7 +454,7 @@ deviate from the documented system without explicit user approval.
|
|
|
397
454
|
- Don't add confirmation dialogs for reversible actions (archive, unarchive). Only destructive actions (delete) get a dialog.
|
|
398
455
|
- Don't introduce ORMs, query builders, or schema validation libraries; the project is small enough for raw prepared statements and hand-written mappers.
|
|
399
456
|
- Don't break the single-source-of-truth of `CLAUDE.md` → `AGENTS.md` symlink. Edit `AGENTS.md`; `CLAUDE.md` follows automatically.
|
|
400
|
-
- Don't
|
|
457
|
+
- Don't swallow failed or timed-out shutdowns before deleting, purging, or replacing a worktree. Only non-critical best-effort side effects may fail without blocking the primary operation.
|
|
401
458
|
- Don't hardcode user-visible text in the frontend. Every string must go through `$t()` / `t()` with keys in all 5 locale files. See [Internationalization (i18n)](#internationalization-i18n).
|
|
402
459
|
- Don't hardcode hex colors, spacing literals, or font names in components. Use the CSS variables from `src/client/src/css/design-tokens.scss` and the patterns in `DESIGN.md`. See [Design System](#design-system).
|
|
403
460
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.41
|
|
8
|
+
|
|
9
|
+
- feat(attachments): support documents in workspace creation and chat
|
|
10
|
+
|
|
7
11
|
## 1.11.40
|
|
8
12
|
|
|
9
13
|
- feat(workspaces): add external MCP access and harden workspace flows
|
package/README.md
CHANGED
|
@@ -38,14 +38,16 @@ Default port is `3000`; if it's taken, `SERVER_PORT` (checked first) or `PORT` p
|
|
|
38
38
|
SERVER_PORT=9997 PORT=9998 npx @loicngr/kobo@latest
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Kōbō's production build is an installable PWA — use your browser's **Install app** action. Want to run from source or contribute instead? See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Prefer Docker? Jump to [Docker deployment](#docker
|
|
41
|
+
Kōbō's production build is an installable PWA — use your browser's **Install app** action. Want to run from source or contribute instead? See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Prefer Docker? Jump to [Docker deployment](#docker).
|
|
42
42
|
|
|
43
43
|

|
|
44
44
|
|
|
45
45
|
## Everything else Kōbō does
|
|
46
46
|
|
|
47
|
+
- **Attachments from the first prompt**: paste, drop or attach screenshots, Markdown, text files or PDFs to the creation description or workspace chat. Kōbō copies them into the worktree for the agent. See [creation attachments](./CONFIGURATION.md#attachments-in-the-creation-description) and [chat attachments](./CONFIGURATION.md#attachments-in-workspace-chat).
|
|
48
|
+
|
|
47
49
|
- **Command palette & search**: `/` autocompletes skills and commands, `@` fuzzy-completes worktree file paths, `Ctrl+F` searches readable messages, `Ctrl+K` opens a command palette. The global search page deep-links to the same spot.
|
|
48
|
-
- **
|
|
50
|
+
- **MCP toolset (`kobo-tasks`)**: task/acceptance-criteria CRUD, dev server control, a unified `get_ticket` (Notion or Sentry), cross-workspace conversation search, per-session usage, and a `.ai/thoughts` decision log — see the [MCP guide](./src/mcp-server/README.md#tools). External MCP clients can also discover workspaces, read conversations, send messages, and answer questions through HTTP or global stdio.
|
|
49
51
|
|
|
50
52
|

|
|
51
53
|
- **Multi-forge Git panel**: GitHub (`gh`), GitLab (`glab`), or Bitbucket Community (`bkt`), auto-detected from the remote — `Open PR`, `Merge ready PR`, `Change PR base`, `Change source branch`, all from the UI.
|
|
@@ -56,7 +58,7 @@ Kōbō's production build is an installable PWA — use your browser's **Install
|
|
|
56
58
|

|
|
57
59
|
- **Quota-aware**: 5-hour / 7-day Claude usage and Codex rate-limit buckets live in the footer; sessions auto-resume after a reset.
|
|
58
60
|
- **Disk-space purge**: reclaim a merged workspace's `node_modules`/`vendor` weight without losing its chat history — see [`CONFIGURATION.md`](./CONFIGURATION.md#auto-purge-worktree-on-pr-merged).
|
|
59
|
-
- **Lifecycle scripts**: shell scripts run
|
|
61
|
+
- **Lifecycle scripts**: shell scripts run on setup, cleanup, archive, session end, PR merge, or auto-loop stop, with output streamed into the chat.
|
|
60
62
|
- **Observability**: a per-session timeline (duration, tools, tokens, errors) and a downloadable redacted diagnostic JSON.
|
|
61
63
|
- **Optional integrations**: Notion (import missions) and Sentry (fix from issue URL), each independently toggled with a **Test connection** action; local voice transcription via `whisper.cpp`.
|
|
62
64
|
|
|
@@ -105,7 +107,7 @@ src/
|
|
|
105
107
|
├── client/ # Vue 3 + Quasar PWA
|
|
106
108
|
├── mcp-server/ # kobo-tasks MCP server, spawned per workspace
|
|
107
109
|
├── shared/ # types shared backend ↔ frontend
|
|
108
|
-
└── __tests__/ # Vitest suite
|
|
110
|
+
└── __tests__/ # backend Vitest suite; client tests live in client/src/__tests__/
|
|
109
111
|
```
|
|
110
112
|
|
|
111
113
|
[`AGENTS.md`](./AGENTS.md) covers the data model, WebSocket protocol, engine contracts, MCP tool surface, migration discipline, i18n rules, and contribution guidelines.
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
5
|
+
import { DOCUMENT_EXTENSIONS, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_REQUEST_BYTES, validateAttachments, } from '../../shared/attachments.js';
|
|
6
|
+
import { attachmentReference, saveAttachments } from '../services/attachment-service.js';
|
|
4
7
|
import * as imageService from '../services/image-service.js';
|
|
5
8
|
import * as workspaceService from '../services/workspace-service.js';
|
|
6
|
-
import { isPathInside } from '../utils/safe-path.js';
|
|
7
|
-
|
|
8
|
-
const MAX_FILE_SIZE =
|
|
9
|
+
import { ensureDirectoryInside, isPathInside } from '../utils/safe-path.js';
|
|
10
|
+
import { WorkspaceLifecycleBusyError, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
11
|
+
const MAX_FILE_SIZE = MAX_ATTACHMENT_BYTES;
|
|
9
12
|
/** MIME types accepted for image uploads. */
|
|
10
13
|
const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
11
14
|
/** File extension → MIME type, used to serve uploaded images back to the frontend. */
|
|
@@ -18,8 +21,65 @@ const EXT_TO_MIME = {
|
|
|
18
21
|
};
|
|
19
22
|
/** Hono sub-router for workspace image upload and deletion. */
|
|
20
23
|
const app = new Hono();
|
|
24
|
+
// New chat uploads share formats, limits and storage with workspace creation.
|
|
25
|
+
app.post('/:id/attachments', bodyLimit({ maxSize: MAX_ATTACHMENT_REQUEST_BYTES }), async (c) => {
|
|
26
|
+
try {
|
|
27
|
+
const { id } = c.req.param();
|
|
28
|
+
let form;
|
|
29
|
+
try {
|
|
30
|
+
form = await c.req.raw.formData();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return c.json({ error: 'Invalid multipart body' }, 400);
|
|
34
|
+
}
|
|
35
|
+
const files = form.getAll('attachment');
|
|
36
|
+
if (files.length !== 1 || !(files[0] instanceof File))
|
|
37
|
+
return c.json({ error: 'Expected one attachment file' }, 400);
|
|
38
|
+
const file = files[0];
|
|
39
|
+
const error = validateAttachments([file]);
|
|
40
|
+
if (error)
|
|
41
|
+
return c.json({ error: `Invalid attachment: ${error}`, code: error }, 400);
|
|
42
|
+
return await withWorkspaceLifecycleGuard(id, async () => {
|
|
43
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
44
|
+
if (!workspace)
|
|
45
|
+
return c.json({ error: 'Workspace not found' }, 404);
|
|
46
|
+
if (workspace.worktreePurgedAt)
|
|
47
|
+
return c.json({ error: 'Restore the worktree before uploading files' }, 409);
|
|
48
|
+
const [saved] = await saveAttachments(workspace.worktreePath, [file]);
|
|
49
|
+
return c.json({ ...saved, path: saved.relativePath, reference: attachmentReference(saved) }, 201);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, error instanceof WorkspaceLifecycleBusyError ? 409 : 500);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
app.delete('/:id/attachments/:filename', async (c) => {
|
|
57
|
+
try {
|
|
58
|
+
const { id, filename } = c.req.param();
|
|
59
|
+
// Accept only the generated document layout, never a caller-supplied path.
|
|
60
|
+
if (!/^[A-Za-z0-9_-]{10}\.[a-z]+$/.test(filename) ||
|
|
61
|
+
!DOCUMENT_EXTENSIONS.includes(path.extname(filename))) {
|
|
62
|
+
return c.json({ error: 'Invalid attachment filename' }, 400);
|
|
63
|
+
}
|
|
64
|
+
return await withWorkspaceLifecycleGuard(id, async () => {
|
|
65
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
66
|
+
if (!workspace)
|
|
67
|
+
return c.json({ error: 'Workspace not found' }, 404);
|
|
68
|
+
if (workspace.worktreePurgedAt)
|
|
69
|
+
return c.json({ error: 'Worktree is purged' }, 409);
|
|
70
|
+
const directory = ensureDirectoryInside(workspace.worktreePath, '.ai/attachments');
|
|
71
|
+
// unlink removes a replaced leaf symlink itself; it never follows it.
|
|
72
|
+
fs.unlinkSync(path.join(directory, filename));
|
|
73
|
+
return c.body(null, 204);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const missing = error.code === 'ENOENT';
|
|
78
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, error instanceof WorkspaceLifecycleBusyError ? 409 : missing ? 404 : 500);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
21
81
|
// POST /:id/images — upload an image
|
|
22
|
-
app.post('/:id/images', async (c) => {
|
|
82
|
+
app.post('/:id/images', bodyLimit({ maxSize: MAX_ATTACHMENT_REQUEST_BYTES }), async (c) => {
|
|
23
83
|
try {
|
|
24
84
|
const { id } = c.req.param();
|
|
25
85
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -37,7 +97,7 @@ app.post('/:id/images', async (c) => {
|
|
|
37
97
|
const arrayBuffer = await file.arrayBuffer();
|
|
38
98
|
const buffer = Buffer.from(arrayBuffer);
|
|
39
99
|
if (buffer.length > MAX_FILE_SIZE) {
|
|
40
|
-
return c.json({ error: `File too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max:
|
|
100
|
+
return c.json({ error: `File too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max: 50 MB` }, 400);
|
|
41
101
|
}
|
|
42
102
|
const worktreePath = workspace.worktreePath;
|
|
43
103
|
const result = await imageService.saveImage(worktreePath, buffer, file.name);
|
|
@@ -7,12 +7,15 @@ const execFileAsync = promisify(execFileCb);
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { Hono } from 'hono';
|
|
10
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
11
|
+
import { MAX_ATTACHMENT_REQUEST_BYTES } from '../../shared/attachments.js';
|
|
10
12
|
import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, buildGroomingIntro } from '../../shared/auto-loop-prompts.js';
|
|
11
13
|
import { getDb } from '../db/index.js';
|
|
12
14
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
13
15
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
14
16
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
15
17
|
import * as archiveScriptService from '../services/archive-script-service.js';
|
|
18
|
+
import { AttachmentRequestError, attachmentReference, readWorkspaceCreationRequest, removeAttachments, saveAttachments, } from '../services/attachment-service.js';
|
|
16
19
|
import * as autoLoopService from '../services/auto-loop-service.js';
|
|
17
20
|
import { changeSourceBranch } from '../services/change-source-branch-service.js';
|
|
18
21
|
import { listChatHistory, pushChatHistory } from '../services/chat-history-service.js';
|
|
@@ -183,12 +186,16 @@ function emitCreateFailed(creationId, step, message) {
|
|
|
183
186
|
*/
|
|
184
187
|
async function rollbackFailedCreation(workspace, opts) {
|
|
185
188
|
try {
|
|
186
|
-
|
|
189
|
+
const warnings = await deleteWorkspaceWithSideEffects(workspace, {
|
|
187
190
|
deleteLocalBranch: opts.deleteLocalBranch,
|
|
188
191
|
removeWorktree: opts.removeWorktree,
|
|
189
192
|
// Nothing was ever pushed at this point in the creation flow.
|
|
190
193
|
deleteRemoteBranch: false,
|
|
191
194
|
});
|
|
195
|
+
if (!opts.removeWorktree && opts.attachments?.length && fs.existsSync(workspace.worktreePath)) {
|
|
196
|
+
warnings.push(...(await removeAttachments(workspace.worktreePath, opts.attachments)));
|
|
197
|
+
}
|
|
198
|
+
return warnings;
|
|
192
199
|
}
|
|
193
200
|
catch (err) {
|
|
194
201
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -353,8 +360,11 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
|
|
|
353
360
|
return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
|
|
354
361
|
}
|
|
355
362
|
});
|
|
356
|
-
|
|
357
|
-
|
|
363
|
+
const creationBodyLimit = bodyLimit({
|
|
364
|
+
maxSize: MAX_ATTACHMENT_REQUEST_BYTES,
|
|
365
|
+
onError: (c) => c.json({ error: 'Workspace creation request is too large', step: 'validate' }, 413),
|
|
366
|
+
});
|
|
367
|
+
app.post('/', migrationGuard, creationBodyLimit, async (c) => {
|
|
358
368
|
// Declared outside the try so the outermost catch (any unforeseen throw,
|
|
359
369
|
// including a malformed request body) can still name a step instead of
|
|
360
370
|
// leaving the create-progress channel silent. Updated in lockstep with
|
|
@@ -371,14 +381,13 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
371
381
|
// branch. A failure before that point must not delete a branch we never made.
|
|
372
382
|
let createdBranch = false;
|
|
373
383
|
let createdWorktree = false;
|
|
384
|
+
let creationAttachments = [];
|
|
374
385
|
// Hoisted out of the `build-prompt` block (declared with `const` there) so
|
|
375
386
|
// both rollback paths — the `start-agent` branch and the outermost catch —
|
|
376
387
|
// can read it to decide whether the setup-script caveat applies.
|
|
377
388
|
let setupScriptConfigured = false;
|
|
378
389
|
try {
|
|
379
|
-
const body = await c.req
|
|
380
|
-
.json()
|
|
381
|
-
.catch(() => ({}));
|
|
390
|
+
const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
|
|
382
391
|
// workingBranch is derived from git when worktreePath is provided, so
|
|
383
392
|
// it's not required in that flow. The other 3 fields stay mandatory.
|
|
384
393
|
if (!body.name || !body.projectPath || !body.sourceBranch) {
|
|
@@ -890,6 +899,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
890
899
|
['.ai/.git-conventions.md', '.git-conventions.md'],
|
|
891
900
|
['.ai/thoughts/', 'thoughts/'],
|
|
892
901
|
['.ai/images/', 'images/'],
|
|
902
|
+
['.ai/attachments/', 'attachments/'],
|
|
893
903
|
['.ai/.setup-script.tmp', '.setup-script.tmp'],
|
|
894
904
|
['.ai/.cleanup-script.tmp', '.cleanup-script.tmp'],
|
|
895
905
|
['.ai/.archive-script.tmp', '.archive-script.tmp'],
|
|
@@ -943,6 +953,12 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
943
953
|
let agentStartError = null;
|
|
944
954
|
currentStep = 'write-context-files';
|
|
945
955
|
emitCreateProgress(creationId, currentStep);
|
|
956
|
+
creationAttachments = await saveAttachments(worktreePath, attachments);
|
|
957
|
+
if (creationAttachments.length > 0) {
|
|
958
|
+
body.description = [body.description?.trim(), ...creationAttachments.map(attachmentReference)]
|
|
959
|
+
.filter(Boolean)
|
|
960
|
+
.join('\n\n');
|
|
961
|
+
}
|
|
946
962
|
// Save Notion content as markdown in worktree
|
|
947
963
|
let notionFilePath = null;
|
|
948
964
|
if (notionContent && body.notionUrl) {
|
|
@@ -1343,6 +1359,7 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
1343
1359
|
const rollbackWarnings = await rollbackFailedCreation(workspace, {
|
|
1344
1360
|
deleteLocalBranch: createdBranch,
|
|
1345
1361
|
removeWorktree: createdWorktree,
|
|
1362
|
+
attachments: creationAttachments,
|
|
1346
1363
|
});
|
|
1347
1364
|
// The setup script has already run by this point — see the caveat's own
|
|
1348
1365
|
// comment for what that rollback cannot reach.
|
|
@@ -1400,6 +1417,7 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
1400
1417
|
const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, {
|
|
1401
1418
|
deleteLocalBranch: createdBranch,
|
|
1402
1419
|
removeWorktree: createdWorktree,
|
|
1420
|
+
attachments: creationAttachments,
|
|
1403
1421
|
});
|
|
1404
1422
|
logError('workspaces', 'Rolled back a creation that failed outside any per-step handler', {
|
|
1405
1423
|
workspaceId: createdWorkspace.id,
|
|
@@ -1418,7 +1436,7 @@ Once the brainstorming + planning steps above are complete and you have a saved
|
|
|
1418
1436
|
return c.json({ error: failure, step: currentStep, rollback: { done: true, warnings: rollbackWarnings } }, 500);
|
|
1419
1437
|
}
|
|
1420
1438
|
emitCreateFailed(creationId, currentStep, message);
|
|
1421
|
-
return c.json({ error: message, step: currentStep }, 500);
|
|
1439
|
+
return c.json({ error: message, step: currentStep }, err instanceof AttachmentRequestError ? 400 : 500);
|
|
1422
1440
|
}
|
|
1423
1441
|
});
|
|
1424
1442
|
// POST /api/workspaces/:id/sessions — create a new idle agent session
|