termdock 1.4.308 → 1.4.309

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.
@@ -68,6 +68,20 @@ export const COLLAB_HELP = `td collab — durable messages; no agent-specific ho
68
68
  消息历史只覆盖仍保留记录;不存在的 after-id 返回 MESSAGE_NOT_FOUND,不能据此声称历史完整。
69
69
  session create --group <id> --launch-profile <id> --cwd <absolute-path> --idempotency-key <key>
70
70
  session get <id> | session restore <id> --idempotency-key <key>
71
+ session release <id> --generation <session-get-generation> --idempotency-key <key>
72
+ 权限 session.release;仅原 Agent 已退出、精确空 shell、无 pending 消息时释放实际运行承载。
73
+ released: runtime_present=false、terminal_binding=null;保留逻辑 ID/原生 UUID/原消息,restore 后可继续 reply。
74
+ released 上 send/reply 返回 SESSION_RELEASED,不入队、不隐式恢复;业务先持久接收,再明确 restore。
75
+ 释放/恢复最低 CLI/实际服务 1.4.309;检查 session_runtime_release/stable_session_identity/session_lifecycle_generation。
76
+ generation 每次实际 restore/release/管理员兼容确认递增;旧 generation 拒绝,运行中精确 restore 不递增。
77
+ 重用同幂等键回读原操作结果,不能用于新一轮恢复;用 session get 查询当前状态。
78
+ 旧配置须管理员 td integration resume-configuration <id> 查看,再 confirm-resume 显式确认当前指纹。
79
+ 状态 starting/restoring/binding_pending/ready/failed/releasing/released;runtime_present=null 表示无法确认。
80
+ 失败码:INVALID_SESSION_GENERATION、SESSION_GENERATION_MISMATCH、SESSION_RELEASE_UNSUPPORTED;
81
+ SESSION_PENDING_MESSAGES/SESSION_STILL_RUNNING/SESSION_OPERATION_IN_PROGRESS 表示应先处理原现场;
82
+ SESSION_NATIVE_OPERATION_IN_PROGRESS、SESSION_SCOPE_CONFLICT、SESSION_IDENTITY_MISMATCH 拒绝并发或身份/归属冲突;
83
+ SESSION_RELEASE_FAILED、SESSION_STORAGE_FULL 保留原记录,不表示回收成功;
84
+ RESUME_CONFIGURATION_CHANGED/RESUME_CONFIGURATION_UNCONFIRMED 拒绝静默替换配置;OPERATION_OUTCOME_UNCONFIRMED 保留记录待核对。
71
85
  task respond <task-id> --attempt <attempt-id> --to-event <comment-event-id> --content <答复>
72
86
  凭据通过 TERMDOCK_INTEGRATION_CREDENTIAL_FILE 注入,不通过命令行传 token。
73
87
  消息接入最低 CLI/实际服务均为 1.4.308;capabilities 回读双方版本与 integration_message_send/reply/history。
@@ -172,7 +186,7 @@ function roleLine(member, role) {
172
186
  return role ? `- ${label}:${role}` : `- ${label}(未设置)`;
173
187
  }
174
188
  const BOOLEAN_OPTIONS = new Set(['json', 'jsonl', 'text', 'follow', 'stdin', 'receipt-only', 'confirm', 'raw', 'help', 'no-rules', 'managed', 'shared-directory', 'integration']);
175
- const VALUE_OPTIONS = new Set(['purpose', 'principal', 'source', 'external-actor', 'external-message-id', 'launch-profile', 'to-event', 'decision', 'work-type', 'summary', 'verdict', 'reviewers', 'title', 'content', 'constraints', 'acceptance', 'assignee', 'coordinator', 'parent', 'depends-on', 'attempt', 'status', 'artifact', 'options', 'revision', 'evidence', 'session', 'group', 'thread', 'idempotency-key', 'file', 'wait-until', 'timeout', 'expect-reply', 'response-kind', 'metadata', 'task-envelope', 'expires-at', 'since', 'after-id', 'cursor', 'consumer', 'limit', 'from', 'kind', 'name', 'cwd', 'task', 'pane', 'lines', 'if-version', 'origin']);
189
+ const VALUE_OPTIONS = new Set(['generation', 'purpose', 'principal', 'source', 'external-actor', 'external-message-id', 'launch-profile', 'to-event', 'decision', 'work-type', 'summary', 'verdict', 'reviewers', 'title', 'content', 'constraints', 'acceptance', 'assignee', 'coordinator', 'parent', 'depends-on', 'attempt', 'status', 'artifact', 'options', 'revision', 'evidence', 'session', 'group', 'thread', 'idempotency-key', 'file', 'wait-until', 'timeout', 'expect-reply', 'response-kind', 'metadata', 'task-envelope', 'expires-at', 'since', 'after-id', 'cursor', 'consumer', 'limit', 'from', 'kind', 'name', 'cwd', 'task', 'pane', 'lines', 'if-version', 'origin']);
176
190
  export function parseCollaborationCommand(argv) {
177
191
  const options = {};
178
192
  const positional = [];
@@ -238,12 +252,14 @@ export function parseCollaborationCommand(argv) {
238
252
  throw new Error('Unexpected integration arguments');
239
253
  if (action === 'events' && (!['subscribe', 'ack'].includes(command.operation ?? '') || (command.operation === 'ack' ? !command.target : Boolean(command.target))))
240
254
  throw new Error('Usage: events subscribe | events ack <cursor>');
241
- if (action === 'session' && (!['create', 'get', 'restore'].includes(command.operation ?? '') || (command.operation === 'create' ? Boolean(command.target) : !command.target)))
242
- throw new Error('Usage: session create|get|restore');
255
+ if (action === 'session' && (!['create', 'get', 'restore', 'release'].includes(command.operation ?? '') || (command.operation === 'create' ? Boolean(command.target) : !command.target)))
256
+ throw new Error('Usage: session create|get|restore|release');
243
257
  if (action === 'session' && command.operation !== 'get' && !options['idempotency-key'])
244
258
  throw new Error('Session mutation requires an explicit --idempotency-key');
259
+ if (action === 'session' && command.operation === 'release' && (!/^[1-9][0-9]*$/.test(String(options.generation ?? '')) || !Number.isSafeInteger(Number(options.generation))))
260
+ throw new Error('session release requires --generation from session get');
245
261
  const integrationFlags = action === 'events' ? command.operation === 'ack' ? ['consumer'] : ['consumer', 'group', 'timeout']
246
- : command.operation === 'create' ? ['group', 'launch-profile', 'cwd', 'idempotency-key'] : command.operation === 'restore' ? ['idempotency-key'] : [];
262
+ : command.operation === 'create' ? ['group', 'launch-profile', 'cwd', 'idempotency-key'] : command.operation === 'restore' ? ['idempotency-key'] : command.operation === 'release' ? ['idempotency-key', 'generation'] : [];
247
263
  for (const flag of Object.keys(options))
248
264
  if (!['principal', 'json', 'jsonl', 'text', 'help', ...integrationFlags].includes(flag))
249
265
  throw new Error(`--${flag} is not supported by ${action} ${command.operation}`);
@@ -381,7 +397,7 @@ export function parseCollaborationCommand(argv) {
381
397
  throw new Error(`Unexpected arguments for ${action}`);
382
398
  const allowed = new Set(['json', 'jsonl', 'text', 'help', 'session', 'principal']);
383
399
  const byAction = {
384
- events: ['consumer', 'group', 'timeout'], session: ['group', 'launch-profile', 'cwd', 'idempotency-key'],
400
+ events: ['consumer', 'group', 'timeout'], session: ['group', 'launch-profile', 'cwd', 'idempotency-key', 'generation'],
385
401
  task: ['purpose', 'to-event', 'decision', 'source', 'external-actor', 'external-message-id', 'metadata', 'work-type', 'summary', 'integration', 'managed', 'shared-directory', 'reviewers', 'verdict', 'group', 'title', 'content', 'constraints', 'acceptance', 'assignee', 'coordinator', 'parent', 'depends-on', 'attempt', 'status', 'artifact', 'options', 'revision', 'evidence', 'idempotency-key', 'file', 'stdin'],
386
402
  rules: ['file', 'stdin', 'if-version'], transport: ['file', 'origin'], group: ['file'], status: [], capabilities: [], rebind: ['pane'], help: [...BOOLEAN_OPTIONS, ...VALUE_OPTIONS],
387
403
  send: ['group', 'thread', 'idempotency-key', 'file', 'stdin', 'wait-until', 'timeout', 'expect-reply', 'response-kind', 'metadata', 'expires-at', 'kind'],
@@ -45,6 +45,8 @@ export class CollaborationStore {
45
45
  drawId;
46
46
  onMessageQueued;
47
47
  changeListeners = new Set();
48
+ runtimeGuards = new Set();
49
+ registerRuntimeGuard(guard) { this.runtimeGuards.add(guard); return () => { this.runtimeGuards.delete(guard); }; }
48
50
  subscribe(listener) { this.changeListeners.add(listener); return () => { this.changeListeners.delete(listener); }; }
49
51
  snapshotMessages() { return structuredClone(this.document.messages); }
50
52
  peerActivity = new Map();
@@ -324,6 +326,9 @@ export class CollaborationStore {
324
326
  throw new CollaborationError('MESSAGE_TOO_LARGE', `Message exceeds ${COLLAB_LIMITS.message_bytes} UTF-8 bytes`, 413);
325
327
  if (!content.trim())
326
328
  throw new Error('消息不能为空');
329
+ for (const id of input.toSessionIds)
330
+ for (const guard of this.runtimeGuards)
331
+ guard(id);
327
332
  const recipients = Array.from(new Set(input.toSessionIds)).filter((id) => group.sessionIds.includes(id) && id !== input.fromSessionId);
328
333
  if (recipients.length === 0)
329
334
  throw new Error('没有有效的接收会话');
@@ -8,6 +8,15 @@ import { COLLAB_HELP, executeCollaborationCommand, duration } from './collaborat
8
8
  import { getTermdockVersion } from '../utils/version.js';
9
9
  export const INTEGRATION_HELP = `td integration — local scoped integration administration
10
10
  groups | list
11
+ resume-configuration <TD-session-id> (read current/recorded fingerprints; no mutation)
12
+ confirm-resume <TD-session-id> --generation <N> --fingerprint <sha256>
13
+ Administrator explicitly authorizes the current launcher/plugin recovery semantics.
14
+ This never proves unknown historical file contents or modifies Agent configuration.
15
+ Old records need this confirmation before release; new records capture a fingerprint.
16
+ Later changes fail closed. Grant session.release separately for runtime reclamation.
17
+ Minimum CLI/running service 1.4.309; capabilities.session_resume_configuration=true.
18
+ Errors: RESUME_CONFIGURATION_CHANGED, EXACT_RESUME_UNSUPPORTED,
19
+ SESSION_GENERATION_MISMATCH, SESSION_OPERATION_IN_PROGRESS, LAUNCHER_UNAVAILABLE.
11
20
  diagnostics <TD-session-id> (read the last restore check; administrator only)
12
21
  create --file <policy.json> --credential-file <private-output.json>
13
22
  update --file <policy.json> (same identity/group; preserves the credential)
@@ -63,6 +72,30 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
63
72
  return 0;
64
73
  }
65
74
  const socketPath = integrationSocketPath(port);
75
+ if (action === 'resume-configuration' || action === 'confirm-resume') {
76
+ const id = argv[1];
77
+ if (!id || !/^[a-zA-Z0-9._-]{1,80}$/.test(id))
78
+ throw new Error('A full TD session id is required');
79
+ const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
80
+ if (capabilities.session_resume_configuration !== true)
81
+ throw new CollaborationError('SESSION_RELEASE_UNSUPPORTED', 'CLI and running service must be at least 1.4.309', 409);
82
+ let body;
83
+ if (action === 'confirm-resume') {
84
+ const args = {};
85
+ for (let i = 2; i < argv.length; i += 2) {
86
+ if (!['--generation', '--fingerprint'].includes(argv[i]) || !argv[i + 1] || args[argv[i]])
87
+ throw new Error('confirm-resume requires --generation and --fingerprint');
88
+ args[argv[i]] = argv[i + 1];
89
+ }
90
+ if (!/^[1-9][0-9]*$/.test(args['--generation'] ?? '') || !Number.isSafeInteger(Number(args['--generation'])) || !/^[a-f0-9]{64}$/.test(args['--fingerprint'] ?? ''))
91
+ throw new Error('Invalid generation or fingerprint');
92
+ body = { generation: Number(args['--generation']), fingerprint: args['--fingerprint'] };
93
+ }
94
+ else if (argv.length !== 2)
95
+ throw new Error('resume-configuration requires one TD session id');
96
+ write(JSON.stringify(parsed(await request(socketPath, body ? 'POST' : 'GET', `/admin/sessions/${encodeURIComponent(id)}/resume-configuration`, body, headers(undefined, adminToken)))));
97
+ return 0;
98
+ }
66
99
  if (action === 'diagnostics') {
67
100
  if (argv.length !== 2 || !/^[a-zA-Z0-9._-]{1,80}$/.test(argv[1]))
68
101
  throw new Error('diagnostics requires one TD session id');
@@ -161,9 +194,11 @@ export async function runIntegrationCollab(command, port, io) {
161
194
  return subscribe(socketPath, `/events?${params}`, credential, command.options.timeout ? duration(String(command.options.timeout)) : undefined, io.write);
162
195
  }
163
196
  if (command.action === 'session') {
164
- const route = command.operation === 'create' ? '/sessions' : `/sessions/${encodeURIComponent(command.target)}${command.operation === 'restore' ? '/restore' : ''}`;
197
+ if (command.operation === 'release' && (capability.session_runtime_release !== true || capability.session_lifecycle_generation !== true || capability.stable_session_identity !== true))
198
+ throw new CollaborationError('SESSION_RELEASE_UNSUPPORTED', 'CLI and running service must support the lifecycle contract (minimum 1.4.309)', 409);
199
+ const route = command.operation === 'create' ? '/sessions' : `/sessions/${encodeURIComponent(command.target)}${['restore', 'release'].includes(command.operation) ? `/${command.operation}` : ''}`;
165
200
  const body = command.operation === 'create' ? { group_id: command.options.group, launch_profile: command.options['launch-profile'], cwd: command.options.cwd, idempotency_key: command.options['idempotency-key'] }
166
- : command.operation === 'restore' ? { idempotency_key: command.options['idempotency-key'] } : undefined;
201
+ : ['restore', 'release'].includes(command.operation) ? { ...(command.operation === 'release' ? { generation: Number(command.options.generation) } : {}), idempotency_key: command.options['idempotency-key'] } : undefined;
167
202
  io.write(JSON.stringify(parsed(await request(socketPath, command.operation === 'get' ? 'GET' : 'POST', route, body, headers(credential)))));
168
203
  return 0;
169
204
  }
@@ -34,6 +34,7 @@ export class IntegrationRuntime {
34
34
  this.options = options;
35
35
  this.store = new IntegrationStore(path.join(options.directory, 'integrations.json'));
36
36
  this.sessions = new IntegrationSessions(path.join(options.directory, 'integration-sessions.json'), options.sessions);
37
+ this.stops.push(options.messages.registerRuntimeGuard(id => this.sessions.assertRuntimeMessage(id)));
37
38
  const changed = () => { try {
38
39
  this.sync();
39
40
  }
@@ -70,6 +71,8 @@ export class IntegrationRuntime {
70
71
  app.get('/admin/groups', run(req => { admin(req); return { groups: options.messages.list().filter(g => !g.federated).map(g => ({ id: g.id, name: g.name })) }; }));
71
72
  app.get('/admin/capabilities', run(req => { admin(req); return this.capabilities(); }));
72
73
  app.get('/admin/sessions/:id/restore-diagnostics', run(req => { admin(req); return this.sessions.restoreDiagnostics(req.params.id); }));
74
+ app.get('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return this.sessions.resumeConfiguration(req.params.id); }));
75
+ app.post('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return { session: publicIntegrationSession(await this.sessions.confirmResumeConfiguration(req.params.id, req.body?.generation, req.body?.fingerprint)) }; }));
73
76
  app.get('/admin/principals', run(req => { admin(req); return { integrations: this.store.policies() }; }));
74
77
  app.post('/admin/principals', run(req => { admin(req); this.group(req.body?.groupId); const provisioned = this.store.provision(req.body); try {
75
78
  this.sync();
@@ -120,7 +123,13 @@ export class IntegrationRuntime {
120
123
  const targets = Array.isArray(raw.toSessionIds) ? raw.toSessionIds : [raw.targetSessionId];
121
124
  if (!targets.length || targets.some((id) => typeof id !== 'string'))
122
125
  integrationError('INVALID_MESSAGE_TARGET', 'Specify a local group member');
123
- const recipients = targets.map((id) => scopedId(id, group.sessionIds, 'SESSION'));
126
+ const descriptors = this.sessions.snapshot().filter(s => s.group_id === p.groupId);
127
+ const recipients = targets.map((id) => scopedId(id, [...group.sessionIds, ...descriptors.map(s => s.session_id)], 'SESSION'));
128
+ for (const id of recipients) {
129
+ this.sessions.assertMessageTarget(p, id);
130
+ if (!group.sessionIds.includes(id))
131
+ integrationError('SESSION_NOT_FOUND', 'Restore the original runtime and membership before sending', 404);
132
+ }
124
133
  let thread;
125
134
  if (raw.thread_id)
126
135
  thread = scopedId(raw.thread_id, options.messages.snapshotMessages().filter(m => m.groupId === p.groupId).map(m => m.threadId), 'THREAD');
@@ -135,6 +144,8 @@ export class IntegrationRuntime {
135
144
  const raw = req.body ?? {}, original = this.message(p, raw.messageId);
136
145
  const target = original.toPrincipalId === p.id ? original.fromSessionId
137
146
  : original.integrationOrigin?.integrationId === p.id ? original.toSessionId : null;
147
+ if (target)
148
+ this.sessions.assertMessageTarget(p, target);
138
149
  if (!target || !this.group(p.groupId).sessionIds.includes(target))
139
150
  integrationError('NO_REPLY_TARGET', 'Reply requires your own message and its original live group member', 409);
140
151
  const messages = options.messages.send({ ...this.messageExtras(raw), groupId: p.groupId, fromSessionId: null,
@@ -180,6 +191,7 @@ export class IntegrationRuntime {
180
191
  }));
181
192
  app.get('/sessions/:id', run(async (req) => { const p = principal(req); this.authorize(p, 'session.read'); return { session: publicIntegrationSession(await this.sessions.get(p, req.params.id)) }; }));
182
193
  app.post('/sessions/:id/restore', run(async (req) => { const p = principal(req); this.authorize(p, 'session.restore'); const session = await this.sessions.restore(p, req.params.id, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
194
+ app.post('/sessions/:id/release', run(async (req) => { const p = principal(req); this.authorize(p, 'session.release'); const session = await this.sessions.release(p, req.params.id, req.body?.generation, req.body?.idempotency_key); this.sync(); return { session: publicIntegrationSession(session) }; }));
183
195
  app.get('/events', run((req, res) => {
184
196
  const p = principal(req);
185
197
  this.authorize(p, 'events.read', req.query.group);
@@ -203,7 +215,8 @@ export class IntegrationRuntime {
203
215
  integration_principal: true, group_scoped_permissions: true, durable_event_push: true,
204
216
  event_ack_semantics: 'durable_received', correlated_task_responses: true, accepted_task_comments: true,
205
217
  idempotent_session_create: true, exact_session_restore: true, external_validation: false,
206
- startup_input_conditions: true,
218
+ startup_input_conditions: true, session_runtime_release: true, stable_session_identity: true, session_lifecycle_generation: true,
219
+ session_resume_configuration: true, session_lifecycle_min_version: '1.4.309',
207
220
  integration_policy_update: true,
208
221
  session_restore_diagnostics: true,
209
222
  native_identity_linux_flock_owner: process.platform === 'linux',
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
4
  import { atomicJson, integrationError } from './integrationStore.js';
5
+ import { SessionLifecycleCoordinator, assertMessageRuntime, assertReleaseFacts, assertReleaseGeneration } from './sessionLifecycle.js';
6
+ import { resumeConfigurationFingerprint } from './sessionResumeConfiguration.js';
5
7
  import { NativeResumeOwnerError } from './nativeResumeOwner.js';
6
8
  /** Quote each configured argument as literal shell data, including paths. */
7
9
  export function integrationLaunchCommand(record, restore) {
@@ -50,7 +52,8 @@ export class IntegrationSessions {
50
52
  doc = { version: 1, sessions: [], requests: {} };
51
53
  pending = new Map();
52
54
  listeners = new Set();
53
- restoring = new Set();
55
+ lifecycle = new SessionLifecycleCoordinator();
56
+ mutations = new Map();
54
57
  timers = new Set();
55
58
  stopObservation;
56
59
  stopDeliveryGuard;
@@ -70,8 +73,12 @@ export class IntegrationSessions {
70
73
  if (error.code !== 'ENOENT')
71
74
  throw error;
72
75
  }
76
+ for (const record of this.doc.sessions) {
77
+ record.generation ??= 1;
78
+ record.runtime_present ??= null;
79
+ }
73
80
  for (const record of this.doc.sessions)
74
- if (['starting', 'restoring', 'binding_pending'].includes(record.state) || record.startup_input?.state === 'pending')
81
+ if (['starting', 'restoring', 'binding_pending'].includes(record.state) || record.state === 'releasing' || record.startup_input?.state === 'pending')
75
82
  this.scheduleRefresh(record);
76
83
  // A pre-restart partial match cannot prove continuity of the live screen.
77
84
  for (const record of this.doc.sessions)
@@ -89,12 +96,17 @@ export class IntegrationSessions {
89
96
  }
90
97
  subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
91
98
  snapshot() { return structuredClone(this.doc.sessions); }
92
- save() { atomicJson(this.file, this.doc); for (const listener of this.listeners) {
93
- try {
94
- listener();
99
+ save() {
100
+ if (Buffer.byteLength(JSON.stringify(this.doc)) > 64 * 1024 * 1024)
101
+ integrationError('SESSION_STORAGE_FULL', 'Recovery descriptor journal is full; no new operation was committed', 503);
102
+ atomicJson(this.file, this.doc);
103
+ for (const listener of this.listeners) {
104
+ try {
105
+ listener();
106
+ }
107
+ catch { /* Durable session state is reconciled on restart. */ }
95
108
  }
96
- catch { /* Durable session state is reconciled on restart. */ }
97
- } }
109
+ }
98
110
  saveRecord(record) {
99
111
  const before = this.doc.sessions;
100
112
  this.doc.sessions = [...before.filter(s => s.session_id !== record.session_id), structuredClone(record)];
@@ -139,7 +151,7 @@ export class IntegrationSessions {
139
151
  integrationError('INTEGRATION_SESSION_LIMIT', 'Integration session storage is full', 409);
140
152
  const now = Date.now(), record = { operation_id: randomUUID(), session_id: randomBytes(6).toString('hex'),
141
153
  group_id: principal.groupId, principal_id: principal.id, agent_slug: profile.agentSlug, agent_native_session_id: null,
142
- state: 'starting', error_code: null, cwd, launch_profile: profile.id, profile: structuredClone(profile),
154
+ state: 'starting', generation: 1, runtime_present: null, error_code: null, cwd, launch_profile: profile.id, profile: structuredClone(profile),
143
155
  created_at: now, updated_at: now, deadline: now + this.bindingTimeout, launch_submitted: false };
144
156
  record.startup_input = startupInput(record, now);
145
157
  this.doc.requests[request.scoped] = { hash: request.hash, sessionId: record.session_id };
@@ -156,6 +168,21 @@ export class IntegrationSessions {
156
168
  const promise = (async () => {
157
169
  try {
158
170
  // Store intent before launching. A failed persistence cannot execute.
171
+ if (this.adapter.resumeConfiguration) {
172
+ try {
173
+ const fingerprint = await resumeConfigurationFingerprint(record, this.adapter);
174
+ if (record.resume_configuration_fingerprint && record.resume_configuration_fingerprint !== fingerprint)
175
+ integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery launcher or plugin semantics changed', 409);
176
+ if (!restoring)
177
+ record.resume_configuration_fingerprint = fingerprint;
178
+ }
179
+ catch (error) {
180
+ // Existing creation supports plugins without an exact resume path.
181
+ // They remain launchable, but cannot gain a releasable descriptor.
182
+ if (restoring || error.code !== 'EXACT_RESUME_UNSUPPORTED')
183
+ throw error;
184
+ }
185
+ }
159
186
  record.launch_submitted = true;
160
187
  this.saveRecord(record);
161
188
  const prepared = (binding) => { record.terminal_binding = binding; this.saveRecord(record); };
@@ -187,27 +214,74 @@ export class IntegrationSessions {
187
214
  return structuredClone({ session_id: record.session_id, operation_id: record.operation_id,
188
215
  state: record.state, error_code: record.error_code, diagnostics: record.restore_diagnostics ?? null });
189
216
  }
190
- async restore(principal, id, idempotencyKey) {
217
+ assertRuntimeMessage(id) { assertMessageRuntime(this.doc.sessions.find(s => s.session_id === id)); }
218
+ assertMessageTarget(principal, id) {
219
+ const record = this.doc.sessions.find(s => s.session_id === id && s.group_id === principal.groupId);
220
+ assertMessageRuntime(record);
221
+ }
222
+ async mutate(principal, key, input, action) {
223
+ const request = this.request(principal, key, input);
224
+ if (request.previous?.result) {
225
+ this.lookup(principal, request.previous.sessionId);
226
+ return structuredClone(request.previous.result);
227
+ }
228
+ const active = this.mutations.get(request.scoped);
229
+ if (active) {
230
+ if (active.hash !== request.hash)
231
+ integrationError('IDEMPOTENCY_CONFLICT', 'Operation key parameters changed', 409);
232
+ return active.promise;
233
+ }
234
+ const promise = action().then(result => {
235
+ if (this.doc.requests[request.scoped]) {
236
+ const before = this.doc.requests[request.scoped];
237
+ this.doc.requests[request.scoped] = { ...before, result: structuredClone(result) };
238
+ try {
239
+ this.save();
240
+ }
241
+ catch (error) {
242
+ this.doc.requests[request.scoped] = before;
243
+ throw error;
244
+ }
245
+ }
246
+ return result;
247
+ }).finally(() => this.mutations.delete(request.scoped));
248
+ this.mutations.set(request.scoped, { hash: request.hash, promise });
249
+ return promise;
250
+ }
251
+ async replayRequest(principal, id, request) {
252
+ const record = await (this.pending.get(id) ?? this.get(principal, id));
253
+ if (request.operationId && (record.operation_id !== request.operationId || record.generation !== request.generation))
254
+ integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Original operation result was not committed and a later generation exists; read session get, do not reuse this key', 409);
255
+ return record;
256
+ }
257
+ async restore(principal, id, key) {
258
+ return this.mutate(principal, key, ['restore', id], () => this.lifecycle.run(id, () => this.restoreOnce(principal, id, key)));
259
+ }
260
+ async restoreOnce(principal, id, idempotencyKey) {
191
261
  const request = this.request(principal, idempotencyKey, ['restore', id]);
192
262
  if (request.previous)
193
- return this.pending.get(id) ?? this.get(principal, id);
194
- if (this.pending.has(id) || this.restoring.has(id))
263
+ return this.replayRequest(principal, id, request.previous);
264
+ if (this.pending.has(id))
195
265
  integrationError('SESSION_OPERATION_IN_PROGRESS', 'Another session operation is in progress', 409);
196
- this.restoring.add(id);
266
+ let releaseNative;
197
267
  try {
198
268
  const record = await this.get(principal, id);
199
269
  if (!record.agent_native_session_id)
200
270
  integrationError('NATIVE_SESSION_ID_MISSING', 'An exact native session binding is required', 409);
201
271
  // Recheck the current grant and actual directories; restore uses the
202
272
  // original profile snapshot, never changed model or wrapper defaults.
203
- if (!principal.launchProfiles.some(p => p.id === record.launch_profile))
273
+ const authorizedProfile = principal.launchProfiles.find(p => p.id === record.launch_profile);
274
+ if (!authorizedProfile)
204
275
  integrationError('LAUNCH_PROFILE_DENIED', 'Original launch profile is no longer authorized', 403);
276
+ permittedCwd(authorizedProfile, record.cwd);
205
277
  permittedCwd(record.profile, record.cwd);
278
+ if (record.operation_uncertain || record.state === 'releasing')
279
+ integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Reconcile prior release using session get before restore', 409);
206
280
  const observed = await this.adapter.inspect(record);
207
281
  if (observed.running) {
208
282
  if (observed.agentSlug !== record.agent_slug || observed.nativeId !== record.agent_native_session_id)
209
283
  integrationError('SESSION_IDENTITY_MISMATCH', 'Existing process does not prove the exact native binding', 409);
210
- this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id };
284
+ this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
211
285
  try {
212
286
  this.save();
213
287
  }
@@ -221,6 +295,13 @@ export class IntegrationSessions {
221
295
  integrationError('SESSION_OPERATION_IN_PROGRESS', 'Prior launch outcome is not yet resolved; inspect the original session', 409);
222
296
  if (observed.exists && !observed.shell)
223
297
  integrationError('SESSION_TARGET_NOT_SHELL', 'Original pane is not a verified shell', 409);
298
+ if (record.state === 'released' && !record.resume_configuration_fingerprint)
299
+ integrationError('RESUME_CONFIGURATION_UNCONFIRMED', 'Administrator must explicitly confirm the current recovery configuration', 409);
300
+ releaseNative = this.lifecycle.reserveNative(record, this.doc.sessions);
301
+ if (record.resume_configuration_fingerprint && record.resume_configuration_fingerprint !== await resumeConfigurationFingerprint(record, this.adapter))
302
+ integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery launcher or plugin semantics changed', 409);
303
+ record.generation = (record.generation ?? 1) + 1;
304
+ record.runtime_present = observed.exists;
224
305
  record.state = 'restoring';
225
306
  record.error_code = null;
226
307
  record.operation_id = randomUUID();
@@ -230,7 +311,7 @@ export class IntegrationSessions {
230
311
  if (!record.profile.startupInput && !record.startup_condition)
231
312
  record.startup_condition = structuredClone(principal.launchProfiles.find(profile => profile.id === record.launch_profile)?.startupInput);
232
313
  record.startup_input = startupInput(record, Date.now());
233
- this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id };
314
+ this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
234
315
  try {
235
316
  this.saveRecord(record);
236
317
  }
@@ -241,13 +322,135 @@ export class IntegrationSessions {
241
322
  return await this.launch(record, true);
242
323
  }
243
324
  finally {
244
- this.restoring.delete(id);
325
+ releaseNative?.();
245
326
  }
246
327
  }
328
+ async resumeConfiguration(id) {
329
+ const record = this.doc.sessions.find(s => s.session_id === id);
330
+ if (!record)
331
+ return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Recovery record does not exist', 404);
332
+ return { session_id: id, generation: record.generation ?? 1, recorded_fingerprint: record.resume_configuration_fingerprint ?? null,
333
+ current_fingerprint: await resumeConfigurationFingerprint(record, this.adapter), confirmation_required: !record.resume_configuration_fingerprint };
334
+ }
335
+ async confirmResumeConfiguration(id, generation, fingerprint) {
336
+ return this.lifecycle.run(id, async () => {
337
+ const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
338
+ if (!record)
339
+ return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Recovery record does not exist', 404);
340
+ assertReleaseGeneration(record, generation);
341
+ if (this.pending.has(id) || ['starting', 'restoring', 'binding_pending', 'releasing'].includes(record.state))
342
+ integrationError('SESSION_OPERATION_IN_PROGRESS', 'Resolve the current operation before confirming compatibility', 409);
343
+ if (!/^[a-f0-9]{64}$/.test(fingerprint) || fingerprint !== await resumeConfigurationFingerprint(record, this.adapter))
344
+ integrationError('RESUME_CONFIGURATION_CHANGED', 'Current fingerprint no longer matches the administrator confirmation', 409);
345
+ record.resume_configuration_fingerprint = fingerprint;
346
+ record.generation = generation + 1;
347
+ record.updated_at = Date.now();
348
+ this.saveRecord(record);
349
+ return record;
350
+ });
351
+ }
352
+ async release(principal, id, generation, key) {
353
+ return this.mutate(principal, key, ['release', id, generation], () => this.lifecycle.run(id, async () => {
354
+ const request = this.request(principal, key, ['release', id, generation]);
355
+ if (request.previous)
356
+ return this.replayRequest(principal, id, request.previous);
357
+ if (!this.adapter.release || !this.adapter.runtimePresent)
358
+ integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime adapter does not support release', 409);
359
+ if (this.pending.has(id))
360
+ integrationError('SESSION_OPERATION_IN_PROGRESS', 'Launch is in progress', 409);
361
+ const record = await this.get(principal, id);
362
+ assertReleaseGeneration(record, generation);
363
+ if (record.state !== 'released') {
364
+ assertReleaseFacts(record, await this.adapter.inspect(record));
365
+ if (this.adapter.pendingMessages?.(id))
366
+ integrationError('SESSION_PENDING_MESSAGES', 'Pending messages must be resolved before release', 409);
367
+ if (!record.resume_configuration_fingerprint)
368
+ integrationError('RESUME_CONFIGURATION_UNCONFIRMED', 'Administrator must inspect and confirm the recovery configuration before release', 409);
369
+ if (record.resume_configuration_fingerprint !== await resumeConfigurationFingerprint(record, this.adapter))
370
+ integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery launcher or plugin semantics changed', 409);
371
+ }
372
+ if (record.state === 'released') {
373
+ this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
374
+ try {
375
+ this.save();
376
+ }
377
+ catch (error) {
378
+ delete this.doc.requests[request.scoped];
379
+ throw error;
380
+ }
381
+ return record;
382
+ }
383
+ if (this.adapter.prepareRelease)
384
+ record.group_role = (await this.adapter.prepareRelease(record)).group_role;
385
+ record.state = 'releasing';
386
+ record.operation_id = randomUUID();
387
+ record.generation = generation + 1;
388
+ record.operation_uncertain = false;
389
+ record.error_code = null;
390
+ record.updated_at = Date.now();
391
+ this.doc.requests[request.scoped] = { hash: request.hash, sessionId: id, operationId: record.operation_id, generation: record.generation };
392
+ try {
393
+ this.saveRecord(record);
394
+ }
395
+ catch (error) {
396
+ delete this.doc.requests[request.scoped];
397
+ throw error;
398
+ }
399
+ try {
400
+ await this.adapter.release(record, false);
401
+ if (await this.adapter.runtimePresent(record))
402
+ integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Runtime removal could not be confirmed', 409);
403
+ record.state = 'released';
404
+ record.runtime_present = false;
405
+ record.terminal_binding = null;
406
+ record.startup_input = undefined;
407
+ }
408
+ catch (error) {
409
+ record.state = 'failed';
410
+ record.operation_uncertain = true;
411
+ record.runtime_present = null;
412
+ record.error_code = typeof error.code === 'string' ? error.code : 'SESSION_RELEASE_FAILED';
413
+ }
414
+ record.updated_at = Date.now();
415
+ this.saveRecord(record);
416
+ return record;
417
+ }));
418
+ }
247
419
  async refresh(id) {
248
420
  const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
249
421
  if (!record)
250
422
  return;
423
+ if (record.state === 'released' || record.state === 'releasing' || record.operation_uncertain) {
424
+ if (this.lifecycle.active(id))
425
+ return;
426
+ try {
427
+ if (record.state !== 'released')
428
+ await this.adapter.release?.(record, true);
429
+ const present = await this.adapter.runtimePresent?.(record);
430
+ if (present === undefined)
431
+ integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime presence is unavailable', 409);
432
+ if (record.state !== 'released') {
433
+ if (present)
434
+ integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Prior release still has a runtime; explicit release retry is required', 409);
435
+ // Absent runtime metadata has been reconciled without killing a live runtime.
436
+ }
437
+ else if (present)
438
+ integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'A runtime exists for a released descriptor', 409);
439
+ const next = { ...record, state: 'released', runtime_present: false, terminal_binding: null, startup_input: undefined, operation_uncertain: false, error_code: null };
440
+ if (JSON.stringify(this.doc.sessions.find(s => s.session_id === id)) !== JSON.stringify(record))
441
+ return;
442
+ if (JSON.stringify(next) !== JSON.stringify(record))
443
+ this.saveRecord({ ...next, updated_at: Date.now() });
444
+ }
445
+ catch (error) {
446
+ if (JSON.stringify(this.doc.sessions.find(s => s.session_id === id)) !== JSON.stringify(record))
447
+ return;
448
+ const next = { ...record, state: 'failed', runtime_present: null, operation_uncertain: true, error_code: typeof error.code === 'string' ? error.code : 'OPERATION_OUTCOME_UNCONFIRMED' };
449
+ if (JSON.stringify(next) !== JSON.stringify(record))
450
+ this.saveRecord({ ...next, updated_at: Date.now() });
451
+ }
452
+ return;
453
+ }
251
454
  let observed;
252
455
  try {
253
456
  observed = await this.adapter.inspect(record);
@@ -261,7 +464,7 @@ export class IntegrationSessions {
261
464
  this.saveRecord({ ...record, state: 'failed', error_code: typeof code === 'string' ? code : 'SESSION_OBSERVATION_FAILED', updated_at: Date.now() });
262
465
  return;
263
466
  }
264
- const next = { ...record };
467
+ const next = { ...record, runtime_present: this.adapter.runtimePresent ? await this.adapter.runtimePresent(record) : observed.exists };
265
468
  if (observed.nativeId && (typeof observed.nativeId !== 'string' || observed.nativeId.length > 256 || /[\x00-\x1f\x7f]/.test(observed.nativeId))) {
266
469
  next.state = 'failed';
267
470
  next.error_code = 'NATIVE_SESSION_ID_INVALID';
@@ -325,7 +528,12 @@ export class IntegrationSessions {
325
528
  }
326
529
  /** Only gates configured launches. Native UUID and Agent turn state are not input prerequisites. */
327
530
  async deliveryReadiness(id) {
328
- if (!this.doc.sessions.find(record => record.session_id === id)?.startup_input)
531
+ const existing = this.doc.sessions.find(record => record.session_id === id);
532
+ if (!existing)
533
+ return null;
534
+ if (existing.state === 'released' || existing.state === 'releasing' || existing.operation_uncertain)
535
+ return { allowed: false, reason: existing.state === 'released' ? 'SESSION_RELEASED' : 'SESSION_OPERATION_IN_PROGRESS' };
536
+ if (!existing.startup_input)
329
537
  return null;
330
538
  await this.refresh(id);
331
539
  const record = this.doc.sessions.find(record => record.session_id === id);
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import { CollaborationError } from './collaborationProtocol.js';
5
5
  export const INTEGRATION_PROTOCOL = 1;
6
- export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore'];
6
+ export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore', 'session.release'];
7
7
  export function integrationError(code, message, status = 400) { throw new CollaborationError(code, message, status); }
8
8
  export function atomicJson(file, value) {
9
9
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
@@ -0,0 +1,81 @@
1
+ import { CollaborationError } from './collaborationProtocol.js';
2
+ const fail = (code, message) => { throw new CollaborationError(code, message, 409); };
3
+ /** One coordinator per service. A reservation covers validation and launch;
4
+ * persisted unresolved intents also block a second identity after restart.
5
+ * This does not promise atomic exclusion of independently launched programs. */
6
+ export class SessionLifecycleCoordinator {
7
+ operations = new Set();
8
+ natives = new Map();
9
+ async run(id, action) {
10
+ if (this.operations.has(id))
11
+ fail('SESSION_OPERATION_IN_PROGRESS', 'Another lifecycle operation is in progress');
12
+ this.operations.add(id);
13
+ try {
14
+ return await action();
15
+ }
16
+ finally {
17
+ this.operations.delete(id);
18
+ }
19
+ }
20
+ active(id) { return this.operations.has(id); }
21
+ reserveNative(record, descriptors) {
22
+ const key = JSON.stringify([record.agent_slug, record.agent_native_session_id]);
23
+ const owner = this.natives.get(key);
24
+ if (owner && owner !== record.session_id || descriptors.some(other => other.session_id !== record.session_id
25
+ && other.agent_slug === record.agent_slug && other.agent_native_session_id === record.agent_native_session_id
26
+ && (['starting', 'restoring', 'binding_pending'].includes(other.state) || other.operation_uncertain))) {
27
+ fail('SESSION_NATIVE_OPERATION_IN_PROGRESS', 'Another descriptor has an unresolved operation for this native identity');
28
+ }
29
+ this.natives.set(key, record.session_id);
30
+ return () => { if (this.natives.get(key) === record.session_id)
31
+ this.natives.delete(key); };
32
+ }
33
+ }
34
+ export function assertReleaseGeneration(record, generation) {
35
+ if (!Number.isSafeInteger(generation) || Number(generation) < 1)
36
+ fail('INVALID_SESSION_GENERATION', 'release requires the generation returned by session get');
37
+ if (generation !== (record.generation ?? 1))
38
+ fail('SESSION_GENERATION_MISMATCH', 'Session generation changed; read its current state before releasing');
39
+ }
40
+ export function assertReleaseFacts(record, facts) {
41
+ if (!record.agent_native_session_id)
42
+ fail('NATIVE_SESSION_ID_MISSING', 'Release requires a durable exact native identity');
43
+ if (['starting', 'restoring', 'binding_pending', 'releasing'].includes(record.state))
44
+ fail('SESSION_OPERATION_IN_PROGRESS', 'Prior operation is unresolved');
45
+ if (facts.running)
46
+ fail('SESSION_STILL_RUNNING', 'Agent is still running; explicitly exit it before releasing the runtime');
47
+ if (facts.exists && !facts.shell)
48
+ fail('SESSION_IDENTITY_MISMATCH', 'Runtime is not the verified original empty shell');
49
+ }
50
+ export function assertMessageRuntime(record) {
51
+ if (!record)
52
+ return;
53
+ if (record.state === 'released')
54
+ fail('SESSION_RELEASED', 'Runtime was released; explicitly restore this session before sending');
55
+ if (record.state === 'releasing')
56
+ fail('SESSION_OPERATION_IN_PROGRESS', 'Runtime release is in progress; message was not enqueued');
57
+ if (record.operation_uncertain)
58
+ fail('OPERATION_OUTCOME_UNCONFIRMED', 'Lifecycle outcome needs reconciliation before sending');
59
+ }
60
+ /** Shared release protocol. No Agent idle inference, task result, or UI
61
+ * visibility participates in deciding whether a process may be destroyed. */
62
+ export async function releaseSessionRuntime(adapter, reconcile) {
63
+ await adapter.barrier(async () => {
64
+ if (adapter.pendingMessages())
65
+ fail('SESSION_PENDING_MESSAGES', 'Pending messages appeared before runtime release');
66
+ if (await adapter.exists()) {
67
+ if (reconcile)
68
+ fail('OPERATION_OUTCOME_UNCONFIRMED', 'Original runtime still exists; restart reconciliation will not terminate it');
69
+ await adapter.verifyOwnership();
70
+ const facts = await adapter.inspect();
71
+ if (facts.running)
72
+ fail('SESSION_STILL_RUNNING', 'Agent restarted before release');
73
+ if (!facts.exists || !facts.shell)
74
+ fail('SESSION_IDENTITY_MISMATCH', 'Original runtime is not a verified foreground shell');
75
+ await adapter.destroy();
76
+ }
77
+ if (await adapter.exists())
78
+ fail('OPERATION_OUTCOME_UNCONFIRMED', 'Owned runtime still exists after release');
79
+ await adapter.forgetRuntime();
80
+ });
81
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { CollaborationError } from './collaborationProtocol.js';
4
+ const fail = (code, message) => { throw new CollaborationError(code, message, 409); };
5
+ /** Fingerprint only the launcher file and recovery semantics. This does not
6
+ * claim to fingerprint its dependencies, private Agent binaries, or history. */
7
+ export async function resumeConfigurationFingerprint(record, adapter) {
8
+ const semantics = await adapter.resumeConfiguration?.(record);
9
+ if (semantics === undefined)
10
+ fail('EXACT_RESUME_UNSUPPORTED', 'Adapter does not expose exact recovery semantics');
11
+ try {
12
+ const executable = fs.realpathSync(record.profile.executable);
13
+ const before = fs.statSync(executable);
14
+ if (!before.isFile() || before.size > 512 * 1024 * 1024)
15
+ fail('LAUNCHER_UNAVAILABLE', 'Launcher must be a regular file within the fingerprint size limit');
16
+ fs.accessSync(executable, fs.constants.X_OK);
17
+ const hash = createHash('sha256');
18
+ for await (const chunk of fs.createReadStream(executable))
19
+ hash.update(chunk);
20
+ const after = fs.statSync(executable);
21
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs
22
+ || fs.realpathSync(record.profile.executable) !== executable)
23
+ fail('RESUME_CONFIGURATION_CHANGED', 'Launcher changed while fingerprinting');
24
+ return createHash('sha256').update(JSON.stringify({ version: 1, executable, content: hash.digest('hex'),
25
+ agent: record.agent_slug, argv: record.profile.argv, resumeArgv: record.profile.resumeArgv, cwd: record.cwd, semantics })).digest('hex');
26
+ }
27
+ catch (error) {
28
+ if (error instanceof CollaborationError)
29
+ throw error;
30
+ return fail('LAUNCHER_UNAVAILABLE', 'Recovery launcher could not be read and verified');
31
+ }
32
+ }
@@ -2,6 +2,8 @@ import { collaborationSessionDisplayName } from '../utils/sessionDisplayName.js'
2
2
  import { integrationError } from '../agent/integrationStore.js';
3
3
  import { assertNativeResumeAvailable, collectNativeResumeOwnerCandidates } from '../agent/nativeResumeOwner.js';
4
4
  import { readNativeProcessIdentity } from '../agent/nativeProcessIdentity.js';
5
+ import { releaseSessionRuntime } from '../agent/sessionLifecycle.js';
6
+ import { resumeConfigurationFingerprint } from '../agent/sessionResumeConfiguration.js';
5
7
  import { integrationLaunchCommand } from '../agent/integrationSessions.js';
6
8
  import { ensureTeam } from '../agent/collaborationTeam.js';
7
9
  import { androidRecordings } from '../android/recording.js';
@@ -39,7 +41,7 @@ import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fi
39
41
  import { writeResponseChunk } from '../utils/httpResponse.js';
40
42
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
41
43
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, } from '../utils/tmuxProgramDetection.js';
42
- import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, getPluginNativeIdentityConfig, listAgents, } from '../agent/registry.js';
44
+ import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, getPluginNativeIdentityConfig, getPluginResumeConfig, listAgents, } from '../agent/registry.js';
43
45
  import { applyAgentEvent, agentEventMatchesCurrentAgent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
44
46
  import { canRestoreDeadAgentShell, isConfirmedAgentResumeProcess, normalizePersistedAgentResumeInfo, } from '../agent/resumePersistence.js';
45
47
  import { AgentResumeHistoryStore } from '../agent/resumeHistory.js';
@@ -1861,10 +1863,76 @@ function integrationTerminalRecord(record) {
1861
1863
  integrationError('SESSION_IDENTITY_MISMATCH', 'Owned TD terminal binding changed', 409);
1862
1864
  return terminal;
1863
1865
  }
1866
+ async function integrationTmuxPresent(record) {
1867
+ try {
1868
+ await runTmux(['has-session', '-t', `=wt-integration-${record.session_id}`]);
1869
+ return true;
1870
+ }
1871
+ catch (error) {
1872
+ if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
1873
+ return false;
1874
+ throw error;
1875
+ }
1876
+ }
1864
1877
  export const integrationSessionAdapter = {
1865
1878
  subscribe(listener) { integrationSessionObservers.add(listener); return () => { integrationSessionObservers.delete(listener); }; },
1866
1879
  registerDeliveryGuard(guard) { integrationDeliveryGuard = guard; return () => { if (integrationDeliveryGuard === guard)
1867
1880
  integrationDeliveryGuard = undefined; }; },
1881
+ pendingMessages(id) { return collaborationStore.pendingCount(id); },
1882
+ async prepareRelease(record) {
1883
+ if (collaborationStore.groupsForSession(record.session_id).some(group => group.id !== record.group_id))
1884
+ integrationError('SESSION_SCOPE_CONFLICT', 'Runtime belongs to another collaboration group; administrator must resolve its membership first', 409);
1885
+ return { group_role: collaborationStore.getGroup(record.group_id)?.roles?.[record.session_id] };
1886
+ },
1887
+ async resumeConfiguration(record) {
1888
+ await listDetectedAgentLaunchers();
1889
+ const agent = agentBySlug(record.agent_slug);
1890
+ const command = agent && buildResumeCommand(agent, 'td-fingerprint-native-id', null);
1891
+ if (!agent || !command)
1892
+ integrationError('EXACT_RESUME_UNSUPPORTED', 'Agent must support exact native recovery', 409);
1893
+ return { version: 1, slug: agent.slug, aliases: agent.aliases, capabilities: agent.capabilities ?? [],
1894
+ resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: getPluginNativeIdentityConfig(agent.slug) ?? null };
1895
+ },
1896
+ async runtimePresent(record) {
1897
+ if (integrationTerminalRecord(record))
1898
+ return true;
1899
+ if ([...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`))
1900
+ return true;
1901
+ if (collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id))
1902
+ return true;
1903
+ return integrationTmuxPresent(record);
1904
+ },
1905
+ async release(record, reconcile) {
1906
+ await releaseSessionRuntime({
1907
+ barrier: action => collaborationDeliveryWorker.reconfigure(record.session_id, action),
1908
+ exists: () => integrationTmuxPresent(record),
1909
+ pendingMessages: () => collaborationStore.pendingCount(record.session_id),
1910
+ async verifyOwnership() {
1911
+ await integrationSessionAdapter.prepareRelease(record);
1912
+ const terminal = integrationTerminalRecord(record);
1913
+ if (!terminal || !record.terminal_binding)
1914
+ integrationError('SESSION_IDENTITY_MISMATCH', 'Live runtime lacks its exact persisted binding', 409);
1915
+ if (globalSessionState.sessions.some(other => other.sessionId !== record.session_id && (other.tmuxSessionName === terminal.tmuxSessionName
1916
+ || terminal.backendSessionId && other.backendSessionId === terminal.backendSessionId)))
1917
+ integrationError('SESSION_IDENTITY_MISMATCH', 'Runtime has another logical owner', 409);
1918
+ },
1919
+ inspect: () => integrationSessionAdapter.inspect(record),
1920
+ destroy: async () => { await destroyTmuxSessionSafely(`wt-integration-${record.session_id}`); },
1921
+ async forgetRuntime() {
1922
+ // Only remove exact owned PTY/inventory records after actual tmux absence.
1923
+ integrationTerminalRecord(record);
1924
+ await integrationSessionAdapter.prepareRelease(record);
1925
+ for (const [id, backend] of terminalSessions)
1926
+ if (backend.mode === 'tmux' && backend.tmuxSessionName === `wt-integration-${record.session_id}`)
1927
+ cleanupSession(id, { killProcess: true });
1928
+ removeGlobalSessionRecord(record.session_id);
1929
+ collaborationStore.archiveSession(record.session_id);
1930
+ collaborationRouting.remove(record.session_id);
1931
+ await persistGlobalStateNow();
1932
+ broadcastClientState();
1933
+ },
1934
+ }, reconcile);
1935
+ },
1868
1936
  async capture(record) {
1869
1937
  const observed = await integrationSessionAdapter.inspect(record);
1870
1938
  if (!observed.exists || !observed.running || observed.agentSlug !== record.agent_slug || !record.terminal_binding
@@ -2011,12 +2079,18 @@ async function launchIntegrationTerminal(record, restoring, prepared) {
2011
2079
  if (!latest || latest.deleted)
2012
2080
  integrationError('TASK_GROUP_NOT_FOUND', 'Integration group changed', 409);
2013
2081
  collaborationStore.save({ id: latest.id, name: latest.name, sessionIds: [...new Set([...latest.sessionIds, record.session_id])] });
2082
+ if (restoring && record.group_role)
2083
+ collaborationStore.setRole({ groupId: latest.id, sessionId: record.session_id, role: record.group_role });
2014
2084
  if (restoring)
2015
2085
  upsertGlobalSessionRecord({ ...terminal, agentResume: { slug: record.agent_slug, sessionId: record.agent_native_session_id,
2016
2086
  launchArgv: [record.profile.executable, ...record.profile.argv], updatedAt: Date.now() } });
2017
2087
  await persistGlobalStateNow();
2018
2088
  // Recheck the actual pinned shell immediately before writing. This cannot
2019
2089
  // use an active-pane target which might have changed during preparation.
2090
+ if (record.resume_configuration_fingerprint && record.resume_configuration_fingerprint !== await resumeConfigurationFingerprint(record, integrationSessionAdapter))
2091
+ integrationError('RESUME_CONFIGURATION_CHANGED', 'Recovery configuration changed before terminal input', 409);
2092
+ if (restoring)
2093
+ await assertIntegrationNativeResumeAvailable(record);
2020
2094
  const shell = await inspectCollaborationTmux({ sessionId: record.session_id, backendSessionId: opened.terminalSession.sessionId,
2021
2095
  mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: null, nativeSessionId: null, pane: pinned });
2022
2096
  if (shell.state !== 'ready' || !shell.pane || !await integrationPaneIsIdleShell(shell.pane))
@@ -9,7 +9,8 @@
9
9
  直接 exec 为 pane PID 的原生 argv 核验及可选 Linux 持锁身份要求 **实际服务至少 1.4.306**;
10
10
  后台执行记录与用途迁移要求 **CLI / 实际服务均至少 1.4.307**;
11
11
  普通消息身份发送、持久回复及消息历史要求 **CLI / 实际服务均至少 1.4.308**;
12
- 建议 CLI 与服务同步安装 1.4.308(既有接入命令的 CLI 最低仍为 1.4.301,诊断 CLI 最低为 1.4.305)。
12
+ 释放运行承载、稳定逻辑身份与恢复配置确认要求 **CLI / 实际服务均至少 1.4.309**;
13
+ 建议 CLI 与服务同步安装 1.4.309(既有接入命令的 CLI 最低仍为 1.4.301,诊断 CLI 最低为 1.4.305)。
13
14
  现有 `--session`、消息投递、任务报告与自动协作入口继续兼容;旧的
14
15
  `task create --integration` 仍表示代码集成子任务,新身份使用 `--principal`。
15
16
 
@@ -389,7 +390,7 @@ task 事件 `event_id` 与任务内事件 ID 一致,`respond.reply_to_event_id
389
390
  | `task.delivery` | `{delivery}`:任务投递 id、attemptId、messageId、status、deliveredAt、error |
390
391
  | `message.queued/message.reply` | `{message_id,thread_id,reply_to,from_session_id,to_session_id,to_principal_id,integration_origin,response_kind,stored_at,content,metadata}` |
391
392
  | `message.delivery` | `{receipt}`:实际入队/远端接收/终端写入/后台回复保存阶段、失败、重试计数与时间,及明确回复 ID;不含快照或已读字段 |
392
- | `session.starting/binding_pending/ready/restoring/failed` | `{session}`:下节公开会话记录,失败包含 error_code |
393
+ | `session.starting/binding_pending/ready/restoring/releasing/released/failed` | `{session}`:下节公开会话记录,失败包含 error_code |
393
394
 
394
395
  TD 在源记录提交后触发 journal 及订阅,journal 中的同一事件 ID 在重连重放时保持不变。
395
396
  新 consumer 从最早保留事件开始;接入方按 `event_id` 去重,**先持久写入 inbox,
@@ -438,7 +439,9 @@ agent_native_session_id,state,error_code,cwd,launch_profile,created_at,updated_a
438
439
  | binding_pending | 实际观察到该 Agent,尚未捕获确切原生 ID |
439
440
  | ready | 实际观察到该 Agent 和确切原生 ID;不表示任务空闲/完成 |
440
441
  | restoring | 显式恢复意图持久化,等待原 ID 的运行证据 |
441
- | failed | 启动、绑定、身份核验或运行观察失败,详见 error_code |
442
+ | releasing | 释放意图已落盘;不能接收新投递,尚未证明实际承载回收 |
443
+ | released | 实际进程/PTY/tmux/活动终端登记与本组活动成员已移除;恢复描述保留 |
444
+ | failed | 启动、绑定、身份核验、释放或运行观察失败,详见 error_code |
442
445
 
443
446
  同键同请求返回原 TD ID;已记录但结果不明的启动不会自动重新执行。通常等
444
447
  session 事件;get 也会现场核验。120 秒内未形成绑定返回 `SESSION_BINDING_TIMEOUT`。
@@ -526,6 +529,133 @@ PID、同 ID 拒绝/不同 ID 排除、遗留 owner、错误 start_id/PID、inod
526
529
  持锁、多个 ID 和 argv 冲突。**私有 TraeX 的 1.4.306 身份接入仍需使用方实机复验**。
527
530
  原 `startupInput` 仍独立控制首次写入;核验 native ID 不表示输入初始化完成。
528
531
 
532
+ ### 会话身份与运行承载的生命周期(1.4.309)
533
+
534
+ 这项能力把稳定的会话记录与可回收的终端承载分开。TD 保留逻辑 `session_id`、
535
+ 原生身份、授权归属、原启动配置和消息关联;terminal/backend/tmux/pane 是可重建的
536
+ 运行承载。`released` 不进入活动终端列表,没有常驻 Agent、PTY 或 tmux。
537
+ 它不是隐藏的终端,也不表示删除 Agent 原生历史。原生历史仍由 Agent 管理。
538
+
539
+ 共享内核 `sessionLifecycle.ts` 提供操作串行、原生身份预留、代次和释放验证协议;
540
+ 首批调用者为现有 IntegrationSession/受限 CLI。复用原有持久恢复记录,不新增
541
+ conversation/job/task 或裸 UUID 导入。现有用户关闭、最近关闭列表、任务执行归档
542
+ 保持现有行为,本版尚未将它们迁入该协议。principal 是调用权限,不是业务类型。
543
+
544
+ ```sh
545
+ # 管理员将 session.release 追加到原身份的 permissions,凭据保持不变。
546
+ td integration update --file /private/path/policy.json
547
+ # 后台从 get 获取当前 generation(不能硬编码,也不能仅沿用旧事件中的代次)。
548
+ td collab --principal report-bridge session get <session-id>
549
+ td collab --principal report-bridge session release <session-id> \
550
+ --generation <current-generation> --idempotency-key <this-release-key>
551
+ # 下一条外部消息先持久接收,显式恢复,再 send/reply。
552
+ td collab --principal report-bridge session restore <session-id> \
553
+ --idempotency-key <this-restore-key>
554
+ td collab --principal report-bridge reply <retained-original-message-id> \
555
+ --file /private/path/followup.txt --idempotency-key <this-message-key>
556
+ ```
557
+
558
+ 权限:`session.release` 与 `session.restore` 分开授权,`session.read` 用于状态回读。
559
+ 必须仍属于原 principal/本机组;没有 `session.release` 的旧身份不会获得该能力。
560
+ 运行中 Agent 必须先由调用方明确正常退出;TD 不根据 result、快照、输出间隔或
561
+ 忙闲推断代为终止。首版验证固定的单 pane、原 shell PID/前台进程组和纯交互 shell
562
+ 参数,没有 Agent 或其它前台工作。shell 中人工输入的未执行文字不属于可靠观测,
563
+ 调用方需保证不与人工输入同时操作。无原生 ID、未知进程、进行中的启动/恢复、
564
+ pending 消息或其它组的成员归属均拒绝;不取消 pending、不补投 delivered 正文。
565
+
566
+ 新增公开字段:`generation`(旧记录初始为 1)、`runtime_present`(true/false/null)、
567
+ `operation_uncertain`(可选)、`resume_configuration_fingerprint`(可选)。
568
+ `runtime_present=null` 表示观测/操作结果不明,不能解释为零资源;shell 仍存在时
569
+ 为 true。成功 `released` 必须 `runtime_present=false,terminal_binding=null,error_code=null`。
570
+ 释放保存的原组角色在恢复时重建,组、消息、结果、旧任务不会随释放删除。
571
+
572
+ 实际释放和实际恢复在执行前递增 generation;管理员兼容确认也递增,以使旧观察
573
+ 失效。已经 released 的新键 release、精确运行中的新键 restore 为无动作回读,保持
574
+ 原 operation/generation。release 必须携带 get 返回的当前代次,旧值报
575
+ `SESSION_GENERATION_MISMATCH`;代次不等于消息序号、任务 attempt 或业务周期。
576
+ 从本版开始完成的 release/restore 幂等键重放返回**该操作原结果快照**,可能已不是
577
+ 当前状态;使用 get 查当前状态。不同参数复用键返回 `IDEMPOTENCY_CONFLICT`,
578
+ 下一次实际操作使用新键。若崩溃发生在结果快照提交前,且会话已经进入后续代次,
579
+ 旧键返回 OPERATION_OUTCOME_UNCONFIRMED,不把新操作结果冒充旧结果。升级前的旧幂等
580
+ 记录仍按兼容规则回读当前状态,不重复执行。
581
+
582
+ 同 ID 的生命周期串行;同 Agent/native ID 的验证/启动使用服务内互斥预留,重启后
583
+ 持久的未决启动意图也阻挡另一描述启动。同一键的并发请求共用原操作,不启动两份。
584
+ 原生身份检查继续使用实际 argv/当前持锁证据;该预留不是独立程序之间的全局原子锁。
585
+ 释放先保存意图,再阻断新投递、等待正在进行的投递,重新核验后销毁,最后保存结果。
586
+ 落盘失败不先执行回收;回收失败保留描述并报告 failed,不宣称 released。
587
+
588
+ 重启对 releasing/不确定记录核验实际承载:已经不存在时可以清理残留登记并收口,
589
+ 仍存在时返回 `OPERATION_OUTCOME_UNCONFIRMED`,不会自动杀进程或再启动 Agent。
590
+ 调用方读 get/诊断、明确处理现场后,可用当前 generation 和新键再次请求 release。
591
+ Released 记录若出现未授权重建的承载,同样拒绝静默接管。
592
+ `session.releasing/released/restoring/ready/failed` 推送原 operation_id/generation;
593
+ 沿用现有 event_id 去重、连续持久接收 ACK、断线重放与保留缺口契约。
594
+
595
+ Released 上 send/reply 返回 `SESSION_RELEASED`,不入队、不隐式启动。恢复重建承载后
596
+ 逻辑 ID 不变,原 retained 消息的 reply_to、thread_id 与 principal 关联不需要迁移。
597
+ 恢复返回 ready 只证明原生身份,消息仍受本次 startupInput 保护。恢复失败不得静默
598
+ 创建另一 UUID、使用 --last,或改走新的业务任务/会话。
599
+
600
+ **历史保留边界不变**:普通消息仍只覆盖保留记录(通常最近 2,000 条及待投递/近期
601
+ 幂等保护记录),每组事件仍保留 10,000 条。释放不是无限历史锚点;原消息裁剪后
602
+ reply 返回 `MESSAGE_NOT_FOUND`,不能伪造旧 reply_to。是否用同恢复会话的新普通
603
+ 消息作为新咨询锚点由业务方决定,并保留自己的外部历史。没有 forget 或删除原生
604
+ 历史接口;用户放弃历史与释放承载是不同操作,原生历史删除不在本版范围。
605
+
606
+ #### 恢复配置兼容性与旧记录初始化
607
+
608
+ 新建可精确恢复的记录在启动前保存配置指纹。指纹涵盖当前 launcher 的真实路径、
609
+ 文件 SHA-256、原 argv/resumeArgv/cwd、Agent 别名、精确 resume 语义、插件 nativeIdentity
610
+ 声明。它不是整个私有 Agent 安装包/包装器依赖/原生历史的备份,也不会检查私有
611
+ 会话正文。包装器路径相同不能证明内容相同;文件超过 512 MiB 不支持指纹采集。
612
+ 现有不能精确 resume 的插件仍可创建终端,但不因此获得安全释放能力。
613
+
614
+ 旧记录未保存历史文件内容,升级时只初始化代次,不自动采集为“历史已验证”。
615
+ 第一次 release 前,管理员先读取**当前**指纹,核对原模型/参数/cwd、包装器当前内容
616
+ 和插件恢复语义,再明确授权当前配置用于原历史恢复:
617
+
618
+ ```sh
619
+ td integration resume-configuration <session-id>
620
+ # 返回 generation, recorded_fingerprint(旧记录为 null), current_fingerprint,
621
+ # confirmation_required。无 shell/PTY/状态改变,不返回文件正文或凭据。
622
+ td integration confirm-resume <session-id> \
623
+ --generation <returned-generation> --fingerprint <current_fingerprint>
624
+ ```
625
+
626
+ 确认只证明管理员接受当前兼容性,**不证明未知的旧包装器内容**;不改 argv/profile/
627
+ cwd/native UUID/私有程序。后来指纹不一致时明确失败,管理员也可在核对后用上述
628
+ 步骤确认新兼容指纹;TD 不暗中迁移模型或参数。正在执行生命周期操作时不允许确认。
629
+ 恢复继续使用原 profile 快照;同时按当前授权的 profile/cwdRoots 复核 cwd,撤销或
630
+ 缩小授权不被旧快照绕过。插件精确恢复能力消失或身份/启动条件失败仍会拒绝。
631
+
632
+ 能力检测:CLI/实际服务均至少 1.4.309,协议 1;服务 capabilities 必须包含
633
+ `session_runtime_release=true,stable_session_identity=true,session_lifecycle_generation=true,
634
+ session_resume_configuration=true,session_lifecycle_min_version="1.4.309"`。
635
+ CLI 对旧服务先检查能力,失败为 `SESSION_RELEASE_UNSUPPORTED`,不发送回收请求。
636
+ 本地管理员配置确认同样先探测服务能力。未知或不兼容错误不得转为新会话。
637
+
638
+ | 错误码 | 准确语义及处理 |
639
+ | --- | --- |
640
+ | SESSION_RELEASE_UNSUPPORTED | CLI/实际服务或运行适配器不支持本协议;升级两者 |
641
+ | INVALID_SESSION_GENERATION | 缺少或非法代次;从 get 获取正整数 |
642
+ | SESSION_GENERATION_MISMATCH | 观察已过时;重新读当前记录,不硬改旧请求 |
643
+ | SESSION_STILL_RUNNING | 实际 Agent 仍在运行;不能推断空闲后自动退出 |
644
+ | SESSION_PENDING_MESSAGES | 存在 pending 正文;先明确处理,不清空或重投 |
645
+ | SESSION_OPERATION_IN_PROGRESS | 同 ID 正在操作或原启动未决 |
646
+ | SESSION_NATIVE_OPERATION_IN_PROGRESS | 同原生身份有其它描述的在途/持久未决操作 |
647
+ | SESSION_SCOPE_CONFLICT | 终端还属于其它组;管理员先处理归属 |
648
+ | SESSION_IDENTITY_MISMATCH | 固定终端/pane/PID/布局或 shell 证明不符 |
649
+ | RESUME_CONFIGURATION_UNCONFIRMED | 旧记录缺少兼容确认;管理员核对当前指纹 |
650
+ | RESUME_CONFIGURATION_CHANGED | launcher 内容/路径或插件恢复语义变化;不自动采纳 |
651
+ | OPERATION_OUTCOME_UNCONFIRMED | 释放结果不明或承载重新出现;保留记录并核对 |
652
+ | SESSION_STORAGE_FULL | 恢复记录 journal 超过 64 MiB;停止新增操作并由管理员处理 |
653
+ | SESSION_RELEASE_FAILED | 未分类释放失败;不返回私有异常输出 |
654
+ | SESSION_RELEASED | 消息目标已释放;先显式精确 restore |
655
+
656
+ 原有权限/组/目录、幂等、EXACT_RESUME_UNSUPPORTED、原生重复所有者、启动/绑定错误
657
+ 继续适用;本节不改变这些错误的保护条件。
658
+
529
659
  ### 管理员恢复诊断(1.4.305)
530
660
 
531
661
  `capabilities.session_restore_diagnostics=true` 表示服务支持持久恢复诊断。
@@ -586,6 +716,11 @@ no-op restore 不创建新检查。报告跟随会话保存并可在服务重启
586
716
 
587
717
  ## 验收边界
588
718
 
719
+ 本版维护机验证:共享释放协议、真实 Unix socket 权限/生命周期事件、零入队拒绝、
720
+ 同键并发/旧代次/异常释放/重启对账、配置内容变化与旧记录显式确认、原消息关联;
721
+ 隔离真实 tmux 的实际销毁与原逻辑 ID 重建。本机 fixture 不等于私有 TraeX 验收,
722
+ 本版真实 TraeX 的零承载→同 UUID→真人原话题往返仍待使用方升级后验证。
723
+
589
724
  维护机验证:真实 Unix socket 主动推送/断线重放/撤销、journal 重载与源记录补偿、
590
725
  权限与幂等隔离、accepted 上咨询/关联答复、修订后的新结果、原 profile 恢复和
591
726
  实际 shell 参数透传。适配器测试不冒充真实 Agent 的原生会话验收。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.308",
3
+ "version": "1.4.309",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.308",
4
+ "version": "1.4.309",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-E9GDa4nrCXADRXdyPKufusZt6IcCr4m2mGdx5h34KMQ=",
9
- "serverBundleHash": "sha256-KCZQLf7sIa26nzVvLZBZhHNcitDKANSYJdK523lF3qg=",
9
+ "serverBundleHash": "sha256-3rATyPEt6ztAvF1p3T03N1mo2A1qIKkVUePIG0xc53w=",
10
10
  "clientBundleHash": "sha256-Yh0fz4oTrM6A2pNHLYx+Y67/R7Wfa84cFlIPMrwAsfE=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"