@yeaft/webchat-agent 1.0.89 → 1.0.91

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.91",
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
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * recovery.js — best-effort Session disk self-recovery.
3
+ *
4
+ * Instances can contain old partially migrated Session dirs where the
5
+ * conversation transcript still exists under conversation/messages/*.md, but
6
+ * session.json is missing. This module repairs Session metadata and the
7
+ * coordinator audit-log index only. It deliberately does not convert
8
+ * conversation Markdown into the audit log: the engine/UI history source is
9
+ * ConversationStore under conversation/, not sessions/<id>/messages/.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ rmSync,
18
+ statSync,
19
+ } from 'fs';
20
+ import { join } from 'path';
21
+ import { writeAtomic, openLog } from '../storage/index.js';
22
+ import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
23
+ import { loadSessionMeta, SESSION_META_FILE } from './session-store.js';
24
+
25
+ const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
26
+ const DEFAULT_VP_ID = 'omni';
27
+ const AUDIT_INDEX_FILE = 'index.json';
28
+
29
+ export function repairSessionStore(yeaftDir, options = {}) {
30
+ const root = join(yeaftDir, 'sessions');
31
+ if (!existsSync(root)) return { repaired: 0, sessions: [] };
32
+ const sessionIds = safeReadDir(root)
33
+ .filter(name => !name.startsWith('.') && SESSION_ID_RE.test(name))
34
+ .filter(name => isDirectory(join(root, name)));
35
+ const sessions = [];
36
+ for (const sessionId of sessionIds) {
37
+ const result = repairSessionDir(root, sessionId, options);
38
+ if (result.changed) sessions.push(result);
39
+ }
40
+ return { repaired: sessions.length, sessions };
41
+ }
42
+
43
+ export function repairSessionDir(sessionsRoot, sessionId, options = {}) {
44
+ const dir = join(sessionsRoot, sessionId);
45
+ const result = {
46
+ sessionId,
47
+ changed: false,
48
+ metaCreated: false,
49
+ auditIndexRebuilt: false,
50
+ };
51
+ if (!isDirectory(dir)) return result;
52
+
53
+ if (!loadSessionMeta(dir)) {
54
+ const meta = inferSessionMeta(dir, sessionId, options);
55
+ writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
56
+ result.metaCreated = true;
57
+ result.changed = true;
58
+ }
59
+
60
+ if (repairAuditIndexIfNeeded(join(dir, 'messages'))) {
61
+ result.auditIndexRebuilt = true;
62
+ result.changed = true;
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ function inferSessionMeta(dir, sessionId, options = {}) {
69
+ const first = readFirstConversationMarkdownRow(dir, sessionId);
70
+ const createdAt = first?.time || safeStatTime(dir) || new Date().toISOString();
71
+ const name = inferSessionName(sessionId);
72
+ const roster = Array.isArray(options.defaultRoster)
73
+ ? options.defaultRoster.filter(v => typeof v === 'string' && v)
74
+ : [DEFAULT_VP_ID];
75
+ const defaultVpId = typeof options.defaultVpId === 'string'
76
+ ? options.defaultVpId
77
+ : (roster.includes(DEFAULT_VP_ID) ? DEFAULT_VP_ID : (roster[0] || null));
78
+ return {
79
+ id: sessionId,
80
+ name,
81
+ roster,
82
+ defaultVpId,
83
+ announcement: '',
84
+ workDir: typeof options.workDir === 'string' ? options.workDir : '',
85
+ createdAt,
86
+ };
87
+ }
88
+
89
+ function inferSessionName(sessionId) {
90
+ const raw = sessionId
91
+ .replace(/^session_/, '')
92
+ .replace(/_[A-Z0-9]{8}$/, '')
93
+ .replace(/[-_]+/g, ' ')
94
+ .trim();
95
+ return raw ? raw.replace(/\b\w/g, ch => ch.toUpperCase()) : sessionId;
96
+ }
97
+
98
+ function readFirstConversationMarkdownRow(sessionDir, sessionId) {
99
+ const dirs = [
100
+ join(sessionDir, 'conversation', 'cold'),
101
+ join(sessionDir, 'conversation', 'messages'),
102
+ ];
103
+ let first = null;
104
+ for (const dir of dirs) {
105
+ if (!existsSync(dir)) continue;
106
+ for (const file of safeReadDir(dir).filter(f => /^m\d+\.md$/.test(f)).sort()) {
107
+ const msg = parseMessage(safeRead(join(dir, file)));
108
+ if (!msg) continue;
109
+ if (msg.sessionId && msg.sessionId !== sessionId) continue;
110
+ if (!first || compareMarkdownMessages(msg, first) < 0) first = msg;
111
+ }
112
+ }
113
+ return first;
114
+ }
115
+
116
+ function repairAuditIndexIfNeeded(messagesDir) {
117
+ if (!existsSync(messagesDir)) return false;
118
+ const segmentFiles = safeReadDir(messagesDir).filter(f => /^\d+\.jsonl$/.test(f)).sort();
119
+ if (segmentFiles.length === 0) return false;
120
+
121
+ const indexPath = join(messagesDir, AUDIT_INDEX_FILE);
122
+ if (!auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles)) return false;
123
+
124
+ rmSync(indexPath, { force: true });
125
+ const log = openLog(messagesDir);
126
+ log.close();
127
+ return true;
128
+ }
129
+
130
+ function auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles) {
131
+ const parsed = readJson(indexPath);
132
+ if (!parsed || !Array.isArray(parsed.segments)) return true;
133
+ const indexFiles = parsed.segments.map(seg => seg && seg.file).filter(Boolean).sort();
134
+ if (indexFiles.length !== segmentFiles.length) return true;
135
+ for (let i = 0; i < segmentFiles.length; i++) {
136
+ if (indexFiles[i] !== segmentFiles[i]) return true;
137
+ }
138
+
139
+ const byFile = new Map(parsed.segments.map(seg => [seg.file, seg]));
140
+ for (const file of segmentFiles) {
141
+ const seg = byFile.get(file);
142
+ if (!seg) return true;
143
+ const bytes = safeSize(join(messagesDir, file));
144
+ // This catches the broken real-world case: index says an existing segment
145
+ // is empty even though the JSONL file has data. Full validation is left to
146
+ // openLog's rebuild path after we remove the stale index.
147
+ if (bytes > 0 && ((Number(seg.count) || 0) === 0 || (Number(seg.bytes) || 0) === 0)) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function compareMarkdownMessages(a, b) {
153
+ const as = parseSeqFromId(a?.id);
154
+ const bs = parseSeqFromId(b?.id);
155
+ if (Number.isFinite(as) && Number.isFinite(bs) && as !== bs) return as - bs;
156
+ return String(a?.time || '').localeCompare(String(b?.time || ''));
157
+ }
158
+
159
+ function readJson(path) {
160
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
161
+ catch { return null; }
162
+ }
163
+
164
+ function safeRead(path) {
165
+ try { return readFileSync(path, 'utf8'); }
166
+ catch { return ''; }
167
+ }
168
+
169
+ function safeReadDir(dir) {
170
+ try { return readdirSync(dir); }
171
+ catch { return []; }
172
+ }
173
+
174
+ function isDirectory(path) {
175
+ try { return statSync(path).isDirectory(); }
176
+ catch { return false; }
177
+ }
178
+
179
+ function safeSize(path) {
180
+ try { return statSync(path).size; }
181
+ catch { return 0; }
182
+ }
183
+
184
+ function safeStatTime(path) {
185
+ try { return statSync(path).mtime.toISOString(); }
186
+ catch { return null; }
187
+ }
@@ -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,15 @@ 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 { repairSessionStore } from './recovery.js';
61
+ import {
62
+ addOrUpdateManifestSession,
63
+ ensureSessionsManifest,
64
+ hasSessionManifest,
65
+ listManifestSessions,
66
+ removeManifestSession,
67
+ resolveManifestSessionDir,
68
+ } from './session-manifest.js';
60
69
 
61
70
  /**
62
71
  * Default memory root used when callers don't pass `options.memoryRoot`.
@@ -156,95 +165,194 @@ export function unregisterSessionWorkDir(defaultYeaftDir, sessionId) {
156
165
  writeWorkDirRegistry(defaultYeaftDir, registry);
157
166
  }
158
167
 
168
+ function ensureSessionManifestReady(yeaftDir) {
169
+ return ensureSessionsManifest(yeaftDir, {
170
+ sessionsRoot: sessionsRoot(yeaftDir),
171
+ registry: readWorkDirRegistry(yeaftDir),
172
+ yeaftDirForWorkDir,
173
+ sessionsRootForYeaftDir: sessionsRoot,
174
+ copySessionExtras: (projectYeaftDir, sessionId) => copySessionExtras(projectYeaftDir, yeaftDir, sessionId),
175
+ unregisterSessionWorkDir: (sessionId) => unregisterSessionWorkDir(yeaftDir, sessionId),
176
+ });
177
+ }
178
+
179
+ function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
180
+ for (const family of ['session', 'sessions', 'group']) {
181
+ const src = join(sourceYeaftDir, 'memory', family, sessionId);
182
+ const dst = join(destYeaftDir, 'memory', family, sessionId);
183
+ if (!existsSync(src) || existsSync(dst)) continue;
184
+ mkdirSync(join(dst, '..'), { recursive: true });
185
+ cpSync(src, dst, { recursive: true, errorOnExist: false });
186
+ }
187
+ }
188
+
189
+ function repairSessionStoreAndManifest(yeaftDir, options = {}) {
190
+ const repaired = repairSessionStore(yeaftDir, options);
191
+ ensureSessionManifestReady(yeaftDir);
192
+ for (const row of repaired.sessions || []) {
193
+ const dir = join(sessionsRoot(yeaftDir), row.sessionId);
194
+ const meta = loadSessionMeta(dir);
195
+ if (meta) addOrUpdateManifestSession(yeaftDir, meta, dir);
196
+ }
197
+ return repaired;
198
+ }
199
+
159
200
  /**
160
201
  * 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.
202
+ * project `.yeaft/sessions` tree. New steady-state discovery uses
203
+ * `sessions-manifest.json`; this helper bootstraps that manifest once and
204
+ * reports what happened for boot logs/tests.
168
205
  *
169
206
  * @param {string} yeaftDir user-level Yeaft root
170
207
  * @returns {{migrated:string[], skipped:string[], errors:Array<{sessionId:string,error:string}>}}
171
208
  */
172
209
  export function migrateRegisteredWorkDirSessions(yeaftDir) {
173
210
  const result = { migrated: [], skipped: [], errors: [] };
174
- if (!yeaftDir) return result;
211
+ if (!yeaftDir || hasSessionManifest(yeaftDir)) return result;
212
+
213
+ const invalidTargets = new Set();
175
214
  const registry = readWorkDirRegistry(yeaftDir);
176
- const userRoot = sessionsRoot(yeaftDir);
177
215
  for (const [sessionId, workDir] of Object.entries(registry)) {
178
216
  const normalized = normalizeWorkDir(workDir);
179
217
  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) });
218
+ const targetDir = join(sessionsRoot(yeaftDir), sessionId);
219
+ if (existsSync(targetDir) && !loadSessionMeta(targetDir)) {
220
+ invalidTargets.add(sessionId);
221
+ result.errors.push({ sessionId, error: 'target session directory exists but session metadata is invalid' });
211
222
  }
212
223
  }
224
+
225
+ const bootstrap = ensureSessionManifestReady(yeaftDir);
226
+ result.migrated = Array.isArray(bootstrap.migratedIds) ? bootstrap.migratedIds.slice() : [];
227
+ result.skipped = (Array.isArray(bootstrap.skippedIds) ? bootstrap.skippedIds : [])
228
+ .filter(sessionId => !invalidTargets.has(sessionId));
213
229
  return result;
214
230
  }
215
231
 
216
232
  /**
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.
233
+ * Scan the `.yeaft/sessions/` directory under `workDir` and return every
234
+ * session meta we can read. Read-only: never touches the registry.
235
+ *
236
+ * Each returned record carries `workDir` (the normalized path we scanned).
237
+ * This utility deliberately does NOT decorate `alreadyRegistered` — that
238
+ * cross-references the central workdir registry, which is a separate
239
+ * concern owned by the handler / caller (see
240
+ * `handleYeaftScanWorkdirSessions`). Keeping the utility layer-pure makes
241
+ * it usable from contexts that don't have / don't care about the registry
242
+ * (e.g. CLI tools, future per-workdir snapshots).
243
+ *
244
+ * Returns `[]` for missing dir / empty dir / unreadable dir — never throws.
245
+ * Sort is `createdAt` descending (most-recent first) because the restore
246
+ * UI typically cares about "the session I made yesterday".
221
247
  *
222
248
  * @param {string} workDir — the working directory to scan.
223
249
  * @returns {Array<object>}
224
250
  */
225
251
  export function scanWorkdirSessions(workDir) {
226
- return [];
252
+ const normalized = normalizeWorkDir(workDir);
253
+ if (!normalized) return [];
254
+ const groupYeaftDir = yeaftDirForWorkDir(normalized);
255
+ const root = sessionsRoot(groupYeaftDir);
256
+ if (!existsSync(root)) return [];
257
+ const out = [];
258
+ let entries;
259
+ try {
260
+ entries = readdirSync(root);
261
+ } catch {
262
+ return [];
263
+ }
264
+ for (const name of entries) {
265
+ if (name.startsWith('.')) continue; // skip .archived-* and dotfiles
266
+ const dir = join(root, name);
267
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
268
+ const meta = loadSessionMeta(dir);
269
+ if (!meta) continue;
270
+ out.push({
271
+ ...meta,
272
+ workDir: normalized,
273
+ });
274
+ }
275
+ return out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
227
276
  }
228
277
 
229
278
  /**
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.
279
+ * Register `(sessionId, workDir)` in the central registry so the next
280
+ * `snapshotSessions()` includes this session.
281
+ *
282
+ * Validates that `<workDir>/.yeaft/sessions/<sessionId>/session.json`
283
+ * exists and is parseable, with legacy `group.json` as a read fallback.
284
+ * Throws:
285
+ * - `not_found` — the session dir is not on disk at this workdir
286
+ * - `corrupt_meta` — the dir exists but session metadata is missing /
287
+ * unreadable / can't be parsed. Surfaced as a distinct
288
+ * code so the UI can tell the user "the file is broken"
289
+ * instead of "you picked the wrong workdir" (review
290
+ * finding I1).
291
+ *
292
+ * Idempotent: if the same `(sessionId, workDir)` is already registered,
293
+ * we still rewrite the entry (with the normalized path) and return the
294
+ * fresh meta — no error.
233
295
  *
234
296
  * @param {string} defaultYeaftDir
235
297
  * @param {string} sessionId
236
298
  * @param {string} workDir
237
- * @returns {object}
299
+ * @returns {object} the session meta, with `workDir` set to the normalized path.
238
300
  */
239
301
  export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
240
302
  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');
303
+ const normalized = normalizeWorkDir(workDir);
304
+ if (!normalized) throw new SessionCrudError('invalid_workdir', sessionId);
305
+ const projectYeaftDir = yeaftDirForWorkDir(normalized);
306
+ const sourceDir = join(sessionsRoot(projectYeaftDir), sessionId);
307
+ if (!existsSync(sourceDir)) throw new SessionCrudError('not_found', sessionId);
308
+ const meta = loadSessionMeta(sourceDir);
309
+ if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `session metadata missing or unreadable at ${sourceDir} (expected session.json or legacy group.json)`);
310
+
311
+ ensureSessionManifestReady(defaultYeaftDir);
312
+ const root = sessionsRoot(defaultYeaftDir);
313
+ const destDir = join(root, sessionId);
314
+ const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
315
+ if (existsSync(destDir)) {
316
+ const existing = loadSessionMeta(destDir);
317
+ if (existing && manifestDir === destDir) return { ...existing, workDir: existing.workDir || normalized };
318
+ throw new SessionCrudError('duplicate', sessionId, `session already exists at ${destDir}`);
319
+ }
320
+
321
+ mkdirSync(root, { recursive: true });
322
+ cpSync(sourceDir, destDir, { recursive: true, errorOnExist: true });
323
+ const importedMeta = { ...meta, workDir: normalized };
324
+ const handle = openSession(root, sessionId);
325
+ try {
326
+ handle.saveMeta(importedMeta);
327
+ } finally {
328
+ handle.close();
329
+ }
330
+ copySessionExtras(projectYeaftDir, defaultYeaftDir, sessionId);
331
+ addOrUpdateManifestSession(defaultYeaftDir, importedMeta, destDir);
332
+ unregisterSessionWorkDir(defaultYeaftDir, sessionId);
333
+ return importedMeta;
242
334
  }
243
335
 
244
336
  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.
337
+ if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
338
+
339
+ const manifestReady = hasSessionManifest(defaultYeaftDir);
340
+ ensureSessionManifestReady(defaultYeaftDir);
341
+ const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
342
+ if (manifestDir) return join(manifestDir, '..', '..');
343
+ if (manifestReady) return defaultYeaftDir;
344
+
345
+ const registry = readWorkDirRegistry(defaultYeaftDir);
346
+ const workDir = normalizeWorkDir(registry[sessionId]);
347
+ if (workDir) {
348
+ const candidate = yeaftDirForWorkDir(workDir);
349
+ const candidateDir = join(sessionsRoot(candidate), sessionId);
350
+ if (existsSync(candidateDir) && loadSessionMeta(candidateDir)) return candidate;
351
+ }
352
+
353
+ const defaultSessionDir = join(sessionsRoot(defaultYeaftDir), sessionId);
354
+ if (existsSync(defaultSessionDir) && loadSessionMeta(defaultSessionDir)) return defaultYeaftDir;
355
+
248
356
  return defaultYeaftDir;
249
357
  }
250
358
 
@@ -284,7 +392,10 @@ function scanSortedVpIds(libDir) {
284
392
  export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
285
393
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
286
394
  const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
287
- const existing = listSessions(sessionsRoot(yeaftDir));
395
+ repairSessionStoreAndManifest(yeaftDir, {
396
+ defaultRoster: scanSortedVpIds(libDir),
397
+ });
398
+ const existing = listManifestSessions(yeaftDir).map(row => row.meta);
288
399
  if (existing.length > 0) {
289
400
  return { seeded: false, sessionId: existing[0].id };
290
401
  }
@@ -301,6 +412,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
301
412
  defaultVpId,
302
413
  memoryRoot,
303
414
  });
415
+ const meta = group.getMeta();
416
+ if (meta) addOrUpdateManifestSession(yeaftDir, meta, join(sessionsRoot(yeaftDir), meta.id));
304
417
  return {
305
418
  seeded: created,
306
419
  sessionId: group.id,
@@ -322,7 +435,9 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
322
435
  export function createSessionFromSpec(yeaftDir, spec, options = {}) {
323
436
  const input = spec || {};
324
437
  const normalizedWorkDir = normalizeWorkDir(input.workDir);
325
- const memoryRoot = options.memoryRoot || (yeaftDir ? join(yeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
438
+ ensureSessionManifestReady(yeaftDir);
439
+ const groupYeaftDir = yeaftDir;
440
+ const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
326
441
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
327
442
  const name = String(input.name || '').trim();
328
443
  if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
@@ -348,7 +463,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
348
463
  if (!defaultVpId) defaultVpId = roster[0] || null;
349
464
 
350
465
  const id = makeSessionId(name);
351
- const root = sessionsRoot(yeaftDir);
466
+ const root = sessionsRoot(groupYeaftDir);
352
467
  if (existsSync(join(root, id))) {
353
468
  // Extremely unlikely (ulid suffix), but surface deterministically.
354
469
  throw new SessionCrudError('duplicate', id);
@@ -357,7 +472,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
357
472
  const handle = createSession(root, { id, name, roster, defaultVpId, workDir: normalizedWorkDir });
358
473
  const meta = handle.getMeta();
359
474
  handle.close();
360
- if (normalizedWorkDir) registerSessionWorkDir(yeaftDir, id, normalizedWorkDir);
475
+ addOrUpdateManifestSession(yeaftDir, meta, join(root, id));
361
476
 
362
477
  // Per-session config (v1: model only). We always create an empty
363
478
  // config.json so hand-editing tools can find a session-level override
@@ -449,12 +564,14 @@ export function updateSessionConfig(yeaftDir, sessionId, partial) {
449
564
  * own second-confirm modal (acceptance #4 in task-334-slice-specs.md 334m).
450
565
  */
451
566
  export function archiveSession(yeaftDir, sessionId) {
452
- const root = sessionsRoot(yeaftDir);
567
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
568
+ const root = sessionsRoot(groupYeaftDir);
453
569
  const srcDir = join(root, sessionId);
454
570
  // Idempotent — nothing on disk, nothing to archive. Workdir-registry is
455
571
  // still cleared in case it points at a stale row.
456
572
  if (!existsSync(srcDir) || !loadSessionMeta(srcDir)) {
457
573
  unregisterSessionWorkDir(yeaftDir, sessionId);
574
+ removeManifestSession(yeaftDir, sessionId);
458
575
  return { sessionId, archivedAs: null, alreadyGone: true };
459
576
  }
460
577
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
@@ -463,6 +580,7 @@ export function archiveSession(yeaftDir, sessionId) {
463
580
  const dstDir = join(root, `.archived-${ts}-${suffix}-${sessionId}`);
464
581
  renameSync(srcDir, dstDir);
465
582
  unregisterSessionWorkDir(yeaftDir, sessionId);
583
+ removeManifestSession(yeaftDir, sessionId);
466
584
  return { sessionId, archivedAs: dstDir, alreadyGone: false };
467
585
  }
468
586
 
@@ -479,8 +597,9 @@ export function archiveSession(yeaftDir, sessionId) {
479
597
  * delete cleans up legacy state too.
480
598
  */
481
599
  export function deleteSession(yeaftDir, sessionId, options = {}) {
482
- const memoryRoot = options.memoryRoot || (yeaftDir ? join(yeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
483
- const root = sessionsRoot(yeaftDir);
600
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
601
+ const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
602
+ const root = sessionsRoot(groupYeaftDir);
484
603
  const srcDir = join(root, sessionId);
485
604
  const liveExists = existsSync(srcDir) && !!loadSessionMeta(srcDir);
486
605
 
@@ -520,6 +639,7 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
520
639
  }
521
640
 
522
641
  unregisterSessionWorkDir(yeaftDir, sessionId);
642
+ removeManifestSession(yeaftDir, sessionId);
523
643
  return {
524
644
  sessionId,
525
645
  deleted: liveExists,
@@ -601,7 +721,8 @@ export function setSessionDefaultVp(yeaftDir, sessionId, vpId) {
601
721
  }
602
722
 
603
723
  export function requireSession(yeaftDir, sessionId) {
604
- const root = sessionsRoot(yeaftDir);
724
+ const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
725
+ const root = sessionsRoot(groupYeaftDir);
605
726
  const dir = join(root, sessionId);
606
727
  if (!existsSync(dir) || !loadSessionMeta(dir)) {
607
728
  throw new SessionCrudError('not_found', sessionId);
@@ -611,14 +732,10 @@ export function requireSession(yeaftDir, sessionId) {
611
732
 
612
733
  /** Convenience: snapshot all non-archived groups for WS broadcast. */
613
734
  export function snapshotSessions(yeaftDir) {
735
+ repairSessionStoreAndManifest(yeaftDir);
614
736
  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);
737
+ for (const row of listManifestSessions(yeaftDir)) {
738
+ byId.set(row.meta.id, row.meta);
622
739
  }
623
740
  // Attach per-group config overrides (v1: just `model`). Frontend can
624
741
  // 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
+ }