@loicngr/kobo 1.11.39 → 1.11.40

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 (145) hide show
  1. package/AGENTS.md +12 -0
  2. package/CHANGELOG.md +4 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  4. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  5. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  6. package/dist/server/db/mcp-message-schema.js +16 -0
  7. package/dist/server/db/migrations.js +4 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/db/search-schema.js +53 -0
  10. package/dist/server/index.js +17 -86
  11. package/dist/server/middleware/network-auth-middleware.js +5 -1
  12. package/dist/server/routes/changelog.js +4 -49
  13. package/dist/server/routes/git.js +2 -2
  14. package/dist/server/routes/mcp.js +47 -0
  15. package/dist/server/routes/pull-requests.js +1 -1
  16. package/dist/server/routes/search.js +13 -4
  17. package/dist/server/routes/settings.js +5 -0
  18. package/dist/server/routes/workspaces.js +225 -241
  19. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  20. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  21. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  22. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  23. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  24. package/dist/server/services/agent/orchestrator.js +20 -3
  25. package/dist/server/services/change-source-branch-service.js +10 -42
  26. package/dist/server/services/chat-receipt-service.js +33 -0
  27. package/dist/server/services/cron-service.js +27 -1
  28. package/dist/server/services/mcp-client-context.js +12 -0
  29. package/dist/server/services/mcp-connection-info-service.js +63 -0
  30. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  31. package/dist/server/services/mcp-message-request-service.js +64 -0
  32. package/dist/server/services/pr-checkout-service.js +10 -10
  33. package/dist/server/services/pr-watcher-service.js +14 -9
  34. package/dist/server/services/search/indexer.js +232 -0
  35. package/dist/server/services/search/worker.js +76 -0
  36. package/dist/server/services/search-service.js +104 -110
  37. package/dist/server/services/update-check-service.js +119 -0
  38. package/dist/server/services/websocket-service.js +121 -35
  39. package/dist/server/services/workspace-dialogue-service.js +128 -0
  40. package/dist/server/services/workspace-message-service.js +58 -0
  41. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  42. package/dist/server/services/worktree-restore-service.js +2 -2
  43. package/dist/server/services/worktree-service.js +197 -124
  44. package/dist/server/utils/bounded-process.js +137 -0
  45. package/dist/server/utils/git-ops.js +76 -128
  46. package/dist/server/utils/git-repo-lock.js +27 -13
  47. package/dist/shared/workspace-dialogue-tools.js +89 -0
  48. package/dist/shared/workspace-message-types.js +19 -0
  49. package/package.json +2 -1
  50. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  51. package/src/client/dist/pwa/assets/ActivityFeed-MqsC-95i.js +8 -0
  52. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BxURWYxa.js} +1 -1
  53. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-DjiMDAUi.js} +2 -2
  54. package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +2 -0
  55. package/src/client/dist/pwa/assets/DashboardPage-mA5RZf1t.js +1 -0
  56. package/src/client/dist/pwa/assets/DiffViewer-Ci7ucmy3.js +8 -0
  57. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  58. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-RZTvcJkT.js} +1 -1
  59. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-CTOc3yA0.js} +1 -1
  60. package/src/client/dist/pwa/assets/GitPanel-_kL2gMoy.js +3 -0
  61. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-D3__hKJ5.js} +1 -1
  62. package/src/client/dist/pwa/assets/MainLayout-B3JKUm3n.js +2 -0
  63. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-td0RAVPx.js} +1 -1
  64. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-BkyFPV7a.js} +1 -1
  65. package/src/client/dist/pwa/assets/QSelect-WspK3KC7.js +1 -0
  66. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-7pw7iuV0.js} +1 -1
  67. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  68. package/src/client/dist/pwa/assets/SchedulePanel-CLNM13oo.js +1 -0
  69. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  70. package/src/client/dist/pwa/assets/SearchPage-CmRgIvu4.js +1 -0
  71. package/src/client/dist/pwa/assets/SettingsPage-Cj6Y_TlN.js +9 -0
  72. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  73. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-CCHGEsKf.js} +1 -1
  74. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-ijmsYk-2.js} +9 -9
  75. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DuQFjlQF.js} +1 -1
  76. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-C5RF0lOK.js} +1 -1
  77. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BR_XElog.js} +1 -1
  78. package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +4 -0
  79. package/src/client/dist/pwa/assets/{WorkspacePage-BAC6u9OP.css → WorkspacePage-CqcMweNL.css} +1 -1
  80. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-DUqzdkbg.js} +1 -1
  81. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-ImiSpzfI.js} +1 -1
  82. package/src/client/dist/pwa/assets/de-CaHID9mW.js +17 -0
  83. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-2C1hn1pw.js} +1 -1
  84. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-Bm1vVaBr.js} +1 -1
  85. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-_D8DFYXo.js} +1 -1
  86. package/src/client/dist/pwa/assets/engineFeatures-CZ9qfBYq.js +1 -0
  87. package/src/client/dist/pwa/assets/es-ZhzlCmer.js +17 -0
  88. package/src/client/dist/pwa/assets/{expand-template-B6xAlAaM.js → expand-template-CbDNnTPP.js} +1 -1
  89. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-pPImdFUy.js} +1 -1
  90. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-dTX38ZsV.js} +1 -1
  91. package/src/client/dist/pwa/assets/fr-BfXw7L7J.js +17 -0
  92. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-DGluDnAm.js} +1 -1
  93. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-BgviTnpD.js} +1 -1
  94. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-CV-MrunL.js} +1 -1
  95. package/src/client/dist/pwa/assets/i18n-CmXBenUp.js +1 -0
  96. package/src/client/dist/pwa/assets/index-9tiw3NjK.js +20 -0
  97. package/src/client/dist/pwa/assets/it-CHAurcTF.js +17 -0
  98. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C2VFcHf2.js} +1 -1
  99. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-Dg9ZhbP5.js} +1 -1
  100. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-ImgrPYNl.js} +1 -1
  101. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-waWZXS1T.js} +1 -1
  102. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-BB2rtCHM.js} +2 -2
  103. package/src/client/dist/pwa/assets/network-auth-h6O0qCMA.js +1 -0
  104. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-Dyg-FGI6.js} +1 -1
  105. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-Dfpg92li.js} +1 -1
  106. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-DDWmNBW1.js} +1 -1
  107. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-XUfY_zbw.js} +1 -1
  108. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DVLs-DBA.js} +1 -1
  109. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-D34BqMwB.js} +1 -1
  110. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-DwUkPy-O.js} +1 -1
  111. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-C7-UpNXx.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  113. package/src/client/dist/pwa/assets/use-tours-DfE-kwrX.js +2 -0
  114. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-DwwdOEAq.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-Cx1oQhEr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-BQOiJsdx.js} +1 -1
  117. package/src/client/dist/pwa/index.html +1 -1
  118. package/src/client/dist/pwa/sw.js +1 -1
  119. package/src/mcp-server/README.md +118 -2
  120. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  121. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  122. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  123. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  124. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  125. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  126. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  127. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  128. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  129. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  130. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  131. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  132. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  133. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  134. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  135. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  136. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  137. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  138. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  139. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  140. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  141. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  142. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  143. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  144. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  145. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
package/AGENTS.md CHANGED
@@ -162,6 +162,18 @@ Every feature that touches the schema:
162
162
 
163
163
  ## WebSocket protocol
164
164
 
165
+ ### External MCP dialogue and update checks
166
+
167
+ `POST /api/mcp` exposes stateless Streamable HTTP discovery/dialogue tools, behind the existing Host/Origin and network-token gates. The global stdio server forwards dialogue calls through this backend so agent runtime ownership remains centralized. `workspace-message-service.ts` shares delivery with WebSocket chat.
168
+
169
+ Optional `send_workspace_message.idempotency_key` uses `mcp_message_requests` (migration v43). Reserve before dispatch, preserve fingerprints and terminal outcomes, and never automatically resend an unfinished request after restart. The user event and accepted receipt commit in one SQLite transaction; broadcast follows commit. Dispatch may precede persistence, so interrupted requests are explicitly `delivery_unknown`, not an exactly-once execution promise. The backend alone calls `reconcileMessageRequests` on startup. History retention does not delete the ledger; workspace deletion cascades it.
170
+
171
+ MCP messages and question answers include optional `source: { kind: 'mcp', clientName, transport }`; keep it across live, sync, history and conversation grouping paths without changing legacy `sender` behavior. Client names are display labels, not authorization identities. Unicode HTTP labels use URI encoding with `X-Kobo-Client-Name-Encoding: uri`; stdio forwards the initialization name or `KOBO_MCP_CLIENT_NAME` override.
172
+
173
+ `update-check-service.ts` owns the single immediate/every-20-minute npm release check. It is explicitly started/stopped by the backend, coalesces route/poller calls, and broadcasts ephemeral global `kobo:update-checked` events. The client update store also refreshes at reconnect. Do not reintroduce a per-tab registry poll or automatically install updates.
174
+
175
+ The explicit `npm run test:mcp:live` / `make test-mcp-live` command requires `KOBO_LIVE_ENGINE` and `KOBO_LIVE_MODEL`, uses real credentials and disposable data, and must stay outside `make ci`. Normal CI uses deterministic transport/service tests.
176
+
165
177
  Clients subscribe to individual workspace ids. The server sends `WsEvent` objects:
166
178
 
167
179
  ```ts
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.40
8
+
9
+ - feat(workspaces): add external MCP access and harden workspace flows
10
+
7
11
  ## 1.11.39
8
12
 
9
13
  - fix(deps): require Claude SDK with Fable 5.1 support
@@ -4,7 +4,7 @@ import { nanoid } from 'nanoid';
4
4
  import * as cronService from '../server/services/cron-service.js';
5
5
  import * as settingsService from '../server/services/settings-service.js';
6
6
  import { slugifyProjectName } from '../server/utils/project-slug.js';
7
- import { resolveExistingPathInside } from '../server/utils/safe-path.js';
7
+ import { ensureDirectoryInside, resolveExistingPathInside } from '../server/utils/safe-path.js';
8
8
  import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
9
9
  import { MASKED_SECRET, SECRET_GLOBAL_KEYS } from '../shared/consts.js';
10
10
  /** Allowed task status values. */
@@ -404,7 +404,7 @@ export function readDocumentHandler(worktreePath, relPath) {
404
404
  }
405
405
  /**
406
406
  * Append a thought / decision / note to
407
- * `.ai/thoughts/logs/<YYYY-MM-DD>-<slug>.md`. Creates the directory if missing.
407
+ * `.ai/thoughts/logs/<YYYY-MM-DD>-<slug>-<timestamp>-<id>.md`. Creates the directory if missing.
408
408
  * Returns the path (worktree-relative) of the file actually written — useful
409
409
  * for the agent to reference it in chat.
410
410
  *
@@ -419,8 +419,7 @@ export function logThoughtHandler(worktreePath, data) {
419
419
  const content = data.content?.trim();
420
420
  if (!content)
421
421
  throw new Error('content is required');
422
- const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts', 'logs');
423
- fs.mkdirSync(thoughtsDir, { recursive: true });
422
+ const thoughtsDir = ensureDirectoryInside(worktreePath, path.join('.ai', 'thoughts', 'logs'));
424
423
  const date = new Date().toISOString().slice(0, 10);
425
424
  const slug = title
426
425
  .toLowerCase()
@@ -430,12 +429,20 @@ export function logThoughtHandler(worktreePath, data) {
430
429
  .replace(/^-+|-+$/g, '')
431
430
  .slice(0, 60) || 'note';
432
431
  const tagSuffix = data.tag ? `-${data.tag.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}` : '';
433
- const filename = `${date}-${slug}${tagSuffix}.md`;
434
- const abs = path.join(thoughtsDir, filename);
435
- const relPath = `.ai/thoughts/logs/${filename}`;
436
432
  const header = `# ${title}\n\n_${new Date().toISOString()}_${data.tag ? ` · tag: \`${data.tag}\`` : ''}\n\n`;
437
- fs.writeFileSync(abs, header + content + (content.endsWith('\n') ? '' : '\n'), 'utf-8');
438
- return { path: relPath };
433
+ for (let attempt = 0; attempt < 5; attempt++) {
434
+ const filename = `${date}-${slug}${tagSuffix}-${Date.now()}-${nanoid(10)}.md`;
435
+ const abs = path.join(thoughtsDir, filename);
436
+ try {
437
+ fs.writeFileSync(abs, header + content + (content.endsWith('\n') ? '' : '\n'), { encoding: 'utf-8', flag: 'wx' });
438
+ return { path: `.ai/thoughts/logs/${filename}` };
439
+ }
440
+ catch (err) {
441
+ if (err.code !== 'EEXIST')
442
+ throw err;
443
+ }
444
+ }
445
+ throw new Error('Could not allocate a unique thought filename');
439
446
  }
440
447
  /**
441
448
  * Aggregate `usage` events from `ws_events` to report how many tokens and
@@ -3,7 +3,9 @@ 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 { WORKSPACE_DIALOGUE_TOOLS } from '../shared/workspace-dialogue-tools.js';
6
7
  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
+ import { callWorkspaceDialogueTool } from './workspace-dialogue-client.js';
7
9
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
8
10
  const dbPath = process.env.KOBO_DB_PATH;
9
11
  const settingsPath = process.env.KOBO_SETTINGS_PATH;
@@ -94,9 +96,12 @@ async function notifyAgentDescriptionUpdated() {
94
96
  /** Generic HTTP request to the Kobo backend, returning parsed JSON or null. */
95
97
  async function backendRequest(method, pathname, body) {
96
98
  const url = `${backendUrl}${pathname}`;
97
- const init = { method };
99
+ const headers = process.env.KOBO_NETWORK_TOKEN
100
+ ? { 'X-Kobo-Token': process.env.KOBO_NETWORK_TOKEN }
101
+ : {};
102
+ const init = { method, headers };
98
103
  if (body !== undefined) {
99
- init.headers = { 'Content-Type': 'application/json' };
104
+ headers['Content-Type'] = 'application/json';
100
105
  init.body = JSON.stringify(body);
101
106
  }
102
107
  const res = await fetch(url, init);
@@ -475,6 +480,7 @@ const WORKSPACE_SCOPED_TOOLS = [
475
480
  },
476
481
  ];
477
482
  const GLOBAL_TOOLS = [
483
+ ...WORKSPACE_DIALOGUE_TOOLS,
478
484
  {
479
485
  name: 'list_workspaces',
480
486
  description: 'List Kōbō workspaces with their id, title, status, and creation date. Works even when the Kōbō backend server is not running (reads the database directly). Use this to discover existing workspaces before creating one or targeting one with archive_workspace/stop_workspace.',
@@ -594,6 +600,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
594
600
  return fail(`Tool '${name}' requires a workspace-bound MCP session (KOBO_WORKSPACE_ID not set).`);
595
601
  }
596
602
  try {
603
+ if (WORKSPACE_DIALOGUE_TOOLS.some((tool) => tool.name === name)) {
604
+ if (workspaceId && a.workspace_id === workspaceId && name === 'send_workspace_message')
605
+ return fail('Use the normal conversation to communicate in your own workspace.');
606
+ return await callWorkspaceDialogueTool(backendUrl, name, a, process.env.KOBO_NETWORK_TOKEN, process.env.KOBO_MCP_CLIENT_NAME ?? server.getClientVersion()?.name);
607
+ }
597
608
  if (name === 'list_tasks') {
598
609
  return ok(listTasksHandler(db, workspaceId));
599
610
  }
@@ -0,0 +1,28 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { validateDialogueArguments } from '../shared/workspace-dialogue-tools.js';
4
+ import { normalizeClientName } from '../shared/workspace-message-types.js';
5
+ /** Stateless HTTP bridge: external stdio clients never own agent runtime state. */
6
+ export async function callWorkspaceDialogueTool(backendUrl, name, args, token, clientName) {
7
+ const arguments_ = validateDialogueArguments(name, args);
8
+ const client = new Client({ name: 'kobo-stdio-bridge', version: '1.0.0' });
9
+ const signal = AbortSignal.timeout(30_000);
10
+ const transport = new StreamableHTTPClientTransport(new URL(`${backendUrl.replace(/\/$/, '')}/api/mcp`), {
11
+ requestInit: {
12
+ signal,
13
+ headers: {
14
+ ...(token ? { 'X-Kobo-Token': token } : {}),
15
+ 'X-Kobo-Client-Name': encodeURIComponent(normalizeClientName(clientName)),
16
+ 'X-Kobo-Client-Name-Encoding': 'uri',
17
+ 'X-Kobo-Client-Transport': 'stdio',
18
+ },
19
+ },
20
+ });
21
+ try {
22
+ await client.connect(transport, { signal });
23
+ return await client.callTool({ name, arguments: arguments_ }, undefined, { signal });
24
+ }
25
+ finally {
26
+ await client.close();
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ /** v43 schema shared by fresh installs and the append-only migration. */
2
+ export function initMcpMessageSchema(db) {
3
+ db.exec(`CREATE TABLE IF NOT EXISTS mcp_message_requests (
4
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
5
+ idempotency_key TEXT NOT NULL,
6
+ request_id TEXT NOT NULL UNIQUE,
7
+ fingerprint TEXT NOT NULL,
8
+ state TEXT NOT NULL CHECK (state IN ('reserved','dispatching','accepted','rejected','unknown')),
9
+ result_json TEXT,
10
+ event_id TEXT,
11
+ session_id TEXT,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL,
14
+ PRIMARY KEY (workspace_id, idempotency_key)
15
+ )`);
16
+ }
@@ -1,4 +1,6 @@
1
+ import { initMcpMessageSchema } from './mcp-message-schema.js';
1
2
  import { initSchema } from './schema.js';
3
+ import { initSearchSchema } from './search-schema.js';
2
4
  /** Ordered registry of all schema migrations. Append new entries at the end. */
3
5
  export const migrations = [
4
6
  {
@@ -740,6 +742,8 @@ export const migrations = [
740
742
  CREATE INDEX IF NOT EXISTS idx_workspace_activity_created ON workspace_activity(created_at);`);
741
743
  },
742
744
  },
745
+ { version: 42, name: 'index-logical-search-messages', migrate: initSearchSchema },
746
+ { version: 43, name: 'durable-mcp-message-requests', migrate: initMcpMessageSchema },
743
747
  ];
744
748
  /** Current schema version — always equals the highest migration version. */
745
749
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -1,3 +1,5 @@
1
+ import { initMcpMessageSchema } from './mcp-message-schema.js';
2
+ import { initSearchSchema } from './search-schema.js';
1
3
  /** Create all tables and indexes for a fresh install. Not used for upgrades -- see migrations.ts. */
2
4
  export function initSchema(db) {
3
5
  db.exec(`
@@ -221,4 +223,6 @@ export function initSchema(db) {
221
223
  CREATE INDEX IF NOT EXISTS idx_session_event_metrics_session
222
224
  ON session_event_metrics(session_id);
223
225
  `);
226
+ initSearchSchema(db);
227
+ initMcpMessageSchema(db);
224
228
  }
@@ -0,0 +1,53 @@
1
+ /** Derived data only. The event log remains the source of truth. */
2
+ export function initSearchSchema(db) {
3
+ db.exec(`
4
+ CREATE TABLE IF NOT EXISTS search_index_state (
5
+ id INTEGER PRIMARY KEY CHECK(id = 1), cursor TEXT NOT NULL DEFAULT '',
6
+ complete INTEGER NOT NULL DEFAULT 0, processed INTEGER NOT NULL DEFAULT 0,
7
+ total INTEGER NOT NULL DEFAULT -1, base_order INTEGER NOT NULL
8
+ );
9
+ INSERT OR IGNORE INTO search_index_state(id, base_order)
10
+ SELECT 1, COALESCE(MAX(rowid), 0) FROM ws_events;
11
+ CREATE TABLE IF NOT EXISTS search_changes (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL, operation TEXT NOT NULL
13
+ );
14
+ CREATE INDEX IF NOT EXISTS idx_search_changes_event ON search_changes(event_id);
15
+ CREATE TABLE IF NOT EXISTS search_fragments (
16
+ event_id TEXT PRIMARY KEY, message_key TEXT NOT NULL,
17
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
18
+ session_id TEXT, type TEXT NOT NULL, created_at TEXT NOT NULL,
19
+ event_order INTEGER NOT NULL, text TEXT NOT NULL
20
+ );
21
+ CREATE INDEX IF NOT EXISTS idx_search_fragments_message ON search_fragments(message_key, event_order);
22
+ CREATE TABLE IF NOT EXISTS search_messages (
23
+ id INTEGER PRIMARY KEY, message_key TEXT NOT NULL UNIQUE,
24
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
25
+ session_id TEXT, event_id TEXT NOT NULL, type TEXT NOT NULL,
26
+ created_at TEXT NOT NULL, text TEXT NOT NULL, normalized TEXT NOT NULL
27
+ );
28
+ CREATE INDEX IF NOT EXISTS idx_search_messages_workspace ON search_messages(workspace_id, created_at DESC);
29
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_messages_fts USING fts5(
30
+ normalized, content='search_messages', content_rowid='id', tokenize='trigram case_sensitive 1'
31
+ );
32
+ CREATE TRIGGER IF NOT EXISTS search_messages_insert AFTER INSERT ON search_messages BEGIN
33
+ INSERT INTO search_messages_fts(rowid, normalized) VALUES (new.id, new.normalized);
34
+ END;
35
+ CREATE TRIGGER IF NOT EXISTS search_messages_delete AFTER DELETE ON search_messages BEGIN
36
+ INSERT INTO search_messages_fts(search_messages_fts, rowid, normalized) VALUES ('delete', old.id, old.normalized);
37
+ END;
38
+ CREATE TRIGGER IF NOT EXISTS search_messages_update AFTER UPDATE ON search_messages BEGIN
39
+ INSERT INTO search_messages_fts(search_messages_fts, rowid, normalized) VALUES ('delete', old.id, old.normalized);
40
+ INSERT INTO search_messages_fts(rowid, normalized) VALUES (new.id, new.normalized);
41
+ END;
42
+ CREATE TRIGGER IF NOT EXISTS search_events_insert AFTER INSERT ON ws_events BEGIN
43
+ INSERT INTO search_changes(event_id, operation) VALUES(new.id, 'insert');
44
+ END;
45
+ CREATE TRIGGER IF NOT EXISTS search_events_update AFTER UPDATE ON ws_events BEGIN
46
+ INSERT INTO search_changes(event_id, operation) VALUES(old.id, 'update');
47
+ INSERT INTO search_changes(event_id, operation) SELECT new.id, 'update' WHERE new.id != old.id;
48
+ END;
49
+ CREATE TRIGGER IF NOT EXISTS search_events_delete AFTER DELETE ON ws_events BEGIN
50
+ INSERT INTO search_changes(event_id, operation) VALUES(old.id, 'delete');
51
+ END;
52
+ `);
53
+ }
@@ -18,6 +18,7 @@ import fsRouter from './routes/fs.js';
18
18
  import gitRouter from './routes/git.js';
19
19
  import healthRouter from './routes/health.js';
20
20
  import imagesRouter from './routes/images.js';
21
+ import mcpRouter from './routes/mcp.js';
21
22
  import { migrationRouter } from './routes/migration.js';
22
23
  import notionRouter from './routes/notion.js';
23
24
  import pullRequestsRouter from './routes/pull-requests.js';
@@ -29,23 +30,27 @@ import usageRoutes from './routes/usage.js';
29
30
  import voiceRouter from './routes/voice.js';
30
31
  import workspaceTemplatesRouter from './routes/workspace-templates.js';
31
32
  import workspacesRouter from './routes/workspaces.js';
32
- import { getAvailableSkills, isAgentUnavailableError, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
33
+ import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
33
34
  import * as autoLoopService from './services/auto-loop-service.js';
34
35
  import { startAwaitingUserReminder, stopAwaitingUserReminder } from './services/awaiting-user-reminder-service.js';
35
36
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
36
37
  import * as cronService from './services/cron-service.js';
37
38
  import { createDailyDbBackupIfNeeded, createPreMigrationBackup, startDailyDbBackupScheduler, } from './services/db-backup-service.js';
38
39
  import { startDevServer, stopAllDevServers, stopDevServer } from './services/dev-server-service.js';
40
+ import { reconcileMessageRequests } from './services/mcp-message-request-service.js';
39
41
  import { authorizeWsUpgrade, generateToken, getLanHostnames, getLanUrls, isAllowedOrigin, isAllowedRequestHost, resolveBindHost, resolveDevClientOrigin, resolveNetworkAccessEnvOverrides, resolveProxyHostname, } from './services/network-access-service.js';
40
42
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
41
43
  import * as quotaBackoffService from './services/quota-backoff-service.js';
44
+ import { startSearchIndex, stopSearchIndex } from './services/search-service.js';
42
45
  import { getGlobalSettings, updateNetworkAccessSettings } from './services/settings-service.js';
43
46
  import { reloadDefaultTemplates } from './services/templates-service.js';
44
47
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
48
+ import { startUpdateChecker, stopUpdateChecker } from './services/update-check-service.js';
45
49
  import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
46
50
  import * as wakeupService from './services/wakeup-service.js';
47
- import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
48
- import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
51
+ import { handleConnection, setMessageHandler } from './services/websocket-service.js';
52
+ import { deliverWorkspaceMessage } from './services/workspace-message-service.js';
53
+ import { getWorkspace } from './services/workspace-service.js';
49
54
  import { pruneWsEvents, resolveRetentionConfig } from './services/ws-events-retention-service.js';
50
55
  import { getChangelogPath, getClientSpaPath, getDbPath, getKoboHome, getPackageVersion, resolveSpaFile, } from './utils/paths.js';
51
56
  import { formatStartupBanner, readStartupNotes, startStartupSpinner, startupDebug } from './utils/startup-display.js';
@@ -69,6 +74,8 @@ catch (err) {
69
74
  console.error('[kobo] Pre-migration backup failed (continuing — daily backup remains as fallback):', err);
70
75
  }
71
76
  runMigrations(db);
77
+ reconcileMessageRequests(db);
78
+ startSearchIndex();
72
79
  // Event retention. OPT-IN: disabled by default, so this is a no-op until the
73
80
  // user sets a window in Settings → Worktrees. Deliberately not enabled by
74
81
  // default — turning it on for an existing install would delete months of
@@ -145,6 +152,7 @@ app.use('/api/*', networkAuthMiddleware);
145
152
  app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
146
153
  // Mount route sub-routers
147
154
  app.route('/api/workspaces', workspacesRouter);
155
+ app.route('/api/mcp', mcpRouter);
148
156
  app.route('/api/pull-requests', pullRequestsRouter);
149
157
  app.route('/api/workspaces', imagesRouter);
150
158
  app.route('/api/notion', notionRouter);
@@ -221,6 +229,7 @@ const server = serve({
221
229
  hostname: bindHost,
222
230
  }, (info) => {
223
231
  setBackendPort(info.port);
232
+ startUpdateChecker();
224
233
  stopStartupSpinner();
225
234
  const settings = getGlobalSettings();
226
235
  console.log(formatStartupBanner({
@@ -346,91 +355,11 @@ terminalWss.on('connection', (ws, workspaceId) => {
346
355
  setMessageHandler(async (type, payload) => {
347
356
  const p = payload;
348
357
  if (type === 'chat:message' && p?.workspaceId && p?.content) {
349
- if (getWorkspace(p.workspaceId)?.status === 'compacting') {
350
- emitEphemeral(p.workspaceId, 'chat:rejected', {
351
- reason: 'compacting',
352
- sessionId: p.sessionId,
353
- content: p.content,
354
- message: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
355
- });
356
- return;
357
- }
358
- // Auto-loop owns the agent's turns. A user message means the user wants
359
- // to redirect the conversation, so disable the loop (idempotent — the
360
- // `autoloop:disabled` event is emitted with reason='user-action' so the
361
- // frontend chip updates) and let the message through to the running
362
- // session. The user can re-enable auto-loop manually once their
363
- // intervention is done. Grooming phase (ready=0) is skipped — the loop
364
- // hasn't started yet, so chat messages during grooming pass through
365
- // untouched (the user can still answer the agent's questions).
366
- const autoLoopStatus = autoLoopService.getStatus(p.workspaceId);
367
- if (autoLoopStatus.auto_loop && autoLoopStatus.auto_loop_ready) {
368
- autoLoopService.disable(p.workspaceId, 'user-action');
369
- }
370
- // Reject chat input while paused on canUseTool — sending here would spawn
371
- // a parallel session and orphan the pending callback.
372
- const wsRow = getWorkspace(p.workspaceId);
373
- if (wsRow?.status === 'awaiting-user') {
374
- emitEphemeral(p.workspaceId, 'chat:rejected', {
375
- reason: 'awaiting-user',
376
- sessionId: p.sessionId,
377
- content: p.content,
378
- message: 'Answer via the question panel — typing in chat would orphan the pending callback',
379
- });
380
- return;
381
- }
382
- // Prefer the session explicitly selected by the client (sessionId hint),
383
- // falling back to the running/most-recent non-idle session so idle sessions
384
- // never steal the tagging.
385
- const activeSession = getActiveSession(p.workspaceId);
386
- const sessionTag = p.sessionId ?? activeSession?.id ?? undefined;
387
358
  try {
388
- await sendMessage(p.workspaceId, p.content, p.sessionId);
389
- emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, sessionTag);
390
- if (p.force)
391
- emitEphemeral(p.workspaceId, 'chat:accepted', { sessionId: p.sessionId });
359
+ await deliverWorkspaceMessage(p.workspaceId, { ...p, content: p.content });
392
360
  }
393
- catch (err) {
394
- const msg = err instanceof Error ? err.message : String(err);
395
- // Resume on every shape that means "no agent can receive this message"
396
- // — the closed-stdin case included. Anything else (queue full, disk
397
- // error, a stop in progress) surfaces to the user instead of silently
398
- // respawning a fresh agent.
399
- if (!isAgentUnavailableError(msg)) {
400
- emitEphemeral(p.workspaceId, 'chat:rejected', { sessionId: p.sessionId, content: p.content, message: msg });
401
- console.error(`[ws] chat:message failed for workspace ${p.workspaceId}:`, err);
402
- return;
403
- }
404
- // Agent not running — resume the session hinted by the client if any,
405
- // otherwise the most-recent active session.
406
- try {
407
- const workspace = getWorkspace(p.workspaceId);
408
- if (workspace) {
409
- const worktreePath = workspace.worktreePath;
410
- // Plan mode blocks MCP tools — when the caller knows the message
411
- // requires them (e.g. grooming), it sets the override to bypass the
412
- // workspace default for this spawn only.
413
- const effectiveMode = p.agentPermissionModeOverride ?? workspace.agentPermissionMode;
414
- const started = startAgent(p.workspaceId, worktreePath, p.content, workspace.model, true, effectiveMode, p.sessionId, workspace.reasoningEffort);
415
- updateWorkspaceStatus(p.workspaceId, 'executing');
416
- const resumedSessionId = p.sessionId ?? started.agentSessionId;
417
- emit(p.workspaceId, 'user:message', { content: p.content, sender: 'user' }, resumedSessionId);
418
- if (p.force)
419
- emitEphemeral(p.workspaceId, 'chat:accepted', { sessionId: resumedSessionId });
420
- }
421
- else {
422
- emitEphemeral(p.workspaceId, 'chat:rejected', {
423
- sessionId: p.sessionId,
424
- content: p.content,
425
- message: `Workspace '${p.workspaceId}' not found`,
426
- });
427
- }
428
- }
429
- catch (restartErr) {
430
- const message = restartErr instanceof Error ? restartErr.message : String(restartErr);
431
- emitEphemeral(p.workspaceId, 'chat:rejected', { sessionId: p.sessionId, content: p.content, message });
432
- console.error('[ws] Failed to resume agent:', message);
433
- }
361
+ catch (error) {
362
+ console.error(`[ws] chat:message failed for workspace ${p.workspaceId}:`, error);
434
363
  }
435
364
  }
436
365
  if (type === 'workspace:start' && p?.workspaceId) {
@@ -543,6 +472,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
543
472
  if (isShuttingDown)
544
473
  return;
545
474
  isShuttingDown = true;
475
+ stopUpdateChecker();
546
476
  console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
547
477
  const forceExitTimer = setTimeout(() => {
548
478
  console.error('[kobo] Graceful shutdown timed out; forcing exit');
@@ -608,6 +538,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
608
538
  }
609
539
  finally {
610
540
  try {
541
+ await stopSearchIndex();
611
542
  await dailyBackupScheduler.stop();
612
543
  closeDb();
613
544
  console.log('[kobo] Database closed');
@@ -21,11 +21,15 @@ export const networkAuthMiddleware = async (c, next) => {
21
21
  return next();
22
22
  const address = getConnInfo(c).remote.address;
23
23
  const global = getGlobalSettings();
24
+ // MCP clients commonly send the shared token as an Authorization header.
25
+ const bearerToken = /^\/api\/mcp\/?$/.test(c.req.path)
26
+ ? c.req.header('Authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]
27
+ : undefined;
24
28
  const decision = evaluateNetworkAccess({
25
29
  address,
26
30
  enabled: global.networkAccessEnabled,
27
31
  expectedToken: global.networkAccessToken,
28
- providedToken: c.req.header('X-Kobo-Token'),
32
+ providedToken: c.req.header('X-Kobo-Token') ?? bearerToken,
29
33
  trustLoopback: !global.networkAccessBehindProxy,
30
34
  });
31
35
  if (decision.allow)
@@ -1,57 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import { Hono } from 'hono';
3
+ import { refreshUpdateCheck } from '../services/update-check-service.js';
3
4
  import { parseChangelog } from '../utils/changelog.js';
4
- import { getChangelogPath, getPackageVersion } from '../utils/paths.js';
5
+ import { getChangelogPath } from '../utils/paths.js';
6
+ export { _clearLatestVersionCache } from '../services/update-check-service.js';
5
7
  export { parseChangelog } from '../utils/changelog.js';
6
8
  /** Hono sub-router for the in-app "What's new" dialog. */
7
9
  const app = new Hono();
8
- /**
9
- * Latest version published to npm, looked up at most once a day.
10
- *
11
- * "What's new" tells the user what changed once they have upgraded; nothing
12
- * told them an upgrade existed. A self-hosted install would silently rot.
13
- *
14
- * Best-effort by construction: no network, a registry hiccup or an air-gapped
15
- * machine all resolve to null, and the client simply shows nothing.
16
- */
17
- const REGISTRY_URL = 'https://registry.npmjs.org/@loicngr/kobo/latest';
18
- const LATEST_TTL_MS = 24 * 60 * 60 * 1000;
19
- let latestCache = null;
20
- /** The lookup in progress, so two loads racing on a cold cache share one request. */
21
- let inFlight = null;
22
- async function fetchLatestVersion() {
23
- if (latestCache && Date.now() - latestCache.fetchedAt < LATEST_TTL_MS)
24
- return latestCache.version;
25
- if (inFlight)
26
- return inFlight;
27
- inFlight = lookupLatestVersion().finally(() => {
28
- inFlight = null;
29
- });
30
- return inFlight;
31
- }
32
- async function lookupLatestVersion() {
33
- let version = null;
34
- try {
35
- const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5_000) });
36
- if (res.ok) {
37
- const body = (await res.json());
38
- if (typeof body.version === 'string')
39
- version = body.version;
40
- }
41
- }
42
- catch {
43
- // Offline, blocked, or the registry is down. Not worth a log line on a
44
- // path that runs on every app load.
45
- }
46
- // Cache the failure too: a machine with no network must not retry per load.
47
- latestCache = { version, fetchedAt: Date.now() };
48
- return version;
49
- }
50
- /** @internal test-only — drop the cached registry lookup. */
51
- export function _clearLatestVersionCache() {
52
- latestCache = null;
53
- inFlight = null;
54
- }
55
10
  // GET /api/changelog — current app version + parsed CHANGELOG.md sections,
56
11
  // powering the "What's new" dialog shown after an update, plus the latest
57
12
  // version published to npm so the UI can offer to upgrade.
@@ -64,7 +19,7 @@ app.get('/', async (c) => {
64
19
  catch {
65
20
  // No CHANGELOG.md shipped (or unreadable) — degrade to an empty list.
66
21
  }
67
- return c.json({ currentVersion: getPackageVersion(), latestVersion: await fetchLatestVersion(), versions });
22
+ return c.json({ ...(await refreshUpdateCheck()), versions });
68
23
  }
69
24
  catch (err) {
70
25
  const message = err instanceof Error ? err.message : String(err);
@@ -42,7 +42,7 @@ app.get('/files', (c) => {
42
42
  });
43
43
  // GET /api/git/orphan-worktrees?projectPath=<path> — list worktrees of a
44
44
  // project that are NOT attached to any Kōbō workspace yet.
45
- app.get('/orphan-worktrees', (c) => {
45
+ app.get('/orphan-worktrees', async (c) => {
46
46
  try {
47
47
  const projectPath = c.req.query('projectPath');
48
48
  if (!projectPath) {
@@ -51,7 +51,7 @@ app.get('/orphan-worktrees', (c) => {
51
51
  const db = getDb();
52
52
  const rows = db.prepare('SELECT worktree_path FROM workspaces WHERE project_path = ?').all(projectPath);
53
53
  const attachedPaths = new Set(rows.map((r) => r.worktree_path).filter((p) => !!p));
54
- const orphans = listOrphanWorktrees(projectPath, attachedPaths);
54
+ const orphans = await listOrphanWorktrees(projectPath, attachedPaths);
55
55
  return c.json(orphans);
56
56
  }
57
57
  catch (err) {
@@ -0,0 +1,47 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { Hono } from 'hono';
5
+ import { bodyLimit } from 'hono/body-limit';
6
+ import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
7
+ import { createMcpClientContext } from '../services/mcp-client-context.js';
8
+ import { executeWorkspaceDialogueTool } from '../services/workspace-dialogue-service.js';
9
+ const app = new Hono();
10
+ app.use('*', bodyLimit({ maxSize: 1024 * 1024 }));
11
+ app.all('/', async (c) => {
12
+ const server = new Server({ name: 'kobo-workspaces', version: '1.0.0' }, { capabilities: { tools: {} } });
13
+ const transport = new WebStandardStreamableHTTPServerTransport({
14
+ sessionIdGenerator: undefined,
15
+ enableJsonResponse: true,
16
+ });
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: EXTERNAL_MCP_TOOLS }));
18
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
19
+ try {
20
+ const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
21
+ const data = await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
22
+ const structuredContent = data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
23
+ return {
24
+ content: [{ type: 'text', text: JSON.stringify(data) }],
25
+ ...(structuredContent ? { structuredContent } : {}),
26
+ ...(structuredContent?.accepted === false ? { isError: true } : {}),
27
+ };
28
+ }
29
+ catch (error) {
30
+ return {
31
+ isError: true,
32
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
33
+ };
34
+ }
35
+ });
36
+ try {
37
+ await server.connect(transport);
38
+ return await transport.handleRequest(c.req.raw);
39
+ }
40
+ catch (error) {
41
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
42
+ }
43
+ finally {
44
+ await server.close();
45
+ }
46
+ });
47
+ export default app;
@@ -77,7 +77,7 @@ app.post('/diagnose', async (c) => {
77
77
  if (!pr)
78
78
  return c.json({ error: `Pull request #${prNumber} not found` }, 404);
79
79
  const workspaces = listWorkspaces(true).filter((workspace) => path.resolve(workspace.projectPath) === path.resolve(projectPath));
80
- const report = prCheckout.diagnoseLocalState(projectPath, pr.headBranch, worktreesPath ?? null, attachedWorktreePaths(workspaces), workspaces);
80
+ const report = await prCheckout.diagnoseLocalState(projectPath, pr.headBranch, worktreesPath ?? null, attachedWorktreePaths(workspaces), workspaces);
81
81
  if (pr.isFork)
82
82
  report.blockers.push({ kind: 'fork-pr' });
83
83
  return c.json({ report, pr, fingerprint: await prCheckout.computeFingerprint(report) });
@@ -1,12 +1,20 @@
1
1
  import { Hono } from 'hono';
2
- import { searchEvents } from '../services/search-service.js';
2
+ import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
3
3
  const DEFAULT_LIMIT = 50;
4
4
  const MAX_LIMIT = 200;
5
5
  const app = new Hono();
6
6
  // GET /api/search?q=...&limit=50&includeArchived=true
7
7
  // Search readable text across ws_events (user messages + agent outputs),
8
8
  // joined with workspaces. Returns up to `limit` snippets, most recent first.
9
- app.get('/', (c) => {
9
+ app.get('/status', (c) => {
10
+ try {
11
+ return c.json(getSearchIndexStatus());
12
+ }
13
+ catch (err) {
14
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 503);
15
+ }
16
+ });
17
+ app.get('/', async (c) => {
10
18
  const qRaw = c.req.query('q') ?? '';
11
19
  const q = qRaw.trim();
12
20
  if (!q) {
@@ -22,12 +30,13 @@ app.get('/', (c) => {
22
30
  }
23
31
  const includeArchived = c.req.query('includeArchived') === 'true';
24
32
  try {
25
- const results = searchEvents(q, { limit, includeArchived });
33
+ const results = await searchEvents(q, { limit, includeArchived }, c.req.raw.signal);
34
+ c.header('X-Kobo-Search-Partial', String(getSearchIndexStatus().state !== 'ready'));
26
35
  return c.json(results);
27
36
  }
28
37
  catch (err) {
29
38
  const message = err instanceof Error ? err.message : String(err);
30
- return c.json({ error: message }, 500);
39
+ return c.json({ error: message }, 503);
31
40
  }
32
41
  });
33
42
  export default app;