antechamber 1.0.41 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +88 -141
  3. package/bin/opencode-antechamber +14 -4
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/commands/context.d.ts +5 -2
  7. package/dist/commands/context.js +8 -4
  8. package/dist/commands/doctor.js +4 -3
  9. package/dist/commands/setup.js +9 -28
  10. package/dist/config.d.ts +6 -2
  11. package/dist/config.js +18 -4
  12. package/dist/latency.d.ts +10 -0
  13. package/dist/latency.js +115 -0
  14. package/dist/misc-routes.js +2 -10
  15. package/dist/route-services.d.ts +0 -2
  16. package/dist/routes-v1.d.ts +1 -1
  17. package/dist/routes-v1.js +29 -418
  18. package/dist/routes-v2.js +1 -1
  19. package/dist/self-update.d.ts +2 -1
  20. package/dist/self-update.js +5 -1
  21. package/dist/server.d.ts +1 -3
  22. package/dist/server.js +9 -12
  23. package/dist/session-store.d.ts +12 -10
  24. package/dist/session-store.js +238 -128
  25. package/dist/session-tree.js +2 -2
  26. package/dist/sse.d.ts +0 -2
  27. package/dist/sse.js +13 -46
  28. package/dist/tool-map.js +1 -3
  29. package/dist/work-status.js +1 -1
  30. package/extensions/antechamber/README.md +100 -0
  31. package/extensions/antechamber/bin/opencode-antechamber +42 -0
  32. package/extensions/{omo-dag/docs/DESIGN.md → antechamber/docs/GRAPH.md} +5 -5
  33. package/extensions/{omo-work/docs/DESIGN.md → antechamber/docs/WORK.md} +3 -3
  34. package/extensions/antechamber/package.json +60 -0
  35. package/extensions/{omo-dag → antechamber}/panel/index.html +8 -1
  36. package/extensions/antechamber/panel/main.js +261 -0
  37. package/extensions/antechamber/runtime/launcher.js +201 -0
  38. package/extensions/antechamber/service/main.js +884 -0
  39. package/package.json +75 -26
  40. package/extensions/omo-compat/README.md +0 -48
  41. package/extensions/omo-compat/icon.svg +0 -1
  42. package/extensions/omo-compat/package.json +0 -43
  43. package/extensions/omo-dag/README.md +0 -167
  44. package/extensions/omo-dag/package.json +0 -33
  45. package/extensions/omo-dag/panel/main.js +0 -176
  46. package/extensions/omo-dag/service/main.js +0 -457
  47. package/extensions/omo-work/README.md +0 -88
  48. package/extensions/omo-work/package.json +0 -30
  49. package/extensions/omo-work/service/main.js +0 -149
  50. /package/extensions/{omo-dag → antechamber}/icon.svg +0 -0
  51. /package/extensions/{omo-work → antechamber}/status/index.html +0 -0
  52. /package/extensions/{omo-work → antechamber}/status/main.js +0 -0
@@ -6,7 +6,7 @@
6
6
  * so the adapter never needs its own transcript store — only:
7
7
  *
8
8
  * 1. a scan of that directory to rebuild the sidebar after a restart, and
9
- * 2. a small registry JSON for adapter-owned metadata that pi does not
9
+ * 2. a SQLite registry for adapter-owned metadata that pi does not
10
10
  * keep (titles, conversation cursors and child-task metadata).
11
11
  *
12
12
  * OpenChamber session ids for restored sessions are derived deterministically
@@ -111,23 +111,25 @@ export declare function scanPiSessions(directory: string): ScannedSession[];
111
111
  export declare class SessionStore {
112
112
  readonly stateDir: string;
113
113
  private readonly file;
114
- private entries;
115
- /** The registry bytes `entries` was parsed from or last written as; equal bytes on disk mean the cache is current. */
116
- private raw;
114
+ private db;
115
+ private version;
116
+ private readonly entries;
117
117
  private batchState;
118
118
  constructor(stateDir?: string);
119
- private load;
120
- private committed;
121
- private readRaw;
122
- private parseEntries;
123
- private withRegistryLock;
119
+ private database;
120
+ private migrate;
121
+ private upsert;
122
+ private writeRow;
123
+ private refresh;
124
+ private read;
124
125
  private persist;
125
126
  private mutate;
126
- /** Synchronous restoration reads its own changes, then merges them under one short-lived write lock. */
127
+ /** Stage only touched rows; replay partial updates against the latest rows under SQLite's write lock. */
127
128
  batch<T>(action: () => T): T;
128
129
  get(ocSessionId: string): SessionRegistryEntry | undefined;
129
130
  idFor(piSessionId: string): string | undefined;
130
131
  childIds(): string[];
132
+ private matching;
131
133
  set(ocSessionId: string, entry: SessionRegistryEntry): void;
132
134
  /** Merge a partial update (e.g. title override, updated timestamp). */
133
135
  update(ocSessionId: string, patch: Partial<SessionRegistryEntry>): void;
@@ -6,7 +6,7 @@
6
6
  * so the adapter never needs its own transcript store — only:
7
7
  *
8
8
  * 1. a scan of that directory to rebuild the sidebar after a restart, and
9
- * 2. a small registry JSON for adapter-owned metadata that pi does not
9
+ * 2. a SQLite registry for adapter-owned metadata that pi does not
10
10
  * keep (titles, conversation cursors and child-task metadata).
11
11
  *
12
12
  * OpenChamber session ids for restored sessions are derived deterministically
@@ -14,8 +14,9 @@
14
14
  * the same OpenChamber id, even if the registry file is lost.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
17
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, } from "node:fs";
18
18
  import { join } from "node:path";
19
+ import { DatabaseSync } from "node:sqlite";
19
20
  import { piAgentDir } from "./agent-dir.js";
20
21
  import { isPromptPart } from "./prompt-parts.js";
21
22
  import { defaultStateDir, envSetting } from "./config.js";
@@ -42,9 +43,6 @@ const TITLE_MAX_LINES = 200;
42
43
  const TITLE_MAX_CHARS = 60;
43
44
  const REGISTRY_DIRECTORY_MODE = 0o700;
44
45
  const REGISTRY_FILE_MODE = 0o600;
45
- const REGISTRY_LOCK_RETRY_MS = 10;
46
- const REGISTRY_LOCK_STALE_MS = 5_000;
47
- const registryLockWaiter = new Int32Array(new SharedArrayBuffer(4));
48
46
  function readHead(path, bytes = SNIFF_BYTES) {
49
47
  const fd = openSync(path, "r");
50
48
  try {
@@ -359,7 +357,7 @@ export function scanPiSessions(directory) {
359
357
  }
360
358
  /**
361
359
  * Persistent oc-session-id → metadata registry (title overrides, known pi
362
- * paths). Atomic writes, lazy load, corrupt file degrades to an empty map.
360
+ * paths). Legacy JSON records are validated at migration; SQLite writes are atomic.
363
361
  */
364
362
  function record(value) {
365
363
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -512,140 +510,231 @@ function errorCode(error) {
512
510
  export class SessionStore {
513
511
  stateDir;
514
512
  file;
515
- entries;
516
- /** The registry bytes `entries` was parsed from or last written as; equal bytes on disk mean the cache is current. */
517
- raw;
513
+ db;
514
+ version;
515
+ entries = new Map();
518
516
  batchState;
519
517
  constructor(stateDir) {
520
518
  const dir = stateDir ?? envSetting("STATE_DIR") ?? defaultStateDir();
521
519
  this.stateDir = dir;
522
- this.file = join(dir, "sessions.json");
520
+ this.file = join(dir, "sessions.sqlite");
523
521
  }
524
- load() {
525
- if (this.batchState !== undefined) {
526
- // Shallow: changes replace whole entries, and get() clones an entry before handing it out.
527
- this.batchState.entries ??= new Map(this.committed());
528
- return this.batchState.entries;
529
- }
530
- return this.committed();
531
- }
532
- committed() {
533
- if (this.entries !== undefined)
534
- return this.entries;
535
- this.raw = this.readRaw();
536
- this.entries = this.parseEntries(this.raw);
537
- return this.entries;
538
- }
539
- readRaw() {
540
- try {
541
- return readFileSync(this.file);
542
- }
543
- catch {
522
+ database(write = false) {
523
+ if (this.db !== undefined)
524
+ return this.db;
525
+ const legacy = join(this.stateDir, "sessions.json");
526
+ if (!write && !existsSync(this.file) && !existsSync(legacy))
544
527
  return undefined;
545
- }
546
- }
547
- parseEntries(raw) {
548
- const entries = new Map();
549
- if (raw === undefined)
550
- return entries;
528
+ let db;
551
529
  try {
552
- const parsed = JSON.parse(raw.toString("utf8"));
553
- if (!record(parsed))
554
- return entries;
555
- for (const [id, value] of Object.entries(parsed))
556
- if (registryEntry(value))
557
- entries.set(id, value);
530
+ mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
531
+ chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
532
+ // SQLite inherits the database mode for WAL/SHM; set it before enabling WAL.
533
+ closeSync(openSync(this.file, "a", REGISTRY_FILE_MODE));
534
+ chmodSync(this.file, REGISTRY_FILE_MODE);
535
+ db = new DatabaseSync(this.file);
536
+ db.exec("PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;");
537
+ db.exec(`CREATE TABLE IF NOT EXISTS sessions (
538
+ id TEXT PRIMARY KEY, payload TEXT NOT NULL, pi_session_id TEXT NOT NULL,
539
+ directory TEXT NOT NULL, parent_id TEXT, has_task INTEGER NOT NULL
540
+ );
541
+ CREATE INDEX IF NOT EXISTS sessions_pi ON sessions(pi_session_id);
542
+ CREATE INDEX IF NOT EXISTS sessions_directory ON sessions(directory);
543
+ CREATE INDEX IF NOT EXISTS sessions_children ON sessions(parent_id) WHERE has_task = 1;
544
+ CREATE TABLE IF NOT EXISTS registry_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
545
+ this.migrate(db, legacy);
546
+ for (const suffix of ["-wal", "-shm"]) {
547
+ const path = this.file + suffix;
548
+ if (existsSync(path))
549
+ chmodSync(path, REGISTRY_FILE_MODE);
550
+ }
551
+ this.db = db;
552
+ return db;
558
553
  }
559
- catch {
560
- /* corrupt — start empty */
554
+ catch (error) {
555
+ db?.close();
556
+ throw new SessionPersistenceError(this.file, error);
561
557
  }
562
- return entries;
563
558
  }
564
- withRegistryLock(action) {
565
- const lock = `${this.file}.lock`;
566
- let fd;
559
+ migrate(db, legacy) {
560
+ const meta = (key) => db.prepare("SELECT value FROM registry_meta WHERE key = ?").get(key)?.value;
561
+ const setMeta = (key, value) => db
562
+ .prepare("INSERT INTO registry_meta VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
563
+ .run(key, value);
564
+ db.prepare("INSERT OR IGNORE INTO registry_meta VALUES ('created_at', ?)").run(String(Date.now()));
565
+ if (!existsSync(legacy))
566
+ return;
567
+ db.exec("BEGIN IMMEDIATE");
568
+ let backup;
569
+ let merged = 0;
567
570
  try {
568
- mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
569
- chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
570
- for (;;) {
571
+ const imported = meta("legacy_backup");
572
+ const parse = () => {
571
573
  try {
572
- fd = openSync(lock, "wx", REGISTRY_FILE_MODE);
573
- break;
574
+ const parsed = JSON.parse(readFileSync(legacy, "utf8"));
575
+ return record(parsed) ? parsed : undefined;
574
576
  }
575
577
  catch (error) {
576
- if (errorCode(error) !== "EEXIST")
578
+ if (!(error instanceof SyntaxError))
577
579
  throw error;
578
- try {
579
- if (Date.now() - statSync(lock).mtimeMs >= REGISTRY_LOCK_STALE_MS) {
580
- unlinkSync(lock);
580
+ // Preserve malformed JSON untouched; a later repair can still be imported.
581
+ return undefined;
582
+ }
583
+ };
584
+ if (typeof imported === "string" && !existsSync(imported))
585
+ backup = imported;
586
+ else if (imported === undefined && db.prepare("SELECT 1 FROM sessions LIMIT 1").get() === undefined) {
587
+ const parsed = parse();
588
+ if (parsed !== undefined) {
589
+ const upsert = this.upsert(db);
590
+ for (const [id, entry] of Object.entries(parsed))
591
+ if (registryEntry(entry))
592
+ this.writeRow(upsert, id, entry);
593
+ backup = `${legacy}.migrated-${Date.now()}-${process.pid}`;
594
+ setMeta("legacy_backup", backup);
595
+ setMeta("reconciled_at", String(Date.now()));
596
+ }
597
+ }
598
+ else {
599
+ // A sessions.json next to a populated registry was written by an older antechamber after the migration (a
600
+ // rollback). Sessions it changed since then are newer than the rows here; older entries, including ones
601
+ // deleted since, stay as the registry has them.
602
+ const since = Number(meta("reconciled_at") ?? /\.migrated-(\d+)-/.exec(String(imported))?.[1] ?? meta("created_at") ?? Infinity);
603
+ const parsed = parse();
604
+ if (parsed !== undefined) {
605
+ const upsert = this.upsert(db);
606
+ for (const [id, entry] of Object.entries(parsed)) {
607
+ if (!registryEntry(entry) || !(entry.updated > since))
608
+ continue;
609
+ const current = this.read(db, id);
610
+ if (current !== undefined && current.updated >= entry.updated)
581
611
  continue;
582
- }
612
+ this.writeRow(upsert, id, entry);
613
+ merged += 1;
583
614
  }
584
- catch (staleLockError) {
585
- if (errorCode(staleLockError) !== "ENOENT")
586
- throw staleLockError;
587
- continue;
615
+ if (merged > 0) {
616
+ backup = `${legacy}.merged-${Date.now()}-${process.pid}`;
617
+ setMeta("reconciled_at", String(Date.now()));
588
618
  }
589
- Atomics.wait(registryLockWaiter, 0, 0, REGISTRY_LOCK_RETRY_MS);
590
619
  }
591
620
  }
592
- return action();
621
+ db.exec("COMMIT");
593
622
  }
594
623
  catch (error) {
595
- throw new SessionPersistenceError(this.file, error);
624
+ db.exec("ROLLBACK");
625
+ throw error;
596
626
  }
597
- finally {
598
- if (fd !== undefined) {
599
- closeSync(fd);
600
- unlinkSync(lock);
627
+ if (merged > 0)
628
+ this.entries.clear();
629
+ if (merged > 0)
630
+ console.warn(`[session-store] merged ${merged} sessions an older antechamber wrote to ${legacy}`);
631
+ // The durable marker handles a crash after COMMIT but before rename, including an empty import.
632
+ // Never overwrite an existing backup or import the same legacy file after sessions are deleted.
633
+ if (backup !== undefined && !existsSync(backup)) {
634
+ try {
635
+ chmodSync(legacy, REGISTRY_FILE_MODE);
636
+ renameSync(legacy, backup);
637
+ }
638
+ catch (error) {
639
+ if (errorCode(error) !== "ENOENT")
640
+ throw error;
601
641
  }
602
642
  }
603
643
  }
604
- persist(entries) {
605
- const obj = Object.fromEntries([...entries].map(([id, entry]) => [id, persistedEntry(entry)]));
606
- const tmp = `${this.file}.${process.pid}.tmp`;
607
- const data = Buffer.from(JSON.stringify(obj));
608
- writeFileSync(tmp, data, { mode: REGISTRY_FILE_MODE });
609
- chmodSync(tmp, REGISTRY_FILE_MODE);
610
- renameSync(tmp, this.file);
611
- return data;
612
- }
613
- mutate(change) {
614
- if (this.batchState !== undefined) {
615
- this.batchState.changes.push(change);
616
- change(this.load());
617
- return;
644
+ upsert(db) {
645
+ return db.prepare(`INSERT INTO sessions (id, payload, pi_session_id, directory, parent_id, has_task)
646
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
647
+ payload=excluded.payload, pi_session_id=excluded.pi_session_id, directory=excluded.directory,
648
+ parent_id=excluded.parent_id, has_task=excluded.has_task`);
649
+ }
650
+ writeRow(statement, id, entry) {
651
+ statement.run(id, JSON.stringify(persistedEntry(entry)), entry.piSessionId, entry.directory, entry.parentID ?? null, entry.task === undefined ? 0 : 1);
652
+ }
653
+ refresh(db) {
654
+ const version = db.prepare("PRAGMA data_version").get()?.data_version;
655
+ if (typeof version !== "number" && typeof version !== "bigint")
656
+ throw new Error("Invalid SQLite data_version");
657
+ if (this.version !== version) {
658
+ this.entries.clear();
659
+ this.version = version;
618
660
  }
619
- this.withRegistryLock(() => {
620
- // Another process may have written since; only parse again when the bytes differ.
621
- const raw = this.readRaw();
622
- const entries = this.entries !== undefined && raw !== undefined && this.raw !== undefined && raw.equals(this.raw)
623
- ? new Map(this.entries)
624
- : this.parseEntries(raw);
625
- const written = change(entries) ? this.persist(entries) : raw;
626
- this.entries = entries;
627
- this.raw = written;
628
- });
629
661
  }
630
- /** Synchronous restoration reads its own changes, then merges them under one short-lived write lock. */
662
+ read(db, id) {
663
+ const cached = this.entries.get(id);
664
+ if (cached !== undefined)
665
+ return cached;
666
+ const payload = db.prepare("SELECT payload FROM sessions WHERE id = ?").get(id)?.payload;
667
+ if (typeof payload !== "string")
668
+ return undefined;
669
+ const entry = JSON.parse(payload);
670
+ if (!registryEntry(entry))
671
+ return undefined;
672
+ this.entries.set(id, entry);
673
+ return entry;
674
+ }
675
+ persist(changes) {
676
+ try {
677
+ mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
678
+ chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
679
+ const db = this.database(true);
680
+ db.exec("BEGIN IMMEDIATE");
681
+ const changed = new Map();
682
+ try {
683
+ this.refresh(db);
684
+ for (const { id, apply } of changes) {
685
+ const previous = changed.has(id) ? changed.get(id) : this.read(db, id);
686
+ const next = apply(previous);
687
+ if (next !== undefined || previous !== undefined)
688
+ changed.set(id, next);
689
+ }
690
+ const upsert = this.upsert(db);
691
+ const remove = db.prepare("DELETE FROM sessions WHERE id = ?");
692
+ for (const [id, entry] of changed) {
693
+ if (entry === undefined)
694
+ remove.run(id);
695
+ else
696
+ this.writeRow(upsert, id, entry);
697
+ }
698
+ db.exec("COMMIT");
699
+ }
700
+ catch (error) {
701
+ db.exec("ROLLBACK");
702
+ throw error;
703
+ }
704
+ for (const [id, entry] of changed) {
705
+ if (entry === undefined)
706
+ this.entries.delete(id);
707
+ else
708
+ this.entries.set(id, entry);
709
+ }
710
+ }
711
+ catch (error) {
712
+ if (error instanceof SessionPersistenceError)
713
+ throw error;
714
+ throw new SessionPersistenceError(this.file, error);
715
+ }
716
+ }
717
+ mutate(id, apply) {
718
+ const batch = this.batchState;
719
+ if (batch === undefined)
720
+ this.persist([{ id, apply }]);
721
+ else {
722
+ const previous = this.get(id);
723
+ batch.changes.push({ id, apply });
724
+ batch.entries.set(id, apply(previous));
725
+ }
726
+ }
727
+ /** Stage only touched rows; replay partial updates against the latest rows under SQLite's write lock. */
631
728
  batch(action) {
632
729
  if (this.batchState !== undefined)
633
730
  return action();
634
- const changes = [];
635
- const batch = { entries: undefined, isolated: new WeakSet(), changes };
731
+ const batch = { entries: new Map(), changes: [] };
636
732
  this.batchState = batch;
637
733
  try {
638
734
  const result = action();
639
735
  this.batchState = undefined;
640
- if (batch.changes.length !== 0) {
641
- this.mutate((entries) => {
642
- let changed = false;
643
- for (const change of batch.changes)
644
- if (change(entries))
645
- changed = true;
646
- return changed;
647
- });
648
- }
736
+ if (batch.changes.length !== 0)
737
+ this.persist(batch.changes);
649
738
  return result;
650
739
  }
651
740
  finally {
@@ -653,49 +742,70 @@ export class SessionStore {
653
742
  }
654
743
  }
655
744
  get(ocSessionId) {
656
- const entries = this.load();
657
- const entry = entries.get(ocSessionId);
658
- if (this.batchState === undefined || entry === undefined || this.batchState.isolated.has(entry))
745
+ const batch = this.batchState;
746
+ if (batch?.entries.has(ocSessionId))
747
+ return batch.entries.get(ocSessionId);
748
+ const db = this.database();
749
+ if (db === undefined)
750
+ return undefined;
751
+ this.refresh(db);
752
+ const entry = this.read(db, ocSessionId);
753
+ if (batch === undefined)
659
754
  return entry;
660
- const staged = structuredClone(entry);
661
- entries.set(ocSessionId, staged);
662
- this.batchState.isolated.add(staged);
755
+ const staged = entry === undefined ? undefined : structuredClone(entry);
756
+ batch.entries.set(ocSessionId, staged);
663
757
  return staged;
664
758
  }
665
759
  idFor(piSessionId) {
666
- return [...this.load()].find(([, entry]) => entry.piSessionId === piSessionId)?.[0];
760
+ return this.matching("SELECT id FROM sessions WHERE pi_session_id = ? ORDER BY rowid", [piSessionId], (entry) => entry.piSessionId === piSessionId)[0];
667
761
  }
668
762
  childIds() {
669
- return [...this.load()]
670
- .filter(([, entry]) => entry.task !== undefined && entry.parentID !== undefined)
671
- .map(([id]) => id);
763
+ return this.matching("SELECT id FROM sessions WHERE has_task = 1 AND parent_id IS NOT NULL ORDER BY rowid", [], (entry) => entry.task !== undefined && entry.parentID !== undefined);
764
+ }
765
+ matching(query, parameters, matches) {
766
+ const db = this.database();
767
+ if (db !== undefined)
768
+ this.refresh(db);
769
+ const ids = new Set();
770
+ for (const row of db?.prepare(query).all(...parameters) ?? [])
771
+ if (typeof row.id === "string")
772
+ ids.add(row.id);
773
+ if (this.batchState === undefined)
774
+ return [...ids];
775
+ for (const id of this.batchState?.entries.keys() ?? [])
776
+ ids.add(id);
777
+ return [...ids].filter((id) => {
778
+ const entry = this.get(id);
779
+ return entry !== undefined && matches(entry);
780
+ });
672
781
  }
673
782
  set(ocSessionId, entry) {
674
783
  const saved = this.batchState === undefined ? entry : structuredClone(entry);
675
- this.mutate((entries) => {
676
- entries.set(ocSessionId, this.batchState === undefined ? saved : structuredClone(saved));
677
- return true;
678
- });
784
+ this.mutate(ocSessionId, () => (this.batchState === undefined ? saved : structuredClone(saved)));
679
785
  }
680
786
  /** Merge a partial update (e.g. title override, updated timestamp). */
681
787
  update(ocSessionId, patch) {
682
788
  const saved = this.batchState === undefined ? patch : structuredClone(patch);
683
- this.mutate((entries) => {
684
- const previous = entries.get(ocSessionId);
685
- if (previous === undefined)
686
- return false;
687
- entries.set(ocSessionId, {
789
+ this.mutate(ocSessionId, (previous) => previous === undefined
790
+ ? undefined
791
+ : {
688
792
  ...previous,
689
793
  ...(this.batchState === undefined ? saved : structuredClone(saved)),
690
794
  });
691
- return true;
692
- });
693
795
  }
694
796
  remove(ocSessionId) {
695
- this.mutate((entries) => entries.delete(ocSessionId));
797
+ this.mutate(ocSessionId, () => undefined);
696
798
  }
697
799
  /** Every directory the registry knows about — used to resolve bare session ids. */
698
800
  directories() {
699
- return [...new Set([...this.load().values()].map((e) => e.directory))];
801
+ const db = this.database();
802
+ const directories = new Set();
803
+ for (const row of db?.prepare("SELECT DISTINCT directory FROM sessions").all() ?? [])
804
+ if (typeof row.directory === "string")
805
+ directories.add(row.directory);
806
+ if (this.batchState === undefined)
807
+ return [...directories];
808
+ const ids = this.matching("SELECT id FROM sessions", [], () => true);
809
+ return [...new Set(ids.map((id) => this.get(id).directory))];
700
810
  }
701
811
  }
@@ -332,8 +332,8 @@ function entryForMessage(s, tree, messageID) {
332
332
  }
333
333
  /**
334
334
  * The last entry of an assistant reply: the reply, or the latest result of its own tool calls.
335
- * OpenChamber 2.x /btw names a final reply (no tool calls); 1.24's /btw can name any completed
336
- * step, whose tool results belong to the same OpenChamber message.
335
+ * A final reply has no tool calls. When an API caller selects a tool-bearing
336
+ * reply, its completed tool results belong to the same message.
337
337
  */
338
338
  function replyEnd(tree, reply, leafId) {
339
339
  const content = reply.message?.content;
package/dist/sse.d.ts CHANGED
@@ -3,9 +3,7 @@ import type { ServerResponse } from "node:http";
3
3
  import type { OCEvent } from "./types.js";
4
4
  import type { TodoUpdated } from "./todo-bridge.js";
5
5
  export type SSESubscriber = {
6
- flavor: "v1" | "v2";
7
6
  directory?: string;
8
- global: boolean;
9
7
  };
10
8
  /** The session an event belongs to, when it names one. */
11
9
  export declare function eventSessionID(event: OCEvent | TodoUpdated): string | undefined;
package/dist/sse.js CHANGED
@@ -48,9 +48,7 @@ export class SSE {
48
48
  : event.type === "todo.updated"
49
49
  ? [{ id: `evt_${randomUUID()}`, type: event.type, location: { directory }, data: event.properties }]
50
50
  : this.v2events.translate(directory, event);
51
- const records = v2Frames.length === 0
52
- ? [this.record(directory, { v1: event })]
53
- : v2Frames.map((v2, index) => this.record(directory, index === 0 ? { v1: event, v2 } : { v2 }));
51
+ const records = v2Frames.map((v2) => this.record(directory, { v2 }));
54
52
  for (const record of records)
55
53
  this.retain(record);
56
54
  for (const [res, subscriber] of this.clients) {
@@ -59,7 +57,7 @@ export class SSE {
59
57
  continue;
60
58
  }
61
59
  for (const record of records) {
62
- if (!subscriber.global && subscriber.directory !== directory)
60
+ if (subscriber.directory !== undefined && subscriber.directory !== directory)
63
61
  continue;
64
62
  if (!this.writeRecord(res, subscriber, record))
65
63
  break;
@@ -102,22 +100,14 @@ export class SSE {
102
100
  this.clients.delete(res);
103
101
  });
104
102
  // OpenCode emits server.connected on subscribe; OpenChamber waits for it.
105
- const connected = subscriber.flavor === "v2"
106
- ? {
107
- id: `evt_${Date.now()}`,
108
- created: Date.now(),
109
- type: "server.connected",
110
- location: { directory: subscriber.directory ?? this.options.defaultDirectory },
111
- data: {},
112
- }
113
- : { type: "server.connected", properties: {} };
114
- const initial = subscriber.flavor === "v1" && subscriber.global
115
- ? {
116
- directory: subscriber.directory ?? this.options.defaultDirectory,
117
- payload: { id: `evt_${randomUUID()}`, ...connected },
118
- }
119
- : connected;
120
- write(res, `data: ${JSON.stringify(initial)}\n\n`);
103
+ const connected = {
104
+ id: `evt_${Date.now()}`,
105
+ created: Date.now(),
106
+ type: "server.connected",
107
+ location: { directory: subscriber.directory ?? this.options.defaultDirectory },
108
+ data: {},
109
+ };
110
+ write(res, `data: ${JSON.stringify(connected)}\n\n`);
121
111
  const cursor = Number(lastEventID);
122
112
  if (!Number.isSafeInteger(cursor) || cursor < 0)
123
113
  return;
@@ -135,35 +125,12 @@ export class SSE {
135
125
  this.replay.shift();
136
126
  }
137
127
  writeRecord(res, subscriber, record) {
138
- if (!subscriber.global && subscriber.directory !== record.directory)
128
+ if (subscriber.directory !== undefined && subscriber.directory !== record.directory)
139
129
  return true;
140
- const data = subscriber.flavor === "v2"
141
- ? record.v2
142
- : record.v1 === undefined
143
- ? undefined
144
- : subscriber.global
145
- ? {
146
- directory: record.directory,
147
- project: record.directory,
148
- payload: {
149
- id: `evt_${record.id}`,
150
- ...record.v1,
151
- properties: {
152
- ...record.v1.properties,
153
- ...(record.v1.type === "session.created" ||
154
- record.v1.type === "session.updated" ||
155
- record.v1.type === "session.deleted"
156
- ? { sessionID: record.v1.properties.info.id }
157
- : {}),
158
- },
159
- },
160
- }
161
- : record.v1;
130
+ const data = record.v2;
162
131
  if (data === undefined)
163
132
  return true;
164
- const frame = subscriber.flavor === "v1" && !subscriber.global
165
- ? `data: ${JSON.stringify(data)}\n\n`
166
- : `id: ${record.id}\ndata: ${JSON.stringify(data)}\n\n`;
133
+ const frame = `id: ${record.id}\ndata: ${JSON.stringify(data)}\n\n`;
167
134
  return this.writeSSE(res, frame);
168
135
  }
169
136
  }
package/dist/tool-map.js CHANGED
@@ -257,9 +257,7 @@ export function nestedToolParts(parent, details, sessions = {}) {
257
257
  messageID: parent.messageID,
258
258
  type: "tool",
259
259
  callID: `nested:${callID}`,
260
- // OpenChamber 1.24 never styles a task card as failed or shows its error (ToolPart.tsx isTaskTool),
261
- // and a failed spawn has no session to open, so it goes out as a plain card that does both.
262
- tool: failed && mapped.tool === "task" ? "agent" : mapped.tool,
260
+ tool: mapped.tool,
263
261
  state: !failed
264
262
  ? { ...state, status: "completed" }
265
263
  : {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Background work of one session for the OmO Work status section (extensions/omo-work):
2
+ * Background work of one session for the OmO Work status section (extensions/antechamber):
3
3
  * todo phases, terminal monitors, detached eval cells, background terminal sessions and the
4
4
  * goal continuation timer. OpenChamber 2.x has no native surface for any of these.
5
5
  *