@yeaft/webchat-agent 0.1.954 → 0.1.956

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.954",
3
+ "version": "0.1.956",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -1339,7 +1339,7 @@ export class Engine {
1339
1339
  * string-prompt shape (no regression for existing callers).
1340
1340
  * @yields {EngineEvent}
1341
1341
  */
1342
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1342
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1343
1343
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1344
1344
  yield {
1345
1345
  type: 'error',
@@ -1402,7 +1402,7 @@ export class Engine {
1402
1402
 
1403
1403
  try {
1404
1404
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1405
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy });
1405
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy });
1406
1406
  } finally {
1407
1407
  if (signal) {
1408
1408
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1422,7 +1422,7 @@ export class Engine {
1422
1422
  * in a try/finally without indenting the whole loop.
1423
1423
  * @private
1424
1424
  */
1425
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null }) {
1425
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null }) {
1426
1426
 
1427
1427
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1428
1428
  ? collabToolPolicy
@@ -1511,12 +1511,13 @@ export class Engine {
1511
1511
  : [];
1512
1512
 
1513
1513
  // ─── Active Scope (DESIGN-PROMPT §3 ④) ──────────────────────
1514
- // Structured per-turn scope summary: group + vp + envelope routing
1514
+ // Structured per-turn scope summary: session + vp + members + envelope routing
1515
1515
  // info. Long-form scope content lives in AMS — this block carries
1516
1516
  // only IDs + tiny labels. (Feature scope retired 2026-05-13.)
1517
1517
  const activeScope = {
1518
1518
  sessionId: sessionId || '',
1519
1519
  vpId: ownVpIdForAms || '',
1520
+ members: Array.isArray(sessionMembers) ? sessionMembers : [],
1520
1521
  envelope: inboundEnvelope || null,
1521
1522
  };
1522
1523
 
package/yeaft/prompts.js CHANGED
@@ -9,12 +9,12 @@
9
9
  *
10
10
  * Concept layering (DESIGN-PROMPT §3):
11
11
  * ① Identity — VP persona body (or Yeaft fallback)
12
- * ② Rules — group announcement, date, mode template, tools,
12
+ * ② Rules — session announcement, date, mode template, tools,
13
13
  * tool-guidance, skills, common rules
14
14
  * ③ Memory — single block produced upstream by the AMS render
15
15
  * outlet and threaded through here as `memoryInjection`
16
16
  * ④ Active Scope — structured per-turn scope summary
17
- * (group / vp / envelope IDs)
17
+ * (session / vp / members / envelope IDs)
18
18
  *
19
19
  * The compact summary, user_profile, and core_memory blocks that used to
20
20
  * live inside the system prompt are GONE. Compact summary is now part of
@@ -256,11 +256,11 @@ export function normalizePromptLanguage(language) {
256
256
  *
257
257
  * Prompt structure (DESIGN-PROMPT §3):
258
258
  * ① Identity — Core identity (persona or Yeaft fallback)
259
- * ② Rules — Group announcement, date, mode, tools, guidance, skills
259
+ * ② Rules — Session announcement, date, mode, tools, guidance, skills
260
260
  * ③ Memory — Single block produced by the AMS render outlet
261
261
  * (callers pass it as `memoryInjection`).
262
262
  * ④ Active Scope — Structured per-turn scope summary
263
- * (group / vp / envelope IDs).
263
+ * (session / vp / members / envelope IDs).
264
264
  * (The previous standalone user_profile / core_memory blocks are
265
265
  * gone — those signals now arrive through AMS Resident. Task
266
266
  * context (`taskCtx`) was wired into Active Scope by task-334e
@@ -270,6 +270,7 @@ export function normalizePromptLanguage(language) {
270
270
  * @param {object} [activeScope] — structured scope summary for this turn
271
271
  * @param {string} [activeScope.sessionId]
272
272
  * @param {string} [activeScope.vpId]
273
+ * @param {string[]} [activeScope.members] current session roster
273
274
  * @param {object} [activeScope.envelope] inbound routing info (sender, intent)
274
275
  *
275
276
  * @param {{
@@ -337,8 +338,8 @@ export function buildSystemPrompt({
337
338
  parts.push(`${docHeader}\n${introLine}${docText}`);
338
339
  }
339
340
 
340
- // ─── 1.5 Group Announcement (CLAUDE.md-style shared prefix) ───
341
- // When a group has set an announcement, every VP in the group sees it
341
+ // ─── 1.5 Session Announcement (CLAUDE.md-style shared prefix) ───
342
+ // When a session has set an announcement, every VP in the session sees it
342
343
  // near the top of the system prompt — before tools, memory, mode-specific
343
344
  // instructions. Empty/whitespace = no block emitted.
344
345
  const annText = (typeof sessionAnnouncement === 'string') ? sessionAnnouncement.trim() : '';
@@ -482,9 +483,10 @@ function hasCjk(text) {
482
483
  *
483
484
  * Schema:
484
485
  * ## active_scope
485
- * group: <sessionId> (omitted when missing)
486
- * vp: <vpId> (omitted when missing)
487
- * envelope: from=<sender> intent=<intent> (omitted when no envelope)
486
+ * session: <sessionId> (omitted when missing)
487
+ * vp: <vpId> (omitted when missing)
488
+ * members: <vpId>, <vpId> (omitted when missing)
489
+ * envelope: from=<sender> intent=<intent> (omitted when no envelope)
488
490
  *
489
491
  * Returns '' when the input has no useful field — we don't emit an empty
490
492
  * header. (`featureId`/`featureTitle` fields were removed 2026-05-13 along
@@ -493,6 +495,7 @@ function hasCjk(text) {
493
495
  * @param {object} [activeScope]
494
496
  * @param {string} [activeScope.sessionId]
495
497
  * @param {string} [activeScope.vpId]
498
+ * @param {string[]} [activeScope.members] current session roster
496
499
  * @param {object} [activeScope.envelope] inbound routing summary
497
500
  * @param {object} lang
498
501
  * @returns {string}
@@ -501,16 +504,19 @@ function renderActiveScope(activeScope, lang) {
501
504
  if (!activeScope || typeof activeScope !== 'object') return '';
502
505
 
503
506
  const lines = [];
504
- const group = typeof activeScope.sessionId === 'string' && activeScope.sessionId.trim()
507
+ const session = typeof activeScope.sessionId === 'string' && activeScope.sessionId.trim()
505
508
  ? activeScope.sessionId.trim()
506
509
  : '';
507
- if (group) lines.push(`group: ${group}`);
510
+ if (session) lines.push(`session: ${session}`);
508
511
 
509
512
  const vp = typeof activeScope.vpId === 'string' && activeScope.vpId.trim()
510
513
  ? activeScope.vpId.trim()
511
514
  : '';
512
515
  if (vp) lines.push(`vp: ${vp}`);
513
516
 
517
+ const membersLine = renderSessionMembersLine(activeScope.members);
518
+ if (membersLine) lines.push(`members: ${membersLine}`);
519
+
514
520
  const envLine = renderEnvelopeLine(activeScope.envelope);
515
521
  if (envLine) lines.push(`envelope: ${envLine}`);
516
522
 
@@ -519,6 +525,21 @@ function renderActiveScope(activeScope, lang) {
519
525
  return `${lang.activeScopeHeader}\n${lines.join('\n')}`;
520
526
  }
521
527
 
528
+
529
+ function renderSessionMembersLine(members) {
530
+ if (!Array.isArray(members)) return '';
531
+ const clean = [];
532
+ const seen = new Set();
533
+ for (const member of members) {
534
+ if (typeof member !== 'string') continue;
535
+ const id = member.trim();
536
+ if (!id || seen.has(id)) continue;
537
+ seen.add(id);
538
+ clean.push(id);
539
+ }
540
+ return clean.join(', ');
541
+ }
542
+
522
543
  /**
523
544
  * Render a one-line envelope summary. Pulls the small set of routing
524
545
  * fields we surface to the LLM (sender, intent, originating user) and
@@ -56,6 +56,7 @@ export function createRouter(deps = {}) {
56
56
  * taskId?: string|null,
57
57
  * inboundEnvelope?: any, // the envelope the sender is currently handling
58
58
  * // (drives causedBy chain & loop guard)
59
+ * sourceThreadId?: string|null, // sender-side thread that should own the visible forward row
59
60
  * }} args
60
61
  * @param {{ taskMembers?: string[] }} [opts] — forwarded to coordinator.ingest
61
62
  * @returns {{
@@ -148,6 +149,9 @@ export function createRouter(deps = {}) {
148
149
  senderVpId: from,
149
150
  reason: args.reason || null,
150
151
  causedBy: chain,
152
+ sourceThreadId: typeof args.sourceThreadId === 'string' && args.sourceThreadId.trim()
153
+ ? args.sourceThreadId.trim()
154
+ : null,
151
155
  },
152
156
  },
153
157
  opts,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * group-config.js — Per-group selected model state.
2
+ * session-config.js — Per-session selected model override state.
3
3
  *
4
- * Each group may carry its header-selected model in `config.json` at
4
+ * Each session may carry its header-selected model override in `config.json` at
5
5
  * ~/.yeaft/sessions/<sessionId>/config.json
6
6
  *
7
7
  * v1 schema (intentionally tiny — extend via additive keys only):
@@ -9,7 +9,7 @@
9
9
  * "model": "my-proxy/claude-sonnet-4-20250514" // optional
10
10
  * }
11
11
  *
12
- * Missing file → empty object. Missing field → fall back to user-level
12
+ * Missing file or `{}` no session-level override. Missing field → fall back to user-level
13
13
  * config (`~/.yeaft/config.json` via loadConfig()). Resolution is a
14
14
  * shallow overlay for send-time effective config.
15
15
  *
@@ -25,7 +25,7 @@ import { sessionsRoot, resolveSessionYeaftDir } from './session-crud.js';
25
25
 
26
26
  const CONFIG_FILE = 'config.json';
27
27
 
28
- /** Whitelist of persisted group model-state fields. Reject everything else. */
28
+ /** Whitelist of persisted session model-override fields. Reject everything else. */
29
29
  const ALLOWED_KEYS = new Set(['model']);
30
30
 
31
31
  export class SessionConfigError extends Error {
@@ -37,18 +37,18 @@ export class SessionConfigError extends Error {
37
37
  }
38
38
 
39
39
  /**
40
- * Resolve the on-disk path for a group's config.json. Honours the
41
- * per-group workDir registry so groups bound to a project directory
42
- * keep their config alongside the group meta.
40
+ * Resolve the on-disk path for a session's config.json. Honours the
41
+ * per-session workDir registry so sessions bound to a project directory
42
+ * keep their config alongside the session metadata.
43
43
  */
44
44
  export function sessionConfigPath(yeaftDir, sessionId) {
45
45
  if (!yeaftDir) return null;
46
- const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
47
- return join(sessionsRoot(groupYeaftDir), sessionId, CONFIG_FILE);
46
+ const sessionYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
47
+ return join(sessionsRoot(sessionYeaftDir), sessionId, CONFIG_FILE);
48
48
  }
49
49
 
50
50
  /**
51
- * Read a group's config.json. Returns `{}` when the file is missing or
51
+ * Read a session's config.json. Returns `{}` when the file is missing or
52
52
  * corrupt — callers fall back to user-level defaults via
53
53
  * `resolveSessionConfig`. We never auto-write on read.
54
54
  *
@@ -12,7 +12,7 @@
12
12
  * Plus the D1 bootstrap helper:
13
13
  * ensureDefaultSessionIfEmpty(yeaftDir, {libDir}) — if NO session exists on
14
14
  * disk, seed `session_default` with roster = every VP in the library, and
15
- * defaultVpId = alphabetically first vpId. No-op when ≥1 group present.
15
+ * defaultVpId = alphabetically first vpId. No-op when ≥1 session present.
16
16
  *
17
17
  * Hard constraints (PM):
18
18
  * (a) We don't touch 334o storage primitives (storage/index.js) — we call
@@ -202,13 +202,15 @@ export function scanWorkdirSessions(workDir) {
202
202
  * Register `(sessionId, workDir)` in the central registry so the next
203
203
  * `snapshotSessions()` includes this session.
204
204
  *
205
- * Validates that `<workDir>/.yeaft/sessions/<sessionId>/group.json` exists
206
- * and is parseable. Throws:
205
+ * Validates that `<workDir>/.yeaft/sessions/<sessionId>/session.json`
206
+ * exists and is parseable, with legacy `group.json` as a read fallback.
207
+ * Throws:
207
208
  * - `not_found` — the session dir is not on disk at this workdir
208
- * - `corrupt_meta` — the dir exists but `group.json` is missing / unreadable
209
- * / can't be parsed. Surfaced as a distinct code so the
210
- * UI can tell the user "the file is broken" instead of
211
- * "you picked the wrong workdir" (review finding I1).
209
+ * - `corrupt_meta` — the dir exists but session metadata is missing /
210
+ * unreadable / can't be parsed. Surfaced as a distinct
211
+ * code so the UI can tell the user "the file is broken"
212
+ * instead of "you picked the wrong workdir" (review
213
+ * finding I1).
212
214
  *
213
215
  * Idempotent: if the same `(sessionId, workDir)` is already registered,
214
216
  * we still rewrite the entry (with the normalized path) and return the
@@ -227,7 +229,7 @@ export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
227
229
  const dir = join(sessionsRoot(groupYeaftDir), sessionId);
228
230
  if (!existsSync(dir)) throw new SessionCrudError('not_found', sessionId);
229
231
  const meta = loadSessionMeta(dir);
230
- if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `group.json missing or unreadable at ${dir}`);
232
+ if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `session metadata missing or unreadable at ${dir} (expected session.json or legacy group.json)`);
231
233
  registerSessionWorkDir(defaultYeaftDir, sessionId, normalized);
232
234
  return { ...meta, workDir: normalized };
233
235
  }
@@ -344,11 +346,12 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
344
346
  handle.close();
345
347
  if (normalizedWorkDir) registerSessionWorkDir(yeaftDir, id, normalizedWorkDir);
346
348
 
347
- // Per-group config (v1: model only). We always create an empty
348
- // config.json so the file's presence signals "owned by this group" and
349
- // hand-editing tools can find a stub. Initial overrides from the
350
- // wizard spec (currently just `config.model`) are persisted here so
351
- // the engine cache picks them up on the very first turn.
349
+ // Per-session config (v1: model only). We always create an empty
350
+ // config.json so hand-editing tools can find a session-level override
351
+ // stub. An empty object means "no session override; use global config".
352
+ // Initial overrides from the wizard spec (currently just `config.model`)
353
+ // are persisted here so the engine cache picks them up on the very first
354
+ // turn.
352
355
  try {
353
356
  ensureSessionConfigFile(yeaftDir, id);
354
357
  if (spec && spec.config && typeof spec.config === 'object') {
@@ -1,17 +1,19 @@
1
1
  /**
2
- * group-store.js — Per-group persistent store for task-334b.
2
+ * session-store.js — Per-session persistent store for task-334b.
3
3
  *
4
4
  * Layout (see architecture §2):
5
- * ~/.yeaft/sessions/<group-id>/
6
- * group.json # { id, name, roster: [vpId...], defaultVpId, createdAt }
5
+ * ~/.yeaft/sessions/<session-id>/
6
+ * session.json # { id, name, roster: [vpId...], defaultVpId, createdAt }
7
7
  * messages/ # JSONL size-rotation log (334o openLog)
8
8
  * 000001.jsonl
9
9
  * index.json
10
10
  * tasks/ # populated by 334n — reserved here
11
11
  * vps/ # populated by 334c RoleInstance runtime — reserved here
12
12
  *
13
- * This module owns only the group.json + messages/ log. Roster mutation
14
- * logic lives in roster.js so coordinator and group-store both compose it.
13
+ * This module owns only the session.json + messages/ log. Roster mutation
14
+ * logic lives in roster.js so coordinator and session-store both compose it.
15
+ * Legacy `group.json` is read as a compatibility alias for sessions created
16
+ * before the storage terminology was fixed; new writes are always session.json.
15
17
  *
16
18
  * Hard constraint: the store does not parse @-mentions, does not dispatch,
17
19
  * and has no knowledge of VP/RoleInstance. It is pure persistence over 334o.
@@ -28,15 +30,16 @@ import { join } from 'path';
28
30
  import { writeAtomic, openLog } from '../storage/index.js';
29
31
  import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
30
32
 
31
- const GROUP_FILE = 'group.json';
33
+ export const SESSION_META_FILE = 'session.json';
34
+ export const LEGACY_GROUP_META_FILE = 'group.json';
32
35
  const MESSAGES_DIR = 'messages';
33
36
 
34
37
  /**
35
- * Load (or create) the directory for a single group.
38
+ * Load (or create) the directory for a single session.
36
39
  *
37
- * @param {string} sessionsRoot e.g. `${yeaftDir}/groups`
40
+ * @param {string} sessionsRoot e.g. `${yeaftDir}/sessions`
38
41
  * @param {string} sessionId
39
- * @returns {GroupHandle}
42
+ * @returns {SessionHandle}
40
43
  */
41
44
  export function openSession(sessionsRoot, sessionId) {
42
45
  if (!sessionId || typeof sessionId !== 'string') {
@@ -47,7 +50,7 @@ export function openSession(sessionsRoot, sessionId) {
47
50
 
48
51
  let meta = loadSessionMeta(dir);
49
52
  if (!meta) {
50
- // Fresh group — caller must call initGroup() next; we return a handle
53
+ // Fresh session — caller must call createSession() next; we return a handle
51
54
  // with meta=null so createSession() can write the initial file.
52
55
  }
53
56
 
@@ -60,14 +63,14 @@ export function openSession(sessionsRoot, sessionId) {
60
63
  id: sessionId,
61
64
  /** Return current meta (reads fresh from memory after last save). */
62
65
  getMeta() { return meta ? structuredClone(meta) : null; },
63
- /** Overwrite group.json atomically. */
66
+ /** Overwrite session.json atomically. */
64
67
  saveMeta(next) {
65
68
  validateMeta(next);
66
69
  meta = next;
67
- writeAtomic(join(dir, GROUP_FILE), JSON.stringify(meta, null, 2));
70
+ writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
68
71
  },
69
72
  /**
70
- * Append a message to the group log. Assigns an id if absent.
73
+ * Append a message to the session log. Assigns an id if absent.
71
74
  * Returns the stored record (with id + ts).
72
75
  *
73
76
  * Structural invariant: NO field on `record` may start with `_`.
@@ -113,14 +116,14 @@ export function openSession(sessionsRoot, sessionId) {
113
116
  }
114
117
 
115
118
  /**
116
- * Create a fresh group on disk. Fails if group.json already exists.
117
- * @returns {GroupHandle}
119
+ * Create a fresh session on disk. Fails if session metadata already exists.
120
+ * @returns {SessionHandle}
118
121
  */
119
122
  export function createSession(sessionsRoot, spec) {
120
123
  if (!spec || !spec.id) throw new Error('createSession: spec.id required');
121
124
  const h = openSession(sessionsRoot, spec.id);
122
125
  if (h.getMeta()) {
123
- throw new Error(`group ${spec.id} already exists`);
126
+ throw new Error(`session ${spec.id} already exists`);
124
127
  }
125
128
  const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
126
129
  for (const v of roster) {
@@ -146,15 +149,19 @@ export function createSession(sessionsRoot, spec) {
146
149
  return h;
147
150
  }
148
151
 
149
- /** Non-destructive load — returns null if group.json is missing/corrupt. */
152
+ /**
153
+ * Non-destructive load — returns null if session metadata is missing/corrupt.
154
+ * Reads canonical session.json first, then legacy group.json for disk
155
+ * compatibility. Callers that later save the handle will write session.json.
156
+ */
150
157
  export function loadSessionMeta(dir) {
151
- const path = join(dir, GROUP_FILE);
152
- if (!existsSync(path)) return null;
158
+ const path = resolveSessionMetaPath(dir);
159
+ if (!path) return null;
153
160
  try {
154
161
  const raw = readFileSync(path, 'utf8');
155
162
  const parsed = JSON.parse(raw);
156
163
  validateMeta(parsed);
157
- // Legacy groups created before optional fields were added are
164
+ // Legacy sessions created before optional fields were added are
158
165
  // forward-compat: missing fields read back as safe empty strings.
159
166
  if (typeof parsed.announcement !== 'string') parsed.announcement = '';
160
167
  if (typeof parsed.workDir !== 'string') parsed.workDir = '';
@@ -164,7 +171,7 @@ export function loadSessionMeta(dir) {
164
171
  }
165
172
  }
166
173
 
167
- /** List every group directory under `sessionsRoot`. */
174
+ /** List every session directory under `sessionsRoot`. */
168
175
  export function listSessions(sessionsRoot) {
169
176
  if (!existsSync(sessionsRoot)) return [];
170
177
  const out = [];
@@ -182,25 +189,25 @@ export function listSessions(sessionsRoot) {
182
189
  }
183
190
 
184
191
  function validateMeta(meta) {
185
- if (!meta || typeof meta !== 'object') throw new Error('group.json must be object');
186
- if (!meta.id || typeof meta.id !== 'string') throw new Error('group.id required');
187
- if (!Array.isArray(meta.roster)) throw new Error('group.roster must be array');
192
+ if (!meta || typeof meta !== 'object') throw new Error('session.json must be object');
193
+ if (!meta.id || typeof meta.id !== 'string') throw new Error('session.id required');
194
+ if (!Array.isArray(meta.roster)) throw new Error('session.roster must be array');
188
195
  for (const v of meta.roster) {
189
- if (typeof v !== 'string') throw new Error('group.roster must be string[]');
196
+ if (typeof v !== 'string') throw new Error('session.roster must be string[]');
190
197
  }
191
198
  if (meta.defaultVpId != null && typeof meta.defaultVpId !== 'string') {
192
- throw new Error('group.defaultVpId must be string|null');
199
+ throw new Error('session.defaultVpId must be string|null');
193
200
  }
194
201
  if (meta.announcement != null && typeof meta.announcement !== 'string') {
195
- throw new Error('group.announcement must be string');
202
+ throw new Error('session.announcement must be string');
196
203
  }
197
204
  if (meta.workDir != null && typeof meta.workDir !== 'string') {
198
- throw new Error('group.workDir must be string');
205
+ throw new Error('session.workDir must be string');
199
206
  }
200
207
  }
201
208
 
202
209
  /**
203
- * @typedef {Object} GroupHandle
210
+ * @typedef {Object} SessionHandle
204
211
  * @property {string} dir
205
212
  * @property {string} id
206
213
  * @property {() => any} getMeta
@@ -210,3 +217,11 @@ function validateMeta(meta) {
210
217
  * @property {(first:string,last:string)=>Generator<any>} readMessageRange
211
218
  * @property {() => void} close
212
219
  */
220
+
221
+ function resolveSessionMetaPath(dir) {
222
+ const canonical = join(dir, SESSION_META_FILE);
223
+ if (existsSync(canonical)) return canonical;
224
+ const legacy = join(dir, LEGACY_GROUP_META_FILE);
225
+ if (existsSync(legacy)) return legacy;
226
+ return null;
227
+ }
@@ -95,6 +95,7 @@ Returns JSON: { ok, dispatched?, error?, detail? }.`,
95
95
  reason: reason || null,
96
96
  taskId: ctx.taskId ?? null,
97
97
  inboundEnvelope: ctx.inboundEnvelope ?? null,
98
+ sourceThreadId: ctx.threadId ?? null,
98
99
  },
99
100
  { taskMembers: ctx.taskMembers },
100
101
  );
@@ -356,6 +356,18 @@ function buildVpPromptPayload(vpId, envelope) {
356
356
  return { text, prompt, promptParts };
357
357
  }
358
358
 
359
+ export function visibleInboundThreadId(envelope, fallbackThreadId = 'main') {
360
+ const meta = envelope?.msg?.meta || {};
361
+ if (
362
+ meta.injectedBy === 'route_forward'
363
+ && typeof meta.sourceThreadId === 'string'
364
+ && meta.sourceThreadId.trim()
365
+ ) {
366
+ return meta.sourceThreadId.trim();
367
+ }
368
+ return fallbackThreadId || 'main';
369
+ }
370
+
359
371
  function threadSnapshotForClassifier(thread) {
360
372
  return {
361
373
  threadId: thread.threadId,
@@ -833,6 +845,13 @@ export function __testGetVpThreads(sessionId, vpId) {
833
845
  }));
834
846
  }
835
847
 
848
+ /** Test-only: seed a VP thread without starting its engine driver. */
849
+ export function __testSeedVpThread({ sessionId, vpId, threadId, title = 'test thread', status = 'queued' }) {
850
+ const thread = getOrCreateVpThread({ sessionId, vpId, threadId, title });
851
+ thread.status = status;
852
+ return thread.threadId;
853
+ }
854
+
836
855
  /** Test-only: wait for thread classification/routing spawned by a msg id. */
837
856
  export async function __testWaitForRoutePromises(msgId) {
838
857
  await waitForRoutePromises(msgId);
@@ -1016,7 +1035,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1016
1035
  msgId: envelope?.msg?.id,
1017
1036
  text,
1018
1037
  sessionId,
1019
- threadId: thread.threadId,
1038
+ threadId: visibleInboundThreadId(envelope, thread.threadId),
1020
1039
  role: envelope?.msg?.meta?.injectedBy === 'route_forward' ? 'assistant' : 'user',
1021
1040
  speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
1022
1041
  attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
@@ -1109,7 +1128,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1109
1128
  msgId: envMsgId,
1110
1129
  text,
1111
1130
  sessionId,
1112
- threadId: thread.threadId,
1131
+ threadId: visibleInboundThreadId(envelope, thread.threadId),
1113
1132
  role: isForward ? 'assistant' : 'user',
1114
1133
  speakerVpId: senderVpId,
1115
1134
  attachments: Array.isArray(meta.attachments) ? meta.attachments : [],
@@ -2619,10 +2638,10 @@ function resolveCollabToolPolicy(sessionMeta) {
2619
2638
  }
2620
2639
 
2621
2640
  export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope, threadId = 'main' }) {
2622
- // Read the group meta once and reuse for both defaultVpId fallback and
2623
- // announcement injection. Each .getMeta() reload reads + parses the
2624
- // group.json file, so calling it twice per turn is wasteful — and
2625
- // (more importantly) opens a window where a concurrent group edit
2641
+ // Read the session meta once and reuse for defaultVpId fallback,
2642
+ // announcement injection, and roster prompt context. Calling getMeta()
2643
+ // twice per turn is wasteful — and (more importantly) opens a window
2644
+ // where a concurrent session edit
2626
2645
  // could land between the two reads, giving the engine a defaultVpId
2627
2646
  // from one snapshot and an announcement from a newer one.
2628
2647
  let sessionMeta = null;
@@ -2660,13 +2679,18 @@ export function buildVpQueryOpts({ vpId, sessionCoordinator, sessionId, envelope
2660
2679
  }
2661
2680
  const collabToolPolicy = resolveCollabToolPolicy(sessionMeta);
2662
2681
  if (collabToolPolicy) out.collabToolPolicy = collabToolPolicy;
2663
- // task-334-group-editor: surface the group announcement to the engine so
2682
+ if (sessionMeta && Array.isArray(sessionMeta.roster)) {
2683
+ out.sessionMembers = sessionMeta.roster
2684
+ .filter(v => typeof v === 'string' && v.trim())
2685
+ .map(v => v.trim());
2686
+ }
2687
+ // task-334-session-editor: surface the session announcement to the engine so
2664
2688
  // buildWorkerPrompt can inject it as a CLAUDE.md-style shared prefix.
2665
2689
  // Empty/missing reads as '' and prompts.js skips the section.
2666
2690
  if (sessionMeta && typeof sessionMeta.announcement === 'string') {
2667
2691
  out.sessionAnnouncement = sessionMeta.announcement;
2668
2692
  }
2669
- // Surface the group's configured working directory so the engine can
2693
+ // Surface the session's configured working directory so the engine can
2670
2694
  // resolve CLAUDE.md / AGENTS.md at that path and inject it as a
2671
2695
  // [Project Doc] block above the announcement. Groups with no workDir
2672
2696
  // skip the block silently (matches the announcement contract).