@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.
Files changed (80) hide show
  1. package/AGENTS.md +89 -32
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +6 -4
  4. package/dist/server/routes/images.js +65 -5
  5. package/dist/server/routes/workspaces.js +25 -7
  6. package/dist/server/services/attachment-service.js +122 -0
  7. package/dist/server/services/image-service.js +16 -7
  8. package/dist/shared/attachments.js +58 -0
  9. package/package.json +1 -1
  10. package/src/client/dist/pwa/assets/{ActivityFeed-MqsC-95i.js → ActivityFeed-DTIe6O9o.js} +1 -1
  11. package/src/client/dist/pwa/assets/{ChangelogPage-BxURWYxa.js → ChangelogPage-BWeu0YeI.js} +1 -1
  12. package/src/client/dist/pwa/assets/{ComparisonPanel-DjiMDAUi.js → ComparisonPanel-o_EHlTwV.js} +1 -1
  13. package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
  14. package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
  15. package/src/client/dist/pwa/assets/{DashboardPage-mA5RZf1t.js → DashboardPage-CIo_gesD.js} +1 -1
  16. package/src/client/dist/pwa/assets/{DiffViewer-Ci7ucmy3.js → DiffViewer-DEcf48KI.js} +3 -3
  17. package/src/client/dist/pwa/assets/{DocumentsPanel-RZTvcJkT.js → DocumentsPanel-D9vcB91W.js} +1 -1
  18. package/src/client/dist/pwa/assets/{DrawerToggleButton-CTOc3yA0.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
  19. package/src/client/dist/pwa/assets/{GitPanel-_kL2gMoy.js → GitPanel-DCmB2R5i.js} +3 -3
  20. package/src/client/dist/pwa/assets/{HealthPage-D3__hKJ5.js → HealthPage-_q3vy_xn.js} +1 -1
  21. package/src/client/dist/pwa/assets/{MainLayout-B3JKUm3n.js → MainLayout-Duwnq3cc.js} +2 -2
  22. package/src/client/dist/pwa/assets/{QBtnDropdown-td0RAVPx.js → QBtnDropdown-BeiZIHVA.js} +1 -1
  23. package/src/client/dist/pwa/assets/{QDrawer-BkyFPV7a.js → QDrawer-Fj3M9QGD.js} +1 -1
  24. package/src/client/dist/pwa/assets/{QSelect-WspK3KC7.js → QSelect-DtSUuK8t.js} +1 -1
  25. package/src/client/dist/pwa/assets/{QTooltip-7pw7iuV0.js → QTooltip-DwkX8eQ6.js} +1 -1
  26. package/src/client/dist/pwa/assets/{SchedulePanel-CLNM13oo.js → SchedulePanel-BlZDd7TK.js} +1 -1
  27. package/src/client/dist/pwa/assets/{SearchPage-CmRgIvu4.js → SearchPage-BGi1yRxU.js} +1 -1
  28. package/src/client/dist/pwa/assets/{SettingsPage-Cj6Y_TlN.js → SettingsPage-DnO-ImWE.js} +1 -1
  29. package/src/client/dist/pwa/assets/{SplitWorkspacePage-CCHGEsKf.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
  30. package/src/client/dist/pwa/assets/{TerminalPanel-ijmsYk-2.js → TerminalPanel-IdPIGp2_.js} +1 -1
  31. package/src/client/dist/pwa/assets/{TouchPan-DuQFjlQF.js → TouchPan-DTJqxVp9.js} +1 -1
  32. package/src/client/dist/pwa/assets/{TourReplayButton-C5RF0lOK.js → TourReplayButton-Bj2GGyhU.js} +1 -1
  33. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BR_XElog.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
  34. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
  35. package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
  36. package/src/client/dist/pwa/assets/{browser-DUqzdkbg.js → browser-BFj5wwbS.js} +1 -1
  37. package/src/client/dist/pwa/assets/{build-path-tree-ImiSpzfI.js → build-path-tree-C-HSiySk.js} +1 -1
  38. package/src/client/dist/pwa/assets/{de-CaHID9mW.js → de-BdLe7apJ.js} +1 -1
  39. package/src/client/dist/pwa/assets/{documents-2C1hn1pw.js → documents-C2Xnjh9Z.js} +1 -1
  40. package/src/client/dist/pwa/assets/{editor.api-Bm1vVaBr.js → editor.api-BbKfnNmv.js} +1 -1
  41. package/src/client/dist/pwa/assets/{efforts-_D8DFYXo.js → efforts-BM7fdaaL.js} +1 -1
  42. package/src/client/dist/pwa/assets/{engineFeatures-CZ9qfBYq.js → engineFeatures-b74YRODT.js} +1 -1
  43. package/src/client/dist/pwa/assets/{es-ZhzlCmer.js → es-C1LM-3aq.js} +1 -1
  44. package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
  45. package/src/client/dist/pwa/assets/{format-pPImdFUy.js → format-CnbUxAmw.js} +1 -1
  46. package/src/client/dist/pwa/assets/{formatters-dTX38ZsV.js → formatters-B2uUIbAY.js} +1 -1
  47. package/src/client/dist/pwa/assets/{fr-BfXw7L7J.js → fr-KCK6r5ga.js} +1 -1
  48. package/src/client/dist/pwa/assets/{freemarker2-DGluDnAm.js → freemarker2-Cu1vGY0x.js} +1 -1
  49. package/src/client/dist/pwa/assets/{handlebars-BgviTnpD.js → handlebars-CETuqLyz.js} +1 -1
  50. package/src/client/dist/pwa/assets/{html-CV-MrunL.js → html-BTNpr39P.js} +1 -1
  51. package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
  52. package/src/client/dist/pwa/assets/{index-9tiw3NjK.js → index-mvh5w1cM.js} +3 -3
  53. package/src/client/dist/pwa/assets/{it-CHAurcTF.js → it-BM6t0IGU.js} +1 -1
  54. package/src/client/dist/pwa/assets/{javascript-C2VFcHf2.js → javascript-C7Ncb6V7.js} +1 -1
  55. package/src/client/dist/pwa/assets/{liquid-Dg9ZhbP5.js → liquid-D0hUdg4x.js} +1 -1
  56. package/src/client/dist/pwa/assets/{mdx-ImgrPYNl.js → mdx-BK5H1Xst.js} +1 -1
  57. package/src/client/dist/pwa/assets/{monaco-contributions-waWZXS1T.js → monaco-contributions-BBW6JNgz.js} +1 -1
  58. package/src/client/dist/pwa/assets/{monaco.contribution-BB2rtCHM.js → monaco.contribution-1LZrAlY4.js} +2 -2
  59. package/src/client/dist/pwa/assets/{network-auth-h6O0qCMA.js → network-auth-CVGS__6a.js} +1 -1
  60. package/src/client/dist/pwa/assets/{notify-theme-Dyg-FGI6.js → notify-theme-DIKLxr8F.js} +1 -1
  61. package/src/client/dist/pwa/assets/{pending-diff-open-Dfpg92li.js → pending-diff-open-B02zuH1j.js} +1 -1
  62. package/src/client/dist/pwa/assets/{permissionModes-DDWmNBW1.js → permissionModes-C3otN1Vk.js} +1 -1
  63. package/src/client/dist/pwa/assets/{project-color-XUfY_zbw.js → project-color-BUcW_eZA.js} +1 -1
  64. package/src/client/dist/pwa/assets/{python-DVLs-DBA.js → python-DEeUVvnp.js} +1 -1
  65. package/src/client/dist/pwa/assets/{razor-D34BqMwB.js → razor-CG6cL8ya.js} +1 -1
  66. package/src/client/dist/pwa/assets/{render-chat-markdown-DwUkPy-O.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
  67. package/src/client/dist/pwa/assets/{typescript-C7-UpNXx.js → typescript-CSQAg5UC.js} +1 -1
  68. package/src/client/dist/pwa/assets/{use-tours-DfE-kwrX.js → use-tours-_6HwJ_Rp.js} +1 -1
  69. package/src/client/dist/pwa/assets/{workspace-templates-DwwdOEAq.js → workspace-templates-B8WWsWEI.js} +1 -1
  70. package/src/client/dist/pwa/assets/{xml-Cx1oQhEr.js → xml-BSB4-DXR.js} +1 -1
  71. package/src/client/dist/pwa/assets/{yaml-BQOiJsdx.js → yaml-CSGKph85.js} +1 -1
  72. package/src/client/dist/pwa/index.html +1 -1
  73. package/src/client/dist/pwa/sw.js +1 -1
  74. package/src/mcp-server/README.md +51 -7
  75. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
  76. package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +0 -2
  77. package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +0 -4
  78. package/src/client/dist/pwa/assets/WorkspacePage-CqcMweNL.css +0 -1
  79. package/src/client/dist/pwa/assets/expand-template-CbDNnTPP.js +0 -1
  80. 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 multiple Claude Code agents across isolated git worktrees. Each "workspace" is a self-contained mission with its own worktree, branch, Claude session, optional dev server, optional Notion source-of-truth, and a dedicated MCP tools server. A Vue 3 UI lets the human track progress, read live agent output, and manage the lifecycle.
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 HTTP/WS requests behind a shared token (localhost always exempt). No multi-tenant concerns.
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/pure utilities. Vue components are not unit-tested; type-checking and manual smoke tests cover their behavior. Run `npm test` and `(cd src/client && npm test)` for the complete suites.
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 test # run full vitest suite (backend + client tests)
40
- (cd src/client && npm test) # client tests only (stores, utils)
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 # incremental migrations, bumped per feature
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 # maps engine AgentEvent stream to WS emit + DB side-effects
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/ # ForgeProvider abstraction — github / gitlab / none, with registry + auto-resolve
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
- │ │ ├── plans.ts # /api/workspaces/:id/plans — plan file browser (read-only)
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, NotionPanel, AcceptancePanel, ChatInput, GitPanel, PlansPanel, WorkspaceAttentionLabels, HelpMenu…
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
- └── __tests__/ # vitest, one file per service/route
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 `ScheduleWakeup` tool: target_at (ISO UTC), prompt, reason; CASCADE DELETE on workspace |
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)` is the source of truth for **fresh installs only**. It creates every table at its current shape. New installations (empty `data/` directory) run `initSchema` once and land at the latest `SCHEMA_VERSION`.
136
- - **`src/server/db/migrations.ts`**: `runMigrations(db)` reads the current `version` from the `schema_version` table and sequentially applies every pending migration block up to `SCHEMA_VERSION`. Existing databases upgrade through this path.
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. Bump `SCHEMA_VERSION` in `migrations.ts` (e.g. `1` → `2`)
145
- 2. Add a new guarded block at the bottom of `runMigrations` that applies only if `currentVersion < newVersion`, using `db.exec` (better-sqlite3) to run raw SQL like `ALTER TABLE workspaces ADD COLUMN new_field TEXT`
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. At the end of `runMigrations`, bump the row in `schema_version` to the new `SCHEMA_VERSION` (the existing code already does this)
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.** `runMigrations(db)` is called from `getDb()` via `src/server/db/index.ts`.
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
- Common types: `agent:output`, `agent:status`, `agent:error`, `user:message`, `task:updated`, `devserver:status`, `workspace:archived`, `workspace:unarchived`, `sync:response`.
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 a `ForgeProvider` interface with three concrete providers: `github` (wraps the `gh` CLI), `gitlab` (wraps the `glab` CLI), and `none` (a no-op that disables PR/MR features cleanly). The public surface is two functions: `getForgeProvider(name)` in `registry.ts` (returns the provider for a named forge) and `resolveForge(projectPath)` in `resolve.ts` (reads the per-project `forge` setting, then falls back to auto-detection from the `origin` remote URL: host contains `github.com` → GitHub, host contains `gitlab` → GitLab, otherwise `none`). The per-project `forge` setting (`'auto' | 'github' | 'gitlab' | 'none'`, default `'auto'`) is stored in `settings.json` and seeded by settings migration v32. PR routes (`open-pr`, `change-pr-base`) and the pr-watcher go through the resolved provider. **Kōbō ships no forge credentials**, so the user must install and authenticate `gh` or `glab` themselves; when the CLI is absent or unauthenticated, PR/MR actions are disabled with a tooltip rather than a raw error.
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 MCP command and args can be overridden via `NOTION_MCP_COMMAND` (default `npx`) and `NOTION_MCP_ARGS` (default `-y @notionhq/notion-mcp-server`) for pinning a specific version or using a fork.
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/`): uses `@anthropic-ai/claude-agent-sdk` (in-process async iterator). Spawns no subprocess. Auth via `~/.claude.json` or `ANTHROPIC_API_KEY` env var. 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`.
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.** Codex emits one `message:text` event per token-delta (50-200 per message), versus Claude which emits ~1 per content block. The naive `eventCount` watcher in `ActivityFeed.vue` triggered an animated `scrollToBottom(180)` per event, causing stacked animations and visible jank. The fix coalesces requests through `requestAnimationFrame` and only animates the *first* scroll after a quiet period; subsequent scrolls during a burst snap instantly.
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; empty means the feature is disabled (opt-in).
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`) are detected via regex on the error message and the warning toast carries a copy-pasteable `sudo rm -rf` + `git worktree prune` recovery command plus prevention tips (Docker `USER` directive, `setfacl` default ACL). See [CONFIGURATION.md → Auto-purge worktree on PR merged](CONFIGURATION.md#auto-purge-worktree-on-pr-merged).
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`, checks `fs.existsSync(worktreePath)`, and on a hit 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.
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
- **Swallowed failures** are acceptable (and required) for best-effort side effects like `agentManager.stopAgent` and `devServerService.stopDevServer` during delete/archive. Log with `console.error` and continue. Never let these break the happy path.
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` v10 with 5 supported locales: English (`en`), French (`fr`), German (`de`), Spanish (`es`), Italian (`it`). Translation files live in `src/client/src/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 existing tests in `src/__tests__/workspace-service.test.ts` for the setup pattern (fresh in-memory DB per test via `resetDb()`).
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 (`workspace-store`, `settings-store`, `templates-store`) and pure utility modules (`expand-template`). Run with `cd src/client && npm test`. Vue components are **not** tested: type-check via `npx tsc --noEmit` + manual smoke testing covers UI behavior.
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 skip `try/catch` swallowing on best-effort cleanup (agent stop, dev-server stop, worktree removal). These must never break the primary operation.
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-deployment).
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
  ![Diff viewer with side-by-side changes](docs/assets/images/diff-viewer.png)
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
- - **Full 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 [`AGENTS.md`](./AGENTS.md) for the full tool list.
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
  ![Sub-agents panel showing parallel tool calls](docs/assets/images/sub-agents-panel.png)
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
  ![Agent asking a clarifying question, awaiting the user's answer](docs/assets/images/agent-question.png)
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 automatically on setup, cleanup, and archive, with output streamed into the chat.
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, backend + client, thousands of tests
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
- /** Maximum allowed upload size for a single image (10 MB). */
8
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
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: 10 MB` }, 400);
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
- return await deleteWorkspaceWithSideEffects(workspace, {
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
- // POST /api/workspaces — create workspace
357
- app.post('/', migrationGuard, async (c) => {
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