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