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
@@ -1,5 +1,5 @@
1
1
  /** Pi message/part translation and the live event bridge, with explicit manager services. */
2
- import type { OCAssistantMessage, OCMessageWithParts, OCEvent, OCSessionStatus, OCPart } from "./types.js";
2
+ import type { OCAssistantMessage, OCMessageWithParts, OCModelRef, OCEvent, OCSessionStatus, OCPart } from "./types.js";
3
3
  import type { ManagedSession } from "./sessions.js";
4
4
  import type { SessionStore } from "./session-store.js";
5
5
  import type { SubagentBridge } from "./subagents.js";
@@ -22,8 +22,19 @@ export declare function id(prefix: string): string;
22
22
  export declare function piModelId(responseModel: unknown, model: unknown): unknown;
23
23
  export declare function piStopReason(stopReason: string | undefined, errorMessage?: string): Pick<OCAssistantMessage, "finish" | "error">;
24
24
  export declare function textOf(content: Array<Record<string, unknown>> | undefined): string;
25
- export declare function newPart(kind: "text" | "reasoning", sessionID: string, messageID: string): OCPart;
26
- export declare function applyUsage(msg: OCAssistantMessage, usage: PiUsage | undefined): void;
25
+ /**
26
+ * Recompute the session's own spend from its loaded assistant messages. Subagent
27
+ * spend stays on the child sessions: OpenChamber sums the parentID subtree, so
28
+ * folding it in here would count it twice. Returns whether the value changed.
29
+ */
30
+ export declare function syncSessionCost(s: ManagedSession): boolean;
31
+ /**
32
+ * An OmO compaction as OpenCode shows one: a user turn holding a `compaction`
33
+ * part (OpenChamber renders it as `/compact`) answered by a `summary` message.
34
+ * IDs derive from the native compaction entry, not its retained boundary:
35
+ * multiple compactions can retain the same entry.
36
+ */
37
+ export declare function compactionMessages(s: ManagedSession, key: string, summary: string, created: number, model: OCModelRef | undefined): [OCMessageWithParts, OCMessageWithParts];
27
38
  interface PiEventServices {
28
39
  readonly emit: (directory: string, event: OCEvent | TodoUpdated) => void;
29
40
  readonly subagents: SubagentBridge;
@@ -39,11 +50,18 @@ interface PiEventServices {
39
50
  export declare class PiEventTranslator {
40
51
  private services;
41
52
  constructor(services: PiEventServices);
53
+ /** Close dead-runtime UI state before an error or idle event can reach the client. */
54
+ finishInterruptedRun(s: ManagedSession, error: NonNullable<OCAssistantMessage["error"]>): void;
42
55
  setStatus(s: ManagedSession, status: OCSessionStatus): void;
43
56
  emitPart(s: ManagedSession, part: OCPart, delta?: string): void;
44
57
  deliverInput(s: ManagedSession, messageID: string): void;
45
58
  private reconcileUser;
46
59
  onPiEvent(s: ManagedSession, ev: Record<string, unknown>): void;
60
+ private onTodoEntry;
61
+ private onLifecycleEvent;
62
+ private onToolEvent;
63
+ private onMessageEvent;
64
+ private onCompactionEvent;
47
65
  private finishStreaming;
48
66
  private onMessageUpdate;
49
67
  private onProviderTools;
@@ -2,7 +2,7 @@
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultMetadata, resultText, } from "./tool-map.js";
4
4
  import { TodoBridge } from "./todo-bridge.js";
5
- import { recordTurn } from "./session-tree.js";
5
+ import { readSessionTree, recordTurn } from "./session-tree.js";
6
6
  import { touchedFiles } from "./session-diff.js";
7
7
  import { getAgentName } from "./agent-name.js";
8
8
  import { debugLog } from "./misc-routes.js";
@@ -42,7 +42,7 @@ export function textOf(content) {
42
42
  return "";
43
43
  return content.map((b) => (b?.type === "text" && typeof b.text === "string" ? b.text : "")).join("");
44
44
  }
45
- export function newPart(kind, sessionID, messageID) {
45
+ function newPart(kind, sessionID, messageID) {
46
46
  const base = {
47
47
  id: id("prt"),
48
48
  sessionID,
@@ -54,7 +54,7 @@ export function newPart(kind, sessionID, messageID) {
54
54
  ? { ...base, type: "text" }
55
55
  : { ...base, type: "reasoning" };
56
56
  }
57
- export function applyUsage(msg, usage) {
57
+ function applyUsage(msg, usage) {
58
58
  if (!usage)
59
59
  return;
60
60
  msg.tokens = {
@@ -66,12 +66,109 @@ export function applyUsage(msg, usage) {
66
66
  if (usage.cost?.total !== undefined)
67
67
  msg.cost = usage.cost.total;
68
68
  }
69
+ /**
70
+ * Recompute the session's own spend from its loaded assistant messages. Subagent
71
+ * spend stays on the child sessions: OpenChamber sums the parentID subtree, so
72
+ * folding it in here would count it twice. Returns whether the value changed.
73
+ */
74
+ export function syncSessionCost(s) {
75
+ if (s.messages === undefined)
76
+ return false;
77
+ let cost = 0;
78
+ const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
79
+ for (const message of s.messages)
80
+ if (message.info.role === "assistant") {
81
+ cost += message.info.cost;
82
+ tokens.input += message.info.tokens.input;
83
+ tokens.output += message.info.tokens.output;
84
+ tokens.reasoning += message.info.tokens.reasoning;
85
+ tokens.cache.read += message.info.tokens.cache.read;
86
+ tokens.cache.write += message.info.tokens.cache.write;
87
+ }
88
+ s.info.tokens = tokens;
89
+ if ((s.info.cost ?? 0) === cost)
90
+ return false; // absent means zero; avoid no-op updates
91
+ s.info.cost = cost;
92
+ return true;
93
+ }
94
+ /**
95
+ * An OmO compaction as OpenCode shows one: a user turn holding a `compaction`
96
+ * part (OpenChamber renders it as `/compact`) answered by a `summary` message.
97
+ * IDs derive from the native compaction entry, not its retained boundary:
98
+ * multiple compactions can retain the same entry.
99
+ */
100
+ export function compactionMessages(s, key, summary, created, model) {
101
+ const sessionID = s.info.id;
102
+ const userID = `msg_compaction_${key}`;
103
+ const summaryID = `${userID}_summary`;
104
+ const served = model ?? { providerID: "unknown", modelID: "unknown" };
105
+ return [
106
+ {
107
+ info: { id: userID, sessionID, role: "user", time: { created }, agent: getAgentName(), model: served },
108
+ parts: [{ id: `prt_compaction_${key}`, sessionID, messageID: userID, type: "compaction", auto: false }],
109
+ },
110
+ {
111
+ info: {
112
+ id: summaryID,
113
+ sessionID,
114
+ role: "assistant",
115
+ time: { created, completed: created },
116
+ parentID: userID,
117
+ modelID: served.modelID,
118
+ providerID: served.providerID,
119
+ mode: getAgentName(),
120
+ path: { cwd: s.info.directory, root: s.info.directory },
121
+ cost: 0,
122
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
123
+ finish: "stop",
124
+ summary: true,
125
+ },
126
+ parts: [
127
+ {
128
+ id: `prt_compaction_${key}_summary`,
129
+ sessionID,
130
+ messageID: summaryID,
131
+ type: "text",
132
+ text: summary,
133
+ time: { start: created, end: created },
134
+ },
135
+ ],
136
+ },
137
+ ];
138
+ }
69
139
  /** Translates per-session events; registry and lifecycle operations stay with SessionManager. */
70
140
  export class PiEventTranslator {
71
141
  services;
72
142
  constructor(services) {
73
143
  this.services = services;
74
144
  }
145
+ /** Close dead-runtime UI state before an error or idle event can reach the client. */
146
+ finishInterruptedRun(s, error) {
147
+ const now = Date.now();
148
+ s.approvals?.finishTurn();
149
+ for (const part of s.streaming?.parts.values() ?? []) {
150
+ if (part.type === "tool")
151
+ s.toolByCallId.set(part.callID, part);
152
+ else if ((part.type === "text" || part.type === "reasoning") &&
153
+ part.time !== undefined &&
154
+ part.time.end === undefined) {
155
+ part.time.end = now;
156
+ this.emitPart(s, part);
157
+ }
158
+ }
159
+ this.finalizeDanglingTools(s);
160
+ this.services.subagents.finishCalls(s.info.id);
161
+ if (s.streaming !== undefined) {
162
+ const message = s.streaming.message;
163
+ message.finish = "error";
164
+ message.error = error;
165
+ message.time.completed = now;
166
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: message } });
167
+ this.finishStreaming(s);
168
+ }
169
+ delete s.compactingFromIdle;
170
+ delete s.info.time.compacting;
171
+ }
75
172
  setStatus(s, status) {
76
173
  s.status = status;
77
174
  this.services.emit(s.info.directory, {
@@ -79,6 +176,14 @@ export class PiEventTranslator {
79
176
  properties: { sessionID: s.info.id, status },
80
177
  });
81
178
  if (status.type === "idle") {
179
+ s.info.time.idle = Date.now();
180
+ const last = [...(s.messages ?? [])].reverse().find((message) => message.info.role === "assistant");
181
+ s.info.outcome =
182
+ s.interruptRequested || (last?.info.role === "assistant" && last.info.error?.name === "MessageAbortedError")
183
+ ? "interrupted"
184
+ : last?.info.role === "assistant" && last.info.finish === "error"
185
+ ? "failed"
186
+ : "succeeded";
82
187
  this.services.emit(s.info.directory, { type: "session.idle", properties: { sessionID: s.info.id } });
83
188
  }
84
189
  }
@@ -143,28 +248,59 @@ export class PiEventTranslator {
143
248
  }
144
249
  onPiEvent(s, ev) {
145
250
  this.services.subagents.wake(s, ev);
146
- const dir = s.info.directory;
147
251
  if (envSetting("DEBUG")) {
148
252
  const ame = ev.assistantMessageEvent?.type;
149
253
  debugLog(`[pi-event] ${String(ev.type)}${ame ? `/${ame}` : ""} ${JSON.stringify(ev).slice(0, 300)}`);
150
254
  }
151
255
  switch (ev.type) {
152
- case "entry_appended": {
153
- s.todoBridge ??= new TodoBridge(s.info.id);
154
- const event = s.todoBridge.entry(ev.entry, (part) => this.emitPart(s, part));
155
- if (event !== undefined)
156
- this.services.emit(dir, event);
256
+ case "entry_appended":
257
+ this.onTodoEntry(s, ev);
157
258
  break;
158
- }
159
259
  case "session_info_changed":
160
260
  this.services.applyNativeTitle(s, ev.name);
161
261
  break;
162
262
  case "agent_start":
163
- this.setStatus(s, { type: "busy" });
263
+ case "agent_idle":
264
+ case "agent_end":
265
+ case "auto_retry_start":
266
+ case "turn_end":
267
+ case "extension_error":
268
+ this.onLifecycleEvent(s, ev);
269
+ break;
270
+ case "tool_execution_start":
271
+ case "tool_execution_update":
272
+ case "tool_execution_end":
273
+ this.onToolEvent(s, ev);
274
+ break;
275
+ case "message_start":
276
+ case "message_update":
277
+ case "message_end":
278
+ this.onMessageEvent(s, ev);
164
279
  break;
165
- case "agent_end": {
280
+ case "compaction_start":
281
+ case "compaction_end":
282
+ this.onCompactionEvent(s, ev);
283
+ break;
284
+ default:
285
+ break; // turn_start, queue_update — future milestones
286
+ }
287
+ }
288
+ onTodoEntry(s, ev) {
289
+ s.todoBridge ??= new TodoBridge(s.info.id);
290
+ const event = s.todoBridge.entry(ev.entry, (part) => this.emitPart(s, part));
291
+ if (event !== undefined)
292
+ this.services.emit(s.info.directory, event);
293
+ }
294
+ onLifecycleEvent(s, ev) {
295
+ switch (ev.type) {
296
+ case "agent_start":
297
+ this.setStatus(s, { type: "busy" });
298
+ return;
299
+ case "agent_end":
166
300
  if (ev.willRetry === true)
167
- break; // auto-retry keeps the session busy
301
+ return; // auto-retry keeps the session busy
302
+ if (s.interruptRequested)
303
+ this.finishInterruptedRun(s, { name: "MessageAbortedError", data: { message: "Cancelled" } });
168
304
  s.approvals?.finishTurn();
169
305
  this.finalizeDanglingTools(s);
170
306
  this.services.subagents.finishCalls(s.info.id);
@@ -172,18 +308,28 @@ export class PiEventTranslator {
172
308
  this.services.emitDiff(s);
173
309
  s.info.time.updated = Date.now();
174
310
  this.services.persistMeta(s);
175
- this.services.emit(dir, { type: "session.updated", properties: { info: s.info } });
311
+ this.services.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
176
312
  if (s.piPath !== undefined)
177
313
  this.services.store.update(s.info.id, { updated: Date.now() });
178
314
  recordTurn(s);
179
315
  this.services.persistMeta(s);
180
- break;
181
- }
316
+ return;
182
317
  case "agent_idle":
183
318
  // agent_end closes one run, not the session: OmO may still retry or
184
319
  // start a deferred continuation. Only agent_idle settles that work.
320
+ if (s.interruptRequested) {
321
+ this.finishInterruptedRun(s, { name: "MessageAbortedError", data: { message: "Cancelled" } });
322
+ this.services.emit(s.info.directory, {
323
+ type: "session.error",
324
+ properties: {
325
+ sessionID: s.info.id,
326
+ error: { name: "MessageAbortedError", data: { message: "Cancelled" } },
327
+ },
328
+ });
329
+ delete s.interruptRequested;
330
+ }
185
331
  this.setStatus(s, { type: "idle" });
186
- break;
332
+ return;
187
333
  case "turn_end": {
188
334
  s.approvals?.finishTurn();
189
335
  // pi delivers tool results here, matched back to parts by toolCallId.
@@ -191,18 +337,41 @@ export class PiEventTranslator {
191
337
  if (Array.isArray(results))
192
338
  this.applyToolResults(s, results);
193
339
  this.services.emitDiff(s);
194
- break;
340
+ return;
195
341
  }
342
+ case "auto_retry_start":
343
+ this.setStatus(s, {
344
+ type: "retry",
345
+ attempt: Number(ev.attempt ?? 1),
346
+ message: String(ev.errorMessage ?? "retrying"),
347
+ next: Date.now() + Number(ev.delayMs ?? 0),
348
+ });
349
+ return;
350
+ case "extension_error":
351
+ this.services.emit(s.info.directory, {
352
+ type: "session.error",
353
+ properties: {
354
+ sessionID: s.info.id,
355
+ error: { name: "UnknownError", data: { message: String(ev.error ?? "pi extension error") } },
356
+ },
357
+ });
358
+ return;
359
+ default:
360
+ return;
361
+ }
362
+ }
363
+ onToolEvent(s, ev) {
364
+ switch (ev.type) {
196
365
  case "tool_execution_start": {
197
366
  if (typeof ev.toolCallId === "string" && typeof ev.toolName === "string") {
198
367
  s.approvals?.noteToolStart(ev.toolCallId, ev.toolName, ev.args);
199
368
  }
200
369
  if (ev.toolName === "task")
201
- break; // Expanded when the argument stream closes.
370
+ return; // Expanded when the argument stream closes.
202
371
  const callID = typeof ev.toolCallId === "string" ? ev.toolCallId : undefined;
203
372
  const part = callID !== undefined ? s.toolByCallId.get(callID) : undefined;
204
373
  if (part === undefined)
205
- break;
374
+ return;
206
375
  const name = typeof ev.toolName === "string" ? ev.toolName : part.tool;
207
376
  const mapped = mapTool(name, name === "todo" ? part.state.input : ev.args);
208
377
  part.tool = mapped.tool;
@@ -214,31 +383,37 @@ export class PiEventTranslator {
214
383
  time: { start: part.state.status !== "pending" ? part.state.time.start : Date.now() },
215
384
  };
216
385
  this.emitPart(s, part);
217
- break;
386
+ return;
218
387
  }
219
388
  case "tool_execution_update": {
220
389
  if (typeof ev.toolCallId === "string" &&
221
390
  this.services.subagents.result(s, ev.toolCallId, ev.partialResult !== null && typeof ev.partialResult === "object" ? ev.partialResult : {}, false))
222
- break;
391
+ return;
223
392
  const part = typeof ev.toolCallId === "string" ? s.toolByCallId.get(ev.toolCallId) : undefined;
224
393
  if (part === undefined || part.state.status !== "running")
225
- break;
394
+ return;
226
395
  this.emitPart(s, part);
227
- break;
396
+ return;
228
397
  }
229
398
  case "tool_execution_end": {
230
399
  if (typeof ev.toolCallId === "string")
231
400
  s.approvals?.noteToolEnd(ev.toolCallId);
232
401
  const result = ev.result !== null && typeof ev.result === "object" ? ev.result : {};
233
402
  this.applyToolResults(s, [{ ...result, toolCallId: ev.toolCallId, isError: ev.isError }]);
234
- break;
403
+ return;
235
404
  }
405
+ default:
406
+ return;
407
+ }
408
+ }
409
+ onMessageEvent(s, ev) {
410
+ switch (ev.type) {
236
411
  case "message_start": {
237
412
  if (this.reconcileUser(s, ev))
238
- break;
413
+ return;
239
414
  const msg = ev.message;
240
415
  if (msg?.role !== "assistant")
241
- break; // user message already recorded at prompt()
416
+ return; // user message already recorded at prompt()
242
417
  // pi stamps the assistant message with the model really serving it.
243
418
  // Trust that over our snapshot, which goes stale whenever pi switches on
244
419
  // its own side (subagent routing, an in-session /model, a resumed file).
@@ -261,56 +436,121 @@ export class PiEventTranslator {
261
436
  };
262
437
  s.streaming = { message: assistant, parts: new Map() };
263
438
  s.messages?.push({ info: assistant, parts: [] });
264
- this.services.emit(dir, { type: "message.updated", properties: { info: assistant } });
265
- break;
439
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: assistant } });
440
+ return;
266
441
  }
267
442
  case "message_update":
268
443
  this.onMessageUpdate(s, ev);
269
- break;
444
+ return;
270
445
  case "message_end": {
271
446
  if (this.reconcileUser(s, ev))
272
- break;
447
+ return;
273
448
  const ended = ev.message;
274
- if (ended?.role === "assistant" && s.streaming) {
275
- this.onProviderTools(s, ev.message);
276
- if (ended.stopReason === "aborted" || ended.stopReason === "error") {
277
- for (const part of finishProviderToolParts(s.toolByCallId.values(), Date.now(), ended.stopReason === "aborted" ? "Cancelled" : "No result"))
278
- this.emitPart(s, part);
279
- }
280
- // Last chance to correct attribution: some providers only reveal the
281
- // concrete model once the stream has closed.
282
- if (this.services.applyPiModel(s, ended.provider, piModelId(ended.responseModel, ended.model)) &&
283
- s.model !== undefined) {
284
- s.streaming.message.providerID = s.model.providerID;
285
- s.streaming.message.modelID = s.model.modelID;
286
- }
287
- applyUsage(s.streaming.message, ended.usage);
288
- Object.assign(s.streaming.message, piStopReason(ended.stopReason, ended.errorMessage));
289
- s.streaming.message.time.completed = Date.now();
290
- this.services.emit(dir, { type: "message.updated", properties: { info: s.streaming.message } });
291
- this.finishStreaming(s);
449
+ if (ended?.role !== "assistant" || !s.streaming)
450
+ return;
451
+ this.onProviderTools(s, ev.message);
452
+ if (ended.stopReason === "aborted" || ended.stopReason === "error") {
453
+ for (const part of finishProviderToolParts(s.toolByCallId.values(), Date.now(), ended.stopReason === "aborted" ? "Cancelled" : "No result"))
454
+ this.emitPart(s, part);
292
455
  }
293
- break;
456
+ // Last chance to correct attribution: some providers only reveal the
457
+ // concrete model once the stream has closed.
458
+ if (this.services.applyPiModel(s, ended.provider, piModelId(ended.responseModel, ended.model)) &&
459
+ s.model !== undefined) {
460
+ s.streaming.message.providerID = s.model.providerID;
461
+ s.streaming.message.modelID = s.model.modelID;
462
+ }
463
+ applyUsage(s.streaming.message, ended.usage);
464
+ Object.assign(s.streaming.message, piStopReason(ended.stopReason, ended.errorMessage));
465
+ s.streaming.message.time.completed = Date.now();
466
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: s.streaming.message } });
467
+ this.finishStreaming(s);
468
+ if (syncSessionCost(s))
469
+ this.services.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
470
+ return;
294
471
  }
295
- case "auto_retry_start":
296
- this.setStatus(s, {
297
- type: "retry",
298
- attempt: Number(ev.attempt ?? 1),
299
- message: String(ev.errorMessage ?? "retrying"),
300
- next: Date.now() + Number(ev.delayMs ?? 0),
301
- });
302
- break;
303
- case "extension_error":
304
- this.services.emit(dir, {
305
- type: "session.error",
472
+ default:
473
+ return;
474
+ }
475
+ }
476
+ onCompactionEvent(s, ev) {
477
+ switch (ev.type) {
478
+ case "compaction_start":
479
+ s.compactionID = id("compaction");
480
+ this.services.emit(s.info.directory, {
481
+ type: "session.compaction.started",
306
482
  properties: {
307
483
  sessionID: s.info.id,
308
- error: { name: "UnknownError", data: { message: String(ev.error ?? "pi extension error") } },
484
+ inputID: s.compactionID,
485
+ reason: typeof ev.reason === "string" ? ev.reason : "manual",
309
486
  },
310
487
  });
311
- break;
488
+ s.info.time.compacting = Date.now();
489
+ this.services.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
490
+ // A manual /compact runs no agent turn, so nothing else marks the session busy.
491
+ if (s.status.type === "idle") {
492
+ s.compactingFromIdle = true;
493
+ this.setStatus(s, { type: "busy" });
494
+ }
495
+ return;
496
+ case "compaction_end": {
497
+ delete s.info.time.compacting;
498
+ const result = ev.result;
499
+ const inputID = s.compactionID ?? id("compaction");
500
+ delete s.compactionID;
501
+ this.services.emit(s.info.directory, ev.aborted === true || typeof result?.summary !== "string"
502
+ ? {
503
+ type: "session.compaction.failed",
504
+ properties: {
505
+ sessionID: s.info.id,
506
+ inputID,
507
+ reason: typeof ev.reason === "string" ? ev.reason : "manual",
508
+ error: {
509
+ name: "CompactionError",
510
+ data: { message: ev.aborted === true ? "Compaction cancelled" : "Compaction failed" },
511
+ },
512
+ },
513
+ }
514
+ : {
515
+ type: "session.compaction.ended",
516
+ properties: {
517
+ sessionID: s.info.id,
518
+ inputID,
519
+ reason: typeof ev.reason === "string" ? ev.reason : "manual",
520
+ text: result.summary,
521
+ recent: "",
522
+ },
523
+ });
524
+ if (ev.aborted !== true && typeof result?.summary === "string" && typeof result.firstKeptEntryId === "string") {
525
+ const messages = this.services.ensureMessages(s);
526
+ let key;
527
+ // compaction_end follows persistence, but its result omits the entry ID.
528
+ if (s.piPath !== undefined) {
529
+ for (const entry of readSessionTree(s.piPath).entries) {
530
+ if (entry.type === "compaction")
531
+ key = entry.id;
532
+ }
533
+ }
534
+ for (const message of compactionMessages(s, key ?? id("compaction"), result.summary, Date.now(), s.model)) {
535
+ if (messages.some((item) => item.info.id === message.info.id))
536
+ continue;
537
+ // Keep a hidden redo branch after the new turn, as appendMessage does.
538
+ const boundary = messages.findIndex((item) => item.info.id === s.tree.revert?.messageID);
539
+ messages.splice(boundary < 0 ? messages.length : boundary, 0, message);
540
+ this.services.emit(s.info.directory, { type: "message.updated", properties: { info: message.info } });
541
+ for (const part of message.parts)
542
+ this.emitPart(s, part);
543
+ }
544
+ }
545
+ this.services.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
546
+ if (s.compactingFromIdle) {
547
+ delete s.compactingFromIdle;
548
+ this.setStatus(s, { type: "idle" });
549
+ }
550
+ return;
551
+ }
312
552
  default:
313
- break; // turn_start, queue_update, compaction_* — future milestones
553
+ return;
314
554
  }
315
555
  }
316
556
  finishStreaming(s) {
@@ -351,7 +591,19 @@ export class PiEventTranslator {
351
591
  const textPart = part;
352
592
  if (ame.type === `${wire}_delta` && typeof ame.delta === "string") {
353
593
  textPart.text += ame.delta;
354
- this.emitPart(s, textPart, ame.delta);
594
+ this.services.emit(dir, {
595
+ type: "message.part.delta",
596
+ properties: {
597
+ sessionID: s.info.id,
598
+ messageID: textPart.messageID,
599
+ partID: textPart.id,
600
+ field: "text",
601
+ delta: ame.delta,
602
+ },
603
+ });
604
+ // Append before publishing the authoritative snapshot. Sending another
605
+ // append delta on the snapshot would duplicate text in streaming clients.
606
+ this.emitPart(s, textPart);
355
607
  }
356
608
  else if (ame.type === `${wire}_end`) {
357
609
  if (typeof ame.content === "string")
package/dist/pi-rpc.d.ts CHANGED
@@ -15,7 +15,26 @@ export interface PiRpcCommand {
15
15
  [key: string]: unknown;
16
16
  }
17
17
  export declare const RPC_COMMAND_TIMEOUT_MS = 60000;
18
- export declare const MAX_JSONL_FRAME_BYTES: number;
18
+ /**
19
+ * `compact` answers only when the summary is written. OmO bounds one compaction by
20
+ * its SUMMARIZATION_MAX_DURATION_CAP_MS (30 min); the generic 60 s budget gave up
21
+ * long before a large session finished, leaving the run orphaned.
22
+ */
23
+ export declare const COMPACT_RPC_TIMEOUT_MS: number;
24
+ export declare class UpstreamProtocolError extends Error {
25
+ constructor(message: string);
26
+ }
27
+ /** A process's group and kernel start time: with the PID, the identity that survives PID reuse checks. */
28
+ interface ProcessIdentity {
29
+ readonly pid: number;
30
+ readonly group: number;
31
+ readonly start: string;
32
+ }
33
+ /** Reads process identities so disposal can tell owned survivors from a reused PID. */
34
+ export interface ProcessTable {
35
+ identity(pid: number): ProcessIdentity | undefined;
36
+ groupMembers(group: number): ProcessIdentity[];
37
+ }
19
38
  export type PiRpcResponse<T = unknown> = {
20
39
  readonly id: string;
21
40
  readonly type: "response";
@@ -29,13 +48,14 @@ export type PiRpcResponse<T = unknown> = {
29
48
  readonly error?: string;
30
49
  readonly data?: never;
31
50
  });
32
- export interface PiRpcClientOptions {
51
+ interface PiRpcClientOptions {
33
52
  cwd: string;
34
53
  /** Path/name of the pi binary. Defaults to ANTECHAMBER_PI_BINARY env or "omo" from PATH. */
35
54
  piBinary?: string;
36
55
  /** Extra CLI flags, e.g. ["--no-session"] or ["--provider", "x"]. */
37
56
  args?: string[];
38
57
  clock?: LifecycleClock;
58
+ processTable?: ProcessTable;
39
59
  }
40
60
  export declare class PiRpcClient extends EventEmitter {
41
61
  private options;
@@ -55,6 +75,9 @@ export declare class PiRpcClient extends EventEmitter {
55
75
  constructor(options: PiRpcClientOptions);
56
76
  private drain;
57
77
  private frameOverflow;
78
+ /** Node has reaped the leader, so its PID (and group ID) may already belong to another process. */
79
+ private reaped;
80
+ private closeForTransportFailure;
58
81
  private handleLine;
59
82
  private handleExtensionUI;
60
83
  respondExtensionUI(response: Record<string, unknown>): void;
@@ -66,3 +89,4 @@ export declare class PiRpcClient extends EventEmitter {
66
89
  notify(cmd: Omit<PiRpcCommand, "id">): void;
67
90
  dispose(): Promise<void>;
68
91
  }
92
+ export {};