antechamber 1.0.40 → 1.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sessions.js CHANGED
@@ -87,7 +87,9 @@ export class SessionManager {
87
87
  this.workStatus = new WorkStatusBridge(this.monitors);
88
88
  this.shells = new OmoShells(this.sessions, this.monitors, this.workStatus, this.emit);
89
89
  this.events = new PiEventTranslator({
90
- emit: this.emit,
90
+ emit: (directory, event) => {
91
+ this.emit(directory, event);
92
+ },
91
93
  subagents: this.subagents,
92
94
  monitors: this.monitors,
93
95
  workStatus: this.workStatus,
@@ -161,6 +163,7 @@ export class SessionManager {
161
163
  messages: [],
162
164
  status: { type: "idle" },
163
165
  model: undefined,
166
+ initializing: true,
164
167
  streaming: undefined,
165
168
  toolByCallId: new Map(),
166
169
  hasPendingInteraction: () => false,
@@ -179,13 +182,11 @@ export class SessionManager {
179
182
  this.wireDormant(managed);
180
183
  this.lifecycle.touch(managed);
181
184
  this.wirePi(managed, pi);
185
+ this.applyPiModel(managed, state.data.model?.provider, state.data.model?.id);
182
186
  if (options.model !== undefined)
183
187
  await this.setModel(info.id, options.model);
184
188
  this.emit(directory, { type: "session.created", properties: { info } });
185
- // Announce pi's real model *after* session.created, so the client already
186
- // knows the session when the switch marker lands in its transcript.
187
- if (options.model === undefined)
188
- this.applyPiModel(managed, state.data.model?.provider, state.data.model?.id);
189
+ delete managed.initializing;
189
190
  // Bind pi's session file so the session survives adapter restarts. pi
190
191
  // reports the path directly; fall back to a background scan for older pi
191
192
  // versions, and re-bind after the first prompt (pi persists lazily).
@@ -250,6 +251,7 @@ export class SessionManager {
250
251
  s.info.time.updated = Date.now();
251
252
  this.persistMeta(s);
252
253
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
254
+ this.emit(s.info.directory, { type: "session.renamed", properties: { sessionID: s.info.id, title } });
253
255
  }
254
256
  refreshNativeTitles() {
255
257
  for (const s of this.sessions.values())
@@ -394,6 +396,14 @@ export class SessionManager {
394
396
  return [...this.sessions.values()].map((s) => this.sessionInfo(s));
395
397
  });
396
398
  }
399
+ /** Reuse an already-running agent for directory-scoped catalog reads. Never revives a dormant session. */
400
+ liveClient(directory) {
401
+ for (const session of this.sessions.values()) {
402
+ if (session.info.directory === directory && session.pi !== undefined)
403
+ return session.pi;
404
+ }
405
+ return undefined;
406
+ }
397
407
  sessionInfo(s) {
398
408
  if (s.restored !== undefined)
399
409
  this.ensureMessages(s);
@@ -458,6 +468,9 @@ export class SessionManager {
458
468
  return undefined;
459
469
  if (patch.agent !== undefined && !getExposedAgentNames().includes(patch.agent))
460
470
  throw new RequestBodyError(400, `unknown agent: ${patch.agent}`);
471
+ const previousTitle = s.info.title;
472
+ const previousAgent = s.info.agent ?? getAgentName();
473
+ const wasArchived = s.info.time.archived !== undefined;
461
474
  if (typeof patch.title === "string" && patch.title.trim() !== "") {
462
475
  const title = patch.title.trim().replace(/\s+/g, " ");
463
476
  if (s.pi !== undefined) {
@@ -483,8 +496,58 @@ export class SessionManager {
483
496
  if (!this.subagents.save(s.info.id))
484
497
  this.persistMeta(s);
485
498
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
499
+ if (s.info.title !== previousTitle)
500
+ this.emit(s.info.directory, {
501
+ type: "session.renamed",
502
+ properties: { sessionID: sessionId, title: s.info.title },
503
+ });
504
+ if ((s.info.agent ?? getAgentName()) !== previousAgent)
505
+ this.emit(s.info.directory, {
506
+ type: "session.agent.selected",
507
+ properties: { sessionID: sessionId, agent: s.info.agent ?? getAgentName(), previous: previousAgent },
508
+ });
509
+ if ((s.info.time.archived !== undefined) !== wasArchived)
510
+ this.emit(s.info.directory, {
511
+ type: s.info.time.archived !== undefined ? "session.archived" : "session.unarchived",
512
+ properties: { sessionID: sessionId },
513
+ });
486
514
  return s.info;
487
515
  }
516
+ /** Mark only the idle boundary that the caller actually observed. */
517
+ view(sessionId, idle) {
518
+ const s = this.get(sessionId);
519
+ if (s === undefined ||
520
+ s.info.time.idle === undefined ||
521
+ idle > s.info.time.idle ||
522
+ (s.info.time.viewed !== undefined && s.info.time.viewed >= idle))
523
+ return;
524
+ s.info.time.viewed = idle;
525
+ this.persistMeta(s);
526
+ this.emit(s.info.directory, { type: "session.viewed", properties: { sessionID: sessionId, idle } });
527
+ }
528
+ putInstruction(sessionId, key, value) {
529
+ const s = this.get(sessionId);
530
+ if (s === undefined)
531
+ throw new Error(`unknown session: ${sessionId}`);
532
+ const previous = s.instructions?.[key];
533
+ if (previous?.removed !== true &&
534
+ previous !== undefined &&
535
+ JSON.stringify(previous.value) === JSON.stringify(value))
536
+ return;
537
+ (s.instructions ??= {})[key] = { value };
538
+ this.persistMeta(s);
539
+ }
540
+ removeInstruction(sessionId, key) {
541
+ const s = this.get(sessionId);
542
+ if (s === undefined)
543
+ throw new Error(`unknown session: ${sessionId}`);
544
+ const instructions = s.instructions;
545
+ const previous = instructions?.[key];
546
+ if (instructions === undefined || previous === undefined || previous.removed)
547
+ return;
548
+ instructions[key] = { value: null, removed: true };
549
+ this.persistMeta(s);
550
+ }
488
551
  messages(sessionId) {
489
552
  const s = this.get(sessionId);
490
553
  if (!s)
@@ -499,6 +562,61 @@ export class SessionManager {
499
562
  }
500
563
  return messages;
501
564
  }
565
+ compaction(sessionId, messageID) {
566
+ const s = this.get(sessionId);
567
+ if (s === undefined)
568
+ return undefined;
569
+ const history = this.ensureMessages(s);
570
+ const index = history.findIndex((message) => message.parts.some((part) => part.type === "compaction") &&
571
+ (message.info.id === messageID ||
572
+ s.tree.messageEntries[messageID] === message.info.id.replace(/^msg_compaction_/, "")));
573
+ const message = history[index];
574
+ const summary = history[index + 1];
575
+ if (message !== undefined && summary?.info.role === "assistant" && summary.info.summary) {
576
+ const entryID = message.info.id.replace(/^msg_compaction_/, "");
577
+ const admittedID = Object.keys(s.tree.messageEntries).find((key) => key !== message.info.id && s.tree.messageEntries[key] === entryID) ?? message.info.id;
578
+ const content = summary.parts.find((part) => part.type === "text");
579
+ return {
580
+ id: admittedID,
581
+ type: "compaction",
582
+ time: { created: s.tree.compactionAdmissions?.[admittedID] ?? message.info.time.created },
583
+ status: "completed",
584
+ reason: s.tree.compactionReasons?.[entryID] ?? "manual",
585
+ summary: content?.type === "text" ? content.text : "",
586
+ };
587
+ }
588
+ if (s.compactionID !== messageID)
589
+ return undefined;
590
+ return {
591
+ id: messageID,
592
+ type: "compaction",
593
+ time: { created: s.tree.compactionAdmissions?.[messageID] ?? s.info.time.compacting ?? Date.now() },
594
+ status: "running",
595
+ reason: "manual",
596
+ summary: "",
597
+ };
598
+ }
599
+ /** Synchronous admission owns the ID before any native work can yield to another request. */
600
+ admitCompaction(sessionId, messageID) {
601
+ const s = this.get(sessionId);
602
+ if (s === undefined)
603
+ throw new SessionTreeError(`unknown session: ${sessionId}`);
604
+ const history = this.ensureMessages(s);
605
+ const owner = history.find((message) => message.info.id === messageID);
606
+ if ((owner !== undefined && !owner.parts.some((part) => part.type === "compaction")) ||
607
+ Object.keys(s.tree.idleBoundaries ?? {}).some((key) => `msg_idle_${key.replace(/^msg_/, "")}` === messageID))
608
+ return { kind: "conflict" };
609
+ const exact = this.compaction(sessionId, messageID);
610
+ if (exact !== undefined)
611
+ return { kind: "existing", item: exact };
612
+ if (s.tree.messageEntries[messageID] !== undefined)
613
+ return { kind: "conflict" };
614
+ if (s.compactionID !== undefined)
615
+ return { kind: "existing", item: this.compaction(sessionId, s.compactionID) };
616
+ s.compactionID = messageID;
617
+ (s.tree.compactionAdmissions ??= {})[messageID] = Date.now();
618
+ return { kind: "admitted", item: this.compaction(sessionId, messageID) };
619
+ }
502
620
  /** Append to canonical history, before the hidden redo branch while reverted. */
503
621
  appendMessage(sessionId, message) {
504
622
  const s = this.get(sessionId);
@@ -626,6 +744,8 @@ export class SessionManager {
626
744
  child.info.agent = s.info.agent ?? getAgentName();
627
745
  if (s.info.metadata !== undefined)
628
746
  child.info.metadata = structuredClone(s.info.metadata);
747
+ if (s.instructions !== undefined)
748
+ child.instructions = structuredClone(s.instructions);
629
749
  const childTree = await getEntries(pi);
630
750
  child.tree.leafId = childTree.leafId;
631
751
  this.ensureMessages(child);
@@ -979,10 +1099,11 @@ export class SessionManager {
979
1099
  s.model = model;
980
1100
  s.info.model = { id: modelId, providerID: provider, variant: s.thinkingLevel ?? "default" };
981
1101
  this.persistMeta(s);
982
- this.emit(s.info.directory, {
983
- type: "model.changed",
984
- properties: { sessionID: s.info.id, model, ...(previous !== undefined ? { previous } : {}) },
985
- });
1102
+ if (!s.initializing)
1103
+ this.emit(s.info.directory, {
1104
+ type: "model.changed",
1105
+ properties: { sessionID: s.info.id, model, ...(previous !== undefined ? { previous } : {}) },
1106
+ });
986
1107
  return true;
987
1108
  }
988
1109
  async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
@@ -1035,6 +1156,9 @@ export class SessionManager {
1035
1156
  agent: getAgentName(),
1036
1157
  model: userMessageModel(s.model ?? model ?? { providerID: "unknown", modelID: "unknown" }, s.thinkingLevel),
1037
1158
  ...(context ? { synthetic: true } : {}),
1159
+ ...(context && s.tree.promptDescriptions?.[messageID ?? ""] !== undefined
1160
+ ? { description: s.tree.promptDescriptions[messageID ?? ""] }
1161
+ : {}),
1038
1162
  ...(metadata !== undefined ? { metadata } : {}),
1039
1163
  };
1040
1164
  if (metadata !== undefined)
@@ -1052,12 +1176,18 @@ export class SessionManager {
1052
1176
  (s.tree.promptParts ??= {})[userMsg.id] = [...inputParts];
1053
1177
  s.messages?.push({ info: userMsg, parts });
1054
1178
  s.info.time.updated = Date.now();
1179
+ const previousTitle = s.info.title;
1055
1180
  if (!context && s.info.title === "New session" && s.titleSource !== "user" && s.titleSource !== "native") {
1056
1181
  s.info.title = text.trim().replace(/\s+/g, " ").slice(0, SESSION_TITLE_MAX_CHARS) || "New session";
1057
1182
  s.titleSource = "fallback";
1058
1183
  }
1059
1184
  this.persistMeta(s);
1060
1185
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
1186
+ if (s.info.title !== previousTitle)
1187
+ this.emit(s.info.directory, {
1188
+ type: "session.renamed",
1189
+ properties: { sessionID: s.info.id, title: s.info.title },
1190
+ });
1061
1191
  this.emit(s.info.directory, { type: "message.updated", properties: { info: userMsg } });
1062
1192
  // Echo the user text part: the reducer replaces the client's sessionID-less
1063
1193
  // optimistic part (same type, new id) in place — without this echo a page
@@ -1069,6 +1199,10 @@ export class SessionManager {
1069
1199
  async promptLive(s, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
1070
1200
  const pi = await this.preparePrompt(s, model);
1071
1201
  const native = nativePromptInput(text, parts ?? []);
1202
+ // OpenChamber treats instruction writes as best effort, so a rejected delivery must not fail the prompt;
1203
+ // the entries stay undelivered and are offered again before the next prompt.
1204
+ if (!noReply)
1205
+ await this.deliverInstructions(s, pi).catch((error) => console.error(`[instructions] delivery to ${s.info.id} failed:`, error));
1072
1206
  const userMsg = this.publishPrompt(s, text, model, messageID, parts, metadata, noReply);
1073
1207
  if (noReply) {
1074
1208
  await this.admitContext(s, pi, userMsg, native.message);
@@ -1078,6 +1212,53 @@ export class SessionManager {
1078
1212
  return;
1079
1213
  await this.bindPromptState(s);
1080
1214
  }
1215
+ /**
1216
+ * What the model has is read from the active native branch, not stored beside it: each delivery carries the
1217
+ * full delivered map in `details`, so after a revert, unrevert, fork or compaction the last delivery still in
1218
+ * context says exactly what to send again. Deliveries before a compaction's kept range are no longer in context
1219
+ * (OpenCode rewinds its instruction state the same way, core/session/projector.ts).
1220
+ */
1221
+ async deliverInstructions(s, pi) {
1222
+ const instructions = Object.entries(s.instructions ?? {});
1223
+ if (instructions.length === 0)
1224
+ return;
1225
+ const delivered = deliveredInstructions(branchEntries(await getEntries(pi)));
1226
+ const pending = instructions
1227
+ .filter(([key, entry]) => entry.removed
1228
+ ? key in delivered
1229
+ : !(key in delivered) || JSON.stringify(entry.value) !== JSON.stringify(delivered[key]))
1230
+ .sort(([left], [right]) => left.localeCompare(right));
1231
+ if (pending.length === 0)
1232
+ return;
1233
+ const text = pending
1234
+ .map(([key, entry]) => {
1235
+ if (entry.removed)
1236
+ return `The context under "${key}" no longer applies. Disregard it.`;
1237
+ const block = `<context key="${key}">\n${typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value, null, 2)}\n</context>`;
1238
+ return key in delivered
1239
+ ? `The context under "${key}" changed and supersedes the previous value:\n${block}`
1240
+ : block;
1241
+ })
1242
+ .join("\n\n");
1243
+ const snapshot = { ...delivered };
1244
+ for (const [key, entry] of pending) {
1245
+ if (entry.removed)
1246
+ delete snapshot[key];
1247
+ else
1248
+ snapshot[key] = entry.value;
1249
+ }
1250
+ const result = await pi.command({
1251
+ type: "send_custom_message",
1252
+ customType: INSTRUCTIONS_CUSTOM_TYPE,
1253
+ content: [{ type: "text", text }],
1254
+ display: false,
1255
+ details: { instructions: snapshot },
1256
+ triggerTurn: false,
1257
+ });
1258
+ if (!result.success)
1259
+ throw new Error(result.error ?? "instruction delivery rejected");
1260
+ await this.bindPromptState(s);
1261
+ }
1081
1262
  /**
1082
1263
  * No-reply context (v2 synthetic, v1 noReply) joins the native conversation without a turn:
1083
1264
  * OmO appends it now when idle, or after the running turn, so the next prompt carries it.
@@ -1494,6 +1675,7 @@ export class SessionManager {
1494
1675
  (reg?.title && reg.title !== "New session" && reg.title !== sc.fallbackTitle ? "user" : "fallback");
1495
1676
  const useRegistry = titleSource === "user" || (sc.titleSource !== "native" && titleSource === "generated");
1496
1677
  const resumed = dormantModel(sc, reg?.model);
1678
+ const tree = durableShellTree(reg?.tree ?? { messageEntries: {} });
1497
1679
  const info = {
1498
1680
  id: ocId,
1499
1681
  projectID: sc.directory,
@@ -1518,10 +1700,12 @@ export class SessionManager {
1518
1700
  created: sc.created,
1519
1701
  updated: Math.max(sc.updated, reg?.updated ?? 0),
1520
1702
  ...(reg?.archived ? { archived: reg.archived } : {}),
1703
+ ...(reg?.viewed !== undefined ? { viewed: reg.viewed } : {}),
1704
+ ...(tree.lastIdle !== undefined ? { idle: tree.lastIdle } : {}),
1521
1705
  },
1522
1706
  };
1523
1707
  const managed = {
1524
- tree: durableShellTree(reg?.tree ?? { messageEntries: {} }),
1708
+ tree,
1525
1709
  titleSource: useRegistry ? titleSource : (sc.titleSource ?? "fallback"),
1526
1710
  info,
1527
1711
  pi: undefined,
@@ -1534,6 +1718,7 @@ export class SessionManager {
1534
1718
  hasPendingInteraction: () => false,
1535
1719
  hasRunningChildren: () => false,
1536
1720
  ...(reg?.lastStop !== undefined ? { lastStop: reg.lastStop } : {}),
1721
+ ...(reg?.instructions !== undefined ? { instructions: structuredClone(reg.instructions) } : {}),
1537
1722
  };
1538
1723
  this.sessions.set(ocId, managed);
1539
1724
  if (reg?.connectorOwned === true)
@@ -1608,7 +1793,9 @@ export class SessionManager {
1608
1793
  ...(s.info.title !== "New session" ? { title: s.info.title } : {}),
1609
1794
  ...(s.titleSource !== undefined ? { titleSource: s.titleSource } : {}),
1610
1795
  ...(s.info.time.archived !== undefined ? { archived: s.info.time.archived } : {}),
1796
+ ...(s.info.time.viewed !== undefined ? { viewed: s.info.time.viewed } : {}),
1611
1797
  ...(s.info.metadata !== undefined ? { metadata: s.info.metadata } : {}),
1798
+ ...(s.instructions !== undefined ? { instructions: s.instructions } : {}),
1612
1799
  ...(s.info.agent !== undefined ? { agent: s.info.agent } : {}),
1613
1800
  ...(s.info.parentID !== undefined ? { parentID: s.info.parentID } : {}),
1614
1801
  ...(s.info.parentMessageID !== undefined ? { parentMessageID: s.info.parentMessageID } : {}),
@@ -1629,6 +1816,47 @@ export class SessionManager {
1629
1816
  }
1630
1817
  /** Keep the existing event entry point for RPC wiring and test harnesses. */
1631
1818
  onPiEvent(s, ev) {
1819
+ const interrupted = s.interruptRequested;
1632
1820
  this.events.onPiEvent(s, ev);
1821
+ if (ev.type === "agent_idle" && s.info.time.idle !== undefined) {
1822
+ recordTurn(s);
1823
+ const last = s.messages?.findLast((message) => message.info.role === "assistant" &&
1824
+ !message.info.summary &&
1825
+ !message.info.id.startsWith("msg_background_") &&
1826
+ !s.localMessageIds?.has(message.info.id));
1827
+ if (last?.info.role === "assistant") {
1828
+ const error = interrupted ? { name: "MessageAbortedError", data: { message: "Cancelled" } } : last.info.error;
1829
+ const outcome = interrupted ? "interrupted" : (s.info.outcome ?? "succeeded");
1830
+ s.info.outcome = outcome;
1831
+ (s.tree.idleBoundaries ??= {})[last.info.id] = {
1832
+ at: s.info.time.idle,
1833
+ outcome,
1834
+ ...(error !== undefined ? { error } : {}),
1835
+ };
1836
+ }
1837
+ this.persistMeta(s);
1838
+ }
1633
1839
  }
1634
1840
  }
1841
+ const INSTRUCTIONS_CUSTOM_TYPE = "antechamber.instructions";
1842
+ /** The instruction map the model holds on this branch: the last delivery inside the newest compaction's kept range. */
1843
+ function deliveredInstructions(branch) {
1844
+ let start = 0;
1845
+ for (let index = branch.length - 1; index >= 0; index--) {
1846
+ const entry = branch[index];
1847
+ if (entry.type !== "compaction")
1848
+ continue;
1849
+ const kept = branch.findIndex((candidate) => candidate.id === entry.firstKeptEntryId);
1850
+ start = kept >= 0 ? kept : index + 1;
1851
+ break;
1852
+ }
1853
+ for (let index = branch.length - 1; index >= start; index--) {
1854
+ const entry = branch[index];
1855
+ if (entry?.type !== "custom_message" || entry.customType !== INSTRUCTIONS_CUSTOM_TYPE)
1856
+ continue;
1857
+ const details = entry.details;
1858
+ if (details?.instructions !== null && typeof details?.instructions === "object")
1859
+ return { ...details.instructions };
1860
+ }
1861
+ return {};
1862
+ }
@@ -0,0 +1,20 @@
1
+ type Model = {
2
+ providerID: string;
3
+ id: string;
4
+ };
5
+ /**
6
+ * A hang guard, not a budget: OpenChamber sizes its own small-model deadline by prompt length up to 900 s
7
+ * (server/lib/small-model/index.js generationBudgetMs) and its disconnect cancels the run here, and OpenCode's
8
+ * generate.text has no server deadline. This only has to outlast the longest budget a caller may give.
9
+ */
10
+ export declare const GENERATION_DEADLINE_MS: number;
11
+ /** Own the auxiliary agents separately from user sessions, with no admission queue. */
12
+ export declare class TextGeneration {
13
+ private readonly active;
14
+ private stopped;
15
+ generate(directory: string, prompt: string, model?: Model, signal?: AbortSignal): Promise<string>;
16
+ dispose(): Promise<void>;
17
+ }
18
+ /** Run a model turn in an agent with no persistent session, never in a user's chat. */
19
+ export declare function generateText(directory: string, prompt: string, model?: Model, caller?: AbortSignal): Promise<string>;
20
+ export {};
@@ -0,0 +1,93 @@
1
+ import { PiRpcClient } from "./pi-rpc.js";
2
+ /**
3
+ * A hang guard, not a budget: OpenChamber sizes its own small-model deadline by prompt length up to 900 s
4
+ * (server/lib/small-model/index.js generationBudgetMs) and its disconnect cancels the run here, and OpenCode's
5
+ * generate.text has no server deadline. This only has to outlast the longest budget a caller may give.
6
+ */
7
+ export const GENERATION_DEADLINE_MS = 15 * 60 * 1000;
8
+ /** Own the auxiliary agents separately from user sessions, with no admission queue. */
9
+ export class TextGeneration {
10
+ active = new Map();
11
+ stopped = false;
12
+ async generate(directory, prompt, model, signal) {
13
+ if (this.stopped)
14
+ throw new Error("Text generation is stopping");
15
+ if (this.active.size >= 2)
16
+ throw new Error("Text generation is at capacity");
17
+ const cancellation = new AbortController();
18
+ const result = generateText(directory, prompt, model, signal ? AbortSignal.any([signal, cancellation.signal]) : cancellation.signal);
19
+ this.active.set(cancellation, result);
20
+ try {
21
+ return await result;
22
+ }
23
+ finally {
24
+ this.active.delete(cancellation);
25
+ }
26
+ }
27
+ async dispose() {
28
+ this.stopped = true;
29
+ for (const cancellation of this.active.keys())
30
+ cancellation.abort(new Error("Text generation is stopping"));
31
+ await Promise.allSettled(this.active.values());
32
+ }
33
+ }
34
+ /** Run a model turn in an agent with no persistent session, never in a user's chat. */
35
+ export async function generateText(directory, prompt, model, caller) {
36
+ caller?.throwIfAborted();
37
+ // Startup overrides initialize agent state without persisting the default, unlike set_model.
38
+ // senpi dist/cli/args.js:93-104 -> main.js:561-576,843 -> core/sdk.js:125,292-298.
39
+ const pi = new PiRpcClient({
40
+ cwd: directory,
41
+ args: ["--no-session", "--no-tools", ...(model ? ["--provider", model.providerID, "--model", model.id] : [])],
42
+ });
43
+ const cancellation = new AbortController();
44
+ const signal = caller ? AbortSignal.any([caller, cancellation.signal]) : cancellation.signal;
45
+ // This budget covers startup/model resolution, admission, the turn, and the result read.
46
+ const timer = setTimeout(() => cancellation.abort(new Error("Text generation timed out")), GENERATION_DEADLINE_MS);
47
+ try {
48
+ const finished = new Promise((resolve, reject) => {
49
+ const cleanup = () => {
50
+ pi.off("event", onEvent);
51
+ signal.removeEventListener("abort", onAbort);
52
+ };
53
+ const onAbort = () => {
54
+ cleanup();
55
+ reject(signal.reason);
56
+ };
57
+ const onEvent = (event) => {
58
+ if (event.type !== "agent_end")
59
+ return;
60
+ cleanup();
61
+ resolve();
62
+ };
63
+ pi.onEvent(onEvent);
64
+ signal.addEventListener("abort", onAbort, { once: true });
65
+ });
66
+ // A prompt RPC may acknowledge admission before the agent finishes; agent_end
67
+ // is the completion boundary before reading the resulting entries.
68
+ const admitted = pi.command({ type: "prompt", message: prompt }, undefined, signal).then((response) => {
69
+ if (!response.success)
70
+ throw new Error(response.error ?? "Text generation failed");
71
+ });
72
+ await Promise.all([admitted, finished]);
73
+ const history = await pi.command({ type: "get_entries" }, undefined, signal);
74
+ if (!history.success || !Array.isArray(history.data?.entries))
75
+ throw new Error("Text generation produced no readable history");
76
+ const assistant = history.data.entries.findLast((entry) => entry.type === "message" && entry.message?.role === "assistant");
77
+ const content = assistant?.message?.content;
78
+ const text = typeof content === "string"
79
+ ? content
80
+ : content
81
+ ?.filter((part) => part.type === "text")
82
+ .map((part) => part.text ?? "")
83
+ .join("");
84
+ if (!text)
85
+ throw new Error("Text generation produced no text");
86
+ return text;
87
+ }
88
+ finally {
89
+ clearTimeout(timer);
90
+ cancellation.abort();
91
+ await pi.dispose();
92
+ }
93
+ }
package/dist/types.d.ts CHANGED
@@ -48,6 +48,7 @@ export interface OCSession {
48
48
  created: number;
49
49
  updated: number;
50
50
  idle?: number;
51
+ viewed?: number;
51
52
  compacting?: number;
52
53
  /** Epoch ms — presence marks the session archived (SDK v2 session.update). */
53
54
  archived?: number;
@@ -67,6 +68,8 @@ export interface OCUserMessage {
67
68
  };
68
69
  /** No-reply context admitted without a turn (v2 lists it as `type: "synthetic"`). */
69
70
  synthetic?: boolean;
71
+ /** Human-readable label for a v2 synthetic notice. */
72
+ description?: string;
70
73
  /** Client metadata sent with the prompt (OpenChamber 2.x). */
71
74
  metadata?: Record<string, unknown>;
72
75
  }
@@ -291,6 +294,33 @@ export type OCEvent = {
291
294
  properties: {
292
295
  server: string;
293
296
  };
297
+ } | {
298
+ type: "filesystem.changed";
299
+ properties: {
300
+ file: string;
301
+ event: "change" | "unlink";
302
+ };
303
+ } | {
304
+ type: "project.updated";
305
+ properties: {
306
+ id: string;
307
+ canonical: string;
308
+ time: {
309
+ created: number;
310
+ updated: number;
311
+ active: number;
312
+ };
313
+ sandboxes: string[];
314
+ vcs: string;
315
+ };
316
+ } | {
317
+ type: "provider.updated" | "model.updated" | "command.updated";
318
+ properties: Record<string, never>;
319
+ } | {
320
+ type: "vcs.branch.updated";
321
+ properties: {
322
+ branch?: string;
323
+ };
294
324
  } | {
295
325
  type: "session.created";
296
326
  properties: {
@@ -301,6 +331,30 @@ export type OCEvent = {
301
331
  properties: {
302
332
  info: OCSession;
303
333
  };
334
+ } | {
335
+ type: "session.renamed";
336
+ properties: {
337
+ sessionID: string;
338
+ title: string;
339
+ };
340
+ } | {
341
+ type: "session.agent.selected";
342
+ properties: {
343
+ sessionID: string;
344
+ agent: string;
345
+ previous?: string;
346
+ };
347
+ } | {
348
+ type: "session.archived" | "session.unarchived";
349
+ properties: {
350
+ sessionID: string;
351
+ };
352
+ } | {
353
+ type: "session.viewed";
354
+ properties: {
355
+ sessionID: string;
356
+ idle: number;
357
+ };
304
358
  } | {
305
359
  type: "session.revert.staged";
306
360
  properties: {
@@ -327,6 +381,12 @@ export type OCEvent = {
327
381
  reason: string;
328
382
  inputID: string;
329
383
  };
384
+ } | {
385
+ type: "session.compaction.delta";
386
+ properties: {
387
+ sessionID: string;
388
+ text: string;
389
+ };
330
390
  } | {
331
391
  type: "session.compaction.ended";
332
392
  properties: {
package/dist/v2.d.ts CHANGED
@@ -123,6 +123,8 @@ export interface V2Session {
123
123
  time: {
124
124
  created: number;
125
125
  updated: number;
126
+ idle?: number;
127
+ viewed?: number;
126
128
  archived?: number;
127
129
  };
128
130
  title: string;
@@ -148,7 +150,12 @@ export interface V2Message {
148
150
  completed?: number;
149
151
  streamed?: number;
150
152
  };
151
- type: "user" | "assistant" | "synthetic";
153
+ type: "user" | "assistant" | "synthetic" | "idle" | "compaction";
154
+ status?: "running" | "completed" | "failed";
155
+ reason?: string;
156
+ summary?: string;
157
+ outcome?: "succeeded" | "failed" | "interrupted";
158
+ description?: string;
152
159
  text?: string;
153
160
  files?: Array<Record<string, unknown>>;
154
161
  agents?: Array<Record<string, unknown>>;