@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
@@ -0,0 +1,479 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { nanoid } from 'nanoid';
4
+ import { HANDOFF_REPORT_MAX_CHARS, isHandoffPending, } from '../../shared/session-handoff.js';
5
+ import { getDb } from '../db/index.js';
6
+ import { assertAgentStopped } from '../utils/agent-stop-result.js';
7
+ import { ensureDirectoryInside } from '../utils/safe-path.js';
8
+ import { reserveWorkspaceLifecycle } from '../utils/workspace-lifecycle-guard.js';
9
+ import { listEngines } from './agent/engines/registry.js';
10
+ import * as agents from './agent/orchestrator.js';
11
+ import { buildEngineHandoff } from './engine-handoff-service.js';
12
+ import { getReviewReturn } from './review-return-service.js';
13
+ import { activateSession, deactivateSession } from './session-activity-service.js';
14
+ import { registerHandoffStopHandler } from './session-handoff-runtime.js';
15
+ import * as wakeupService from './wakeup-service.js';
16
+ import { emit, emitEphemeral } from './websocket-service.js';
17
+ import * as workspaces from './workspace-service.js';
18
+ export class SessionHandoffError extends Error {
19
+ status;
20
+ constructor(message, status = 409) {
21
+ super(message);
22
+ this.status = status;
23
+ }
24
+ }
25
+ const runtimes = new Map();
26
+ const GENERATION_TIMEOUT_MS = 5 * 60_000;
27
+ function map(row) {
28
+ return {
29
+ id: row.id,
30
+ workspaceId: row.workspace_id,
31
+ sourceSessionId: row.source_session_id,
32
+ targetSessionId: row.target_session_id,
33
+ source: JSON.parse(row.source_configuration),
34
+ target: JSON.parse(row.target_configuration),
35
+ generateSummary: !!row.generate_summary,
36
+ state: row.state,
37
+ reportPath: row.report_path,
38
+ error: row.error,
39
+ createdAt: row.created_at,
40
+ updatedAt: row.updated_at,
41
+ };
42
+ }
43
+ function read(id) {
44
+ const row = getDb().prepare('SELECT * FROM session_handoffs WHERE id = ?').get(id);
45
+ if (!row)
46
+ throw new SessionHandoffError('Session transfer not found', 404);
47
+ return row;
48
+ }
49
+ export function getCurrentSessionHandoff(workspaceId) {
50
+ const row = getDb()
51
+ .prepare('SELECT * FROM session_handoffs WHERE workspace_id = ? ORDER BY rowid DESC LIMIT 1')
52
+ .get(workspaceId);
53
+ return row ? map(row) : null;
54
+ }
55
+ function publish(id) {
56
+ const handoff = map(read(id));
57
+ emitEphemeral(handoff.workspaceId, 'workspace:handoff', { handoff });
58
+ return handoff;
59
+ }
60
+ function persistState(id, next, error = null) {
61
+ getDb()
62
+ .prepare('UPDATE session_handoffs SET state = ?, error = ?, updated_at = ? WHERE id = ?')
63
+ .run(next, error, new Date().toISOString(), id);
64
+ }
65
+ function state(id, next, error = null) {
66
+ persistState(id, next, error);
67
+ return publish(id);
68
+ }
69
+ function configure(workspaceId, configuration) {
70
+ workspaces.updateWorkspaceEngineConfiguration(workspaceId, configuration.engine, configuration.model, configuration.reasoningEffort ?? 'auto', configuration.agentPermissionMode);
71
+ emitEphemeral(workspaceId, 'workspace:configuration', {
72
+ ...configuration,
73
+ reasoningEffort: configuration.reasoningEffort ?? 'auto',
74
+ });
75
+ }
76
+ function normalizeStoppedWorkspace(workspaceId) {
77
+ const workspace = workspaces.getWorkspace(workspaceId);
78
+ if (!agents.hasController(workspaceId) &&
79
+ workspace &&
80
+ ['created', 'executing', 'brainstorming', 'extracting', 'awaiting-user', 'compacting'].includes(workspace.status))
81
+ workspaces.updateWorkspaceStatus(workspaceId, 'idle');
82
+ }
83
+ function restoreSource(row) {
84
+ if (!agents.hasController(row.workspace_id)) {
85
+ if (row.target_session_id)
86
+ deactivateSession(row.workspace_id, row.target_session_id);
87
+ if (row.source_session_id)
88
+ activateSession(row.workspace_id, row.source_session_id);
89
+ }
90
+ configure(row.workspace_id, map(row).source);
91
+ }
92
+ function clearTimer(runtime) {
93
+ if (runtime.timer)
94
+ clearTimeout(runtime.timer);
95
+ runtime.timer = undefined;
96
+ }
97
+ function release(workspaceId, runtime) {
98
+ clearTimer(runtime);
99
+ runtime.removeStopHandler();
100
+ if (runtimes.get(workspaceId) === runtime)
101
+ runtimes.delete(workspaceId);
102
+ runtime.reservation.release();
103
+ }
104
+ function reserve(workspaceId, id) {
105
+ const runtime = {
106
+ id,
107
+ reservation: reserveWorkspaceLifecycle(workspaceId, 'session-handoff'),
108
+ cancelled: false,
109
+ pendingStops: 0,
110
+ running: false,
111
+ removeStopHandler: () => { },
112
+ };
113
+ runtimes.set(workspaceId, runtime);
114
+ runtime.removeStopHandler = registerHandoffStopHandler(workspaceId, {
115
+ shutdown() {
116
+ runtime.cancelled = true;
117
+ clearTimer(runtime);
118
+ getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(id);
119
+ restoreSource(read(id));
120
+ state(id, 'interrupted', 'Kōbō stopped during this transfer. Inspect the sessions before retrying.');
121
+ },
122
+ requested() {
123
+ runtime.pendingStops++;
124
+ runtime.cancelled = true;
125
+ clearTimer(runtime);
126
+ getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(id);
127
+ },
128
+ settled(outcome) {
129
+ if (runtimes.get(workspaceId) !== runtime)
130
+ return;
131
+ runtime.pendingStops--;
132
+ if (outcome !== 'stopped' && outcome !== 'not-running') {
133
+ state(id, 'failed', 'Agent shutdown is not confirmed. Retry Stop before starting another session.');
134
+ return;
135
+ }
136
+ restoreSource(read(id));
137
+ normalizeStoppedWorkspace(workspaceId);
138
+ state(id, 'cancelled');
139
+ release(workspaceId, runtime);
140
+ },
141
+ });
142
+ return runtime;
143
+ }
144
+ function fail(runtime, error) {
145
+ if (runtime.cancelled || !isHandoffPending(map(read(runtime.id))))
146
+ return;
147
+ clearTimer(runtime);
148
+ const row = read(runtime.id);
149
+ getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(row.id);
150
+ restoreSource(row);
151
+ normalizeStoppedWorkspace(row.workspace_id);
152
+ state(row.id, 'failed', error instanceof Error ? error.message : String(error));
153
+ }
154
+ function parseRequest(input) {
155
+ if (!input || typeof input !== 'object' || Array.isArray(input))
156
+ throw new SessionHandoffError('Invalid transfer request', 400);
157
+ const body = input;
158
+ const target = body.target;
159
+ if (typeof body.requestId !== 'string' ||
160
+ !body.requestId.trim() ||
161
+ body.requestId.length > 128 ||
162
+ (body.sourceSessionId !== null && typeof body.sourceSessionId !== 'string') ||
163
+ typeof body.generateSummary !== 'boolean' ||
164
+ !target ||
165
+ typeof target !== 'object' ||
166
+ typeof target.engine !== 'string' ||
167
+ typeof target.model !== 'string' ||
168
+ (target.reasoningEffort !== null && typeof target.reasoningEffort !== 'string') ||
169
+ typeof target.agentPermissionMode !== 'string')
170
+ throw new SessionHandoffError('Invalid transfer request', 400);
171
+ return {
172
+ requestId: body.requestId,
173
+ sourceSessionId: body.sourceSessionId,
174
+ generateSummary: body.generateSummary,
175
+ target: {
176
+ engine: target.engine,
177
+ model: target.model,
178
+ reasoningEffort: target.reasoningEffort ?? 'auto',
179
+ agentPermissionMode: target.agentPermissionMode,
180
+ },
181
+ };
182
+ }
183
+ export function createSessionHandoff(workspaceId, input, suppliedReport) {
184
+ if (agents.isShuttingDown())
185
+ throw new SessionHandoffError('Kōbō is shutting down');
186
+ const body = parseRequest(input);
187
+ const fingerprint = JSON.stringify({ ...body, suppliedReport });
188
+ const previous = getDb()
189
+ .prepare('SELECT * FROM session_handoffs WHERE workspace_id = ? AND request_id = ?')
190
+ .get(workspaceId, body.requestId);
191
+ if (previous) {
192
+ if (previous.request_fingerprint !== fingerprint)
193
+ throw new SessionHandoffError('This request id was already used for another transfer');
194
+ return map(previous);
195
+ }
196
+ const workspace = workspaces.getWorkspace(workspaceId);
197
+ if (!workspace)
198
+ throw new SessionHandoffError('Workspace not found', 404);
199
+ if (workspace.archivedAt || workspace.worktreePurgedAt)
200
+ throw new SessionHandoffError('Restore the workspace before transferring its session');
201
+ if (getReviewReturn(workspaceId))
202
+ throw new SessionHandoffError('Finish or stop the review with automatic return first');
203
+ if (isHandoffPending(getCurrentSessionHandoff(workspaceId)))
204
+ throw new SessionHandoffError('A session transfer is already pending');
205
+ const sourceSession = workspaces.getActiveSession(workspaceId);
206
+ if ((sourceSession?.id ?? null) !== body.sourceSessionId)
207
+ throw new SessionHandoffError('The current session changed. Reopen the transfer dialog.');
208
+ const engine = listEngines().find((item) => item.id === body.target.engine);
209
+ if (!engine?.capabilities.permissionModes.includes(body.target.agentPermissionMode) ||
210
+ (!engine.capabilities.models.some((model) => model.id === body.target.model) &&
211
+ !(workspace.engine === body.target.engine && workspace.model === body.target.model)) ||
212
+ (body.target.reasoningEffort !== 'auto' &&
213
+ !engine.capabilities.effortLevels?.some((effort) => effort.id === body.target.reasoningEffort)))
214
+ throw new SessionHandoffError('The target engine configuration is not supported', 400);
215
+ const id = nanoid();
216
+ const runtime = reserve(workspaceId, id);
217
+ try {
218
+ const source = {
219
+ engine: workspace.engine,
220
+ model: workspace.model,
221
+ reasoningEffort: workspace.reasoningEffort,
222
+ agentPermissionMode: workspace.agentPermissionMode,
223
+ };
224
+ const now = new Date().toISOString();
225
+ getDb()
226
+ .prepare(`INSERT INTO session_handoffs
227
+ (id, workspace_id, request_id, request_fingerprint, source_session_id, source_configuration, target_configuration, source_model, generate_summary, state, report, created_at, updated_at)
228
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopping', ?, ?, ?)`)
229
+ .run(id, workspaceId, body.requestId, fingerprint, body.sourceSessionId, JSON.stringify(source), JSON.stringify(body.target), sourceSession?.model ?? workspace.model, body.generateSummary ? 1 : 0, suppliedReport?.slice(0, HANDOFF_REPORT_MAX_CHARS) ?? null, now, now);
230
+ }
231
+ catch (error) {
232
+ release(workspaceId, runtime);
233
+ throw error;
234
+ }
235
+ const result = publish(id);
236
+ void run(runtime);
237
+ return result;
238
+ }
239
+ function generationPrompt() {
240
+ return `The user has requested an immediate transfer to a fresh conversation. Your previous work has been interrupted. Your ONLY task now is to prepare a concise handoff for the next agent using the context of this exact conversation.
241
+ Do not modify the project, tasks, criteria, schedules, or settings. Do not continue implementation. Respect all existing user constraints and permissions.
242
+ Call kobo__submit_session_handoff with a Markdown report (at most ${HANDOFF_REPORT_MAX_CHARS} characters) covering:
243
+ 1. Objective, user constraints, and remaining scope.
244
+ 2. Decisions and their reasons; separate verified facts from assumptions.
245
+ 3. Progress, relevant files, tasks, checks actually performed and their outcomes.
246
+ 4. Failed approaches and mistakes to avoid repeating.
247
+ 5. Open questions, uncertainties, and the concrete next action.
248
+ Do not claim checks you did not run. Reference source history where needed. After successful submission, end your turn immediately. The backend will start the fresh session; do not do that yourself.`;
249
+ }
250
+ function buildPrompt(row) {
251
+ const workspace = workspaces.getWorkspaceWithTasks(row.workspace_id);
252
+ if (!workspace)
253
+ throw new SessionHandoffError('Workspace not found', 404);
254
+ const configurations = map(row);
255
+ const mission = workspace.initialPrompt ?? workspace.description ?? workspace.name;
256
+ const tasks = JSON.stringify(workspace.tasks.map(({ id, title, status, role, verification, isAcceptanceCriterion }) => ({
257
+ id,
258
+ title,
259
+ status,
260
+ role,
261
+ verification,
262
+ isAcceptanceCriterion,
263
+ })), null, 2);
264
+ const run = getDb().prepare('SELECT * FROM auto_loop_runs WHERE workspace_id = ?').get(row.workspace_id);
265
+ const messages = getDb()
266
+ .prepare("SELECT id, state FROM auto_loop_messages WHERE workspace_id = ? AND state != 'delivered'")
267
+ .all(row.workspace_id);
268
+ return `# Kōbō session handoff\n\nContinue the current mission automatically in this fresh conversation. First verify the actual repository and Kōbō task state, then perform the next unfinished action. Preserve user constraints; this report is context, not authorization to override them. Agent-reported checks do not replace Kōbō verification requirements. Uncertain message deliveries require explicit acknowledgement; do not silently treat them as completed.\n\nSource session: ${row.source_session_id ?? 'none'}\nRead its history using kobo__read_workspace_events_csv with session_id=${row.source_session_id ?? '(no prior session)'}.\n\n## Initial mission\n${mission.slice(0, 32_000)}\n\n## Agent handoff\n${row.report ?? 'No LLM summary was generated. Recover missing decisions from the source conversation.'}\n\n## Current Kōbō tasks and reported evidence\n${tasks.slice(0, 48_000)}\n\n## Auto-loop and pending instructions\nThese are identifiers and statuses only. Do not execute or acknowledge queued instructions during this transfer. Kōbō will dispatch pending instructions separately; unknown deliveries require an explicit user decision.\nIntent: ${workspace.autoLoop}\n${JSON.stringify(run ?? null)}\n${JSON.stringify(messages).slice(0, 24_000)}\n\n${buildEngineHandoff(workspace, configurations.source.engine, configurations.target.engine, row.source_session_id)}`;
269
+ }
270
+ function writeReport(row, content) {
271
+ const workspace = workspaces.getWorkspace(row.workspace_id);
272
+ const directory = ensureDirectoryInside(workspace.worktreePath, '.ai/handoffs');
273
+ // A local ignore file avoids modifying the project's tracked root .gitignore.
274
+ const descriptor = fs.openSync(path.join(directory, '.gitignore'), fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW, 0o600);
275
+ try {
276
+ fs.writeFileSync(descriptor, '\n*\n');
277
+ }
278
+ finally {
279
+ fs.closeSync(descriptor);
280
+ }
281
+ const relative = `.ai/handoffs/${row.id}-${nanoid(6)}.md`;
282
+ fs.writeFileSync(path.join(directory, path.basename(relative)), content, { flag: 'wx', mode: 0o600 });
283
+ getDb().prepare('UPDATE session_handoffs SET report_path = ? WHERE id = ?').run(relative, row.id);
284
+ return relative;
285
+ }
286
+ async function run(runtime) {
287
+ if (runtime.running || runtime.cancelled)
288
+ return;
289
+ runtime.running = true;
290
+ const row = read(runtime.id);
291
+ try {
292
+ state(row.id, 'stopping');
293
+ assertAgentStopped(await agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff'));
294
+ if (runtime.cancelled)
295
+ return;
296
+ normalizeStoppedWorkspace(row.workspace_id);
297
+ const current = read(row.id);
298
+ if (current.generate_summary && !current.report) {
299
+ const session = workspaces.listSessions(row.workspace_id).find((item) => item.id === row.source_session_id);
300
+ if (!session?.engineSessionId || (session.engine && session.engine !== map(row).source.engine))
301
+ throw new SessionHandoffError('The source conversation cannot be resumed. Continue without an LLM summary.');
302
+ const token = nanoid(40);
303
+ getDb().prepare('UPDATE session_handoffs SET generation_token = ? WHERE id = ?').run(token, row.id);
304
+ state(row.id, 'generating');
305
+ runtime.generationError = undefined;
306
+ const source = map(row).source;
307
+ configure(row.workspace_id, source);
308
+ const prompt = generationPrompt();
309
+ workspaces.updateWorkspaceStatus(row.workspace_id, 'executing');
310
+ agents.startAgent(row.workspace_id, workspaces.getWorkspace(row.workspace_id).worktreePath, prompt, row.source_model ?? source.model, true, source.agentPermissionMode, row.source_session_id, source.reasoningEffort ?? 'auto', undefined, {
311
+ lifecycleOwner: runtime.reservation.owner,
312
+ handoffGeneration: true,
313
+ mcpEnv: { KOBO_HANDOFF_ID: row.id, KOBO_HANDOFF_TOKEN: token },
314
+ onError: (message) => {
315
+ runtime.generationError = message;
316
+ },
317
+ onEnded: (event) => {
318
+ if (runtime.cancelled || read(row.id).state !== 'generating')
319
+ return;
320
+ clearTimer(runtime);
321
+ getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(row.id);
322
+ if (!read(row.id).report || event.reason === 'error' || (event.exitCode !== null && event.exitCode !== 0))
323
+ fail(runtime, runtime.generationError ?? 'The source session ended without a valid handoff report.');
324
+ else
325
+ queueMicrotask(() => {
326
+ void startTarget(runtime).catch((error) => fail(runtime, error));
327
+ });
328
+ },
329
+ });
330
+ emit(row.workspace_id, 'user:message', { content: prompt, sender: 'system-prompt' }, row.source_session_id);
331
+ runtime.timer = setTimeout(() => {
332
+ void agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff').then((outcome) => {
333
+ if (runtime.cancelled)
334
+ return;
335
+ try {
336
+ assertAgentStopped(outcome);
337
+ }
338
+ catch (error) {
339
+ fail(runtime, error);
340
+ return;
341
+ }
342
+ if (read(row.id).report)
343
+ void startTarget(runtime).catch((error) => fail(runtime, error));
344
+ else
345
+ fail(runtime, 'Handoff generation timed out. Retry or continue without an LLM summary.');
346
+ });
347
+ }, GENERATION_TIMEOUT_MS);
348
+ runtime.timer.unref?.();
349
+ }
350
+ else
351
+ await startTarget(runtime);
352
+ }
353
+ catch (error) {
354
+ fail(runtime, error);
355
+ }
356
+ finally {
357
+ runtime.running = false;
358
+ }
359
+ }
360
+ async function startTarget(runtime) {
361
+ if (runtime.cancelled)
362
+ return;
363
+ const row = read(runtime.id);
364
+ if (row.state === 'starting' || !isHandoffPending(map(row)))
365
+ return;
366
+ // Both natural completion and deadline recovery converge here; only one may dispatch.
367
+ assertAgentStopped(await agents.stopAgentAndWait(row.workspace_id, undefined, 'handoff'));
368
+ if (runtime.cancelled || read(row.id).state === 'starting')
369
+ return;
370
+ clearTimer(runtime);
371
+ const prompt = buildPrompt(row);
372
+ writeReport(row, prompt);
373
+ configure(row.workspace_id, map(row).target);
374
+ const session = workspaces.createIdleSession(row.workspace_id);
375
+ getDb()
376
+ .prepare('UPDATE session_handoffs SET target_session_id = ?, generation_token = NULL WHERE id = ?')
377
+ .run(session.id, row.id);
378
+ state(row.id, 'starting');
379
+ let startError;
380
+ const completed = () => {
381
+ if (runtime.cancelled || read(row.id).state !== 'starting')
382
+ return;
383
+ if (startError) {
384
+ fail(runtime, startError);
385
+ return;
386
+ }
387
+ // A restart must never restore the source while its wakeup points at an unconfirmed target.
388
+ getDb().transaction(() => {
389
+ wakeupService.transferSession(row.workspace_id, row.source_session_id, session.id);
390
+ persistState(row.id, 'completed');
391
+ })();
392
+ publish(row.id);
393
+ release(row.workspace_id, runtime);
394
+ };
395
+ workspaces.updateWorkspaceStatus(row.workspace_id, 'executing');
396
+ agents.startAgent(row.workspace_id, workspaces.getWorkspace(row.workspace_id).worktreePath, prompt, map(row).target.model, false, map(row).target.agentPermissionMode, session.id, map(row).target.reasoningEffort ?? 'auto', undefined, {
397
+ lifecycleOwner: runtime.reservation.owner,
398
+ onStarted: completed,
399
+ onError: (message) => {
400
+ startError = message;
401
+ },
402
+ onEnded: (event) => {
403
+ if (event.reason === 'error' || event.exitCode)
404
+ fail(runtime, startError ?? 'The target session failed to start.');
405
+ else
406
+ completed();
407
+ },
408
+ });
409
+ emit(row.workspace_id, 'user:message', { content: prompt, sender: 'system-prompt', kind: 'engine-handoff' }, session.id);
410
+ }
411
+ export function submitSessionHandoff(workspaceId, id, token, report) {
412
+ const row = read(id);
413
+ const runtime = runtimes.get(workspaceId);
414
+ if (row.workspace_id !== workspaceId ||
415
+ row.state !== 'generating' ||
416
+ !runtime ||
417
+ runtime.cancelled ||
418
+ typeof token !== 'string' ||
419
+ !token ||
420
+ token !== row.generation_token)
421
+ throw new SessionHandoffError('This handoff generation is no longer active');
422
+ if (typeof report !== 'string' || !report.trim() || report.length > HANDOFF_REPORT_MAX_CHARS)
423
+ throw new SessionHandoffError(`A non-empty Markdown report up to ${HANDOFF_REPORT_MAX_CHARS} characters is required`, 400);
424
+ if (row.report && row.report !== report.trim())
425
+ throw new SessionHandoffError('A different report was already submitted');
426
+ getDb()
427
+ .prepare('UPDATE session_handoffs SET report = ?, updated_at = ? WHERE id = ?')
428
+ .run(report.trim(), new Date().toISOString(), id);
429
+ return { accepted: true };
430
+ }
431
+ export async function decideSessionHandoff(workspaceId, id, action) {
432
+ const row = read(id);
433
+ if (row.workspace_id !== workspaceId)
434
+ throw new SessionHandoffError('Session transfer not found', 404);
435
+ if (!['retry', 'skip', 'cancel'].includes(action))
436
+ throw new SessionHandoffError('Invalid transfer decision', 400);
437
+ if (!isHandoffPending(map(row)))
438
+ return map(row);
439
+ const runtime = runtimes.get(workspaceId) ?? reserve(workspaceId, id);
440
+ if (action === 'cancel') {
441
+ assertAgentStopped(await agents.stopAgentAndWait(workspaceId));
442
+ return map(read(id));
443
+ }
444
+ if (runtime.pendingStops > 0)
445
+ throw new SessionHandoffError('Cancellation is still waiting for the agent to stop');
446
+ if (runtime.running || !['failed', 'interrupted'].includes(row.state))
447
+ throw new SessionHandoffError('The transfer is still running');
448
+ runtime.cancelled = false;
449
+ if (action === 'skip')
450
+ getDb().prepare('UPDATE session_handoffs SET generate_summary = 0, report = NULL WHERE id = ?').run(id);
451
+ void run(runtime);
452
+ return map(read(id));
453
+ }
454
+ /** A restart never replays a possibly accepted native turn. Keep automation reserved until a decision. */
455
+ export function reconcileSessionHandoffs() {
456
+ const rows = getDb()
457
+ .prepare("SELECT * FROM session_handoffs WHERE state NOT IN ('completed', 'cancelled')")
458
+ .all();
459
+ for (const row of rows) {
460
+ getDb().prepare('UPDATE session_handoffs SET generation_token = NULL WHERE id = ?').run(row.id);
461
+ restoreSource(row);
462
+ state(row.id, 'interrupted', 'Kōbō restarted during this transfer. Delivery may be uncertain; inspect the sessions before retrying.');
463
+ if (!runtimes.has(row.workspace_id))
464
+ reserve(row.workspace_id, row.id);
465
+ }
466
+ }
467
+ /** Legacy synchronous engine-switch clients still receive a target session id. */
468
+ export async function waitForSessionHandoff(id) {
469
+ const deadline = Date.now() + 45_000;
470
+ while (Date.now() < deadline) {
471
+ const result = map(read(id));
472
+ if (result.state === 'completed')
473
+ return result;
474
+ if (['failed', 'interrupted', 'cancelled'].includes(result.state))
475
+ throw new SessionHandoffError(result.error ?? 'Session transfer cancelled');
476
+ await new Promise((resolve) => setTimeout(resolve, 25));
477
+ }
478
+ throw new SessionHandoffError('Transfer is still pending. Consult the current session transfer before retrying.');
479
+ }
@@ -76,6 +76,14 @@ export function schedule(workspaceId, delaySeconds, prompt, reason, agentSession
76
76
  console.error('[wakeup-service] schedule failed:', err);
77
77
  }
78
78
  }
79
+ /** Move a source conversation's pending check only once its handoff succeeds. */
80
+ export function transferSession(workspaceId, sourceSessionId, targetSessionId) {
81
+ if (!sourceSessionId)
82
+ return;
83
+ getDb()
84
+ .prepare('UPDATE pending_wakeups SET agent_session_id = ? WHERE workspace_id = ? AND agent_session_id = ?')
85
+ .run(targetSessionId, workspaceId, sourceSessionId);
86
+ }
79
87
  /** Cancel any pending wakeup for the workspace. Idempotent. */
80
88
  export function cancel(workspaceId, reason) {
81
89
  try {
@@ -7,6 +7,7 @@ import * as orchestrator from './agent/orchestrator.js';
7
7
  import * as autoLoopService from './auto-loop-service.js';
8
8
  import * as cronService from './cron-service.js';
9
9
  import * as quotaBackoffService from './quota-backoff-service.js';
10
+ import { SESSION_RECENCY_ORDER } from './session-activity-service.js';
10
11
  import { createTaskRecord, deleteTaskRecord, updateTaskRecord } from './task-mutations.js';
11
12
  import * as wakeupService from './wakeup-service.js';
12
13
  import { emitEphemeral } from './websocket-service.js';
@@ -776,6 +777,7 @@ function mapSession(row) {
776
777
  model: row.model,
777
778
  startedAt: row.started_at,
778
779
  endedAt: row.ended_at,
780
+ activationOrder: row.activation_order,
779
781
  name: row.name,
780
782
  };
781
783
  }
@@ -799,7 +801,8 @@ export function getLatestSession(workspaceId) {
799
801
  * Return the "active" session for tagging events like push/pull/open-pr traces
800
802
  * or resumed chat messages. Skips idle sessions (never started) and prefers a
801
803
  * running session, falling back to the last used session (any non-idle status).
802
- * A resumed conversation keeps its started_at, but updates ended_at on stop.
804
+ * Starts and handoff rollbacks persist activation order independently of execution dates.
805
+ * Legacy sessions fall back to their last start/end time.
803
806
  */
804
807
  export function getActiveSession(workspaceId) {
805
808
  const db = getDb();
@@ -811,7 +814,7 @@ export function getActiveSession(workspaceId) {
811
814
  return mapSession(running);
812
815
  // Otherwise the last used non-idle session (completed, error, quota, etc.)
813
816
  const latestNonIdle = db
814
- .prepare("SELECT * FROM agent_sessions WHERE workspace_id = ? AND status != 'idle' ORDER BY MAX(started_at, COALESCE(ended_at, started_at)) DESC, started_at DESC LIMIT 1")
817
+ .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND status != 'idle' AND activation_order >= 0 ORDER BY ${SESSION_RECENCY_ORDER} LIMIT 1`)
815
818
  .get(workspaceId);
816
819
  return latestNonIdle ? mapSession(latestNonIdle) : null;
817
820
  }
@@ -1,4 +1,4 @@
1
- const busyWorkspaces = new Set();
1
+ const busyWorkspaces = new Map();
2
2
  const deferred = new Map();
3
3
  export class WorkspaceLifecycleBusyError extends Error {
4
4
  code = 'workspace-busy';
@@ -9,10 +9,40 @@ export class WorkspaceLifecycleBusyError extends Error {
9
9
  export function isWorkspaceLifecycleBusy(id) {
10
10
  return busyWorkspaces.has(id);
11
11
  }
12
- export function assertWorkspaceLifecycleAvailable(id) {
13
- if (busyWorkspaces.has(id))
12
+ export function assertWorkspaceLifecycleAvailable(id, owner) {
13
+ const reservation = busyWorkspaces.get(id);
14
+ if (owner ? reservation?.owner !== owner : !!reservation)
14
15
  throw new WorkspaceLifecycleBusyError();
15
16
  }
17
+ export function workspaceLifecycleReason(id) {
18
+ return busyWorkspaces.get(id)?.reason;
19
+ }
20
+ export function reserveWorkspaceLifecycle(id, reason) {
21
+ assertWorkspaceLifecycleAvailable(id);
22
+ const owner = Symbol(id);
23
+ busyWorkspaces.set(id, { owner, reason });
24
+ return {
25
+ owner,
26
+ release() {
27
+ if (busyWorkspaces.get(id)?.owner !== owner)
28
+ return;
29
+ busyWorkspaces.delete(id);
30
+ const callbacks = deferred.get(id);
31
+ deferred.delete(id);
32
+ if (callbacks)
33
+ queueMicrotask(() => {
34
+ for (const retry of callbacks) {
35
+ try {
36
+ retry();
37
+ }
38
+ catch (err) {
39
+ console.error('[workspace-lifecycle] deferred operation failed:', err);
40
+ }
41
+ }
42
+ });
43
+ },
44
+ };
45
+ }
16
46
  /** Keep automatic work pending without polling or acquiring the guard recursively. */
17
47
  export function deferUntilWorkspaceAvailable(id, retry) {
18
48
  if (!busyWorkspaces.has(id))
@@ -24,26 +54,11 @@ export function deferUntilWorkspaceAvailable(id, retry) {
24
54
  }
25
55
  /** Exclude competing restore/purge/delete operations, including their awaits. */
26
56
  export async function withWorkspaceLifecycleGuard(id, action) {
27
- if (busyWorkspaces.has(id))
28
- throw new WorkspaceLifecycleBusyError();
29
- busyWorkspaces.add(id);
57
+ const reservation = reserveWorkspaceLifecycle(id);
30
58
  try {
31
59
  return await action();
32
60
  }
33
61
  finally {
34
- busyWorkspaces.delete(id);
35
- const callbacks = deferred.get(id);
36
- deferred.delete(id);
37
- if (callbacks)
38
- queueMicrotask(() => {
39
- for (const retry of callbacks) {
40
- try {
41
- retry();
42
- }
43
- catch (err) {
44
- console.error('[workspace-lifecycle] deferred operation failed:', err);
45
- }
46
- }
47
- });
62
+ reservation.release();
48
63
  }
49
64
  }
@@ -0,0 +1,2 @@
1
+ /** Shared allowlist for the HTTP browser and workspace MCP document tools. */
2
+ export const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts', '.ai/handoffs'];
@@ -0,0 +1,4 @@
1
+ export function isHandoffPending(handoff) {
2
+ return !!handoff && handoff.state !== 'completed' && handoff.state !== 'cancelled';
3
+ }
4
+ export const HANDOFF_REPORT_MAX_CHARS = 24_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.11.44",
3
+ "version": "1.11.45",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",