@loicngr/kobo 1.11.16 → 1.11.17

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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +8 -7
  4. package/dist/server/db/migrations.js +57 -0
  5. package/dist/server/db/schema.js +10 -39
  6. package/dist/server/index.js +55 -7
  7. package/dist/server/routes/git.js +8 -3
  8. package/dist/server/routes/health.js +19 -2
  9. package/dist/server/routes/settings.js +24 -0
  10. package/dist/server/routes/workspaces.js +371 -72
  11. package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
  12. package/dist/server/services/agent/engines/codex/client.js +16 -4
  13. package/dist/server/services/agent/engines/codex/engine.js +23 -2
  14. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
  15. package/dist/server/services/agent/engines/types.js +13 -0
  16. package/dist/server/services/agent/orchestrator.js +354 -133
  17. package/dist/server/services/agent/session-controller.js +27 -0
  18. package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
  19. package/dist/server/services/change-source-branch-service.js +115 -11
  20. package/dist/server/services/db-backup-service.js +27 -16
  21. package/dist/server/services/pr-watcher-service.js +17 -2
  22. package/dist/server/services/settings-service.js +39 -0
  23. package/dist/server/services/websocket-service.js +114 -48
  24. package/dist/server/services/workspace-service.js +54 -2
  25. package/dist/server/services/worktree-purge-service.js +19 -5
  26. package/dist/server/services/worktree-service.js +38 -1
  27. package/dist/server/services/ws-events-retention-service.js +133 -0
  28. package/dist/server/utils/git-ops.js +209 -14
  29. package/dist/server/utils/git-repo-lock.js +45 -0
  30. package/dist/server/utils/logger.js +108 -0
  31. package/dist/server/utils/script-runner.js +11 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
  34. package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
  36. package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
  37. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
  39. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
  40. package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
  41. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
  42. package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
  43. package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
  44. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
  45. package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
  46. package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
  47. package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
  48. package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
  49. package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
  50. package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
  51. package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
  52. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
  53. package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
  54. package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
  55. package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
  56. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
  57. package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
  58. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
  59. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
  60. package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
  61. package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
  62. package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
  63. package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
  64. package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
  65. package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
  66. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
  67. package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
  68. package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
  69. package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
  70. package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
  71. package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
  72. package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
  73. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
  74. package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
  75. package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
  76. package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
  77. package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
  78. package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
  79. package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
  80. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
  81. package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
  82. package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
  83. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
  84. package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
  88. package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
  91. package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
  92. package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
  93. package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
  94. package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
  95. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
  96. package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
  97. package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
  98. package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
  99. package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
  100. package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
  101. package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
  102. package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
  103. package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
  106. package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
  107. package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
  108. package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
  111. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
  112. package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
  113. package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
  114. package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
  117. package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
  118. package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
  119. package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
  120. package/src/client/dist/pwa/index.html +7 -5
  121. package/src/client/dist/pwa/sw.js +1 -1
  122. package/src/mcp-server/kobo-tasks-server.ts +8 -7
  123. package/dist/server/utils/process-tracker.js +0 -49
  124. package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
  125. package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
  126. package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
  127. package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
  128. package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
  129. package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
  130. package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
  131. package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
  132. package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
  133. package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
  134. package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
  135. package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
  136. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
  137. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
  138. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
  139. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
  140. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
  141. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
  142. package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
  143. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
  144. package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
  145. package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
  146. package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
  147. package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
  148. package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
  149. package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
  150. package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
  151. package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
  152. package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
  153. package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
  154. package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
  155. package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
  156. package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
  157. package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
  158. package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
  159. package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
  160. package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
  161. package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
  162. package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
  163. package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
  164. package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
  165. package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
  166. package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
  167. package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
  168. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
  169. package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
  170. package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
  171. package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
  174. package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
  175. package/src/client/dist/pwa/assets/use-onboarding-BNo8mb65.js +0 -2
package/AGENTS.md CHANGED
@@ -240,7 +240,7 @@ Sequence: `captureRestoreData` (best-effort forge lookup for PR number / URL / m
240
240
 
241
241
  ### Bulk workspace info refresh
242
242
 
243
- `GET /api/workspaces/info` returns `{ workspaces, prSnapshots, gitStats }` in one shot. The client polls this endpoint every 30 s so every non-archived workspace stays ≤ 30 s fresh without a per-card stats request. The server-side pr-watcher feeds the same caches (`lastKnownGitStats` map, PR snapshots) so the work is shared between the polling client and the watchdog loop.
243
+ `GET /api/workspaces/info` returns `{ workspaces, prSnapshots, gitStats }` in one shot. The client polls this endpoint every 15 s so every non-archived workspace stays ≤ 15 s fresh without a per-card stats request. The poll is skipped while the browser tab is hidden and fires once immediately when the tab becomes visible again. The server-side pr-watcher feeds the same caches (`lastKnownGitStats` map, PR snapshots) so the work is shared between the polling client and the watchdog loop.
244
244
 
245
245
  ### File editing in the diff viewer
246
246
 
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.17
8
+
9
+ - feat(client): render in the design system's own type, palette and tokens (#34)
10
+ - perf(client): stop re-rendering frozen content and racing stale requests (#33)
11
+ - fix(client): make the review path usable and stop lying about navigation state (#32)
12
+ - fix(git,db): serialize git writes, bound event history, verify irreversible ops (#31)
13
+ - feat(client,server): tell the truth while working and when it fails (#30)
14
+ - fix(agent): stop reporting a running agent when none exists (#29)
15
+
7
16
  ## 1.11.16
8
17
 
9
18
  - feat(client): responsive mobile layout for drawer, settings nav and workspace toolbar (#28)
@@ -3,7 +3,6 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
5
  import { getDb } from '../server/db/index.js';
6
- import { runMigrations } from '../server/db/migrations.js';
7
6
  import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getTicketSourcesHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, listWorkspacesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, readWorkspaceEventsCsvHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
8
7
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
9
8
  const dbPath = process.env.KOBO_DB_PATH;
@@ -23,13 +22,15 @@ try {
23
22
  // KOBO_DB_PATH is passed). That would silently open a second connection
24
23
  // against the user's prod DB at ~/.config/kobo/kobo.db, which may not
25
24
  // even have the same schema as the dev DB the backend is using.
25
+ // On ouvre SANS migrer. Ce process est lancé une fois par workspace, sur le
26
+ // même fichier de base : `runMigrations` calcule l'ensemble des blocs
27
+ // appliqués AVANT de prendre le verrou, donc deux serveurs MCP démarrés
28
+ // ensemble peuvent rejouer le même bloc — et les blocs v2 à v19 ajoutent
29
+ // des colonnes sans garde d'existence. Un `duplicate column name` ici
30
+ // termine le process et prive l'agent de tous ses outils en pleine session.
31
+ // Le backend migre au démarrage, et il démarre nécessairement avant de
32
+ // pouvoir lancer ce serveur.
26
33
  db = getDb(dbPath);
27
- // Defensive: run migrations to ensure the schema is at the latest version.
28
- // The backend already ran them at boot, but this MCP server might be the
29
- // first to touch the DB (e.g. tests, race at first boot, or KOBO_DB_PATH
30
- // pointing somewhere the backend hasn't migrated). Idempotent — no-op if
31
- // the DB is already at SCHEMA_VERSION.
32
- runMigrations(db);
33
34
  }
34
35
  catch (err) {
35
36
  console.error('[kobo-tasks-server] Failed to open database:', err);
@@ -604,6 +604,63 @@ export const migrations = [
604
604
  }
605
605
  },
606
606
  },
607
+ {
608
+ version: 36,
609
+ name: 'drop-metrics-delete-trigger-add-hot-indexes',
610
+ migrate: (db) => {
611
+ // The AFTER DELETE trigger installed by v34 re-aggregated the entire
612
+ // session for EVERY deleted row. Measured on an identical schema: 79.7 s
613
+ // for 20 000 events, 106.6 s through a cascade, with the SQLite write
614
+ // lock held for the whole duration — during which every WebSocket emit
615
+ // gives up after busy_timeout and loses the event for good.
616
+ // Metrics are now recomputed once, application-side, at the end of the
617
+ // transactions that delete events while keeping the session
618
+ // (recomputeSessionMetrics in workspace-service.ts).
619
+ db.exec('DROP TRIGGER IF EXISTS trg_ws_events_metrics_delete');
620
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
621
+ const hasColumns = (table, needed) => {
622
+ if (!tables.has(table))
623
+ return false;
624
+ const columns = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
625
+ return needed.every((column) => columns.has(column));
626
+ };
627
+ // Five hot paths measured as full table scans before this migration.
628
+ const indexes = [
629
+ {
630
+ table: 'tasks',
631
+ columns: ['workspace_id', 'sort_order'],
632
+ sql: 'CREATE INDEX IF NOT EXISTS idx_tasks_workspace_sort ON tasks(workspace_id, sort_order)',
633
+ },
634
+ {
635
+ table: 'agent_sessions',
636
+ columns: ['workspace_id', 'started_at'],
637
+ sql: 'CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_started ON agent_sessions(workspace_id, started_at DESC)',
638
+ },
639
+ {
640
+ table: 'agent_sessions',
641
+ columns: ['engine_session_id'],
642
+ sql: 'CREATE INDEX IF NOT EXISTS idx_agent_sessions_engine_session ON agent_sessions(engine_session_id)',
643
+ },
644
+ {
645
+ table: 'workspaces',
646
+ columns: ['archived_at', 'updated_at'],
647
+ sql: 'CREATE INDEX IF NOT EXISTS idx_workspaces_archived_updated ON workspaces(archived_at, updated_at DESC)',
648
+ },
649
+ {
650
+ table: 'session_event_metrics',
651
+ columns: ['session_id'],
652
+ sql: 'CREATE INDEX IF NOT EXISTS idx_session_event_metrics_session ON session_event_metrics(session_id)',
653
+ },
654
+ ];
655
+ // Column guards, not just table guards: several shipped migration tests
656
+ // build a stripped-down table (e.g. `workspaces(id, engine)`) and replay
657
+ // every later migration on top of it.
658
+ for (const index of indexes) {
659
+ if (hasColumns(index.table, index.columns))
660
+ db.exec(index.sql);
661
+ }
662
+ },
663
+ },
607
664
  ];
608
665
  /** Current schema version — always equals the highest migration version. */
609
666
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -125,45 +125,6 @@ export function initSchema(db) {
125
125
  output_tokens = MAX(output_tokens, excluded.output_tokens);
126
126
  END;
127
127
 
128
- CREATE TRIGGER IF NOT EXISTS trg_ws_events_metrics_delete
129
- AFTER DELETE ON ws_events
130
- WHEN OLD.type = 'agent:event' AND OLD.session_id IS NOT NULL
131
- BEGIN
132
- DELETE FROM session_event_metrics
133
- WHERE workspace_id = OLD.workspace_id AND session_id = OLD.session_id;
134
-
135
- INSERT INTO session_event_metrics (
136
- workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
137
- )
138
- SELECT
139
- e.workspace_id,
140
- e.session_id,
141
- SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
142
- SUM(CASE
143
- WHEN json_extract(e.payload, '$.kind') = 'error'
144
- OR (json_extract(e.payload, '$.kind') = 'tool:result'
145
- AND json_extract(e.payload, '$.isError') = 1)
146
- THEN 1 ELSE 0
147
- END),
148
- MAX(CASE
149
- WHEN json_extract(e.payload, '$.kind') = 'usage'
150
- AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
151
- THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
152
- END),
153
- MAX(CASE
154
- WHEN json_extract(e.payload, '$.kind') = 'usage'
155
- AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
156
- THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
157
- END)
158
- FROM ws_events e
159
- JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
160
- WHERE e.workspace_id = OLD.workspace_id
161
- AND e.session_id = OLD.session_id
162
- AND e.type = 'agent:event'
163
- AND json_valid(e.payload)
164
- GROUP BY e.workspace_id, e.session_id;
165
- END;
166
-
167
128
  CREATE TABLE IF NOT EXISTS workspace_permission_rules (
168
129
  id TEXT PRIMARY KEY,
169
130
  workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -236,5 +197,15 @@ export function initSchema(db) {
236
197
  ON ws_events(type, created_at DESC, id DESC);
237
198
  CREATE INDEX IF NOT EXISTS idx_workspace_chat_history_workspace_id_id
238
199
  ON workspace_chat_history(workspace_id, id DESC);
200
+ CREATE INDEX IF NOT EXISTS idx_tasks_workspace_sort
201
+ ON tasks(workspace_id, sort_order);
202
+ CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_started
203
+ ON agent_sessions(workspace_id, started_at DESC);
204
+ CREATE INDEX IF NOT EXISTS idx_agent_sessions_engine_session
205
+ ON agent_sessions(engine_session_id);
206
+ CREATE INDEX IF NOT EXISTS idx_workspaces_archived_updated
207
+ ON workspaces(archived_at, updated_at DESC);
208
+ CREATE INDEX IF NOT EXISTS idx_session_event_metrics_session
209
+ ON session_event_metrics(session_id);
239
210
  `);
240
211
  }
@@ -25,7 +25,7 @@ import templatesRouter from './routes/templates.js';
25
25
  import usageRoutes from './routes/usage.js';
26
26
  import voiceRouter from './routes/voice.js';
27
27
  import workspacesRouter from './routes/workspaces.js';
28
- import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
28
+ import { getAvailableSkills, isAgentUnavailableError, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
29
29
  import * as autoLoopService from './services/auto-loop-service.js';
30
30
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
31
31
  import * as cronService from './services/cron-service.js';
@@ -41,6 +41,7 @@ import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
41
41
  import * as wakeupService from './services/wakeup-service.js';
42
42
  import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
43
43
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
44
+ import { pruneWsEvents, resolveRetentionConfig } from './services/ws-events-retention-service.js';
44
45
  import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
45
46
  console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
46
47
  // Initialize DB + run migrations
@@ -53,12 +54,37 @@ try {
53
54
  if (pending.length > 0) {
54
55
  const result = await createPreMigrationBackup(db, getDbPath(), `v${pending[pending.length - 1]}`);
55
56
  console.log(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
57
+ if (result.deleted.length > 0) {
58
+ console.log(`[kobo] Rotated ${result.deleted.length} old pre-migration backup(s)`);
59
+ }
56
60
  }
57
61
  }
58
62
  catch (err) {
59
63
  console.error('[kobo] Pre-migration backup failed (continuing — daily backup remains as fallback):', err);
60
64
  }
61
65
  runMigrations(db);
66
+ // Event retention. OPT-IN: disabled by default, so this is a no-op until the
67
+ // user sets a window in Settings → Worktrees. When enabled it runs at boot
68
+ // only: no session is alive yet, so the batched write lock disturbs nobody, and
69
+ // the daily backup that follows snapshots an already-compacted file.
70
+ // Best-effort — a failure must never block boot.
71
+ try {
72
+ const retentionConfig = resolveRetentionConfig(getGlobalSettings());
73
+ if (retentionConfig.retentionDays > 0) {
74
+ const retentionStartedAt = Date.now();
75
+ const retention = pruneWsEvents(db, retentionConfig);
76
+ if (retention.deleted > 0 || retention.vacuumed) {
77
+ console.log(`[kobo] Event retention (${retentionConfig.retentionDays} d, keeping ${retentionConfig.keepPerWorkspace}/workspace): ` +
78
+ `${retention.deleted} agent event(s) permanently deleted, ` +
79
+ `${retention.sessionsRecomputed} session metric(s) recomputed, ` +
80
+ `${retention.vacuumed ? `VACUUM reclaimed ${retention.freePagesBefore - retention.freePagesAfter} page(s), ` : ''}` +
81
+ `${Date.now() - retentionStartedAt} ms`);
82
+ }
83
+ }
84
+ }
85
+ catch (err) {
86
+ console.error('[kobo] Event retention failed (continuing):', err);
87
+ }
62
88
  // Daily DB backup (best-effort, fire-and-forget — never blocks boot).
63
89
  // Creates a WAL-safe snapshot alongside kobo.db if no backup exists in the
64
90
  // last 24h, and rotates out older backups beyond the retention window.
@@ -329,10 +355,11 @@ setMessageHandler(async (type, payload) => {
329
355
  }
330
356
  catch (err) {
331
357
  const msg = err instanceof Error ? err.message : String(err);
332
- // Only resume on the specific "No agent running" path. Other errors
333
- // (stdin closed, process dead mid-write, etc.) should surface to the
334
- // logs instead of silently respawning a fresh agent.
335
- if (!msg.includes('No agent running')) {
358
+ // Resume on every shape that means "no agent can receive this message"
359
+ // — the closed-stdin case included. Anything else (queue full, disk
360
+ // error, a stop in progress) surfaces to the user instead of silently
361
+ // respawning a fresh agent.
362
+ if (!isAgentUnavailableError(msg)) {
336
363
  emitEphemeral(p.workspaceId, 'chat:rejected', { sessionId: p.sessionId, content: p.content, message: msg });
337
364
  console.error(`[ws] chat:message failed for workspace ${p.workspaceId}:`, err);
338
365
  return;
@@ -446,7 +473,7 @@ server.on('upgrade', (request, socket, head) => {
446
473
  });
447
474
  // Graceful shutdown handler
448
475
  let isShuttingDown = false;
449
- async function gracefulShutdown(signal) {
476
+ async function gracefulShutdown(signal, exitCode = 0) {
450
477
  if (isShuttingDown)
451
478
  return;
452
479
  isShuttingDown = true;
@@ -517,7 +544,28 @@ async function gracefulShutdown(signal) {
517
544
  }
518
545
  clearTimeout(forceExitTimer);
519
546
  console.log('[kobo] Shutdown complete');
520
- process.exit(0);
547
+ process.exit(exitCode);
521
548
  }
522
549
  process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
523
550
  process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
551
+ // Fermeture du terminal parent : sans ce gestionnaire, Node termine le
552
+ // process sans passer par l'arrêt propre, donc sans fermer la base.
553
+ process.on('SIGHUP', () => void gracefulShutdown('SIGHUP'));
554
+ // Un `EPIPE` sur stdout/stderr — typiquement quand la sortie est redirigée
555
+ // vers un lecteur qui s'est fermé — est émis de façon asynchrone et tue le
556
+ // process. On l'ignore : perdre une ligne de journal ne doit jamais arrêter
557
+ // le serveur.
558
+ process.stdout.on('error', () => { });
559
+ process.stderr.on('error', () => { });
560
+ // Filets de dernier recours. On ne poursuit PAS l'exécution après une
561
+ // exception non capturée : l'état du process est indéterminé. On garantit
562
+ // seulement une trace exploitable et une fermeture propre de la base et des
563
+ // WebSockets — ce que la terminaison implicite de Node ne fait pas.
564
+ process.on('uncaughtException', (err) => {
565
+ console.error('[kobo] Uncaught exception — shutting down:', err);
566
+ void gracefulShutdown('uncaughtException', 1);
567
+ });
568
+ process.on('unhandledRejection', (reason) => {
569
+ console.error('[kobo] Unhandled promise rejection — shutting down:', reason);
570
+ void gracefulShutdown('unhandledRejection', 1);
571
+ });
@@ -20,15 +20,20 @@ app.get('/branches', (c) => {
20
20
  return c.json({ error: message }, 500);
21
21
  }
22
22
  });
23
- // GET /api/git/files?path=<worktreePath> — list a worktree's files (tracked +
24
- // untracked-but-not-ignored) for the chat's `@file` autocomplete.
23
+ /** Hard ceiling on the worktree listing. `listWorktreeFiles` already truncates
24
+ * at this value; exposing it lets a caller ask for LESS, never for more. */
25
+ const MAX_WORKTREE_FILES = 5000;
26
+ // GET /api/git/files?path=<worktreePath>&limit=<n> — list a worktree's files
27
+ // (tracked + untracked-but-not-ignored) for the chat's `@file` autocomplete.
25
28
  app.get('/files', (c) => {
26
29
  try {
27
30
  const repoPath = c.req.query('path');
28
31
  if (!repoPath) {
29
32
  return c.json({ error: 'Missing required query parameter: path' }, 400);
30
33
  }
31
- return c.json({ files: listWorktreeFiles(repoPath) });
34
+ const requested = Number.parseInt(c.req.query('limit') ?? '', 10);
35
+ const limit = Number.isFinite(requested) && requested > 0 ? Math.min(requested, MAX_WORKTREE_FILES) : MAX_WORKTREE_FILES;
36
+ return c.json({ files: listWorktreeFiles(repoPath, limit) });
32
37
  }
33
38
  catch (err) {
34
39
  const message = err instanceof Error ? err.message : String(err);
@@ -6,6 +6,7 @@ import { getDb } from '../db/index.js';
6
6
  import { SCHEMA_VERSION } from '../db/migrations.js';
7
7
  import { resolveCodexBinary } from '../services/agent/engines/codex/spawn.js';
8
8
  import { getGlobalSettings, getProjectSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
9
+ import { readRecentLogs } from '../utils/logger.js';
9
10
  import { getDbPath, getKoboHome, getPackageVersion } from '../utils/paths.js';
10
11
  import { slugifyProjectName } from '../utils/project-slug.js';
11
12
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
@@ -48,12 +49,27 @@ function safeFileSize(p) {
48
49
  return null;
49
50
  }
50
51
  }
52
+ const VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
53
+ // GET /api/health/logs?limit=200&level=error — most recent entries, newest first.
54
+ // Declared BEFORE any dynamic segment, per the route-ordering invariant.
55
+ app.get('/logs', (c) => {
56
+ try {
57
+ const rawLimit = Number.parseInt(c.req.query('limit') ?? '', 10);
58
+ const limit = Number.isFinite(rawLimit) ? rawLimit : 200;
59
+ const rawLevel = c.req.query('level');
60
+ const level = VALID_LOG_LEVELS.includes(rawLevel) ? rawLevel : undefined;
61
+ return c.json({ entries: readRecentLogs(level ? { limit, level } : { limit }) });
62
+ }
63
+ catch (err) {
64
+ return c.json({ error: err instanceof Error ? err.message : 'Failed to read logs' }, 500);
65
+ }
66
+ });
51
67
  // GET /api/health/report — detailed health diagnostics for the Health panel.
52
68
  app.get('/report', async (c) => {
53
69
  const db = getDb();
54
70
  const dbPath = getDbPath();
55
71
  const home = getKoboHome();
56
- const cliChecks = Promise.all([checkClaudeCli(), checkCodexCli()]);
72
+ const cliChecks = Promise.all([checkClaudeCli(), checkCodexCli(), checkCli('gh'), checkCli('glab')]);
57
73
  // DB schema version
58
74
  const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
59
75
  const dbSchemaVersion = row?.v ?? 0;
@@ -145,7 +161,7 @@ app.get('/report', async (c) => {
145
161
  .all();
146
162
  const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
147
163
  const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
148
- const [claudeCli, codexCli] = await cliChecks;
164
+ const [claudeCli, codexCli, ghCli, glabCli] = await cliChecks;
149
165
  const report = {
150
166
  version: getPackageVersion(),
151
167
  koboHome: home,
@@ -158,6 +174,7 @@ app.get('/report', async (c) => {
158
174
  settings: { schemaVersion: SETTINGS_SCHEMA_VERSION },
159
175
  claudeCli,
160
176
  codexCli,
177
+ forgeCli: { gh: ghCli, glab: glabCli },
161
178
  workspaces: {
162
179
  total: settingsRow.n,
163
180
  archived: archivedRow.n,
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ import { getDb } from '../db/index.js';
2
3
  import { getBackendPort } from '../services/agent/orchestrator.js';
3
4
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, } from '../services/initial-prompt-template-service.js';
4
5
  import { generateToken, getLanUrls } from '../services/network-access-service.js';
@@ -7,6 +8,7 @@ import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT } from '../services/settings-defaul
7
8
  import * as settingsService from '../services/settings-service.js';
8
9
  import { DEFAULT_CI_FIX_PROMPT_TEMPLATE, DEFAULT_FINALIZATION_PROMPT, DEFAULT_GIT_CONVENTIONS, DEFAULT_PR_PROMPT_TEMPLATE, } from '../services/settings-service.js';
9
10
  import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
11
+ import { countPrunableWsEvents } from '../services/ws-events-retention-service.js';
10
12
  /** Hono sub-router for global and per-project settings CRUD. */
11
13
  const app = new Hono();
12
14
  // GET /api/settings — return full settings
@@ -47,6 +49,28 @@ app.get('/defaults', (c) => {
47
49
  changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
48
50
  });
49
51
  });
52
+ // GET /api/settings/ws-events-retention-preview?days=<n>&keep=<n>
53
+ // Count what a retention window WOULD delete, without deleting anything. Backs
54
+ // the confirmation dialog: enabling retention on a year-old database destroys
55
+ // months of conversation in one go, and the user is entitled to the number
56
+ // before agreeing to it.
57
+ app.get('/ws-events-retention-preview', (c) => {
58
+ try {
59
+ const days = Number.parseInt(c.req.query('days') ?? '', 10);
60
+ const keep = Number.parseInt(c.req.query('keep') ?? '', 10);
61
+ if (!Number.isInteger(days) || days < 0 || !Number.isInteger(keep) || keep < 0) {
62
+ return c.json({ error: 'days and keep must be non-negative integers' }, 400);
63
+ }
64
+ const db = getDb();
65
+ const total = db.prepare('SELECT COUNT(*) AS c FROM ws_events').get().c;
66
+ const deletable = countPrunableWsEvents(db, { retentionDays: days, keepPerWorkspace: keep });
67
+ return c.json({ deletable, total });
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ return c.json({ error: message }, 500);
72
+ }
73
+ });
50
74
  // GET /api/settings/network — network access state + LAN URLs
51
75
  app.get('/network', (c) => {
52
76
  try {