@yeaft/webchat-agent 1.0.414 → 1.0.416

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.
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'fs/promises';
2
2
  import { join, resolve } from 'path';
3
3
  import ctx from '../context.js';
4
4
  import { execAsync, resolveAndValidatePath, getGitRoot, validateGitPath } from './utils.js';
5
+ import { sendWorkbenchResult } from './request-routing.js';
5
6
 
6
7
  export async function handleGitStatus(msg) {
7
8
  const { conversationId, _requestUserId } = msg;
@@ -58,7 +59,7 @@ export async function handleGitStatus(msg) {
58
59
  files.push({ path: displayPath, indexStatus, workTreeStatus });
59
60
  }
60
61
 
61
- ctx.sendToServer({
62
+ sendWorkbenchResult(ctx, msg, {
62
63
  type: 'git_status_result',
63
64
  conversationId,
64
65
  _requestUserId,
@@ -70,7 +71,7 @@ export async function handleGitStatus(msg) {
70
71
  gitRoot
71
72
  });
72
73
  } catch (e) {
73
- ctx.sendToServer({
74
+ sendWorkbenchResult(ctx, msg, {
74
75
  type: 'git_status_result',
75
76
  conversationId,
76
77
  _requestUserId,
@@ -88,7 +89,7 @@ export async function handleGitDiff(msg) {
88
89
  try {
89
90
  // 安全检查:验证 filePath 不包含 shell 注入字符
90
91
  if (!filePath || /[`$;|&><!\n\r]/.test(filePath)) {
91
- ctx.sendToServer({
92
+ sendWorkbenchResult(ctx, msg, {
92
93
  type: 'git_diff_result',
93
94
  conversationId,
94
95
  _requestUserId,
@@ -114,7 +115,7 @@ export async function handleGitDiff(msg) {
114
115
  const fullPath = resolve(gitRoot, filePath);
115
116
  const resolved = resolveAndValidatePath(fullPath, gitRoot);
116
117
  const content = await readFile(resolved, 'utf-8');
117
- ctx.sendToServer({
118
+ sendWorkbenchResult(ctx, msg, {
118
119
  type: 'git_diff_result',
119
120
  conversationId,
120
121
  _requestUserId,
@@ -148,7 +149,7 @@ export async function handleGitDiff(msg) {
148
149
  windowsHide: true
149
150
  });
150
151
  if (cachedOut.trim()) {
151
- ctx.sendToServer({
152
+ sendWorkbenchResult(ctx, msg, {
152
153
  type: 'git_diff_result',
153
154
  conversationId,
154
155
  _requestUserId,
@@ -167,7 +168,7 @@ export async function handleGitDiff(msg) {
167
168
  windowsHide: true
168
169
  });
169
170
  if (wtOut.trim()) {
170
- ctx.sendToServer({
171
+ sendWorkbenchResult(ctx, msg, {
171
172
  type: 'git_diff_result',
172
173
  conversationId,
173
174
  _requestUserId,
@@ -179,7 +180,7 @@ export async function handleGitDiff(msg) {
179
180
  }
180
181
  }
181
182
 
182
- ctx.sendToServer({
183
+ sendWorkbenchResult(ctx, msg, {
183
184
  type: 'git_diff_result',
184
185
  conversationId,
185
186
  _requestUserId,
@@ -188,7 +189,7 @@ export async function handleGitDiff(msg) {
188
189
  diff: stdout
189
190
  });
190
191
  } catch (e) {
191
- ctx.sendToServer({
192
+ sendWorkbenchResult(ctx, msg, {
192
193
  type: 'git_diff_result',
193
194
  conversationId,
194
195
  _requestUserId,
@@ -210,15 +211,15 @@ export async function handleGitAdd(msg) {
210
211
  await execAsync('git add -A', { cwd: gitRoot, timeout: 10000, windowsHide: true });
211
212
  } else {
212
213
  if (!validateGitPath(filePath)) {
213
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: 'Invalid file path' });
214
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: 'Invalid file path' });
214
215
  return;
215
216
  }
216
217
  await execAsync(`git add -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
217
218
  }
218
219
 
219
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: true, message: addAll ? 'All files staged' : `Staged: ${filePath}` });
220
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: true, message: addAll ? 'All files staged' : `Staged: ${filePath}` });
220
221
  } catch (e) {
221
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: e.message });
222
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'add', success: false, error: e.message });
222
223
  }
223
224
  }
224
225
 
@@ -234,15 +235,15 @@ export async function handleGitReset(msg) {
234
235
  await execAsync('git reset HEAD', { cwd: gitRoot, timeout: 10000, windowsHide: true });
235
236
  } else {
236
237
  if (!validateGitPath(filePath)) {
237
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: 'Invalid file path' });
238
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: 'Invalid file path' });
238
239
  return;
239
240
  }
240
241
  await execAsync(`git reset HEAD -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
241
242
  }
242
243
 
243
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: true, message: resetAll ? 'All files unstaged' : `Unstaged: ${filePath}` });
244
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: true, message: resetAll ? 'All files unstaged' : `Unstaged: ${filePath}` });
244
245
  } catch (e) {
245
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: e.message });
246
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'reset', success: false, error: e.message });
246
247
  }
247
248
  }
248
249
 
@@ -253,15 +254,15 @@ export async function handleGitRestore(msg) {
253
254
 
254
255
  try {
255
256
  if (!validateGitPath(filePath)) {
256
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: 'Invalid file path' });
257
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: 'Invalid file path' });
257
258
  return;
258
259
  }
259
260
 
260
261
  const gitRoot = await getGitRoot(workDir);
261
262
  await execAsync(`git restore -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
262
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: true, message: `Restored: ${filePath}` });
263
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: true, message: `Restored: ${filePath}` });
263
264
  } catch (e) {
264
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: e.message });
265
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: false, error: e.message });
265
266
  }
266
267
  }
267
268
 
@@ -272,7 +273,7 @@ export async function handleGitCommit(msg) {
272
273
 
273
274
  try {
274
275
  if (!commitMessage || !commitMessage.trim()) {
275
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: 'Commit message is required' });
276
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: 'Commit message is required' });
276
277
  return;
277
278
  }
278
279
 
@@ -286,13 +287,13 @@ export async function handleGitCommit(msg) {
286
287
  const { stdout } = await execAsync(`git commit -F "${tmpFile}"`, {
287
288
  cwd: gitRoot, timeout: 30000, windowsHide: true
288
289
  });
289
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: true, message: stdout.trim() });
290
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: true, message: stdout.trim() });
290
291
  } finally {
291
292
  // Clean up temp file
292
293
  try { await writeFile(tmpFile, '', 'utf8'); } catch {}
293
294
  }
294
295
  } catch (e) {
295
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: e.stderr?.trim() || e.message });
296
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'commit', success: false, error: e.stderr?.trim() || e.message });
296
297
  }
297
298
  }
298
299
 
@@ -306,8 +307,8 @@ export async function handleGitPush(msg) {
306
307
  const { stdout, stderr } = await execAsync('git push', {
307
308
  cwd: gitRoot, timeout: 60000, windowsHide: true
308
309
  });
309
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: true, message: (stdout + '\n' + stderr).trim() || 'Push complete' });
310
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: true, message: (stdout + '\n' + stderr).trim() || 'Push complete' });
310
311
  } catch (e) {
311
- ctx.sendToServer({ type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: false, error: e.stderr?.trim() || e.message });
312
+ sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'push', success: false, error: e.stderr?.trim() || e.message });
312
313
  }
313
314
  }
@@ -0,0 +1,16 @@
1
+ export function workbenchRequestRouting(source) {
2
+ return {
3
+ ...(source?._workbenchRequestId ? { _workbenchRequestId: source._workbenchRequestId } : {}),
4
+ ...(source?.workbenchRouteKey ? { workbenchRouteKey: source.workbenchRouteKey } : {}),
5
+ ...(source?.workbenchWorkspaceGeneration
6
+ ? { workbenchWorkspaceGeneration: source.workbenchWorkspaceGeneration }
7
+ : {}),
8
+ };
9
+ }
10
+
11
+ export function sendWorkbenchResult(ctx, request, result) {
12
+ ctx.sendToServer({
13
+ ...result,
14
+ ...workbenchRequestRouting(request),
15
+ });
16
+ }
package/yeaft/cli.js CHANGED
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { createInterface } from 'readline';
27
27
  import { randomUUID } from 'node:crypto';
28
+ import { realpathSync } from 'node:fs';
28
29
  import { resolve } from 'node:path';
29
30
  import { fileURLToPath } from 'node:url';
30
31
  import { join } from 'path';
@@ -35,13 +36,16 @@ import { listModels, resolveModel, parseModelRef, resolveContextWindow, resolveM
35
36
  import { buildSystemPrompt } from './prompts.js';
36
37
  import { searchMessages } from './conversation/search.js';
37
38
  import { ConversationStore } from './conversation/persist.js';
38
- import { snapshotSessions } from './sessions/session-crud.js';
39
+ import { sessionsRoot, snapshotSessions } from './sessions/session-crud.js';
39
40
  import { loadSessionConfig, resolveSessionConfig } from './sessions/session-config.js';
41
+ import { createSession } from './sessions/session-store.js';
42
+ import { addOrUpdateManifestSession, withSessionManifestLock } from './sessions/session-manifest.js';
40
43
  import { validateSessionId } from './sessions/ids.js';
41
44
  import {
42
45
  createJsonlWriter,
43
46
  JsonlInput,
44
47
  normalizeStreamRoutingIntent,
48
+ normalizeStreamSessionBootstrap,
45
49
  runStreamTurn,
46
50
  runStreamSessionTurn,
47
51
  } from './stdio-protocol.js';
@@ -900,6 +904,7 @@ async function runStreamJson(config, args) {
900
904
  let taskEventIntakeOpen = true;
901
905
  let hadError = false;
902
906
  let singleEngineTail = Promise.resolve();
907
+ let streamSessionBootstrapOpen = false;
903
908
 
904
909
  const loadStreamHistory = () => conversationStore.loadRecentBySession(sessionId, 20).map(message => ({
905
910
  role: message.role,
@@ -1033,6 +1038,16 @@ async function runStreamJson(config, args) {
1033
1038
 
1034
1039
  configureStreamEngine(engine, null);
1035
1040
  sessionRunner = createCliSessionRunner({ loaded, sessionId, workDir, configureEngine: configureStreamEngine });
1041
+ if (sessionRunner) {
1042
+ snapshotSessions(loaded.yeaftDir);
1043
+ const meta = sessionRunner.meta;
1044
+ if (meta) addOrUpdateManifestSession(
1045
+ loaded.yeaftDir,
1046
+ meta,
1047
+ join(sessionsRoot(loaded.yeaftDir), sessionId),
1048
+ );
1049
+ }
1050
+ streamSessionBootstrapOpen = !sessionRunner;
1036
1051
 
1037
1052
  write({
1038
1053
  type: 'system',
@@ -1091,6 +1106,47 @@ async function runStreamJson(config, args) {
1091
1106
  };
1092
1107
 
1093
1108
  const runPrompt = async (prompt, message = null) => {
1109
+ if (streamSessionBootstrapOpen) {
1110
+ streamSessionBootstrapOpen = false;
1111
+ const bootstrap = normalizeStreamSessionBootstrap(message);
1112
+ if (bootstrap) {
1113
+ let workspaceKey = '';
1114
+ try { workspaceKey = realpathSync(resolve(workDir)); } catch { /* leave empty */ }
1115
+ withSessionManifestLock(loaded.yeaftDir, () => {
1116
+ sessionRunner = createCliSessionRunner({
1117
+ loaded,
1118
+ sessionId,
1119
+ workDir,
1120
+ configureEngine: configureStreamEngine,
1121
+ });
1122
+ if (sessionRunner) return;
1123
+ const handle = createSession(sessionsRoot(loaded.yeaftDir), {
1124
+ id: sessionId,
1125
+ name: sessionId,
1126
+ roster: bootstrap.roster,
1127
+ defaultVpId: bootstrap.defaultVpId,
1128
+ workDir,
1129
+ workspaceKey,
1130
+ });
1131
+ handle.close();
1132
+ });
1133
+ if (!sessionRunner) {
1134
+ sessionRunner = createCliSessionRunner({
1135
+ loaded,
1136
+ sessionId,
1137
+ workDir,
1138
+ configureEngine: configureStreamEngine,
1139
+ });
1140
+ }
1141
+ if (!sessionRunner) throw new Error(`Failed to initialize formal stream-json Session ${sessionId}`);
1142
+ const meta = sessionRunner.meta;
1143
+ if (meta) addOrUpdateManifestSession(
1144
+ loaded.yeaftDir,
1145
+ meta,
1146
+ join(sessionsRoot(loaded.yeaftDir), sessionId),
1147
+ );
1148
+ }
1149
+ }
1094
1150
  const routingIntent = normalizeStreamRoutingIntent(message);
1095
1151
  if (routingIntent && !sessionRunner) {
1096
1152
  throw new Error('stream-json VP selectors require an existing formal Session with a persisted roster');
@@ -15,18 +15,25 @@
15
15
  import {
16
16
  cpSync,
17
17
  existsSync,
18
+ linkSync,
18
19
  mkdirSync,
19
20
  readFileSync,
20
21
  readdirSync,
21
22
  rmSync,
22
23
  statSync,
24
+ unlinkSync,
23
25
  writeFileSync,
24
26
  } from 'fs';
27
+ import { randomUUID } from 'node:crypto';
25
28
  import { join } from 'path';
26
29
  import { loadSessionMeta } from './session-store.js';
30
+ import { writeAtomic } from '../storage/atomic.js';
27
31
 
28
32
  export const SESSIONS_MANIFEST_FILE = 'sessions-manifest.json';
29
33
  const MANIFEST_VERSION = 1;
34
+ const MANIFEST_LOCK_FILE = 'sessions-manifest.lock';
35
+ const MANIFEST_LOCK_WAIT_MS = 5_000;
36
+ const lockWaitArray = new Int32Array(new SharedArrayBuffer(4));
30
37
 
31
38
  export function sessionManifestPath(yeaftDir) {
32
39
  return join(yeaftDir, SESSIONS_MANIFEST_FILE);
@@ -62,6 +69,10 @@ export function loadSessionsManifest(yeaftDir) {
62
69
  }
63
70
 
64
71
  export function writeSessionsManifest(yeaftDir, sessions) {
72
+ return withManifestLock(yeaftDir, () => writeSessionsManifestUnlocked(yeaftDir, sessions));
73
+ }
74
+
75
+ function writeSessionsManifestUnlocked(yeaftDir, sessions) {
65
76
  if (!yeaftDir) throw new Error('yeaftDir required');
66
77
  mkdirSync(yeaftDir, { recursive: true });
67
78
  const manifest = {
@@ -69,7 +80,7 @@ export function writeSessionsManifest(yeaftDir, sessions) {
69
80
  generatedAt: new Date().toISOString(),
70
81
  sessions: dedupeSessions(sessions).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))),
71
82
  };
72
- writeFileSync(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
83
+ writeAtomic(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
73
84
  return manifest;
74
85
  }
75
86
 
@@ -171,21 +182,114 @@ export function ensureSessionsManifest(yeaftDir, options) {
171
182
  }
172
183
  }
173
184
 
174
- const manifest = writeSessionsManifest(yeaftDir, buildManifestFromLocalSessions(yeaftDir, root));
175
- return { created: true, migrated, skipped, migratedIds, skippedIds, manifest };
185
+ return withManifestLock(yeaftDir, () => {
186
+ const current = loadSessionsManifest(yeaftDir);
187
+ const manifest = writeSessionsManifestUnlocked(yeaftDir, [
188
+ ...(current?.sessions || []),
189
+ ...buildManifestFromLocalSessions(yeaftDir, root),
190
+ ]);
191
+ return {
192
+ created: !current,
193
+ migrated,
194
+ skipped,
195
+ migratedIds,
196
+ skippedIds,
197
+ manifest,
198
+ };
199
+ });
176
200
  }
177
201
 
178
202
  export function addOrUpdateManifestSession(yeaftDir, meta, dir) {
179
- const current = loadSessionsManifest(yeaftDir) || { sessions: [] };
180
- const rows = current.sessions.filter(row => row.id !== meta.id);
181
- rows.push(manifestRowFromMeta(meta, dir));
182
- return writeSessionsManifest(yeaftDir, rows);
203
+ return withManifestLock(yeaftDir, () => {
204
+ const current = loadSessionsManifest(yeaftDir);
205
+ const recovered = current?.sessions
206
+ || buildManifestFromLocalSessions(yeaftDir, join(yeaftDir, 'sessions'));
207
+ const rows = recovered.filter(row => row.id !== meta.id);
208
+ rows.push(manifestRowFromMeta(meta, dir));
209
+ return writeSessionsManifestUnlocked(yeaftDir, rows);
210
+ });
183
211
  }
184
212
 
185
213
  export function removeManifestSession(yeaftDir, sessionId) {
186
- const current = loadSessionsManifest(yeaftDir);
187
- if (!current) return null;
188
- return writeSessionsManifest(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
214
+ return withManifestLock(yeaftDir, () => {
215
+ const current = loadSessionsManifest(yeaftDir);
216
+ if (!current) return null;
217
+ return writeSessionsManifestUnlocked(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
218
+ });
219
+ }
220
+
221
+ export function withSessionManifestLock(yeaftDir, operation) {
222
+ if (!yeaftDir) throw new Error('yeaftDir required');
223
+ mkdirSync(yeaftDir, { recursive: true });
224
+ const lockFile = join(yeaftDir, MANIFEST_LOCK_FILE);
225
+ const deadline = Date.now() + MANIFEST_LOCK_WAIT_MS;
226
+ const token = randomUUID();
227
+ const ownerFile = `${lockFile}.owner.${process.pid}.${token}`;
228
+ const owner = { pid: process.pid, token, ownerFile };
229
+ writeFileSync(ownerFile, `${JSON.stringify(owner)}\n`, { flag: 'wx' });
230
+ for (;;) {
231
+ try {
232
+ linkSync(ownerFile, lockFile);
233
+ break;
234
+ } catch (error) {
235
+ if (error?.code !== 'EEXIST') {
236
+ try { unlinkSync(ownerFile); } catch {}
237
+ throw error;
238
+ }
239
+ reapDeadManifestLock(lockFile);
240
+ if (Date.now() >= deadline) {
241
+ try { unlinkSync(ownerFile); } catch {}
242
+ throw new Error('Timed out waiting for Session manifest lock');
243
+ }
244
+ Atomics.wait(lockWaitArray, 0, 0, 25);
245
+ }
246
+ }
247
+ try {
248
+ return operation();
249
+ } finally {
250
+ try {
251
+ const current = JSON.parse(readFileSync(lockFile, 'utf8'));
252
+ if (current.token === owner.token) unlinkSync(lockFile);
253
+ } catch {
254
+ // A missing lock means another process already recovered this owner.
255
+ }
256
+ try { unlinkSync(ownerFile); } catch {}
257
+ }
258
+ }
259
+
260
+ function withManifestLock(yeaftDir, operation) {
261
+ return withSessionManifestLock(yeaftDir, operation);
262
+ }
263
+
264
+ function reapDeadManifestLock(lockFile) {
265
+ let observed;
266
+ try {
267
+ observed = JSON.parse(readFileSync(lockFile, 'utf8'));
268
+ } catch {
269
+ return;
270
+ }
271
+ if (!Number.isInteger(observed.pid) || typeof observed.token !== 'string') return;
272
+ try {
273
+ process.kill(observed.pid, 0);
274
+ return;
275
+ } catch (error) {
276
+ if (error?.code === 'EPERM') return;
277
+ }
278
+ const claim = `${lockFile}.reap.${process.pid}.${randomUUID()}`;
279
+ try {
280
+ linkSync(lockFile, claim);
281
+ const claimed = JSON.parse(readFileSync(claim, 'utf8'));
282
+ if (claimed.token !== observed.token) return;
283
+ unlinkSync(lockFile);
284
+ const expectedOwnerFile = `${lockFile}.owner.${observed.pid}.${observed.token}`;
285
+ if (observed.ownerFile === expectedOwnerFile) {
286
+ try { unlinkSync(expectedOwnerFile); } catch {}
287
+ }
288
+ } catch {
289
+ // Another process either recovered or replaced the observed lock.
290
+ } finally {
291
+ try { unlinkSync(claim); } catch {}
292
+ }
189
293
  }
190
294
 
191
295
  function manifestRowFromMeta(meta, dir) {
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { isReservedVpId, validateVpId } from './sessions/ids.js';
3
4
 
4
5
  const TERMINAL_STOP_REASONS = new Set([
5
6
  'end_turn', 'max_tokens', 'stop_sequence', 'aborted', 'error', 'tool_handoff', 'plan_recorded',
@@ -78,6 +79,62 @@ export function normalizeStreamRoutingIntent(message) {
78
79
  });
79
80
  }
80
81
 
82
+ /**
83
+ * Read the opt-in formal Session seed supplied by an integration's first
84
+ * stream-json prompt. Existing Sessions keep their persisted roster; this only
85
+ * establishes a canonical roster for a previously unknown session id.
86
+ */
87
+ export function normalizeStreamSessionBootstrap(message) {
88
+ if (!message || typeof message !== 'object') return null;
89
+ const hasRoster = Object.hasOwn(message, 'roster');
90
+ const hasVps = Object.hasOwn(message, 'vps');
91
+ const hasDefault = Object.hasOwn(message, 'defaultVpId');
92
+ if (!hasRoster && !hasVps && !hasDefault) return null;
93
+ if (!hasRoster && !hasVps) {
94
+ throw new Error('stream-json defaultVpId requires roster or vps');
95
+ }
96
+
97
+ const readRoster = (key) => {
98
+ const value = message[key];
99
+ if (!Array.isArray(value)) throw new Error(`stream-json ${key} must be an array`);
100
+ return value.slice();
101
+ };
102
+ const roster = hasRoster ? readRoster('roster') : readRoster('vps');
103
+ if (hasRoster && hasVps) {
104
+ const vps = readRoster('vps');
105
+ if (vps.length !== roster.length || vps.some((vpId, index) => vpId !== roster[index])) {
106
+ throw new Error('stream-json roster and vps must contain the same VP ids in the same order');
107
+ }
108
+ }
109
+
110
+ const seen = new Set();
111
+ for (const vpId of roster) {
112
+ const verdict = validateVpId(vpId);
113
+ if (!verdict.ok || isReservedVpId(vpId)) {
114
+ throw new Error(`stream-json roster contains invalid VP id ${JSON.stringify(vpId)} (${verdict.reason || 'reserved'})`);
115
+ }
116
+ if (seen.has(vpId)) throw new Error(`stream-json roster contains duplicate VP id ${vpId}`);
117
+ seen.add(vpId);
118
+ }
119
+
120
+ let defaultVpId = roster[0] || null;
121
+ if (hasDefault && message.defaultVpId != null) {
122
+ defaultVpId = message.defaultVpId;
123
+ const verdict = validateVpId(defaultVpId);
124
+ if (!verdict.ok || isReservedVpId(defaultVpId)) {
125
+ throw new Error(`stream-json defaultVpId is invalid (${verdict.reason || 'reserved'})`);
126
+ }
127
+ if (!seen.has(defaultVpId)) {
128
+ throw new Error(`stream-json defaultVpId ${defaultVpId} is not in roster`);
129
+ }
130
+ }
131
+
132
+ return Object.freeze({
133
+ roster: Object.freeze(roster),
134
+ defaultVpId,
135
+ });
136
+ }
137
+
81
138
  export function createJsonlWriter(output = process.stdout) {
82
139
  return event => { output.write(`${JSON.stringify(event)}\n`); };
83
140
  }