@yeaft/webchat-agent 1.0.89 → 1.0.90

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": "1.0.89",
3
+ "version": "1.0.90",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * migrate/project-sessions-to-user.js — copy workDir-backed Yeaft Sessions into
4
+ * the agent-local user Yeaft directory so normal startup discovers them from
5
+ * `<userYeaftDir>/sessions/` without depending on the workDir registry.
6
+ *
7
+ * Usage:
8
+ * node agent/yeaft/migrate/project-sessions-to-user.js <projectDir> [userYeaftDir]
9
+ * node agent/yeaft/migrate/project-sessions-to-user.js --dry-run <projectDir>
10
+ * node agent/yeaft/migrate/project-sessions-to-user.js --overwrite <projectDir>
11
+ *
12
+ * Default userYeaftDir is `~/.yeaft`.
13
+ */
14
+
15
+ import {
16
+ cpSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ rmSync,
20
+ writeFileSync,
21
+ } from 'fs';
22
+ import { homedir } from 'os';
23
+ import { join } from 'path';
24
+ import {
25
+ loadSessionMeta,
26
+ } from '../sessions/session-store.js';
27
+ import {
28
+ normalizeWorkDir,
29
+ scanWorkdirSessions,
30
+ sessionsRoot,
31
+ unregisterSessionWorkDir,
32
+ yeaftDirForWorkDir,
33
+ } from '../sessions/session-crud.js';
34
+
35
+ /**
36
+ * @typedef {Object} MigrationOptions
37
+ * @property {boolean} [dryRun]
38
+ * @property {boolean} [overwrite]
39
+ * @property {boolean} [deleteSource]
40
+ */
41
+
42
+ /**
43
+ * Copy every readable Session under `<projectDir>/.yeaft/sessions/` into
44
+ * `<userYeaftDir>/sessions/` and copy matching memory scopes. Existing
45
+ * destination Sessions are skipped unless `overwrite` is true.
46
+ *
47
+ * We deliberately unregister migrated Session ids from the legacy workDir
48
+ * registry after a successful copy. Otherwise startup would still prefer the
49
+ * project-backed copy over the newly local copy.
50
+ *
51
+ * @param {string} projectDir
52
+ * @param {string} [userYeaftDir]
53
+ * @param {MigrationOptions} [options]
54
+ * @returns {{ok:boolean, projectDir:string, userYeaftDir:string, scanned:number, copied:number, skipped:number, overwritten:number, removedSources:number, sessions:Array<object>, warnings:string[]}}
55
+ */
56
+ export function migrateProjectSessionsToUser(projectDir, userYeaftDir = join(homedir(), '.yeaft'), options = {}) {
57
+ const normalizedProjectDir = normalizeWorkDir(projectDir);
58
+ const normalizedUserYeaftDir = normalizeWorkDir(userYeaftDir || join(homedir(), '.yeaft'));
59
+ const dryRun = !!options.dryRun;
60
+ const overwrite = !!options.overwrite;
61
+ const deleteSource = !!options.deleteSource;
62
+ const warnings = [];
63
+ const sessions = [];
64
+
65
+ if (!normalizedProjectDir) {
66
+ throw new Error('projectDir required');
67
+ }
68
+ if (!normalizedUserYeaftDir) {
69
+ throw new Error('userYeaftDir required');
70
+ }
71
+
72
+ const projectYeaftDir = yeaftDirForWorkDir(normalizedProjectDir);
73
+ const projectSessionsRoot = sessionsRoot(projectYeaftDir);
74
+ const userSessionsRoot = sessionsRoot(normalizedUserYeaftDir);
75
+ const found = scanWorkdirSessions(normalizedProjectDir);
76
+
77
+ let copied = 0;
78
+ let skipped = 0;
79
+ let overwritten = 0;
80
+ let removedSources = 0;
81
+
82
+ if (!dryRun) {
83
+ mkdirSync(userSessionsRoot, { recursive: true });
84
+ }
85
+
86
+ for (const meta of found) {
87
+ const sessionId = meta.id;
88
+ const sourceDir = join(projectSessionsRoot, sessionId);
89
+ const destDir = join(userSessionsRoot, sessionId);
90
+ const row = {
91
+ id: sessionId,
92
+ name: meta.name || sessionId,
93
+ sourceDir,
94
+ destDir,
95
+ status: 'pending',
96
+ };
97
+
98
+ if (!existsSync(sourceDir) || !loadSessionMeta(sourceDir)) {
99
+ row.status = 'skipped';
100
+ row.reason = 'source_missing_or_corrupt';
101
+ skipped += 1;
102
+ sessions.push(row);
103
+ continue;
104
+ }
105
+
106
+ if (existsSync(destDir)) {
107
+ if (!overwrite) {
108
+ row.status = 'skipped';
109
+ row.reason = 'destination_exists';
110
+ skipped += 1;
111
+ sessions.push(row);
112
+ continue;
113
+ }
114
+ if (!dryRun) rmSync(destDir, { recursive: true, force: true });
115
+ overwritten += 1;
116
+ row.overwritten = true;
117
+ }
118
+
119
+ if (!dryRun) {
120
+ cpSync(sourceDir, destDir, { recursive: true, errorOnExist: false });
121
+ const copiedMemoryDirs = copySessionMemory(projectYeaftDir, normalizedUserYeaftDir, sessionId, { overwrite, dryRun, warnings });
122
+ unregisterSessionWorkDir(normalizedUserYeaftDir, sessionId);
123
+ markMigrated(destDir, normalizedProjectDir);
124
+ if (deleteSource) {
125
+ rmSync(sourceDir, { recursive: true, force: true });
126
+ removeCopiedSourceMemory(copiedMemoryDirs, warnings);
127
+ removedSources += 1;
128
+ }
129
+ }
130
+
131
+ row.status = dryRun ? 'would_copy' : 'copied';
132
+ copied += 1;
133
+ sessions.push(row);
134
+ }
135
+
136
+ return {
137
+ ok: true,
138
+ projectDir: normalizedProjectDir,
139
+ userYeaftDir: normalizedUserYeaftDir,
140
+ scanned: found.length,
141
+ copied,
142
+ skipped,
143
+ overwritten,
144
+ removedSources,
145
+ sessions,
146
+ warnings,
147
+ };
148
+ }
149
+
150
+ function copySessionMemory(projectYeaftDir, userYeaftDir, sessionId, { overwrite, dryRun, warnings }) {
151
+ const pairs = [
152
+ [join(projectYeaftDir, 'memory', 'session', sessionId), join(userYeaftDir, 'memory', 'session', sessionId)],
153
+ [join(projectYeaftDir, 'memory', 'sessions', sessionId), join(userYeaftDir, 'memory', 'sessions', sessionId)],
154
+ [join(projectYeaftDir, 'memory', 'group', sessionId), join(userYeaftDir, 'memory', 'group', sessionId)],
155
+ ];
156
+ const copied = [];
157
+ for (const [src, dst] of pairs) {
158
+ if (!existsSync(src)) continue;
159
+ if (existsSync(dst)) {
160
+ if (!overwrite) {
161
+ warnings.push(`memory destination exists; skipping ${dst}`);
162
+ continue;
163
+ }
164
+ if (!dryRun) rmSync(dst, { recursive: true, force: true });
165
+ }
166
+ if (!dryRun) {
167
+ mkdirSync(join(dst, '..'), { recursive: true });
168
+ cpSync(src, dst, { recursive: true, errorOnExist: false });
169
+ }
170
+ copied.push(src);
171
+ }
172
+ return copied;
173
+ }
174
+
175
+ function removeCopiedSourceMemory(copiedMemoryDirs, warnings) {
176
+ for (const dir of copiedMemoryDirs) {
177
+ try {
178
+ rmSync(dir, { recursive: true, force: true });
179
+ } catch (err) {
180
+ warnings.push(`failed to remove source memory ${dir}: ${err?.message || err}`);
181
+ }
182
+ }
183
+ }
184
+
185
+ function markMigrated(destDir, projectDir) {
186
+ const markerDir = join(destDir, '.migrations');
187
+ mkdirSync(markerDir, { recursive: true });
188
+ writeFileSync(
189
+ join(markerDir, 'project-sessions-to-user.json'),
190
+ `${JSON.stringify({ migratedAt: new Date().toISOString(), sourceProjectDir: projectDir }, null, 2)}\n`,
191
+ );
192
+ }
193
+
194
+ function parseCliArgs(argv) {
195
+ const args = argv.slice(2);
196
+ const options = { dryRun: false, overwrite: false, deleteSource: false };
197
+ const positional = [];
198
+ for (const arg of args) {
199
+ if (arg === '--dry-run') options.dryRun = true;
200
+ else if (arg === '--overwrite') options.overwrite = true;
201
+ else if (arg === '--delete-source') options.deleteSource = true;
202
+ else if (arg === '--help' || arg === '-h') options.help = true;
203
+ else positional.push(arg);
204
+ }
205
+ return { options, positional };
206
+ }
207
+
208
+ function printUsage() {
209
+ console.log('Usage: node agent/yeaft/migrate/project-sessions-to-user.js [--dry-run] [--overwrite] [--delete-source] <projectDir> [userYeaftDir]');
210
+ }
211
+
212
+ if (import.meta.url === `file://${process.argv[1]}`) {
213
+ const { options, positional } = parseCliArgs(process.argv);
214
+ if (options.help || positional.length < 1) {
215
+ printUsage();
216
+ process.exitCode = options.help ? 0 : 1;
217
+ } else {
218
+ try {
219
+ const result = migrateProjectSessionsToUser(positional[0], positional[1], options);
220
+ console.log(JSON.stringify(result, null, 2));
221
+ if (result.scanned === 0) process.exitCode = 2;
222
+ } catch (err) {
223
+ console.error(err?.message || String(err));
224
+ process.exitCode = 1;
225
+ }
226
+ }
227
+ }
@@ -38,18 +38,18 @@ import {
38
38
  existsSync,
39
39
  renameSync,
40
40
  rmSync,
41
- cpSync,
42
41
  readdirSync,
43
42
  statSync,
44
43
  mkdirSync,
45
44
  readFileSync,
46
45
  writeFileSync,
46
+ cpSync,
47
47
  } from 'fs';
48
48
  import { randomBytes } from 'crypto';
49
49
  import { homedir } from 'os';
50
50
  import { isAbsolute, join, resolve } from 'path';
51
51
  import {
52
- openSession, createSession, listSessions, loadSessionMeta,
52
+ openSession, createSession, loadSessionMeta,
53
53
  } from './session-store.js';
54
54
  import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
55
55
  import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
@@ -57,6 +57,14 @@ import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
57
57
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
58
58
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
59
59
  import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
60
+ import {
61
+ addOrUpdateManifestSession,
62
+ ensureSessionsManifest,
63
+ hasSessionManifest,
64
+ listManifestSessions,
65
+ removeManifestSession,
66
+ resolveManifestSessionDir,
67
+ } from './session-manifest.js';
60
68
 
61
69
  /**
62
70
  * Default memory root used when callers don't pass `options.memoryRoot`.
@@ -156,95 +164,183 @@ export function unregisterSessionWorkDir(defaultYeaftDir, sessionId) {
156
164
  writeWorkDirRegistry(defaultYeaftDir, registry);
157
165
  }
158
166
 
167
+ function ensureSessionManifestReady(yeaftDir) {
168
+ return ensureSessionsManifest(yeaftDir, {
169
+ sessionsRoot: sessionsRoot(yeaftDir),
170
+ registry: readWorkDirRegistry(yeaftDir),
171
+ yeaftDirForWorkDir,
172
+ sessionsRootForYeaftDir: sessionsRoot,
173
+ copySessionExtras: (projectYeaftDir, sessionId) => copySessionExtras(projectYeaftDir, yeaftDir, sessionId),
174
+ unregisterSessionWorkDir: (sessionId) => unregisterSessionWorkDir(yeaftDir, sessionId),
175
+ });
176
+ }
177
+
178
+ function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
179
+ for (const family of ['session', 'sessions', 'group']) {
180
+ const src = join(sourceYeaftDir, 'memory', family, sessionId);
181
+ const dst = join(destYeaftDir, 'memory', family, sessionId);
182
+ if (!existsSync(src) || existsSync(dst)) continue;
183
+ mkdirSync(join(dst, '..'), { recursive: true });
184
+ cpSync(src, dst, { recursive: true, errorOnExist: false });
185
+ }
186
+ }
187
+
159
188
  /**
160
189
  * One-way compatibility migration for sessions previously stored under a
161
- * project `.yeaft/sessions` tree. New reads/writes never use that location, but
162
- * older installs may still have their only `session.json` + conversation jsonl
163
- * there and a `group-workdirs.json` registry entry pointing to it.
164
- *
165
- * We copy missing session directories into the user-level root and never
166
- * overwrite an existing user-level session. The old project directory is left in
167
- * place as a safety backup; subsequent runtime paths ignore it.
190
+ * project `.yeaft/sessions` tree. New steady-state discovery uses
191
+ * `sessions-manifest.json`; this helper bootstraps that manifest once and
192
+ * reports what happened for boot logs/tests.
168
193
  *
169
194
  * @param {string} yeaftDir user-level Yeaft root
170
195
  * @returns {{migrated:string[], skipped:string[], errors:Array<{sessionId:string,error:string}>}}
171
196
  */
172
197
  export function migrateRegisteredWorkDirSessions(yeaftDir) {
173
198
  const result = { migrated: [], skipped: [], errors: [] };
174
- if (!yeaftDir) return result;
199
+ if (!yeaftDir || hasSessionManifest(yeaftDir)) return result;
200
+
201
+ const invalidTargets = new Set();
175
202
  const registry = readWorkDirRegistry(yeaftDir);
176
- const userRoot = sessionsRoot(yeaftDir);
177
203
  for (const [sessionId, workDir] of Object.entries(registry)) {
178
204
  const normalized = normalizeWorkDir(workDir);
179
205
  if (!sessionId || !normalized) continue;
180
- const sourceDir = join(sessionsRoot(yeaftDirForWorkDir(normalized)), sessionId);
181
- const targetDir = join(userRoot, sessionId);
182
- let tmpDir = null;
183
- try {
184
- if (existsSync(targetDir)) {
185
- const targetMeta = loadSessionMeta(targetDir);
186
- if (targetMeta) {
187
- result.skipped.push(sessionId);
188
- } else {
189
- result.errors.push({ sessionId, error: 'target session directory exists but session metadata is invalid' });
190
- }
191
- continue;
192
- }
193
- const sourceMeta = existsSync(sourceDir) ? loadSessionMeta(sourceDir) : null;
194
- if (!sourceMeta) {
195
- result.skipped.push(sessionId);
196
- continue;
197
- }
198
- mkdirSync(userRoot, { recursive: true });
199
- tmpDir = join(userRoot, `.migrating-${sessionId}-${randomBytes(6).toString('hex')}`);
200
- cpSync(sourceDir, tmpDir, { recursive: true, errorOnExist: true });
201
- const copiedMeta = loadSessionMeta(tmpDir);
202
- if (!copiedMeta || copiedMeta.id !== sessionId) {
203
- throw new Error('copied session metadata failed validation');
204
- }
205
- renameSync(tmpDir, targetDir);
206
- tmpDir = null;
207
- result.migrated.push(sessionId);
208
- } catch (err) {
209
- if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
210
- result.errors.push({ sessionId, error: err?.message || String(err) });
206
+ const targetDir = join(sessionsRoot(yeaftDir), sessionId);
207
+ if (existsSync(targetDir) && !loadSessionMeta(targetDir)) {
208
+ invalidTargets.add(sessionId);
209
+ result.errors.push({ sessionId, error: 'target session directory exists but session metadata is invalid' });
211
210
  }
212
211
  }
212
+
213
+ const bootstrap = ensureSessionManifestReady(yeaftDir);
214
+ result.migrated = Array.isArray(bootstrap.migratedIds) ? bootstrap.migratedIds.slice() : [];
215
+ result.skipped = (Array.isArray(bootstrap.skippedIds) ? bootstrap.skippedIds : [])
216
+ .filter(sessionId => !invalidTargets.has(sessionId));
213
217
  return result;
214
218
  }
215
219
 
216
220
  /**
217
- * Workdir Session storage was retired: Session metadata/roster/config/history
218
- * live under the agent-local Yeaft root (`~/.yeaft/sessions`), not under a
219
- * project `.yeaft/sessions` tree. Keep this helper as a compatibility no-op for
220
- * older UI flows that may still ask to scan a workdir.
221
+ * Scan the `.yeaft/sessions/` directory under `workDir` and return every
222
+ * session meta we can read. Read-only: never touches the registry.
223
+ *
224
+ * Each returned record carries `workDir` (the normalized path we scanned).
225
+ * This utility deliberately does NOT decorate `alreadyRegistered` — that
226
+ * cross-references the central workdir registry, which is a separate
227
+ * concern owned by the handler / caller (see
228
+ * `handleYeaftScanWorkdirSessions`). Keeping the utility layer-pure makes
229
+ * it usable from contexts that don't have / don't care about the registry
230
+ * (e.g. CLI tools, future per-workdir snapshots).
231
+ *
232
+ * Returns `[]` for missing dir / empty dir / unreadable dir — never throws.
233
+ * Sort is `createdAt` descending (most-recent first) because the restore
234
+ * UI typically cares about "the session I made yesterday".
221
235
  *
222
236
  * @param {string} workDir — the working directory to scan.
223
237
  * @returns {Array<object>}
224
238
  */
225
239
  export function scanWorkdirSessions(workDir) {
226
- return [];
240
+ const normalized = normalizeWorkDir(workDir);
241
+ if (!normalized) return [];
242
+ const groupYeaftDir = yeaftDirForWorkDir(normalized);
243
+ const root = sessionsRoot(groupYeaftDir);
244
+ if (!existsSync(root)) return [];
245
+ const out = [];
246
+ let entries;
247
+ try {
248
+ entries = readdirSync(root);
249
+ } catch {
250
+ return [];
251
+ }
252
+ for (const name of entries) {
253
+ if (name.startsWith('.')) continue; // skip .archived-* and dotfiles
254
+ const dir = join(root, name);
255
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
256
+ const meta = loadSessionMeta(dir);
257
+ if (!meta) continue;
258
+ out.push({
259
+ ...meta,
260
+ workDir: normalized,
261
+ });
262
+ }
263
+ return out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
227
264
  }
228
265
 
229
266
  /**
230
- * Compatibility shim for the retired workdir-restore flow. Session metadata is
231
- * no longer accepted from project `.yeaft/sessions`; users should create or load
232
- * Sessions from the user-level `~/.yeaft/sessions` store.
267
+ * Register `(sessionId, workDir)` in the central registry so the next
268
+ * `snapshotSessions()` includes this session.
269
+ *
270
+ * Validates that `<workDir>/.yeaft/sessions/<sessionId>/session.json`
271
+ * exists and is parseable, with legacy `group.json` as a read fallback.
272
+ * Throws:
273
+ * - `not_found` — the session dir is not on disk at this workdir
274
+ * - `corrupt_meta` — the dir exists but session metadata is missing /
275
+ * unreadable / can't be parsed. Surfaced as a distinct
276
+ * code so the UI can tell the user "the file is broken"
277
+ * instead of "you picked the wrong workdir" (review
278
+ * finding I1).
279
+ *
280
+ * Idempotent: if the same `(sessionId, workDir)` is already registered,
281
+ * we still rewrite the entry (with the normalized path) and return the
282
+ * fresh meta — no error.
233
283
  *
234
284
  * @param {string} defaultYeaftDir
235
285
  * @param {string} sessionId
236
286
  * @param {string} workDir
237
- * @returns {object}
287
+ * @returns {object} the session meta, with `workDir` set to the normalized path.
238
288
  */
239
289
  export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
240
290
  if (!sessionId) throw new SessionCrudError('invalid_session_id', null);
241
- throw new SessionCrudError('not_found', sessionId, 'workdir Session restore is retired; Sessions are stored under the user-level Yeaft folder');
291
+ const normalized = normalizeWorkDir(workDir);
292
+ if (!normalized) throw new SessionCrudError('invalid_workdir', sessionId);
293
+ const projectYeaftDir = yeaftDirForWorkDir(normalized);
294
+ const sourceDir = join(sessionsRoot(projectYeaftDir), sessionId);
295
+ if (!existsSync(sourceDir)) throw new SessionCrudError('not_found', sessionId);
296
+ const meta = loadSessionMeta(sourceDir);
297
+ if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `session metadata missing or unreadable at ${sourceDir} (expected session.json or legacy group.json)`);
298
+
299
+ ensureSessionManifestReady(defaultYeaftDir);
300
+ const root = sessionsRoot(defaultYeaftDir);
301
+ const destDir = join(root, sessionId);
302
+ const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
303
+ if (existsSync(destDir)) {
304
+ const existing = loadSessionMeta(destDir);
305
+ if (existing && manifestDir === destDir) return { ...existing, workDir: existing.workDir || normalized };
306
+ throw new SessionCrudError('duplicate', sessionId, `session already exists at ${destDir}`);
307
+ }
308
+
309
+ mkdirSync(root, { recursive: true });
310
+ cpSync(sourceDir, destDir, { recursive: true, errorOnExist: true });
311
+ const importedMeta = { ...meta, workDir: normalized };
312
+ const handle = openSession(root, sessionId);
313
+ try {
314
+ handle.saveMeta(importedMeta);
315
+ } finally {
316
+ handle.close();
317
+ }
318
+ copySessionExtras(projectYeaftDir, defaultYeaftDir, sessionId);
319
+ addOrUpdateManifestSession(defaultYeaftDir, importedMeta, destDir);
320
+ unregisterSessionWorkDir(defaultYeaftDir, sessionId);
321
+ return importedMeta;
242
322
  }
243
323
 
244
324
  export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
245
- // Legacy helper retained for old call sites. Session storage now always
246
- // resolves to the agent-local root; project `.yeaft` is only for project
247
- // assets such as skills/MCP, never Session data.
325
+ if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
326
+
327
+ const manifestReady = hasSessionManifest(defaultYeaftDir);
328
+ ensureSessionManifestReady(defaultYeaftDir);
329
+ const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
330
+ if (manifestDir) return join(manifestDir, '..', '..');
331
+ if (manifestReady) return defaultYeaftDir;
332
+
333
+ const registry = readWorkDirRegistry(defaultYeaftDir);
334
+ const workDir = normalizeWorkDir(registry[sessionId]);
335
+ if (workDir) {
336
+ const candidate = yeaftDirForWorkDir(workDir);
337
+ const candidateDir = join(sessionsRoot(candidate), sessionId);
338
+ if (existsSync(candidateDir) && loadSessionMeta(candidateDir)) return candidate;
339
+ }
340
+
341
+ const defaultSessionDir = join(sessionsRoot(defaultYeaftDir), sessionId);
342
+ if (existsSync(defaultSessionDir) && loadSessionMeta(defaultSessionDir)) return defaultYeaftDir;
343
+
248
344
  return defaultYeaftDir;
249
345
  }
250
346
 
@@ -284,7 +380,8 @@ function scanSortedVpIds(libDir) {
284
380
  export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
285
381
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
286
382
  const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
287
- const existing = listSessions(sessionsRoot(yeaftDir));
383
+ ensureSessionManifestReady(yeaftDir);
384
+ const existing = listManifestSessions(yeaftDir).map(row => row.meta);
288
385
  if (existing.length > 0) {
289
386
  return { seeded: false, sessionId: existing[0].id };
290
387
  }
@@ -301,6 +398,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
301
398
  defaultVpId,
302
399
  memoryRoot,
303
400
  });
401
+ const meta = group.getMeta();
402
+ if (meta) addOrUpdateManifestSession(yeaftDir, meta, join(sessionsRoot(yeaftDir), meta.id));
304
403
  return {
305
404
  seeded: created,
306
405
  sessionId: group.id,
@@ -322,7 +421,9 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
322
421
  export function createSessionFromSpec(yeaftDir, spec, options = {}) {
323
422
  const input = spec || {};
324
423
  const normalizedWorkDir = normalizeWorkDir(input.workDir);
325
- const memoryRoot = options.memoryRoot || (yeaftDir ? join(yeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
424
+ ensureSessionManifestReady(yeaftDir);
425
+ const groupYeaftDir = yeaftDir;
426
+ const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
326
427
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
327
428
  const name = String(input.name || '').trim();
328
429
  if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
@@ -348,7 +449,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
348
449
  if (!defaultVpId) defaultVpId = roster[0] || null;
349
450
 
350
451
  const id = makeSessionId(name);
351
- const root = sessionsRoot(yeaftDir);
452
+ const root = sessionsRoot(groupYeaftDir);
352
453
  if (existsSync(join(root, id))) {
353
454
  // Extremely unlikely (ulid suffix), but surface deterministically.
354
455
  throw new SessionCrudError('duplicate', id);
@@ -357,7 +458,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
357
458
  const handle = createSession(root, { id, name, roster, defaultVpId, workDir: normalizedWorkDir });
358
459
  const meta = handle.getMeta();
359
460
  handle.close();
360
- if (normalizedWorkDir) registerSessionWorkDir(yeaftDir, id, normalizedWorkDir);
461
+ addOrUpdateManifestSession(yeaftDir, meta, join(root, id));
361
462
 
362
463
  // Per-session config (v1: model only). We always create an empty
363
464
  // config.json so hand-editing tools can find a session-level override
@@ -449,12 +550,14 @@ export function updateSessionConfig(yeaftDir, sessionId, partial) {
449
550
  * own second-confirm modal (acceptance #4 in task-334-slice-specs.md 334m).
450
551
  */
451
552
  export function archiveSession(yeaftDir, sessionId) {
452
- const root = sessionsRoot(yeaftDir);
553
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
554
+ const root = sessionsRoot(groupYeaftDir);
453
555
  const srcDir = join(root, sessionId);
454
556
  // Idempotent — nothing on disk, nothing to archive. Workdir-registry is
455
557
  // still cleared in case it points at a stale row.
456
558
  if (!existsSync(srcDir) || !loadSessionMeta(srcDir)) {
457
559
  unregisterSessionWorkDir(yeaftDir, sessionId);
560
+ removeManifestSession(yeaftDir, sessionId);
458
561
  return { sessionId, archivedAs: null, alreadyGone: true };
459
562
  }
460
563
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
@@ -463,6 +566,7 @@ export function archiveSession(yeaftDir, sessionId) {
463
566
  const dstDir = join(root, `.archived-${ts}-${suffix}-${sessionId}`);
464
567
  renameSync(srcDir, dstDir);
465
568
  unregisterSessionWorkDir(yeaftDir, sessionId);
569
+ removeManifestSession(yeaftDir, sessionId);
466
570
  return { sessionId, archivedAs: dstDir, alreadyGone: false };
467
571
  }
468
572
 
@@ -479,8 +583,9 @@ export function archiveSession(yeaftDir, sessionId) {
479
583
  * delete cleans up legacy state too.
480
584
  */
481
585
  export function deleteSession(yeaftDir, sessionId, options = {}) {
482
- const memoryRoot = options.memoryRoot || (yeaftDir ? join(yeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
483
- const root = sessionsRoot(yeaftDir);
586
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
587
+ const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
588
+ const root = sessionsRoot(groupYeaftDir);
484
589
  const srcDir = join(root, sessionId);
485
590
  const liveExists = existsSync(srcDir) && !!loadSessionMeta(srcDir);
486
591
 
@@ -520,6 +625,7 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
520
625
  }
521
626
 
522
627
  unregisterSessionWorkDir(yeaftDir, sessionId);
628
+ removeManifestSession(yeaftDir, sessionId);
523
629
  return {
524
630
  sessionId,
525
631
  deleted: liveExists,
@@ -601,7 +707,8 @@ export function setSessionDefaultVp(yeaftDir, sessionId, vpId) {
601
707
  }
602
708
 
603
709
  export function requireSession(yeaftDir, sessionId) {
604
- const root = sessionsRoot(yeaftDir);
710
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
711
+ const root = sessionsRoot(groupYeaftDir);
605
712
  const dir = join(root, sessionId);
606
713
  if (!existsSync(dir) || !loadSessionMeta(dir)) {
607
714
  throw new SessionCrudError('not_found', sessionId);
@@ -611,14 +718,10 @@ export function requireSession(yeaftDir, sessionId) {
611
718
 
612
719
  /** Convenience: snapshot all non-archived groups for WS broadcast. */
613
720
  export function snapshotSessions(yeaftDir) {
721
+ ensureSessionManifestReady(yeaftDir);
614
722
  const byId = new Map();
615
- for (const group of listSessions(sessionsRoot(yeaftDir))) {
616
- byId.set(group.id, group);
617
- }
618
- const registry = readWorkDirRegistry(yeaftDir);
619
- for (const [sessionId, workDir] of Object.entries(registry)) {
620
- const meta = byId.get(sessionId);
621
- if (meta && !meta.workDir) meta.workDir = normalizeWorkDir(workDir);
723
+ for (const row of listManifestSessions(yeaftDir)) {
724
+ byId.set(row.meta.id, row.meta);
622
725
  }
623
726
  // Attach per-group config overrides (v1: just `model`). Frontend can
624
727
  // render the effective model without re-querying.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * session-manifest.js — canonical index for Yeaft Session folders.
3
+ *
4
+ * Boot flow:
5
+ * 1. If `<yeaftDir>/sessions-manifest.json` exists, load it and trust only
6
+ * valid entries whose folder still contains readable session metadata.
7
+ * 2. If it does not exist, bootstrap once from legacy folder-based storage:
8
+ * copy registered workDir-backed Sessions into `<yeaftDir>/sessions/`, then
9
+ * index `<yeaftDir>/sessions/` into the manifest.
10
+ *
11
+ * The legacy `group-workdirs.json` registry remains readable for bootstrap only.
12
+ * New steady-state discovery should use this manifest.
13
+ */
14
+
15
+ import {
16
+ cpSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ readdirSync,
21
+ rmSync,
22
+ statSync,
23
+ writeFileSync,
24
+ } from 'fs';
25
+ import { join } from 'path';
26
+ import { loadSessionMeta } from './session-store.js';
27
+
28
+ export const SESSIONS_MANIFEST_FILE = 'sessions-manifest.json';
29
+ const MANIFEST_VERSION = 1;
30
+
31
+ export function sessionManifestPath(yeaftDir) {
32
+ return join(yeaftDir, SESSIONS_MANIFEST_FILE);
33
+ }
34
+
35
+ export function hasSessionManifest(yeaftDir) {
36
+ return !!yeaftDir && existsSync(sessionManifestPath(yeaftDir));
37
+ }
38
+
39
+ export function loadSessionsManifest(yeaftDir) {
40
+ if (!hasSessionManifest(yeaftDir)) return null;
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(sessionManifestPath(yeaftDir), 'utf8'));
43
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
44
+ if (!Array.isArray(parsed.sessions)) return null;
45
+ return {
46
+ version: Number(parsed.version) || MANIFEST_VERSION,
47
+ generatedAt: typeof parsed.generatedAt === 'string' ? parsed.generatedAt : '',
48
+ sessions: parsed.sessions
49
+ .filter(row => row && typeof row === 'object' && typeof row.id === 'string' && typeof row.path === 'string')
50
+ .map(row => ({
51
+ id: row.id,
52
+ path: row.path,
53
+ name: typeof row.name === 'string' ? row.name : row.id,
54
+ createdAt: typeof row.createdAt === 'string' ? row.createdAt : '',
55
+ workDir: typeof row.workDir === 'string' ? row.workDir : '',
56
+ })),
57
+ };
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function writeSessionsManifest(yeaftDir, sessions) {
64
+ if (!yeaftDir) throw new Error('yeaftDir required');
65
+ mkdirSync(yeaftDir, { recursive: true });
66
+ const manifest = {
67
+ version: MANIFEST_VERSION,
68
+ generatedAt: new Date().toISOString(),
69
+ sessions: dedupeSessions(sessions).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))),
70
+ };
71
+ writeFileSync(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
72
+ return manifest;
73
+ }
74
+
75
+ export function buildManifestFromLocalSessions(yeaftDir, sessionsRoot) {
76
+ const rows = [];
77
+ if (!sessionsRoot || !existsSync(sessionsRoot)) return rows;
78
+ let entries;
79
+ try {
80
+ entries = readdirSync(sessionsRoot);
81
+ } catch {
82
+ return rows;
83
+ }
84
+ for (const name of entries) {
85
+ if (name.startsWith('.')) continue;
86
+ const dir = join(sessionsRoot, name);
87
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
88
+ const meta = loadSessionMeta(dir);
89
+ if (!meta) continue;
90
+ rows.push(manifestRowFromMeta(meta, dir));
91
+ }
92
+ return rows;
93
+ }
94
+
95
+ export function listManifestSessions(yeaftDir) {
96
+ const manifest = loadSessionsManifest(yeaftDir);
97
+ if (!manifest) return [];
98
+ const rows = [];
99
+ const seen = new Set();
100
+ for (const entry of manifest.sessions) {
101
+ if (seen.has(entry.id)) continue;
102
+ seen.add(entry.id);
103
+ const meta = loadSessionMeta(entry.path);
104
+ if (!meta) continue;
105
+ rows.push({ meta, dir: entry.path });
106
+ }
107
+ return rows.sort((a, b) => String(a.meta.createdAt || '').localeCompare(String(b.meta.createdAt || '')));
108
+ }
109
+
110
+ export function resolveManifestSessionDir(yeaftDir, sessionId) {
111
+ const manifest = loadSessionsManifest(yeaftDir);
112
+ if (!manifest || !sessionId) return null;
113
+ const entry = manifest.sessions.find(row => row.id === sessionId);
114
+ if (!entry) return null;
115
+ if (!loadSessionMeta(entry.path)) return null;
116
+ return entry.path;
117
+ }
118
+
119
+ /**
120
+ * Bootstrap the manifest from legacy storage if it does not exist.
121
+ *
122
+ * @param {string} yeaftDir
123
+ * @param {{sessionsRoot:string, registry?:object, yeaftDirForWorkDir:(workDir:string)=>string, sessionsRootForYeaftDir:(dir:string)=>string, copySessionExtras?:(projectYeaftDir:string, sessionId:string)=>void, unregisterSessionWorkDir?:(sessionId:string)=>void}} options
124
+ */
125
+ export function ensureSessionsManifest(yeaftDir, options) {
126
+ if (hasSessionManifest(yeaftDir)) {
127
+ return { created: false, migrated: 0, skipped: 0, manifest: loadSessionsManifest(yeaftDir) };
128
+ }
129
+ const root = options.sessionsRoot;
130
+ mkdirSync(root, { recursive: true });
131
+
132
+ let migrated = 0;
133
+ let skipped = 0;
134
+ const migratedIds = [];
135
+ const skippedIds = [];
136
+ const registry = options.registry && typeof options.registry === 'object' ? options.registry : {};
137
+ for (const [sessionId, workDir] of Object.entries(registry)) {
138
+ if (!sessionId || !workDir) continue;
139
+ const projectYeaftDir = options.yeaftDirForWorkDir(workDir);
140
+ const projectRoot = options.sessionsRootForYeaftDir(projectYeaftDir);
141
+ const sourceDir = join(projectRoot, sessionId);
142
+ const destDir = join(root, sessionId);
143
+ const meta = existsSync(sourceDir) ? loadSessionMeta(sourceDir) : null;
144
+ if (!meta) {
145
+ skipped += 1;
146
+ skippedIds.push(sessionId);
147
+ continue;
148
+ }
149
+ if (existsSync(destDir)) {
150
+ if (loadSessionMeta(destDir)) {
151
+ skipped += 1;
152
+ skippedIds.push(sessionId);
153
+ if (typeof options.unregisterSessionWorkDir === 'function') {
154
+ options.unregisterSessionWorkDir(sessionId);
155
+ }
156
+ continue;
157
+ }
158
+ skipped += 1;
159
+ skippedIds.push(sessionId);
160
+ continue;
161
+ }
162
+ cpSync(sourceDir, destDir, { recursive: true, errorOnExist: false });
163
+ migrated += 1;
164
+ migratedIds.push(sessionId);
165
+ if (typeof options.copySessionExtras === 'function') {
166
+ options.copySessionExtras(projectYeaftDir, sessionId);
167
+ }
168
+ if (typeof options.unregisterSessionWorkDir === 'function') {
169
+ options.unregisterSessionWorkDir(sessionId);
170
+ }
171
+ }
172
+
173
+ const manifest = writeSessionsManifest(yeaftDir, buildManifestFromLocalSessions(yeaftDir, root));
174
+ return { created: true, migrated, skipped, migratedIds, skippedIds, manifest };
175
+ }
176
+
177
+ export function addOrUpdateManifestSession(yeaftDir, meta, dir) {
178
+ const current = loadSessionsManifest(yeaftDir) || { sessions: [] };
179
+ const rows = current.sessions.filter(row => row.id !== meta.id);
180
+ rows.push(manifestRowFromMeta(meta, dir));
181
+ return writeSessionsManifest(yeaftDir, rows);
182
+ }
183
+
184
+ export function removeManifestSession(yeaftDir, sessionId) {
185
+ const current = loadSessionsManifest(yeaftDir);
186
+ if (!current) return null;
187
+ return writeSessionsManifest(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
188
+ }
189
+
190
+ function manifestRowFromMeta(meta, dir) {
191
+ return {
192
+ id: meta.id,
193
+ name: meta.name || meta.id,
194
+ path: dir,
195
+ createdAt: meta.createdAt || '',
196
+ workDir: meta.workDir || '',
197
+ };
198
+ }
199
+
200
+ function dedupeSessions(sessions) {
201
+ const byId = new Map();
202
+ for (const row of sessions || []) {
203
+ if (!row || !row.id || !row.path) continue;
204
+ byId.set(row.id, {
205
+ id: row.id,
206
+ name: row.name || row.id,
207
+ path: row.path,
208
+ createdAt: row.createdAt || '',
209
+ workDir: row.workDir || '',
210
+ });
211
+ }
212
+ return Array.from(byId.values());
213
+ }
214
+
215
+ export function __testRemoveSessionsManifest(yeaftDir) {
216
+ rmSync(sessionManifestPath(yeaftDir), { force: true });
217
+ }