@yeaft/webchat-agent 1.0.88 → 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.88",
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
+ }
package/yeaft/session.js CHANGED
@@ -45,7 +45,7 @@ import { TaskManager } from './tasks/manager.js';
45
45
  // resumes with the same onDemand/recent membership it had on
46
46
  // disconnect. Engine.#runQuery uses the registry to populate the
47
47
  // AMS each turn and to run `memory/adjust.js` post-turn.
48
- import { ensureDefaultSessionIfEmpty, yeaftDirForWorkDir } from './sessions/session-crud.js';
48
+ import { ensureDefaultSessionIfEmpty, migrateRegisteredWorkDirSessions } from './sessions/session-crud.js';
49
49
  import { seedDefaultVps } from './vp/seed-defaults.js';
50
50
  import { topUpDefaultVps } from './vp/seed-topup.js';
51
51
  import { archiveLegacyScopes } from './memory/seed-backfill.js';
@@ -71,7 +71,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
71
71
  /**
72
72
  * @typedef {Object} SessionOptions
73
73
  * @property {string} [dir] — Yeaft data directory override (default: ~/.yeaft)
74
- * @property {string} [workDir] — Session workDir; when provided, storage lives under <workDir>/.yeaft while config still comes from dir/defaults
74
+ * @property {string} [workDir] — Session workDir; used only for project-tier assets such as skills/MCP, while Session data stays under the user-level dir
75
75
  * @property {string} [model] — Model override
76
76
  * @property {string} [language] — Language override ('en' | 'zh')
77
77
  * @property {boolean} [debug] — Debug mode override
@@ -150,9 +150,10 @@ export async function loadSession(options = {}) {
150
150
 
151
151
  // ─── 1. Determine config + store directories ─────────────
152
152
  // Must happen BEFORE loadConfig so that first-run generates a
153
- // default config.json that loadConfig can read. workDir-backed
154
- // Sessions store conversation/session data under <workDir>/.yeaft,
155
- // but runtime config still comes from the agent-local configDir.
153
+ // default config.json that loadConfig can read. Session data
154
+ // (metadata/roster/config/messages/memory/tasks) is agent-user data
155
+ // and stays under configDir (`~/.yeaft`). workDir is only a project
156
+ // tier for assets such as skills and MCP config.
156
157
  const overrides = { ...configOverrides };
157
158
  if (dir) overrides.dir = dir;
158
159
  if (model) overrides.model = model;
@@ -161,15 +162,13 @@ export async function loadSession(options = {}) {
161
162
 
162
163
  const sessionWorkDir = typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
163
164
  const configDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
164
- const yeaftDir = sessionWorkDir ? yeaftDirForWorkDir(sessionWorkDir) : configDir;
165
+ const yeaftDir = configDir;
165
166
  const configInitResult = initYeaftDir(configDir);
166
- const storeInitResult = yeaftDir === configDir ? configInitResult : initYeaftDir(yeaftDir);
167
+ const storeInitResult = configInitResult;
167
168
  overrides.dir = configDir;
168
169
 
169
170
  // Log any warnings from directory initialization.
170
- const initWarnings = yeaftDir === configDir
171
- ? configInitResult.warnings
172
- : [...configInitResult.warnings, ...storeInitResult.warnings];
171
+ const initWarnings = configInitResult.warnings;
173
172
  for (const w of initWarnings) {
174
173
  console.warn(`[Yeaft] ${w}`);
175
174
  }
@@ -219,6 +218,22 @@ export async function loadSession(options = {}) {
219
218
  // dream directly. Existing users have already migrated
220
219
  // (state file in ~/.yeaft/.memory-v2-migration.json).
221
220
 
221
+ // ─── 2.3 Project-session migration ─────────────────────
222
+ // Session data used to be allowed under `<workDir>/.yeaft/sessions`.
223
+ // It is now user-level only (`~/.yeaft/sessions`). Copy any registered
224
+ // project-backed sessions into the user root before stores open.
225
+ try {
226
+ const migration = migrateRegisteredWorkDirSessions(configDir);
227
+ if (migration.migrated.length > 0 || migration.errors.length > 0) {
228
+ console.log(
229
+ `[Yeaft] session project-store migration: migrated=${migration.migrated.length} ` +
230
+ `errors=${migration.errors.length}`,
231
+ );
232
+ }
233
+ } catch (err) {
234
+ console.warn(`[Yeaft] session project-store migration failed: ${err?.message || err}`);
235
+ }
236
+
222
237
  // ─── 2a. Permission pre-check ─────────────────────────
223
238
  // If the data dir is not writable, mark session as read-only.
224
239
  // Persistence (conversation, memory, dream) is skipped in this mode.
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * session-config.js — Per-session selected model override state.
3
3
  *
4
- * Each session may carry its header-selected model override in `config.json` at
4
+ * Each session carries its header-selected model override in `config.json` at
5
5
  * ~/.yeaft/sessions/<sessionId>/config.json
6
- * or, for workDir-backed sessions:
7
- * <workDir>/.yeaft/sessions/<sessionId>/config.json
6
+ *
7
+ * Session config is user-level state. Project `.yeaft` directories may provide
8
+ * project assets such as skills/MCP config, but must not own Session config.
8
9
  *
9
10
  * v1 schema (intentionally tiny — extend via additive keys only):
10
11
  * {
@@ -24,7 +25,7 @@
24
25
  import { existsSync, readFileSync } from 'fs';
25
26
  import { join } from 'path';
26
27
  import { writeAtomic } from '../storage/index.js';
27
- import { sessionsRoot, resolveSessionYeaftDir } from './session-crud.js';
28
+ import { sessionsRoot } from './session-crud.js';
28
29
 
29
30
  const CONFIG_FILE = 'config.json';
30
31
 
@@ -41,14 +42,12 @@ export class SessionConfigError extends Error {
41
42
  }
42
43
 
43
44
  /**
44
- * Resolve the on-disk path for a session's config.json. Honours the
45
- * per-session workDir registry so sessions bound to a project directory
46
- * keep their config alongside the session metadata.
45
+ * Resolve the on-disk path for a session's config.json. Always uses the
46
+ * agent-local Yeaft root (`~/.yeaft` in production), not a project `.yeaft`.
47
47
  */
48
48
  export function sessionConfigPath(yeaftDir, sessionId) {
49
49
  if (!yeaftDir) return null;
50
- const sessionYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
51
- return join(sessionsRoot(sessionYeaftDir), sessionId, CONFIG_FILE);
50
+ return join(sessionsRoot(yeaftDir), sessionId, CONFIG_FILE);
52
51
  }
53
52
 
54
53
  /**
@@ -43,12 +43,13 @@ import {
43
43
  mkdirSync,
44
44
  readFileSync,
45
45
  writeFileSync,
46
+ cpSync,
46
47
  } from 'fs';
47
48
  import { randomBytes } from 'crypto';
48
49
  import { homedir } from 'os';
49
50
  import { isAbsolute, join, resolve } from 'path';
50
51
  import {
51
- openSession, createSession, listSessions, loadSessionMeta,
52
+ openSession, createSession, loadSessionMeta,
52
53
  } from './session-store.js';
53
54
  import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
54
55
  import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
@@ -56,6 +57,14 @@ import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
56
57
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
57
58
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
58
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';
59
68
 
60
69
  /**
61
70
  * Default memory root used when callers don't pass `options.memoryRoot`.
@@ -155,6 +164,59 @@ export function unregisterSessionWorkDir(defaultYeaftDir, sessionId) {
155
164
  writeWorkDirRegistry(defaultYeaftDir, registry);
156
165
  }
157
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
+
188
+ /**
189
+ * One-way compatibility migration for sessions previously stored under a
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.
193
+ *
194
+ * @param {string} yeaftDir user-level Yeaft root
195
+ * @returns {{migrated:string[], skipped:string[], errors:Array<{sessionId:string,error:string}>}}
196
+ */
197
+ export function migrateRegisteredWorkDirSessions(yeaftDir) {
198
+ const result = { migrated: [], skipped: [], errors: [] };
199
+ if (!yeaftDir || hasSessionManifest(yeaftDir)) return result;
200
+
201
+ const invalidTargets = new Set();
202
+ const registry = readWorkDirRegistry(yeaftDir);
203
+ for (const [sessionId, workDir] of Object.entries(registry)) {
204
+ const normalized = normalizeWorkDir(workDir);
205
+ if (!sessionId || !normalized) continue;
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' });
210
+ }
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));
217
+ return result;
218
+ }
219
+
158
220
  /**
159
221
  * Scan the `.yeaft/sessions/` directory under `workDir` and return every
160
222
  * session meta we can read. Read-only: never touches the registry.
@@ -228,18 +290,46 @@ export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
228
290
  if (!sessionId) throw new SessionCrudError('invalid_session_id', null);
229
291
  const normalized = normalizeWorkDir(workDir);
230
292
  if (!normalized) throw new SessionCrudError('invalid_workdir', sessionId);
231
- const groupYeaftDir = yeaftDirForWorkDir(normalized);
232
- const dir = join(sessionsRoot(groupYeaftDir), sessionId);
233
- if (!existsSync(dir)) throw new SessionCrudError('not_found', sessionId);
234
- const meta = loadSessionMeta(dir);
235
- if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `session metadata missing or unreadable at ${dir} (expected session.json or legacy group.json)`);
236
- registerSessionWorkDir(defaultYeaftDir, sessionId, normalized);
237
- return { ...meta, workDir: normalized };
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;
238
322
  }
239
323
 
240
324
  export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
241
325
  if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
242
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
+
243
333
  const registry = readWorkDirRegistry(defaultYeaftDir);
244
334
  const workDir = normalizeWorkDir(registry[sessionId]);
245
335
  if (workDir) {
@@ -290,7 +380,8 @@ function scanSortedVpIds(libDir) {
290
380
  export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
291
381
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
292
382
  const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
293
- const existing = listSessions(sessionsRoot(yeaftDir));
383
+ ensureSessionManifestReady(yeaftDir);
384
+ const existing = listManifestSessions(yeaftDir).map(row => row.meta);
294
385
  if (existing.length > 0) {
295
386
  return { seeded: false, sessionId: existing[0].id };
296
387
  }
@@ -307,6 +398,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
307
398
  defaultVpId,
308
399
  memoryRoot,
309
400
  });
401
+ const meta = group.getMeta();
402
+ if (meta) addOrUpdateManifestSession(yeaftDir, meta, join(sessionsRoot(yeaftDir), meta.id));
310
403
  return {
311
404
  seeded: created,
312
405
  sessionId: group.id,
@@ -328,7 +421,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
328
421
  export function createSessionFromSpec(yeaftDir, spec, options = {}) {
329
422
  const input = spec || {};
330
423
  const normalizedWorkDir = normalizeWorkDir(input.workDir);
331
- const groupYeaftDir = normalizedWorkDir ? yeaftDirForWorkDir(normalizedWorkDir) : yeaftDir;
424
+ ensureSessionManifestReady(yeaftDir);
425
+ const groupYeaftDir = yeaftDir;
332
426
  const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
333
427
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
334
428
  const name = String(input.name || '').trim();
@@ -364,7 +458,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
364
458
  const handle = createSession(root, { id, name, roster, defaultVpId, workDir: normalizedWorkDir });
365
459
  const meta = handle.getMeta();
366
460
  handle.close();
367
- if (normalizedWorkDir) registerSessionWorkDir(yeaftDir, id, normalizedWorkDir);
461
+ addOrUpdateManifestSession(yeaftDir, meta, join(root, id));
368
462
 
369
463
  // Per-session config (v1: model only). We always create an empty
370
464
  // config.json so hand-editing tools can find a session-level override
@@ -463,6 +557,7 @@ export function archiveSession(yeaftDir, sessionId) {
463
557
  // still cleared in case it points at a stale row.
464
558
  if (!existsSync(srcDir) || !loadSessionMeta(srcDir)) {
465
559
  unregisterSessionWorkDir(yeaftDir, sessionId);
560
+ removeManifestSession(yeaftDir, sessionId);
466
561
  return { sessionId, archivedAs: null, alreadyGone: true };
467
562
  }
468
563
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
@@ -471,6 +566,7 @@ export function archiveSession(yeaftDir, sessionId) {
471
566
  const dstDir = join(root, `.archived-${ts}-${suffix}-${sessionId}`);
472
567
  renameSync(srcDir, dstDir);
473
568
  unregisterSessionWorkDir(yeaftDir, sessionId);
569
+ removeManifestSession(yeaftDir, sessionId);
474
570
  return { sessionId, archivedAs: dstDir, alreadyGone: false };
475
571
  }
476
572
 
@@ -529,6 +625,7 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
529
625
  }
530
626
 
531
627
  unregisterSessionWorkDir(yeaftDir, sessionId);
628
+ removeManifestSession(yeaftDir, sessionId);
532
629
  return {
533
630
  sessionId,
534
631
  deleted: liveExists,
@@ -621,16 +718,10 @@ export function requireSession(yeaftDir, sessionId) {
621
718
 
622
719
  /** Convenience: snapshot all non-archived groups for WS broadcast. */
623
720
  export function snapshotSessions(yeaftDir) {
721
+ ensureSessionManifestReady(yeaftDir);
624
722
  const byId = new Map();
625
- for (const group of listSessions(sessionsRoot(yeaftDir))) {
626
- byId.set(group.id, group);
627
- }
628
- const registry = readWorkDirRegistry(yeaftDir);
629
- for (const [sessionId, workDir] of Object.entries(registry)) {
630
- const groupYeaftDir = yeaftDirForWorkDir(workDir);
631
- const dir = join(sessionsRoot(groupYeaftDir), sessionId);
632
- const meta = existsSync(dir) ? loadSessionMeta(dir) : null;
633
- if (meta) byId.set(meta.id, meta);
723
+ for (const row of listManifestSessions(yeaftDir)) {
724
+ byId.set(row.meta.id, row.meta);
634
725
  }
635
726
  // Attach per-group config overrides (v1: just `model`). Frontend can
636
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
+ }
@@ -45,12 +45,11 @@ import {
45
45
  removeMember,
46
46
  setSessionDefaultVp,
47
47
  snapshotSessions,
48
- resolveSessionYeaftDir,
49
48
  sessionsRoot,
50
49
  scanWorkdirSessions,
51
50
  restoreSessionToRegistry,
52
51
  readWorkDirRegistry,
53
- yeaftDirForWorkDir,
52
+ migrateRegisteredWorkDirSessions,
54
53
  } from './sessions/session-crud.js';
55
54
  import { openSession, loadSessionMeta } from './sessions/session-store.js';
56
55
  import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
@@ -189,8 +188,8 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
189
188
  let projectRuntime = null;
190
189
  if (sessionId) {
191
190
  try {
192
- const groupYeaftDir = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR, sessionId);
193
- const meta = loadSessionMeta(join(sessionsRoot(groupYeaftDir), sessionId));
191
+ const metaRoot = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
192
+ const meta = loadSessionMeta(join(sessionsRoot(metaRoot), sessionId));
194
193
  projectRuntime = await ensureProjectRuntimeForSessionMeta(meta);
195
194
  } catch { /* best-effort project metadata */ }
196
195
  }
@@ -429,13 +428,6 @@ function projectRuntimeKey(workDir) {
429
428
  return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
430
429
  }
431
430
 
432
- function resolveStoreYeaftDirForSession(defaultYeaftDir, { sessionId = null, sessionMeta = null, workDir = '' } = {}) {
433
- const normalizedWorkDir = normalizeSessionWorkDir(workDir || sessionMeta?.workDir);
434
- if (normalizedWorkDir) return yeaftDirForWorkDir(normalizedWorkDir);
435
- if (sessionId) return resolveSessionYeaftDir(defaultYeaftDir, sessionId);
436
- return defaultYeaftDir;
437
- }
438
-
439
431
  function createThreadId() {
440
432
  return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
441
433
  }
@@ -1315,10 +1307,8 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
1315
1307
  const key = threadKey(sessionId, vpId, threadId);
1316
1308
  if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
1317
1309
  // Per-session config overlay (v1: model only). Falls back to the
1318
- // session's user-level config when no override is set. Prefer the agent-local
1319
- // config root: sessionConfigPath() resolves registered workDir-backed
1320
- // sessions from there, while still allowing a later agent-local session in
1321
- // the same bridge runtime to read its own override after a workDir-first boot.
1310
+ // session's user-level config when no override is set. Session config is
1311
+ // always resolved from the agent-local root; project `.yeaft` is ignored.
1322
1312
  const sessionConfigRoot = liveConfigRoot();
1323
1313
  const groupCfg = loadSessionConfig(sessionConfigRoot, sessionId);
1324
1314
  const effectiveConfig = resolveSessionConfig(session.config, groupCfg);
@@ -2465,14 +2455,10 @@ export function handleYeaftCreateSession(msg) {
2465
2455
  }
2466
2456
 
2467
2457
  /**
2468
- * `yeaft_scan_workdir_sessions` — read-only probe: list every yeaft
2469
- * session physically present under `<workDir>/.yeaft/sessions/` along with
2470
- * an `alreadyRegistered` flag (so the restore UI can disable sessions
2471
- * already visible in the sidebar). Never mutates the registry; never
2472
- * throws on missing/empty directories.
2473
- *
2474
- * Pairs with `handleYeaftRestoreSession` for the "Restore session from a
2475
- * workdir" flow — see plan rosy-snuggling-waterfall.md.
2458
+ * `yeaft_scan_workdir_sessions` — compatibility endpoint for the retired
2459
+ * workdir Session scan flow. Session data now lives under the user-level
2460
+ * `~/.yeaft/sessions`; project `.yeaft` is reserved for project assets such as
2461
+ * skills and MCP config.
2476
2462
  */
2477
2463
  export function handleYeaftScanWorkdirSessions(msg) {
2478
2464
  const requestId = msg && msg.requestId;
@@ -2480,11 +2466,8 @@ export function handleYeaftScanWorkdirSessions(msg) {
2480
2466
  const workDir = String(msg && msg.workDir || '').trim();
2481
2467
  if (!workDir) throw new SessionCrudError('invalid_workdir', null, 'workDir required');
2482
2468
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2483
- // scanWorkdirSessions is a layer-pure utility it doesn't read the
2484
- // central workdir registry. The handler is the right layer to fold in
2485
- // `alreadyRegistered` because that flag couples the per-workdir scan
2486
- // to a specific agent's registry (the same scan from a CLI tool or
2487
- // a different agent would compare against a different registry).
2469
+ // scanWorkdirSessions is now a compatibility no-op because Session data is
2470
+ // user-level only. Keep the decoration shape stable for older clients.
2488
2471
  const sessions = scanWorkdirSessions(workDir);
2489
2472
  const registry = readWorkDirRegistry(yeaftDir);
2490
2473
  const decorated = sessions.map(s => ({
@@ -3533,8 +3516,8 @@ async function runYeaftSessionSend(msg) {
3533
3516
  return;
3534
3517
  }
3535
3518
 
3536
- // Open the session metadata first so a workDir-backed Session can schedule
3537
- // project runtime loading without blocking the user-message hot path. If
3519
+ // Open the user-level session metadata first so project runtime loading can
3520
+ // use the stored workDir without blocking the user-message hot path. If
3538
3521
  // metadata is missing we still boot the agent runtime for a useful error
3539
3522
  // response, then fail on the session open check.
3540
3523
  let sessionHandle = null;
@@ -3542,8 +3525,7 @@ async function runYeaftSessionSend(msg) {
3542
3525
  let sessionMetaForRuntime = null;
3543
3526
  const openSessionStart = perfNowMs();
3544
3527
  try {
3545
- const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
3546
- sessionRoot = sessionsRoot(groupYeaftDir);
3528
+ sessionRoot = sessionsRoot(yeaftDir);
3547
3529
  const dir = join(sessionRoot, sessionId);
3548
3530
  if (existsSync(dir) && loadSessionMeta(dir)) {
3549
3531
  sessionHandle = openSession(sessionRoot, sessionId);
@@ -3564,6 +3546,17 @@ async function runYeaftSessionSend(msg) {
3564
3546
 
3565
3547
  const ensureSessionStart = perfNowMs();
3566
3548
  await ensureSessionLoaded({ sessionMeta: sessionMetaForRuntime, perfTraceId });
3549
+ if (!sessionHandle) {
3550
+ try {
3551
+ const dir = join(sessionRoot, sessionId);
3552
+ if (existsSync(dir) && loadSessionMeta(dir)) {
3553
+ sessionHandle = openSession(sessionRoot, sessionId);
3554
+ sessionMetaForRuntime = sessionHandle.getMeta();
3555
+ }
3556
+ } catch (err) {
3557
+ console.warn('[Yeaft] yeaft_session_chat: migrated session reopen failed', err?.message || err);
3558
+ }
3559
+ }
3567
3560
  scheduleProjectRuntimeLoad(sessionMetaForRuntime?.workDir);
3568
3561
  traceDuration('session_send.ensure_session_loaded', ensureSessionStart);
3569
3562
 
@@ -3927,11 +3920,6 @@ async function ensureSessionLoaded(opts = {}) {
3927
3920
  sessionLoadPromise = (async () => {
3928
3921
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3929
3922
  const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
3930
- const sessionYeaftDir = resolveStoreYeaftDirForSession(yeaftDir, {
3931
- sessionId: opts?.sessionId || opts?.sessionMeta?.id || null,
3932
- sessionMeta: opts?.sessionMeta || null,
3933
- workDir: normalizedWorkDir,
3934
- });
3935
3923
  session = await loadSession({
3936
3924
  ...(yeaftDir && { dir: yeaftDir }),
3937
3925
  ...(normalizedWorkDir && { workDir: normalizedWorkDir }),
@@ -5694,13 +5682,15 @@ export async function handleYeaftLoadHistory(msg) {
5694
5682
  ensureYeaftConversationId();
5695
5683
 
5696
5684
  let sessionMetaForRuntime = null;
5697
- let sessionYeaftDir = yeaftDir;
5685
+ const historyYeaftDir = yeaftDir;
5698
5686
  if (sessionId) {
5699
5687
  try {
5700
- sessionYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
5701
- const metaDir = join(sessionsRoot(sessionYeaftDir), sessionId);
5688
+ migrateRegisteredWorkDirSessions(yeaftDir);
5689
+ const metaDir = join(sessionsRoot(yeaftDir), sessionId);
5702
5690
  sessionMetaForRuntime = loadSessionMeta(metaDir);
5703
- } catch { /* best-effort metadata hint */ }
5691
+ } catch (err) {
5692
+ console.warn('[Yeaft] load_history project-store migration failed:', err?.message || err);
5693
+ }
5704
5694
  }
5705
5695
 
5706
5696
  // First paint must not wait for full Yeaft runtime boot (MCP connects,
@@ -5708,7 +5698,7 @@ export async function handleYeaftLoadHistory(msg) {
5708
5698
  // source of truth and can be opened cheaply, so replay the visible message
5709
5699
  // window immediately, then finish loadSession below for actual turns.
5710
5700
  const coldStoreStart = perfNowMs();
5711
- const coldStore = new ConversationStore(sessionYeaftDir);
5701
+ const coldStore = new ConversationStore(historyYeaftDir);
5712
5702
  traceDuration('history.cold_store_open', coldStoreStart);
5713
5703
  if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
5714
5704
  let afterSeq = afterSeqRaw;