antechamber 1.0.2 → 1.0.4

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,25 @@ and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.4] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - Keep subagents with colliding task IDs in different sessions separate, and recover a foreign child
15
+ archive from its own retained transcript.
16
+
17
+ ## [1.0.3] - 2026-10-20
18
+
19
+ ### Added
20
+
21
+ - The OpenChamber extensions install by Git URL, and OpenChamber offers each release under
22
+ **Update** in Settings → Extensions: every release commits them to the `extension/omo-graph`,
23
+ `extension/omo-work` and `extension/omo-compat` branches, so add
24
+ `https://github.com/eliottness/antechamber#extension/omo-graph` (or `#extension/omo-work`,
25
+ `#extension/omo-compat`). The bare repository URL keeps failing with OpenChamber's "panel.id must
26
+ be kebab-case" toast, because it points at antechamber itself. ZIP installs still work but are
27
+ never updated.
28
+
10
29
  ## [1.0.2] - 2026-10-20
11
30
 
12
31
  ### Fixed
package/README.md CHANGED
@@ -228,13 +228,14 @@ source fits where OpenChamber runs:
228
228
  | OpenChamber runs | Add | Value |
229
229
  |---|---|---|
230
230
  | on the machine where antechamber is installed | **Folder** | the path `antechamber setup` prints: `$(npm root -g)/antechamber/extensions/omo-dag` for npm installs, `<checkout>/extensions/omo-dag` after `npm run build` from source |
231
- | anywhere (remote host, browser-only access) | **URL** | `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-graph.zip` (attached to every GitHub release) |
232
- | anywhere, offline | **ZIP file** | that same `antechamber-omo-graph.zip`, downloaded, or built with `npm run build && npm run pack:extension -- antechamber-omo-graph.zip` |
231
+ | anywhere (remote host, browser-only access) | **URL** | `https://github.com/eliottness/antechamber#extension/omo-graph`, a Git install that OpenChamber offers to update after each release |
232
+ | anywhere, offline | **ZIP file** | `antechamber-omo-graph.zip` from a GitHub release, or built with `npm run build && npm run pack:extension -- antechamber-omo-graph.zip`. OpenChamber never updates ZIP installs |
233
233
 
234
234
  Then approve its two permissions (follow sessions, run the local service) and
235
- open it from the rail icon. Don't install it from the repository's Git URL:
236
- OpenChamber clones the repository root, which is antechamber itself, not the
237
- extension, and refuses it as an invalid manifest. Details:
235
+ open it from the rail icon. Keep the `#extension/omo-graph` part of the URL:
236
+ the bare repository URL makes OpenChamber clone antechamber itself, whose
237
+ `package.json` is not an extension, and OpenChamber words that rejection as
238
+ "panel.id must be kebab-case". Details:
238
239
  [extensions/omo-dag/README.md](extensions/omo-dag/README.md).
239
240
 
240
241
  ## OpenChamber extension: OmO Work
@@ -248,8 +249,8 @@ with a Fire now button. On 2.x, monitors appear here instead of as subagent
248
249
  sessions.
249
250
 
250
251
  Add it like OmO Graph: the folder `antechamber setup` prints
251
- (`.../extensions/omo-work`, built by `npm run build`), or
252
- `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-work.zip`.
252
+ (`.../extensions/omo-work`, built by `npm run build`), or the URL
253
+ `https://github.com/eliottness/antechamber#extension/omo-work`.
253
254
  Approve its local service. It needs an antechamber version that writes a
254
255
  work-status token into its run record (this release or newer) and an
255
256
  OpenChamber restart after upgrading. Details:
@@ -262,11 +263,31 @@ or newer. It tells OpenChamber how to display OmO tool calls that antechamber
262
263
  maps onto OpenChamber's cards; today it heads eval cells with `JS Eval` or
263
264
  `Python Eval` and the cell's summary. Add it the same way as OmO Graph: the
264
265
  folder `antechamber setup` prints (`.../extensions/omo-compat`, no build
265
- needed), or the release archive
266
- `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-compat.zip`.
266
+ needed), or the URL
267
+ `https://github.com/eliottness/antechamber#extension/omo-compat`.
267
268
  It asks for no permissions. Details:
268
269
  [extensions/omo-compat/README.md](extensions/omo-compat/README.md).
269
270
 
271
+ ## Upgrading the OpenChamber extensions
272
+
273
+ Every antechamber release ships the three extensions at its own version. How
274
+ an installed extension moves to a new release depends on how it was added:
275
+
276
+ - **Git URL** (`https://github.com/eliottness/antechamber#extension/...`):
277
+ Settings → Extensions shows **Update available** once a release is out
278
+ (OpenChamber checks at most hourly; **Check for updates** checks now).
279
+ **Update** replaces it in place and keeps its approvals and settings.
280
+ - **Folder**: the folder belongs to the antechamber installation, so it
281
+ changes with `antechamber upgrade` (or `git pull && npm ci && npm run build`
282
+ in a checkout). Toggle the extension off and on to restart its service.
283
+ - **ZIP** (file or release URL): OpenChamber never checks these. Add the new
284
+ archive and choose **Reinstall**, or switch to the Git URL once: remove the
285
+ extension, then add it by URL.
286
+
287
+ Each extension is built against the antechamber of the same release, so
288
+ upgrade antechamber too and restart OpenChamber to run it; `antechamber
289
+ doctor` shows the running version.
290
+
270
291
  ## Configuration
271
292
 
272
293
  Connector settings use the `ANTECHAMBER_` prefix; the deprecated `OCPI_`
@@ -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
  });
@@ -31,8 +31,11 @@ In OpenChamber, open **Settings → Extensions → Add**:
31
31
  `$(npm root -g)/antechamber/extensions/omo-compat` for an npm install, or
32
32
  `<checkout>/extensions/omo-compat` from a source checkout. It needs no build.
33
33
  - **URL**, from anywhere:
34
- `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-compat.zip`.
35
- Every GitHub release attaches this archive.
34
+ `https://github.com/eliottness/antechamber#extension/omo-compat`, a Git
35
+ install that OpenChamber offers to update after each release. Every GitHub
36
+ release also attaches
37
+ `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-compat.zip`,
38
+ which installs by URL too, but OpenChamber never updates ZIP installs.
36
39
 
37
40
  There is nothing to approve and no rail icon. Reload open chats after
38
41
  installing so their cards pick up the new header.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-compat",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",
@@ -58,16 +58,18 @@ fits where OpenChamber runs:
58
58
  - source checkout: `<checkout>/extensions/omo-dag`, after `npm run build`
59
59
  (which also writes `panel/main.js` and `service/main.js`).
60
60
  - **URL**, from anywhere, including a remote OpenChamber you only reach in a
61
- browser:
62
- `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-graph.zip`.
63
- Every GitHub release attaches this archive. Append a version instead of
64
- `latest` (`.../releases/download/vX.Y.Z/antechamber-omo-graph.zip`) to pin
65
- one.
66
- - **ZIP file**, for offline hosts: the same archive, downloaded, or built with
61
+ browser: `https://github.com/eliottness/antechamber#extension/omo-graph`.
62
+ Every release commits the built extension to the `extension/omo-graph`
63
+ branch, so OpenChamber offers it under **Update** in Settings → Extensions.
64
+ To pin a version instead, install its release archive by URL,
65
+ `https://github.com/eliottness/antechamber/releases/download/vX.Y.Z/antechamber-omo-graph.zip`;
66
+ OpenChamber never updates ZIP installs.
67
+ - **ZIP file**, for offline hosts: that archive, downloaded, or built with
67
68
  `npm run build && npm run pack:extension -- antechamber-omo-graph.zip`.
68
69
 
69
- Don't use the repository's Git URL. OpenChamber clones the repository root,
70
- which is antechamber itself, and rejects it as `invalid-manifest`.
70
+ Keep the `#extension/omo-graph` part of the URL. Without it OpenChamber clones
71
+ the repository root, which is antechamber itself, and rejects it as
72
+ `invalid-manifest`, which its toast words as "panel.id must be kebab-case".
71
73
 
72
74
  Approve the request. The extension asks for two things:
73
75
 
@@ -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.2",
3
+ "version": "1.0.4",
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()];
@@ -31,9 +31,12 @@ In OpenChamber, open **Settings → Extensions → Add**:
31
31
  installed: the path `antechamber setup` prints,
32
32
  `$(npm root -g)/antechamber/extensions/omo-work` for npm installs or
33
33
  `<checkout>/extensions/omo-work` after `npm run build`.
34
- - **URL**:
35
- `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-work.zip`.
36
- - **ZIP file**: the same archive, or
34
+ - **URL**: `https://github.com/eliottness/antechamber#extension/omo-work`, a
35
+ Git install that OpenChamber offers to update after each release. The
36
+ release archive,
37
+ `https://github.com/eliottness/antechamber/releases/latest/download/antechamber-omo-work.zip`,
38
+ also installs by URL, but OpenChamber never updates ZIP installs.
39
+ - **ZIP file**: that archive, or
37
40
  `npm run build && npm run pack:work-extension -- antechamber-omo-work.zip`.
38
41
 
39
42
  Approve its **local service**. The section then appears in the Work Status
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber-omo-work",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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.2",
3
+ "version": "1.0.4",
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",