antechamber 1.0.3 → 1.0.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.5] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - Reduce startup CPU usage and boot delays for large session histories by committing restoration metadata
15
+ in one registry write, while preserving restored costs, child sessions and independent registry writers.
16
+
17
+ ## [1.0.4] - 2026-10-20
18
+
19
+ ### Fixed
20
+
21
+ - Keep subagents with colliding task IDs in different sessions separate, and recover a foreign child
22
+ archive from its own retained transcript.
23
+
10
24
  ## [1.0.3] - 2026-10-20
11
25
 
12
26
  ### Added
@@ -91,12 +91,15 @@ export declare class SessionStore {
91
91
  readonly stateDir: string;
92
92
  private readonly file;
93
93
  private entries;
94
+ private batchState;
94
95
  constructor(stateDir?: string);
95
96
  private load;
96
97
  private readEntries;
97
98
  private withRegistryLock;
98
99
  private persist;
99
100
  private mutate;
101
+ /** Synchronous restoration reads its own changes, then merges them under one short-lived write lock. */
102
+ batch<T>(action: () => T): T;
100
103
  get(ocSessionId: string): SessionRegistryEntry | undefined;
101
104
  idFor(piSessionId: string): string | undefined;
102
105
  childIds(): string[];
@@ -378,12 +378,15 @@ export class SessionStore {
378
378
  stateDir;
379
379
  file;
380
380
  entries;
381
+ batchState;
381
382
  constructor(stateDir) {
382
383
  const dir = stateDir ?? envSetting("STATE_DIR") ?? defaultStateDir();
383
384
  this.stateDir = dir;
384
385
  this.file = join(dir, "sessions.json");
385
386
  }
386
387
  load() {
388
+ if (this.batchState !== undefined)
389
+ return this.batchState.entries;
387
390
  if (this.entries !== undefined)
388
391
  return this.entries;
389
392
  this.entries = this.readEntries();
@@ -452,6 +455,11 @@ export class SessionStore {
452
455
  renameSync(tmp, this.file);
453
456
  }
454
457
  mutate(change) {
458
+ if (this.batchState !== undefined) {
459
+ this.batchState.changes.push(change);
460
+ change(this.batchState.entries);
461
+ return;
462
+ }
455
463
  this.withRegistryLock(() => {
456
464
  const entries = this.readEntries();
457
465
  if (change(entries))
@@ -459,6 +467,31 @@ export class SessionStore {
459
467
  this.entries = entries;
460
468
  });
461
469
  }
470
+ /** Synchronous restoration reads its own changes, then merges them under one short-lived write lock. */
471
+ batch(action) {
472
+ if (this.batchState !== undefined)
473
+ return action();
474
+ const changes = [];
475
+ const batch = { entries: structuredClone(this.load()), changes };
476
+ this.batchState = batch;
477
+ try {
478
+ const result = action();
479
+ this.batchState = undefined;
480
+ if (batch.changes.length !== 0) {
481
+ this.mutate((entries) => {
482
+ let changed = false;
483
+ for (const change of batch.changes)
484
+ if (change(entries))
485
+ changed = true;
486
+ return changed;
487
+ });
488
+ }
489
+ return result;
490
+ }
491
+ finally {
492
+ this.batchState = undefined;
493
+ }
494
+ }
462
495
  get(ocSessionId) {
463
496
  return this.load().get(ocSessionId);
464
497
  }
@@ -471,18 +504,23 @@ export class SessionStore {
471
504
  .map(([id]) => id);
472
505
  }
473
506
  set(ocSessionId, entry) {
507
+ const saved = this.batchState === undefined ? entry : structuredClone(entry);
474
508
  this.mutate((entries) => {
475
- entries.set(ocSessionId, entry);
509
+ entries.set(ocSessionId, this.batchState === undefined ? saved : structuredClone(saved));
476
510
  return true;
477
511
  });
478
512
  }
479
513
  /** Merge a partial update (e.g. title override, updated timestamp). */
480
514
  update(ocSessionId, patch) {
515
+ const saved = this.batchState === undefined ? patch : structuredClone(patch);
481
516
  this.mutate((entries) => {
482
517
  const previous = entries.get(ocSessionId);
483
518
  if (previous === undefined)
484
519
  return false;
485
- entries.set(ocSessionId, { ...previous, ...patch });
520
+ entries.set(ocSessionId, {
521
+ ...previous,
522
+ ...(this.batchState === undefined ? saved : structuredClone(saved)),
523
+ });
486
524
  return true;
487
525
  });
488
526
  }
package/dist/sessions.js CHANGED
@@ -308,14 +308,18 @@ export class SessionManager {
308
308
  .sort((a, b) => this.lifecycle.lastUsed(b) - this.lifecycle.lastUsed(a));
309
309
  }
310
310
  list(directory) {
311
- this.refreshNativeTitles();
312
- this.subagents.restoreAll();
313
- if (directory !== undefined) {
314
- this.ensureScanned(directory);
315
- return [...this.sessions.values()].filter((s) => s.info.directory === directory).map((s) => this.sessionInfo(s));
316
- }
317
- // Unfiltered: keep the old semantics — live sessions only.
318
- return [...this.sessions.values()].filter((s) => s.restored === undefined).map((s) => this.sessionInfo(s));
311
+ return this.store.batch(() => {
312
+ this.refreshNativeTitles();
313
+ this.subagents.restoreAll();
314
+ if (directory !== undefined) {
315
+ this.ensureScanned(directory);
316
+ return [...this.sessions.values()]
317
+ .filter((s) => s.info.directory === directory)
318
+ .map((s) => this.sessionInfo(s));
319
+ }
320
+ // Unfiltered: keep the old semantics — live sessions only.
321
+ return [...this.sessions.values()].filter((s) => s.restored === undefined).map((s) => this.sessionInfo(s));
322
+ });
319
323
  }
320
324
  /**
321
325
  * Every restorable session on disk (all directories) plus live ones. Backs the
@@ -323,10 +327,12 @@ export class SessionManager {
323
327
  * global/recent view; `list(directory)` stays the per-project source of truth.
324
328
  */
325
329
  listAll() {
326
- this.refreshNativeTitles();
327
- this.ensureAllScanned();
328
- this.subagents.restoreAll();
329
- return [...this.sessions.values()].map((s) => this.sessionInfo(s));
330
+ return this.store.batch(() => {
331
+ this.refreshNativeTitles();
332
+ this.ensureAllScanned();
333
+ this.subagents.restoreAll();
334
+ return [...this.sessions.values()].map((s) => this.sessionInfo(s));
335
+ });
330
336
  }
331
337
  sessionInfo(s) {
332
338
  if (s.restored !== undefined)
@@ -334,18 +340,20 @@ export class SessionManager {
334
340
  return s.info;
335
341
  }
336
342
  get(sessionId) {
337
- if (this.disposed)
338
- return undefined;
339
- const live = this.sessions.get(sessionId);
340
- if (live !== undefined) {
341
- this.refreshNativeTitles();
342
- this.sessionInfo(live);
343
- return live;
344
- }
345
- const restored = this.subagents.restore(sessionId) ?? this.resolveRestored(sessionId);
346
- if (restored !== undefined)
347
- this.sessionInfo(restored);
348
- return restored;
343
+ return this.store.batch(() => {
344
+ if (this.disposed)
345
+ return undefined;
346
+ const live = this.sessions.get(sessionId);
347
+ if (live !== undefined) {
348
+ this.refreshNativeTitles();
349
+ this.sessionInfo(live);
350
+ return live;
351
+ }
352
+ const restored = this.subagents.restore(sessionId) ?? this.resolveRestored(sessionId);
353
+ if (restored !== undefined)
354
+ this.sessionInfo(restored);
355
+ return restored;
356
+ });
349
357
  }
350
358
  async remove(sessionId) {
351
359
  return this.lifecycle.run(() => this.removeLive(sessionId));
@@ -1,6 +1,6 @@
1
- import { type SessionStore } from "./session-store.js";
2
- import { type EvalAgent, type ToolResult } from "./tool-map.js";
1
+ import { SessionStore } from "./session-store.js";
3
2
  import type { ManagedSession } from "./sessions.js";
3
+ import { type EvalAgent, type ToolResult } from "./tool-map.js";
4
4
  import type { OCEvent, OCMessageWithParts, OCToolPart } from "./types.js";
5
5
  export declare function stripTaskSuffix(text: string): string;
6
6
  /** One stable renderer part per item, with the batch defaults inherited. */
@@ -9,8 +9,10 @@ export declare function taskParts(template: OCToolPart, args: Record<string, unk
9
9
  export declare function updateTaskParts(parts: OCToolPart[], result: ToolResult, ended: boolean, now: number): void;
10
10
  /** Wake messages carry arrays of completion envelopes, not a flat details object. */
11
11
  export declare function taskCompletions(value: unknown): Record<string, unknown>[];
12
- /** Reverse lookup of a child session id, for tasks whose spawn never surfaced as a `task` tool call. */
13
- export declare function findTaskBySession(sessionId: string): {
12
+ /** Keep bookmarks for known tasks; a bare native id is only safe while it has one parent. */
13
+ export declare function taskSessionId(id: string, parentPiId: string, store?: SessionStore): string;
14
+ /** Reverse lookup never resolves an unbound, ambiguous bare id by store enumeration order. */
15
+ export declare function findTaskBySession(sessionId: string, store?: SessionStore): {
14
16
  path: string;
15
17
  data: Record<string, unknown>;
16
18
  } | undefined;
@@ -53,6 +55,7 @@ export declare class SubagentBridge {
53
55
  private readonly taskParents;
54
56
  constructor(store: SessionStore, sessions: Map<string, ManagedSession>, emit: (directory: string, event: OCEvent) => void, parse: ParseHistory);
55
57
  private archive;
58
+ private sessionId;
56
59
  wire(parent: ManagedSession): void;
57
60
  start(parent: ManagedSession, original: OCToolPart, args: Record<string, unknown>): void;
58
61
  result(parent: ManagedSession, callID: string, result: ToolResult, ended: boolean): boolean;
package/dist/subagents.js CHANGED
@@ -3,7 +3,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rea
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { piAgentDir } from "./agent-dir.js";
5
5
  import { syncSessionCost } from "./pi-event-translation.js";
6
- import { ocIdFor, SessionPersistenceError } from "./session-store.js";
6
+ import { ocIdFor, SessionPersistenceError, SessionStore } from "./session-store.js";
7
7
  import { mapTool, resultText, toolMappers } from "./tool-map.js";
8
8
  import { ADAPTER_VERSION } from "./version.js";
9
9
  // macOS watches directories through FSEvents, which goes live asynchronously after
@@ -106,7 +106,12 @@ export function updateTaskParts(parts, result, ended, now) {
106
106
  const metadata = {
107
107
  ...previous,
108
108
  task: data,
109
- ...(taskId(data.task_id) ? { sessionID: ocIdFor(data.task_id), taskID: data.task_id } : {}),
109
+ ...(taskId(data.task_id)
110
+ ? {
111
+ sessionID: previous?.taskID === data.task_id ? previous.sessionID : ocIdFor(data.task_id),
112
+ taskID: data.task_id,
113
+ }
114
+ : {}),
110
115
  };
111
116
  // Preserve a final result for the fallback even after the parent JSONL is gone.
112
117
  if (ended && output !== "" && !active(data.status))
@@ -206,16 +211,42 @@ function childRecord(id, path) {
206
211
  const data = jsonObject(path);
207
212
  return data?.task_id === id && typeof data.parent_session_id === "string" ? data : undefined;
208
213
  }
209
- /** Reverse lookup of a child session id, for tasks whose spawn never surfaced as a `task` tool call. */
210
- export function findTaskBySession(sessionId) {
214
+ /** Keep bookmarks for known tasks; a bare native id is only safe while it has one parent. */
215
+ export function taskSessionId(id, parentPiId, store = new SessionStore()) {
216
+ for (const sessionID of store.childIds()) {
217
+ const task = store.get(sessionID)?.task;
218
+ if (task?.id === id && task.parentPiId === parentPiId)
219
+ return sessionID;
220
+ }
221
+ const legacyID = ocIdFor(id);
222
+ const legacy = store.get(legacyID);
223
+ if (legacy !== undefined) {
224
+ if (legacy.deleted && legacy.parentID === (store.idFor(parentPiId) ?? ocIdFor(parentPiId)))
225
+ return legacyID;
226
+ return ocIdFor(`${parentPiId}:${id}`);
227
+ }
228
+ const qualifiedID = ocIdFor(`${parentPiId}:${id}`);
229
+ if (store.get(qualifiedID))
230
+ return qualifiedID;
231
+ for (const root of taskStores()) {
232
+ const data = childRecord(id, join(root, "tasks", `${id}.json`));
233
+ if (data && data.parent_session_id !== parentPiId)
234
+ return qualifiedID;
235
+ }
236
+ return legacyID;
237
+ }
238
+ /** Reverse lookup never resolves an unbound, ambiguous bare id by store enumeration order. */
239
+ export function findTaskBySession(sessionId, store = new SessionStore()) {
240
+ let found;
211
241
  for (const { id, path } of taskFiles()) {
212
- if (ocIdFor(id) !== sessionId)
213
- continue;
214
242
  const data = childRecord(id, path);
215
- if (data)
216
- return { path, data };
243
+ if (!data || taskSessionId(id, String(data.parent_session_id), store) !== sessionId)
244
+ continue;
245
+ if (found && found.data.parent_session_id !== data.parent_session_id)
246
+ return undefined;
247
+ found = { path, data };
217
248
  }
218
- return undefined;
249
+ return found;
219
250
  }
220
251
  /**
221
252
  * Whether a recorded argument names a task record's value. Codemode caps recorded strings at 512 code
@@ -266,7 +297,7 @@ export function evalAgentSession(parentPiId, args, claimed, after) {
266
297
  const summary = text(best.data.task_summary);
267
298
  const agentType = text(best.data.agent_type);
268
299
  return {
269
- sessionID: ocIdFor(best.id),
300
+ sessionID: taskSessionId(best.id, parentPiId),
270
301
  ...(name !== undefined && name !== best.id ? { name } : {}),
271
302
  ...(summary !== undefined ? { summary } : {}),
272
303
  ...(agentType !== undefined ? { agentType } : {}),
@@ -283,6 +314,35 @@ export function findTask(id, parentPiId) {
283
314
  }
284
315
  return undefined;
285
316
  }
317
+ function transcriptSessionId(raw) {
318
+ try {
319
+ const header = JSON.parse(raw.split("\n", 1)[0] ?? "");
320
+ return record(header) && header.type === "session" && typeof header.id === "string" ? header.id : undefined;
321
+ }
322
+ catch (error) {
323
+ if (error instanceof SyntaxError)
324
+ return undefined;
325
+ throw error;
326
+ }
327
+ }
328
+ function transcriptSessionIdFromFile(path) {
329
+ let fd;
330
+ try {
331
+ fd = openSync(path, "r");
332
+ }
333
+ catch (error) {
334
+ if (error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR", "EACCES"].includes(String(error.code)))
335
+ return undefined;
336
+ throw error;
337
+ }
338
+ try {
339
+ const buffer = Buffer.alloc(4096);
340
+ return transcriptSessionId(buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8"));
341
+ }
342
+ finally {
343
+ closeSync(fd);
344
+ }
345
+ }
286
346
  /** Byte offsets and a byte carry retain split UTF-8 characters and torn JSON lines. */
287
347
  export class JsonlTail {
288
348
  offset = 0;
@@ -339,8 +399,14 @@ export class SubagentBridge {
339
399
  this.emit = emit;
340
400
  this.parse = parse;
341
401
  }
342
- archive(id) {
343
- return join(this.store.stateDir, "children", `${id}.jsonl`);
402
+ archive(child) {
403
+ const previous = this.store.get(child.session.info.id)?.piPath;
404
+ const directory = join(this.store.stateDir, "children");
405
+ return ((previous !== undefined && dirname(previous) === directory ? previous : undefined) ??
406
+ join(directory, `${child.session.info.id === ocIdFor(child.task.id) ? child.task.id : child.session.info.id}.jsonl`));
407
+ }
408
+ sessionId(parent, id) {
409
+ return taskSessionId(id, parent.piSessionId ?? parent.restored?.piSessionId ?? "", this.store);
344
410
  }
345
411
  wire(parent) {
346
412
  parent.hasRunningChildren = () => [...this.children.values()].some((child) => child.session.info.parentID === parent.info.id && child.session.status.type === "busy");
@@ -373,7 +439,7 @@ export class SubagentBridge {
373
439
  if (message.customType !== "omo-senpi:wake")
374
440
  return;
375
441
  for (const data of taskCompletions(message.details)) {
376
- const id = ocIdFor(String(data.task_id));
442
+ const id = this.sessionId(parent, String(data.task_id));
377
443
  // A registry-only child (after a restart) or a task with no card (spawned from
378
444
  // `eval`) is not loaded yet; restore() covers both.
379
445
  if (!this.children.has(id))
@@ -399,11 +465,14 @@ export class SubagentBridge {
399
465
  for (const row of ev.data.tasks) {
400
466
  if (!record(row) || !taskId(row.task_id))
401
467
  continue;
402
- const id = ocIdFor(row.task_id);
468
+ if (typeof row.parent_session_id === "string" &&
469
+ row.parent_session_id !== (parent.piSessionId ?? parent.restored?.piSessionId))
470
+ continue;
471
+ const id = this.sessionId(parent, row.task_id);
403
472
  if (!this.children.has(id))
404
473
  this.restore(id);
405
474
  const child = this.children.get(id);
406
- if (!child)
475
+ if (!child || child.session.info.parentID !== parent.info.id)
407
476
  continue;
408
477
  const data = snapshotState(row);
409
478
  // OmO re-sends every row ~150 ms after each progress event of any task; only changes matter.
@@ -440,12 +509,13 @@ export class SubagentBridge {
440
509
  if (part.type !== "tool" || part.tool !== "task" || part.state.status === "pending")
441
510
  continue;
442
511
  const metadata = part.state.metadata;
443
- if (!taskId(metadata?.taskID))
512
+ if (!metadata || !taskId(metadata.taskID))
444
513
  continue;
445
514
  const id = metadata.taskID;
446
515
  const details = record(metadata.task) ? metadata.task : {};
447
- let child = this.children.get(ocIdFor(id));
448
- if (!child && this.deleted(ocIdFor(id)))
516
+ const sessionID = this.sessionId(parent, id);
517
+ let child = this.children.get(sessionID);
518
+ if (!child && this.deleted(sessionID))
449
519
  continue;
450
520
  if (!child) {
451
521
  child = this.create(parent, {
@@ -461,16 +531,17 @@ export class SubagentBridge {
461
531
  child.task.details = { ...child.task.details, ...details };
462
532
  }
463
533
  child.live ||= live;
534
+ metadata.sessionID = child.session.info.id;
464
535
  if (terminal(details.status) && part.state.status !== "running")
465
536
  child.task.terminalAt = part.state.time.end;
466
537
  child.parts.set(part.id, part);
467
538
  this.refresh(child, true);
468
539
  }
469
540
  }
470
- create(parent, task) {
471
- const archived = this.store.get(ocIdFor(task.id))?.archived;
541
+ create(parent, task, id = taskSessionId(task.id, task.parentPiId, this.store)) {
542
+ const archived = this.store.get(id)?.archived;
472
543
  const session = this.managedSession({
473
- id: ocIdFor(task.id),
544
+ id,
474
545
  parentID: parent.info.id,
475
546
  projectID: parent.info.projectID,
476
547
  directory: parent.info.directory,
@@ -504,7 +575,7 @@ export class SubagentBridge {
504
575
  return this.removed.has(id) ? undefined : this.adopt(id);
505
576
  if (entry.deleted || !entry.task || !entry.parentID)
506
577
  return undefined;
507
- const child = this.create(this.detachedParent(entry.parentID, entry.directory, entry), entry.task);
578
+ const child = this.create(this.detachedParent(entry.parentID, entry.directory, entry), entry.task, id);
508
579
  child.session.info.title = entry.title ?? child.session.info.title;
509
580
  this.refresh(child, false);
510
581
  return child.session;
@@ -513,7 +584,7 @@ export class SubagentBridge {
513
584
  * Tasks spawned outside a `task` tool call (e.g. `agent()` inside an `eval`
514
585
  * cell) never produce a card, so their session is built from the task record.
515
586
  */
516
- adopt(id, found = findTaskBySession(id)) {
587
+ adopt(id, found = findTaskBySession(id, this.store)) {
517
588
  if (!found)
518
589
  return undefined;
519
590
  const parentPi = String(found.data.parent_session_id);
@@ -532,7 +603,7 @@ export class SubagentBridge {
532
603
  ...(typeof found.data.agent_type === "string" ? { subagent_type: found.data.agent_type } : {}),
533
604
  },
534
605
  details: found.data,
535
- });
606
+ }, id);
536
607
  this.refresh(child, false);
537
608
  return child.session;
538
609
  }
@@ -554,9 +625,9 @@ export class SubagentBridge {
554
625
  childIdsByPi() {
555
626
  const byPi = new Map();
556
627
  for (const id of this.store.childIds()) {
557
- const piSessionId = this.store.get(id)?.piSessionId;
558
- if (piSessionId)
559
- byPi.set(piSessionId, id);
628
+ const entry = this.store.get(id);
629
+ if (entry?.piSessionId && !entry.deleted)
630
+ byPi.set(entry.piSessionId, id);
560
631
  }
561
632
  return byPi;
562
633
  }
@@ -589,16 +660,16 @@ export class SubagentBridge {
589
660
  this.restore(id);
590
661
  const childByPi = this.childIdsByPi();
591
662
  for (const { id, path } of taskFiles()) {
592
- const sessionId = ocIdFor(id);
593
- if (this.children.has(sessionId) || this.removed.has(sessionId) || this.store.get(sessionId))
594
- continue;
595
- let parentPi = this.taskParents.get(id);
663
+ let parentPi = this.taskParents.get(path);
596
664
  if (parentPi === undefined) {
597
665
  const parent = childRecord(id, path)?.parent_session_id;
598
666
  if (typeof parent !== "string")
599
667
  continue;
600
- this.taskParents.set(id, (parentPi = parent));
668
+ this.taskParents.set(path, (parentPi = parent));
601
669
  }
670
+ const sessionId = taskSessionId(id, parentPi, this.store);
671
+ if (this.children.has(sessionId) || this.removed.has(sessionId) || this.store.get(sessionId))
672
+ continue;
602
673
  if (!this.parentFor(parentPi, childByPi))
603
674
  continue;
604
675
  const data = childRecord(id, path);
@@ -641,7 +712,7 @@ export class SubagentBridge {
641
712
  }
642
713
  }
643
714
  refresh(child, publish, useRecord = true) {
644
- const archive = this.archive(child.task.id);
715
+ const archive = this.archive(child);
645
716
  const found = this.reconcileTaskRecord(child, useRecord);
646
717
  // OmO stops in-process workers with their parent and leaves them `running` but `persisted_only`
647
718
  // until the revived parent reconciles them (omo-task.js `DK`/`jK`); they are not running.
@@ -695,32 +766,52 @@ export class SubagentBridge {
695
766
  // A resumed live task can be newer than its archive; completed history prefers the archive.
696
767
  const liveRun = running || previousStatus === "busy";
697
768
  let raw = liveRun ? undefined : optionalFile(archive);
769
+ const expected = (child.livePath === undefined ? undefined : transcriptSessionIdFromFile(child.livePath)) ??
770
+ (typeof child.task.details.child_session_id === "string" && child.task.details.child_session_id !== ""
771
+ ? child.task.details.child_session_id
772
+ : undefined);
773
+ const legacyArchive = join(this.store.stateDir, "children", `${child.task.id}.jsonl`);
774
+ if (!liveRun && raw === undefined && expected !== undefined) {
775
+ for (const path of [join(this.store.stateDir, "children", `native-${ocIdFor(expected)}.jsonl`), legacyArchive]) {
776
+ const retained = optionalFile(path);
777
+ if (retained !== undefined && transcriptSessionId(retained) === expected) {
778
+ raw = retained;
779
+ break;
780
+ }
781
+ }
782
+ }
783
+ const foreign = raw !== undefined && expected !== undefined && transcriptSessionId(raw) !== expected;
784
+ if (foreign && raw !== undefined) {
785
+ const displaced = transcriptSessionId(raw);
786
+ if (displaced !== undefined) {
787
+ const retained = join(this.store.stateDir, "children", `native-${ocIdFor(displaced)}.jsonl`);
788
+ // This may be the other child's only history; retain it before repairing the shared path.
789
+ if (!existsSync(retained))
790
+ this.saveArchive(retained, raw);
791
+ }
792
+ raw = undefined;
793
+ }
698
794
  if (running && child.recordPath)
699
795
  this.watch(child, child.recordPath);
700
796
  if (raw === undefined && child.livePath !== undefined) {
701
797
  if (running)
702
798
  this.watch(child, child.livePath);
703
799
  child.tail.read(child.livePath);
704
- if (child.tail.text !== "")
800
+ if (child.tail.text !== "" && (expected === undefined || transcriptSessionId(child.tail.text) === expected))
705
801
  raw = child.tail.text;
706
802
  }
707
803
  if (raw !== undefined) {
708
- const headerLine = raw.split("\n", 1)[0];
709
- try {
710
- const header = JSON.parse(headerLine ?? "");
711
- if (record(header) && typeof header.id === "string")
712
- child.session.piSessionId = header.id;
713
- }
714
- catch (error) {
715
- if (!(error instanceof SyntaxError))
716
- throw error;
717
- }
804
+ const piSessionId = transcriptSessionId(raw);
805
+ if (piSessionId !== undefined)
806
+ child.session.piSessionId = piSessionId;
718
807
  child.session.messages = this.parse(child.session, raw);
719
808
  this.discover(child.session, child.session.messages);
720
- if (!running && terminal(child.task.details.status) && (liveRun || !existsSync(archive)))
809
+ if (!running && terminal(child.task.details.status) && (liveRun || foreign || !existsSync(archive)))
721
810
  this.saveArchive(archive, raw);
722
811
  }
723
812
  else {
813
+ if (expected !== undefined)
814
+ child.session.piSessionId = expected;
724
815
  child.session.messages = this.fallback(child);
725
816
  }
726
817
  child.loaded = true;
@@ -836,7 +927,7 @@ export class SubagentBridge {
836
927
  try {
837
928
  this.store.set(info.id, {
838
929
  piSessionId: child.session.piSessionId ?? child.task.id,
839
- piPath: this.archive(child.task.id),
930
+ piPath: this.archive(child),
840
931
  directory: info.directory,
841
932
  title: info.title,
842
933
  parentID: info.parentID,
@@ -924,9 +1015,10 @@ export class SubagentBridge {
924
1015
  this.store.set(id, {
925
1016
  deleted: true,
926
1017
  piSessionId: child.session.piSessionId ?? child.task.id,
927
- piPath: this.archive(child.task.id),
1018
+ piPath: this.archive(child),
928
1019
  directory: info.directory,
929
1020
  ...(info.parentID !== undefined ? { parentID: info.parentID } : {}),
1021
+ task: child.task,
930
1022
  created: info.time.created,
931
1023
  updated: info.time.updated,
932
1024
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "OpenChamber extension: displays OmO tool calls the way OmO means them when OpenChamber runs on antechamber",
5
5
  "private": true,
6
6
  "type": "module",
@@ -36,16 +36,20 @@ exceeds that bound, the service keeps the root and a valid task prefix, removes
36
36
  edges to omitted tasks, and returns `truncated.omittedNodes`; the panel
37
37
  validates that field and displays the count.
38
38
 
39
- The connector names sub-agent sessions `ses_<md5(task_id)>` and root sessions
40
- `ses_<md5(piSessionId)>` (`ocIdFor` in `src/session-store.ts`). The graph
41
- uses the same function, so a node's `sessionId` is exactly the id
42
- `openSession` needs, even for runs missing from the registry.
39
+ The connector names root sessions `ses_<md5(piSessionId)>` (`ocIdFor` in
40
+ `src/session-store.ts`). Sub-agent links use their recorded registry ID when
41
+ available. Unregistered tasks use `ses_<md5(task_id)>` while the task ID has
42
+ one parent, or `ses_<md5(parent_session_id + ":" + task_id)>` when it collides
43
+ across parents. Discovery retains both colliding task records, and the graph
44
+ uses the same identity rules so `openSession` opens the correct child.
45
+ Legacy deletion tombstones retain their owning parent's bare identity, even
46
+ when they predate task metadata in the registry.
43
47
 
44
48
  ## Graph model (`src/graph.ts`)
45
49
 
46
50
  - **Run**: every task whose `root_session_id` equals the resolved root. The
47
51
  root is resolved from the open session: it is either a root itself, a
48
- sub-agent (`ses_<md5(task_id)>`, so the run is that task's root), or a
52
+ sub-agent (resolved through its parent-qualified identity or registry), or a
49
53
  registry entry.
50
54
  - **Spawn edges**: `parent_session_id` → task. A task launched from inside a
51
55
  sub-agent has that sub-agent's own session id as its parent (its
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-graph",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "OpenChamber extension: live, interactive graph of the OmO sub-agents behind the current session",
5
5
  "private": true,
6
6
  "type": "module",
@@ -54,6 +54,7 @@ function normalizeRegistry(raw) {
54
54
  for (const [id, value] of Object.entries(raw)) {
55
55
  if (!isObject(value)) continue;
56
56
  const entry = {};
57
+ if (value.deleted === true) entry.deleted = true;
57
58
  if (text(value.piSessionId) !== void 0) entry.piSessionId = text(value.piSessionId);
58
59
  if (text(value.parentID) !== void 0) entry.parentID = text(value.parentID);
59
60
  if (text(value.title) !== void 0) entry.title = text(value.title);
@@ -65,6 +66,20 @@ function normalizeRegistry(raw) {
65
66
  function ocIdFor(piSessionId) {
66
67
  return `ses_${createHash("md5").update(piSessionId).digest("hex")}`;
67
68
  }
69
+ function taskSessionId(task, tasks, registry) {
70
+ const registered = Object.entries(registry).find(
71
+ ([, entry]) => entry.task?.id === task.task_id && entry.task.parentPiId === task.parent_session_id
72
+ );
73
+ if (registered) return registered[0];
74
+ const legacy = ocIdFor(task.task_id);
75
+ const parent = Object.entries(registry).find(([, entry]) => entry.piSessionId === task.parent_session_id)?.[0];
76
+ if (registry[legacy]?.deleted && registry[legacy]?.parentID === (parent ?? ocIdFor(task.parent_session_id ?? "")))
77
+ return legacy;
78
+ const qualified = ocIdFor(`${task.parent_session_id}:${task.task_id}`);
79
+ if (registry[legacy] !== void 0 || registry[qualified] !== void 0 || tasks.some((other) => other.task_id === task.task_id && other.parent_session_id !== task.parent_session_id))
80
+ return qualified;
81
+ return legacy;
82
+ }
68
83
  var ACTIVE = /* @__PURE__ */ new Set(["running", "starting"]);
69
84
  var WAITING = /* @__PURE__ */ new Set(["queued", "pending", "suspended"]);
70
85
  var FAILED = /* @__PURE__ */ new Set(["failed", "error", "lost", "denied", "plan_error"]);
@@ -108,7 +123,7 @@ ${task.spawn_spec?.instructions ?? ""}`;
108
123
  }
109
124
  function resolveSession(sessionId, tasks, registry) {
110
125
  const entry = registry[sessionId];
111
- const focusTask = tasks.find((t) => ocIdFor(t.task_id) === sessionId) ?? (entry?.task?.id ? tasks.find((t) => t.task_id === entry.task?.id) : void 0);
126
+ const focusTask = tasks.find((t) => taskSessionId(t, tasks, registry) === sessionId);
112
127
  let rootPi;
113
128
  if (focusTask) rootPi = focusTask.root_session_id ?? focusTask.parent_session_id ?? entry?.task?.parentPiId;
114
129
  else if (entry?.task?.parentPiId) rootPi = entry.task.parentPiId;
@@ -183,7 +198,7 @@ function buildGraph(sessionId, tasks, registry) {
183
198
  return {
184
199
  id: t.task_id,
185
200
  kind: "task",
186
- sessionId: ocIdFor(t.task_id),
201
+ sessionId: taskSessionId(t, tasks, registry),
187
202
  title: firstLine(t.task_summary ?? t.description ?? t.name, 90) ?? t.task_id,
188
203
  state,
189
204
  rawStatus: String(t.status ?? "unknown"),
@@ -322,7 +337,7 @@ var TaskStore = class {
322
337
  const path = join(dir, "tasks", file);
323
338
  if (!isRegularFile(path)) continue;
324
339
  const task = this.tasks.read(path, normalizeTask);
325
- if (task) out.set(task.task_id, task);
340
+ if (task) out.set(JSON.stringify([task.parent_session_id, task.task_id]), task);
326
341
  }
327
342
  }
328
343
  return [...out.values()];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-work",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "OpenChamber 2.x Work Status section: OmO todo phases, monitors, eval cells and background work of the current session",
5
5
  "private": true,
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "OpenCode-compatible HTTP/SSE server that lets OpenChamber drive the pi coding agent (and OmO) over RPC",
5
5
  "type": "module",
6
6
  "license": "MIT",