@loicngr/kobo 1.11.8 → 1.11.10

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 (180) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +2 -2
  4. package/dist/server/db/migrations.js +155 -19
  5. package/dist/server/db/schema.js +93 -0
  6. package/dist/server/index.js +35 -26
  7. package/dist/server/routes/dev-server.js +10 -8
  8. package/dist/server/routes/documents.js +33 -3
  9. package/dist/server/routes/health.js +16 -15
  10. package/dist/server/routes/images.js +17 -2
  11. package/dist/server/routes/workspaces.js +64 -49
  12. package/dist/server/services/agent/engines/codex/engine.js +5 -1
  13. package/dist/server/services/agent/orchestrator.js +8 -1
  14. package/dist/server/services/auto-loop-service.js +1 -1
  15. package/dist/server/services/change-source-branch-service.js +43 -3
  16. package/dist/server/services/dev-server-service.js +55 -41
  17. package/dist/server/services/forge/bitbucket/provider.js +6 -11
  18. package/dist/server/services/forge/github/provider.js +10 -9
  19. package/dist/server/services/forge/gitlab/provider.js +11 -13
  20. package/dist/server/services/git-stats-service.js +8 -6
  21. package/dist/server/services/image-service.js +34 -5
  22. package/dist/server/services/notion-service.js +45 -5
  23. package/dist/server/services/pr-watcher-service.js +20 -9
  24. package/dist/server/services/search-service.js +53 -40
  25. package/dist/server/services/websocket-service.js +8 -3
  26. package/dist/server/services/workspace-service.js +81 -10
  27. package/dist/server/services/worktree-purge-service.js +1 -1
  28. package/dist/server/utils/git-ops.js +89 -22
  29. package/dist/server/utils/mcp-client.js +4 -0
  30. package/dist/server/utils/safe-path.js +14 -0
  31. package/dist/server/utils/script-runner.js +16 -1
  32. package/package.json +22 -22
  33. package/src/client/dist/pwa/assets/{ActivityFeed-D9EfbRqv.css → ActivityFeed-BrjeLlhi.css} +1 -1
  34. package/src/client/dist/pwa/assets/ActivityFeed-UEAexGjc.js +8 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-XSYwu9XA.js +1 -0
  36. package/src/client/dist/pwa/assets/{ClosePopup-_rbZbpfb.js → ClosePopup-dpcrW9DC.js} +1 -1
  37. package/src/client/dist/pwa/assets/CreatePage-BK__rjvR.js +2 -0
  38. package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
  39. package/src/client/dist/pwa/assets/{DiffViewer-CRrDdYBa.css → DiffViewer-CS3P9Ngi.css} +1 -1
  40. package/src/client/dist/pwa/assets/DiffViewer-DnFN6Sc_.js +8 -0
  41. package/src/client/dist/pwa/assets/HealthPage-DVrZfMI6.js +1 -0
  42. package/src/client/dist/pwa/assets/MainLayout-D_3nFaCG.js +19 -0
  43. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
  44. package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
  45. package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
  46. package/src/client/dist/pwa/assets/QBtnToggle-BgfN19l8.js +1 -0
  47. package/src/client/dist/pwa/assets/QChip-EQGYShN0.js +1 -0
  48. package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
  49. package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
  50. package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
  51. package/src/client/dist/pwa/assets/QSelect-CtxxliRU.js +36 -0
  52. package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
  53. package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
  54. package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
  55. package/src/client/dist/pwa/assets/QTooltip-HEIFh5w9.js +1 -0
  56. package/src/client/dist/pwa/assets/SearchPage-1B5HOf90.js +1 -0
  57. package/src/client/dist/pwa/assets/SettingsPage-BXiWr2ba.css +1 -0
  58. package/src/client/dist/pwa/assets/SettingsPage-v8f4rrCt.js +16 -0
  59. package/src/client/dist/pwa/assets/TouchPan-lynWXY69.js +1 -0
  60. package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
  61. package/src/client/dist/pwa/assets/WorkspacePage-Vjzi8YnW.js +4 -0
  62. package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
  63. package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
  64. package/src/client/dist/pwa/assets/cssMode-cg8iVI-i.js +1 -0
  65. package/src/client/dist/pwa/assets/documents-Dlx8yiSO.js +1 -0
  66. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
  67. package/src/client/dist/pwa/assets/editor.api-CfaJRkrX.js +847 -0
  68. package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
  69. package/src/client/dist/pwa/assets/expand-template-D3LZldj1.js +1 -0
  70. package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
  71. package/src/client/dist/pwa/assets/{freemarker2-DczqHd5F.js → freemarker2-B_8OIClK.js} +1 -1
  72. package/src/client/dist/pwa/assets/handlebars-CaYi_uxU.js +1 -0
  73. package/src/client/dist/pwa/assets/html-BnCUDUl1.js +1 -0
  74. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
  75. package/src/client/dist/pwa/assets/htmlMode-BZjKGnQF.js +1 -0
  76. package/src/client/dist/pwa/assets/i18n-B6SDs7Lm.js +1 -0
  77. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
  78. package/src/client/dist/pwa/assets/index-CoOsPyyG.js +82 -0
  79. package/src/client/dist/pwa/assets/{javascript-Bs83z_W4.js → javascript-DkSfoeHU.js} +1 -1
  80. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
  81. package/src/client/dist/pwa/assets/{jsonMode-Bc-8bzJi.js → jsonMode-yKXxfx1V.js} +3 -3
  82. package/src/client/dist/pwa/assets/{kobo-commands-B2kM0INN.js → kobo-commands-vn1IQxLn.js} +1 -1
  83. package/src/client/dist/pwa/assets/layout-CiQpugQB.js +1 -0
  84. package/src/client/dist/pwa/assets/liquid-Dlhysv3U.js +1 -0
  85. package/src/client/dist/pwa/assets/lspLanguageFeatures-CeYSQFUz.js +4 -0
  86. package/src/client/dist/pwa/assets/mdx-B8iME5NA.js +1 -0
  87. package/src/client/dist/pwa/assets/network-auth-j2Drut6q.js +1 -0
  88. package/src/client/dist/pwa/assets/pending-diff-open-B4RYz7G_.js +1 -0
  89. package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
  90. package/src/client/dist/pwa/assets/{project-color-DIFZ95_g.js → project-color-e3OdFi9X.js} +1 -1
  91. package/src/client/dist/pwa/assets/python-CbFbC0ZR.js +1 -0
  92. package/src/client/dist/pwa/assets/razor-7c2NCdFI.js +1 -0
  93. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
  94. package/src/client/dist/pwa/assets/render-chat-markdown-Fer8kY38.js +67 -0
  95. package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
  96. package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
  97. package/src/client/dist/pwa/assets/{tsMode-DOsxzgZi.js → tsMode-BmShKjbx.js} +1 -1
  98. package/src/client/dist/pwa/assets/typescript-Dhu7Upbs.js +1 -0
  99. package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
  100. package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
  101. package/src/client/dist/pwa/assets/use-onboarding-fe4sPCNf.js +2 -0
  102. package/src/client/dist/pwa/assets/use-quasar-CeHlTrGg.js +1 -0
  103. package/src/client/dist/pwa/assets/vs-B-xsceNQ.js +56 -0
  104. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
  105. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
  106. package/src/client/dist/pwa/assets/workers-zVMTXVoC.js +1 -0
  107. package/src/client/dist/pwa/assets/xml-D64wpCtR.js +1 -0
  108. package/src/client/dist/pwa/assets/yaml-CLSRKwfx.js +1 -0
  109. package/src/client/dist/pwa/index.html +8 -8
  110. package/src/client/dist/pwa/manifest.json +1 -1
  111. package/src/client/dist/pwa/sw.js +1 -1
  112. package/src/client/dist/pwa/assets/ActivityFeed-BkkyAw81.js +0 -8
  113. package/src/client/dist/pwa/assets/ChangelogPage-BlvhBx6o.js +0 -1
  114. package/src/client/dist/pwa/assets/CreatePage-B_JcmRfU.css +0 -1
  115. package/src/client/dist/pwa/assets/CreatePage-RSyoQwD7.js +0 -2
  116. package/src/client/dist/pwa/assets/DiffViewer-BSNxNex2.js +0 -8
  117. package/src/client/dist/pwa/assets/HealthPage-CUD1kipV.js +0 -1
  118. package/src/client/dist/pwa/assets/MainLayout-BCLgsn0C.js +0 -37
  119. package/src/client/dist/pwa/assets/MainLayout-DlS2wxkK.css +0 -1
  120. package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
  121. package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
  122. package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
  123. package/src/client/dist/pwa/assets/QBtnToggle-xg3LYW7D.js +0 -1
  124. package/src/client/dist/pwa/assets/QChip-DhUfnmbz.js +0 -1
  125. package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
  126. package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
  127. package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
  128. package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
  129. package/src/client/dist/pwa/assets/QSelect-BhyT0k2N.js +0 -36
  130. package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
  131. package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
  132. package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
  133. package/src/client/dist/pwa/assets/QTooltip-ybbFi3KK.js +0 -1
  134. package/src/client/dist/pwa/assets/SearchPage-Bb7TG3Vo.js +0 -1
  135. package/src/client/dist/pwa/assets/SettingsPage-C2qe4xKu.css +0 -1
  136. package/src/client/dist/pwa/assets/SettingsPage-CjPnnn60.js +0 -16
  137. package/src/client/dist/pwa/assets/ThinkingItem-B1RKyCIS.js +0 -1
  138. package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
  139. package/src/client/dist/pwa/assets/TouchPan-DzZPYIol.js +0 -1
  140. package/src/client/dist/pwa/assets/WorkspacePage-6Rgjarvf.js +0 -4
  141. package/src/client/dist/pwa/assets/WorkspacePage-Bgxg_Rua.css +0 -1
  142. package/src/client/dist/pwa/assets/build-path-tree-KRACVkD3.js +0 -1
  143. package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
  144. package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
  145. package/src/client/dist/pwa/assets/cssMode-CQsB6mFO.js +0 -1
  146. package/src/client/dist/pwa/assets/documents-C7efBF5t.js +0 -1
  147. package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
  148. package/src/client/dist/pwa/assets/editor.api2-C58pkxmw.js +0 -872
  149. package/src/client/dist/pwa/assets/editor.main-BhLqjdq4.js +0 -6
  150. package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
  151. package/src/client/dist/pwa/assets/expand-template-BA-hhG5v.js +0 -1
  152. package/src/client/dist/pwa/assets/formatters-DugWNOEs.js +0 -1
  153. package/src/client/dist/pwa/assets/handlebars-DmA7twi8.js +0 -1
  154. package/src/client/dist/pwa/assets/html-DtZDrViU.js +0 -1
  155. package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
  156. package/src/client/dist/pwa/assets/htmlMode-DO9_qUdT.js +0 -1
  157. package/src/client/dist/pwa/assets/i18n-CipNqHTm.js +0 -1
  158. package/src/client/dist/pwa/assets/index-BxspWSDs.js +0 -84
  159. package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
  160. package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
  161. package/src/client/dist/pwa/assets/layout-C6frIxKZ.js +0 -1
  162. package/src/client/dist/pwa/assets/liquid-Dnxe0NUB.js +0 -1
  163. package/src/client/dist/pwa/assets/lspLanguageFeatures-qDBRYovw.js +0 -4
  164. package/src/client/dist/pwa/assets/mdx-C1oke9bq.js +0 -1
  165. package/src/client/dist/pwa/assets/monaco.contribution-BbFzrpO3.js +0 -2
  166. package/src/client/dist/pwa/assets/network-auth-Bj1LS5R7.js +0 -1
  167. package/src/client/dist/pwa/assets/pending-diff-open-C3voSObg.js +0 -1
  168. package/src/client/dist/pwa/assets/permissionModes-DMuXGtZu.js +0 -1
  169. package/src/client/dist/pwa/assets/python-Dgq-bJSM.js +0 -1
  170. package/src/client/dist/pwa/assets/razor-Bms4CQGF.js +0 -1
  171. package/src/client/dist/pwa/assets/render-chat-markdown-BRSTe4je.js +0 -66
  172. package/src/client/dist/pwa/assets/typescript-CzOp_GqX.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
  174. package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
  175. package/src/client/dist/pwa/assets/use-onboarding-BGy3sOAN.js +0 -2
  176. package/src/client/dist/pwa/assets/use-quasar-DsmzxifG.js +0 -1
  177. package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
  178. package/src/client/dist/pwa/assets/workers-0fozwhvS.js +0 -1
  179. package/src/client/dist/pwa/assets/xml-CPa01uHm.js +0 -1
  180. package/src/client/dist/pwa/assets/yaml-Cw3fzSuh.js +0 -1
package/AGENTS.md CHANGED
@@ -10,7 +10,7 @@ Single-user dev tool, local by default. The server binds `127.0.0.1` unless the
10
10
 
11
11
  ## Tech stack
12
12
 
13
- **Backend**: Node.js ≥ 20, Hono (HTTP), `ws` (WebSocket), better-sqlite3 (WAL mode), nanoid, `@modelcontextprotocol/sdk`. TypeScript throughout, `tsx` for dev, `tsc` for production build.
13
+ **Backend**: Node.js ≥ 24.15, Hono (HTTP), `ws` (WebSocket), better-sqlite3 (WAL mode), nanoid, `@modelcontextprotocol/sdk`. TypeScript throughout, `tsx` for dev, `tsc` for production build.
14
14
 
15
15
  **Frontend**: Vue 3, Quasar 2, Pinia, vue-router, marked + dompurify for markdown rendering. Vite via `@quasar/app-vite`, built and served as a Progressive Web App in production.
16
16
 
@@ -26,6 +26,7 @@ Single-user dev tool, local by default. The server binds `127.0.0.1` unless the
26
26
  # Install
27
27
  npm install # root
28
28
  (cd src/client && npm install) # client — separate tree
29
+ (cd src/client/src-pwa && npm install) # Quasar PWA build — separate tree
29
30
 
30
31
  # Develop
31
32
  npm run dev # backend only (tsx watch on src/server/index.ts)
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ 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.10
8
+
9
+ - docs: update setup guide
10
+ - fix: improve app reliability
11
+
12
+ ## 1.11.9
13
+
14
+ - fix: address 19 bugs found by parallel codebase audit
15
+
7
16
  ## 1.11.8
8
17
 
9
18
  - fix(whip): preserve delivery and shortcut handling (#24)
package/README.md CHANGED
@@ -21,7 +21,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
21
21
  - **Full MCP toolset (`kobo-tasks`)**: a per-workspace MCP server the agent uses for far more than tasks — task/acceptance-criteria CRUD, starting/stopping the dev server and reading its logs, a unified `get_ticket` (Notion or Sentry), searching past conversations across every workspace, per-session token/cost usage, and a `.ai/thoughts` decision log. Native Claude Code Task tools complement it for lightweight sub-agent coordination. See [`AGENTS.md`](./AGENTS.md) for the full tool list.
22
22
 
23
23
  ![Sub-agents panel showing parallel tool calls](docs/assets/images/sub-agents-panel.png)
24
- - **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition), inline conflict resolution, and `Sync` / `Push` / `Commit` / `Open PR` / `Merge ready PR` / `Change PR base` / `Change source branch`. You can write the commit message yourself or ask the active engine to prepare the commit; PR/MR merging always requires a confirmation, then Kōbō can offer to delete the merged remote branch. Edit/Write cards in chat open their exact changed file in the diff viewer. Multi-forge: GitHub (`gh`), GitLab (`glab`), Bitbucket Community (`bkt`), or no forge, auto-detected from the remote and overridable per project.
24
+ - **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition) and syntax highlighting for PHP, Vue SFCs, JavaScript, TypeScript, and the existing web/config languages. It also provides inline conflict resolution and `Sync` / `Push` / `Commit` / `Open PR` / `Merge ready PR` / `Change PR base` / `Change source branch`. You can write the commit message yourself or ask the active engine to prepare the commit; PR/MR merging always requires a confirmation, then Kōbō can offer to delete the merged remote branch. Edit/Write cards in chat open their exact changed file in the diff viewer. Multi-forge: GitHub (`gh`), GitLab (`glab`), Bitbucket Community (`bkt`), or no forge, auto-detected from the remote and overridable per project.
25
25
 
26
26
  ![Diff viewer with side-by-side changes](docs/assets/images/diff-viewer.png)
27
27
  - **Dev server panel**: start, stop, and tail logs for a workspace's dev server (Docker or npm) straight from the Tools panel — no need to leave the UI. When a project has no configured server, the panel and MCP tool report that explicitly instead of implying one is running.
@@ -41,7 +41,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
41
41
 
42
42
  ## Quick start
43
43
 
44
- Requires Node.js ≥ 20 and a logged-in Claude Code **or** Codex CLI.
44
+ Requires Node.js ≥ 24.15 and a logged-in Claude Code **or** Codex CLI.
45
45
 
46
46
  ```bash
47
47
  npx @loicngr/kobo@latest
@@ -461,6 +461,136 @@ export const migrations = [
461
461
  `);
462
462
  },
463
463
  },
464
+ {
465
+ version: 34,
466
+ name: 'add-session-event-metrics',
467
+ migrate: (db) => {
468
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
469
+ if (!tables.has('workspaces') || !tables.has('agent_sessions') || !tables.has('ws_events'))
470
+ return;
471
+ db.exec(`
472
+ CREATE TABLE IF NOT EXISTS session_event_metrics (
473
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
474
+ session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
475
+ tool_calls INTEGER NOT NULL DEFAULT 0,
476
+ errors INTEGER NOT NULL DEFAULT 0,
477
+ input_tokens INTEGER NOT NULL DEFAULT 0,
478
+ output_tokens INTEGER NOT NULL DEFAULT 0,
479
+ PRIMARY KEY (workspace_id, session_id)
480
+ );
481
+ CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_type_session
482
+ ON ws_events(workspace_id, type, session_id, created_at);
483
+ CREATE INDEX IF NOT EXISTS idx_ws_events_type_created
484
+ ON ws_events(type, created_at DESC, id DESC);
485
+
486
+ CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_insert
487
+ AFTER INSERT ON ws_events
488
+ WHEN NEW.type = 'agent:event'
489
+ AND NEW.session_id IS NOT NULL
490
+ AND json_valid(NEW.payload)
491
+ AND EXISTS (
492
+ SELECT 1 FROM agent_sessions
493
+ WHERE id = NEW.session_id AND workspace_id = NEW.workspace_id
494
+ )
495
+ BEGIN
496
+ INSERT INTO session_event_metrics (
497
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
498
+ ) VALUES (
499
+ NEW.workspace_id,
500
+ NEW.session_id,
501
+ CASE WHEN json_extract(NEW.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END,
502
+ CASE
503
+ WHEN json_extract(NEW.payload, '$.kind') = 'error'
504
+ OR (json_extract(NEW.payload, '$.kind') = 'tool:result'
505
+ AND json_extract(NEW.payload, '$.isError') = 1)
506
+ THEN 1 ELSE 0
507
+ END,
508
+ CASE
509
+ WHEN json_extract(NEW.payload, '$.kind') = 'usage'
510
+ AND json_type(NEW.payload, '$.inputTokens') IN ('integer', 'real')
511
+ THEN CAST(json_extract(NEW.payload, '$.inputTokens') AS INTEGER) ELSE 0
512
+ END,
513
+ CASE
514
+ WHEN json_extract(NEW.payload, '$.kind') = 'usage'
515
+ AND json_type(NEW.payload, '$.outputTokens') IN ('integer', 'real')
516
+ THEN CAST(json_extract(NEW.payload, '$.outputTokens') AS INTEGER) ELSE 0
517
+ END
518
+ )
519
+ ON CONFLICT(workspace_id, session_id) DO UPDATE SET
520
+ tool_calls = tool_calls + excluded.tool_calls,
521
+ errors = errors + excluded.errors,
522
+ input_tokens = MAX(input_tokens, excluded.input_tokens),
523
+ output_tokens = MAX(output_tokens, excluded.output_tokens);
524
+ END;
525
+
526
+ CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_delete
527
+ AFTER DELETE ON ws_events
528
+ WHEN OLD.type = 'agent:event' AND OLD.session_id IS NOT NULL
529
+ BEGIN
530
+ DELETE FROM session_event_metrics
531
+ WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id;
532
+
533
+ INSERT INTO session_event_metrics (
534
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
535
+ )
536
+ SELECT
537
+ e.workspace_id,
538
+ e.session_id,
539
+ SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
540
+ SUM(CASE
541
+ WHEN json_extract(e.payload, '$.kind') = 'error'
542
+ OR (json_extract(e.payload, '$.kind') = 'tool:result'
543
+ AND json_extract(e.payload, '$.isError') = 1)
544
+ THEN 1 ELSE 0
545
+ END),
546
+ MAX(CASE
547
+ WHEN json_extract(e.payload, '$.kind') = 'usage'
548
+ AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
549
+ THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
550
+ END),
551
+ MAX(CASE
552
+ WHEN json_extract(e.payload, '$.kind') = 'usage'
553
+ AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
554
+ THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
555
+ END)
556
+ FROM ws_events e
557
+ JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
558
+ WHERE e.workspace_id = OLD.workspace_id
559
+ AND e.session_id = OLD.session_id
560
+ AND e.type = 'agent:event'
561
+ AND json_valid(e.payload)
562
+ GROUP BY e.workspace_id, e.session_id;
563
+ END;
564
+
565
+ INSERT INTO session_event_metrics (
566
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
567
+ )
568
+ SELECT
569
+ e.workspace_id,
570
+ e.session_id,
571
+ SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
572
+ SUM(CASE
573
+ WHEN json_extract(e.payload, '$.kind') = 'error'
574
+ OR (json_extract(e.payload, '$.kind') = 'tool:result'
575
+ AND json_extract(e.payload, '$.isError') = 1)
576
+ THEN 1 ELSE 0
577
+ END),
578
+ MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
579
+ THEN COALESCE(CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER), 0) ELSE 0 END),
580
+ MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
581
+ THEN COALESCE(CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER), 0) ELSE 0 END)
582
+ FROM ws_events e
583
+ JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
584
+ WHERE e.type = 'agent:event' AND e.session_id IS NOT NULL AND json_valid(e.payload)
585
+ GROUP BY e.workspace_id, e.session_id
586
+ ON CONFLICT(workspace_id, session_id) DO UPDATE SET
587
+ tool_calls = excluded.tool_calls,
588
+ errors = excluded.errors,
589
+ input_tokens = excluded.input_tokens,
590
+ output_tokens = excluded.output_tokens;
591
+ `);
592
+ },
593
+ },
464
594
  ];
465
595
  /** Current schema version — always equals the highest migration version. */
466
596
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -479,37 +609,43 @@ export function runMigrations(db) {
479
609
  if (hasLegacy) {
480
610
  const legacyRow = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
481
611
  const legacyVersion = legacyRow?.version ?? 0;
482
- // Back-fill history for all migrations that were already applied under the old system.
483
- if (legacyVersion >= 1) {
484
- const now = new Date().toISOString();
485
- // Version 1 = initSchema (always applied if legacyVersion >= 1)
486
- db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
487
- for (const m of migrations) {
488
- if (m.version <= legacyVersion) {
489
- db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
612
+ db.transaction(() => {
613
+ // Back-fill history for all migrations that were already applied under the old system.
614
+ if (legacyVersion >= 1) {
615
+ const now = new Date().toISOString();
616
+ // Version 1 = initSchema (always applied if legacyVersion >= 1)
617
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
618
+ for (const m of migrations) {
619
+ if (m.version <= legacyVersion) {
620
+ db.prepare('INSERT OR IGNORE INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
621
+ }
490
622
  }
491
623
  }
492
- }
493
- db.exec('DROP TABLE schema_version');
624
+ db.exec('DROP TABLE schema_version');
625
+ })();
494
626
  }
495
627
  // ── Determine current state ────────────────────────────────────────────────
496
628
  const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
497
629
  // Fresh install — no migrations applied yet.
498
630
  if (!applied.has(1)) {
499
- initSchema(db);
500
- const now = new Date().toISOString();
501
- db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
502
- // Mark all incremental migrations as applied (initSchema creates the latest shape).
503
- for (const m of migrations) {
504
- db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
505
- }
631
+ db.transaction(() => {
632
+ initSchema(db);
633
+ const now = new Date().toISOString();
634
+ db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(1, 'init-schema', now);
635
+ // Mark all incremental migrations as applied (initSchema creates the latest shape).
636
+ for (const m of migrations) {
637
+ db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, now);
638
+ }
639
+ })();
506
640
  return;
507
641
  }
508
642
  // Apply pending migrations sequentially.
509
643
  for (const m of migrations) {
510
644
  if (!applied.has(m.version)) {
511
- m.migrate(db);
512
- db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
645
+ db.transaction(() => {
646
+ m.migrate(db);
647
+ db.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)').run(m.version, m.name, new Date().toISOString());
648
+ })();
513
649
  }
514
650
  }
515
651
  // Soft downgrade guard: if the database carries a migration version this
@@ -74,6 +74,95 @@ export function initSchema(db) {
74
74
  created_at TEXT NOT NULL
75
75
  );
76
76
 
77
+ CREATE TABLE IF NOT EXISTS session_event_metrics (
78
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
79
+ session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
80
+ tool_calls INTEGER NOT NULL DEFAULT 0,
81
+ errors INTEGER NOT NULL DEFAULT 0,
82
+ input_tokens INTEGER NOT NULL DEFAULT 0,
83
+ output_tokens INTEGER NOT NULL DEFAULT 0,
84
+ PRIMARY KEY (workspace_id, session_id)
85
+ );
86
+
87
+ CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_insert
88
+ AFTER INSERT ON ws_events
89
+ WHEN NEW.type = 'agent:event'
90
+ AND NEW.session_id IS NOT NULL
91
+ AND json_valid(NEW.payload)
92
+ AND EXISTS (
93
+ SELECT 1 FROM agent_sessions
94
+ WHERE id = NEW.session_id AND workspace_id = NEW.workspace_id
95
+ )
96
+ BEGIN
97
+ INSERT INTO session_event_metrics (
98
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
99
+ ) VALUES (
100
+ NEW.workspace_id,
101
+ NEW.session_id,
102
+ CASE WHEN json_extract(NEW.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END,
103
+ CASE
104
+ WHEN json_extract(NEW.payload, '$.kind') = 'error'
105
+ OR (json_extract(NEW.payload, '$.kind') = 'tool:result'
106
+ AND json_extract(NEW.payload, '$.isError') = 1)
107
+ THEN 1 ELSE 0
108
+ END,
109
+ CASE
110
+ WHEN json_extract(NEW.payload, '$.kind') = 'usage'
111
+ AND json_type(NEW.payload, '$.inputTokens') IN ('integer', 'real')
112
+ THEN CAST(json_extract(NEW.payload, '$.inputTokens') AS INTEGER) ELSE 0
113
+ END,
114
+ CASE
115
+ WHEN json_extract(NEW.payload, '$.kind') = 'usage'
116
+ AND json_type(NEW.payload, '$.outputTokens') IN ('integer', 'real')
117
+ THEN CAST(json_extract(NEW.payload, '$.outputTokens') AS INTEGER) ELSE 0
118
+ END
119
+ )
120
+ ON CONFLICT(workspace_id, session_id) DO UPDATE SET
121
+ tool_calls = tool_calls + excluded.tool_calls,
122
+ errors = errors + excluded.errors,
123
+ input_tokens = MAX(input_tokens, excluded.input_tokens),
124
+ output_tokens = MAX(output_tokens, excluded.output_tokens);
125
+ END;
126
+
127
+ CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_delete
128
+ AFTER DELETE ON ws_events
129
+ WHEN OLD.type = 'agent:event' AND OLD.session_id IS NOT NULL
130
+ BEGIN
131
+ DELETE FROM session_event_metrics
132
+ WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id;
133
+
134
+ INSERT INTO session_event_metrics (
135
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
136
+ )
137
+ SELECT
138
+ e.workspace_id,
139
+ e.session_id,
140
+ SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
141
+ SUM(CASE
142
+ WHEN json_extract(e.payload, '$.kind') = 'error'
143
+ OR (json_extract(e.payload, '$.kind') = 'tool:result'
144
+ AND json_extract(e.payload, '$.isError') = 1)
145
+ THEN 1 ELSE 0
146
+ END),
147
+ MAX(CASE
148
+ WHEN json_extract(e.payload, '$.kind') = 'usage'
149
+ AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
150
+ THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
151
+ END),
152
+ MAX(CASE
153
+ WHEN json_extract(e.payload, '$.kind') = 'usage'
154
+ AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
155
+ THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
156
+ END)
157
+ FROM ws_events e
158
+ JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
159
+ WHERE e.workspace_id = OLD.workspace_id
160
+ AND e.session_id = OLD.session_id
161
+ AND e.type = 'agent:event'
162
+ AND json_valid(e.payload)
163
+ GROUP BY e.workspace_id, e.session_id;
164
+ END;
165
+
77
166
  CREATE TABLE IF NOT EXISTS workspace_permission_rules (
78
167
  id TEXT PRIMARY KEY,
79
168
  workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -140,6 +229,10 @@ export function initSchema(db) {
140
229
  CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
141
230
  CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
142
231
  CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
232
+ CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_type_session
233
+ ON ws_events(workspace_id, type, session_id, created_at);
234
+ CREATE INDEX IF NOT EXISTS idx_ws_events_type_created
235
+ ON ws_events(type, created_at DESC, id DESC);
143
236
  CREATE INDEX IF NOT EXISTS idx_workspace_chat_history_workspace_id_id
144
237
  ON workspace_chat_history(workspace_id, id DESC);
145
238
  `);
@@ -406,12 +406,9 @@ setMessageHandler(async (type, payload) => {
406
406
  }
407
407
  }
408
408
  if (type === 'devserver:stop' && p?.workspaceId) {
409
- try {
410
- stopDevServer(p.workspaceId);
411
- }
412
- catch (err) {
409
+ void stopDevServer(p.workspaceId).catch((err) => {
413
410
  console.error('[ws] Failed to stop dev-server:', err);
414
- }
411
+ });
415
412
  }
416
413
  });
417
414
  // Handle WebSocket upgrade requests on /ws path
@@ -451,16 +448,22 @@ server.on('upgrade', (request, socket, head) => {
451
448
  });
452
449
  // Graceful shutdown handler
453
450
  let isShuttingDown = false;
454
- function gracefulShutdown(signal) {
451
+ async function gracefulShutdown(signal) {
455
452
  if (isShuttingDown)
456
453
  return;
457
454
  isShuttingDown = true;
458
455
  console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
459
- // Stop accepting new connections
460
- wss.close(() => {
461
- console.log('[kobo] WebSocket server closed');
462
- });
463
- terminalWss.close();
456
+ const forceExitTimer = setTimeout(() => {
457
+ console.error('[kobo] Graceful shutdown timed out; forcing exit');
458
+ process.exit(1);
459
+ }, 5_000);
460
+ forceExitTimer.unref();
461
+ // Ask WebSocket clients to leave so their upgraded HTTP connections do not
462
+ // keep server.close() waiting indefinitely.
463
+ for (const client of wss.clients)
464
+ client.close(1001, 'Server shutting down');
465
+ for (const client of terminalWss.clients)
466
+ client.close(1001, 'Server shutting down');
464
467
  try {
465
468
  destroyAllTerminals();
466
469
  console.log('[kobo] Terminals killed');
@@ -468,9 +471,6 @@ function gracefulShutdown(signal) {
468
471
  catch {
469
472
  // Best-effort
470
473
  }
471
- server.close(() => {
472
- console.log('[kobo] HTTP server closed');
473
- });
474
474
  // Stop background services
475
475
  try {
476
476
  stopWatchdog();
@@ -498,19 +498,28 @@ function gracefulShutdown(signal) {
498
498
  catch {
499
499
  // Best-effort
500
500
  }
501
- // Close database
501
+ // Stop accepting new HTTP/WS work, then let in-flight handlers finish before
502
+ // closing SQLite. Closing the DB earlier makes those handlers fail midway.
502
503
  try {
503
- closeDb();
504
- console.log('[kobo] Database closed');
504
+ await Promise.all([
505
+ new Promise((resolve) => server.close(() => resolve())),
506
+ new Promise((resolve) => wss.close(() => resolve())),
507
+ new Promise((resolve) => terminalWss.close(() => resolve())),
508
+ ]);
509
+ console.log('[kobo] HTTP and WebSocket servers closed');
505
510
  }
506
- catch {
507
- // Best-effort
511
+ finally {
512
+ try {
513
+ closeDb();
514
+ console.log('[kobo] Database closed');
515
+ }
516
+ catch {
517
+ // Best-effort
518
+ }
508
519
  }
509
- // Give a short grace period for in-flight requests, then exit
510
- setTimeout(() => {
511
- console.log('[kobo] Shutdown complete');
512
- process.exit(0);
513
- }, 2000).unref();
520
+ clearTimeout(forceExitTimer);
521
+ console.log('[kobo] Shutdown complete');
522
+ process.exit(0);
514
523
  }
515
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
516
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
524
+ process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
525
+ process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
@@ -6,7 +6,7 @@ import { getWorkspace } from '../services/workspace-service.js';
6
6
  /** Hono sub-router for per-workspace dev server lifecycle (start, stop, status, logs). */
7
7
  const app = new Hono();
8
8
  // GET /api/dev-server/:workspaceId/status
9
- app.get('/:workspaceId/status', (c) => {
9
+ app.get('/:workspaceId/status', async (c) => {
10
10
  try {
11
11
  const workspaceId = c.req.param('workspaceId');
12
12
  const workspace = getWorkspace(workspaceId);
@@ -26,7 +26,7 @@ app.get('/:workspaceId/status', (c) => {
26
26
  containers: [],
27
27
  });
28
28
  }
29
- const status = getStatus(workspace.projectPath, workspace.workingBranch, workspaceId);
29
+ const status = await getStatus(workspace.projectPath, workspace.workingBranch, workspaceId);
30
30
  // If runtime detection returns unknown, use persisted status from DB
31
31
  if (status.status === 'unknown' && workspace.devServerStatus && workspace.devServerStatus !== 'stopped') {
32
32
  status.status = workspace.devServerStatus;
@@ -51,18 +51,19 @@ app.post('/:workspaceId/start', migrationGuard, (c) => {
51
51
  }
52
52
  catch (err) {
53
53
  const message = err instanceof Error ? err.message : String(err);
54
- return c.json({ error: message }, 500);
54
+ const status = message.includes('already starting') ? 409 : 500;
55
+ return c.json({ error: message }, status);
55
56
  }
56
57
  });
57
58
  // POST /api/dev-server/:workspaceId/stop
58
- app.post('/:workspaceId/stop', migrationGuard, (c) => {
59
+ app.post('/:workspaceId/stop', migrationGuard, async (c) => {
59
60
  try {
60
61
  const workspaceId = c.req.param('workspaceId');
61
62
  const workspace = getWorkspace(workspaceId);
62
63
  if (!workspace) {
63
64
  return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
64
65
  }
65
- const status = stopDevServer(workspaceId);
66
+ const status = await stopDevServer(workspaceId);
66
67
  return c.json(status);
67
68
  }
68
69
  catch (err) {
@@ -71,15 +72,16 @@ app.post('/:workspaceId/stop', migrationGuard, (c) => {
71
72
  }
72
73
  });
73
74
  // GET /api/dev-server/:workspaceId/logs
74
- app.get('/:workspaceId/logs', (c) => {
75
+ app.get('/:workspaceId/logs', async (c) => {
75
76
  try {
76
77
  const workspaceId = c.req.param('workspaceId');
77
78
  const workspace = getWorkspace(workspaceId);
78
79
  if (!workspace) {
79
80
  return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
80
81
  }
81
- const tail = parseInt(c.req.query('tail') ?? '200', 10);
82
- const logs = getDevServerLogs(workspaceId, tail);
82
+ const parsedTail = parseInt(c.req.query('tail') ?? '200', 10);
83
+ const tail = Math.max(1, Math.min(Number.isFinite(parsedTail) ? parsedTail : 200, 1000));
84
+ const logs = await getDevServerLogs(workspaceId, tail);
83
85
  return c.json({ logs });
84
86
  }
85
87
  catch (err) {
@@ -1,7 +1,8 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import * as workspaceService from '../services/workspace-service.js';
5
+ import { isPathInside, resolveExistingPathInside } from '../utils/safe-path.js';
5
6
  /** Hono sub-router for workspace document browsing (read-only). */
6
7
  const app = new Hono();
7
8
  /**
@@ -17,6 +18,7 @@ const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
17
18
  const MD_EXT = '.md';
18
19
  /** Depth cap to keep recursion bounded even on pathological symlink loops. */
19
20
  const MAX_DEPTH = 8;
21
+ const MAX_DOCUMENT_SIZE = 1024 * 1024;
20
22
  function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
21
23
  if (depth > MAX_DEPTH)
22
24
  return;
@@ -34,11 +36,13 @@ function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
34
36
  const relEntry = `${rootRel}/${entry}`;
35
37
  let stat;
36
38
  try {
37
- stat = statSync(absEntry);
39
+ stat = lstatSync(absEntry);
38
40
  }
39
41
  catch {
40
42
  continue;
41
43
  }
44
+ if (stat.isSymbolicLink())
45
+ continue;
42
46
  if (stat.isDirectory()) {
43
47
  walkMarkdownFiles(absEntry, relEntry, out, depth + 1);
44
48
  }
@@ -65,6 +69,15 @@ app.get('/:id/documents', (c) => {
65
69
  const absDir = path.join(worktreePath, dir);
66
70
  if (!existsSync(absDir))
67
71
  continue;
72
+ const dirStat = lstatSync(absDir);
73
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory())
74
+ continue;
75
+ try {
76
+ resolveExistingPathInside(worktreePath, absDir);
77
+ }
78
+ catch {
79
+ continue;
80
+ }
68
81
  walkMarkdownFiles(absDir, dir, documents);
69
82
  }
70
83
  // Sort by modifiedAt descending (most recent first)
@@ -102,7 +115,24 @@ app.get('/:id/document', (c) => {
102
115
  if (!existsSync(absPath)) {
103
116
  return c.json({ error: `Document not found: ${normalized}` }, 404);
104
117
  }
105
- const content = readFileSync(absPath, 'utf-8');
118
+ const fileStat = lstatSync(absPath);
119
+ if (fileStat.isSymbolicLink())
120
+ return c.json({ error: 'Symbolic links are not allowed' }, 400);
121
+ if (!fileStat.isFile())
122
+ return c.json({ error: `Document not found: ${normalized}` }, 404);
123
+ const realWorktree = realpathSync(worktreePath);
124
+ const realFile = resolveExistingPathInside(worktreePath, absPath);
125
+ const allowed = DOCUMENT_DIRS.some((dir) => {
126
+ const allowedRoot = path.join(worktreePath, dir);
127
+ if (!existsSync(allowedRoot) || lstatSync(allowedRoot).isSymbolicLink())
128
+ return false;
129
+ return isPathInside(realpathSync(allowedRoot), realFile);
130
+ });
131
+ if (!isPathInside(realWorktree, realFile) || !allowed)
132
+ return c.json({ error: 'Path escapes allowed root' }, 400);
133
+ if (fileStat.size > MAX_DOCUMENT_SIZE)
134
+ return c.json({ error: 'Document exceeds the 1 MB size limit' }, 413);
135
+ const content = readFileSync(realFile, 'utf-8');
106
136
  return c.json({ content, path: normalized });
107
137
  }
108
138
  catch (err) {