@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

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 (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. package/src/sdk-wave-e.ts +1 -1
@@ -1,1675 +1,1696 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const crypto = require('node:crypto');
6
- const {
7
- normalizeContext, normalizeAttachments, normalizeAgentId, normalizeEffort, cleanId, PERMISSION_MODES, AGENTS,
8
- } = require('../../contracts');
9
- const { snapshotEvents, subscribeNormalized } = require('./event-stream');
10
- const { SessionHistory } = require('./session-history');
11
- const { recoverClaudeHistory } = require('./claude-history-recovery');
12
- // The history reader and task identity load from the same legacy runtime
13
- // tree as every other runtime module: loadLegacyRuntime binds to the owning
14
- // distribution, so a packaged server loads them from its own legacy tree and
15
- // a missing file fails the public entry instead of borrowing source code.
16
- const { loadLegacyRuntime } = require('./runtime-loader');
17
- const { createNativeTaskHistoryReader } = loadLegacyRuntime('native-task-history-reader');
18
- const { createNativeTaskRef } = loadLegacyRuntime('task-identity');
19
-
20
- const ACTIVE_STATES = new Set(['created', 'spawning', 'running', 'thinking', 'tool-running', 'pending-permission']);
21
-
22
- // Native fact rows are durable-by-contract: they go through the receipt
23
- // path (write + fsync) so nothing is ever built on facts that still live
24
- // only in memory. Plain chat events keep the batched append path.
25
- const NATIVE_FACT_TYPES = new Set(['claude/native-trace', 'claude/native-task-state', 'claude/native-task-transcript']);
26
-
27
- const DELTA_TYPES = new Set(['claude/text-delta', 'claude/thinking-delta']);
28
-
29
- // The same native fact as the committed row, ignoring fields that
30
- // legitimately differ between a journaled row and an in-memory replay of it.
31
- function canonicalNativeFact(event) {
32
- const ordered = {};
33
- for (const key of Object.keys(event || {}).sort()) {
34
- if (key === 'at' || key === 'sequence' || key === 'sessionId' || key === 'contextId') continue;
35
- ordered[key] = event[key];
36
- }
37
- return JSON.stringify(ordered);
38
- }
39
-
40
- // The earliest buffered same-id delta sequence a merged durable row is
41
- // proven to cover. Rows written since compaction kept provenance carry
42
- // compactFrom and are trusted as-is; older archives only admit the coverage
43
- // reading when a sequence gap exists AND the buffer's own fragments form a
44
- // strict prefix of the merged text — otherwise the row is a continuation
45
- // delta and must be appended, not substituted.
46
- function compactionCoverage(buffered, durable, lastBufferedSequence) {
47
- const declared = Number(durable && durable.compactFrom);
48
- if (Number.isSafeInteger(declared) && declared > 0) return declared;
49
- const sequence = Number(durable && durable.sequence);
50
- if (!Number.isSafeInteger(sequence) || sequence <= 0) return null;
51
- if (!Number.isFinite(lastBufferedSequence) || sequence <= lastBufferedSequence + 1) return null;
52
- let prefix = '';
53
- let earliest = null;
54
- for (const event of buffered) {
55
- if (!event || event.type !== durable.type || event.id !== durable.id) continue;
56
- const own = Number(event.sequence);
57
- if (!Number.isSafeInteger(own) || own <= 0 || own >= sequence) continue;
58
- prefix += String(event.text || '');
59
- if (earliest === null || own < earliest) earliest = own;
60
- }
61
- if (earliest === null || prefix.length === 0) return null;
62
- const text = String(durable.text || '');
63
- return text.length > prefix.length && text.startsWith(prefix) ? earliest : null;
64
- }
65
-
66
- // The health identity accepts only a canonical persisted store id; nothing
67
- // volatile (port, PID, epoch) or malformed may pose as a store scope.
68
- const STORE_SCOPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
69
-
70
- class WorkbenchService {
71
- constructor(options) {
72
- this.runner = options.runner;
73
- this.diagnostics = options.diagnostics || null;
74
- this.knowledge = options.knowledge || null;
75
- this.pendingInputs = new Map();
76
- this.knowledgeBySession = new Map();
77
- this.profiles = options.profiles;
78
- this.contexts = options.contexts;
79
- this.attachments = options.attachments;
80
- this.projects = options.projects || null;
81
- this.sessionArchive = options.sessionArchive || null;
82
- this.uiPreferences = options.uiPreferences || null;
83
- this.captureSettings = options.captureSettings || null;
84
- this.conversationCapture = options.conversationCapture || null;
85
- this.onCaptureSettingsSaved = options.onCaptureSettingsSaved || null;
86
- this.agentDiscovery = options.agentDiscovery || null;
87
- this.resolveRuntimeExecutable = options.resolveRuntimeExecutable || null;
88
- this.modelContextWindows = options.modelContextWindows || null;
89
- this.modelDetection = options.modelDetection || null;
90
- // Trusted delegation stores: the immutable reservation ledger plus the
91
- // write-once phase/claim journal. Null when the host wired none — the
92
- // delegation APIs then answer explicit unavailability instead of
93
- // pretending to run commands.
94
- this.delegations = options.delegations || null;
95
- // Per-service runtime generation: fresh for every WorkbenchService
96
- // instance, constant for its lifetime, never persisted anywhere.
97
- this.runtimeEpoch = crypto.randomUUID();
98
- const injectedIdentity = options.storeIdentity || null;
99
- const storeScope = injectedIdentity && injectedIdentity.available === true && typeof injectedIdentity.storeScope === 'string'
100
- && STORE_SCOPE_PATTERN.test(injectedIdentity.storeScope)
101
- ? injectedIdentity.storeScope
102
- : null;
103
- this.taskIdentity = Object.freeze({
104
- storeScope,
105
- runtimeEpoch: this.runtimeEpoch,
106
- available: storeScope !== null,
107
- errorCode: !storeScope && injectedIdentity ? injectedIdentity.errorCode || 'STORE_SCOPE_UNAVAILABLE' : null,
108
- });
109
- this.apiPrefix = String(options.apiPrefix || '/api/claude-workbench/v1').replace(/\/$/, '');
110
- // Read from the synced manifest — a hardcoded literal here shipped 0.3.0
111
- // in every /health response long after the actual release moved on.
112
- this.version = options.version || require('../package.json').version;
113
- this.maxSessions = Number(options.maxSessions || 50);
114
- this.maxConcurrentRuns = Number(options.maxConcurrentRuns || 4);
115
- this.testWorkspace = path.resolve(options.testWorkspace);
116
- this.auditFile = options.auditFile;
117
- this.historyRoot = options.historyRoot || path.join(path.dirname(this.auditFile || this.testWorkspace), 'session-history');
118
- this.histories = new Map();
119
- // Per-session native-fact committer closures, cached so every attach
120
- // passes the SAME function reference: the runner's binding contract
121
- // treats a different reference under the same identity as an owner
122
- // conflict, and a naive closure-per-call would break idempotence.
123
- this.nativeCommitters = new Map();
124
- this.turnMonitors = new Map();
125
- this.sessionAgents = new Map();
126
- this.sessionRevision = 0;
127
- this.sessionChangeLog = [];
128
- this.sessionChangeListeners = new Set();
129
- this.sessionSignatures = new Map();
130
- const archiveRetentionDays = options.archiveRetentionDays === undefined ? 7 : Number(options.archiveRetentionDays);
131
- this.archiveRetentionMs = Number.isFinite(archiveRetentionDays) && archiveRetentionDays > 0
132
- ? archiveRetentionDays * 24 * 60 * 60 * 1000
133
- : 0;
134
- this.archiveSweeper = null;
135
- // Archived sessions past the retention window are purged on boot and
136
- // once a day for the lifetime of the process.
137
- if (this.sessionArchive && this.archiveRetentionMs > 0) {
138
- this.purgeExpiredArchivedSessions();
139
- this.archiveSweeper = setInterval(() => {
140
- try { this.purgeExpiredArchivedSessions(); } catch { /* retried next sweep */ }
141
- }, Number(options.archiveSweepIntervalMs || 24 * 60 * 60 * 1000));
142
- if (typeof this.archiveSweeper.unref === 'function') this.archiveSweeper.unref();
143
- }
144
- fs.mkdirSync(this.testWorkspace, { recursive: true });
145
- }
146
-
147
- health() {
148
- // One observation point: both counters must come from the same
149
- // listSessions snapshot, or a mid-mutation probe disagrees with itself
150
- // and pays the full store scan twice.
151
- const sessions = this.runner.listSessions({});
152
- return {
153
- status: 'ok', version: this.version, apiVersion: 'v1',
154
- activeProcesses: this._activeCount(sessions), sessions: sessions.length,
155
- time: new Date().toISOString(),
156
- taskIdentity: { ...this.taskIdentity },
157
- ...(this.conversationCapture ? { conversationCapture: { ...this.conversationCapture.status(), enabled: this.captureSettings?.read().terminalConversations === true } } : {}),
158
- };
159
- }
160
-
161
- async runtime() {
162
- let executable = null;
163
- try { executable = await (this.resolveRuntimeExecutable ? this.resolveRuntimeExecutable() : this.runner.findClaudeExecutableForSdk()); } catch {}
164
- const command = executable && (executable.cmd || executable);
165
- return { runtime: 'claude-code', available: Boolean(command), executable: command ? path.basename(String(command)) : null, transport: 'agent-sdk' };
166
- }
167
-
168
- // Live availability probing when a discovery provider is wired in,
169
- // otherwise fall back to what the injected runner can see.
170
- listAgents() {
171
- if (this.agentDiscovery) {
172
- return { agents: Object.values(this.agentDiscovery()), scan: this.agentDiscovery.snapshot?.() };
173
- }
174
- let claudeAvailable = false;
175
- try { const executable = this.runner.findClaudeExecutableForSdk(); claudeAvailable = Boolean(typeof executable === 'string' ? executable : executable?.cmd); } catch {}
176
- return {
177
- agents: Object.keys(AGENTS).map(id => {
178
- const agent = AGENTS[id];
179
- return {
180
- id: agent.id, label: agent.label, transport: agent.transport,
181
- authModes: [...agent.authModes],
182
- capabilities: { ...agent.capabilities, generationParams: [...agent.capabilities.generationParams] },
183
- available: id === 'claude-code' ? claudeAvailable : false,
184
- version: null, reason: '',
185
- };
186
- }),
187
- };
188
- }
189
-
190
- refreshAgents() {
191
- if (this.agentDiscovery?.refresh) void this.agentDiscovery.refresh().catch(() => {});
192
- return this.listAgents();
193
- }
194
-
195
- listProfiles() { return { profiles: this.profiles.list() }; }
196
- getProfile(id) { return this.profiles.get(id); }
197
- saveProfile(input, id) { return this.profiles.save(input, id); }
198
- deleteProfile(id) { return this.profiles.delete(id); }
199
- setCredential(id, credential) { return this.profiles.setCredential(id, credential); }
200
-
201
- getCaptureSettings() {
202
- if (!this.captureSettings) return { schema: 'capture-settings/v1', terminalConversations: false };
203
- return this.captureSettings.read();
204
- }
205
-
206
- saveCaptureSettings(input = {}) {
207
- if (!this.captureSettings) {
208
- throw Object.assign(new Error('capture settings are not wired into this service'), { status: 501 });
209
- }
210
- const saved = this.captureSettings.save(input);
211
- // Push the new scope into the driver immediately: sessions started after
212
- // this call carry the matching AI_CODING_EVENT_BRIDGE_CAPTURE env.
213
- if (typeof this.onCaptureSettingsSaved === 'function') this.onCaptureSettingsSaved(saved.terminalConversations);
214
- return saved;
215
- }
216
-
217
- // ---- projects ----
218
- listProjects() {
219
- this._requireProjects();
220
- return { projects: this.projects.list() };
221
- }
222
-
223
- getProject(id) {
224
- this._requireProjects();
225
- const projectId = cleanId(id, 'projectId');
226
- const project = this.projects.get(projectId);
227
- if (!project) throw Object.assign(new Error('project not found'), { status: 404 });
228
- return project;
229
- }
230
-
231
- createProject(input = {}) {
232
- this._requireProjects();
233
- const project = this.projects.create(input);
234
- this.contexts.register(project.id, project.rootPath);
235
- this._audit('project-create', { projectId: project.id, rootPath: project.rootPath });
236
- return project;
237
- }
238
-
239
- renameProject(id, input = {}) {
240
- this._requireProjects();
241
- const projectId = cleanId(id, 'projectId');
242
- if (input.defaultAiProfileId) this.profiles.runtimeProfile(input.defaultAiProfileId);
243
- const project = this.projects.update(projectId, input);
244
- this._audit('project-rename', { projectId, name: project.name });
245
- return project;
246
- }
247
-
248
- removeProject(id, options = {}) {
249
- this._requireProjects();
250
- const projectId = cleanId(id, 'projectId');
251
- if (!this.projects.get(projectId)) throw Object.assign(new Error('project not found'), { status: 404 });
252
- const keepSessions = options.keepSessions === true;
253
- for (const session of this.runner.listSessions({ projectSlug: projectId })) {
254
- if (ACTIVE_STATES.has(session.state) || session.backgroundTaskCount > 0) try { this.runner.abort(session.sessionId); } catch {}
255
- if (!keepSessions) {
256
- let deleted = false;
257
- if (typeof this.runner.deleteSession === 'function') {
258
- try { deleted = this.runner.deleteSession(session.sessionId) !== false; } catch {}
259
- }
260
- this.sessionArchive && this.sessionArchive.forget(session.sessionId);
261
- this.knowledge?.forget(session.sessionId);
262
- if (deleted) { this._removeHistory(session.sessionId); this._notifySessionDeleted(session); }
263
- }
264
- this.sessionAgents.delete(session.sessionId);
265
- }
266
- this.projects.remove(projectId);
267
- this.contexts.unregister && this.contexts.unregister(projectId);
268
- this._audit('project-remove', { projectId, keepSessions });
269
- return true;
270
- }
271
-
272
- _requireProjects() {
273
- if (!this.projects) throw Object.assign(new Error('project workspace support is not enabled'), { status: 501 });
274
- }
275
-
276
- // ---- session archive ----
277
- archiveSession(sessionId) {
278
- this._requireArchive();
279
- const id = cleanId(sessionId, 'sessionId');
280
- if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
281
- const record = this.sessionArchive.archive(id);
282
- this._notifySessionChange(id);
283
- this._audit('session-archive', { sessionId: id });
284
- return { sessionId: id, ...record };
285
- }
286
-
287
- restoreSession(sessionId) {
288
- this._requireArchive();
289
- const id = cleanId(sessionId, 'sessionId');
290
- if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
291
- const restored = this.sessionArchive.restore(id);
292
- this._notifySessionChange(id);
293
- this._audit('session-restore', { sessionId: id });
294
- return { sessionId: id, restored };
295
- }
296
-
297
- // Archived sessions never restored within the retention window are deleted
298
- // outright: the runner record goes first (best effort) and the archive mark
299
- // only once the record is gone — a failed delete keeps the session hidden
300
- // and retries on the next sweep instead of resurfacing unarchived.
301
- purgeExpiredArchivedSessions() {
302
- if (!this.sessionArchive || !(this.archiveRetentionMs > 0)) return [];
303
- const purged = [];
304
- for (const sessionId of this.sessionArchive.expired(this.archiveRetentionMs)) {
305
- const previous = this.runner.getState(sessionId);
306
- let deleted = true;
307
- if (typeof this.runner.deleteSession === 'function') {
308
- try { this.runner.deleteSession(sessionId); } catch { deleted = false; }
309
- }
310
- this.sessionAgents.delete(sessionId);
311
- if (deleted) {
312
- if (previous) this._notifySessionDeleted(previous);
313
- this._removeHistory(sessionId);
314
- this.knowledge?.forget(sessionId);
315
- this.sessionArchive.forget(sessionId);
316
- purged.push(sessionId);
317
- this._audit('archive-expired', { sessionId });
318
- }
319
- }
320
- return purged;
321
- }
322
-
323
- getUiPreferences() {
324
- if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
325
- return this.uiPreferences.read();
326
- }
327
-
328
- saveUiPreferences(input = {}) {
329
- if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
330
- return this.uiPreferences.save(input);
331
- }
332
-
333
- _requireArchive() {
334
- if (!this.sessionArchive) throw Object.assign(new Error('session archive support is not enabled'), { status: 501 });
335
- }
336
-
337
- listSessions(contextId, options = {}) {
338
- const id = cleanId(contextId, 'contextId');
339
- const includeArchived = Boolean(options.includeArchived);
340
- const all = this.runner.listSessions({ projectSlug: id });
341
- const visible = includeArchived || !this.sessionArchive
342
- ? all
343
- : all.filter(session => !this.sessionArchive.isArchived(session.sessionId));
344
- return visible.map((session, index) => this._summary(session, index === 0));
345
- }
346
-
347
- startSession(contextInput, aiProfileId, options = {}) {
348
- if (this.destroyed) throw Object.assign(new Error('workbench is shut down'), { status: 503 });
349
- const context = normalizeContext(contextInput);
350
- if (this.runner.listSessions({}).length >= this.maxSessions) {
351
- throw Object.assign(new Error('session limit reached'), { status: 429 });
352
- }
353
- const resolved = this.contexts.resolve(context);
354
- const project = this.projects && this.projects.get(context.workspaceRef);
355
- const agentId = normalizeAgentId(options.agentId || context.agentId || project && project.defaultAgentId);
356
- const effort = normalizeEffort(options.effort);
357
- // null explicitly selects the agent's login; omission inherits the project.
358
- const profileChoice = aiProfileId !== undefined ? aiProfileId
359
- : context.aiProfileId !== undefined ? context.aiProfileId : project && project.defaultAiProfileId;
360
- const profileId = profileChoice ? cleanId(profileChoice, 'aiProfileId') : null;
361
- const inheritsProject = aiProfileId === undefined && context.aiProfileId === undefined;
362
- const selectedModel = options.model !== undefined ? options.model
363
- : context.model !== undefined ? context.model : inheritsProject && project ? project.defaultModel : undefined;
364
- const profile = this._runtimeProfile(profileId, selectedModel);
365
- const started = this.runner.startChatSession({
366
- slug: context.contextId,
367
- projectPath: resolved.workspacePath,
368
- kbPath: resolved.workspacePath,
369
- // Persisted so later delegations can prove this session's trusted
370
- // workspace without guessing from the current UI project.
371
- workspaceRef: context.workspaceRef,
372
- aiProfile: profile,
373
- permissionMode: options.permissionMode || profile.permissionMode || 'default',
374
- agentId,
375
- effort,
376
- });
377
- // CLI drivers recycle numeric ids after a restart; a fresh session must
378
- // not inherit the archived flag of an unrelated predecessor that happened
379
- // to hold the same id.
380
- if (this.sessionArchive && this.sessionArchive.isArchived(started.sessionId)) {
381
- this.sessionArchive.forget(started.sessionId);
382
- }
383
- this.sessionAgents.set(started.sessionId, agentId);
384
- // The native-task scope is bound from trusted service identity at
385
- // session creation, before any input can arrive.
386
- this._attachNativeTasks(started.sessionId);
387
- const session = this.runner.getSession(started.sessionId);
388
- if (session) {
389
- session.selectedModel = profile.mainModel || null;
390
- session.metadata = { ...(session.metadata || {}), inputOrigin: normalizeInputOrigin(options.inputOrigin) };
391
- }
392
- this._trackHistory(started.sessionId);
393
- this._notifySessionChange(started.sessionId);
394
- this._audit('session-start', { sessionId: started.sessionId, contextId: context.contextId, profileId, agentId });
395
- return this._summary(this.runner.getState(started.sessionId), true);
396
- }
397
-
398
- loadSession(sessionId) {
399
- const id = cleanId(sessionId, 'sessionId');
400
- const state = this.runner.getState(id);
401
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
402
- // An existing session binds its fixed native-task scope on first Service
403
- // attach; it can never switch to a different one afterwards.
404
- this._attachNativeTasks(id);
405
- const usage = this.runner.getSessionTokenUsage(id);
406
- return {
407
- session: this._summary(state, true),
408
- events: snapshotEvents(this.runner, id),
409
- permission: state.pendingPermission || null,
410
- permissionMode: state.permissionMode || 'default',
411
- tokenUsage: { ...usage, hasUsage: Boolean(usage && (usage.used || usage.inputTokens || usage.outputTokens)) },
412
- };
413
- }
414
-
415
- // Trusted-scope injection: the store scope, the runtime epoch and the
416
- // durable-fact committer come from the frozen service task identity only -
417
- // browser input and session options never reach them. Without a usable
418
- // store identity the native-task feature stays explicitly unavailable
419
- // while chat and history continue unchanged.
420
- _attachNativeTasks(sessionId) {
421
- if (!sessionId || !this.taskIdentity.available || !this.taskIdentity.storeScope) return false;
422
- if (typeof this.runner.attachNativeTaskRuntime !== 'function') return false;
423
- let committer = this.nativeCommitters.get(sessionId);
424
- if (!committer) {
425
- committer = this._nativeFactCommitter(sessionId);
426
- this.nativeCommitters.set(sessionId, committer);
427
- }
428
- try {
429
- const result = this.runner.attachNativeTaskRuntime(sessionId, {
430
- storeScope: this.taskIdentity.storeScope,
431
- runtimeEpoch: this.taskIdentity.runtimeEpoch,
432
- commitNativeFact: committer,
433
- });
434
- return Boolean(result && result.attached);
435
- } catch {
436
- return false;
437
- }
438
- }
439
-
440
- // The one trusted synchronous committer the runner's publish gate calls
441
- // BEFORE a native fact reaches any buffer, listener or persisted
442
- // snapshot. It goes through the sequence-validated durable receipt path
443
- // (write + fsync), never a raw file bypass: a duplicate of an
444
- // already-committed same-sequence fact is an idempotent success, a
445
- // same-sequence disagreement is an explicit conflict failure, and a
446
- // history that cannot even be attached is a fixed unavailable phase.
447
- _nativeFactCommitter(sessionId) {
448
- return event => {
449
- try {
450
- const history = this._history(sessionId);
451
- const sequence = Number(event && event.sequence);
452
- if (!Number.isSafeInteger(sequence) || sequence <= 0) {
453
- return { ok: false, phase: 'sequence', error: 'native fact is not sequence-stamped' };
454
- }
455
- if (sequence <= history.highWater) {
456
- const known = history.durableRow(sequence);
457
- if (known && canonicalNativeFact(known) === canonicalNativeFact(event)) {
458
- return { ok: true, phase: 'already-committed', sequence, highWater: history.highWater };
459
- }
460
- return {
461
- ok: false, phase: 'conflict',
462
- error: `native fact at sequence ${sequence} conflicts with the committed row`,
463
- highWater: history.highWater,
464
- };
465
- }
466
- return history.appendDurable([event]);
467
- } catch {
468
- return { ok: false, phase: 'history-unavailable', error: 'session history is not attachable' };
469
- }
470
- };
471
- }
472
-
473
- // Unbind this Service's own committer (same epoch + same closure
474
- // reference); a different or second instance is refused by the runner.
475
- _detachNativeTasks(sessionId) {
476
- const committer = this.nativeCommitters.get(sessionId) || null;
477
- this.nativeCommitters.delete(sessionId);
478
- if (typeof this.runner.detachNativeTaskRuntime !== 'function') return;
479
- try {
480
- this.runner.detachNativeTaskRuntime(sessionId, {
481
- runtimeEpoch: this.taskIdentity.runtimeEpoch,
482
- commitNativeFact: committer,
483
- });
484
- } catch { /* binding already gone */ }
485
- }
486
-
487
- _history(id) {
488
- if (this.histories.has(id)) return this.histories.get(id).history;
489
- const session = this.runner.getSession(id);
490
- const state = this.runner.getState(id);
491
- if (!session || !state) throw Object.assign(new Error('session not found'), { status: 404 });
492
- const first = session.outputBuffer?.[0];
493
- const history = new SessionHistory({ file: this._historyFile(id), sessionId: id, startedAt: state.startedAt,
494
- contextId: state.projectSlug, incomplete: Number(first?.sequence) > 1
495
- || Number(session.turns) > (session.outputBuffer || []).filter(event => event.type === 'claude/user-prompt').length,
496
- onError: error => this.diagnostics?.error('session.history.write.failed', '会话历史保存失败。', { phase: 'history', error: { code: error.code || 'HISTORY_WRITE_FAILED', message: '无法写入会话历史。' }, context: { sessionId: id } }),
497
- });
498
- this._reconcileHistory(id, history, session);
499
- const unsubscribe = this.runner.subscribe(id, event => {
500
- if (NATIVE_FACT_TYPES.has(event.type)) {
501
- const sequence = Number(event.sequence);
502
- if (Number.isSafeInteger(sequence) && sequence > 0 && sequence <= history.highWater) {
503
- const known = history.durableRow(sequence);
504
- if (known && canonicalNativeFact(known) === canonicalNativeFact(event)) {
505
- // Idempotent replay of a committed fact - including the very
506
- // fact the publish gate's committer just landed: no second
507
- // write, but the session-change notification must still fire.
508
- } else {
509
- // A same-sequence fact is never blindly treated as "already
510
- // written" and never overwrites the committed row: either the
511
- // numbered row is missing or it disagrees with the replayed fact,
512
- // and either way the conflict is an explicit diagnosis.
513
- this.diagnostics?.error('session.history.durable.conflict', '原生任务事实与持久化历史同序号冲突;拒绝改写,原文件已保留。', {
514
- phase: 'history', error: { code: 'HISTORY_DURABLE_CONFLICT', message: `native fact at sequence ${sequence} conflicts with the committed row` },
515
- context: { sessionId: id, sequence, present: Boolean(known) },
516
- });
517
- return;
518
- }
519
- } else {
520
- // Fallback for native facts that flowed ungated (a runner without
521
- // a bound committer): the receipt is diagnosed, never faked.
522
- const receipt = history.appendDurable([event]);
523
- if (!receipt.ok) this.diagnostics?.error('session.history.durable.failed', '原生任务事实未能持久化;任务历史不可用,原文件已保留。', {
524
- phase: 'history', error: { code: `HISTORY_DURABLE_${String(receipt.phase).toUpperCase()}`, message: String(receipt.error || 'durable append failed') },
525
- context: { sessionId: id, highWater: receipt.highWater, pendingCount: receipt.pendingCount ?? null },
526
- });
527
- }
528
- } else history.append(event);
529
- this._notifySessionChange(id, event);
530
- });
531
- this.histories.set(id, { history, unsubscribe });
532
- return history;
533
- }
534
-
535
- // Forward-only reconciliation between the runner snapshot counter and the
536
- // durable journal, run once when a session's history is attached and
537
- // therefore before any new sequence is minted for it. The old behavior
538
- // reset a newer durable high-water mark back to an older snapshot count,
539
- // which both silently dropped fresh events (sequence at or below the reset
540
- // mark) and re-minted sequences the file already contained. Now both
541
- // sides adopt max(snapshot, durable) and the losing side is repaired or
542
- // explicitly marked instead of being rewritten: durable-ahead merges the
543
- // missing durable tail into the replay buffer so subscribers can be
544
- // served the gap; snapshot-ahead replays the contiguous in-memory prefix
545
- // through the durable receipt path, and a hole that cannot be
546
- // reconstructed is recorded as a recovery gap — never faked as complete.
547
- _reconcileHistory(id, history, session) {
548
- if (!history.exists || !Number.isFinite(session.eventSequence)) return;
549
- const buffered = Array.isArray(session.outputBuffer) ? session.outputBuffer : [];
550
- let bufferedMax = 0;
551
- for (const event of buffered) {
552
- const own = Number(event && event.sequence);
553
- if (Number.isFinite(own) && own > 0 && own > bufferedMax) bufferedMax = own;
554
- }
555
- if (history.highWater > session.eventSequence) {
556
- const tail = history.durableTail({ afterSequence: bufferedMax });
557
- const lastDeltaSequence = new Map();
558
- for (let i = buffered.length - 1; i >= 0; i--) {
559
- const event = buffered[i];
560
- if (!event || !DELTA_TYPES.has(event.type)) continue;
561
- const id = event.id ?? null;
562
- if (!lastDeltaSequence.has(id)) lastDeltaSequence.set(id, Number(event.sequence) || 0);
563
- }
564
- for (const event of tail.events) {
565
- if (event && DELTA_TYPES.has(event.type)) {
566
- const id = event.id ?? null;
567
- const coveredFrom = compactionCoverage(buffered, event, lastDeltaSequence.get(id) || 0);
568
- if (coveredFrom !== null) {
569
- // A durable compaction subsumes fragments the replay buffer
570
- // still holds in part: the merged row replaces them instead of
571
- // concatenating onto them, so no client's cached prefix is
572
- // duplicated and a cursor below the merged row resyncs
573
- // explicitly instead of receiving the whole fragment as a delta.
574
- for (let i = buffered.length - 1; i >= 0; i--) {
575
- const held = buffered[i];
576
- if (held && DELTA_TYPES.has(held.type) && held.id === event.id && Number(held.sequence) >= coveredFrom) buffered.splice(i, 1);
577
- }
578
- lastDeltaSequence.set(id, Number(event.sequence) || 0);
579
- } else if (Number.isFinite(Number(event.sequence))) {
580
- lastDeltaSequence.set(id, Math.max(lastDeltaSequence.get(id) || 0, Number(event.sequence)));
581
- }
582
- }
583
- buffered.push(event);
584
- while (buffered.length > 5000) buffered.shift();
585
- }
586
- session.eventSequence = Math.max(session.eventSequence, history.highWater);
587
- return;
588
- }
589
- if (session.eventSequence > history.highWater) {
590
- const above = [];
591
- for (const event of buffered) {
592
- const own = Number(event && event.sequence);
593
- if (Number.isFinite(own) && own > history.highWater) above.push([own, event]);
594
- }
595
- above.sort((a, b) => a[0] - b[0]);
596
- const catchUp = [];
597
- let expected = history.highWater + 1;
598
- for (const [sequence, event] of above) {
599
- if (sequence !== expected) break;
600
- catchUp.push(event);
601
- expected += 1;
602
- }
603
- if (catchUp.length) {
604
- const receipt = history.appendDurable(catchUp);
605
- if (!receipt.ok) {
606
- this.diagnostics?.error('session.history.reconcile.failed', '崩溃边界恢复未能补写会话历史;原文件已保留。', {
607
- phase: 'history', error: { code: `HISTORY_DURABLE_${String(receipt.phase).toUpperCase()}`, message: String(receipt.error || 'durable append failed') },
608
- context: { sessionId: id, highWater: receipt.highWater },
609
- });
610
- return;
611
- }
612
- }
613
- if (history.highWater < session.eventSequence) {
614
- // The gap marker is journaled, not kept in RAM: a restart must keep
615
- // reporting the hole instead of presenting a healed-looking archive.
616
- // A failed marker receipt is explicit and stays retryable — the
617
- // next reconcile rewrites it instead of skipping it.
618
- const recorded = history.recordRecoveryGap(history.highWater + 1, session.eventSequence);
619
- if (!recorded.ok) {
620
- this.diagnostics?.error('session.history.recovery.record.failed', '缺口标记未能持久化;原文件已保留,保持可重试。', {
621
- phase: 'history', error: { code: `HISTORY_DURABLE_${String(recorded.phase).toUpperCase()}`, message: String(recorded.error || 'gap marker write failed') },
622
- context: { sessionId: id, from: history.highWater + 1, to: session.eventSequence },
623
- });
624
- }
625
- this.diagnostics?.error('session.history.recovery.gap', '会话历史存在无法重建的序号缺口;历史读取将显式标注。', {
626
- phase: 'history', error: { code: 'HISTORY_RECOVERY_GAP', message: '快照与持久化历史之间存在缺口,不能宣称完整。' },
627
- context: { sessionId: id, from: history.highWater + 1, to: session.eventSequence },
628
- });
629
- }
630
- }
631
- }
632
-
633
- _trackHistory(id) {
634
- try { return this._history(id); }
635
- catch (error) {
636
- this.diagnostics?.error('session.history.read.failed', '会话历史读取失败;实时会话仍可使用。', {
637
- phase: 'history', error: { code: error.code || 'HISTORY_READ_FAILED', message: '会话历史不可用,原文件已保留。' }, context: { sessionId: id },
638
- });
639
- return null;
640
- }
641
- }
642
-
643
- loadHistory(sessionId, options = {}) {
644
- const id = cleanId(sessionId, 'sessionId');
645
- try {
646
- const history = this._history(id);
647
- const state = this.runner.getState(id);
648
- if (history.incomplete && !ACTIVE_STATES.has(state.state) && this._summary(state, false).agentId === 'claude-code') {
649
- const session = this.runner.getSession(id);
650
- const recovered = recoverClaudeHistory(session);
651
- if (recovered) history.seedRecovered(recovered, Number(session.eventSequence) || history.highWater);
652
- }
653
- return history.read(options);
654
- } catch (error) {
655
- if (![400, 404].includes(error.status)) {
656
- this.diagnostics?.error('session.history.read.failed', '会话历史读取失败,原文件已保留。', {
657
- phase: 'history', error: { code: error.code || 'HISTORY_READ_FAILED', message: '无法读取完整会话历史。' }, context: { sessionId: id },
658
- });
659
- error.status = 503;
660
- }
661
- throw error;
662
- }
663
- }
664
-
665
- _removeHistory(id) {
666
- this.knowledgeBySession.delete(id);
667
- this._detachNativeTasks(id);
668
- const record = this.histories.get(id);
669
- record?.unsubscribe(); record?.history.close();
670
- this.histories.delete(id);
671
- try { fs.unlinkSync(this._historyFile(id)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
672
- }
673
-
674
- _historyFile(id) { return path.join(this.historyRoot, `${crypto.createHash('sha256').update(id).digest('hex')}.jsonl`); }
675
-
676
- // ---- native task history (read-only over the session's own journal) ----
677
-
678
- // The fixed scope every native-task read is bound to: the frozen trusted
679
- // store identity plus the session's real project and id. Nothing in it is
680
- // client-derived; the client only ever echoes it back for comparison.
681
- _nativeTaskScope(state) {
682
- return { storeScope: this.taskIdentity.storeScope, contextId: state.projectSlug, parentSessionId: state.sessionId };
683
- }
684
-
685
- _requireNativeTaskScope() {
686
- if (!this.taskIdentity.available || !this.taskIdentity.storeScope) {
687
- throw Object.assign(new Error('native task history is unavailable: the trusted store scope is not configured'), {
688
- status: 503, code: this.taskIdentity.errorCode || 'STORE_SCOPE_UNAVAILABLE',
689
- });
690
- }
691
- return this.taskIdentity.storeScope;
692
- }
693
-
694
- // The reader file always comes from the service's own session-history
695
- // instance; recovery seeds never run on this path, and a failure keeps the
696
- // original file and surfaces as an explicit unavailable answer.
697
- _nativeHistoryFor(id) {
698
- try {
699
- return this._history(id);
700
- } catch (error) {
701
- if (error && (error.status === 400 || error.status === 404)) throw error;
702
- throw Object.assign(new Error('native task history is not readable; the original history file is preserved'), {
703
- status: 503, code: error && error.code ? error.code : 'HISTORY_READ_FAILED',
704
- });
705
- }
706
- }
707
-
708
- // A journal that has proven a write failure is never served as complete
709
- // history: the newest facts may still live only in memory, so a page off
710
- // the disk would silently omit them. Mirrors the SessionHistory read gate
711
- // with a fixed safe message; the latched code token travels as the code.
712
- // The same hole exists whenever ANY native fact is still pending behind a
713
- // durable barrier - a recovery gap whose marker is not yet on disk, or a
714
- // failed batch still awaiting its close-time marker: while the barrier
715
- // head exists the native surfaces refuse with one generic pending code
716
- // instead of serving the journal as complete. Ordinary chat reads are
717
- // untouched, and a cleared barrier restores these reads.
718
- _requireDurableJournal(history) {
719
- if (history.error) {
720
- throw Object.assign(new Error('会话历史写入失败,原文件已保留;原生任务历史暂不可用。'), { status: 503, code: history.error });
721
- }
722
- if (history.durableBarrier) {
723
- throw Object.assign(new Error('尚有未提交历史事实,原生任务历史暂不可用。'), { status: 503, code: 'HISTORY_FACTS_PENDING' });
724
- }
725
- }
726
-
727
- // The journal may know a recovery gap the on-disk scan can no longer see
728
- // (the marker row disappeared after attach). The reader's own verdict
729
- // stays untouched; the service-known hole is merged in, never dropped.
730
- _nativeGapOverlay(history, page) {
731
- const gap = history.recoveryGap;
732
- if (!gap || page.incomplete.includes('history-gap')) return page;
733
- const items = [{ code: 'service-recovery-gap', source: 'service', from: gap.from, to: gap.to }].concat(page.diagnostics.items);
734
- const total = page.diagnostics.total + 1;
735
- return {
736
- ...page,
737
- incomplete: [...page.incomplete, 'history-gap'],
738
- diagnostics: { items: items.slice(0, 20), total, truncated: total > 20 },
739
- };
740
- }
741
-
742
- // GET /sessions/:id/native-tasks - list observed native background tasks
743
- // for the session out of its durable history. Pure read: no input is
744
- // dispatched and no live handle is created; task rows are historical
745
- // observations, never claims that anything is running or controllable.
746
- async listNativeTasks(sessionId, query = {}) {
747
- const id = cleanId(sessionId, 'sessionId');
748
- const state = this.runner.getState(id);
749
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
750
- const storeScope = this._requireNativeTaskScope();
751
- if (typeof query.storeScope !== 'string' || query.storeScope === '') {
752
- throw Object.assign(new Error('storeScope is required'), { status: 400 });
753
- }
754
- if (!STORE_SCOPE_PATTERN.test(query.storeScope)) {
755
- throw Object.assign(new Error('storeScope is invalid'), { status: 400 });
756
- }
757
- if (typeof query.contextId !== 'string' || query.contextId === '') {
758
- throw Object.assign(new Error('contextId is required'), { status: 400 });
759
- }
760
- const contextId = cleanId(query.contextId, 'contextId');
761
- if (query.storeScope !== storeScope || contextId !== state.projectSlug) {
762
- throw Object.assign(new Error('native task scope does not match this session; resync from the task list'), {
763
- status: 409, code: 'SCOPE_MISMATCH', resync: true,
764
- });
765
- }
766
- const history = this._nativeHistoryFor(id);
767
- this._requireDurableJournal(history);
768
- const scope = this._nativeTaskScope(state);
769
- const reader = createNativeTaskHistoryReader({ file: history.file, scope });
770
- let page;
771
- try { page = await reader.listTasks(); }
772
- catch (error) { if (error && error.status === 409) error.resync = true; throw error; }
773
- return { ...this._nativeGapOverlay(history, page), scope: { ...scope } };
774
- }
775
-
776
- // POST /sessions/:id/native-tasks/history - page one bound task's events
777
- // out of the same durable history. Pure read: no dispatch, no stop, no
778
- // re-minted sequences, no SSE replay.
779
- async loadNativeTaskHistory(sessionId, input = {}) {
780
- const id = cleanId(sessionId, 'sessionId');
781
- const state = this.runner.getState(id);
782
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
783
- this._requireNativeTaskScope();
784
- const ref = createNativeTaskRef(input && input.taskRef);
785
- if (!ref) throw Object.assign(new Error('taskRef is not a valid native-task reference'), { status: 400, code: 'INVALID_TASK_REF' });
786
- // The whole reference is checked against the real session scope before
787
- // any journal is constructed or read, so a foreign-scope ref is a scope
788
- // conflict even when the target file is missing or corrupt.
789
- const scope = this._nativeTaskScope(state);
790
- if (ref.storeScope !== scope.storeScope || ref.contextId !== scope.contextId || ref.parentSessionId !== scope.parentSessionId) {
791
- throw Object.assign(new Error('taskRef scope does not match this session; resync from the task list'), {
792
- status: 409, code: 'SCOPE_MISMATCH', resync: true,
793
- });
794
- }
795
- const history = this._nativeHistoryFor(id);
796
- this._requireDurableJournal(history);
797
- const reader = createNativeTaskHistoryReader({ file: history.file, scope });
798
- let page;
799
- try { page = await reader.readTaskEvents(ref, { cursor: input.cursor, limit: input.limit }); }
800
- catch (error) { if (error && error.status === 409) error.resync = true; throw error; }
801
- return { ...this._nativeGapOverlay(history, page), taskRef: { ...ref }, scope: { ...scope } };
802
- }
803
-
804
- // ---- delegated sessions (trusted Workbench delegation commands) ----
805
-
806
- _requireDelegations() {
807
- if (!this.delegations || !this.delegations.ledger || !this.delegations.phases) {
808
- throw Object.assign(new Error('delegated sessions are unavailable: the delegation ledger is not wired'), {
809
- status: 503, code: 'DELEGATION_UNAVAILABLE',
810
- });
811
- }
812
- }
813
-
814
- _delegationSupported(agentId) {
815
- if (typeof this.runner.supportsDelegatedSessions === 'function') {
816
- return this.runner.supportsDelegatedSessions(agentId) === true;
817
- }
818
- return typeof this.runner.startDelegatedSession === 'function';
819
- }
820
-
821
- // The trusted workspace a delegated child inherits. Preference order: the
822
- // parent's own persisted workspaceRef re-resolved through the real resolver,
823
- // then — for sessions recorded before workspaceRef existed — a UNIQUE
824
- // real-path match against the registered workspaces. Ambiguous or
825
- // unresolvable is an explicit 409; the current UI project is never picked.
826
- _delegationWorkspace(parentState) {
827
- const parentSession = this.runner.getSession(parentState.sessionId);
828
- const ref = parentSession && typeof parentSession.workspaceRef === 'string' && parentSession.workspaceRef
829
- ? parentSession.workspaceRef : null;
830
- if (ref) {
831
- try {
832
- const resolved = this.contexts.resolve({ contextId: parentState.projectSlug, workspaceRef: ref });
833
- return { ref, rootPath: resolved.workspacePath };
834
- } catch {
835
- throw Object.assign(new Error('the parent workspace is no longer registered; no delegation was issued'), {
836
- status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
837
- });
838
- }
839
- }
840
- const recordedPath = parentSession && typeof parentSession.projectPath === 'string' && parentSession.projectPath
841
- ? parentSession.projectPath : null;
842
- if (!recordedPath) {
843
- throw Object.assign(new Error('the parent session has no trusted workspace record; no delegation was issued'), {
844
- status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
845
- });
846
- }
847
- const matches = this.contexts.findByRealPath(recordedPath);
848
- if (matches.length === 1) return { ref: matches[0], rootPath: recordedPath };
849
- if (matches.length === 0) {
850
- throw Object.assign(new Error('the parent workspace does not match any registered workspace; no delegation was issued'), {
851
- status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
852
- });
853
- }
854
- throw Object.assign(new Error('the parent workspace matches multiple registered workspaces; no delegation was issued'), {
855
- status: 409, code: 'DELEGATION_WORKSPACE_AMBIGUOUS',
856
- });
857
- }
858
-
859
- // Canonical command hash over EVERY choice that affects execution: the
860
- // normalized text, the agent/profile/model/permission/effort selection and
861
- // the resolved workspace. Credentials, env and prompt bodies beyond the
862
- // command text itself never enter the ledger — only this hash does.
863
- _delegationPayloadHash(command) {
864
- const canonical = JSON.stringify({
865
- schema: 'workbench-delegation-command/v1',
866
- text: command.text,
867
- agentId: command.agentId,
868
- aiProfileId: command.aiProfileId,
869
- model: command.model,
870
- permissionMode: command.permissionMode,
871
- effort: command.effort || null,
872
- workspaceRef: command.workspaceRef,
873
- contextId: command.contextId,
874
- parentSessionId: command.parentSessionId,
875
- });
876
- return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
877
- }
878
-
879
- _delegationDispatchState(record) {
880
- const phases = this.delegations.phases.stateOf(record.delegationId);
881
- const live = this.delegations.phases.isClaimLive(record.delegationId);
882
- if (phases.outcome) {
883
- const known = phases.outcome.outcome === 'dispatched';
884
- return {
885
- phase: known ? 'dispatched' : 'dispatch-unknown',
886
- outcome: known ? 'dispatched' : 'unknown',
887
- dispatch: { state: phases.outcome.outcome, attemptId: phases.outcome.attemptId, at: phases.outcome.at },
888
- claim: phases.claim,
889
- };
890
- }
891
- if (phases.claim) {
892
- if (live) {
893
- return { phase: 'dispatching', outcome: 'pending', dispatch: { state: 'dispatching', attemptId: phases.claim.attemptId }, claim: phases.claim };
894
- }
895
- // A claim nobody in this Service can still settle: the model receipt is
896
- // unprovable, so the honest verdict is dispatch-unknown.
897
- return {
898
- phase: 'dispatch-unknown', outcome: 'unknown',
899
- dispatch: { state: 'dispatch-unknown', attemptId: phases.claim.attemptId, reason: 'owner-unreachable' },
900
- claim: phases.claim,
901
- };
902
- }
903
- if (phases.childCreated) {
904
- return { phase: 'child-created', outcome: 'not-dispatched', dispatch: { state: 'awaiting-dispatch' }, claim: null };
905
- }
906
- if (phases.childCreateFailed) {
907
- return { phase: 'child-create-failed', outcome: 'not-started', dispatch: { state: 'not-dispatched', code: phases.childCreateFailed.code }, claim: null };
908
- }
909
- return { phase: 'reserved', outcome: 'not-started', dispatch: { state: 'not-dispatched' }, claim: null };
910
- }
911
-
912
- // Real child status straight from the runner. A missing child is reported
913
- // as the explicit dangling fact it is — reads never resurrect it.
914
- _delegationChild(record) {
915
- const state = this.runner.getState(record.childSessionId);
916
- if (!state) return { sessionId: record.childSessionId, present: false, state: 'missing' };
917
- const summary = this._summary(state, ACTIVE_STATES.has(state.state));
918
- return {
919
- sessionId: record.childSessionId,
920
- present: true,
921
- state: state.state,
922
- agentId: summary.agentId,
923
- model: summary.model || null,
924
- active: summary.active,
925
- turns: summary.turns,
926
- archived: summary.archived,
927
- };
928
- }
929
-
930
- _delegationView(record) {
931
- const status = this._delegationDispatchState(record);
932
- return {
933
- delegation: {
934
- kind: 'delegated-session',
935
- storeScope: record.scope.storeScope,
936
- contextId: record.scope.contextId,
937
- parentSessionId: record.scope.parentSessionId,
938
- delegationId: record.delegationId,
939
- childSessionId: record.childSessionId,
940
- workspaceRef: record.workspace.ref,
941
- requestId: record.requestId,
942
- issuedAt: record.issuedAt,
943
- source: record.source,
944
- basis: record.basis,
945
- sdkRunIdClaimed: false,
946
- inputCausalityClaimed: false,
947
- initiator: { type: 'external-api', authenticatedAs: null },
948
- selection: { profileId: record.selection.profileId, model: record.selection.model },
949
- },
950
- phase: status.phase,
951
- outcome: status.outcome,
952
- dispatch: status.dispatch,
953
- child: this._delegationChild(record),
954
- };
955
- }
956
-
957
- _adoptDelegatedChild(childSessionId, agentId) {
958
- this.sessionAgents.set(childSessionId, agentId);
959
- this._attachNativeTasks(childSessionId);
960
- this._trackHistory(childSessionId);
961
- this._notifySessionChange(childSessionId);
962
- }
963
-
964
- _recordDelegationFailure(record, code) {
965
- try {
966
- this.delegations.phases.recordChildCreateFailed({ delegationId: record.delegationId, code });
967
- } catch {
968
- // The failure fact is diagnostics; the explicit error answer below is
969
- // already the honest result and the reservation stays untouched.
970
- }
971
- }
972
-
973
- // POST /sessions/:parentId/delegations — issue one delegation command.
974
- // Frozen order (W1-DELEGATION-DECISIONS): validate → reserve (atomic fsync,
975
- // idempotent by requestId, payload change = 409) → trusted child factory
976
- // with the reserved identity (relation durable before anything else) →
977
- // durable dispatch claim (exactly one owner) → send → durable outcome. Any
978
- // unknown after the claim is reported as dispatch-unknown and never
979
- // auto-re-input. accepted means the Workbench accepted the command, not
980
- // that a model proved receipt.
981
- async createDelegation(parentIdRaw, input = {}) {
982
- this._requireDelegations();
983
- const storeScope = this._requireNativeTaskScope();
984
- const parentId = cleanId(parentIdRaw, 'sessionId');
985
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
986
- throw Object.assign(new Error('delegation command must be an object'), { status: 400, code: 'DELEGATION_COMMAND_INVALID' });
987
- }
988
- const scopeInput = input.scope;
989
- if (!scopeInput || typeof scopeInput !== 'object' || Array.isArray(scopeInput)
990
- || Object.keys(scopeInput).length !== 2 || !('storeScope' in scopeInput) || !('contextId' in scopeInput)) {
991
- throw Object.assign(new Error('scope must be an object with exactly storeScope and contextId; parentSessionId comes from the route'), {
992
- status: 400, code: 'DELEGATION_SCOPE_INVALID',
993
- });
994
- }
995
- if (typeof scopeInput.storeScope !== 'string' || !STORE_SCOPE_PATTERN.test(scopeInput.storeScope) || scopeInput.storeScope !== storeScope) {
996
- throw Object.assign(new Error('scope.storeScope does not match this workbench; resync from health'), {
997
- status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
998
- });
999
- }
1000
- const contextId = cleanId(scopeInput.contextId, 'contextId');
1001
- if (typeof input.requestId !== 'string' || !input.requestId.length || input.requestId.length > 256
1002
- || /[\u0000-\u001f\u007f]/.test(input.requestId)) {
1003
- throw Object.assign(new Error('requestId must be a non-empty string without control characters'), {
1004
- status: 400, code: 'DELEGATION_REQUEST_INVALID',
1005
- });
1006
- }
1007
- const requestId = input.requestId;
1008
- const text = typeof input.text === 'string' ? input.text.trim() : '';
1009
- if (!text || text.length > 200000) {
1010
- throw Object.assign(new Error('text must be a non-empty command of at most 200000 characters'), {
1011
- status: 400, code: 'DELEGATION_TEXT_INVALID',
1012
- });
1013
- }
1014
- const agentId = normalizeAgentId(input.agentId === undefined || input.agentId === null || input.agentId === '' ? undefined : input.agentId);
1015
- const effort = input.effort === undefined || input.effort === null || input.effort === '' ? undefined : normalizeEffort(input.effort);
1016
- const permissionMode = input.permissionMode === undefined || input.permissionMode === null || input.permissionMode === ''
1017
- ? 'default' : input.permissionMode;
1018
- if (!PERMISSION_MODES.includes(permissionMode)) {
1019
- throw Object.assign(new Error('permissionMode is invalid'), { status: 400, code: 'DELEGATION_SELECTION_REJECTED' });
1020
- }
1021
- if (input.metadata !== undefined || input.inputOrigin !== undefined) {
1022
- throw Object.assign(new Error('caller metadata does not grant trusted relations; omit metadata and inputOrigin'), {
1023
- status: 400, code: 'DELEGATION_CALLER_IDENTITY_REJECTED',
1024
- });
1025
- }
1026
-
1027
- const parentState = this.runner.getState(parentId);
1028
- if (!parentState) throw Object.assign(new Error('parent session not found'), { status: 404, code: 'DELEGATION_PARENT_NOT_FOUND' });
1029
- if (parentState.projectSlug !== contextId) {
1030
- throw Object.assign(new Error('scope.contextId does not match the parent session; resync from the session list'), {
1031
- status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
1032
- });
1033
- }
1034
- if (!this._delegationSupported(agentId)) {
1035
- throw Object.assign(new Error(`agent "${agentId}" cannot run delegated sessions with a server-reserved identity`), {
1036
- status: 501, code: 'DELEGATION_AGENT_UNSUPPORTED',
1037
- });
1038
- }
1039
- // The selection resolves NOW so the payload hash covers the concrete
1040
- // profile/model the child will run with (unknown profile → 404 here).
1041
- const aiProfileId = input.aiProfileId === undefined ? (parentState.aiProfileId || null)
1042
- : input.aiProfileId === null ? null : cleanId(input.aiProfileId, 'aiProfileId');
1043
- const profile = this._runtimeProfile(aiProfileId, input.model === undefined ? undefined : input.model);
1044
- const model = profile.mainModel || null;
1045
- const workspace = this._delegationWorkspace(parentState);
1046
- if (this.runner.listSessions({}).length >= this.maxSessions) {
1047
- throw Object.assign(new Error('session limit reached'), { status: 429, code: 'DELEGATION_SESSION_LIMIT' });
1048
- }
1049
- const payloadHash = this._delegationPayloadHash({
1050
- text, agentId, aiProfileId, model, permissionMode, effort,
1051
- workspaceRef: workspace.ref, contextId, parentSessionId: parentId,
1052
- });
1053
-
1054
- const record = this.delegations.ledger.reserve({
1055
- scope: { storeScope, contextId, parentSessionId: parentId },
1056
- requestId,
1057
- payloadHash,
1058
- workspace: { ref: workspace.ref, rootPath: workspace.rootPath, resolvedFrom: 'parent-session-record' },
1059
- selection: { profileId: aiProfileId, model },
1060
- });
1061
- this._audit('delegation-reserve', {
1062
- delegationId: record.delegationId, parentSessionId: parentId, contextId, childSessionId: record.childSessionId,
1063
- });
1064
-
1065
- // Once a dispatch claim exists the input may already have reached a
1066
- // model: never re-create, never re-send — report the durable truth.
1067
- const prior = this._delegationDispatchState(record);
1068
- if (prior.claim) return { accepted: true, ...this._delegationView(record) };
1069
-
1070
- if (typeof this.runner.startDelegatedSession !== 'function') {
1071
- throw Object.assign(new Error(`agent "${agentId}" cannot run delegated sessions with a server-reserved identity`), {
1072
- status: 501, code: 'DELEGATION_AGENT_UNSUPPORTED',
1073
- });
1074
- }
1075
- let started;
1076
- try {
1077
- started = this.runner.startDelegatedSession({
1078
- agentId,
1079
- reservedSessionId: record.childSessionId,
1080
- delegation: {
1081
- delegationId: record.delegationId,
1082
- parentSessionId: parentId,
1083
- storeScope,
1084
- contextId,
1085
- },
1086
- slug: contextId,
1087
- projectPath: workspace.rootPath,
1088
- kbPath: workspace.rootPath,
1089
- workspaceRef: workspace.ref,
1090
- aiProfile: profile,
1091
- permissionMode,
1092
- ...(effort ? { effort } : {}),
1093
- });
1094
- } catch (error) {
1095
- const code = error && typeof error.code === 'string' && error.code.startsWith('DELEGATION_')
1096
- ? error.code : 'DELEGATION_CHILD_CREATE_FAILED';
1097
- this._recordDelegationFailure(record, code);
1098
- if (error && error.status) throw error;
1099
- throw Object.assign(new Error('the delegated child session could not be created; no model input was sent'), {
1100
- status: 500, code,
1101
- });
1102
- }
1103
- if (!started || started.sessionId !== record.childSessionId) {
1104
- this._recordDelegationFailure(record, 'DELEGATION_CHILD_IDENTITY_MISMATCH');
1105
- throw Object.assign(new Error('the driver did not honor the reserved child identity; no model input was sent'), {
1106
- status: 500, code: 'DELEGATION_CHILD_IDENTITY_MISMATCH',
1107
- });
1108
- }
1109
- this._adoptDelegatedChild(record.childSessionId, agentId);
1110
- this.delegations.phases.recordChildCreated({
1111
- delegationId: record.delegationId,
1112
- childSessionId: record.childSessionId,
1113
- workspaceRef: workspace.ref,
1114
- });
1115
- this._notifySessionChange(record.childSessionId);
1116
-
1117
- // Guards mirroring send()'s own pre-input checks: a refusal here happens
1118
- // provably before any input, so no claim is written and the reservation
1119
- // simply reports awaiting-dispatch.
1120
- const childState = this.runner.getState(record.childSessionId);
1121
- if (!childState) {
1122
- this._recordDelegationFailure(record, 'DELEGATION_CHILD_CREATE_FAILED');
1123
- throw Object.assign(new Error('the delegated child session could not be created; no model input was sent'), {
1124
- status: 500, code: 'DELEGATION_CHILD_CREATE_FAILED',
1125
- });
1126
- }
1127
- if (ACTIVE_STATES.has(childState.state) || this.pendingInputs.has(record.childSessionId)) {
1128
- throw Object.assign(new Error('the delegated child already has an active turn; the command stays reserved'), {
1129
- status: 409, code: 'DELEGATION_CHILD_BUSY',
1130
- });
1131
- }
1132
- if (this._activeCount() >= this.maxConcurrentRuns) {
1133
- throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429, code: 'DELEGATION_CONCURRENCY_LIMIT' });
1134
- }
1135
-
1136
- // Durable dispatch claim BEFORE the send: exactly one owner across
1137
- // Services and processes; a lost claim never sends.
1138
- let claim;
1139
- try {
1140
- claim = this.delegations.phases.claimDispatch({
1141
- delegationId: record.delegationId,
1142
- attemptId: crypto.randomUUID(),
1143
- ownerPid: process.pid,
1144
- ownerEpoch: this.runtimeEpoch,
1145
- });
1146
- } catch (error) {
1147
- throw Object.assign(new Error('the dispatch attempt could not be recorded; no model input was sent'), {
1148
- status: 503, code: error && typeof error.code === 'string' ? error.code : 'DELEGATION_CLAIM_FAILED',
1149
- });
1150
- }
1151
- if (!claim.claimed) {
1152
- return { accepted: true, ...this._delegationView(record) };
1153
- }
1154
-
1155
- let outcome = 'dispatched';
1156
- let failureCode = null;
1157
- try {
1158
- await this.send(record.childSessionId, text, {
1159
- inputOrigin: 'agent-delegation',
1160
- permissionMode,
1161
- ...(effort ? { effort } : {}),
1162
- });
1163
- } catch (error) {
1164
- outcome = 'dispatch-unknown';
1165
- failureCode = error && typeof error.code === 'string' ? error.code : null;
1166
- }
1167
- try {
1168
- this.delegations.phases.recordOutcome({
1169
- delegationId: record.delegationId,
1170
- attemptId: claim.claim.attemptId,
1171
- outcome,
1172
- });
1173
- } catch {
1174
- // The receipt could not be proven on disk after a possibly accepted
1175
- // input: the only honest durable verdict is dispatch-unknown.
1176
- outcome = 'dispatch-unknown';
1177
- this.delegations.phases.abandonLiveClaim({
1178
- delegationId: record.delegationId,
1179
- attemptId: claim.claim.attemptId,
1180
- });
1181
- }
1182
- this._audit('delegation-dispatch', { delegationId: record.delegationId, childSessionId: record.childSessionId, outcome });
1183
- const view = this._delegationView(record);
1184
- if (failureCode && outcome === 'dispatch-unknown') view.dispatch.errorCode = failureCode;
1185
- return { accepted: true, ...view };
1186
- }
1187
-
1188
- // GET /sessions/:parentId/delegations — trusted relations, durable command
1189
- // facts and the real child status. Pure read: no session is created, no
1190
- // input is sent, nothing is refreshed back to life.
1191
- listDelegations(parentIdRaw, query = {}) {
1192
- this._requireDelegations();
1193
- const storeScope = this._requireNativeTaskScope();
1194
- const parentId = cleanId(parentIdRaw, 'sessionId');
1195
- const parentState = this.runner.getState(parentId);
1196
- if (!parentState) throw Object.assign(new Error('parent session not found'), { status: 404, code: 'DELEGATION_PARENT_NOT_FOUND' });
1197
- if (typeof query.storeScope !== 'string' || !STORE_SCOPE_PATTERN.test(query.storeScope)) {
1198
- throw Object.assign(new Error('storeScope is invalid'), { status: 400, code: 'DELEGATION_SCOPE_INVALID' });
1199
- }
1200
- if (typeof query.contextId !== 'string' || !query.contextId) {
1201
- throw Object.assign(new Error('contextId is required'), { status: 400, code: 'DELEGATION_SCOPE_INVALID' });
1202
- }
1203
- const contextId = cleanId(query.contextId, 'contextId');
1204
- if (query.storeScope !== storeScope || contextId !== parentState.projectSlug) {
1205
- throw Object.assign(new Error('delegation scope does not match this session; resync from the session list'), {
1206
- status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
1207
- });
1208
- }
1209
- const records = this.delegations.ledger.list({ storeScope, contextId, parentSessionId: parentId });
1210
- return {
1211
- scope: { storeScope, contextId, parentSessionId: parentId },
1212
- delegations: records.map(record => this._delegationView(record)),
1213
- };
1214
- }
1215
-
1216
- subscribe(sessionId, afterSequence, onEvent) {
1217
- const id = cleanId(sessionId, 'sessionId');
1218
- this._attachNativeTasks(id);
1219
- this._trackHistory(id);
1220
- return subscribeNormalized(this.runner, id, Number(afterSequence || 0), onEvent);
1221
- }
1222
-
1223
- _notifySessionChange(id, event) {
1224
- const state = this.runner.getState(id);
1225
- if (!state) return;
1226
- const session = this.runner.getSession(id);
1227
- if (event?.type === 'claude/user-prompt' && event.text && session && !session.title) session.title = event.text.replace(/\s+/g, ' ').slice(0, 80);
1228
- const summary = this._summary(state, ACTIVE_STATES.has(state.state));
1229
- if (session?.title) summary.title = session.title;
1230
- const signature = JSON.stringify(summary);
1231
- if (this.sessionSignatures.get(id) === signature) return;
1232
- this.sessionSignatures.set(id, signature);
1233
- const change = { type: 'workbench/session-changed', revision: ++this.sessionRevision, contextId: summary.contextId, session: summary };
1234
- this.sessionChangeLog.push(change);
1235
- if (this.sessionChangeLog.length > 1000) this.sessionChangeLog.shift();
1236
- for (const listener of this.sessionChangeListeners) if (!listener.contextId || listener.contextId === summary.contextId) {
1237
- try { listener.callback(change); } catch { /* disconnected subscriber */ }
1238
- }
1239
- }
1240
-
1241
- _notifySessionDeleted(state) {
1242
- const summary = this._summary(state, false);
1243
- this.sessionSignatures.delete(state.sessionId);
1244
- const change = { type: 'workbench/session-changed', deleted: true, revision: ++this.sessionRevision, contextId: summary.contextId, session: summary };
1245
- this.sessionChangeLog.push(change);
1246
- if (this.sessionChangeLog.length > 1000) this.sessionChangeLog.shift();
1247
- for (const listener of this.sessionChangeListeners) if (!listener.contextId || listener.contextId === summary.contextId) {
1248
- try { listener.callback(change); } catch { /* disconnected */ }
1249
- }
1250
- }
1251
-
1252
- subscribeSessionChanges(contextId, afterRevision, callback) {
1253
- const id = contextId === '*' ? null : cleanId(contextId, 'contextId');
1254
- const sessions = this.runner.listSessions(id ? { projectSlug: id } : {});
1255
- for (const session of sessions) this._trackHistory(session.sessionId);
1256
- const after = Math.max(0, Number(afterRevision) || 0);
1257
- const first = this.sessionChangeLog[0]?.revision || 0;
1258
- if (after > this.sessionRevision || after && first > after + 1) {
1259
- callback({ type: 'workbench/resync', revision: this.sessionRevision, contextId: id });
1260
- } else if (after) {
1261
- for (const change of this.sessionChangeLog) if (change.revision > after && (!id || change.session.contextId === id)) callback(change);
1262
- } else callback({ type: 'workbench/sessions-snapshot', revision: this.sessionRevision, contextId: id, sessions: id ? this.listSessions(id, { includeArchived: true }) : sessions.map(s => this._summary(s, ACTIVE_STATES.has(s.state))) });
1263
- const listener = { contextId: id, callback };
1264
- this.sessionChangeListeners.add(listener);
1265
- return () => this.sessionChangeListeners.delete(listener);
1266
- }
1267
-
1268
- async listCommands(sessionId) {
1269
- const id = cleanId(sessionId, 'sessionId');
1270
- const state = this.runner.getState(id);
1271
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1272
- if (typeof this.runner.listSupportedCommands !== 'function') return { commands: [] };
1273
- const profile = this._profileForSession(id, state);
1274
- const commands = await this.runner.listSupportedCommands(id, profile);
1275
- return { commands: Array.isArray(commands) ? commands : [] };
1276
- }
1277
-
1278
- readAttachment(sessionId, attachmentId) {
1279
- const id = cleanId(sessionId, 'sessionId');
1280
- if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
1281
- const result = this.attachments && this.attachments.read(id, attachmentId);
1282
- if (!result) throw Object.assign(new Error('attachment not found'), { status: 404 });
1283
- return result;
1284
- }
1285
-
1286
- async send(sessionId, text, options = {}, attachmentInput = []) {
1287
- if (this.destroyed) throw Object.assign(new Error('workbench is shut down'), { status: 503 });
1288
- const id = cleanId(sessionId, 'sessionId');
1289
- const state = this.runner.getState(id);
1290
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1291
- // A direct input can be the first Service contact for an existing or
1292
- // restored session: the trusted native-task scope binds before any SDK
1293
- // input can stream, without relying on an earlier snapshot request.
1294
- this._attachNativeTasks(id);
1295
- if (ACTIVE_STATES.has(state.state) || this.pendingInputs.has(id)) {
1296
- throw Object.assign(new Error('session already has an active turn'), { status: 409 });
1297
- }
1298
- if (this._activeCount() - (state.backgroundTaskCount > 0 ? 1 : 0) >= this.maxConcurrentRuns) {
1299
- throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429 });
1300
- }
1301
- const value = String(text || '').trim();
1302
- const attachments = normalizeAttachments(attachmentInput);
1303
- if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
1304
- if (value.length > 200000) throw Object.assign(new Error('input exceeds 200000 characters'), { status: 413 });
1305
- const profile = this._profileForSession(id, state);
1306
- const session = this.runner.getSession(id);
1307
- this._trackHistory(id);
1308
- const request = { aborted: false };
1309
- this.pendingInputs.set(id, request);
1310
- try {
1311
- const knowledge = this.knowledge ? await this.knowledge.retrieve(session.projectPath || session.kbPath || state.projectPath, value, options?.knowledgeScope, { sessionId: id }) : null;
1312
- if (request.aborted) throw Object.assign(new Error('task cancelled before agent launch'), { status: 409 });
1313
- if (knowledge) this.knowledgeBySession.set(id, knowledge);
1314
- if (knowledge) this.knowledge.remember?.(id, knowledge);
1315
- const previousEventCount = session && session.outputBuffer ? session.outputBuffer.length : 0;
1316
- const captureTurn = this.conversationCapture && this.captureSettings?.read().terminalConversations ? {
1317
- projectPath: session.projectPath || session.kbPath, source: this._summary(state, false).agentId,
1318
- sessionId: id, turnId: `turn_${crypto.randomUUID()}`, inputOrigin: session.metadata?.inputOrigin === 'agent-delegation' ? 'agent-delegation' : normalizeInputOrigin(options?.inputOrigin || session.metadata?.inputOrigin),
1319
- } : null;
1320
- if (captureTurn) this._capture(captureTurn, 'user_prompt', value);
1321
- const stored = this.attachments ? this.attachments.saveMany(id, attachments) : attachments;
1322
- const displayAttachments = stored.map(item => ({
1323
- id: item.id, name: item.name, mediaType: item.mediaType, size: item.size,
1324
- width: item.width, height: item.height,
1325
- url: `${this.apiPrefix}/sessions/${encodeURIComponent(id)}/attachments/${encodeURIComponent(item.id)}`,
1326
- }));
1327
- const runtimeAttachments = attachments.map((item, index) => ({ ...displayAttachments[index], data: item.data }));
1328
- const inputOrigin = session.metadata?.inputOrigin === 'agent-delegation' ? 'agent-delegation' : normalizeInputOrigin(options?.inputOrigin || session.metadata?.inputOrigin);
1329
- try { await this.runner.sendInput(id, value, profile, { ...(options || {}), inputOrigin, knowledgeContext: knowledge?.prompt || '', attachments: runtimeAttachments, displayAttachments }); }
1330
- catch (error) { this._runtimeFailure(id, error); if (captureTurn) this._capture(captureTurn, 'session_end', undefined, 'failed'); throw error; }
1331
- this._watchTurn(id, previousEventCount, captureTurn);
1332
- this._audit('input', { sessionId: id, contextId: state.projectSlug, characters: value.length, attachments: attachments.length, attachmentBytes: attachments.reduce((sum, item) => sum + item.size, 0) });
1333
- return { accepted: true, sessionId: id, state: this.runner.getState(id)?.state, knowledge: knowledge ? { ...knowledge, prompt: undefined } : null };
1334
- } finally { this.pendingInputs.delete(id); }
1335
- }
1336
-
1337
- knowledgeContext(sessionId) {
1338
- const id = cleanId(sessionId, 'sessionId');
1339
- if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
1340
- const value = this.knowledgeBySession.get(id) || this.knowledge?.saved?.(id);
1341
- return value ? { ...value, prompt: undefined } : { status: 'not-retrieved', references: [] };
1342
- }
1343
-
1344
- resolvePermission(sessionId, requestId, decision) {
1345
- const id = cleanId(sessionId, 'sessionId');
1346
- const rid = cleanId(requestId, 'requestId');
1347
- this._trackHistory(id);
1348
- const result = this.runner.resolvePermission(id, rid, decision || {});
1349
- this._audit('permission-resolve', { sessionId: id, requestId: rid, allow: decision && decision.allow === true });
1350
- return result;
1351
- }
1352
-
1353
- abort(sessionId, reason = 'user-abort') {
1354
- const id = cleanId(sessionId, 'sessionId');
1355
- if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
1356
- this._trackHistory(id);
1357
- if (this.pendingInputs.has(id)) this.pendingInputs.get(id).aborted = true;
1358
- this.runner.abort(id, reason);
1359
- // A net for runners that emit no aborted event: normally the monitor
1360
- // already released itself on that event, having recorded session_end once.
1361
- this._clearTurnMonitor(id);
1362
- this._audit('abort', { sessionId: id, reason });
1363
- return { accepted: true, sessionId: id };
1364
- }
1365
-
1366
- setPermissionMode(sessionId, mode) {
1367
- const id = cleanId(sessionId, 'sessionId');
1368
- if (!PERMISSION_MODES.includes(mode)) throw Object.assign(new Error('invalid permission mode'), { status: 400 });
1369
- this._trackHistory(id);
1370
- const session = this.runner.getSession(id);
1371
- if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
1372
- session.permissionMode = mode;
1373
- this._audit('permission-mode', { sessionId: id, mode });
1374
- return { sessionId: id, permissionMode: mode };
1375
- }
1376
-
1377
- // Rebind an existing session to another AI profile/model (or back to the
1378
- // agent's own login with aiProfileId null). The session and its history
1379
- // survive; the next turn simply runs under the new selection.
1380
- updateSessionSelection(sessionId, aiProfileId, model) {
1381
- const id = cleanId(sessionId, 'sessionId');
1382
- const state = this.runner.getState(id);
1383
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1384
- if (ACTIVE_STATES.has(state.state) || state.backgroundTaskCount > 0) {
1385
- throw Object.assign(new Error('session already has an active turn'), { status: 409 });
1386
- }
1387
- this._trackHistory(id);
1388
- // _runtimeProfile resolves and validates the profile (404 unknown id,
1389
- // 400 disabled/missing credential/invalid model) exactly like startSession.
1390
- const profileId = aiProfileId ? cleanId(aiProfileId, 'aiProfileId') : null;
1391
- const profile = this._runtimeProfile(profileId, model === undefined ? undefined : model);
1392
- if (typeof this.runner.updateSelection === 'function') {
1393
- this.runner.updateSelection(id, profile, profile.mainModel || null);
1394
- } else {
1395
- const session = this.runner.getSession(id);
1396
- if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
1397
- session.aiProfileId = profileId;
1398
- session.selectedModel = profile.mainModel || null;
1399
- }
1400
- this._audit('selection-update', { sessionId: id, contextId: state.projectSlug, profileId, model: profile.mainModel || null });
1401
- return this._summary(this.runner.getState(id), true);
1402
- }
1403
-
1404
- async testProfile(profileId) {
1405
- const id = cleanId(profileId, 'profileId');
1406
- const profile = this.profiles.runtimeProfile(id);
1407
- return this._runProfileTest(profile, id);
1408
- }
1409
-
1410
- async testProfileDraft(input = {}) {
1411
- if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
1412
- throw Object.assign(new Error('profile is required'), { status: 400 });
1413
- }
1414
- const profile = this.profiles.runtimeDraft(input.profile, input.credential);
1415
- return this._runProfileTest(profile, profile.id);
1416
- }
1417
-
1418
- // Detect the models a provider draft can actually use (endpoint model list
1419
- // joined with models.dev capability metadata). Uses the same draft+credential
1420
- // resolution as testProfileDraft, so a blank credential falls back to the
1421
- // stored secret of an already-saved profile.
1422
- async detectProfileModels(input = {}) {
1423
- if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
1424
- throw Object.assign(new Error('profile is required'), { status: 400 });
1425
- }
1426
- if (!this.modelDetection) {
1427
- throw Object.assign(new Error('model detection is unavailable'), { status: 501 });
1428
- }
1429
- // Detection precedes model configuration, so an empty model list is fine here.
1430
- const profile = this.profiles.runtimeDraft(input.profile, input.credential, { requireModel: false });
1431
- const result = await this.modelDetection.detectModels({ profile });
1432
- this._audit('profile-detect-models', { profileId: profile.id, count: result.models.length, sources: Object.keys(result.sources) });
1433
- return result;
1434
- }
1435
-
1436
- async _runProfileTest(profile, profileId) {
1437
- const id = cleanId(profileId, 'profileId');
1438
- const contextId = `profile-test-${Date.now().toString(36)}`;
1439
- const startedAt = Date.now();
1440
- const started = this.runner.startChatSession({
1441
- slug: contextId, projectPath: this.testWorkspace, kbPath: this.testWorkspace,
1442
- aiProfile: profile, permissionMode: 'default',
1443
- });
1444
- let firstEventMs = null;
1445
- let preview = '';
1446
- let unsubscribe = null;
1447
- try {
1448
- const outcome = await new Promise((resolve, reject) => {
1449
- const timeout = setTimeout(() => reject(new Error('Claude Code profile test timed out')), Math.min(profile.timeoutMs, 300000));
1450
- unsubscribe = this.runner.subscribe(started.sessionId, event => {
1451
- if (firstEventMs == null && event.type !== 'claude/session-ready') firstEventMs = Date.now() - startedAt;
1452
- if (event.type === 'claude/result') {
1453
- clearTimeout(timeout);
1454
- if (event.isError) {
1455
- reject(new Error(String(event.result || event.message || event.error || 'Claude Code returned an error result')));
1456
- } else {
1457
- preview = String(event.result || event.text || '').slice(0, 500);
1458
- resolve(event);
1459
- }
1460
- } else if (event.type === 'claude/error') {
1461
- clearTimeout(timeout); reject(new Error(String(event.message || event.error || 'Claude Code runtime error')));
1462
- } else if (event.type === 'claude/aborted') {
1463
- clearTimeout(timeout); reject(new Error('Claude Code profile test was aborted'));
1464
- }
1465
- });
1466
- this.runner.sendInput(started.sessionId, 'Reply with exactly: WORKBENCH_RUNTIME_OK', profile, { permissionMode: 'default' }).catch(error => {
1467
- clearTimeout(timeout); reject(error);
1468
- });
1469
- });
1470
- const state = this.runner.getState(started.sessionId);
1471
- return {
1472
- ok: true, runtime: 'claude-code', runtimeVerified: true, profileId: id,
1473
- model: outcome.model || state && state.model || profile.models.default,
1474
- firstEventMs: firstEventMs == null ? Date.now() - startedAt : firstEventMs,
1475
- durationMs: Date.now() - startedAt, resultPreview: redact(preview),
1476
- };
1477
- } finally {
1478
- try { unsubscribe && unsubscribe(); } catch {}
1479
- try { this.runner.abort(started.sessionId); } catch {}
1480
- try { this.runner.deleteSession(started.sessionId); } catch {}
1481
- }
1482
- }
1483
-
1484
- destroy() {
1485
- if (this.destroyPromise) return this.destroyPromise;
1486
- this.destroyed = true;
1487
- for (const request of this.pendingInputs.values()) request.aborted = true;
1488
- this.sessionChangeListeners.clear();
1489
- this.agentDiscovery?.close?.();
1490
- this.conversationCapture?.close();
1491
- if (this.archiveSweeper) { clearInterval(this.archiveSweeper); this.archiveSweeper = null; }
1492
- for (const sessionId of this.turnMonitors.keys()) this._clearTurnMonitor(sessionId);
1493
- for (const session of this.runner.listSessions({})) {
1494
- if (ACTIVE_STATES.has(session.state)) try { this.runner.abort(session.sessionId); } catch {}
1495
- }
1496
- this.destroyPromise = Promise.resolve(this.runner.destroy?.()).finally(() => {
1497
- this.sessionChangeListeners.clear();
1498
- for (const { history, unsubscribe } of this.histories.values()) { unsubscribe(); history.close(); }
1499
- this.histories.clear();
1500
- // Closing the Service unbinds only its OWN committer bindings: the
1501
- // runner marks those runtimes non-sticky unavailable (rebindable by a
1502
- // successor), while bindings owned by another instance are refused
1503
- // there and stay untouched.
1504
- for (const sessionId of [...this.nativeCommitters.keys()]) this._detachNativeTasks(sessionId);
1505
- });
1506
- return this.destroyPromise;
1507
- }
1508
-
1509
- _runtimeProfile(profileId, selectedModel) {
1510
- const profile = profileId ? this.profiles.runtimeProfile(profileId)
1511
- : { id: null, models: { default: '' }, timeoutMs: 300000 };
1512
- const model = selectedModel == null ? profile.models.default : String(selectedModel).trim();
1513
- if (model.length > 128 || /[\r\n\x00]/.test(model)) throw Object.assign(new Error('model is invalid'), { status: 400 });
1514
- return { ...profile, models: { ...profile.models, default: model }, mainModel: model };
1515
- }
1516
-
1517
- _profileForSession(id, state) {
1518
- const session = this.runner.getSession(id);
1519
- const model = session && Object.prototype.hasOwnProperty.call(session, 'selectedModel')
1520
- ? session.selectedModel : state.selectedModel || state.model;
1521
- return this._runtimeProfile(state.aiProfileId, model);
1522
- }
1523
-
1524
- _summary(session, active) {
1525
- const agentId = session.agentId
1526
- || (typeof this.runner.agentOf === 'function' ? this.runner.agentOf(session.sessionId) : null)
1527
- || this.sessionAgents.get(session.sessionId)
1528
- || 'claude-code';
1529
- return {
1530
- sessionId: session.sessionId,
1531
- contextId: session.projectSlug,
1532
- projectId: session.projectSlug,
1533
- agentId,
1534
- title: session.title || `Session ${String(session.sessionId).slice(-8)}`,
1535
- active: Boolean(active), state: session.state, model: session.model || null,
1536
- aiProfileId: session.aiProfileId || null, permissionMode: session.permissionMode || 'default',
1537
- selectedModel: session.selectedModel || this.runner.getSession(session.sessionId)?.selectedModel || null,
1538
- authSource: session.aiProfileId ? 'profile-injected' : 'agent-owned',
1539
- providerName: session.aiProfileId ? this.profiles.get(session.aiProfileId)?.name || 'Unavailable provider' : AGENTS[agentId].label,
1540
- archived: Boolean(this.sessionArchive && this.sessionArchive.isArchived(session.sessionId)),
1541
- turns: Number(session.turns || 0), startedAt: session.startedAt,
1542
- tasks: Array.isArray(session.tasks) ? session.tasks : [],
1543
- backgroundTaskCount: Number(session.backgroundTaskCount || 0),
1544
- // W-1: prefer the driver's live activity marker (refreshed at turn
1545
- // end); endedAt/startAt only predate it for legacy records.
1546
- updatedAt: session.updatedAt || session.endedAt || session.startedAt, connectionState: 'connected',
1547
- };
1548
- }
1549
-
1550
- // Approximate context occupancy for the capacity bubble. Token totals come
1551
- // from the driver; the per-category split is a characters/4 estimate over
1552
- // the session event log and is explicitly flagged as such.
1553
- contextUsage(sessionId) {
1554
- const id = cleanId(sessionId, 'sessionId');
1555
- const state = this.runner.getState(id);
1556
- if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1557
- const usage = this.runner.getSessionTokenUsage(id) || {};
1558
- const contextWindow = this._contextWindowFor(state.model, this._sessionProfileContextWindow(id, state));
1559
- const breakdown = this._estimateBreakdown(id);
1560
- const used = Number(usage.used || usage.inputTokens || 0) || 0;
1561
- return {
1562
- sessionId: id,
1563
- agentId: this._summary(state, false).agentId,
1564
- model: state.model || null,
1565
- contextWindow,
1566
- used,
1567
- percent: contextWindow ? Math.round((used / contextWindow) * 1000) / 10 : null,
1568
- inputTokens: Number(usage.inputTokens || 0) || 0,
1569
- outputTokens: Number(usage.outputTokens || 0) || 0,
1570
- breakdown,
1571
- estimated: true,
1572
- };
1573
- }
1574
-
1575
- _sessionProfileContextWindow(sessionId, state) {
1576
- try {
1577
- const profile = this._profileForSession(sessionId, state);
1578
- return profile ? Number(profile.contextWindow) || 0 : 0;
1579
- } catch { return 0; }
1580
- }
1581
-
1582
- _contextWindowFor(model, profileContextWindow) {
1583
- if (!model && !profileContextWindow) return 200000;
1584
- if (this.modelContextWindows && typeof this.modelContextWindows.resolveContextWindow === 'function') {
1585
- try {
1586
- // The profile store defaults contextWindow to 200000; that default must
1587
- // not shadow the recognition table (e.g. MiniMax → 1M). Only an explicit
1588
- // non-default override wins over auto-detection.
1589
- const explicit = Number(profileContextWindow) > 0 && Number(profileContextWindow) !== 200000 ? Number(profileContextWindow) : undefined;
1590
- const resolved = this.modelContextWindows.resolveContextWindow({ profileContextWindow: explicit, model, fallback: 200000 });
1591
- if (resolved) return resolved;
1592
- } catch { /* fall through to the default */ }
1593
- }
1594
- return Number(profileContextWindow) > 0 ? Number(profileContextWindow) : 200000;
1595
- }
1596
-
1597
- _estimateBreakdown(sessionId) {
1598
- const session = this.runner.getSession(sessionId);
1599
- const buckets = { messages: 0, tools: 0, other: 0 };
1600
- if (!session || !Array.isArray(session.outputBuffer)) return buckets;
1601
- for (const event of session.outputBuffer) {
1602
- const text = String(event.text || event.result || event.output || '');
1603
- const tokens = Math.ceil(text.length / 4);
1604
- if (event.type === 'claude/user-prompt' || event.type === 'claude/text-delta') buckets.messages += tokens;
1605
- else if (event.type === 'claude/tool-use' || event.type === 'claude/tool-result') buckets.tools += tokens;
1606
- else buckets.other += tokens;
1607
- }
1608
- return buckets;
1609
- }
1610
-
1611
- _activeCount(sessions = null) { const list = Array.isArray(sessions) ? sessions : this.runner.listSessions({}); const active = new Set(list.filter(item => ACTIVE_STATES.has(item.state) || item.backgroundTaskCount > 0).map(item => item.sessionId)); return active.size + [...this.pendingInputs.keys()].filter(id => !active.has(id)).length; }
1612
- _capture(turn, eventType, content, outcome) {
1613
- try { this.conversationCapture.enqueue(turn, eventType, content, outcome); }
1614
- catch { this._audit('capture-gap', { sessionId: turn.sessionId, code: 'CAPTURE_QUEUE_UNAVAILABLE' }); }
1615
- }
1616
- _watchTurn(sessionId, previousEventCount, captureTurn) {
1617
- this._clearTurnMonitor(sessionId);
1618
- let seen = 0;
1619
- let unsubscribe = null;
1620
- let completed = false;
1621
- let responseText = '';
1622
- let released = false;
1623
- // The monitor owns capture and failure diagnostics on its own; there is no
1624
- // whole-task timer anymore, so keeping it must not depend on one.
1625
- const release = () => {
1626
- if (released) return;
1627
- released = true;
1628
- if (unsubscribe) { try { unsubscribe(); } catch {} }
1629
- this.turnMonitors.delete(sessionId);
1630
- };
1631
- unsubscribe = this.runner.subscribe(sessionId, event => {
1632
- if (released) return;
1633
- seen += 1;
1634
- if (seen <= previousEventCount) return;
1635
- if (event?.parentToolUseId) return;
1636
- if (event?.type === 'claude/text-delta') responseText += String(event.text || '');
1637
- if (['claude/result', 'claude/turn-end', 'claude/error', 'claude/aborted'].includes(event && event.type)) {
1638
- if (event.type === 'claude/error' || event.type === 'claude/result' && event.isError) this._runtimeFailure(sessionId, new Error(String(event.message || event.error || event.result || 'Agent 运行失败。')));
1639
- if (captureTurn && !completed) {
1640
- completed = true;
1641
- if (event.type === 'claude/result' && !event.isError && typeof event.result === 'string' && event.result) this._capture(captureTurn, 'assistant_response', event.result);
1642
- if (event.type === 'claude/turn-end' && !event.exitCode && responseText) this._capture(captureTurn, 'assistant_response', responseText);
1643
- this._capture(captureTurn, 'session_end', undefined, event.type === 'claude/result' && !event.isError || event.type === 'claude/turn-end' && !event.exitCode ? 'completed' : 'failed');
1644
- }
1645
- release();
1646
- }
1647
- });
1648
- // The synchronous replay may deliver the terminal event while
1649
- // `unsubscribe` is still unassigned, so release() could not detach yet.
1650
- if (released) { try { unsubscribe(); } catch {} }
1651
- else this.turnMonitors.set(sessionId, unsubscribe);
1652
- }
1653
- _clearTurnMonitor(sessionId) {
1654
- const unsubscribe = this.turnMonitors.get(sessionId);
1655
- if (unsubscribe) try { unsubscribe(); } catch {}
1656
- this.turnMonitors.delete(sessionId);
1657
- }
1658
- _runtimeFailure(sessionId, error) {
1659
- const state = this.runner.getState(sessionId);
1660
- this.diagnostics?.error('agent.runtime.failed', 'Agent 会话运行失败。', { projectId: /^project-[A-Za-z0-9._-]+$/.test(state?.projectSlug || '') ? state.projectSlug : undefined, runId: sessionId, phase: 'runtime', error, context: { agent: this.runner.agentOf?.(sessionId) || this.sessionAgents.get(sessionId) || state?.agentId || 'claude-code', sessionId } });
1661
- }
1662
- _audit(action, detail) {
1663
- if (!this.auditFile) return;
1664
- fs.mkdirSync(path.dirname(this.auditFile), { recursive: true });
1665
- fs.appendFileSync(this.auditFile, `${JSON.stringify({ at: new Date().toISOString(), action, ...detail })}\n`, 'utf8');
1666
- }
1667
- }
1668
-
1669
- function redact(value) {
1670
- return String(value || '').replace(/(?:sk|key|token)-[a-zA-Z0-9._-]{8,}/gi, '[REDACTED]');
1671
- }
1672
-
1673
- function normalizeInputOrigin(value) { return ['human', 'agent-delegation'].includes(value) ? value : 'unknown'; }
1674
-
1675
- module.exports = { WorkbenchService, ACTIVE_STATES, redact };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('node:crypto');
6
+ const {
7
+ normalizeContext, normalizeAttachments, normalizeAgentId, normalizeEffort, cleanId, PERMISSION_MODES, AGENTS,
8
+ } = require('../../contracts');
9
+ const { snapshotEvents, subscribeNormalized } = require('./event-stream');
10
+ const { SessionHistory } = require('./session-history');
11
+ const { parentContext } = require('./parent-context');
12
+ const PARENT_CONTEXT = Symbol('trusted parent conversation snapshot');
13
+ const { recoverClaudeHistory } = require('./claude-history-recovery');
14
+ // The history reader and task identity load from the same legacy runtime
15
+ // tree as every other runtime module: loadLegacyRuntime binds to the owning
16
+ // distribution, so a packaged server loads them from its own legacy tree and
17
+ // a missing file fails the public entry instead of borrowing source code.
18
+ const { loadLegacyRuntime } = require('./runtime-loader');
19
+ const { createNativeTaskHistoryReader } = loadLegacyRuntime('native-task-history-reader');
20
+ const { createNativeTaskRef } = loadLegacyRuntime('task-identity');
21
+
22
+ const ACTIVE_STATES = new Set(['created', 'spawning', 'running', 'thinking', 'tool-running', 'pending-permission']);
23
+
24
+ // Native fact rows are durable-by-contract: they go through the receipt
25
+ // path (write + fsync) so nothing is ever built on facts that still live
26
+ // only in memory. Plain chat events keep the batched append path.
27
+ const NATIVE_FACT_TYPES = new Set(['claude/native-trace', 'claude/native-task-state', 'claude/native-task-transcript']);
28
+
29
+ const DELTA_TYPES = new Set(['claude/text-delta', 'claude/thinking-delta']);
30
+
31
+ // The same native fact as the committed row, ignoring fields that
32
+ // legitimately differ between a journaled row and an in-memory replay of it.
33
+ function canonicalNativeFact(event) {
34
+ const ordered = {};
35
+ for (const key of Object.keys(event || {}).sort()) {
36
+ if (key === 'at' || key === 'sequence' || key === 'sessionId' || key === 'contextId') continue;
37
+ ordered[key] = event[key];
38
+ }
39
+ return JSON.stringify(ordered);
40
+ }
41
+
42
+ // The earliest buffered same-id delta sequence a merged durable row is
43
+ // proven to cover. Rows written since compaction kept provenance carry
44
+ // compactFrom and are trusted as-is; older archives only admit the coverage
45
+ // reading when a sequence gap exists AND the buffer's own fragments form a
46
+ // strict prefix of the merged text — otherwise the row is a continuation
47
+ // delta and must be appended, not substituted.
48
+ function compactionCoverage(buffered, durable, lastBufferedSequence) {
49
+ const declared = Number(durable && durable.compactFrom);
50
+ if (Number.isSafeInteger(declared) && declared > 0) return declared;
51
+ const sequence = Number(durable && durable.sequence);
52
+ if (!Number.isSafeInteger(sequence) || sequence <= 0) return null;
53
+ if (!Number.isFinite(lastBufferedSequence) || sequence <= lastBufferedSequence + 1) return null;
54
+ let prefix = '';
55
+ let earliest = null;
56
+ for (const event of buffered) {
57
+ if (!event || event.type !== durable.type || event.id !== durable.id) continue;
58
+ const own = Number(event.sequence);
59
+ if (!Number.isSafeInteger(own) || own <= 0 || own >= sequence) continue;
60
+ prefix += String(event.text || '');
61
+ if (earliest === null || own < earliest) earliest = own;
62
+ }
63
+ if (earliest === null || prefix.length === 0) return null;
64
+ const text = String(durable.text || '');
65
+ return text.length > prefix.length && text.startsWith(prefix) ? earliest : null;
66
+ }
67
+
68
+ // The health identity accepts only a canonical persisted store id; nothing
69
+ // volatile (port, PID, epoch) or malformed may pose as a store scope.
70
+ const STORE_SCOPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
71
+
72
+ class WorkbenchService {
73
+ constructor(options) {
74
+ this.runner = options.runner;
75
+ this.diagnostics = options.diagnostics || null;
76
+ this.knowledge = options.knowledge || null;
77
+ this.pendingInputs = new Map();
78
+ this.knowledgeBySession = new Map();
79
+ this.profiles = options.profiles;
80
+ this.contexts = options.contexts;
81
+ this.attachments = options.attachments;
82
+ this.projects = options.projects || null;
83
+ this.sessionArchive = options.sessionArchive || null;
84
+ this.uiPreferences = options.uiPreferences || null;
85
+ this.captureSettings = options.captureSettings || null;
86
+ this.conversationCapture = options.conversationCapture || null;
87
+ this.onCaptureSettingsSaved = options.onCaptureSettingsSaved || null;
88
+ this.agentDiscovery = options.agentDiscovery || null;
89
+ this.resolveRuntimeExecutable = options.resolveRuntimeExecutable || null;
90
+ this.modelContextWindows = options.modelContextWindows || null;
91
+ this.modelDetection = options.modelDetection || null;
92
+ // Trusted delegation stores: the immutable reservation ledger plus the
93
+ // write-once phase/claim journal. Null when the host wired none — the
94
+ // delegation APIs then answer explicit unavailability instead of
95
+ // pretending to run commands.
96
+ this.delegations = options.delegations || null;
97
+ // Per-service runtime generation: fresh for every WorkbenchService
98
+ // instance, constant for its lifetime, never persisted anywhere.
99
+ this.runtimeEpoch = crypto.randomUUID();
100
+ const injectedIdentity = options.storeIdentity || null;
101
+ const storeScope = injectedIdentity && injectedIdentity.available === true && typeof injectedIdentity.storeScope === 'string'
102
+ && STORE_SCOPE_PATTERN.test(injectedIdentity.storeScope)
103
+ ? injectedIdentity.storeScope
104
+ : null;
105
+ this.taskIdentity = Object.freeze({
106
+ storeScope,
107
+ runtimeEpoch: this.runtimeEpoch,
108
+ available: storeScope !== null,
109
+ errorCode: !storeScope && injectedIdentity ? injectedIdentity.errorCode || 'STORE_SCOPE_UNAVAILABLE' : null,
110
+ });
111
+ this.apiPrefix = String(options.apiPrefix || '/api/claude-workbench/v1').replace(/\/$/, '');
112
+ // Read from the synced manifest — a hardcoded literal here shipped 0.3.0
113
+ // in every /health response long after the actual release moved on.
114
+ this.version = options.version || require('../package.json').version;
115
+ this.maxSessions = Number(options.maxSessions || 50);
116
+ this.maxConcurrentRuns = Number(options.maxConcurrentRuns || 4);
117
+ this.testWorkspace = path.resolve(options.testWorkspace);
118
+ this.auditFile = options.auditFile;
119
+ this.historyRoot = options.historyRoot || path.join(path.dirname(this.auditFile || this.testWorkspace), 'session-history');
120
+ this.histories = new Map();
121
+ // Per-session native-fact committer closures, cached so every attach
122
+ // passes the SAME function reference: the runner's binding contract
123
+ // treats a different reference under the same identity as an owner
124
+ // conflict, and a naive closure-per-call would break idempotence.
125
+ this.nativeCommitters = new Map();
126
+ this.turnMonitors = new Map();
127
+ this.sessionAgents = new Map();
128
+ this.sessionRevision = 0;
129
+ this.sessionChangeLog = [];
130
+ this.sessionChangeListeners = new Set();
131
+ this.sessionSignatures = new Map();
132
+ const archiveRetentionDays = options.archiveRetentionDays === undefined ? 7 : Number(options.archiveRetentionDays);
133
+ this.archiveRetentionMs = Number.isFinite(archiveRetentionDays) && archiveRetentionDays > 0
134
+ ? archiveRetentionDays * 24 * 60 * 60 * 1000
135
+ : 0;
136
+ this.archiveSweeper = null;
137
+ // Archived sessions past the retention window are purged on boot and
138
+ // once a day for the lifetime of the process.
139
+ if (this.sessionArchive && this.archiveRetentionMs > 0) {
140
+ this.purgeExpiredArchivedSessions();
141
+ this.archiveSweeper = setInterval(() => {
142
+ try { this.purgeExpiredArchivedSessions(); } catch { /* retried next sweep */ }
143
+ }, Number(options.archiveSweepIntervalMs || 24 * 60 * 60 * 1000));
144
+ if (typeof this.archiveSweeper.unref === 'function') this.archiveSweeper.unref();
145
+ }
146
+ fs.mkdirSync(this.testWorkspace, { recursive: true });
147
+ }
148
+
149
+ health() {
150
+ // One observation point: both counters must come from the same
151
+ // listSessions snapshot, or a mid-mutation probe disagrees with itself
152
+ // and pays the full store scan twice.
153
+ const sessions = this.runner.listSessions({});
154
+ return {
155
+ status: 'ok', version: this.version, apiVersion: 'v1',
156
+ activeProcesses: this._activeCount(sessions), sessions: sessions.length,
157
+ time: new Date().toISOString(),
158
+ taskIdentity: { ...this.taskIdentity },
159
+ ...(this.conversationCapture ? { conversationCapture: { ...this.conversationCapture.status(), enabled: this.captureSettings?.read().terminalConversations === true } } : {}),
160
+ };
161
+ }
162
+
163
+ async runtime() {
164
+ let executable = null;
165
+ try { executable = await (this.resolveRuntimeExecutable ? this.resolveRuntimeExecutable() : this.runner.findClaudeExecutableForSdk()); } catch {}
166
+ const command = executable && (executable.cmd || executable);
167
+ return { runtime: 'claude-code', available: Boolean(command), executable: command ? path.basename(String(command)) : null, transport: 'agent-sdk' };
168
+ }
169
+
170
+ // Live availability probing when a discovery provider is wired in,
171
+ // otherwise fall back to what the injected runner can see.
172
+ listAgents() {
173
+ if (this.agentDiscovery) {
174
+ return { agents: Object.values(this.agentDiscovery()), scan: this.agentDiscovery.snapshot?.() };
175
+ }
176
+ let claudeAvailable = false;
177
+ try { const executable = this.runner.findClaudeExecutableForSdk(); claudeAvailable = Boolean(typeof executable === 'string' ? executable : executable?.cmd); } catch {}
178
+ return {
179
+ agents: Object.keys(AGENTS).map(id => {
180
+ const agent = AGENTS[id];
181
+ return {
182
+ id: agent.id, label: agent.label, transport: agent.transport,
183
+ authModes: [...agent.authModes],
184
+ capabilities: { ...agent.capabilities, generationParams: [...agent.capabilities.generationParams] },
185
+ available: id === 'claude-code' ? claudeAvailable : false,
186
+ version: null, reason: '',
187
+ };
188
+ }),
189
+ };
190
+ }
191
+
192
+ refreshAgents() {
193
+ if (this.agentDiscovery?.refresh) void this.agentDiscovery.refresh().catch(() => {});
194
+ return this.listAgents();
195
+ }
196
+
197
+ listProfiles() { return { profiles: this.profiles.list() }; }
198
+ getProfile(id) { return this.profiles.get(id); }
199
+ saveProfile(input, id) { return this.profiles.save(input, id); }
200
+ deleteProfile(id) { return this.profiles.delete(id); }
201
+ setCredential(id, credential) { return this.profiles.setCredential(id, credential); }
202
+
203
+ getCaptureSettings() {
204
+ if (!this.captureSettings) return { schema: 'capture-settings/v1', terminalConversations: false };
205
+ return this.captureSettings.read();
206
+ }
207
+
208
+ saveCaptureSettings(input = {}) {
209
+ if (!this.captureSettings) {
210
+ throw Object.assign(new Error('capture settings are not wired into this service'), { status: 501 });
211
+ }
212
+ const saved = this.captureSettings.save(input);
213
+ // Push the new scope into the driver immediately: sessions started after
214
+ // this call carry the matching AI_CODING_EVENT_BRIDGE_CAPTURE env.
215
+ if (typeof this.onCaptureSettingsSaved === 'function') this.onCaptureSettingsSaved(saved.terminalConversations);
216
+ return saved;
217
+ }
218
+
219
+ // ---- projects ----
220
+ listProjects() {
221
+ this._requireProjects();
222
+ return { projects: this.projects.list() };
223
+ }
224
+
225
+ getProject(id) {
226
+ this._requireProjects();
227
+ const projectId = cleanId(id, 'projectId');
228
+ const project = this.projects.get(projectId);
229
+ if (!project) throw Object.assign(new Error('project not found'), { status: 404 });
230
+ return project;
231
+ }
232
+
233
+ createProject(input = {}) {
234
+ this._requireProjects();
235
+ const project = this.projects.create(input);
236
+ this.contexts.register(project.id, project.rootPath);
237
+ this._audit('project-create', { projectId: project.id, rootPath: project.rootPath });
238
+ return project;
239
+ }
240
+
241
+ renameProject(id, input = {}) {
242
+ this._requireProjects();
243
+ const projectId = cleanId(id, 'projectId');
244
+ if (input.defaultAiProfileId) this.profiles.runtimeProfile(input.defaultAiProfileId);
245
+ const project = this.projects.update(projectId, input);
246
+ this._audit('project-rename', { projectId, name: project.name });
247
+ return project;
248
+ }
249
+
250
+ removeProject(id, options = {}) {
251
+ this._requireProjects();
252
+ const projectId = cleanId(id, 'projectId');
253
+ if (!this.projects.get(projectId)) throw Object.assign(new Error('project not found'), { status: 404 });
254
+ const keepSessions = options.keepSessions === true;
255
+ for (const session of this.runner.listSessions({ projectSlug: projectId })) {
256
+ if (ACTIVE_STATES.has(session.state) || session.backgroundTaskCount > 0) try { this.runner.abort(session.sessionId); } catch {}
257
+ if (!keepSessions) {
258
+ let deleted = false;
259
+ if (typeof this.runner.deleteSession === 'function') {
260
+ try { deleted = this.runner.deleteSession(session.sessionId) !== false; } catch {}
261
+ }
262
+ this.sessionArchive && this.sessionArchive.forget(session.sessionId);
263
+ this.knowledge?.forget(session.sessionId);
264
+ if (deleted) { this._removeHistory(session.sessionId); this._notifySessionDeleted(session); }
265
+ }
266
+ this.sessionAgents.delete(session.sessionId);
267
+ }
268
+ this.projects.remove(projectId);
269
+ this.contexts.unregister && this.contexts.unregister(projectId);
270
+ this._audit('project-remove', { projectId, keepSessions });
271
+ return true;
272
+ }
273
+
274
+ _requireProjects() {
275
+ if (!this.projects) throw Object.assign(new Error('project workspace support is not enabled'), { status: 501 });
276
+ }
277
+
278
+ // ---- session archive ----
279
+ archiveSession(sessionId) {
280
+ this._requireArchive();
281
+ const id = cleanId(sessionId, 'sessionId');
282
+ if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
283
+ const record = this.sessionArchive.archive(id);
284
+ this._notifySessionChange(id);
285
+ this._audit('session-archive', { sessionId: id });
286
+ return { sessionId: id, ...record };
287
+ }
288
+
289
+ restoreSession(sessionId) {
290
+ this._requireArchive();
291
+ const id = cleanId(sessionId, 'sessionId');
292
+ if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
293
+ const restored = this.sessionArchive.restore(id);
294
+ this._notifySessionChange(id);
295
+ this._audit('session-restore', { sessionId: id });
296
+ return { sessionId: id, restored };
297
+ }
298
+
299
+ // Archived sessions never restored within the retention window are deleted
300
+ // outright: the runner record goes first (best effort) and the archive mark
301
+ // only once the record is gone — a failed delete keeps the session hidden
302
+ // and retries on the next sweep instead of resurfacing unarchived.
303
+ purgeExpiredArchivedSessions() {
304
+ if (!this.sessionArchive || !(this.archiveRetentionMs > 0)) return [];
305
+ const purged = [];
306
+ for (const sessionId of this.sessionArchive.expired(this.archiveRetentionMs)) {
307
+ const previous = this.runner.getState(sessionId);
308
+ let deleted = true;
309
+ if (typeof this.runner.deleteSession === 'function') {
310
+ try { this.runner.deleteSession(sessionId); } catch { deleted = false; }
311
+ }
312
+ this.sessionAgents.delete(sessionId);
313
+ if (deleted) {
314
+ if (previous) this._notifySessionDeleted(previous);
315
+ this._removeHistory(sessionId);
316
+ this.knowledge?.forget(sessionId);
317
+ this.sessionArchive.forget(sessionId);
318
+ purged.push(sessionId);
319
+ this._audit('archive-expired', { sessionId });
320
+ }
321
+ }
322
+ return purged;
323
+ }
324
+
325
+ getUiPreferences() {
326
+ if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
327
+ return this.uiPreferences.read();
328
+ }
329
+
330
+ saveUiPreferences(input = {}) {
331
+ if (!this.uiPreferences) throw Object.assign(new Error('ui preferences support is not enabled'), { status: 501 });
332
+ return this.uiPreferences.save(input);
333
+ }
334
+
335
+ _requireArchive() {
336
+ if (!this.sessionArchive) throw Object.assign(new Error('session archive support is not enabled'), { status: 501 });
337
+ }
338
+
339
+ listSessions(contextId, options = {}) {
340
+ const id = cleanId(contextId, 'contextId');
341
+ const includeArchived = Boolean(options.includeArchived);
342
+ const all = this.runner.listSessions({ projectSlug: id });
343
+ const visible = includeArchived || !this.sessionArchive
344
+ ? all
345
+ : all.filter(session => !this.sessionArchive.isArchived(session.sessionId));
346
+ return visible.map((session, index) => this._summary(session, index === 0));
347
+ }
348
+
349
+ startSession(contextInput, aiProfileId, options = {}) {
350
+ if (this.destroyed) throw Object.assign(new Error('workbench is shut down'), { status: 503 });
351
+ const context = normalizeContext(contextInput);
352
+ if (this.runner.listSessions({}).length >= this.maxSessions) {
353
+ throw Object.assign(new Error('session limit reached'), { status: 429 });
354
+ }
355
+ const resolved = this.contexts.resolve(context);
356
+ const project = this.projects && this.projects.get(context.workspaceRef);
357
+ const agentId = normalizeAgentId(options.agentId || context.agentId || project && project.defaultAgentId);
358
+ const effort = normalizeEffort(options.effort);
359
+ // null explicitly selects the agent's login; omission inherits the project.
360
+ const profileChoice = aiProfileId !== undefined ? aiProfileId
361
+ : context.aiProfileId !== undefined ? context.aiProfileId : project && project.defaultAiProfileId;
362
+ const profileId = profileChoice ? cleanId(profileChoice, 'aiProfileId') : null;
363
+ const inheritsProject = aiProfileId === undefined && context.aiProfileId === undefined;
364
+ const selectedModel = options.model !== undefined ? options.model
365
+ : context.model !== undefined ? context.model : inheritsProject && project ? project.defaultModel : undefined;
366
+ const profile = this._runtimeProfile(profileId, selectedModel);
367
+ const started = this.runner.startChatSession({
368
+ slug: context.contextId,
369
+ projectPath: resolved.workspacePath,
370
+ kbPath: resolved.workspacePath,
371
+ // Persisted so later delegations can prove this session's trusted
372
+ // workspace without guessing from the current UI project.
373
+ workspaceRef: context.workspaceRef,
374
+ aiProfile: profile,
375
+ permissionMode: options.permissionMode || profile.permissionMode || 'default',
376
+ agentId,
377
+ effort,
378
+ });
379
+ // CLI drivers recycle numeric ids after a restart; a fresh session must
380
+ // not inherit the archived flag of an unrelated predecessor that happened
381
+ // to hold the same id.
382
+ if (this.sessionArchive && this.sessionArchive.isArchived(started.sessionId)) {
383
+ this.sessionArchive.forget(started.sessionId);
384
+ }
385
+ this.sessionAgents.set(started.sessionId, agentId);
386
+ // The native-task scope is bound from trusted service identity at
387
+ // session creation, before any input can arrive.
388
+ this._attachNativeTasks(started.sessionId);
389
+ const session = this.runner.getSession(started.sessionId);
390
+ if (session) {
391
+ session.selectedModel = profile.mainModel || null;
392
+ session.metadata = { ...(session.metadata || {}), inputOrigin: normalizeInputOrigin(options.inputOrigin) };
393
+ }
394
+ this._trackHistory(started.sessionId);
395
+ this._notifySessionChange(started.sessionId);
396
+ this._audit('session-start', { sessionId: started.sessionId, contextId: context.contextId, profileId, agentId });
397
+ return this._summary(this.runner.getState(started.sessionId), true);
398
+ }
399
+
400
+ loadSession(sessionId) {
401
+ const id = cleanId(sessionId, 'sessionId');
402
+ const state = this.runner.getState(id);
403
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
404
+ // An existing session binds its fixed native-task scope on first Service
405
+ // attach; it can never switch to a different one afterwards.
406
+ this._attachNativeTasks(id);
407
+ const usage = this.runner.getSessionTokenUsage(id);
408
+ return {
409
+ session: this._summary(state, true),
410
+ events: snapshotEvents(this.runner, id),
411
+ permission: state.pendingPermission || null,
412
+ permissionMode: state.permissionMode || 'default',
413
+ tokenUsage: { ...usage, hasUsage: Boolean(usage && (usage.used || usage.inputTokens || usage.outputTokens)) },
414
+ };
415
+ }
416
+
417
+ // Trusted-scope injection: the store scope, the runtime epoch and the
418
+ // durable-fact committer come from the frozen service task identity only -
419
+ // browser input and session options never reach them. Without a usable
420
+ // store identity the native-task feature stays explicitly unavailable
421
+ // while chat and history continue unchanged.
422
+ _attachNativeTasks(sessionId) {
423
+ if (!sessionId || !this.taskIdentity.available || !this.taskIdentity.storeScope) return false;
424
+ if (typeof this.runner.attachNativeTaskRuntime !== 'function') return false;
425
+ let committer = this.nativeCommitters.get(sessionId);
426
+ if (!committer) {
427
+ committer = this._nativeFactCommitter(sessionId);
428
+ this.nativeCommitters.set(sessionId, committer);
429
+ }
430
+ try {
431
+ const result = this.runner.attachNativeTaskRuntime(sessionId, {
432
+ storeScope: this.taskIdentity.storeScope,
433
+ runtimeEpoch: this.taskIdentity.runtimeEpoch,
434
+ commitNativeFact: committer,
435
+ });
436
+ return Boolean(result && result.attached);
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
441
+
442
+ // The one trusted synchronous committer the runner's publish gate calls
443
+ // BEFORE a native fact reaches any buffer, listener or persisted
444
+ // snapshot. It goes through the sequence-validated durable receipt path
445
+ // (write + fsync), never a raw file bypass: a duplicate of an
446
+ // already-committed same-sequence fact is an idempotent success, a
447
+ // same-sequence disagreement is an explicit conflict failure, and a
448
+ // history that cannot even be attached is a fixed unavailable phase.
449
+ _nativeFactCommitter(sessionId) {
450
+ return event => {
451
+ try {
452
+ const history = this._history(sessionId);
453
+ const sequence = Number(event && event.sequence);
454
+ if (!Number.isSafeInteger(sequence) || sequence <= 0) {
455
+ return { ok: false, phase: 'sequence', error: 'native fact is not sequence-stamped' };
456
+ }
457
+ if (sequence <= history.highWater) {
458
+ const known = history.durableRow(sequence);
459
+ if (known && canonicalNativeFact(known) === canonicalNativeFact(event)) {
460
+ return { ok: true, phase: 'already-committed', sequence, highWater: history.highWater };
461
+ }
462
+ return {
463
+ ok: false, phase: 'conflict',
464
+ error: `native fact at sequence ${sequence} conflicts with the committed row`,
465
+ highWater: history.highWater,
466
+ };
467
+ }
468
+ return history.appendDurable([event]);
469
+ } catch {
470
+ return { ok: false, phase: 'history-unavailable', error: 'session history is not attachable' };
471
+ }
472
+ };
473
+ }
474
+
475
+ // Unbind this Service's own committer (same epoch + same closure
476
+ // reference); a different or second instance is refused by the runner.
477
+ _detachNativeTasks(sessionId) {
478
+ const committer = this.nativeCommitters.get(sessionId) || null;
479
+ this.nativeCommitters.delete(sessionId);
480
+ if (typeof this.runner.detachNativeTaskRuntime !== 'function') return;
481
+ try {
482
+ this.runner.detachNativeTaskRuntime(sessionId, {
483
+ runtimeEpoch: this.taskIdentity.runtimeEpoch,
484
+ commitNativeFact: committer,
485
+ });
486
+ } catch { /* binding already gone */ }
487
+ }
488
+
489
+ _history(id) {
490
+ if (this.histories.has(id)) return this.histories.get(id).history;
491
+ const session = this.runner.getSession(id);
492
+ const state = this.runner.getState(id);
493
+ if (!session || !state) throw Object.assign(new Error('session not found'), { status: 404 });
494
+ const first = session.outputBuffer?.[0];
495
+ const history = new SessionHistory({ file: this._historyFile(id), sessionId: id, startedAt: state.startedAt,
496
+ contextId: state.projectSlug, incomplete: Number(first?.sequence) > 1
497
+ || Number(session.turns) > (session.outputBuffer || []).filter(event => event.type === 'claude/user-prompt').length,
498
+ onError: error => this.diagnostics?.error('session.history.write.failed', '会话历史保存失败。', { phase: 'history', error: { code: error.code || 'HISTORY_WRITE_FAILED', message: '无法写入会话历史。' }, context: { sessionId: id } }),
499
+ });
500
+ this._reconcileHistory(id, history, session);
501
+ const unsubscribe = this.runner.subscribe(id, event => {
502
+ if (NATIVE_FACT_TYPES.has(event.type)) {
503
+ const sequence = Number(event.sequence);
504
+ if (Number.isSafeInteger(sequence) && sequence > 0 && sequence <= history.highWater) {
505
+ const known = history.durableRow(sequence);
506
+ if (known && canonicalNativeFact(known) === canonicalNativeFact(event)) {
507
+ // Idempotent replay of a committed fact - including the very
508
+ // fact the publish gate's committer just landed: no second
509
+ // write, but the session-change notification must still fire.
510
+ } else {
511
+ // A same-sequence fact is never blindly treated as "already
512
+ // written" and never overwrites the committed row: either the
513
+ // numbered row is missing or it disagrees with the replayed fact,
514
+ // and either way the conflict is an explicit diagnosis.
515
+ this.diagnostics?.error('session.history.durable.conflict', '原生任务事实与持久化历史同序号冲突;拒绝改写,原文件已保留。', {
516
+ phase: 'history', error: { code: 'HISTORY_DURABLE_CONFLICT', message: `native fact at sequence ${sequence} conflicts with the committed row` },
517
+ context: { sessionId: id, sequence, present: Boolean(known) },
518
+ });
519
+ return;
520
+ }
521
+ } else {
522
+ // Fallback for native facts that flowed ungated (a runner without
523
+ // a bound committer): the receipt is diagnosed, never faked.
524
+ const receipt = history.appendDurable([event]);
525
+ if (!receipt.ok) this.diagnostics?.error('session.history.durable.failed', '原生任务事实未能持久化;任务历史不可用,原文件已保留。', {
526
+ phase: 'history', error: { code: `HISTORY_DURABLE_${String(receipt.phase).toUpperCase()}`, message: String(receipt.error || 'durable append failed') },
527
+ context: { sessionId: id, highWater: receipt.highWater, pendingCount: receipt.pendingCount ?? null },
528
+ });
529
+ }
530
+ } else history.append(event);
531
+ this._notifySessionChange(id, event);
532
+ });
533
+ this.histories.set(id, { history, unsubscribe });
534
+ return history;
535
+ }
536
+
537
+ // Forward-only reconciliation between the runner snapshot counter and the
538
+ // durable journal, run once when a session's history is attached and
539
+ // therefore before any new sequence is minted for it. The old behavior
540
+ // reset a newer durable high-water mark back to an older snapshot count,
541
+ // which both silently dropped fresh events (sequence at or below the reset
542
+ // mark) and re-minted sequences the file already contained. Now both
543
+ // sides adopt max(snapshot, durable) and the losing side is repaired or
544
+ // explicitly marked instead of being rewritten: durable-ahead merges the
545
+ // missing durable tail into the replay buffer so subscribers can be
546
+ // served the gap; snapshot-ahead replays the contiguous in-memory prefix
547
+ // through the durable receipt path, and a hole that cannot be
548
+ // reconstructed is recorded as a recovery gap — never faked as complete.
549
+ _reconcileHistory(id, history, session) {
550
+ if (!history.exists || !Number.isFinite(session.eventSequence)) return;
551
+ const buffered = Array.isArray(session.outputBuffer) ? session.outputBuffer : [];
552
+ let bufferedMax = 0;
553
+ for (const event of buffered) {
554
+ const own = Number(event && event.sequence);
555
+ if (Number.isFinite(own) && own > 0 && own > bufferedMax) bufferedMax = own;
556
+ }
557
+ if (history.highWater > session.eventSequence) {
558
+ const tail = history.durableTail({ afterSequence: bufferedMax });
559
+ const lastDeltaSequence = new Map();
560
+ for (let i = buffered.length - 1; i >= 0; i--) {
561
+ const event = buffered[i];
562
+ if (!event || !DELTA_TYPES.has(event.type)) continue;
563
+ const id = event.id ?? null;
564
+ if (!lastDeltaSequence.has(id)) lastDeltaSequence.set(id, Number(event.sequence) || 0);
565
+ }
566
+ for (const event of tail.events) {
567
+ if (event && DELTA_TYPES.has(event.type)) {
568
+ const id = event.id ?? null;
569
+ const coveredFrom = compactionCoverage(buffered, event, lastDeltaSequence.get(id) || 0);
570
+ if (coveredFrom !== null) {
571
+ // A durable compaction subsumes fragments the replay buffer
572
+ // still holds in part: the merged row replaces them instead of
573
+ // concatenating onto them, so no client's cached prefix is
574
+ // duplicated and a cursor below the merged row resyncs
575
+ // explicitly instead of receiving the whole fragment as a delta.
576
+ for (let i = buffered.length - 1; i >= 0; i--) {
577
+ const held = buffered[i];
578
+ if (held && DELTA_TYPES.has(held.type) && held.id === event.id && Number(held.sequence) >= coveredFrom) buffered.splice(i, 1);
579
+ }
580
+ lastDeltaSequence.set(id, Number(event.sequence) || 0);
581
+ } else if (Number.isFinite(Number(event.sequence))) {
582
+ lastDeltaSequence.set(id, Math.max(lastDeltaSequence.get(id) || 0, Number(event.sequence)));
583
+ }
584
+ }
585
+ buffered.push(event);
586
+ while (buffered.length > 5000) buffered.shift();
587
+ }
588
+ session.eventSequence = Math.max(session.eventSequence, history.highWater);
589
+ return;
590
+ }
591
+ if (session.eventSequence > history.highWater) {
592
+ const above = [];
593
+ for (const event of buffered) {
594
+ const own = Number(event && event.sequence);
595
+ if (Number.isFinite(own) && own > history.highWater) above.push([own, event]);
596
+ }
597
+ above.sort((a, b) => a[0] - b[0]);
598
+ const catchUp = [];
599
+ let expected = history.highWater + 1;
600
+ for (const [sequence, event] of above) {
601
+ if (sequence !== expected) break;
602
+ catchUp.push(event);
603
+ expected += 1;
604
+ }
605
+ if (catchUp.length) {
606
+ const receipt = history.appendDurable(catchUp);
607
+ if (!receipt.ok) {
608
+ this.diagnostics?.error('session.history.reconcile.failed', '崩溃边界恢复未能补写会话历史;原文件已保留。', {
609
+ phase: 'history', error: { code: `HISTORY_DURABLE_${String(receipt.phase).toUpperCase()}`, message: String(receipt.error || 'durable append failed') },
610
+ context: { sessionId: id, highWater: receipt.highWater },
611
+ });
612
+ return;
613
+ }
614
+ }
615
+ if (history.highWater < session.eventSequence) {
616
+ // The gap marker is journaled, not kept in RAM: a restart must keep
617
+ // reporting the hole instead of presenting a healed-looking archive.
618
+ // A failed marker receipt is explicit and stays retryable — the
619
+ // next reconcile rewrites it instead of skipping it.
620
+ const recorded = history.recordRecoveryGap(history.highWater + 1, session.eventSequence);
621
+ if (!recorded.ok) {
622
+ this.diagnostics?.error('session.history.recovery.record.failed', '缺口标记未能持久化;原文件已保留,保持可重试。', {
623
+ phase: 'history', error: { code: `HISTORY_DURABLE_${String(recorded.phase).toUpperCase()}`, message: String(recorded.error || 'gap marker write failed') },
624
+ context: { sessionId: id, from: history.highWater + 1, to: session.eventSequence },
625
+ });
626
+ }
627
+ this.diagnostics?.error('session.history.recovery.gap', '会话历史存在无法重建的序号缺口;历史读取将显式标注。', {
628
+ phase: 'history', error: { code: 'HISTORY_RECOVERY_GAP', message: '快照与持久化历史之间存在缺口,不能宣称完整。' },
629
+ context: { sessionId: id, from: history.highWater + 1, to: session.eventSequence },
630
+ });
631
+ }
632
+ }
633
+ }
634
+
635
+ _trackHistory(id) {
636
+ try { return this._history(id); }
637
+ catch (error) {
638
+ this.diagnostics?.error('session.history.read.failed', '会话历史读取失败;实时会话仍可使用。', {
639
+ phase: 'history', error: { code: error.code || 'HISTORY_READ_FAILED', message: '会话历史不可用,原文件已保留。' }, context: { sessionId: id },
640
+ });
641
+ return null;
642
+ }
643
+ }
644
+
645
+ loadHistory(sessionId, options = {}) {
646
+ const id = cleanId(sessionId, 'sessionId');
647
+ try {
648
+ const history = this._history(id);
649
+ const state = this.runner.getState(id);
650
+ if (history.incomplete && !ACTIVE_STATES.has(state.state) && this._summary(state, false).agentId === 'claude-code') {
651
+ const session = this.runner.getSession(id);
652
+ const recovered = recoverClaudeHistory(session);
653
+ if (recovered) history.seedRecovered(recovered, Number(session.eventSequence) || history.highWater);
654
+ }
655
+ return history.read(options);
656
+ } catch (error) {
657
+ if (![400, 404].includes(error.status)) {
658
+ this.diagnostics?.error('session.history.read.failed', '会话历史读取失败,原文件已保留。', {
659
+ phase: 'history', error: { code: error.code || 'HISTORY_READ_FAILED', message: '无法读取完整会话历史。' }, context: { sessionId: id },
660
+ });
661
+ error.status = 503;
662
+ }
663
+ throw error;
664
+ }
665
+ }
666
+
667
+ _removeHistory(id) {
668
+ this.knowledgeBySession.delete(id);
669
+ this._detachNativeTasks(id);
670
+ const record = this.histories.get(id);
671
+ record?.unsubscribe(); record?.history.close();
672
+ this.histories.delete(id);
673
+ try { fs.unlinkSync(this._historyFile(id)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
674
+ }
675
+
676
+ _historyFile(id) { return path.join(this.historyRoot, `${crypto.createHash('sha256').update(id).digest('hex')}.jsonl`); }
677
+
678
+ // ---- native task history (read-only over the session's own journal) ----
679
+
680
+ // The fixed scope every native-task read is bound to: the frozen trusted
681
+ // store identity plus the session's real project and id. Nothing in it is
682
+ // client-derived; the client only ever echoes it back for comparison.
683
+ _nativeTaskScope(state) {
684
+ return { storeScope: this.taskIdentity.storeScope, contextId: state.projectSlug, parentSessionId: state.sessionId };
685
+ }
686
+
687
+ _requireNativeTaskScope() {
688
+ if (!this.taskIdentity.available || !this.taskIdentity.storeScope) {
689
+ throw Object.assign(new Error('native task history is unavailable: the trusted store scope is not configured'), {
690
+ status: 503, code: this.taskIdentity.errorCode || 'STORE_SCOPE_UNAVAILABLE',
691
+ });
692
+ }
693
+ return this.taskIdentity.storeScope;
694
+ }
695
+
696
+ // The reader file always comes from the service's own session-history
697
+ // instance; recovery seeds never run on this path, and a failure keeps the
698
+ // original file and surfaces as an explicit unavailable answer.
699
+ _nativeHistoryFor(id) {
700
+ try {
701
+ return this._history(id);
702
+ } catch (error) {
703
+ if (error && (error.status === 400 || error.status === 404)) throw error;
704
+ throw Object.assign(new Error('native task history is not readable; the original history file is preserved'), {
705
+ status: 503, code: error && error.code ? error.code : 'HISTORY_READ_FAILED',
706
+ });
707
+ }
708
+ }
709
+
710
+ // A journal that has proven a write failure is never served as complete
711
+ // history: the newest facts may still live only in memory, so a page off
712
+ // the disk would silently omit them. Mirrors the SessionHistory read gate
713
+ // with a fixed safe message; the latched code token travels as the code.
714
+ // The same hole exists whenever ANY native fact is still pending behind a
715
+ // durable barrier - a recovery gap whose marker is not yet on disk, or a
716
+ // failed batch still awaiting its close-time marker: while the barrier
717
+ // head exists the native surfaces refuse with one generic pending code
718
+ // instead of serving the journal as complete. Ordinary chat reads are
719
+ // untouched, and a cleared barrier restores these reads.
720
+ _requireDurableJournal(history) {
721
+ if (history.error) {
722
+ throw Object.assign(new Error('会话历史写入失败,原文件已保留;原生任务历史暂不可用。'), { status: 503, code: history.error });
723
+ }
724
+ if (history.durableBarrier) {
725
+ throw Object.assign(new Error('尚有未提交历史事实,原生任务历史暂不可用。'), { status: 503, code: 'HISTORY_FACTS_PENDING' });
726
+ }
727
+ }
728
+
729
+ // The journal may know a recovery gap the on-disk scan can no longer see
730
+ // (the marker row disappeared after attach). The reader's own verdict
731
+ // stays untouched; the service-known hole is merged in, never dropped.
732
+ _nativeGapOverlay(history, page) {
733
+ const gap = history.recoveryGap;
734
+ if (!gap || page.incomplete.includes('history-gap')) return page;
735
+ const items = [{ code: 'service-recovery-gap', source: 'service', from: gap.from, to: gap.to }].concat(page.diagnostics.items);
736
+ const total = page.diagnostics.total + 1;
737
+ return {
738
+ ...page,
739
+ incomplete: [...page.incomplete, 'history-gap'],
740
+ diagnostics: { items: items.slice(0, 20), total, truncated: total > 20 },
741
+ };
742
+ }
743
+
744
+ // GET /sessions/:id/native-tasks - list observed native background tasks
745
+ // for the session out of its durable history. Pure read: no input is
746
+ // dispatched and no live handle is created; task rows are historical
747
+ // observations, never claims that anything is running or controllable.
748
+ async listNativeTasks(sessionId, query = {}) {
749
+ const id = cleanId(sessionId, 'sessionId');
750
+ const state = this.runner.getState(id);
751
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
752
+ const storeScope = this._requireNativeTaskScope();
753
+ if (typeof query.storeScope !== 'string' || query.storeScope === '') {
754
+ throw Object.assign(new Error('storeScope is required'), { status: 400 });
755
+ }
756
+ if (!STORE_SCOPE_PATTERN.test(query.storeScope)) {
757
+ throw Object.assign(new Error('storeScope is invalid'), { status: 400 });
758
+ }
759
+ if (typeof query.contextId !== 'string' || query.contextId === '') {
760
+ throw Object.assign(new Error('contextId is required'), { status: 400 });
761
+ }
762
+ const contextId = cleanId(query.contextId, 'contextId');
763
+ if (query.storeScope !== storeScope || contextId !== state.projectSlug) {
764
+ throw Object.assign(new Error('native task scope does not match this session; resync from the task list'), {
765
+ status: 409, code: 'SCOPE_MISMATCH', resync: true,
766
+ });
767
+ }
768
+ const history = this._nativeHistoryFor(id);
769
+ this._requireDurableJournal(history);
770
+ const scope = this._nativeTaskScope(state);
771
+ const reader = createNativeTaskHistoryReader({ file: history.file, scope });
772
+ let page;
773
+ try { page = await reader.listTasks(); }
774
+ catch (error) { if (error && error.status === 409) error.resync = true; throw error; }
775
+ return { ...this._nativeGapOverlay(history, page), scope: { ...scope } };
776
+ }
777
+
778
+ // POST /sessions/:id/native-tasks/history - page one bound task's events
779
+ // out of the same durable history. Pure read: no dispatch, no stop, no
780
+ // re-minted sequences, no SSE replay.
781
+ async loadNativeTaskHistory(sessionId, input = {}) {
782
+ const id = cleanId(sessionId, 'sessionId');
783
+ const state = this.runner.getState(id);
784
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
785
+ this._requireNativeTaskScope();
786
+ const ref = createNativeTaskRef(input && input.taskRef);
787
+ if (!ref) throw Object.assign(new Error('taskRef is not a valid native-task reference'), { status: 400, code: 'INVALID_TASK_REF' });
788
+ // The whole reference is checked against the real session scope before
789
+ // any journal is constructed or read, so a foreign-scope ref is a scope
790
+ // conflict even when the target file is missing or corrupt.
791
+ const scope = this._nativeTaskScope(state);
792
+ if (ref.storeScope !== scope.storeScope || ref.contextId !== scope.contextId || ref.parentSessionId !== scope.parentSessionId) {
793
+ throw Object.assign(new Error('taskRef scope does not match this session; resync from the task list'), {
794
+ status: 409, code: 'SCOPE_MISMATCH', resync: true,
795
+ });
796
+ }
797
+ const history = this._nativeHistoryFor(id);
798
+ this._requireDurableJournal(history);
799
+ const reader = createNativeTaskHistoryReader({ file: history.file, scope });
800
+ let page;
801
+ try { page = await reader.readTaskEvents(ref, { cursor: input.cursor, limit: input.limit }); }
802
+ catch (error) { if (error && error.status === 409) error.resync = true; throw error; }
803
+ return { ...this._nativeGapOverlay(history, page), taskRef: { ...ref }, scope: { ...scope } };
804
+ }
805
+
806
+ // ---- delegated sessions (trusted Workbench delegation commands) ----
807
+
808
+ _requireDelegations() {
809
+ if (!this.delegations || !this.delegations.ledger || !this.delegations.phases) {
810
+ throw Object.assign(new Error('delegated sessions are unavailable: the delegation ledger is not wired'), {
811
+ status: 503, code: 'DELEGATION_UNAVAILABLE',
812
+ });
813
+ }
814
+ }
815
+
816
+ _delegationSupported(agentId) {
817
+ if (typeof this.runner.supportsDelegatedSessions === 'function') {
818
+ return this.runner.supportsDelegatedSessions(agentId) === true;
819
+ }
820
+ return typeof this.runner.startDelegatedSession === 'function';
821
+ }
822
+
823
+ // The trusted workspace a delegated child inherits. Preference order: the
824
+ // parent's own persisted workspaceRef re-resolved through the real resolver,
825
+ // then — for sessions recorded before workspaceRef existed — a UNIQUE
826
+ // real-path match against the registered workspaces. Ambiguous or
827
+ // unresolvable is an explicit 409; the current UI project is never picked.
828
+ _delegationWorkspace(parentState) {
829
+ const parentSession = this.runner.getSession(parentState.sessionId);
830
+ const ref = parentSession && typeof parentSession.workspaceRef === 'string' && parentSession.workspaceRef
831
+ ? parentSession.workspaceRef : null;
832
+ if (ref) {
833
+ try {
834
+ const resolved = this.contexts.resolve({ contextId: parentState.projectSlug, workspaceRef: ref });
835
+ return { ref, rootPath: resolved.workspacePath };
836
+ } catch {
837
+ throw Object.assign(new Error('the parent workspace is no longer registered; no delegation was issued'), {
838
+ status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
839
+ });
840
+ }
841
+ }
842
+ const recordedPath = parentSession && typeof parentSession.projectPath === 'string' && parentSession.projectPath
843
+ ? parentSession.projectPath : null;
844
+ if (!recordedPath) {
845
+ throw Object.assign(new Error('the parent session has no trusted workspace record; no delegation was issued'), {
846
+ status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
847
+ });
848
+ }
849
+ const matches = this.contexts.findByRealPath(recordedPath);
850
+ if (matches.length === 1) return { ref: matches[0], rootPath: recordedPath };
851
+ if (matches.length === 0) {
852
+ throw Object.assign(new Error('the parent workspace does not match any registered workspace; no delegation was issued'), {
853
+ status: 409, code: 'DELEGATION_WORKSPACE_UNRESOLVED',
854
+ });
855
+ }
856
+ throw Object.assign(new Error('the parent workspace matches multiple registered workspaces; no delegation was issued'), {
857
+ status: 409, code: 'DELEGATION_WORKSPACE_AMBIGUOUS',
858
+ });
859
+ }
860
+
861
+ // Canonical command hash over EVERY choice that affects execution: the
862
+ // normalized text, the agent/profile/model/permission/effort selection and
863
+ // the resolved workspace and context mode. Only the command hash is stored;
864
+ // opt-in parent context is frozen separately on the private reservation.
865
+ _delegationPayloadHash(command) {
866
+ const canonical = JSON.stringify({
867
+ schema: 'workbench-delegation-command/v1',
868
+ text: command.text,
869
+ agentId: command.agentId,
870
+ aiProfileId: command.aiProfileId,
871
+ model: command.model,
872
+ permissionMode: command.permissionMode,
873
+ effort: command.effort || null,
874
+ workspaceRef: command.workspaceRef,
875
+ contextId: command.contextId,
876
+ parentSessionId: command.parentSessionId,
877
+ ...(command.contextMode ? { contextMode: command.contextMode } : {}),
878
+ });
879
+ return crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
880
+ }
881
+
882
+ _delegationDispatchState(record) {
883
+ const phases = this.delegations.phases.stateOf(record.delegationId);
884
+ const live = this.delegations.phases.isClaimLive(record.delegationId);
885
+ if (phases.outcome) {
886
+ const known = phases.outcome.outcome === 'dispatched';
887
+ return {
888
+ phase: known ? 'dispatched' : 'dispatch-unknown',
889
+ outcome: known ? 'dispatched' : 'unknown',
890
+ dispatch: { state: phases.outcome.outcome, attemptId: phases.outcome.attemptId, at: phases.outcome.at },
891
+ claim: phases.claim,
892
+ };
893
+ }
894
+ if (phases.claim) {
895
+ if (live) {
896
+ return { phase: 'dispatching', outcome: 'pending', dispatch: { state: 'dispatching', attemptId: phases.claim.attemptId }, claim: phases.claim };
897
+ }
898
+ // A claim nobody in this Service can still settle: the model receipt is
899
+ // unprovable, so the honest verdict is dispatch-unknown.
900
+ return {
901
+ phase: 'dispatch-unknown', outcome: 'unknown',
902
+ dispatch: { state: 'dispatch-unknown', attemptId: phases.claim.attemptId, reason: 'owner-unreachable' },
903
+ claim: phases.claim,
904
+ };
905
+ }
906
+ if (phases.childCreated) {
907
+ return { phase: 'child-created', outcome: 'not-dispatched', dispatch: { state: 'awaiting-dispatch' }, claim: null };
908
+ }
909
+ if (phases.childCreateFailed) {
910
+ return { phase: 'child-create-failed', outcome: 'not-started', dispatch: { state: 'not-dispatched', code: phases.childCreateFailed.code }, claim: null };
911
+ }
912
+ return { phase: 'reserved', outcome: 'not-started', dispatch: { state: 'not-dispatched' }, claim: null };
913
+ }
914
+
915
+ // Real child status straight from the runner. A missing child is reported
916
+ // as the explicit dangling fact it is — reads never resurrect it.
917
+ _delegationChild(record) {
918
+ const state = this.runner.getState(record.childSessionId);
919
+ if (!state) return { sessionId: record.childSessionId, present: false, state: 'missing' };
920
+ const summary = this._summary(state, ACTIVE_STATES.has(state.state));
921
+ return {
922
+ sessionId: record.childSessionId,
923
+ present: true,
924
+ state: state.state,
925
+ agentId: summary.agentId,
926
+ model: summary.model || null,
927
+ active: summary.active,
928
+ turns: summary.turns,
929
+ archived: summary.archived,
930
+ };
931
+ }
932
+
933
+ _delegationView(record) {
934
+ const status = this._delegationDispatchState(record);
935
+ return {
936
+ delegation: {
937
+ kind: 'delegated-session',
938
+ storeScope: record.scope.storeScope,
939
+ contextId: record.scope.contextId,
940
+ parentSessionId: record.scope.parentSessionId,
941
+ delegationId: record.delegationId,
942
+ childSessionId: record.childSessionId,
943
+ workspaceRef: record.workspace.ref,
944
+ requestId: record.requestId,
945
+ issuedAt: record.issuedAt,
946
+ source: record.source,
947
+ basis: record.basis,
948
+ sdkRunIdClaimed: false,
949
+ inputCausalityClaimed: false,
950
+ initiator: { type: 'external-api', authenticatedAs: null },
951
+ selection: { profileId: record.selection.profileId, model: record.selection.model },
952
+ },
953
+ phase: status.phase,
954
+ outcome: status.outcome,
955
+ dispatch: status.dispatch,
956
+ child: this._delegationChild(record),
957
+ ...(record.context ? { context: { mode: record.context.mode, truncated: record.context.truncated } } : {}),
958
+ };
959
+ }
960
+
961
+ _adoptDelegatedChild(childSessionId, agentId) {
962
+ this.sessionAgents.set(childSessionId, agentId);
963
+ this._attachNativeTasks(childSessionId);
964
+ this._trackHistory(childSessionId);
965
+ this._notifySessionChange(childSessionId);
966
+ }
967
+
968
+ _recordDelegationFailure(record, code) {
969
+ try {
970
+ this.delegations.phases.recordChildCreateFailed({ delegationId: record.delegationId, code });
971
+ } catch {
972
+ // The failure fact is diagnostics; the explicit error answer below is
973
+ // already the honest result and the reservation stays untouched.
974
+ }
975
+ }
976
+
977
+ // POST /sessions/:parentId/delegations — issue one delegation command.
978
+ // Frozen order (W1-DELEGATION-DECISIONS): validate → reserve (atomic fsync,
979
+ // idempotent by requestId, payload change = 409) → trusted child factory
980
+ // with the reserved identity (relation durable before anything else) →
981
+ // durable dispatch claim (exactly one owner) → send → durable outcome. Any
982
+ // unknown after the claim is reported as dispatch-unknown and never
983
+ // auto-re-input. accepted means the Workbench accepted the command, not
984
+ // that a model proved receipt.
985
+ async createDelegation(parentIdRaw, input = {}) {
986
+ this._requireDelegations();
987
+ const storeScope = this._requireNativeTaskScope();
988
+ const parentId = cleanId(parentIdRaw, 'sessionId');
989
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
990
+ throw Object.assign(new Error('delegation command must be an object'), { status: 400, code: 'DELEGATION_COMMAND_INVALID' });
991
+ }
992
+ const scopeInput = input.scope;
993
+ if (!scopeInput || typeof scopeInput !== 'object' || Array.isArray(scopeInput)
994
+ || Object.keys(scopeInput).length !== 2 || !('storeScope' in scopeInput) || !('contextId' in scopeInput)) {
995
+ throw Object.assign(new Error('scope must be an object with exactly storeScope and contextId; parentSessionId comes from the route'), {
996
+ status: 400, code: 'DELEGATION_SCOPE_INVALID',
997
+ });
998
+ }
999
+ if (typeof scopeInput.storeScope !== 'string' || !STORE_SCOPE_PATTERN.test(scopeInput.storeScope) || scopeInput.storeScope !== storeScope) {
1000
+ throw Object.assign(new Error('scope.storeScope does not match this workbench; resync from health'), {
1001
+ status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
1002
+ });
1003
+ }
1004
+ const contextId = cleanId(scopeInput.contextId, 'contextId');
1005
+ if (typeof input.requestId !== 'string' || !input.requestId.length || input.requestId.length > 256
1006
+ || /[\u0000-\u001f\u007f]/.test(input.requestId)) {
1007
+ throw Object.assign(new Error('requestId must be a non-empty string without control characters'), {
1008
+ status: 400, code: 'DELEGATION_REQUEST_INVALID',
1009
+ });
1010
+ }
1011
+ const requestId = input.requestId;
1012
+ if (input.contextMode !== undefined && input.contextMode !== 'parent-snapshot') {
1013
+ throw Object.assign(new Error('unsupported context mode'), { status: 400, code: 'DELEGATION_CONTEXT_INVALID' });
1014
+ }
1015
+ const text = typeof input.text === 'string' ? input.text.trim() : '';
1016
+ if (!text || text.length > 200000) {
1017
+ throw Object.assign(new Error('text must be a non-empty command of at most 200000 characters'), {
1018
+ status: 400, code: 'DELEGATION_TEXT_INVALID',
1019
+ });
1020
+ }
1021
+ const agentId = normalizeAgentId(input.agentId === undefined || input.agentId === null || input.agentId === '' ? undefined : input.agentId);
1022
+ const effort = input.effort === undefined || input.effort === null || input.effort === '' ? undefined : normalizeEffort(input.effort);
1023
+ const permissionMode = input.permissionMode === undefined || input.permissionMode === null || input.permissionMode === ''
1024
+ ? 'default' : input.permissionMode;
1025
+ if (!PERMISSION_MODES.includes(permissionMode)) {
1026
+ throw Object.assign(new Error('permissionMode is invalid'), { status: 400, code: 'DELEGATION_SELECTION_REJECTED' });
1027
+ }
1028
+ if (input.metadata !== undefined || input.inputOrigin !== undefined) {
1029
+ throw Object.assign(new Error('caller metadata does not grant trusted relations; omit metadata and inputOrigin'), {
1030
+ status: 400, code: 'DELEGATION_CALLER_IDENTITY_REJECTED',
1031
+ });
1032
+ }
1033
+
1034
+ const parentState = this.runner.getState(parentId);
1035
+ if (!parentState) throw Object.assign(new Error('parent session not found'), { status: 404, code: 'DELEGATION_PARENT_NOT_FOUND' });
1036
+ if (parentState.projectSlug !== contextId) {
1037
+ throw Object.assign(new Error('scope.contextId does not match the parent session; resync from the session list'), {
1038
+ status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
1039
+ });
1040
+ }
1041
+ if (!this._delegationSupported(agentId)) {
1042
+ throw Object.assign(new Error(`agent "${agentId}" cannot run delegated sessions with a server-reserved identity`), {
1043
+ status: 501, code: 'DELEGATION_AGENT_UNSUPPORTED',
1044
+ });
1045
+ }
1046
+ // The selection resolves NOW so the payload hash covers the concrete
1047
+ // profile/model the child will run with (unknown profile → 404 here).
1048
+ const aiProfileId = input.aiProfileId === undefined ? (parentState.aiProfileId || null)
1049
+ : input.aiProfileId === null ? null : cleanId(input.aiProfileId, 'aiProfileId');
1050
+ const profile = this._runtimeProfile(aiProfileId, input.model === undefined ? undefined : input.model);
1051
+ const model = profile.mainModel || null;
1052
+ const workspace = this._delegationWorkspace(parentState);
1053
+ if (this.runner.listSessions({}).length >= this.maxSessions) {
1054
+ throw Object.assign(new Error('session limit reached'), { status: 429, code: 'DELEGATION_SESSION_LIMIT' });
1055
+ }
1056
+ const payloadHash = this._delegationPayloadHash({
1057
+ text, agentId, aiProfileId, model, permissionMode, effort,
1058
+ workspaceRef: workspace.ref, contextId, parentSessionId: parentId,
1059
+ contextMode: input.contextMode,
1060
+ });
1061
+
1062
+ const record = this.delegations.ledger.reserve({
1063
+ scope: { storeScope, contextId, parentSessionId: parentId },
1064
+ requestId,
1065
+ payloadHash,
1066
+ workspace: { ref: workspace.ref, rootPath: workspace.rootPath, resolvedFrom: 'parent-session-record' },
1067
+ selection: { profileId: aiProfileId, model },
1068
+ ...(input.contextMode ? { context: () => {
1069
+ this._history(parentId).flush();
1070
+ const events = [];
1071
+ let before;
1072
+ do {
1073
+ const page = this.loadHistory(parentId, { before, limit: 100 });
1074
+ if (page.incomplete) throw Object.assign(new Error('parent conversation history is incomplete'), { status: 409, code: 'PARENT_CONTEXT_INCOMPLETE' });
1075
+ events.unshift(...page.events);
1076
+ before = page.nextCursor || null;
1077
+ } while (before);
1078
+ return parentContext(events, text);
1079
+ } } : {}),
1080
+ });
1081
+ this._audit('delegation-reserve', {
1082
+ delegationId: record.delegationId, parentSessionId: parentId, contextId, childSessionId: record.childSessionId,
1083
+ });
1084
+
1085
+ // Once a dispatch claim exists the input may already have reached a
1086
+ // model: never re-create, never re-send — report the durable truth.
1087
+ const prior = this._delegationDispatchState(record);
1088
+ if (prior.claim) return { accepted: true, ...this._delegationView(record) };
1089
+
1090
+ if (typeof this.runner.startDelegatedSession !== 'function') {
1091
+ throw Object.assign(new Error(`agent "${agentId}" cannot run delegated sessions with a server-reserved identity`), {
1092
+ status: 501, code: 'DELEGATION_AGENT_UNSUPPORTED',
1093
+ });
1094
+ }
1095
+ let started;
1096
+ try {
1097
+ started = this.runner.startDelegatedSession({
1098
+ agentId,
1099
+ reservedSessionId: record.childSessionId,
1100
+ delegation: {
1101
+ delegationId: record.delegationId,
1102
+ parentSessionId: parentId,
1103
+ storeScope,
1104
+ contextId,
1105
+ },
1106
+ slug: contextId,
1107
+ projectPath: workspace.rootPath,
1108
+ kbPath: workspace.rootPath,
1109
+ workspaceRef: workspace.ref,
1110
+ aiProfile: profile,
1111
+ permissionMode,
1112
+ ...(effort ? { effort } : {}),
1113
+ });
1114
+ } catch (error) {
1115
+ const code = error && typeof error.code === 'string' && error.code.startsWith('DELEGATION_')
1116
+ ? error.code : 'DELEGATION_CHILD_CREATE_FAILED';
1117
+ this._recordDelegationFailure(record, code);
1118
+ if (error && error.status) throw error;
1119
+ throw Object.assign(new Error('the delegated child session could not be created; no model input was sent'), {
1120
+ status: 500, code,
1121
+ });
1122
+ }
1123
+ if (!started || started.sessionId !== record.childSessionId) {
1124
+ this._recordDelegationFailure(record, 'DELEGATION_CHILD_IDENTITY_MISMATCH');
1125
+ throw Object.assign(new Error('the driver did not honor the reserved child identity; no model input was sent'), {
1126
+ status: 500, code: 'DELEGATION_CHILD_IDENTITY_MISMATCH',
1127
+ });
1128
+ }
1129
+ this._adoptDelegatedChild(record.childSessionId, agentId);
1130
+ this.delegations.phases.recordChildCreated({
1131
+ delegationId: record.delegationId,
1132
+ childSessionId: record.childSessionId,
1133
+ workspaceRef: workspace.ref,
1134
+ });
1135
+ this._notifySessionChange(record.childSessionId);
1136
+
1137
+ // Guards mirroring send()'s own pre-input checks: a refusal here happens
1138
+ // provably before any input, so no claim is written and the reservation
1139
+ // simply reports awaiting-dispatch.
1140
+ const childState = this.runner.getState(record.childSessionId);
1141
+ if (!childState) {
1142
+ this._recordDelegationFailure(record, 'DELEGATION_CHILD_CREATE_FAILED');
1143
+ throw Object.assign(new Error('the delegated child session could not be created; no model input was sent'), {
1144
+ status: 500, code: 'DELEGATION_CHILD_CREATE_FAILED',
1145
+ });
1146
+ }
1147
+ if (ACTIVE_STATES.has(childState.state) || this.pendingInputs.has(record.childSessionId)) {
1148
+ throw Object.assign(new Error('the delegated child already has an active turn; the command stays reserved'), {
1149
+ status: 409, code: 'DELEGATION_CHILD_BUSY',
1150
+ });
1151
+ }
1152
+ if (this._activeCount() >= this.maxConcurrentRuns) {
1153
+ throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429, code: 'DELEGATION_CONCURRENCY_LIMIT' });
1154
+ }
1155
+
1156
+ // Durable dispatch claim BEFORE the send: exactly one owner across
1157
+ // Services and processes; a lost claim never sends.
1158
+ let claim;
1159
+ try {
1160
+ claim = this.delegations.phases.claimDispatch({
1161
+ delegationId: record.delegationId,
1162
+ attemptId: crypto.randomUUID(),
1163
+ ownerPid: process.pid,
1164
+ ownerEpoch: this.runtimeEpoch,
1165
+ });
1166
+ } catch (error) {
1167
+ throw Object.assign(new Error('the dispatch attempt could not be recorded; no model input was sent'), {
1168
+ status: 503, code: error && typeof error.code === 'string' ? error.code : 'DELEGATION_CLAIM_FAILED',
1169
+ });
1170
+ }
1171
+ if (!claim.claimed) {
1172
+ return { accepted: true, ...this._delegationView(record) };
1173
+ }
1174
+
1175
+ let outcome = 'dispatched';
1176
+ let failureCode = null;
1177
+ try {
1178
+ await this.send(record.childSessionId, text, {
1179
+ [PARENT_CONTEXT]: record.context ? record.context.text : '',
1180
+ inputOrigin: 'agent-delegation',
1181
+ permissionMode,
1182
+ ...(effort ? { effort } : {}),
1183
+ });
1184
+ } catch (error) {
1185
+ outcome = 'dispatch-unknown';
1186
+ failureCode = error && typeof error.code === 'string' ? error.code : null;
1187
+ }
1188
+ try {
1189
+ this.delegations.phases.recordOutcome({
1190
+ delegationId: record.delegationId,
1191
+ attemptId: claim.claim.attemptId,
1192
+ outcome,
1193
+ });
1194
+ } catch {
1195
+ // The receipt could not be proven on disk after a possibly accepted
1196
+ // input: the only honest durable verdict is dispatch-unknown.
1197
+ outcome = 'dispatch-unknown';
1198
+ this.delegations.phases.abandonLiveClaim({
1199
+ delegationId: record.delegationId,
1200
+ attemptId: claim.claim.attemptId,
1201
+ });
1202
+ }
1203
+ this._audit('delegation-dispatch', { delegationId: record.delegationId, childSessionId: record.childSessionId, outcome });
1204
+ const view = this._delegationView(record);
1205
+ if (failureCode && outcome === 'dispatch-unknown') view.dispatch.errorCode = failureCode;
1206
+ return { accepted: true, ...view };
1207
+ }
1208
+
1209
+ // GET /sessions/:parentId/delegations — trusted relations, durable command
1210
+ // facts and the real child status. Pure read: no session is created, no
1211
+ // input is sent, nothing is refreshed back to life.
1212
+ listDelegations(parentIdRaw, query = {}) {
1213
+ this._requireDelegations();
1214
+ const storeScope = this._requireNativeTaskScope();
1215
+ const parentId = cleanId(parentIdRaw, 'sessionId');
1216
+ const parentState = this.runner.getState(parentId);
1217
+ if (!parentState) throw Object.assign(new Error('parent session not found'), { status: 404, code: 'DELEGATION_PARENT_NOT_FOUND' });
1218
+ if (typeof query.storeScope !== 'string' || !STORE_SCOPE_PATTERN.test(query.storeScope)) {
1219
+ throw Object.assign(new Error('storeScope is invalid'), { status: 400, code: 'DELEGATION_SCOPE_INVALID' });
1220
+ }
1221
+ if (typeof query.contextId !== 'string' || !query.contextId) {
1222
+ throw Object.assign(new Error('contextId is required'), { status: 400, code: 'DELEGATION_SCOPE_INVALID' });
1223
+ }
1224
+ const contextId = cleanId(query.contextId, 'contextId');
1225
+ if (query.storeScope !== storeScope || contextId !== parentState.projectSlug) {
1226
+ throw Object.assign(new Error('delegation scope does not match this session; resync from the session list'), {
1227
+ status: 409, code: 'DELEGATION_SCOPE_MISMATCH', resync: true,
1228
+ });
1229
+ }
1230
+ const records = this.delegations.ledger.list({ storeScope, contextId, parentSessionId: parentId });
1231
+ return {
1232
+ scope: { storeScope, contextId, parentSessionId: parentId },
1233
+ delegations: records.map(record => this._delegationView(record)),
1234
+ };
1235
+ }
1236
+
1237
+ subscribe(sessionId, afterSequence, onEvent) {
1238
+ const id = cleanId(sessionId, 'sessionId');
1239
+ this._attachNativeTasks(id);
1240
+ this._trackHistory(id);
1241
+ return subscribeNormalized(this.runner, id, Number(afterSequence || 0), onEvent);
1242
+ }
1243
+
1244
+ _notifySessionChange(id, event) {
1245
+ const state = this.runner.getState(id);
1246
+ if (!state) return;
1247
+ const session = this.runner.getSession(id);
1248
+ if (event?.type === 'claude/user-prompt' && event.text && session && !session.title) session.title = event.text.replace(/\s+/g, ' ').slice(0, 80);
1249
+ const summary = this._summary(state, ACTIVE_STATES.has(state.state));
1250
+ if (session?.title) summary.title = session.title;
1251
+ const signature = JSON.stringify(summary);
1252
+ if (this.sessionSignatures.get(id) === signature) return;
1253
+ this.sessionSignatures.set(id, signature);
1254
+ const change = { type: 'workbench/session-changed', revision: ++this.sessionRevision, contextId: summary.contextId, session: summary };
1255
+ this.sessionChangeLog.push(change);
1256
+ if (this.sessionChangeLog.length > 1000) this.sessionChangeLog.shift();
1257
+ for (const listener of this.sessionChangeListeners) if (!listener.contextId || listener.contextId === summary.contextId) {
1258
+ try { listener.callback(change); } catch { /* disconnected subscriber */ }
1259
+ }
1260
+ }
1261
+
1262
+ _notifySessionDeleted(state) {
1263
+ const summary = this._summary(state, false);
1264
+ this.sessionSignatures.delete(state.sessionId);
1265
+ const change = { type: 'workbench/session-changed', deleted: true, revision: ++this.sessionRevision, contextId: summary.contextId, session: summary };
1266
+ this.sessionChangeLog.push(change);
1267
+ if (this.sessionChangeLog.length > 1000) this.sessionChangeLog.shift();
1268
+ for (const listener of this.sessionChangeListeners) if (!listener.contextId || listener.contextId === summary.contextId) {
1269
+ try { listener.callback(change); } catch { /* disconnected */ }
1270
+ }
1271
+ }
1272
+
1273
+ subscribeSessionChanges(contextId, afterRevision, callback) {
1274
+ const id = contextId === '*' ? null : cleanId(contextId, 'contextId');
1275
+ const sessions = this.runner.listSessions(id ? { projectSlug: id } : {});
1276
+ for (const session of sessions) this._trackHistory(session.sessionId);
1277
+ const after = Math.max(0, Number(afterRevision) || 0);
1278
+ const first = this.sessionChangeLog[0]?.revision || 0;
1279
+ if (after > this.sessionRevision || after && first > after + 1) {
1280
+ callback({ type: 'workbench/resync', revision: this.sessionRevision, contextId: id });
1281
+ } else if (after) {
1282
+ for (const change of this.sessionChangeLog) if (change.revision > after && (!id || change.session.contextId === id)) callback(change);
1283
+ } else callback({ type: 'workbench/sessions-snapshot', revision: this.sessionRevision, contextId: id, sessions: id ? this.listSessions(id, { includeArchived: true }) : sessions.map(s => this._summary(s, ACTIVE_STATES.has(s.state))) });
1284
+ const listener = { contextId: id, callback };
1285
+ this.sessionChangeListeners.add(listener);
1286
+ return () => this.sessionChangeListeners.delete(listener);
1287
+ }
1288
+
1289
+ async listCommands(sessionId) {
1290
+ const id = cleanId(sessionId, 'sessionId');
1291
+ const state = this.runner.getState(id);
1292
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1293
+ if (typeof this.runner.listSupportedCommands !== 'function') return { commands: [] };
1294
+ const profile = this._profileForSession(id, state);
1295
+ const commands = await this.runner.listSupportedCommands(id, profile);
1296
+ return { commands: Array.isArray(commands) ? commands : [] };
1297
+ }
1298
+
1299
+ readAttachment(sessionId, attachmentId) {
1300
+ const id = cleanId(sessionId, 'sessionId');
1301
+ if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
1302
+ const result = this.attachments && this.attachments.read(id, attachmentId);
1303
+ if (!result) throw Object.assign(new Error('attachment not found'), { status: 404 });
1304
+ return result;
1305
+ }
1306
+
1307
+ async send(sessionId, text, options = {}, attachmentInput = []) {
1308
+ if (this.destroyed) throw Object.assign(new Error('workbench is shut down'), { status: 503 });
1309
+ const id = cleanId(sessionId, 'sessionId');
1310
+ const state = this.runner.getState(id);
1311
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1312
+ // A direct input can be the first Service contact for an existing or
1313
+ // restored session: the trusted native-task scope binds before any SDK
1314
+ // input can stream, without relying on an earlier snapshot request.
1315
+ this._attachNativeTasks(id);
1316
+ if (ACTIVE_STATES.has(state.state) || this.pendingInputs.has(id)) {
1317
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
1318
+ }
1319
+ if (this._activeCount() - (state.backgroundTaskCount > 0 ? 1 : 0) >= this.maxConcurrentRuns) {
1320
+ throw Object.assign(new Error('runtime concurrency limit reached'), { status: 429 });
1321
+ }
1322
+ const value = String(text || '').trim();
1323
+ const attachments = normalizeAttachments(attachmentInput);
1324
+ if (!value && !attachments.length) throw Object.assign(new Error('text or attachments are required'), { status: 400 });
1325
+ if (value.length > 200000) throw Object.assign(new Error('input exceeds 200000 characters'), { status: 413 });
1326
+ const profile = this._profileForSession(id, state);
1327
+ const session = this.runner.getSession(id);
1328
+ this._trackHistory(id);
1329
+ const request = { aborted: false };
1330
+ this.pendingInputs.set(id, request);
1331
+ try {
1332
+ const knowledge = this.knowledge ? await this.knowledge.retrieve(session.projectPath || session.kbPath || state.projectPath, value, options?.knowledgeScope, { sessionId: id }) : null;
1333
+ if (request.aborted) throw Object.assign(new Error('task cancelled before agent launch'), { status: 409 });
1334
+ if (knowledge) this.knowledgeBySession.set(id, knowledge);
1335
+ if (knowledge) this.knowledge.remember?.(id, knowledge);
1336
+ const previousEventCount = session && session.outputBuffer ? session.outputBuffer.length : 0;
1337
+ const captureTurn = this.conversationCapture && this.captureSettings?.read().terminalConversations ? {
1338
+ projectPath: session.projectPath || session.kbPath, source: this._summary(state, false).agentId,
1339
+ sessionId: id, turnId: `turn_${crypto.randomUUID()}`, inputOrigin: session.metadata?.inputOrigin === 'agent-delegation' ? 'agent-delegation' : normalizeInputOrigin(options?.inputOrigin || session.metadata?.inputOrigin),
1340
+ } : null;
1341
+ if (captureTurn) this._capture(captureTurn, 'user_prompt', value);
1342
+ const stored = this.attachments ? this.attachments.saveMany(id, attachments) : attachments;
1343
+ const displayAttachments = stored.map(item => ({
1344
+ id: item.id, name: item.name, mediaType: item.mediaType, size: item.size,
1345
+ width: item.width, height: item.height,
1346
+ url: `${this.apiPrefix}/sessions/${encodeURIComponent(id)}/attachments/${encodeURIComponent(item.id)}`,
1347
+ }));
1348
+ const runtimeAttachments = attachments.map((item, index) => ({ ...displayAttachments[index], data: item.data }));
1349
+ const inputOrigin = session.metadata?.inputOrigin === 'agent-delegation' ? 'agent-delegation' : normalizeInputOrigin(options?.inputOrigin || session.metadata?.inputOrigin);
1350
+ try { await this.runner.sendInput(id, value, profile, { ...(options || {}), parentContext: options[PARENT_CONTEXT] || '', inputOrigin, knowledgeContext: knowledge?.prompt || '', attachments: runtimeAttachments, displayAttachments }); }
1351
+ catch (error) { this._runtimeFailure(id, error); if (captureTurn) this._capture(captureTurn, 'session_end', undefined, 'failed'); throw error; }
1352
+ this._watchTurn(id, previousEventCount, captureTurn);
1353
+ this._audit('input', { sessionId: id, contextId: state.projectSlug, characters: value.length, attachments: attachments.length, attachmentBytes: attachments.reduce((sum, item) => sum + item.size, 0) });
1354
+ return { accepted: true, sessionId: id, state: this.runner.getState(id)?.state, knowledge: knowledge ? { ...knowledge, prompt: undefined } : null };
1355
+ } finally { this.pendingInputs.delete(id); }
1356
+ }
1357
+
1358
+ knowledgeContext(sessionId) {
1359
+ const id = cleanId(sessionId, 'sessionId');
1360
+ if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
1361
+ const value = this.knowledgeBySession.get(id) || this.knowledge?.saved?.(id);
1362
+ return value ? { ...value, prompt: undefined } : { status: 'not-retrieved', references: [] };
1363
+ }
1364
+
1365
+ resolvePermission(sessionId, requestId, decision) {
1366
+ const id = cleanId(sessionId, 'sessionId');
1367
+ const rid = cleanId(requestId, 'requestId');
1368
+ this._trackHistory(id);
1369
+ const result = this.runner.resolvePermission(id, rid, decision || {});
1370
+ this._audit('permission-resolve', { sessionId: id, requestId: rid, allow: decision && decision.allow === true });
1371
+ return result;
1372
+ }
1373
+
1374
+ abort(sessionId, reason = 'user-abort') {
1375
+ const id = cleanId(sessionId, 'sessionId');
1376
+ if (!this.runner.getState(id)) throw Object.assign(new Error('session not found'), { status: 404 });
1377
+ this._trackHistory(id);
1378
+ if (this.pendingInputs.has(id)) this.pendingInputs.get(id).aborted = true;
1379
+ this.runner.abort(id, reason);
1380
+ // A net for runners that emit no aborted event: normally the monitor
1381
+ // already released itself on that event, having recorded session_end once.
1382
+ this._clearTurnMonitor(id);
1383
+ this._audit('abort', { sessionId: id, reason });
1384
+ return { accepted: true, sessionId: id };
1385
+ }
1386
+
1387
+ setPermissionMode(sessionId, mode) {
1388
+ const id = cleanId(sessionId, 'sessionId');
1389
+ if (!PERMISSION_MODES.includes(mode)) throw Object.assign(new Error('invalid permission mode'), { status: 400 });
1390
+ this._trackHistory(id);
1391
+ const session = this.runner.getSession(id);
1392
+ if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
1393
+ session.permissionMode = mode;
1394
+ this._audit('permission-mode', { sessionId: id, mode });
1395
+ return { sessionId: id, permissionMode: mode };
1396
+ }
1397
+
1398
+ // Rebind an existing session to another AI profile/model (or back to the
1399
+ // agent's own login with aiProfileId null). The session and its history
1400
+ // survive; the next turn simply runs under the new selection.
1401
+ updateSessionSelection(sessionId, aiProfileId, model) {
1402
+ const id = cleanId(sessionId, 'sessionId');
1403
+ const state = this.runner.getState(id);
1404
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1405
+ if (ACTIVE_STATES.has(state.state) || state.backgroundTaskCount > 0) {
1406
+ throw Object.assign(new Error('session already has an active turn'), { status: 409 });
1407
+ }
1408
+ this._trackHistory(id);
1409
+ // _runtimeProfile resolves and validates the profile (404 unknown id,
1410
+ // 400 disabled/missing credential/invalid model) exactly like startSession.
1411
+ const profileId = aiProfileId ? cleanId(aiProfileId, 'aiProfileId') : null;
1412
+ const profile = this._runtimeProfile(profileId, model === undefined ? undefined : model);
1413
+ if (typeof this.runner.updateSelection === 'function') {
1414
+ this.runner.updateSelection(id, profile, profile.mainModel || null);
1415
+ } else {
1416
+ const session = this.runner.getSession(id);
1417
+ if (!session) throw Object.assign(new Error('session not found'), { status: 404 });
1418
+ session.aiProfileId = profileId;
1419
+ session.selectedModel = profile.mainModel || null;
1420
+ }
1421
+ this._audit('selection-update', { sessionId: id, contextId: state.projectSlug, profileId, model: profile.mainModel || null });
1422
+ return this._summary(this.runner.getState(id), true);
1423
+ }
1424
+
1425
+ async testProfile(profileId) {
1426
+ const id = cleanId(profileId, 'profileId');
1427
+ const profile = this.profiles.runtimeProfile(id);
1428
+ return this._runProfileTest(profile, id);
1429
+ }
1430
+
1431
+ async testProfileDraft(input = {}) {
1432
+ if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
1433
+ throw Object.assign(new Error('profile is required'), { status: 400 });
1434
+ }
1435
+ const profile = this.profiles.runtimeDraft(input.profile, input.credential);
1436
+ return this._runProfileTest(profile, profile.id);
1437
+ }
1438
+
1439
+ // Detect the models a provider draft can actually use (endpoint model list
1440
+ // joined with models.dev capability metadata). Uses the same draft+credential
1441
+ // resolution as testProfileDraft, so a blank credential falls back to the
1442
+ // stored secret of an already-saved profile.
1443
+ async detectProfileModels(input = {}) {
1444
+ if (!input || typeof input !== 'object' || Array.isArray(input) || !input.profile) {
1445
+ throw Object.assign(new Error('profile is required'), { status: 400 });
1446
+ }
1447
+ if (!this.modelDetection) {
1448
+ throw Object.assign(new Error('model detection is unavailable'), { status: 501 });
1449
+ }
1450
+ // Detection precedes model configuration, so an empty model list is fine here.
1451
+ const profile = this.profiles.runtimeDraft(input.profile, input.credential, { requireModel: false });
1452
+ const result = await this.modelDetection.detectModels({ profile });
1453
+ this._audit('profile-detect-models', { profileId: profile.id, count: result.models.length, sources: Object.keys(result.sources) });
1454
+ return result;
1455
+ }
1456
+
1457
+ async _runProfileTest(profile, profileId) {
1458
+ const id = cleanId(profileId, 'profileId');
1459
+ const contextId = `profile-test-${Date.now().toString(36)}`;
1460
+ const startedAt = Date.now();
1461
+ const started = this.runner.startChatSession({
1462
+ slug: contextId, projectPath: this.testWorkspace, kbPath: this.testWorkspace,
1463
+ aiProfile: profile, permissionMode: 'default',
1464
+ });
1465
+ let firstEventMs = null;
1466
+ let preview = '';
1467
+ let unsubscribe = null;
1468
+ try {
1469
+ const outcome = await new Promise((resolve, reject) => {
1470
+ const timeout = setTimeout(() => reject(new Error('Claude Code profile test timed out')), Math.min(profile.timeoutMs, 300000));
1471
+ unsubscribe = this.runner.subscribe(started.sessionId, event => {
1472
+ if (firstEventMs == null && event.type !== 'claude/session-ready') firstEventMs = Date.now() - startedAt;
1473
+ if (event.type === 'claude/result') {
1474
+ clearTimeout(timeout);
1475
+ if (event.isError) {
1476
+ reject(new Error(String(event.result || event.message || event.error || 'Claude Code returned an error result')));
1477
+ } else {
1478
+ preview = String(event.result || event.text || '').slice(0, 500);
1479
+ resolve(event);
1480
+ }
1481
+ } else if (event.type === 'claude/error') {
1482
+ clearTimeout(timeout); reject(new Error(String(event.message || event.error || 'Claude Code runtime error')));
1483
+ } else if (event.type === 'claude/aborted') {
1484
+ clearTimeout(timeout); reject(new Error('Claude Code profile test was aborted'));
1485
+ }
1486
+ });
1487
+ this.runner.sendInput(started.sessionId, 'Reply with exactly: WORKBENCH_RUNTIME_OK', profile, { permissionMode: 'default' }).catch(error => {
1488
+ clearTimeout(timeout); reject(error);
1489
+ });
1490
+ });
1491
+ const state = this.runner.getState(started.sessionId);
1492
+ return {
1493
+ ok: true, runtime: 'claude-code', runtimeVerified: true, profileId: id,
1494
+ model: outcome.model || state && state.model || profile.models.default,
1495
+ firstEventMs: firstEventMs == null ? Date.now() - startedAt : firstEventMs,
1496
+ durationMs: Date.now() - startedAt, resultPreview: redact(preview),
1497
+ };
1498
+ } finally {
1499
+ try { unsubscribe && unsubscribe(); } catch {}
1500
+ try { this.runner.abort(started.sessionId); } catch {}
1501
+ try { this.runner.deleteSession(started.sessionId); } catch {}
1502
+ }
1503
+ }
1504
+
1505
+ destroy() {
1506
+ if (this.destroyPromise) return this.destroyPromise;
1507
+ this.destroyed = true;
1508
+ for (const request of this.pendingInputs.values()) request.aborted = true;
1509
+ this.sessionChangeListeners.clear();
1510
+ this.agentDiscovery?.close?.();
1511
+ this.conversationCapture?.close();
1512
+ if (this.archiveSweeper) { clearInterval(this.archiveSweeper); this.archiveSweeper = null; }
1513
+ for (const sessionId of this.turnMonitors.keys()) this._clearTurnMonitor(sessionId);
1514
+ for (const session of this.runner.listSessions({})) {
1515
+ if (ACTIVE_STATES.has(session.state)) try { this.runner.abort(session.sessionId); } catch {}
1516
+ }
1517
+ this.destroyPromise = Promise.resolve(this.runner.destroy?.()).finally(() => {
1518
+ this.sessionChangeListeners.clear();
1519
+ for (const { history, unsubscribe } of this.histories.values()) { unsubscribe(); history.close(); }
1520
+ this.histories.clear();
1521
+ // Closing the Service unbinds only its OWN committer bindings: the
1522
+ // runner marks those runtimes non-sticky unavailable (rebindable by a
1523
+ // successor), while bindings owned by another instance are refused
1524
+ // there and stay untouched.
1525
+ for (const sessionId of [...this.nativeCommitters.keys()]) this._detachNativeTasks(sessionId);
1526
+ });
1527
+ return this.destroyPromise;
1528
+ }
1529
+
1530
+ _runtimeProfile(profileId, selectedModel) {
1531
+ const profile = profileId ? this.profiles.runtimeProfile(profileId)
1532
+ : { id: null, models: { default: '' }, timeoutMs: 300000 };
1533
+ const model = selectedModel == null ? profile.models.default : String(selectedModel).trim();
1534
+ if (model.length > 128 || /[\r\n\x00]/.test(model)) throw Object.assign(new Error('model is invalid'), { status: 400 });
1535
+ return { ...profile, models: { ...profile.models, default: model }, mainModel: model };
1536
+ }
1537
+
1538
+ _profileForSession(id, state) {
1539
+ const session = this.runner.getSession(id);
1540
+ const model = session && Object.prototype.hasOwnProperty.call(session, 'selectedModel')
1541
+ ? session.selectedModel : state.selectedModel || state.model;
1542
+ return this._runtimeProfile(state.aiProfileId, model);
1543
+ }
1544
+
1545
+ _summary(session, active) {
1546
+ const agentId = session.agentId
1547
+ || (typeof this.runner.agentOf === 'function' ? this.runner.agentOf(session.sessionId) : null)
1548
+ || this.sessionAgents.get(session.sessionId)
1549
+ || 'claude-code';
1550
+ return {
1551
+ sessionId: session.sessionId,
1552
+ contextId: session.projectSlug,
1553
+ projectId: session.projectSlug,
1554
+ agentId,
1555
+ title: session.title || `Session ${String(session.sessionId).slice(-8)}`,
1556
+ active: Boolean(active), state: session.state, model: session.model || null,
1557
+ aiProfileId: session.aiProfileId || null, permissionMode: session.permissionMode || 'default',
1558
+ selectedModel: session.selectedModel || this.runner.getSession(session.sessionId)?.selectedModel || null,
1559
+ authSource: session.aiProfileId ? 'profile-injected' : 'agent-owned',
1560
+ providerName: session.aiProfileId ? this.profiles.get(session.aiProfileId)?.name || 'Unavailable provider' : AGENTS[agentId].label,
1561
+ archived: Boolean(this.sessionArchive && this.sessionArchive.isArchived(session.sessionId)),
1562
+ turns: Number(session.turns || 0), startedAt: session.startedAt,
1563
+ tasks: Array.isArray(session.tasks) ? session.tasks : [],
1564
+ backgroundTaskCount: Number(session.backgroundTaskCount || 0),
1565
+ // W-1: prefer the driver's live activity marker (refreshed at turn
1566
+ // end); endedAt/startAt only predate it for legacy records.
1567
+ updatedAt: session.updatedAt || session.endedAt || session.startedAt, connectionState: 'connected',
1568
+ };
1569
+ }
1570
+
1571
+ // Approximate context occupancy for the capacity bubble. Token totals come
1572
+ // from the driver; the per-category split is a characters/4 estimate over
1573
+ // the session event log and is explicitly flagged as such.
1574
+ contextUsage(sessionId) {
1575
+ const id = cleanId(sessionId, 'sessionId');
1576
+ const state = this.runner.getState(id);
1577
+ if (!state) throw Object.assign(new Error('session not found'), { status: 404 });
1578
+ const usage = this.runner.getSessionTokenUsage(id) || {};
1579
+ const contextWindow = this._contextWindowFor(state.model, this._sessionProfileContextWindow(id, state));
1580
+ const breakdown = this._estimateBreakdown(id);
1581
+ const used = Number(usage.used || usage.inputTokens || 0) || 0;
1582
+ return {
1583
+ sessionId: id,
1584
+ agentId: this._summary(state, false).agentId,
1585
+ model: state.model || null,
1586
+ contextWindow,
1587
+ used,
1588
+ percent: contextWindow ? Math.round((used / contextWindow) * 1000) / 10 : null,
1589
+ inputTokens: Number(usage.inputTokens || 0) || 0,
1590
+ outputTokens: Number(usage.outputTokens || 0) || 0,
1591
+ breakdown,
1592
+ estimated: true,
1593
+ };
1594
+ }
1595
+
1596
+ _sessionProfileContextWindow(sessionId, state) {
1597
+ try {
1598
+ const profile = this._profileForSession(sessionId, state);
1599
+ return profile ? Number(profile.contextWindow) || 0 : 0;
1600
+ } catch { return 0; }
1601
+ }
1602
+
1603
+ _contextWindowFor(model, profileContextWindow) {
1604
+ if (!model && !profileContextWindow) return 200000;
1605
+ if (this.modelContextWindows && typeof this.modelContextWindows.resolveContextWindow === 'function') {
1606
+ try {
1607
+ // The profile store defaults contextWindow to 200000; that default must
1608
+ // not shadow the recognition table (e.g. MiniMax → 1M). Only an explicit
1609
+ // non-default override wins over auto-detection.
1610
+ const explicit = Number(profileContextWindow) > 0 && Number(profileContextWindow) !== 200000 ? Number(profileContextWindow) : undefined;
1611
+ const resolved = this.modelContextWindows.resolveContextWindow({ profileContextWindow: explicit, model, fallback: 200000 });
1612
+ if (resolved) return resolved;
1613
+ } catch { /* fall through to the default */ }
1614
+ }
1615
+ return Number(profileContextWindow) > 0 ? Number(profileContextWindow) : 200000;
1616
+ }
1617
+
1618
+ _estimateBreakdown(sessionId) {
1619
+ const session = this.runner.getSession(sessionId);
1620
+ const buckets = { messages: 0, tools: 0, other: 0 };
1621
+ if (!session || !Array.isArray(session.outputBuffer)) return buckets;
1622
+ for (const event of session.outputBuffer) {
1623
+ const text = String(event.text || event.result || event.output || '');
1624
+ const tokens = Math.ceil(text.length / 4);
1625
+ if (event.type === 'claude/user-prompt' || event.type === 'claude/text-delta') buckets.messages += tokens;
1626
+ else if (event.type === 'claude/tool-use' || event.type === 'claude/tool-result') buckets.tools += tokens;
1627
+ else buckets.other += tokens;
1628
+ }
1629
+ return buckets;
1630
+ }
1631
+
1632
+ _activeCount(sessions = null) { const list = Array.isArray(sessions) ? sessions : this.runner.listSessions({}); const active = new Set(list.filter(item => ACTIVE_STATES.has(item.state) || item.backgroundTaskCount > 0).map(item => item.sessionId)); return active.size + [...this.pendingInputs.keys()].filter(id => !active.has(id)).length; }
1633
+ _capture(turn, eventType, content, outcome) {
1634
+ try { this.conversationCapture.enqueue(turn, eventType, content, outcome); }
1635
+ catch { this._audit('capture-gap', { sessionId: turn.sessionId, code: 'CAPTURE_QUEUE_UNAVAILABLE' }); }
1636
+ }
1637
+ _watchTurn(sessionId, previousEventCount, captureTurn) {
1638
+ this._clearTurnMonitor(sessionId);
1639
+ let seen = 0;
1640
+ let unsubscribe = null;
1641
+ let completed = false;
1642
+ let responseText = '';
1643
+ let released = false;
1644
+ // The monitor owns capture and failure diagnostics on its own; there is no
1645
+ // whole-task timer anymore, so keeping it must not depend on one.
1646
+ const release = () => {
1647
+ if (released) return;
1648
+ released = true;
1649
+ if (unsubscribe) { try { unsubscribe(); } catch {} }
1650
+ this.turnMonitors.delete(sessionId);
1651
+ };
1652
+ unsubscribe = this.runner.subscribe(sessionId, event => {
1653
+ if (released) return;
1654
+ seen += 1;
1655
+ if (seen <= previousEventCount) return;
1656
+ if (event?.parentToolUseId) return;
1657
+ if (event?.type === 'claude/text-delta') responseText += String(event.text || '');
1658
+ if (['claude/result', 'claude/turn-end', 'claude/error', 'claude/aborted'].includes(event && event.type)) {
1659
+ if (event.type === 'claude/error' || event.type === 'claude/result' && event.isError) this._runtimeFailure(sessionId, new Error(String(event.message || event.error || event.result || 'Agent 运行失败。')));
1660
+ if (captureTurn && !completed) {
1661
+ completed = true;
1662
+ if (event.type === 'claude/result' && !event.isError && typeof event.result === 'string' && event.result) this._capture(captureTurn, 'assistant_response', event.result);
1663
+ if (event.type === 'claude/turn-end' && !event.exitCode && responseText) this._capture(captureTurn, 'assistant_response', responseText);
1664
+ this._capture(captureTurn, 'session_end', undefined, event.type === 'claude/result' && !event.isError || event.type === 'claude/turn-end' && !event.exitCode ? 'completed' : 'failed');
1665
+ }
1666
+ release();
1667
+ }
1668
+ });
1669
+ // The synchronous replay may deliver the terminal event while
1670
+ // `unsubscribe` is still unassigned, so release() could not detach yet.
1671
+ if (released) { try { unsubscribe(); } catch {} }
1672
+ else this.turnMonitors.set(sessionId, unsubscribe);
1673
+ }
1674
+ _clearTurnMonitor(sessionId) {
1675
+ const unsubscribe = this.turnMonitors.get(sessionId);
1676
+ if (unsubscribe) try { unsubscribe(); } catch {}
1677
+ this.turnMonitors.delete(sessionId);
1678
+ }
1679
+ _runtimeFailure(sessionId, error) {
1680
+ const state = this.runner.getState(sessionId);
1681
+ this.diagnostics?.error('agent.runtime.failed', 'Agent 会话运行失败。', { projectId: /^project-[A-Za-z0-9._-]+$/.test(state?.projectSlug || '') ? state.projectSlug : undefined, runId: sessionId, phase: 'runtime', error, context: { agent: this.runner.agentOf?.(sessionId) || this.sessionAgents.get(sessionId) || state?.agentId || 'claude-code', sessionId } });
1682
+ }
1683
+ _audit(action, detail) {
1684
+ if (!this.auditFile) return;
1685
+ fs.mkdirSync(path.dirname(this.auditFile), { recursive: true });
1686
+ fs.appendFileSync(this.auditFile, `${JSON.stringify({ at: new Date().toISOString(), action, ...detail })}\n`, 'utf8');
1687
+ }
1688
+ }
1689
+
1690
+ function redact(value) {
1691
+ return String(value || '').replace(/(?:sk|key|token)-[a-zA-Z0-9._-]{8,}/gi, '[REDACTED]');
1692
+ }
1693
+
1694
+ function normalizeInputOrigin(value) { return ['human', 'agent-delegation'].includes(value) ? value : 'unknown'; }
1695
+
1696
+ module.exports = { WorkbenchService, ACTIVE_STATES, redact };