@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.
- package/AGENTS.md +40 -2
- package/CHANGELOG.md +5 -0
- package/README.md +13 -1
- package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
- package/dist/mcp-server/kobo-tasks-server.js +31 -5
- package/dist/server/db/migrations.js +14 -0
- package/dist/server/db/schema.js +4 -1
- package/dist/server/db/session-handoff-schema.js +24 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/documents.js +1 -1
- package/dist/server/routes/workspaces.js +73 -9
- package/dist/server/services/agent/engines/claude-code/engine.js +15 -0
- package/dist/server/services/agent/engines/codex/engine.js +1 -0
- package/dist/server/services/agent/orchestrator.js +63 -10
- package/dist/server/services/engine-handoff-service.js +5 -5
- package/dist/server/services/session-activity-service.js +17 -0
- package/dist/server/services/session-handoff-runtime.js +26 -0
- package/dist/server/services/session-handoff-service.js +479 -0
- package/dist/server/services/wakeup-service.js +8 -0
- package/dist/server/services/workspace-service.js +5 -2
- package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
- package/dist/shared/document-roots.js +2 -0
- package/dist/shared/session-handoff.js +4 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-DSKCvpr2.js → ChangelogPage-CnXTnFhe.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-DDxcsxyC.js → ComparisonPanel-CKylN45q.js} +2 -2
- package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-Dg2s9nnv.js → DashboardPage-CZQLfoV1.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-BSzNScIr.js → DiffViewer-Csf3WmLc.js} +4 -4
- package/src/client/dist/pwa/assets/{DocumentsPanel-DfEoI_f7.js → DocumentsPanel-BeCttB-x.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-D85PwhKb.js → DrawerToggleButton-BJBBYeOc.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-CaLCpejR.js → HealthPage-l88TEk-k.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +1 -0
- package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-qJWGcZpC.js → QBtnDropdown-44Mj-y_1.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-DX3PhMqC.js → QDrawer-B4qHw8wt.js} +1 -1
- package/src/client/dist/pwa/assets/QExpansionItem-Bok1m8Wi.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-cDK5AOQ5.js → QTooltip-BDihn2aY.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +1 -0
- package/src/client/dist/pwa/assets/{SearchPage-BO-J4aNc.js → SearchPage-BwZ2TX1G.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-BIEceNwH.js → SplitWorkspacePage-Bg6nKBcA.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DitOCad1.js → TerminalPanel-Cq-FR7A7.js} +9 -9
- package/src/client/dist/pwa/assets/{TouchPan-DvPAhZsa.js → TouchPan-BQ1cvzRC.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-CJYdH3Jf.js → TourReplayButton-Dz1yEz_z.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CY-9z9nw.js → WorkspaceAttentionLabels-BCVOP8vp.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-DAVo-E4p.css → WorkspacePage-BbUvR1Vf.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +4 -0
- package/src/client/dist/pwa/assets/{browser-BsB6cFv4.js → browser-BmWgBx9M.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-6EjBmYYY.js +1 -0
- package/src/client/dist/pwa/assets/de-COObokpu.js +17 -0
- package/src/client/dist/pwa/assets/{documents-SCd3w8xV.js → documents-BZ8bwcjr.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-BUdM7I6y.js → editor.api-CK7--ckY.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-DTJQP9or.js → efforts-Bw0o2Bud.js} +2 -2
- package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +1 -0
- package/src/client/dist/pwa/assets/es-By2Q9Tca.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-C8VyjASo.js +1 -0
- package/src/client/dist/pwa/assets/{format-BjQxf4pe.js → format-LtTLLitJ.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BejhmNTV.js → formatters-Bl9zA7Ai.js} +1 -1
- package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-5d5GhyRp.js → freemarker2-DxezMaje.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DaafEsDK.js → handlebars-DK_Bj6Rx.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CwHBDd2A.js → html-DFfsfStD.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +1 -0
- package/src/client/dist/pwa/assets/index-BLd8DAzg.js +20 -0
- package/src/client/dist/pwa/assets/it-CeK5lyIA.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-DSNIEEH5.js → javascript-DuNbcaL7.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Dma1UpA1.js → liquid-CM3JKyhv.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-D8rqEAws.js → mdx-BG3zYwNF.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-2E7O2zoi.js → monaco-contributions-DLscZGH-.js} +1 -1
- package/src/client/dist/pwa/assets/monaco.contribution-DnhZHwXT.js +2 -0
- package/src/client/dist/pwa/assets/{network-auth-DNmhJTZn.js → network-auth-BJiPbO5l.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-B6Hiwvwb.js → notify-theme-D4p3qPkp.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CeiVIFZn.js → pending-diff-open-dYIFdS4r.js} +1 -1
- package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +36 -0
- package/src/client/dist/pwa/assets/{project-color-BCp9SWoI.js → project-color-R_1je4vW.js} +1 -1
- package/src/client/dist/pwa/assets/{python-f-9NqRlI.js → python-COs6pHk2.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DsCmoX0h.js → razor-Jl1VqMsk.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DK5BD9mU.js → render-chat-markdown-AaOo0Tgq.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-cEHg4L10.js → typescript-BU_TUuAM.js} +1 -1
- package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
- package/src/client/dist/pwa/assets/{use-tours-BmTbosmB.js → use-tours-CVn874kD.js} +2 -2
- package/src/client/dist/pwa/assets/{workspace-templates-DUWDLMIX.js → workspace-templates-BCKR8TXr.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-Plt-Y7we.js → xml-gFFsobxE.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-ScEmaAeN.js → yaml-DaXaqaGD.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +8 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +4 -1
- package/src/mcp-server/kobo-tasks-server.ts +38 -5
- package/src/client/dist/pwa/assets/ActivityFeed-D_y4aikp.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-DKVEknbL.js +0 -2
- package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +0 -2
- package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +0 -1
- package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-LvHiZ6ZH.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Aw89fRSq.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +0 -1
- package/src/client/dist/pwa/assets/de-Mu7biceO.js +0 -17
- package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +0 -1
- package/src/client/dist/pwa/assets/es-BY5IfNI2.js +0 -17
- package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +0 -1
- package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +0 -1
- package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +0 -20
- package/src/client/dist/pwa/assets/it-ByPZQK_K.js +0 -17
- package/src/client/dist/pwa/assets/monaco.contribution-DqOgo72G.js +0 -2
- 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
|
|
270
|
-
`max(startedAt, endedAt)`
|
|
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
|
|
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/
|
|
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
|
-
|
|
613
|
-
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;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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;
|