antechamber 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +17 -14
  3. package/bin/opencode-antechamber +10 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +21 -3
  18. package/dist/pi-event-translation.js +317 -65
  19. package/dist/pi-rpc.d.ts +26 -2
  20. package/dist/pi-rpc.js +102 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +110 -65
  26. package/dist/routes-v2.js +376 -73
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +20 -15
  30. package/dist/session-commands.js +61 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +113 -42
  34. package/dist/session-hosts.js +2 -2
  35. package/dist/session-store.d.ts +8 -4
  36. package/dist/session-store.js +183 -33
  37. package/dist/session-tree.d.ts +9 -2
  38. package/dist/session-tree.js +51 -63
  39. package/dist/sessions.d.ts +34 -6
  40. package/dist/sessions.js +340 -146
  41. package/dist/sse.d.ts +16 -6
  42. package/dist/sse.js +77 -21
  43. package/dist/subagents.d.ts +5 -0
  44. package/dist/subagents.js +86 -69
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +164 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +141 -50
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/extensions/omo-dag/README.md +2 -0
  52. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  53. package/extensions/omo-dag/panel/index.html +1 -1
  54. package/extensions/omo-dag/panel/main.js +60 -60
  55. package/extensions/omo-dag/service/main.js +72 -8
  56. package/package.json +1 -1
package/dist/sse.d.ts CHANGED
@@ -2,18 +2,28 @@
2
2
  import type { ServerResponse } from "node:http";
3
3
  import type { OCEvent } from "./types.js";
4
4
  import type { TodoUpdated } from "./todo-bridge.js";
5
+ export type SSESubscriber = {
6
+ flavor: "v1" | "v2";
7
+ directory?: string;
8
+ global: boolean;
9
+ };
5
10
  export declare class SSE {
6
11
  private options;
7
- /** SSE subscribers tagged by protocol flavor: v1 (`/event`) or v2 (`/api/event`). */
8
- readonly clients: Map<ServerResponse, "v1" | "v2">;
12
+ /** SSE subscribers tagged by protocol flavor and explicit directory scope. */
13
+ readonly clients: Map<ServerResponse, SSESubscriber>;
9
14
  private v2events;
15
+ private nextEventID;
16
+ private replay;
10
17
  constructor(options: {
11
18
  defaultDirectory: string;
12
19
  },
13
- /** SSE subscribers tagged by protocol flavor: v1 (`/event`) or v2 (`/api/event`). */
14
- clients: Map<ServerResponse, "v1" | "v2">);
20
+ /** SSE subscribers tagged by protocol flavor and explicit directory scope. */
21
+ clients: Map<ServerResponse, SSESubscriber>);
15
22
  close(): void;
16
- broadcast(_directory: string, event: OCEvent | TodoUpdated): void;
23
+ broadcast(directory: string, event: OCEvent | TodoUpdated): void;
17
24
  writeSSE(res: ServerResponse, frame: string): boolean;
18
- handleSSE(res: ServerResponse, flavor: "v1" | "v2", write: (res: ServerResponse, frame: string) => boolean): void;
25
+ handleSSE(res: ServerResponse, subscriber: SSESubscriber, lastEventID: string | undefined, write: (res: ServerResponse, frame: string) => boolean): void;
26
+ private record;
27
+ private retain;
28
+ private writeRecord;
19
29
  }
package/dist/sse.js CHANGED
@@ -4,12 +4,15 @@ import { errorBody, json } from "./http.js";
4
4
  import { V2EventTranslator } from "./v2.js";
5
5
  const MAX_SSE_SUBSCRIBERS = 64;
6
6
  const MAX_SSE_PENDING_BYTES = 8 * 1024 * 1024;
7
+ const MAX_SSE_REPLAY_RECORDS = 1024;
7
8
  export class SSE {
8
9
  options;
9
10
  clients;
10
11
  v2events = new V2EventTranslator();
12
+ nextEventID = 0;
13
+ replay = [];
11
14
  constructor(options,
12
- /** SSE subscribers tagged by protocol flavor: v1 (`/event`) or v2 (`/api/event`). */
15
+ /** SSE subscribers tagged by protocol flavor and explicit directory scope. */
13
16
  clients) {
14
17
  this.options = options;
15
18
  this.clients = clients;
@@ -20,25 +23,25 @@ export class SSE {
20
23
  res.end();
21
24
  }
22
25
  }
23
- broadcast(_directory, event) {
24
- const v1Frame = `data: ${JSON.stringify(event)}\n\n`;
25
- const frames = event.type === "todo.updated"
26
- ? [{ id: `evt_${randomUUID()}`, type: event.type, location: { directory: _directory }, data: event.properties }]
27
- : this.v2events.translate(_directory, event);
28
- const v2Frames = frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`);
29
- for (const [res, flavor] of this.clients) {
26
+ broadcast(directory, event) {
27
+ const v2Frames = event.type === "todo.updated"
28
+ ? [{ id: `evt_${randomUUID()}`, type: event.type, location: { directory }, data: event.properties }]
29
+ : this.v2events.translate(directory, event);
30
+ const records = v2Frames.length === 0
31
+ ? [this.record(directory, { v1: event })]
32
+ : v2Frames.map((v2, index) => this.record(directory, index === 0 ? { v1: event, v2 } : { v2 }));
33
+ for (const record of records)
34
+ this.retain(record);
35
+ for (const [res, subscriber] of this.clients) {
30
36
  if (res.writableEnded || res.destroyed) {
31
37
  this.clients.delete(res);
32
38
  continue;
33
39
  }
34
- if (flavor === "v2") {
35
- for (const frame of v2Frames) {
36
- if (!this.writeSSE(res, frame))
37
- break;
38
- }
39
- }
40
- else {
41
- this.writeSSE(res, v1Frame);
40
+ for (const record of records) {
41
+ if (!subscriber.global && subscriber.directory !== directory)
42
+ continue;
43
+ if (!this.writeRecord(res, subscriber, record))
44
+ break;
42
45
  }
43
46
  }
44
47
  }
@@ -56,7 +59,7 @@ export class SSE {
56
59
  res.destroy();
57
60
  return false;
58
61
  }
59
- handleSSE(res, flavor, write) {
62
+ handleSSE(res, subscriber, lastEventID, write) {
60
63
  if (this.clients.size >= MAX_SSE_SUBSCRIBERS) {
61
64
  json(res, 503, errorBody("ServiceUnavailableError", "Too many SSE subscribers"));
62
65
  return;
@@ -66,7 +69,7 @@ export class SSE {
66
69
  "Cache-Control": "no-cache",
67
70
  Connection: "keep-alive",
68
71
  });
69
- this.clients.set(res, flavor);
72
+ this.clients.set(res, subscriber);
70
73
  // OpenChamber's SSE proxy aborts an upstream that is silent for 20s, so
71
74
  // mirror real opencode's `: heartbeat` comment keepalive.
72
75
  const heartbeat = setInterval(() => {
@@ -78,15 +81,68 @@ export class SSE {
78
81
  this.clients.delete(res);
79
82
  });
80
83
  // OpenCode emits server.connected on subscribe; OpenChamber waits for it.
81
- const connected = flavor === "v2"
84
+ const connected = subscriber.flavor === "v2"
82
85
  ? {
83
86
  id: `evt_${Date.now()}`,
84
87
  created: Date.now(),
85
88
  type: "server.connected",
86
- location: { directory: this.options.defaultDirectory },
89
+ location: { directory: subscriber.directory ?? this.options.defaultDirectory },
87
90
  data: {},
88
91
  }
89
92
  : { type: "server.connected", properties: {} };
90
- write(res, `data: ${JSON.stringify(connected)}\n\n`);
93
+ const initial = subscriber.flavor === "v1" && subscriber.global
94
+ ? {
95
+ directory: subscriber.directory ?? this.options.defaultDirectory,
96
+ payload: { id: `evt_${randomUUID()}`, ...connected },
97
+ }
98
+ : connected;
99
+ write(res, `data: ${JSON.stringify(initial)}\n\n`);
100
+ const cursor = Number(lastEventID);
101
+ if (!Number.isSafeInteger(cursor) || cursor < 0)
102
+ return;
103
+ for (const record of this.replay) {
104
+ if (record.id > cursor && !this.writeRecord(res, subscriber, record))
105
+ break;
106
+ }
107
+ }
108
+ record(directory, frames) {
109
+ return { id: ++this.nextEventID, directory, ...frames };
110
+ }
111
+ retain(record) {
112
+ this.replay.push(record);
113
+ if (this.replay.length > MAX_SSE_REPLAY_RECORDS)
114
+ this.replay.shift();
115
+ }
116
+ writeRecord(res, subscriber, record) {
117
+ if (!subscriber.global && subscriber.directory !== record.directory)
118
+ return true;
119
+ const data = subscriber.flavor === "v2"
120
+ ? record.v2
121
+ : record.v1 === undefined
122
+ ? undefined
123
+ : subscriber.global
124
+ ? {
125
+ directory: record.directory,
126
+ project: record.directory,
127
+ payload: {
128
+ id: `evt_${record.id}`,
129
+ ...record.v1,
130
+ properties: {
131
+ ...record.v1.properties,
132
+ ...(record.v1.type === "session.created" ||
133
+ record.v1.type === "session.updated" ||
134
+ record.v1.type === "session.deleted"
135
+ ? { sessionID: record.v1.properties.info.id }
136
+ : {}),
137
+ },
138
+ },
139
+ }
140
+ : record.v1;
141
+ if (data === undefined)
142
+ return true;
143
+ const frame = subscriber.flavor === "v1" && !subscriber.global
144
+ ? `data: ${JSON.stringify(data)}\n\n`
145
+ : `id: ${record.id}\ndata: ${JSON.stringify(data)}\n\n`;
146
+ return this.writeSSE(res, frame);
91
147
  }
92
148
  }
@@ -46,11 +46,16 @@ export declare class SubagentBridge {
46
46
  private discoverParts;
47
47
  private create;
48
48
  restore(id: string): ManagedSession | undefined;
49
+ private managedSession;
49
50
  restoreAll(): void;
50
51
  owns(id: string): boolean;
51
52
  messages(id: string): OCMessageWithParts[];
52
53
  private watch;
53
54
  private refresh;
55
+ private reconcileTaskRecord;
56
+ private hydrateTranscript;
57
+ private reconcileRuntime;
58
+ private publishRefresh;
54
59
  private saveArchive;
55
60
  private fallback;
56
61
  private persist;
package/dist/subagents.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, watch, writeFileSync, } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { piAgentDir } from "./agent-dir.js";
5
+ import { syncSessionCost } from "./pi-event-translation.js";
5
6
  import { ocIdFor, SessionPersistenceError } from "./session-store.js";
6
7
  import { mapTool, resultText, toolMappers } from "./tool-map.js";
7
8
  import { ADAPTER_VERSION } from "./version.js";
@@ -294,26 +295,15 @@ export class SubagentBridge {
294
295
  }
295
296
  }
296
297
  create(parent, task) {
297
- const session = {
298
- info: {
299
- id: ocIdFor(task.id),
300
- parentID: parent.info.id,
301
- projectID: parent.info.projectID,
302
- directory: parent.info.directory,
303
- title: typeof task.input.description === "string" ? task.input.description : task.id,
304
- version: parent.info.version,
305
- time: { ...parent.info.time },
306
- },
307
- pi: undefined,
308
- messages: undefined,
309
- model: undefined,
310
- streaming: undefined,
311
- status: { type: "idle" },
312
- toolByCallId: new Map(),
313
- tree: { messageEntries: {} },
314
- hasPendingInteraction: () => false,
315
- hasRunningChildren: () => false,
316
- };
298
+ const session = this.managedSession({
299
+ id: ocIdFor(task.id),
300
+ parentID: parent.info.id,
301
+ projectID: parent.info.projectID,
302
+ directory: parent.info.directory,
303
+ title: typeof task.input.description === "string" ? task.input.description : task.id,
304
+ version: parent.info.version,
305
+ time: { ...parent.info.time },
306
+ });
317
307
  const child = {
318
308
  session,
319
309
  task,
@@ -338,15 +328,21 @@ export class SubagentBridge {
338
328
  const entry = this.store.get(id);
339
329
  if (!entry?.task || !entry.parentID)
340
330
  return undefined;
341
- const parent = {
342
- info: {
343
- id: entry.parentID,
344
- projectID: entry.directory,
345
- directory: entry.directory,
346
- title: "",
347
- version: ADAPTER_VERSION,
348
- time: { created: entry.created, updated: entry.updated },
349
- },
331
+ const parent = this.managedSession({
332
+ id: entry.parentID,
333
+ projectID: entry.directory,
334
+ directory: entry.directory,
335
+ title: "",
336
+ version: ADAPTER_VERSION,
337
+ time: { created: entry.created, updated: entry.updated },
338
+ });
339
+ const child = this.create(parent, entry.task);
340
+ child.session.info.title = entry.title ?? child.session.info.title;
341
+ return child.session;
342
+ }
343
+ managedSession(info) {
344
+ return {
345
+ info,
350
346
  pi: undefined,
351
347
  messages: undefined,
352
348
  model: undefined,
@@ -357,9 +353,6 @@ export class SubagentBridge {
357
353
  hasPendingInteraction: () => false,
358
354
  hasRunningChildren: () => false,
359
355
  };
360
- const child = this.create(parent, entry.task);
361
- child.session.info.title = entry.title ?? child.session.info.title;
362
- return child.session;
363
356
  }
364
357
  restoreAll() {
365
358
  for (const id of this.store.childIds())
@@ -395,44 +388,63 @@ export class SubagentBridge {
395
388
  }
396
389
  refresh(child, publish, useRecord = true) {
397
390
  const archive = this.archive(child.task.id);
391
+ const found = this.reconcileTaskRecord(child, archive, useRecord);
392
+ const running = active(child.task.details.status) && (found !== undefined || child.live);
393
+ const state = {
394
+ previousMessages: child.session.messages ?? [],
395
+ previousStatus: child.session.status.type,
396
+ running,
397
+ costChanged: this.hydrateTranscript(child, archive, running, child.session.status.type),
398
+ updatedParts: this.reconcileRuntime(child, running),
399
+ };
400
+ if (state.costChanged)
401
+ this.emit(child.session.info.directory, { type: "session.updated", properties: { info: child.session.info } });
402
+ if (publish)
403
+ for (const part of state.updatedParts)
404
+ this.emitPart(child.session, part);
405
+ this.persist(child);
406
+ this.publishRefresh(child, state, publish);
407
+ }
408
+ reconcileTaskRecord(child, archive, useRecord) {
398
409
  const found = findTask(child.task.id, child.task.parentPiId);
399
- if (found) {
400
- child.recordPath = found.path;
401
- const updated = typeof found.data.updated_at === "string" ? Date.parse(found.data.updated_at) : NaN;
402
- const retainedTerminal = Math.max(child.task.terminalAt ?? 0, existsSync(archive) ? statSync(archive).mtimeMs : 0);
403
- const staleRunning = active(found.data.status) && terminal(child.task.details.status) && updated <= retainedTerminal;
404
- if (useRecord && !staleRunning)
405
- child.task.details = { ...child.task.details, ...found.data };
406
- const host = found.data.host_session;
407
- if (record(host) && typeof host.session_path === "string")
408
- child.livePath = host.session_path;
409
- else if (typeof found.data.child_session_id === "string" && found.data.child_session_id !== "") {
410
- // In-process agents persist under the task store but have no RPC host_session.
411
- // Match the recorded session, not another retained run in the same directory.
412
- const sessions = join(dirname(dirname(found.path)), "children", child.task.id, "sessions", child.task.id);
413
- try {
414
- const file = readdirSync(sessions, { withFileTypes: true }).find((entry) => entry.isFile() && entry.name.endsWith(`_${found.data.child_session_id}.jsonl`));
415
- child.livePath = file ? join(sessions, file.name) : undefined;
416
- }
417
- catch (error) {
418
- if (!(error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR", "EACCES"].includes(String(error.code))))
419
- throw error;
420
- child.livePath = undefined;
421
- }
422
- }
410
+ if (!found)
411
+ return undefined;
412
+ child.recordPath = found.path;
413
+ const updated = typeof found.data.updated_at === "string" ? Date.parse(found.data.updated_at) : NaN;
414
+ const retainedTerminal = Math.max(child.task.terminalAt ?? 0, existsSync(archive) ? statSync(archive).mtimeMs : 0);
415
+ const staleRunning = active(found.data.status) && terminal(child.task.details.status) && updated <= retainedTerminal;
416
+ if (useRecord && !staleRunning)
417
+ child.task.details = { ...child.task.details, ...found.data };
418
+ const host = found.data.host_session;
419
+ if (record(host) && typeof host.session_path === "string") {
420
+ child.livePath = host.session_path;
421
+ return found;
423
422
  }
424
- const running = active(child.task.details.status) && (found !== undefined || child.live);
425
- const previousMessages = child.session.messages ?? [];
426
- const previousStatus = child.session.status.type;
423
+ if (typeof found.data.child_session_id !== "string" || found.data.child_session_id === "")
424
+ return found;
425
+ // In-process agents persist under the task store but have no RPC host_session.
426
+ // Match the recorded session, not another retained run in the same directory.
427
+ const sessions = join(dirname(dirname(found.path)), "children", child.task.id, "sessions", child.task.id);
428
+ try {
429
+ const file = readdirSync(sessions, { withFileTypes: true }).find((entry) => entry.isFile() && entry.name.endsWith(`_${found.data.child_session_id}.jsonl`));
430
+ child.livePath = file ? join(sessions, file.name) : undefined;
431
+ }
432
+ catch (error) {
433
+ if (!(error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR", "EACCES"].includes(String(error.code))))
434
+ throw error;
435
+ child.livePath = undefined;
436
+ }
437
+ return found;
438
+ }
439
+ hydrateTranscript(child, archive, running, previousStatus) {
427
440
  // A resumed live task can be newer than its archive; completed history prefers the archive.
428
441
  const liveRun = running || previousStatus === "busy";
429
442
  let raw = liveRun ? undefined : optionalFile(archive);
430
443
  if (running && child.recordPath)
431
444
  this.watch(child, child.recordPath);
432
445
  if (raw === undefined && child.livePath !== undefined) {
433
- if (running) {
446
+ if (running)
434
447
  this.watch(child, child.livePath);
435
- }
436
448
  child.tail.read(child.livePath);
437
449
  if (child.tail.text !== "")
438
450
  raw = child.tail.text;
@@ -457,34 +469,39 @@ export class SubagentBridge {
457
469
  child.session.messages = this.fallback(child);
458
470
  }
459
471
  child.loaded = true;
472
+ // Each child reports only its own spend; OpenChamber sums the subtree by parentID.
473
+ return syncSessionCost(child.session);
474
+ }
475
+ reconcileRuntime(child, running) {
460
476
  child.session.status = { type: running ? "busy" : "idle" };
461
477
  if (!running)
462
478
  this.closeWatchers(child);
463
- for (const part of child.parts.values()) {
479
+ const parts = [...child.parts.values()];
480
+ for (const part of parts) {
464
481
  updateTaskParts([part], {
465
482
  details: child.task.details,
466
483
  content: [{ type: "text", text: child.task.details.final_response ?? "" }],
467
484
  }, !running, Date.now());
468
- if (publish)
469
- this.emitPart(child.session, part);
470
485
  }
471
- this.persist(child);
486
+ return parts;
487
+ }
488
+ publishRefresh(child, state, publish) {
472
489
  if (!publish)
473
490
  return;
474
- const before = new Map(previousMessages.map((message) => [message.info.id, JSON.stringify(message)]));
475
- for (const message of child.session.messages) {
491
+ const before = new Map(state.previousMessages.map((message) => [message.info.id, JSON.stringify(message)]));
492
+ for (const message of child.session.messages ?? []) {
476
493
  if (before.get(message.info.id) === JSON.stringify(message))
477
494
  continue;
478
495
  this.emit(child.session.info.directory, { type: "message.updated", properties: { info: message.info } });
479
496
  for (const part of message.parts)
480
497
  this.emitPart(child.session, part);
481
498
  }
482
- if (previousStatus !== child.session.status.type) {
499
+ if (state.previousStatus !== child.session.status.type) {
483
500
  this.emit(child.session.info.directory, {
484
501
  type: "session.status",
485
502
  properties: { sessionID: child.session.info.id, status: child.session.status },
486
503
  });
487
- if (!running)
504
+ if (!state.running)
488
505
  this.emit(child.session.info.directory, {
489
506
  type: "session.idle",
490
507
  properties: { sessionID: child.session.info.id },
@@ -1,6 +1,6 @@
1
1
  import type { OCToolPart } from "./types.js";
2
2
  /** OmO arguments translated to OpenChamber's renderer contracts. */
3
- export interface MappedTool {
3
+ interface MappedTool {
4
4
  tool: string;
5
5
  input: Record<string, unknown>;
6
6
  title: string;
@@ -12,7 +12,7 @@ export interface ToolResult {
12
12
  details?: unknown;
13
13
  isError?: boolean;
14
14
  }
15
- export type ToolMapper = (args: Record<string, unknown>, result?: ToolResult) => MappedTool;
15
+ type ToolMapper = (args: Record<string, unknown>, result?: ToolResult) => MappedTool;
16
16
  /** Mutable registry: todos 6/7 can assign handlers for `todo` and `task`. */
17
17
  export declare const toolMappers: Record<string, ToolMapper>;
18
18
  /** Text blocks only; images and structured details are not tool output text. */
@@ -30,3 +30,4 @@ export declare function providerToolParts(content: unknown, sessionID: string, m
30
30
  export declare function applyProviderToolResults(content: unknown, parts: Iterable<OCToolPart>, timestamp: number): OCToolPart[];
31
31
  /** Only an actual result (or a self-contained provider status) means success. */
32
32
  export declare function finishProviderToolParts(parts: Iterable<OCToolPart>, timestamp: number, error?: string): OCToolPart[];
33
+ export {};