metame-cli 1.5.3 → 1.5.5

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.
Files changed (51) hide show
  1. package/README.md +60 -18
  2. package/index.js +352 -79
  3. package/package.json +2 -2
  4. package/scripts/agent-layer.js +4 -2
  5. package/scripts/bin/dispatch_to +178 -90
  6. package/scripts/daemon-admin-commands.js +353 -105
  7. package/scripts/daemon-agent-commands.js +434 -66
  8. package/scripts/daemon-bridges.js +477 -68
  9. package/scripts/daemon-claude-engine.js +1267 -674
  10. package/scripts/daemon-command-router.js +205 -27
  11. package/scripts/daemon-command-session-route.js +118 -0
  12. package/scripts/daemon-default.yaml +7 -0
  13. package/scripts/daemon-engine-runtime.js +96 -20
  14. package/scripts/daemon-exec-commands.js +108 -49
  15. package/scripts/daemon-file-browser.js +64 -7
  16. package/scripts/daemon-notify.js +18 -4
  17. package/scripts/daemon-ops-commands.js +16 -2
  18. package/scripts/daemon-remote-dispatch.js +55 -1
  19. package/scripts/daemon-runtime-lifecycle.js +87 -0
  20. package/scripts/daemon-session-commands.js +102 -45
  21. package/scripts/daemon-session-store.js +497 -66
  22. package/scripts/daemon-siri-bridge.js +234 -0
  23. package/scripts/daemon-siri-imessage.js +209 -0
  24. package/scripts/daemon-task-scheduler.js +10 -2
  25. package/scripts/daemon.js +697 -179
  26. package/scripts/daemon.yaml +7 -0
  27. package/scripts/docs/agent-guide.md +36 -3
  28. package/scripts/docs/hook-config.md +134 -0
  29. package/scripts/docs/maintenance-manual.md +162 -5
  30. package/scripts/docs/pointer-map.md +60 -5
  31. package/scripts/feishu-adapter.js +7 -15
  32. package/scripts/hooks/doc-router.js +29 -0
  33. package/scripts/hooks/hook-utils.js +61 -0
  34. package/scripts/hooks/intent-doc-router.js +54 -0
  35. package/scripts/hooks/intent-engine.js +72 -0
  36. package/scripts/hooks/intent-file-transfer.js +51 -0
  37. package/scripts/hooks/intent-memory-recall.js +35 -0
  38. package/scripts/hooks/intent-ops-assist.js +54 -0
  39. package/scripts/hooks/intent-task-create.js +35 -0
  40. package/scripts/hooks/intent-team-dispatch.js +106 -0
  41. package/scripts/hooks/team-context.js +143 -0
  42. package/scripts/intent-registry.js +59 -0
  43. package/scripts/memory-extract.js +59 -0
  44. package/scripts/memory-nightly-reflect.js +109 -43
  45. package/scripts/memory.js +55 -17
  46. package/scripts/mentor-engine.js +6 -0
  47. package/scripts/schema.js +1 -0
  48. package/scripts/self-reflect.js +110 -12
  49. package/scripts/session-analytics.js +160 -0
  50. package/scripts/signal-capture.js +1 -1
  51. package/scripts/team-dispatch.js +315 -0
@@ -2,6 +2,78 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
 
5
+ function normalizeCodexSandboxMode(value, fallback = null) {
6
+ const text = String(value || '').trim().toLowerCase();
7
+ if (!text) return fallback;
8
+ if (text === 'read-only' || text === 'readonly') return 'read-only';
9
+ if (text === 'workspace-write' || text === 'workspace') return 'workspace-write';
10
+ if (
11
+ text === 'danger-full-access'
12
+ || text === 'dangerous'
13
+ || text === 'full-access'
14
+ || text === 'full'
15
+ || text === 'bypass'
16
+ || text === 'writable'
17
+ ) return 'danger-full-access';
18
+ return fallback;
19
+ }
20
+
21
+ function normalizeCodexApprovalPolicy(value, fallback = null) {
22
+ const text = String(value || '').trim().toLowerCase();
23
+ if (!text) return fallback;
24
+ if (text === 'never' || text === 'no' || text === 'none') return 'never';
25
+ if (text === 'on-failure' || text === 'on_failure' || text === 'failure') return 'on-failure';
26
+ if (text === 'on-request' || text === 'on_request' || text === 'request') return 'on-request';
27
+ if (text === 'untrusted') return 'untrusted';
28
+ return fallback;
29
+ }
30
+
31
+ function normalizeCodexPermissionMeta(meta = {}) {
32
+ const sandboxMode = normalizeCodexSandboxMode(
33
+ meta.sandboxMode || meta.sandbox_mode || meta.permissionMode,
34
+ null
35
+ );
36
+ const approvalPolicy = normalizeCodexApprovalPolicy(
37
+ meta.approvalPolicy || meta.approval_policy,
38
+ null
39
+ );
40
+ if (!sandboxMode && !approvalPolicy) return null;
41
+ return {
42
+ sandboxMode: sandboxMode || 'danger-full-access',
43
+ approvalPolicy: approvalPolicy || 'never',
44
+ permissionMode: sandboxMode || 'danger-full-access',
45
+ };
46
+ }
47
+
48
+ function normalizeEngineName(name) {
49
+ return String(name || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
50
+ }
51
+
52
+ function stripCodexInjectedHints(text) {
53
+ return String(text || '')
54
+ .replace(/\r\n/g, '\n')
55
+ .replace(/\n*System hints \(internal, do not mention to user\):[\s\S]*/i, '')
56
+ .replace(/\n*\[Respond in Simplified Chinese[\s\S]*/i, '')
57
+ .replace(/\n*\[Agent memory snapshot:[\s\S]*/i, '')
58
+ .replace(/\n*\[Relevant facts:[\s\S]*/i, '')
59
+ .trim();
60
+ }
61
+
62
+ function looksLikeInternalCodexPrompt(text) {
63
+ const clean = stripCodexInjectedHints(text).trim();
64
+ if (!clean) return true;
65
+ return (
66
+ /^you are a metame\b/i.test(clean)
67
+ || /^you are a meta ?me\b/i.test(clean)
68
+ || /^you are a session reflection assistant\b/i.test(clean)
69
+ || /^you are a metacognition pattern detector\b/i.test(clean)
70
+ || /^you are codex, based on gpt-5\b/i.test(clean)
71
+ || /^\[nightly-reflect]/i.test(clean)
72
+ || /^\[self-reflect]/i.test(clean)
73
+ || /^\[memory-/i.test(clean)
74
+ );
75
+ }
76
+
5
77
 
6
78
  function createSessionStore(deps) {
7
79
  const {
@@ -16,7 +88,9 @@ function createSessionStore(deps) {
16
88
  } = deps;
17
89
 
18
90
  const CLAUDE_PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
91
+ const CODEX_DB = path.join(HOME, '.codex', 'state_5.sqlite');
19
92
  const _sessionFileCache = new Map(); // sessionId -> { path, ts }
93
+ const _codexRolloutCache = new Map(); // sessionId -> { path, ts }
20
94
  let _sessionCache = null;
21
95
  let _sessionCacheTime = 0;
22
96
  const SESSION_CACHE_TTL = 30000; // 30s — scan is expensive, 10s was too frequent
@@ -42,6 +116,7 @@ function createSessionStore(deps) {
42
116
  function clearSessionFileCache(sessionId) {
43
117
  if (!sessionId) return;
44
118
  _sessionFileCache.delete(sessionId);
119
+ _codexRolloutCache.delete(sessionId);
45
120
  }
46
121
 
47
122
  function truncateSessionLastTurn(sessionId) {
@@ -160,10 +235,9 @@ function createSessionStore(deps) {
160
235
  return '';
161
236
  }
162
237
 
163
- function scanAllSessions() {
164
- if (_sessionCache && (Date.now() - _sessionCacheTime < SESSION_CACHE_TTL)) return _sessionCache;
238
+ function scanClaudeSessions() {
165
239
  try {
166
- if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) { _sessionCache = []; _sessionCacheTime = Date.now(); return []; }
240
+ if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return [];
167
241
  const projects = fs.readdirSync(CLAUDE_PROJECTS_DIR);
168
242
  const sessionMap = new Map();
169
243
  const projPathCache = new Map();
@@ -212,13 +286,7 @@ function createSessionStore(deps) {
212
286
  } catch { /* skip */ }
213
287
  }
214
288
 
215
- const all = Array.from(sessionMap.values());
216
- all.sort((a, b) => {
217
- const aTime = a.fileMtime || new Date(a.modified).getTime();
218
- const bTime = b.fileMtime || new Date(b.modified).getTime();
219
- return bTime - aTime;
220
- });
221
-
289
+ const all = Array.from(sessionMap.values()).map((entry) => ({ ...entry, engine: 'claude' }));
222
290
  const ENRICH_LIMIT = 20;
223
291
  for (let i = 0; i < Math.min(all.length, ENRICH_LIMIT); i++) {
224
292
  const s = all[i];
@@ -276,7 +344,173 @@ function createSessionStore(deps) {
276
344
  s._enriched = true; // [M1] 标记已完成富化,下次跳过
277
345
  } catch { /* non-fatal */ }
278
346
  }
347
+ return all;
348
+ } catch {
349
+ return [];
350
+ }
351
+ }
352
+
353
+ function scanCodexSessions() {
354
+ let db = null;
355
+ try {
356
+ if (!fs.existsSync(CODEX_DB)) return [];
357
+ const { DatabaseSync } = require('node:sqlite');
358
+ db = new DatabaseSync(CODEX_DB, { readonly: true });
359
+ const rows = db.prepare(`
360
+ SELECT
361
+ id,
362
+ cwd,
363
+ title,
364
+ first_user_message,
365
+ source,
366
+ rollout_path,
367
+ created_at,
368
+ updated_at,
369
+ tokens_used,
370
+ archived
371
+ FROM threads
372
+ ORDER BY updated_at DESC
373
+ LIMIT 200
374
+ `).all();
375
+ db.close();
376
+ db = null;
377
+ return rows
378
+ .filter((row) => {
379
+ if (row.archived || !row.id || !row.cwd) return false;
380
+ const seedText = String(row.first_user_message || row.title || '').trim();
381
+ const safeSource = String(row.source || '').trim().toLowerCase();
382
+ if (!seedText) return safeSource === 'cli';
383
+ if (safeSource === 'cli') return true;
384
+ return !looksLikeInternalCodexPrompt(seedText);
385
+ })
386
+ .map((row) => {
387
+ const updatedMs = Number(row.updated_at || row.created_at || 0) * 1000;
388
+ const firstPrompt = stripCodexInjectedHints(row.first_user_message || row.title || '');
389
+ const customTitle = stripCodexInjectedHints(row.title || '');
390
+ if (row.rollout_path) {
391
+ _codexRolloutCache.set(String(row.id), { path: String(row.rollout_path), ts: Date.now() });
392
+ }
393
+ return {
394
+ sessionId: String(row.id),
395
+ projectPath: String(row.cwd),
396
+ fileMtime: updatedMs || 0,
397
+ modified: new Date(updatedMs || Date.now()).toISOString(),
398
+ messageCount: row.tokens_used ? '?' : 1,
399
+ customTitle,
400
+ firstPrompt,
401
+ lastUser: firstPrompt,
402
+ _enriched: false,
403
+ engine: 'codex',
404
+ };
405
+ })
406
+ .map((session) => enrichCodexSession(session));
407
+ } catch {
408
+ if (db) { try { db.close(); } catch { /* ignore */ } }
409
+ return [];
410
+ }
411
+ }
412
+
413
+ function findCodexSessionFile(sessionId) {
414
+ if (!sessionId || !fs.existsSync(CODEX_DB)) return null;
415
+ const cached = _codexRolloutCache.get(sessionId);
416
+ if (cached && Date.now() - cached.ts < 30000) return cached.path;
417
+ let db = null;
418
+ try {
419
+ const { DatabaseSync } = require('node:sqlite');
420
+ db = new DatabaseSync(CODEX_DB, { readonly: true });
421
+ const row = db.prepare('SELECT rollout_path FROM threads WHERE id = ?').get(sessionId);
422
+ db.close();
423
+ db = null;
424
+ const rolloutPath = row && row.rollout_path ? String(row.rollout_path) : null;
425
+ _codexRolloutCache.set(sessionId, { path: rolloutPath, ts: Date.now() });
426
+ return rolloutPath;
427
+ } catch {
428
+ if (db) { try { db.close(); } catch { /* ignore */ } }
429
+ return null;
430
+ }
431
+ }
432
+
433
+ function extractCodexMessageText(payload) {
434
+ if (!payload) return '';
435
+ if (typeof payload === 'string') return payload;
436
+ if (Array.isArray(payload)) {
437
+ return payload.map(item => extractCodexMessageText(item)).filter(Boolean).join('\n').trim();
438
+ }
439
+ if (typeof payload !== 'object') return '';
440
+ if (typeof payload.text === 'string') return payload.text;
441
+ if (typeof payload.message === 'string') return payload.message;
442
+ if (payload.type === 'input_text' || payload.type === 'output_text') return String(payload.text || '');
443
+ if (payload.type === 'message' && Array.isArray(payload.content)) return extractCodexMessageText(payload.content);
444
+ if (Array.isArray(payload.content)) return extractCodexMessageText(payload.content);
445
+ if (payload.payload) return extractCodexMessageText(payload.payload);
446
+ return '';
447
+ }
448
+
449
+ function parseCodexSessionPreview(sessionFile) {
450
+ try {
451
+ if (!sessionFile || !fs.existsSync(sessionFile)) return null;
452
+ const lines = fs.readFileSync(sessionFile, 'utf8').split('\n').filter(Boolean);
453
+ let firstUser = '';
454
+ let lastUser = '';
455
+ let lastAssistant = '';
456
+ let fallbackAssistant = '';
457
+ for (const line of lines) {
458
+ let entry;
459
+ try {
460
+ entry = JSON.parse(line);
461
+ } catch {
462
+ continue;
463
+ }
464
+ if (entry.type === 'response_item' && entry.payload && entry.payload.type === 'message') {
465
+ const role = String(entry.payload.role || '').toLowerCase();
466
+ const text = stripCodexInjectedHints(extractCodexMessageText(entry.payload.content || entry.payload));
467
+ if (!text) continue;
468
+ if (role === 'user') {
469
+ if (!firstUser) firstUser = text;
470
+ lastUser = text;
471
+ } else if (role === 'assistant') {
472
+ lastAssistant = text;
473
+ }
474
+ } else if (entry.type === 'event_msg' && entry.payload && entry.payload.type === 'agent_message') {
475
+ const text = stripCodexInjectedHints(extractCodexMessageText(entry.payload.message));
476
+ if (text) fallbackAssistant = text;
477
+ }
478
+ }
479
+ if (!lastAssistant) lastAssistant = fallbackAssistant;
480
+ return (firstUser || lastUser || lastAssistant)
481
+ ? { firstUser, lastUser, lastAssistant }
482
+ : null;
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+
488
+ function enrichCodexSession(session) {
489
+ if (!session || session._enriched) return session;
490
+ try {
491
+ const sessionFile = findCodexSessionFile(session.sessionId);
492
+ const preview = parseCodexSessionPreview(sessionFile);
493
+ if (preview) {
494
+ if (preview.firstUser) session.firstPrompt = preview.firstUser.slice(0, 120);
495
+ if (preview.lastUser) session.lastUser = preview.lastUser.slice(0, 120);
496
+ }
497
+ session._enriched = true;
498
+ return session;
499
+ } catch {
500
+ session._enriched = true;
501
+ return session;
502
+ }
503
+ }
279
504
 
505
+ function scanAllSessions() {
506
+ if (_sessionCache && (Date.now() - _sessionCacheTime < SESSION_CACHE_TTL)) return _sessionCache;
507
+ try {
508
+ const all = [...scanClaudeSessions(), ...scanCodexSessions()];
509
+ all.sort((a, b) => {
510
+ const aTime = a.fileMtime || new Date(a.modified).getTime();
511
+ const bTime = b.fileMtime || new Date(b.modified).getTime();
512
+ return bTime - aTime;
513
+ });
280
514
  _sessionCache = all;
281
515
  _sessionCacheTime = Date.now();
282
516
  return all;
@@ -285,11 +519,15 @@ function createSessionStore(deps) {
285
519
  }
286
520
  }
287
521
 
288
- function listRecentSessions(limit, cwd) {
522
+ function listRecentSessions(limit, cwd, engine) {
289
523
  let all = scanAllSessions();
290
524
  if (cwd) {
291
525
  all = all.filter(s => s.projectPath === cwd);
292
526
  }
527
+ if (engine) {
528
+ const safeEngine = String(engine).trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
529
+ all = all.filter(s => (s.engine || 'claude') === safeEngine);
530
+ }
293
531
  return all.slice(0, limit || 10);
294
532
  }
295
533
 
@@ -308,22 +546,34 @@ function createSessionStore(deps) {
308
546
  return null;
309
547
  }
310
548
 
549
+ function getSessionDisplayTimeMs(session) {
550
+ const realMtime = getSessionFileMtime(session.sessionId, session.projectPath);
551
+ if (Number.isFinite(realMtime) && realMtime > 0) return realMtime;
552
+ if (Number.isFinite(session.fileMtime) && session.fileMtime > 0) return session.fileMtime;
553
+ const modifiedMs = session.modified ? new Date(session.modified).getTime() : NaN;
554
+ if (Number.isFinite(modifiedMs) && modifiedMs > 0) return modifiedMs;
555
+ return Date.now();
556
+ }
557
+
558
+ function getSessionRelativeTimeLabel(session) {
559
+ return formatRelativeTime(new Date(getSessionDisplayTimeMs(session)).toISOString());
560
+ }
561
+
311
562
  function sessionLabel(s) {
312
563
  const name = s.customTitle;
313
564
  const proj = s.projectPath ? path.basename(s.projectPath) : '';
314
- const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
315
- const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
316
- const ago = formatRelativeTime(new Date(timeMs).toISOString());
565
+ const ago = getSessionRelativeTimeLabel(s);
317
566
  const shortId = s.sessionId.slice(0, 4);
567
+ const engineTag = (s.engine || 'claude') === 'codex' ? '[codex] ' : '';
318
568
 
319
- if (name) return `${ago} [${name}] ${proj} #${shortId}`;
569
+ if (name) return `${engineTag}${ago} [${name}] ${proj} #${shortId}`;
320
570
 
321
571
  let title = (s.summary || '').slice(0, 20);
322
572
  if (!title && s.firstPrompt) {
323
573
  title = s.firstPrompt.slice(0, 20);
324
574
  if (s.firstPrompt.length > 20) title += '..';
325
575
  }
326
- return `${ago} ${proj ? proj + ': ' : ''}${title || ''} #${shortId}`;
576
+ return `${engineTag}${ago} ${proj ? proj + ': ' : ''}${title || ''} #${shortId}`;
327
577
  }
328
578
 
329
579
  function sessionDisplayTitle(s, maxLen, sessionTags) {
@@ -367,11 +617,10 @@ function createSessionStore(deps) {
367
617
  sessionTags = sessionTags || loadSessionTags();
368
618
  const title = sessionDisplayTitle(s, 50, sessionTags);
369
619
  const proj = s.projectPath ? path.basename(s.projectPath) : '~';
370
- const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
371
- const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
372
- const ago = formatRelativeTime(new Date(timeMs).toISOString());
620
+ const ago = getSessionRelativeTimeLabel(s);
373
621
  const shortId = s.sessionId.slice(0, 8);
374
622
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 3);
623
+ const engineLabel = (s.engine || 'claude') === 'codex' ? 'codex' : 'claude';
375
624
 
376
625
  // [M2] 转义 markdown 特殊字符,防止用户历史消息破坏渲染
377
626
  const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
@@ -379,7 +628,7 @@ function createSessionStore(deps) {
379
628
  const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
380
629
  let line = `${index}. ${title}${title.length >= 50 ? '..' : ''}`; // [M4] title 已有 sessionId 兜底,不会为空
381
630
  if (tags.length) line += ` ${tags.map(t => `#${t}`).join(' ')}`;
382
- line += `\n 📁${proj} · ${ago}`;
631
+ line += `\n 📁${proj} · ${ago} · ${engineLabel}`;
383
632
  if (snippetRaw && snippetRaw.length > 2) {
384
633
  const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
385
634
  line += `\n 💬 ${snippet}${snippetRaw.length > 60 ? '…' : ''}`;
@@ -395,15 +644,14 @@ function createSessionStore(deps) {
395
644
  if (i > 0) elements.push({ tag: 'hr' });
396
645
  const title = sessionDisplayTitle(s, 60, sessionTags);
397
646
  const proj = s.projectPath ? path.basename(s.projectPath) : '~';
398
- const realMtime = getSessionFileMtime(s.sessionId, s.projectPath);
399
- const timeMs = realMtime || s.fileMtime || new Date(s.modified).getTime();
400
- const ago = formatRelativeTime(new Date(timeMs).toISOString());
647
+ const ago = getSessionRelativeTimeLabel(s);
401
648
  const shortId = s.sessionId.slice(0, 6);
402
649
  const tags = (sessionTags[s.sessionId] && sessionTags[s.sessionId].tags || []).slice(0, 4);
650
+ const engineLabel = (s.engine || 'claude') === 'codex' ? 'codex' : 'claude';
403
651
  // [M2] 转义 markdown 特殊字符;[M4] title 已有 sessionId 兜底
404
652
  const escapeMd = (t) => t.replace(/[_*`\\]/g, '\\$&');
405
653
  const snippetRaw = s.lastUser || (s.firstPrompt || '').replace(/<[^>]+>/g, '').replace(/\[System hints[\s\S]*/i, '').trim().slice(0, 80);
406
- let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago}`;
654
+ let desc = `**${i + 1}. ${title}**\n📁${proj} · ${ago} · ${engineLabel}`;
407
655
  if (tags.length) desc += `\n${tags.map(t => `\`${t}\``).join(' ')}`;
408
656
  if (snippetRaw && snippetRaw.length > 2) {
409
657
  const snippet = escapeMd(snippetRaw.replace(/\n/g, ' ').slice(0, 60));
@@ -451,22 +699,59 @@ function createSessionStore(deps) {
451
699
  function getSessionForEngine(chatId, engine) {
452
700
  const raw = getSession(chatId);
453
701
  if (!raw) return null;
454
- const safeEngine = String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
702
+ const safeEngine = normalizeEngineName(engine);
455
703
  if (!raw.engines) return { cwd: raw.cwd, engine: safeEngine, id: raw.id || null, started: !!raw.started };
456
704
  const slot = raw.engines[safeEngine] || {};
457
- return { cwd: raw.cwd, engine: safeEngine, id: slot.id || null, started: !!slot.started };
705
+ return {
706
+ cwd: raw.cwd,
707
+ engine: safeEngine,
708
+ ...slot,
709
+ id: slot.id || null,
710
+ started: !!slot.started,
711
+ };
458
712
  }
459
713
 
460
- function createSession(chatId, cwd, name, engine = 'claude') {
714
+ function upgradeSessionRecord(raw = {}, fallbackEngine = 'claude') {
715
+ const safeEngine = normalizeEngineName(fallbackEngine);
716
+ if (raw.engines && typeof raw.engines === 'object') {
717
+ return {
718
+ cwd: sanitizeCwd(raw.cwd),
719
+ engines: { ...raw.engines },
720
+ ...(raw.last_active ? { last_active: raw.last_active } : {}),
721
+ };
722
+ }
723
+ const slot = {
724
+ id: raw.id || null,
725
+ started: !!raw.started,
726
+ };
727
+ if (safeEngine === 'codex') {
728
+ const permissionMeta = normalizeCodexPermissionMeta(raw);
729
+ if (permissionMeta) Object.assign(slot, permissionMeta);
730
+ }
731
+ return {
732
+ cwd: sanitizeCwd(raw.cwd),
733
+ engines: { [safeEngine]: slot },
734
+ ...(raw.last_active ? { last_active: raw.last_active } : {}),
735
+ };
736
+ }
737
+
738
+ function createSession(chatId, cwd, name, engine = 'claude', meta = {}) {
461
739
  const state = loadState();
462
- const safeEngine = String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
740
+ const safeEngine = normalizeEngineName(engine);
463
741
  const safeCwd = sanitizeCwd(cwd);
464
742
  const sessionId = crypto.randomUUID();
465
- const existing = state.sessions[chatId] || {};
743
+ const existing = upgradeSessionRecord(state.sessions[chatId] || {}, safeEngine);
466
744
  const existingEngines = existing.engines || {};
745
+ const nextSlot = { id: sessionId, started: false };
746
+ if (safeEngine === 'codex') {
747
+ nextSlot.runtimeSessionObserved = false;
748
+ const permissionMeta = normalizeCodexPermissionMeta(meta);
749
+ if (permissionMeta) Object.assign(nextSlot, permissionMeta);
750
+ }
467
751
  state.sessions[chatId] = {
468
752
  cwd: safeCwd,
469
- engines: { ...existingEngines, [safeEngine]: { id: sessionId, started: false } },
753
+ engines: { ...existingEngines, [safeEngine]: nextSlot },
754
+ last_active: Date.now(),
470
755
  };
471
756
  saveState(state);
472
757
  invalidateSessionCache();
@@ -475,6 +760,67 @@ function createSessionStore(deps) {
475
760
  return getSessionForEngine(chatId, safeEngine);
476
761
  }
477
762
 
763
+ function restoreSessionFromReply(chatId, mapped = {}) {
764
+ if (!chatId || !mapped) return null;
765
+ const safeEngine = normalizeEngineName(mapped.engine);
766
+ const state = loadState();
767
+ if (!state.sessions) state.sessions = {};
768
+ const logicalChatId = String(mapped.logicalChatId || '').trim();
769
+ const targetChatId = logicalChatId || String(chatId);
770
+ const base = upgradeSessionRecord(state.sessions[targetChatId] || {}, safeEngine);
771
+ const logicalBase = logicalChatId
772
+ ? upgradeSessionRecord(state.sessions[logicalChatId] || {}, safeEngine)
773
+ : null;
774
+ const logicalSlot = logicalBase && logicalBase.engines
775
+ ? (logicalBase.engines[safeEngine] || null)
776
+ : null;
777
+ const effectiveMapped = (logicalSlot && logicalSlot.id)
778
+ ? {
779
+ ...mapped,
780
+ ...logicalSlot,
781
+ id: String(logicalSlot.id),
782
+ cwd: logicalBase.cwd || mapped.cwd,
783
+ engine: safeEngine,
784
+ logicalChatId,
785
+ }
786
+ : mapped;
787
+ const resolvedId = String(effectiveMapped.id || '').trim();
788
+ const resolvedCwd = sanitizeCwd(effectiveMapped.cwd || base.cwd);
789
+ if (!resolvedId && !resolvedCwd) return null;
790
+ const restoredSlot = {
791
+ ...(base.engines[safeEngine] || {}),
792
+ ...(resolvedId ? { id: resolvedId } : {}),
793
+ started: true,
794
+ };
795
+ if (safeEngine === 'codex') {
796
+ restoredSlot.runtimeSessionObserved = !!resolvedId;
797
+ const permissionMeta = normalizeCodexPermissionMeta(effectiveMapped) || normalizeCodexPermissionMeta(restoredSlot);
798
+ if (permissionMeta) Object.assign(restoredSlot, permissionMeta);
799
+ }
800
+ const restoredRecord = {
801
+ cwd: resolvedCwd,
802
+ engines: {
803
+ ...base.engines,
804
+ [safeEngine]: restoredSlot,
805
+ },
806
+ last_active: Date.now(),
807
+ };
808
+ state.sessions[targetChatId] = restoredRecord;
809
+ if (String(chatId) !== targetChatId) {
810
+ const aliasBase = upgradeSessionRecord(state.sessions[chatId] || {}, safeEngine);
811
+ state.sessions[chatId] = {
812
+ cwd: restoredRecord.cwd,
813
+ engines: {
814
+ ...aliasBase.engines,
815
+ [safeEngine]: { ...(aliasBase.engines[safeEngine] || {}), ...restoredSlot },
816
+ },
817
+ last_active: restoredRecord.last_active,
818
+ };
819
+ }
820
+ saveState(state);
821
+ return getSessionForEngine(targetChatId, safeEngine);
822
+ }
823
+
478
824
  function getSessionName(sessionId) {
479
825
  try {
480
826
  if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return '';
@@ -517,39 +863,47 @@ function createSessionStore(deps) {
517
863
  function getSessionRecentContext(sessionId) {
518
864
  try {
519
865
  const sessionFile = findSessionFile(sessionId);
520
- if (!sessionFile) return null;
521
- const stat = fs.statSync(sessionFile);
522
- const tailSize = Math.min(262144, stat.size); // 256KB for better coverage of tool-heavy sessions
523
- const buf = Buffer.alloc(tailSize);
524
- const fd = fs.openSync(sessionFile, 'r');
525
- try {
526
- fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
527
- } finally {
528
- fs.closeSync(fd);
529
- }
530
- const lines = buf.toString('utf8').split('\n').reverse();
531
- // [M3] 复用共享函数,统一截取逻辑
532
- const lastUser = extractLastUserFromLines(lines);
533
- let lastAssistant = '';
534
- for (const line of lines) {
535
- if (!line.trim()) continue;
866
+ if (sessionFile) {
867
+ const stat = fs.statSync(sessionFile);
868
+ const tailSize = Math.min(262144, stat.size); // 256KB for better coverage of tool-heavy sessions
869
+ const buf = Buffer.alloc(tailSize);
870
+ const fd = fs.openSync(sessionFile, 'r');
536
871
  try {
537
- const d = JSON.parse(line);
538
- if (!lastAssistant && d.type === 'assistant' && d.message) {
539
- const content = d.message.content;
540
- if (Array.isArray(content)) {
541
- for (const c of content) {
542
- if (c.type === 'text' && c.text && c.text.trim().length > 2) {
543
- lastAssistant = c.text.trim().slice(0, 80);
544
- break;
872
+ fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
873
+ } finally {
874
+ fs.closeSync(fd);
875
+ }
876
+ const lines = buf.toString('utf8').split('\n').reverse();
877
+ // [M3] 复用共享函数,统一截取逻辑
878
+ const lastUser = extractLastUserFromLines(lines);
879
+ let lastAssistant = '';
880
+ for (const line of lines) {
881
+ if (!line.trim()) continue;
882
+ try {
883
+ const d = JSON.parse(line);
884
+ if (!lastAssistant && d.type === 'assistant' && d.message) {
885
+ const content = d.message.content;
886
+ if (Array.isArray(content)) {
887
+ for (const c of content) {
888
+ if (c.type === 'text' && c.text && c.text.trim().length > 2) {
889
+ lastAssistant = c.text.trim().slice(0, 80);
890
+ break;
891
+ }
545
892
  }
546
893
  }
547
894
  }
548
- }
549
- if (lastAssistant) break;
550
- } catch { /* skip bad line */ }
895
+ if (lastAssistant) break;
896
+ } catch { /* skip bad line */ }
897
+ }
898
+ return (lastUser || lastAssistant) ? { lastUser, lastAssistant } : null;
551
899
  }
552
- return (lastUser || lastAssistant) ? { lastUser, lastAssistant } : null;
900
+ const codexFile = findCodexSessionFile(sessionId);
901
+ const preview = parseCodexSessionPreview(codexFile);
902
+ if (!preview) return null;
903
+ return {
904
+ lastUser: (preview.lastUser || '').slice(0, 80),
905
+ lastAssistant: (preview.lastAssistant || '').slice(0, 80),
906
+ };
553
907
  } catch { return null; }
554
908
  }
555
909
 
@@ -558,11 +912,19 @@ function createSessionStore(deps) {
558
912
  const s = state.sessions[chatId];
559
913
  if (!s) return;
560
914
  if (s.engines) {
561
- const safeEngine = String(engine || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
915
+ const safeEngine = normalizeEngineName(engine);
562
916
  if (!s.engines[safeEngine]) s.engines[safeEngine] = {};
563
- s.engines[safeEngine].started = true;
917
+ const slot = s.engines[safeEngine];
918
+ if (safeEngine === 'codex' && slot.runtimeSessionObserved === false) {
919
+ s.last_active = Date.now();
920
+ saveState(state);
921
+ return;
922
+ }
923
+ slot.started = true;
924
+ s.last_active = Date.now();
564
925
  } else {
565
926
  s.started = true; // old flat format
927
+ s.last_active = Date.now();
566
928
  }
567
929
  saveState(state);
568
930
  }
@@ -581,6 +943,27 @@ function createSessionStore(deps) {
581
943
  return !!valid;
582
944
  }
583
945
 
946
+ function _readClaudeSessionMetadata(sessionFile) {
947
+ const content = fs.readFileSync(sessionFile, 'utf8');
948
+ const lines = content.split('\n').filter(l => l.trim());
949
+ const metadata = {
950
+ lines,
951
+ cwd: '',
952
+ model: '',
953
+ };
954
+ for (const line of lines.slice(0, 20)) {
955
+ try {
956
+ const entry = JSON.parse(line);
957
+ const fileCwd = entry.cwd || (entry.message && entry.message.cwd);
958
+ if (!metadata.cwd && fileCwd) metadata.cwd = fileCwd;
959
+ const sessionModel = entry && entry.message && entry.message.model;
960
+ if (!metadata.model && sessionModel && sessionModel !== '<synthetic>') metadata.model = sessionModel;
961
+ if (metadata.cwd && metadata.model) break;
962
+ } catch { /* skip non-JSON lines */ }
963
+ }
964
+ return metadata;
965
+ }
966
+
584
967
  // Claude backend: JSONL files under ~/.claude/projects/<hash>/
585
968
  // Best approach: read cwd directly from session file content (not from dir name)
586
969
  function _isClaudeSessionValid(sessionId, normCwd) {
@@ -588,10 +971,12 @@ function createSessionStore(deps) {
588
971
  const sessionFile = findSessionFile(sessionId);
589
972
  if (!sessionFile) return false;
590
973
 
591
- // Try to read cwd from session JSONL file content (most reliable)
592
- const content = fs.readFileSync(sessionFile, 'utf8');
593
- const lines = content.split('\n').filter(l => l.trim());
594
- for (const line of lines.slice(0, 20)) { // Check first 20 lines
974
+ // Try to read cwd/model from session JSONL file content (most reliable)
975
+ const metadata = _readClaudeSessionMetadata(sessionFile);
976
+ if (metadata.model && !metadata.model.startsWith('claude-')) return false;
977
+ if (metadata.cwd && path.resolve(metadata.cwd) === normCwd) return true;
978
+ if (metadata.cwd) return false;
979
+ for (const line of metadata.lines.slice(0, 20)) { // preserve tolerant parsing for malformed heads
595
980
  try {
596
981
  const entry = JSON.parse(line);
597
982
  const fileCwd = entry.cwd || (entry.message && entry.message.cwd);
@@ -624,8 +1009,6 @@ function createSessionStore(deps) {
624
1009
  }
625
1010
  }
626
1011
 
627
- // Codex backend: SQLite index at ~/.codex/state_5.sqlite
628
- const CODEX_DB = path.join(HOME, '.codex', 'state_5.sqlite');
629
1012
  function _isCodexSessionValid(sessionId, normCwd) {
630
1013
  let db = null;
631
1014
  try {
@@ -645,6 +1028,42 @@ function createSessionStore(deps) {
645
1028
  }
646
1029
  }
647
1030
 
1031
+ function getCodexSessionSandboxProfile(sessionId) {
1032
+ let db = null;
1033
+ try {
1034
+ if (!sessionId) return null;
1035
+ const { DatabaseSync } = require('node:sqlite');
1036
+ db = new DatabaseSync(CODEX_DB, { readonly: true });
1037
+ const row = db.prepare('SELECT sandbox_policy, approval_mode FROM threads WHERE id = ?').get(sessionId);
1038
+ db.close();
1039
+ db = null;
1040
+ if (!row || !row.sandbox_policy) return null;
1041
+ const policy = JSON.parse(String(row.sandbox_policy));
1042
+ const sandboxMode = normalizeCodexSandboxMode(
1043
+ policy && (policy.type || policy.mode || policy.sandbox_mode || policy.sandboxMode),
1044
+ null
1045
+ );
1046
+ const approvalPolicy = normalizeCodexApprovalPolicy(
1047
+ (policy && (policy.approval_policy || policy.approvalPolicy || policy.ask_for_approval)) || row.approval_mode,
1048
+ null
1049
+ );
1050
+ if (!sandboxMode && !approvalPolicy) return null;
1051
+ return {
1052
+ sandboxMode: sandboxMode || 'danger-full-access',
1053
+ approvalPolicy,
1054
+ permissionMode: sandboxMode || 'danger-full-access',
1055
+ };
1056
+ } catch {
1057
+ if (db) { try { db.close(); } catch { /* ignore */ } }
1058
+ return null;
1059
+ }
1060
+ }
1061
+
1062
+ function getCodexSessionPermissionMode(sessionId) {
1063
+ const profile = getCodexSessionSandboxProfile(sessionId);
1064
+ return profile ? profile.permissionMode : null;
1065
+ }
1066
+
648
1067
  function isEngineSessionValid(engine, sessionId, cwd) {
649
1068
  if (!sessionId || !cwd || sessionId === '__continue__') return true;
650
1069
  const normCwd = path.resolve(cwd);
@@ -659,6 +1078,7 @@ function createSessionStore(deps) {
659
1078
 
660
1079
  return {
661
1080
  findSessionFile,
1081
+ findCodexSessionFile,
662
1082
  clearSessionFileCache,
663
1083
  truncateSessionToCheckpoint,
664
1084
  watchSessionFiles,
@@ -673,11 +1093,22 @@ function createSessionStore(deps) {
673
1093
  getSession,
674
1094
  getSessionForEngine,
675
1095
  createSession,
1096
+ restoreSessionFromReply,
676
1097
  getSessionName,
677
1098
  writeSessionName,
678
1099
  markSessionStarted,
679
1100
  getSessionRecentContext,
680
1101
  isEngineSessionValid,
1102
+ getCodexSessionSandboxProfile,
1103
+ getCodexSessionPermissionMode,
1104
+ _private: {
1105
+ _readClaudeSessionMetadata,
1106
+ _isClaudeSessionValid,
1107
+ upgradeSessionRecord,
1108
+ stripCodexInjectedHints,
1109
+ looksLikeInternalCodexPrompt,
1110
+ parseCodexSessionPreview,
1111
+ },
681
1112
  };
682
1113
  }
683
1114