@moxxy/core 0.5.3 → 0.6.0

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.
@@ -16,24 +16,61 @@
16
16
  * best-effort (lose at most the last in-flight event on a crash).
17
17
  */
18
18
 
19
- import { promises as fs } from 'node:fs';
20
- import * as os from 'node:os';
19
+ import { constants as fsConstants, promises as fs } from 'node:fs';
21
20
  import * as path from 'node:path';
22
21
  import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy/sdk';
23
- import { writeFileAtomic } from '@moxxy/sdk/server';
22
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
24
23
  import type { EventLog } from '../events/log.js';
25
24
  import { createLogger, type Logger } from '../logger.js';
26
25
 
26
+ /**
27
+ * The channel that originated a session. Persisted into the sidecar by the
28
+ * runner so every surface (desktop/TUI/mobile) derives the session list from a
29
+ * single source instead of each keeping its own copy. `desktop`/`mobile`
30
+ * sessions are kept in the derived workspace list even before they have a first
31
+ * prompt (a brand-new chat the user just opened); empty `cli`/`tui` sidecars are
32
+ * dropped as noise.
33
+ */
34
+ export type SessionSource = 'cli' | 'tui' | 'desktop' | 'mobile';
35
+
36
+ /** Schema version of the per-session metadata file (`<id>.json`). Bump when the
37
+ * shape changes incompatibly; readers tolerate a missing/older version. */
38
+ export const SESSION_META_VERSION = 1;
39
+
40
+ /**
41
+ * The single per-session metadata file: `~/.moxxy/sessions/<id>.json`.
42
+ *
43
+ * ONE file per session, the unit every surface (TUI/desktop/mobile) lists,
44
+ * searches and caches. The conversation itself lives in the append-only
45
+ * `<id>.jsonl`. Fields split by owner:
46
+ * - the RUNNER owns the content fields (`firstPrompt`, `eventCount`,
47
+ * `lastActivity`, `provider`, `model`, `startedAt`, `source`) and rewrites
48
+ * them on a debounce;
49
+ * - the UI owns `title` (a rename) and `groupId` (which desk it belongs to).
50
+ * Because both write the same file, the runner ADOPTS the UI fields on attach
51
+ * and re-merges them just before each write, so a live session never clobbers a
52
+ * rename or a move.
53
+ */
27
54
  export interface SessionMeta {
55
+ /** Schema version; absent on older files (treated as v0). */
56
+ readonly version?: number;
28
57
  readonly id: string;
29
58
  readonly cwd: string;
30
59
  readonly startedAt: string;
31
60
  readonly lastActivity: string;
32
61
  readonly eventCount: number;
33
- /** First 80 chars of the first user_prompt. Used as the picker label. */
62
+ /** First 80 chars of the first user_prompt. The list/search label. */
34
63
  readonly firstPrompt: string | null;
35
64
  readonly provider: string | null;
36
65
  readonly model: string | null;
66
+ /** Originating channel, when known. Written by the runner via `opts.source`. */
67
+ readonly source?: SessionSource;
68
+ /** Explicit workspace/desk membership (UI-owned). `null`/absent → grouped by
69
+ * cwd containment (CLI/TUI sessions that don't know about desks). */
70
+ readonly groupId?: string | null;
71
+ /** User-set display name, the rename (UI-owned). `null`/absent → the name is
72
+ * derived from `firstPrompt`. */
73
+ readonly title?: string | null;
37
74
  }
38
75
 
39
76
  export interface SessionPersistenceOpts {
@@ -45,6 +82,9 @@ export interface SessionPersistenceOpts {
45
82
  readonly providerName?: string;
46
83
  /** Currently-active model id — captured into the index for the picker. */
47
84
  readonly modelId?: string;
85
+ /** Originating channel — persisted into the sidecar so the workspace list can
86
+ * be derived from a single source (see {@link SessionSource}). */
87
+ readonly source?: SessionSource;
48
88
  /**
49
89
  * Structured logger for persistence-degradation warnings. Defaults to a
50
90
  * stderr JSON logger — event-log write failures are the session's source
@@ -54,7 +94,7 @@ export interface SessionPersistenceOpts {
54
94
  }
55
95
 
56
96
  export function defaultSessionsDir(): string {
57
- return path.join(os.homedir(), '.moxxy', 'sessions');
97
+ return moxxyPath('sessions');
58
98
  }
59
99
 
60
100
  /**
@@ -152,6 +192,7 @@ export class SessionPersistence {
152
192
  this.logPath = path.join(this.dir, `${this.id}.jsonl`);
153
193
  const now = new Date().toISOString();
154
194
  this.meta = {
195
+ version: SESSION_META_VERSION,
155
196
  id: this.id,
156
197
  cwd: opts.cwd,
157
198
  startedAt: now,
@@ -160,6 +201,7 @@ export class SessionPersistence {
160
201
  firstPrompt: null,
161
202
  provider: opts.providerName ?? null,
162
203
  model: opts.modelId ?? null,
204
+ ...(opts.source ? { source: opts.source } : {}),
163
205
  };
164
206
  }
165
207
 
@@ -245,6 +287,16 @@ export class SessionPersistence {
245
287
  }
246
288
 
247
289
  private enqueueAppend(event: MoxxyEvent): void {
290
+ const ownedEvent = eventForSession(event, this.id);
291
+ if (!ownedEvent) {
292
+ this.logger.warn('session persistence ignored foreign-session event', {
293
+ path: this.logPath,
294
+ sessionId: this.id,
295
+ eventId: event.id,
296
+ eventSessionId: event.sessionId,
297
+ });
298
+ return;
299
+ }
248
300
  // Update in-memory meta synchronously so multiple events in the
249
301
  // same tick share one debounced index write.
250
302
  this.meta = {
@@ -253,10 +305,11 @@ export class SessionPersistence {
253
305
  lastActivity: new Date().toISOString(),
254
306
  firstPrompt:
255
307
  this.meta.firstPrompt ??
256
- (event.type === 'user_prompt' ? firstPromptLabel(event.text) : null),
308
+ (ownedEvent.type === 'user_prompt' ? firstPromptLabel(ownedEvent.text) : null),
309
+ ...providerHeaderFromEvent(ownedEvent),
257
310
  };
258
311
  this.scheduleIndexWrite();
259
- const line = JSON.stringify(event) + '\n';
312
+ const line = JSON.stringify(ownedEvent) + '\n';
260
313
  // Never propagate a write error into the listener chain — but never
261
314
  // swallow it silently either: the JSONL is the session's source of
262
315
  // truth, so a failing disk must at least be loud.
@@ -361,28 +414,50 @@ export class SessionPersistence {
361
414
  if (!this.closed) {
362
415
  await this.ensureReady();
363
416
  }
364
- // Write ONLY this session's sidecar (`<id>.meta.json`), never a
365
- // read-modify-write of a shared index.json — that loses rows when two
366
- // moxxy processes update "their" row concurrently (each reads the index,
367
- // re-adds only itself, and the last writer drops the other's row).
368
- // `writeJsonAtomic` already `mkdir -p`s the sidecar's dir, so this owns no
369
- // ensureLogFile the `.jsonl` is the append path's responsibility.
370
- await writeJsonAtomic(metaPath(this.dir, this.meta.id), this.meta);
417
+ // Write ONLY this session's file (`<id>.json`), never a read-modify-write
418
+ // of a shared index — that loses rows when two moxxy processes update
419
+ // "their" row concurrently. `writeJsonAtomic` already `mkdir -p`s the dir,
420
+ // so this owns no ensureLogFile — the `.jsonl` is the append path's job.
421
+ //
422
+ // The UI fields (`title`, `groupId`) are owned by the rename/move path,
423
+ // not the runner. Re-read them just before writing so an external rename
424
+ // or move that landed mid-session is preserved rather than clobbered by
425
+ // our (possibly stale) in-memory copy. The runner owns every other field.
426
+ const onDisk = await readMetaSidecar(this.dir, this.meta.id);
427
+ const merged: SessionMeta = {
428
+ ...this.meta,
429
+ title: onDisk?.title ?? this.meta.title ?? null,
430
+ groupId: onDisk?.groupId ?? this.meta.groupId ?? null,
431
+ };
432
+ await writeJsonAtomic(metaPath(this.dir, this.meta.id), merged);
371
433
  } catch {
372
434
  // Index write failures shouldn't bring down a session; the
373
435
  // user can always re-resume by id from the filename.
374
436
  }
375
437
  }
376
438
 
377
- /** One-time, memoized: `mkdir -p` the dir + create the empty `.jsonl`. On
378
- * failure the latch is cleared so a later flush retries (matching the old
379
- * per-write ensureDir/ensureLogFile recoverability). */
439
+ /** One-time, memoized: `mkdir -p` the dir + create the empty `.jsonl`, then
440
+ * ADOPT a pre-existing file's stable identity: `startedAt` (so the derived
441
+ * "created" time doesn't jump on every resume), `source` (when the caller
442
+ * didn't pass one), and the UI-owned `title`/`groupId` (so the first runner
443
+ * write doesn't drop a rename/move made while no runner was attached). On
444
+ * failure the latch is cleared so a later flush retries. */
380
445
  private ensureReady(): Promise<void> {
381
446
  if (!this.ready) {
382
447
  this.ready = (async () => {
383
448
  await fs.mkdir(this.dir, { recursive: true });
384
449
  const handle = await fs.open(this.logPath, 'a');
385
450
  await handle.close();
451
+ const existing = await readMetaSidecar(this.dir, this.id);
452
+ if (existing) {
453
+ this.meta = {
454
+ ...this.meta,
455
+ startedAt: existing.startedAt,
456
+ source: this.meta.source ?? existing.source,
457
+ title: existing.title ?? null,
458
+ groupId: existing.groupId ?? null,
459
+ };
460
+ }
386
461
  })().catch((err) => {
387
462
  this.ready = null;
388
463
  throw err;
@@ -393,56 +468,85 @@ export class SessionPersistence {
393
468
  }
394
469
 
395
470
  /**
396
- * Read the session index by assembling per-session sidecars (`<id>.meta.json`)
397
- * plus a legacy `index.json` if one exists (sidecars win by id). Sessions whose
398
- * `.jsonl` is missing are dropped. Sorted most-recent-activity first.
471
+ * Read the session index by assembling per-session metadata files (`<id>.json`),
472
+ * then hydrating each one's first prompt from its `.jsonl`. Sessions whose
473
+ * `.jsonl` is missing are dropped. Sorted most-recent-activity first. This is the
474
+ * heavier, jsonl-hydrated read used by `moxxy resume`; the workspace list uses
475
+ * the cheap {@link listSessionMetas} instead.
399
476
  */
400
477
  export async function readIndex(dir = defaultSessionsDir()): Promise<SessionMeta[]> {
401
- const byId = new Map<string, SessionMeta>();
478
+ const { metas, logs } = await readSessionsDir(dir);
479
+ const present = metas.filter((meta) => logs.has(meta.id));
480
+ const hydrated = await mapWithConcurrency(present, READ_INDEX_CONCURRENCY, (meta) =>
481
+ hydrateMetaFirstPrompt(meta, dir),
482
+ );
483
+ return hydrated.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
484
+ }
402
485
 
403
- // Legacy single-file index (pre-sidecar layout). Sidecars override it below.
404
- try {
405
- const raw = await fs.readFile(path.join(dir, 'index.json'), 'utf8');
406
- const parsed = JSON.parse(raw) as unknown;
407
- if (Array.isArray(parsed)) {
408
- for (const m of parsed.filter(isSessionMeta)) byId.set(m.id, m);
409
- }
410
- } catch {
411
- // no legacy index — fine
412
- }
486
+ async function hydrateMetaFirstPrompt(meta: SessionMeta, dir: string): Promise<SessionMeta> {
487
+ const stats = await matchingSessionStatsFromLog(meta.id, path.join(dir, `${meta.id}.jsonl`));
488
+ if (!stats) return meta;
489
+ if (stats.parsedEvents === 0) return meta;
490
+ return {
491
+ ...meta,
492
+ eventCount: stats.eventCount,
493
+ firstPrompt: stats.firstPrompt,
494
+ provider: stats.provider ?? meta.provider,
495
+ model: stats.model ?? meta.model,
496
+ };
497
+ }
413
498
 
414
- let dirents: import('node:fs').Dirent[];
499
+ async function matchingSessionStatsFromLog(
500
+ sessionId: string,
501
+ logPath: string,
502
+ ): Promise<{
503
+ eventCount: number;
504
+ firstPrompt: string | null;
505
+ parsedEvents: number;
506
+ provider: string | null;
507
+ model: string | null;
508
+ } | null> {
509
+ let raw: string;
415
510
  try {
416
- dirents = await fs.readdir(dir, { withFileTypes: true });
511
+ raw = await fs.readFile(logPath, 'utf8');
417
512
  } catch {
418
- dirents = [];
513
+ return null;
419
514
  }
420
- // Cap fan-out: a user with thousands of sidecars in ~/.moxxy/sessions would
421
- // otherwise open thousands of file handles at once (EMFILE) on every resume/
422
- // list. Process in bounded batches instead.
423
- const sidecarFiles = dirents.filter((d) => d.isFile() && d.name.endsWith('.meta.json'));
424
- await mapWithConcurrency(sidecarFiles, READ_INDEX_CONCURRENCY, async (d) => {
515
+ let eventCount = 0;
516
+ let parsedEvents = 0;
517
+ let firstPrompt: string | null = null;
518
+ let provider: string | null = null;
519
+ let model: string | null = null;
520
+ for (const line of raw.split('\n')) {
521
+ if (!line.trim()) continue;
425
522
  try {
426
- const raw = await fs.readFile(path.join(dir, d.name), 'utf8');
427
- const parsed = JSON.parse(raw) as unknown;
428
- if (isSessionMeta(parsed)) byId.set(parsed.id, parsed);
523
+ const event = JSON.parse(line) as MoxxyEvent;
524
+ parsedEvents += 1;
525
+ if (!eventBelongsToSession(event, sessionId)) continue;
526
+ eventCount += 1;
527
+ if (firstPrompt === null && event.type === 'user_prompt') {
528
+ // Mirror the write-time label (SessionPersistence.enqueueAppend):
529
+ // code-point-aware slice + non-string coercion, so a hydrated index row
530
+ // matches the meta the live append wrote (no surrogate split, never null
531
+ // for a present prompt).
532
+ firstPrompt = firstPromptLabel(event.text);
533
+ }
534
+ const header = providerHeaderFromEvent(event);
535
+ if (header.provider !== undefined) provider = header.provider;
536
+ if (header.model !== undefined) model = header.model;
429
537
  } catch {
430
- // skip a malformed/half-written sidecar
538
+ // A corrupt line should not hide a later valid prompt.
431
539
  }
432
- });
540
+ }
541
+ return { eventCount, firstPrompt, parsedEvents, provider, model };
542
+ }
433
543
 
434
- const metas = [...byId.values()];
435
- const checks = await mapWithConcurrency(metas, READ_INDEX_CONCURRENCY, async (meta) => {
436
- try {
437
- await fs.access(path.join(dir, `${meta.id}.jsonl`));
438
- return true;
439
- } catch {
440
- return false;
441
- }
442
- });
443
- return metas
444
- .filter((_, index) => checks[index])
445
- .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
544
+ function providerHeaderFromEvent(event: MoxxyEvent): { provider?: string | null; model?: string | null } {
545
+ if (event.type !== 'provider_request' && event.type !== 'provider_response') return {};
546
+ return {
547
+ provider: event.provider,
548
+ model: event.model,
549
+ };
446
550
  }
447
551
 
448
552
  /**
@@ -484,10 +588,19 @@ export async function restoreEvents(
484
588
  }
485
589
  const events: MoxxyEvent[] = [];
486
590
  let corruptLines = 0;
591
+ let foreignEvents = 0;
592
+ let normalizedSessionIds = 0;
487
593
  for (const line of raw.split('\n')) {
488
594
  if (!line.trim()) continue;
489
595
  try {
490
- events.push(JSON.parse(line) as MoxxyEvent);
596
+ const event = JSON.parse(line) as MoxxyEvent;
597
+ const ownedEvent = eventForSession(event, sessionId);
598
+ if (ownedEvent) {
599
+ if (ownedEvent !== event) normalizedSessionIds += 1;
600
+ events.push(ownedEvent);
601
+ } else {
602
+ foreignEvents += 1;
603
+ }
491
604
  } catch {
492
605
  corruptLines += 1;
493
606
  }
@@ -503,17 +616,37 @@ export async function restoreEvents(
503
616
  }
504
617
  }
505
618
 
506
- if (corruptLines > 0 || resequenced > 0) {
507
- logger.warn('session log restored with gaps — re-sequenced to keep full history replayable', {
619
+ if (corruptLines > 0 || resequenced > 0 || foreignEvents > 0 || normalizedSessionIds > 0) {
620
+ const message =
621
+ foreignEvents > 0
622
+ ? 'session log restored with foreign-session events removed — re-sequenced to keep full history replayable'
623
+ : 'session log restored with gaps — re-sequenced to keep full history replayable';
624
+ logger.warn(message, {
508
625
  sessionId,
509
626
  path: logPath,
510
627
  corruptLines,
628
+ foreignEvents,
629
+ normalizedSessionIds,
511
630
  resequencedEvents: resequenced,
512
631
  restoredEvents: events.length,
513
632
  });
633
+ let canRewrite = true;
634
+ if (foreignEvents > 0) {
635
+ try {
636
+ await backupForeignSessionLog(logPath);
637
+ } catch (err) {
638
+ canRewrite = false;
639
+ logger.warn('failed to backup foreign-session log before repair', {
640
+ sessionId,
641
+ path: logPath,
642
+ error: err instanceof Error ? err.message : String(err),
643
+ });
644
+ }
645
+ }
514
646
  try {
515
- // Conflict guard: only rewrite if the on-disk content still matches what
516
- // we read. Another process (a desktop runner already attached + a CLI
647
+ // Conflict guard: only rewrite if (a) a foreign-session backup didn't
648
+ // fail (`canRewrite`) and (b) the on-disk content still matches what we
649
+ // read. Another process (a desktop runner already attached + a CLI
517
650
  // `resume` of the SAME id) may have appended/repaired between our read and
518
651
  // this write; its appends are a newer snapshot than our re-sequenced one,
519
652
  // and a blind `writeFileAtomic` would clobber them (silent history loss).
@@ -522,7 +655,9 @@ export async function restoreEvents(
522
655
  // rewrite. Restore still succeeds either way — the in-memory log is
523
656
  // already repaired; only the next clean resume would re-run the repair.
524
657
  const current = await fs.readFile(logPath, 'utf8').catch(() => null);
525
- if (current !== raw) {
658
+ if (!canRewrite) {
659
+ /* foreign-session backup failed — skip the destructive rewrite */
660
+ } else if (current !== raw) {
526
661
  logger.warn('skipped repaired-log rewrite — file changed under us (another process attached?)', {
527
662
  sessionId,
528
663
  path: logPath,
@@ -665,9 +800,68 @@ export function pageEvents(
665
800
  }
666
801
 
667
802
  /**
668
- * Remove a session's log file and its sidecar. A leftover legacy `index.json`
669
- * row, if any, is harmless `readIndex` filters out sessions whose `.jsonl` is
670
- * gone, so the deleted session won't reappear.
803
+ * Seed a session's event log from an external event list IFF the session has no
804
+ * log on disk yet. This is the migration that makes the runner's authoritative
805
+ * log the home of a chat whose history previously lived ONLY in a thin client's
806
+ * own mirror (the desktop's NDJSON chat store) — after it runs, `loadHistory`
807
+ * serves that chat from the runner like any other.
808
+ *
809
+ * Idempotent and NON-destructive: if `<sessionId>.jsonl` already exists AND is
810
+ * NON-EMPTY this is a no-op returning `false`, so a session the runner already
811
+ * owns is NEVER overwritten. A 0-byte log IS seeded: `persistence.attach`
812
+ * creates an empty `<id>.jsonl` on every spawn (even a session with zero
813
+ * events), so an existence-only guard would skip exactly the legacy chats this
814
+ * migration targets — and an empty file holds no history, so seeding over it
815
+ * loses nothing. Events are re-sequenced to contiguous `seq` 0..n-1 (order + ids
816
+ * preserved) so the seeded log satisfies {@link EventLog}'s seq invariants when
817
+ * it is later restored. Written temp+rename so a crash mid-seed can't leave a
818
+ * half-written log. Returns `true` iff it wrote the log.
819
+ */
820
+ export async function seedSessionLog(
821
+ sessionId: string,
822
+ events: ReadonlyArray<MoxxyEvent>,
823
+ dir = defaultSessionsDir(),
824
+ ): Promise<boolean> {
825
+ if (events.length === 0) return false;
826
+ const logPath = path.join(dir, `${sessionId}.jsonl`);
827
+ try {
828
+ // A non-empty log is a session the runner already owns → never overwrite.
829
+ // A 0-byte log is the empty file attach() left behind → seed over it.
830
+ if ((await fs.stat(logPath)).size > 0) return false;
831
+ } catch {
832
+ /* no log yet → seed below */
833
+ }
834
+ await fs.mkdir(dir, { recursive: true });
835
+ const reseq = events.map((e, i) => (e.seq === i ? e : ({ ...e, seq: i } as MoxxyEvent)));
836
+ await writeFileAtomic(logPath, reseq.map((e) => JSON.stringify(e) + '\n').join(''));
837
+ return true;
838
+ }
839
+
840
+ function eventBelongsToSession(event: MoxxyEvent, sessionId: string): boolean {
841
+ const eventSessionId = (event as { sessionId?: unknown }).sessionId;
842
+ return eventSessionId == null || eventSessionId === sessionId;
843
+ }
844
+
845
+ function eventForSession(event: MoxxyEvent, sessionId: string): MoxxyEvent | null {
846
+ if (!eventBelongsToSession(event, sessionId)) return null;
847
+ if ((event as { sessionId?: unknown }).sessionId === sessionId) return event;
848
+ return { ...event, sessionId: sessionId as SessionId } as MoxxyEvent;
849
+ }
850
+
851
+ async function backupForeignSessionLog(logPath: string): Promise<void> {
852
+ try {
853
+ await fs.copyFile(logPath, `${logPath}.foreign-session.bak`, fsConstants.COPYFILE_EXCL);
854
+ } catch (err) {
855
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') return;
856
+ throw err;
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Remove a session entirely: its event log (`<id>.jsonl`) and its single
862
+ * metadata file (`<id>.json`). This is the SINGLE deletion mechanism — a session
863
+ * exists iff its `<id>.json` does, so erasing it removes the session from every
864
+ * surface's derived list with no second copy to resurrect it.
671
865
  */
672
866
  export async function deleteSession(
673
867
  sessionId: string,
@@ -676,11 +870,162 @@ export async function deleteSession(
676
870
  assertSafeSessionId(sessionId);
677
871
  await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
678
872
  await fs.rm(metaPath(dir, sessionId), { force: true });
873
+ metaCache.delete(metaPath(dir, sessionId));
679
874
  }
680
875
 
681
- /** Per-session metadata sidecar path. */
876
+ /** Per-session metadata file path: `<id>.json` (one file per session). */
682
877
  function metaPath(dir: string, id: string): string {
683
- return path.join(dir, `${id}.meta.json`);
878
+ return path.join(dir, `${id}.json`);
879
+ }
880
+
881
+ /** Read one session's metadata file, or null if absent/corrupt. */
882
+ async function readMetaSidecar(dir: string, id: string): Promise<SessionMeta | null> {
883
+ try {
884
+ const parsed = JSON.parse(await fs.readFile(metaPath(dir, id), 'utf8')) as unknown;
885
+ return isSessionMeta(parsed) ? parsed : null;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Patch the UI-owned fields of a session's `<id>.json` in place (read-modify-
893
+ * write): the rename (`title`) and move (`groupId`) paths. A live runner adopts
894
+ * and re-merges these on its next write, so this is safe whether or not a runner
895
+ * is attached. No-op if the session has no metadata file yet.
896
+ */
897
+ async function patchSessionMeta(
898
+ sessionId: string,
899
+ patch: Partial<Pick<SessionMeta, 'title' | 'groupId'>>,
900
+ dir: string,
901
+ ): Promise<void> {
902
+ assertSafeSessionId(sessionId);
903
+ const existing = await readMetaSidecar(dir, sessionId);
904
+ if (!existing) return;
905
+ await writeJsonAtomic(metaPath(dir, sessionId), { ...existing, ...patch });
906
+ metaCache.delete(metaPath(dir, sessionId));
907
+ }
908
+
909
+ /** Persist (or clear, when blank) a user-set session title — the rename path. */
910
+ export async function setSessionTitle(
911
+ sessionId: string,
912
+ title: string,
913
+ dir = defaultSessionsDir(),
914
+ ): Promise<void> {
915
+ await patchSessionMeta(sessionId, { title: title.trim() || null }, dir);
916
+ }
917
+
918
+ /** Persist (or clear, when null) a session's explicit desk/workspace membership. */
919
+ export async function setSessionGroup(
920
+ sessionId: string,
921
+ groupId: string | null,
922
+ dir = defaultSessionsDir(),
923
+ ): Promise<void> {
924
+ await patchSessionMeta(sessionId, { groupId: groupId ?? null }, dir);
925
+ }
926
+
927
+ /**
928
+ * Seed a session's `<id>.json` (+ empty `.jsonl`) so a freshly-created session
929
+ * appears in the derived workspace list IMMEDIATELY, before its runner spawns
930
+ * and attaches. Idempotent: if a metadata file already exists this is a no-op,
931
+ * so it never clobbers a live session. The runner adopts the seeded
932
+ * `startedAt`/`source`/`groupId`/`title` on attach.
933
+ */
934
+ export async function seedSessionMeta(
935
+ sessionId: string,
936
+ cwd: string,
937
+ source: SessionSource,
938
+ dir = defaultSessionsDir(),
939
+ groupId: string | null = null,
940
+ ): Promise<void> {
941
+ assertSafeSessionId(sessionId);
942
+ if (await readMetaSidecar(dir, sessionId)) return;
943
+ await fs.mkdir(dir, { recursive: true });
944
+ const handle = await fs.open(path.join(dir, `${sessionId}.jsonl`), 'a');
945
+ await handle.close();
946
+ const now = new Date().toISOString();
947
+ const meta: SessionMeta = {
948
+ version: SESSION_META_VERSION,
949
+ id: sessionId,
950
+ cwd,
951
+ startedAt: now,
952
+ lastActivity: now,
953
+ eventCount: 0,
954
+ firstPrompt: null,
955
+ provider: null,
956
+ model: null,
957
+ source,
958
+ groupId,
959
+ title: null,
960
+ };
961
+ await writeJsonAtomic(metaPath(dir, sessionId), meta);
962
+ metaCache.delete(metaPath(dir, sessionId));
963
+ }
964
+
965
+ /**
966
+ * Parse-cache for `<id>.json` files, keyed by absolute path. A file is re-parsed
967
+ * only when its mtime/size changes, so listing thousands of sessions on every
968
+ * `desks.list` costs N cheap stats plus reparses of just the CHANGED files — not
969
+ * N JSON parses. Cross-process safe: another process's write bumps the mtime,
970
+ * observed on the next stat. Per-process, bounded by the number of sessions.
971
+ */
972
+ const metaCache = new Map<string, { mtimeMs: number; size: number; meta: SessionMeta }>();
973
+
974
+ /**
975
+ * One pass over the sessions dir: a single `readdir` yields BOTH the `<id>.json`
976
+ * metadata files and the set of `<id>` ids that have an event log, so callers
977
+ * test log-existence with an O(1) Set lookup instead of an `fs.access` per
978
+ * session. Each `<id>.json` is `stat`-checked and re-parsed only when its
979
+ * mtime/size changed (the parse cache), so steady-state cost is one `readdir` +
980
+ * N cheap `stat`s + reparses of only the CHANGED files.
981
+ */
982
+ async function readSessionsDir(dir: string): Promise<{ metas: SessionMeta[]; logs: Set<string> }> {
983
+ let dirents: import('node:fs').Dirent[];
984
+ try {
985
+ dirents = await fs.readdir(dir, { withFileTypes: true });
986
+ } catch {
987
+ return { metas: [], logs: new Set() };
988
+ }
989
+ const logs = new Set<string>();
990
+ const files: import('node:fs').Dirent[] = [];
991
+ for (const d of dirents) {
992
+ if (!d.isFile()) continue;
993
+ if (d.name.endsWith('.jsonl')) logs.add(d.name.slice(0, -'.jsonl'.length));
994
+ else if (d.name.endsWith('.json') && d.name !== 'index.json') files.push(d);
995
+ }
996
+ const live = new Set(files.map((d) => path.join(dir, d.name)));
997
+ for (const key of metaCache.keys()) if (!live.has(key)) metaCache.delete(key);
998
+ const metas = await mapWithConcurrency(files, READ_INDEX_CONCURRENCY, async (d) => {
999
+ const file = path.join(dir, d.name);
1000
+ try {
1001
+ const stat = await fs.stat(file);
1002
+ const cached = metaCache.get(file);
1003
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
1004
+ return cached.meta;
1005
+ }
1006
+ const parsed = JSON.parse(await fs.readFile(file, 'utf8')) as unknown;
1007
+ if (!isSessionMeta(parsed)) return null;
1008
+ metaCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, meta: parsed });
1009
+ return parsed;
1010
+ } catch {
1011
+ return null;
1012
+ }
1013
+ });
1014
+ return { metas: metas.filter((m): m is SessionMeta => m !== null), logs };
1015
+ }
1016
+
1017
+ /**
1018
+ * List every session from its `<id>.json` (mtime-cached, no `.jsonl` re-read) —
1019
+ * the cheap read the workspace list/search uses on every `desks.list`. Sessions
1020
+ * whose `.jsonl` is gone are dropped (a deleted session never lingers). Use
1021
+ * {@link readIndex} when you need the jsonl-hydrated picker view. Sorted
1022
+ * most-recent-activity first.
1023
+ */
1024
+ export async function listSessionMetas(dir = defaultSessionsDir()): Promise<SessionMeta[]> {
1025
+ const { metas, logs } = await readSessionsDir(dir);
1026
+ return metas
1027
+ .filter((meta) => logs.has(meta.id))
1028
+ .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
684
1029
  }
685
1030
 
686
1031
  /**