@loicngr/kobo 1.11.44 → 1.11.45

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 (113) hide show
  1. package/AGENTS.md +40 -2
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +13 -1
  4. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  5. package/dist/mcp-server/kobo-tasks-server.js +31 -5
  6. package/dist/server/db/migrations.js +14 -0
  7. package/dist/server/db/schema.js +4 -1
  8. package/dist/server/db/session-handoff-schema.js +24 -0
  9. package/dist/server/index.js +2 -0
  10. package/dist/server/routes/documents.js +1 -1
  11. package/dist/server/routes/workspaces.js +73 -9
  12. package/dist/server/services/agent/engines/claude-code/engine.js +15 -0
  13. package/dist/server/services/agent/engines/codex/engine.js +1 -0
  14. package/dist/server/services/agent/orchestrator.js +63 -10
  15. package/dist/server/services/engine-handoff-service.js +5 -5
  16. package/dist/server/services/session-activity-service.js +17 -0
  17. package/dist/server/services/session-handoff-runtime.js +26 -0
  18. package/dist/server/services/session-handoff-service.js +479 -0
  19. package/dist/server/services/wakeup-service.js +8 -0
  20. package/dist/server/services/workspace-service.js +5 -2
  21. package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
  22. package/dist/shared/document-roots.js +2 -0
  23. package/dist/shared/session-handoff.js +4 -0
  24. package/package.json +1 -1
  25. package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +8 -0
  26. package/src/client/dist/pwa/assets/{ChangelogPage-DSKCvpr2.js → ChangelogPage-CnXTnFhe.js} +1 -1
  27. package/src/client/dist/pwa/assets/{ComparisonPanel-DDxcsxyC.js → ComparisonPanel-CKylN45q.js} +2 -2
  28. package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +2 -0
  29. package/src/client/dist/pwa/assets/{DashboardPage-Dg2s9nnv.js → DashboardPage-CZQLfoV1.js} +1 -1
  30. package/src/client/dist/pwa/assets/{DiffViewer-BSzNScIr.js → DiffViewer-Csf3WmLc.js} +4 -4
  31. package/src/client/dist/pwa/assets/{DocumentsPanel-DfEoI_f7.js → DocumentsPanel-BeCttB-x.js} +1 -1
  32. package/src/client/dist/pwa/assets/{DrawerToggleButton-D85PwhKb.js → DrawerToggleButton-BJBBYeOc.js} +1 -1
  33. package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +3 -0
  34. package/src/client/dist/pwa/assets/{HealthPage-CaLCpejR.js → HealthPage-l88TEk-k.js} +1 -1
  35. package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +1 -0
  36. package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +2 -0
  37. package/src/client/dist/pwa/assets/{QBtnDropdown-qJWGcZpC.js → QBtnDropdown-44Mj-y_1.js} +1 -1
  38. package/src/client/dist/pwa/assets/{QDrawer-DX3PhMqC.js → QDrawer-B4qHw8wt.js} +1 -1
  39. package/src/client/dist/pwa/assets/QExpansionItem-Bok1m8Wi.js +1 -0
  40. package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +1 -0
  41. package/src/client/dist/pwa/assets/{QTooltip-cDK5AOQ5.js → QTooltip-BDihn2aY.js} +1 -1
  42. package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +1 -0
  43. package/src/client/dist/pwa/assets/{SearchPage-BO-J4aNc.js → SearchPage-BwZ2TX1G.js} +1 -1
  44. package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +9 -0
  45. package/src/client/dist/pwa/assets/{SplitWorkspacePage-BIEceNwH.js → SplitWorkspacePage-Bg6nKBcA.js} +1 -1
  46. package/src/client/dist/pwa/assets/{TerminalPanel-DitOCad1.js → TerminalPanel-Cq-FR7A7.js} +9 -9
  47. package/src/client/dist/pwa/assets/{TouchPan-DvPAhZsa.js → TouchPan-BQ1cvzRC.js} +1 -1
  48. package/src/client/dist/pwa/assets/{TourReplayButton-CJYdH3Jf.js → TourReplayButton-Dz1yEz_z.js} +1 -1
  49. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CY-9z9nw.js → WorkspaceAttentionLabels-BCVOP8vp.js} +1 -1
  50. package/src/client/dist/pwa/assets/{WorkspacePage-DAVo-E4p.css → WorkspacePage-BbUvR1Vf.css} +1 -1
  51. package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +4 -0
  52. package/src/client/dist/pwa/assets/{browser-BsB6cFv4.js → browser-BmWgBx9M.js} +1 -1
  53. package/src/client/dist/pwa/assets/build-path-tree-6EjBmYYY.js +1 -0
  54. package/src/client/dist/pwa/assets/de-COObokpu.js +17 -0
  55. package/src/client/dist/pwa/assets/{documents-SCd3w8xV.js → documents-BZ8bwcjr.js} +1 -1
  56. package/src/client/dist/pwa/assets/{editor.api-BUdM7I6y.js → editor.api-CK7--ckY.js} +1 -1
  57. package/src/client/dist/pwa/assets/{efforts-DTJQP9or.js → efforts-Bw0o2Bud.js} +2 -2
  58. package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +1 -0
  59. package/src/client/dist/pwa/assets/es-By2Q9Tca.js +17 -0
  60. package/src/client/dist/pwa/assets/expand-template-C8VyjASo.js +1 -0
  61. package/src/client/dist/pwa/assets/{format-BjQxf4pe.js → format-LtTLLitJ.js} +1 -1
  62. package/src/client/dist/pwa/assets/{formatters-BejhmNTV.js → formatters-Bl9zA7Ai.js} +1 -1
  63. package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +17 -0
  64. package/src/client/dist/pwa/assets/{freemarker2-5d5GhyRp.js → freemarker2-DxezMaje.js} +1 -1
  65. package/src/client/dist/pwa/assets/{handlebars-DaafEsDK.js → handlebars-DK_Bj6Rx.js} +1 -1
  66. package/src/client/dist/pwa/assets/{html-CwHBDd2A.js → html-DFfsfStD.js} +1 -1
  67. package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +1 -0
  68. package/src/client/dist/pwa/assets/index-BLd8DAzg.js +20 -0
  69. package/src/client/dist/pwa/assets/it-CeK5lyIA.js +17 -0
  70. package/src/client/dist/pwa/assets/{javascript-DSNIEEH5.js → javascript-DuNbcaL7.js} +1 -1
  71. package/src/client/dist/pwa/assets/{liquid-Dma1UpA1.js → liquid-CM3JKyhv.js} +1 -1
  72. package/src/client/dist/pwa/assets/{mdx-D8rqEAws.js → mdx-BG3zYwNF.js} +1 -1
  73. package/src/client/dist/pwa/assets/{monaco-contributions-2E7O2zoi.js → monaco-contributions-DLscZGH-.js} +1 -1
  74. package/src/client/dist/pwa/assets/monaco.contribution-DnhZHwXT.js +2 -0
  75. package/src/client/dist/pwa/assets/{network-auth-DNmhJTZn.js → network-auth-BJiPbO5l.js} +1 -1
  76. package/src/client/dist/pwa/assets/{notify-theme-B6Hiwvwb.js → notify-theme-D4p3qPkp.js} +1 -1
  77. package/src/client/dist/pwa/assets/{pending-diff-open-CeiVIFZn.js → pending-diff-open-dYIFdS4r.js} +1 -1
  78. package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +36 -0
  79. package/src/client/dist/pwa/assets/{project-color-BCp9SWoI.js → project-color-R_1je4vW.js} +1 -1
  80. package/src/client/dist/pwa/assets/{python-f-9NqRlI.js → python-COs6pHk2.js} +1 -1
  81. package/src/client/dist/pwa/assets/{razor-DsCmoX0h.js → razor-Jl1VqMsk.js} +1 -1
  82. package/src/client/dist/pwa/assets/{render-chat-markdown-DK5BD9mU.js → render-chat-markdown-AaOo0Tgq.js} +1 -1
  83. package/src/client/dist/pwa/assets/{typescript-cEHg4L10.js → typescript-BU_TUuAM.js} +1 -1
  84. package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
  85. package/src/client/dist/pwa/assets/{use-tours-BmTbosmB.js → use-tours-CVn874kD.js} +2 -2
  86. package/src/client/dist/pwa/assets/{workspace-templates-DUWDLMIX.js → workspace-templates-BCKR8TXr.js} +1 -1
  87. package/src/client/dist/pwa/assets/{xml-Plt-Y7we.js → xml-gFFsobxE.js} +1 -1
  88. package/src/client/dist/pwa/assets/{yaml-ScEmaAeN.js → yaml-DaXaqaGD.js} +1 -1
  89. package/src/client/dist/pwa/index.html +1 -1
  90. package/src/client/dist/pwa/sw.js +1 -1
  91. package/src/mcp-server/README.md +8 -1
  92. package/src/mcp-server/kobo-tasks-handlers.ts +4 -1
  93. package/src/mcp-server/kobo-tasks-server.ts +38 -5
  94. package/src/client/dist/pwa/assets/ActivityFeed-D_y4aikp.js +0 -8
  95. package/src/client/dist/pwa/assets/CreatePage-DKVEknbL.js +0 -2
  96. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +0 -3
  97. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +0 -2
  98. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +0 -1
  99. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +0 -1
  100. package/src/client/dist/pwa/assets/SchedulePanel-LvHiZ6ZH.js +0 -1
  101. package/src/client/dist/pwa/assets/SettingsPage-Aw89fRSq.js +0 -9
  102. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +0 -4
  103. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +0 -1
  104. package/src/client/dist/pwa/assets/de-Mu7biceO.js +0 -17
  105. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +0 -1
  106. package/src/client/dist/pwa/assets/es-BY5IfNI2.js +0 -17
  107. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +0 -1
  108. package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +0 -17
  109. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +0 -1
  110. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +0 -20
  111. package/src/client/dist/pwa/assets/it-ByPZQK_K.js +0 -17
  112. package/src/client/dist/pwa/assets/monaco.contribution-DqOgo72G.js +0 -2
  113. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +0 -36
package/AGENTS.md CHANGED
@@ -264,10 +264,48 @@ Background: the engine was migrated from `@openai/codex-sdk` (one-shot `codex ex
264
264
 
265
265
  ## Workspace operations
266
266
 
267
+ ### Fresh sessions and LLM handoffs
268
+
269
+ `session-handoff-service.ts` owns manual transfers within a workspace, including
270
+ same-engine model changes. `session_handoffs` (migration v46) preserves the source,
271
+ target, request idempotency, report and operation state. Lifecycle reservations
272
+ permit only launches carrying their owner token; ordinary chat, reviews and
273
+ automatic writers remain excluded until completion or explicit cancellation.
274
+
275
+ Stop the source immediately and confirm closure before resuming its exact native
276
+ conversation for the optional report-only turn. The final destination always uses
277
+ a fresh conversation. Generation uses a scoped `submit_session_handoff` MCP tool;
278
+ the backend persists the report, publishes `.ai/handoffs/` documents and owns the
279
+ transition. Never treat this generation as auto-loop progress or settle uncertain
280
+ instructions through it. Pending instruction contents stay with the loop dispatcher.
281
+
282
+ `EngineProcess.ready`, when provided, confirms initial native session/turn
283
+ acceptance. A returned process handle alone is insufficient evidence that a
284
+ transfer started successfully. Startup reconciliation retains interrupted transfers
285
+ for an explicit retry/skip/cancel decision and never replays them automatically.
286
+ Stop cancels pending transfers, including while no engine is running. Preserve
287
+ source selection by last use after a review return; loading a historical completed
288
+ transfer must not change the user's currently selected conversation.
289
+ Failed, interrupted and cancelled transfers restore the source conversation as well
290
+ as its configuration once the engine is stopped. `agent_sessions.activation_order`
291
+ (migration v47) records starts, resumes and source restoration independently of real
292
+ start/end timestamps. Backend current-session and implicit-resume selection, and the
293
+ client's current-session helper, use this order with the legacy timestamp fallback.
294
+ Keep failed target sessions in history and do not force a historical handoff's
295
+ selection over a later conversation after reload.
296
+ Rolled-back targets have `activation_order = -1`: exclude them from default
297
+ selection and implicit resume, but retain their native id and execution history.
298
+ An explicit start/resume activates them again. Without a source conversation,
299
+ rollback clears the client selection, including stale persisted preferences,
300
+ so the next ordinary message starts a fresh conversation on the restored engine.
301
+ Pending wakeups pinned to the source move to the destination only after confirmed
302
+ startup, in the same transaction as handoff completion. Preserve their prompt and
303
+ deadline, and never rebind a wakeup belonging to another conversation.
304
+
267
305
  ### Review LLM and return to the original session
268
306
 
269
- The current conversation is the running session, or the last used session by
270
- `max(startedAt, endedAt)` after it stops. Keep the session list in creation order,
307
+ The current conversation is the running session, or the last activated session,
308
+ falling back to `max(startedAt, endedAt)` for legacy rows. Keep the session list in creation order,
271
309
  but do not use its first entry to identify the current conversation: a temporary
272
310
  review can return to an older session. The stale-session banner and the next
273
311
  review's return target must recognize that original session, including on reload.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,11 @@ 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.45
8
+
9
+ - fix(sessions): harden handoff recovery and highlight transfer progress
10
+ - feat(sessions): add fresh sessions with optional handoffs
11
+
7
12
  ## 1.11.44
8
13
 
9
14
  - chore(deps): update packages and node to 24.21.0
package/README.md CHANGED
@@ -17,7 +17,7 @@ Kōbō (工房, "workshop") turns *one agent, one terminal* into a real workflow
17
17
 
18
18
  | | |
19
19
  |---|---|
20
- | **🗂️ Isolated worktrees, two engines** | Every workspace is its own git worktree and branch — parallel sessions never collide. Pick Claude Code or OpenAI Codex per workspace, and switch engines later without losing the worktree: Kōbō hands off tasks, git state, and recent context to a fresh session. |
20
+ | **🗂️ Isolated worktrees, two engines** | Every workspace is its own git worktree and branch — parallel sessions never collide. Pick Claude Code or OpenAI Codex per workspace, and [continue in a fresh session](#fresh-sessions-and-llm-handoffs) with the same LLM or a different model/engine, preserving the worktree and mission history. |
21
21
  | **💬 Live chat, real diff review** | Streaming responses, inline Edit/Write diffs, a reasoning panel, and a Monaco diff viewer with **inline file editing**, conflict resolution, and one-click `Sync` / `Push` / `Open PR` / `Merge`. |
22
22
  | **🔁 Auto-loop, cron, wakeups** | Turn an agent loose on the task list: it works through tasks, retries on rate limits, and stops itself when there's nothing left, progress stalls, or it needs you. Cron schedules and one-shot wakeups keep workspaces moving on their own timeline. |
23
23
  | **🔀 Create from a PR/MR, or from a ticket** | Pick an open pull/merge request from GitHub, GitLab, or Bitbucket and Kōbō resolves every local conflict for you before spinning up the workspace. Or start straight from a Notion page or a Sentry issue URL. |
@@ -62,6 +62,18 @@ Kōbō's production build is an installable PWA — use your browser's **Install
62
62
  - **Observability**: a per-session timeline (duration, tools, tokens, errors) and a downloadable redacted diagnostic JSON.
63
63
  - **Optional integrations**: Notion (import missions) and Sentry (fix from issue URL), each independently toggled with a **Test connection** action; local voice transcription via `whisper.cpp`.
64
64
 
65
+ ## Fresh sessions and LLM handoffs
66
+
67
+ Long conversation getting crowded? Use **Continue in a fresh session** at the bottom of the right-hand Tools panel. **Change LLM** uses the same transfer flow and also lets you select another model within the current engine.
68
+
69
+ 1. Choose the destination model, reasoning effort and permission mode. A fresh session starts with the workspace's current configuration selected.
70
+ 2. Leave **Generate a handoff with the current agent** enabled to have the current conversation summarize the objective, constraints, decisions, verified progress, failed approaches and next action. Disable it when the old provider has no quota left: Kōbō builds context from the mission, tasks, Git state and source history without calling that LLM.
71
+ 3. Start the transfer. Kōbō interrupts the current work immediately, waits for confirmed shutdown, prepares the handoff and opens a fresh native conversation that continues the mission automatically.
72
+
73
+ The workspace, worktree, task list and conversation history remain available. The transfer links to its source session and a Markdown report in **Documents** (`.ai/handoffs/`, ignored by Git). Auto-loop keeps its intent and diagnostic budget; queued instructions retain their delivery state. Pending wakeups from the source conversation follow the fresh session once its startup is confirmed.
74
+
75
+ If generation fails, choose **Retry**, **Continue without an LLM summary**, or **Cancel transfer**. Cancel and Stop leave the work stopped and restore the source conversation and LLM configuration, including when the destination failed to start. Failed attempts remain in history without being resumed automatically. If there was no source conversation, the next message starts a fresh one. A server restart preserves the transfer and report for an explicit recovery decision; it never automatically replays a potentially delivered prompt. Transfers are manual—conversation size does not trigger them automatically.
76
+
65
77
  ## Configuration
66
78
 
67
79
  The most common knobs:
@@ -1,3 +1,5 @@
1
+ import { DOCUMENT_DIRS } from '../shared/document-roots.js';
2
+ export { DOCUMENT_DIRS } from '../shared/document-roots.js';
1
3
  import fs from 'node:fs';
2
4
  import path from 'node:path';
3
5
  import { nanoid } from 'nanoid';
@@ -267,7 +269,6 @@ export function getTicketSourcesHandler(worktreePath) {
267
269
  }
268
270
  // ── Documents ────────────────────────────────────────────────────────────────
269
271
  /** Directories (relative to the worktree root) scanned for AI-generated docs. */
270
- export const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
271
272
  /** Depth cap to keep recursion bounded even on pathological symlink loops. */
272
273
  const DOC_MAX_DEPTH = 8;
273
274
  const DOC_MAX_FILE_BYTES = 1024 * 1024;
@@ -6,6 +6,8 @@ import { getDb } from '../server/db/index.js';
6
6
  import { WORKSPACE_DIALOGUE_TOOLS } from '../shared/workspace-dialogue-tools.js';
7
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
8
  import { callWorkspaceDialogueTool } from './workspace-dialogue-client.js';
9
+ const handoffId = process.env.KOBO_HANDOFF_ID;
10
+ const handoffToken = process.env.KOBO_HANDOFF_TOKEN;
9
11
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
10
12
  const dbPath = process.env.KOBO_DB_PATH;
11
13
  const settingsPath = process.env.KOBO_SETTINGS_PATH;
@@ -146,6 +148,16 @@ const TASK_ORDER_PROPERTIES = {
146
148
  },
147
149
  };
148
150
  const WORKSPACE_SCOPED_TOOLS = [
151
+ {
152
+ name: 'submit_session_handoff',
153
+ description: 'Submit the final Markdown handoff requested by Kōbō for this generation turn, then end your turn. Only available during a backend-owned session transfer; does not launch or stop sessions.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: { report: { type: 'string', minLength: 1, maxLength: 24000 } },
157
+ required: ['report'],
158
+ },
159
+ annotations: { destructiveHint: false, openWorldHint: false },
160
+ },
149
161
  {
150
162
  name: 'list_tasks',
151
163
  description: 'CALL FIRST on any non-trivial turn to know what the user wants done and what is already completed. Returns every task and acceptance criterion for the current workspace with its id, status, sort_order, role, and verification evidence. Re-call periodically (before marking something done, or after the user asks for a status) to stay in sync with user-added or external updates.',
@@ -412,13 +424,13 @@ const WORKSPACE_SCOPED_TOOLS = [
412
424
  // ── Knowledge / context tools ─────────────────────────────────────────────
413
425
  {
414
426
  name: 'list_documents',
415
- description: 'CALL EARLY on a new session to discover plans, specs, and thoughts previously written for this workspace. Recursively lists every .md under docs/plans/, docs/superpowers/, and .ai/thoughts/. Before writing a new plan, check if one already exists.',
427
+ description: 'CALL EARLY on a new session to discover plans, specs, and thoughts previously written for this workspace. Recursively lists every .md under docs/plans/, docs/superpowers/, .ai/thoughts/, and .ai/handoffs/. Before writing a new plan, check if one already exists.',
416
428
  inputSchema: { type: 'object', properties: {}, required: [] },
417
429
  annotations: { readOnlyHint: true, openWorldHint: false },
418
430
  },
419
431
  {
420
432
  name: 'read_document',
421
- description: 'CALL AFTER list_documents when a file title looks relevant to the current task. Returns the full markdown content. Scoped to docs/plans/, docs/superpowers/, .ai/thoughts/ — reject anything else.',
433
+ description: 'CALL AFTER list_documents when a file title looks relevant to the current task. Returns the full markdown content. Scoped to docs/plans/, docs/superpowers/, .ai/thoughts/, .ai/handoffs/ — reject anything else.',
422
434
  inputSchema: {
423
435
  type: 'object',
424
436
  properties: {
@@ -609,9 +621,13 @@ const GLOBAL_TOOLS = [
609
621
  * one of the ~35 workspace-scoped branches.
610
622
  */
611
623
  const GLOBAL_TOOL_NAMES = new Set(GLOBAL_TOOLS.map((t) => t.name));
612
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
613
- tools: workspaceId ? [...WORKSPACE_SCOPED_TOOLS, ...GLOBAL_TOOLS] : GLOBAL_TOOLS,
614
- }));
624
+ function availableTools() {
625
+ const tools = workspaceId ? [...WORKSPACE_SCOPED_TOOLS, ...GLOBAL_TOOLS] : GLOBAL_TOOLS;
626
+ return tools.filter((tool) => handoffId && handoffToken
627
+ ? tool.name === 'submit_session_handoff' || tool.annotations?.readOnlyHint === true
628
+ : tool.name !== 'submit_session_handoff');
629
+ }
630
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: availableTools() }));
615
631
  /** Wrap a successful result as an MCP tool response with JSON text content. */
616
632
  function ok(data) {
617
633
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
@@ -641,6 +657,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
641
657
  return fail(`Tool '${name}' requires a workspace-bound MCP session (KOBO_WORKSPACE_ID not set).`);
642
658
  }
643
659
  try {
660
+ if (handoffId && handoffToken && !availableTools().some((tool) => tool.name === name))
661
+ return fail('This turn is reserved for a handoff report. Only read tools and submit_session_handoff are available.');
662
+ if (name === 'submit_session_handoff') {
663
+ if (!workspaceId || !handoffId || !handoffToken)
664
+ return fail('No handoff generation is active for this MCP invocation.');
665
+ return ok(await backendRequest('POST', `/api/workspaces/${workspaceId}/session-handoffs/${handoffId}/report`, {
666
+ token: handoffToken,
667
+ report: a.report,
668
+ }));
669
+ }
644
670
  if (WORKSPACE_DIALOGUE_TOOLS.some((tool) => tool.name === name)) {
645
671
  if (workspaceId && a.workspace_id === workspaceId && name === 'send_workspace_message')
646
672
  return fail('Use the normal conversation to communicate in your own workspace.');
@@ -3,6 +3,7 @@ import { initMcpMessageSchema } from './mcp-message-schema.js';
3
3
  import { initReviewReturnSchema } from './review-return-schema.js';
4
4
  import { initSchema } from './schema.js';
5
5
  import { initSearchSchema } from './search-schema.js';
6
+ import { initSessionHandoffSchema } from './session-handoff-schema.js';
6
7
  /** Ordered registry of all schema migrations. Append new entries at the end. */
7
8
  export const migrations = [
8
9
  {
@@ -748,6 +749,19 @@ export const migrations = [
748
749
  { version: 43, name: 'durable-mcp-message-requests', migrate: initMcpMessageSchema },
749
750
  { version: 44, name: 'review-session-returns', migrate: initReviewReturnSchema },
750
751
  { version: 45, name: 'durable-auto-loop', migrate: initAutoLoopSchema },
752
+ { version: 46, name: 'session-handoffs', migrate: initSessionHandoffSchema },
753
+ {
754
+ version: 47,
755
+ name: 'session-activation-order',
756
+ migrate(db) {
757
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'agent_sessions'").get();
758
+ if (!table)
759
+ return;
760
+ const columns = db.prepare('PRAGMA table_info(agent_sessions)').all();
761
+ if (!columns.some((column) => column.name === 'activation_order'))
762
+ db.exec('ALTER TABLE agent_sessions ADD COLUMN activation_order INTEGER NOT NULL DEFAULT 0');
763
+ },
764
+ },
751
765
  ];
752
766
  /** Current schema version — always equals the highest migration version. */
753
767
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -2,6 +2,7 @@ import { initAutoLoopSchema } from './auto-loop-schema.js';
2
2
  import { initMcpMessageSchema } from './mcp-message-schema.js';
3
3
  import { initReviewReturnSchema } from './review-return-schema.js';
4
4
  import { initSearchSchema } from './search-schema.js';
5
+ import { initSessionHandoffSchema } from './session-handoff-schema.js';
5
6
  /** Create all tables and indexes for a fresh install. Not used for upgrades -- see migrations.ts. */
6
7
  export function initSchema(db) {
7
8
  db.exec(`
@@ -70,7 +71,8 @@ export function initSchema(db) {
70
71
  ended_at TEXT,
71
72
  name TEXT,
72
73
  task_progress_baseline TEXT,
73
- end_reason TEXT
74
+ end_reason TEXT,
75
+ activation_order INTEGER NOT NULL DEFAULT 0
74
76
  );
75
77
 
76
78
  CREATE TABLE IF NOT EXISTS ws_events (
@@ -229,4 +231,5 @@ export function initSchema(db) {
229
231
  initMcpMessageSchema(db);
230
232
  initReviewReturnSchema(db);
231
233
  initAutoLoopSchema(db);
234
+ initSessionHandoffSchema(db);
232
235
  }
@@ -0,0 +1,24 @@
1
+ export function initSessionHandoffSchema(db) {
2
+ db.exec(`CREATE TABLE IF NOT EXISTS session_handoffs (
3
+ id TEXT PRIMARY KEY,
4
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
5
+ request_id TEXT NOT NULL,
6
+ request_fingerprint TEXT NOT NULL,
7
+ source_session_id TEXT,
8
+ target_session_id TEXT,
9
+ source_configuration TEXT NOT NULL,
10
+ target_configuration TEXT NOT NULL,
11
+ source_model TEXT,
12
+ generate_summary INTEGER NOT NULL,
13
+ state TEXT NOT NULL,
14
+ report TEXT,
15
+ report_path TEXT,
16
+ generation_token TEXT,
17
+ error TEXT,
18
+ created_at TEXT NOT NULL,
19
+ updated_at TEXT NOT NULL,
20
+ UNIQUE(workspace_id, request_id)
21
+ );
22
+ CREATE UNIQUE INDEX IF NOT EXISTS session_handoffs_pending
23
+ ON session_handoffs(workspace_id) WHERE state NOT IN ('completed', 'cancelled');`);
24
+ }
@@ -43,6 +43,7 @@ import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js'
43
43
  import * as quotaBackoffService from './services/quota-backoff-service.js';
44
44
  import { reconcileReviewReturns } from './services/review-return-service.js';
45
45
  import { startSearchIndex, stopSearchIndex } from './services/search-service.js';
46
+ import { reconcileSessionHandoffs } from './services/session-handoff-service.js';
46
47
  import { getGlobalSettings, updateNetworkAccessSettings } from './services/settings-service.js';
47
48
  import { reloadDefaultTemplates } from './services/templates-service.js';
48
49
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
@@ -118,6 +119,7 @@ const dailyBackupScheduler = startDailyDbBackupScheduler(async () => {
118
119
  // Initialize process cleanup, agent watchdog, PR watcher, and wakeup rehydration
119
120
  reconcileOrphanSessions();
120
121
  reconcileReviewReturns();
122
+ reconcileSessionHandoffs();
121
123
  // Deliver any new default prompt templates to existing installs (seed-once via the
122
124
  // seededDefaultSlugs watermark; never overwrites or re-adds deleted defaults).
123
125
  try {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Hono } from 'hono';
4
+ import { DOCUMENT_DIRS } from '../../shared/document-roots.js';
4
5
  import * as workspaceService from '../services/workspace-service.js';
5
6
  import { isPathInside, resolveExistingPathInside } from '../utils/safe-path.js';
6
7
  /** Hono sub-router for workspace document browsing (read-only). */
@@ -13,7 +14,6 @@ const app = new Hono();
13
14
  * Kept intentionally narrow to avoid leaking unrelated project docs
14
15
  * (README, product specs, …) into the panel.
15
16
  */
16
- const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
17
17
  /** Only .md files are listed. */
18
18
  const MD_EXT = '.md';
19
19
  /** Depth cap to keep recursion bounded even on pathological symlink loops. */
@@ -1,8 +1,11 @@
1
1
  import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { nanoid } from 'nanoid';
3
4
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
5
+ import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
4
6
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
5
7
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
8
+ import { workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
6
9
  import autoLoopMessagesRoutes from './auto-loop-messages.js';
7
10
  const execFileAsync = promisify(execFileCb);
8
11
  import fs from 'node:fs';
@@ -64,6 +67,28 @@ function workspaceErrorStatus(err) {
64
67
  }
65
68
  /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
66
69
  const app = new Hono();
70
+ // Transfers reserve the writer across multiple HTTP requests and engine invocations.
71
+ app.use('/:id/*', async (c, next) => {
72
+ const id = c.req.param('id');
73
+ const route = c.req.path.split(`/api/workspaces/${id}/`)[1] ?? '';
74
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) &&
75
+ workspaceLifecycleReason(id) === 'session-handoff' &&
76
+ !route.startsWith('session-handoffs') &&
77
+ ![
78
+ 'stop',
79
+ 'mark-read',
80
+ 'chat-history',
81
+ 'deferred-tool-use/answer',
82
+ 'deferred-tool-use/cancel',
83
+ 'deferred-permission/decision',
84
+ ].includes(route)) {
85
+ return c.json({
86
+ error: 'A session transfer is pending. Finish or cancel it before changing this workspace.',
87
+ code: 'workspace-busy',
88
+ }, 409);
89
+ }
90
+ await next();
91
+ });
67
92
  // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
68
93
  for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
69
94
  app.use(route, async (c, next) => {
@@ -297,6 +322,44 @@ app.get('/', (c) => {
297
322
  return c.json({ error: message }, workspaceErrorStatus(err));
298
323
  }
299
324
  });
325
+ function handoffError(c, error) {
326
+ return c.json({ error: error instanceof Error ? error.message : 'Session transfer failed' }, error instanceof SessionHandoffError
327
+ ? error.status
328
+ : error instanceof SyntaxError
329
+ ? 400
330
+ : workspaceErrorStatus(error));
331
+ }
332
+ app.post('/:id/session-handoffs', migrationGuard, async (c) => {
333
+ try {
334
+ return c.json({ handoff: createSessionHandoff(c.req.param('id'), await c.req.json()) }, 202);
335
+ }
336
+ catch (error) {
337
+ return handoffError(c, error);
338
+ }
339
+ });
340
+ app.get('/:id/session-handoffs/current', (c) => {
341
+ if (!workspaceService.getWorkspace(c.req.param('id')))
342
+ return c.json({ error: 'Workspace not found' }, 404);
343
+ return c.json({ handoff: getCurrentSessionHandoff(c.req.param('id')) });
344
+ });
345
+ app.post('/:id/session-handoffs/:handoffId/decision', migrationGuard, async (c) => {
346
+ try {
347
+ const body = await c.req.json();
348
+ return c.json({ handoff: await decideSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.action) });
349
+ }
350
+ catch (error) {
351
+ return handoffError(c, error);
352
+ }
353
+ });
354
+ app.post('/:id/session-handoffs/:handoffId/report', migrationGuard, async (c) => {
355
+ try {
356
+ const body = await c.req.json();
357
+ return c.json(submitSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.token, body.report));
358
+ }
359
+ catch (error) {
360
+ return handoffError(c, error);
361
+ }
362
+ });
300
363
  // POST /api/workspaces/:id/engine-handoff-preview — build an editable, deterministic handoff.
301
364
  app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
302
365
  try {
@@ -348,15 +411,14 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
348
411
  error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
349
412
  }, 409);
350
413
  }
351
- // A new engine is started three lines below: the previous one must be dead
352
- // first, or two agents share the worktree.
353
- if (agentManager.hasController(id))
354
- assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'replacement'));
355
- const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
356
- const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
357
- workspaceService.updateWorkspaceStatus(id, 'executing');
358
- wsService.emit(id, 'user:message', { content: handoff, sender: 'user', kind: 'engine-handoff' }, session.agentSessionId);
359
- return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: session.agentSessionId });
414
+ const operation = createSessionHandoff(id, {
415
+ requestId: nanoid(),
416
+ sourceSessionId: workspaceService.getActiveSession(id)?.id ?? null,
417
+ generateSummary: false,
418
+ target: { engine: body.engine, model, reasoningEffort: effort, agentPermissionMode: body.agentPermissionMode },
419
+ }, handoff);
420
+ const completed = await waitForSessionHandoff(operation.id);
421
+ return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: completed.targetSessionId });
360
422
  }
361
423
  catch (err) {
362
424
  return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
@@ -2629,6 +2691,8 @@ app.put('/:id/tags', async (c) => {
2629
2691
  });
2630
2692
  // PATCH /api/workspaces/:id — update workspace fields (status, model, agentPermissionMode, name)
2631
2693
  app.patch('/:id', migrationGuard, async (c) => {
2694
+ if (workspaceLifecycleReason(c.req.param('id')) === 'session-handoff')
2695
+ return c.json({ error: 'A session transfer is pending', code: 'workspace-busy' }, 409);
2632
2696
  try {
2633
2697
  const id = c.req.param('id');
2634
2698
  const parsed = await c.req.json();
@@ -240,11 +240,25 @@ export function createClaudeCodeEngine() {
240
240
  sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
241
241
  const inputStream = new ClaudeInputStream(effectivePrompt);
242
242
  const q = query({ prompt: inputStream, options: sdkOptions });
243
+ let resolveReady;
244
+ let rejectReady;
245
+ const ready = new Promise((resolve, reject) => {
246
+ resolveReady = resolve;
247
+ rejectReady = reject;
248
+ });
249
+ // Legacy callers do not await readiness; still observe an early rejection.
250
+ void ready.catch(() => { });
243
251
  let discoveredSessionId;
244
252
  // A throwing onEvent handler (e.g. DB query against a closed connection
245
253
  // during async test teardown) must not escape as an unhandled rejection.
246
254
  const emitDirect = (ev) => {
247
255
  try {
256
+ if (ev.kind === 'session:started')
257
+ resolveReady();
258
+ if (ev.kind === 'error')
259
+ rejectReady(new Error(ev.message));
260
+ if (ev.kind === 'session:ended')
261
+ rejectReady(new Error('Claude ended before confirming session initialization'));
248
262
  onEvent(ev);
249
263
  }
250
264
  catch (err) {
@@ -588,6 +602,7 @@ export function createClaudeCodeEngine() {
588
602
  }
589
603
  })();
590
604
  const engineProcess = {
605
+ ready,
591
606
  closed: iteratorPromise.then(() => { }),
592
607
  get pid() {
593
608
  return undefined;
@@ -574,6 +574,7 @@ export function createCodexEngine() {
574
574
  }
575
575
  })();
576
576
  const engineProcess = {
577
+ ready: readyPromise,
577
578
  closed: Promise.all([iteratorPromise, childExitedPromise]).then(() => { }),
578
579
  get pid() {
579
580
  return child.pid;