antechamber 1.0.40 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +88 -141
  3. package/bin/opencode-antechamber +14 -4
  4. package/dist/approvals.d.ts +9 -1
  5. package/dist/approvals.js +10 -2
  6. package/dist/catalog.d.ts +13 -2
  7. package/dist/catalog.js +88 -52
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +1 -1
  10. package/dist/commands/context.d.ts +5 -2
  11. package/dist/commands/context.js +8 -4
  12. package/dist/commands/doctor.js +4 -3
  13. package/dist/commands/setup.js +9 -28
  14. package/dist/config.d.ts +6 -2
  15. package/dist/config.js +18 -4
  16. package/dist/file-watch.d.ts +35 -0
  17. package/dist/file-watch.js +163 -0
  18. package/dist/latency.d.ts +10 -0
  19. package/dist/latency.js +115 -0
  20. package/dist/misc-routes.js +14 -12
  21. package/dist/pi-event-translation.js +44 -2
  22. package/dist/route-services.d.ts +8 -3
  23. package/dist/routes-v1.d.ts +1 -1
  24. package/dist/routes-v1.js +29 -418
  25. package/dist/routes-v2.js +252 -34
  26. package/dist/self-update.d.ts +2 -1
  27. package/dist/self-update.js +5 -1
  28. package/dist/server.d.ts +17 -3
  29. package/dist/server.js +191 -18
  30. package/dist/session-commands.d.ts +3 -1
  31. package/dist/session-commands.js +13 -7
  32. package/dist/session-history.js +3 -0
  33. package/dist/session-store.d.ts +19 -10
  34. package/dist/session-store.js +266 -128
  35. package/dist/session-tree.d.ts +15 -1
  36. package/dist/session-tree.js +2 -2
  37. package/dist/sessions.d.ts +85 -1
  38. package/dist/sessions.js +238 -10
  39. package/dist/sse.d.ts +0 -2
  40. package/dist/sse.js +13 -46
  41. package/dist/text-generation.d.ts +20 -0
  42. package/dist/text-generation.js +93 -0
  43. package/dist/tool-map.js +1 -3
  44. package/dist/types.d.ts +60 -0
  45. package/dist/v2.d.ts +8 -1
  46. package/dist/v2.js +24 -4
  47. package/dist/work-status.js +1 -1
  48. package/extensions/antechamber/README.md +100 -0
  49. package/extensions/antechamber/bin/opencode-antechamber +42 -0
  50. package/extensions/{omo-dag/docs/DESIGN.md → antechamber/docs/GRAPH.md} +5 -5
  51. package/extensions/{omo-work/docs/DESIGN.md → antechamber/docs/WORK.md} +3 -3
  52. package/extensions/antechamber/package.json +60 -0
  53. package/extensions/{omo-dag → antechamber}/panel/index.html +8 -1
  54. package/extensions/antechamber/panel/main.js +261 -0
  55. package/extensions/antechamber/runtime/launcher.js +201 -0
  56. package/extensions/antechamber/service/main.js +884 -0
  57. package/package.json +75 -26
  58. package/extensions/omo-compat/README.md +0 -48
  59. package/extensions/omo-compat/icon.svg +0 -1
  60. package/extensions/omo-compat/package.json +0 -43
  61. package/extensions/omo-dag/README.md +0 -167
  62. package/extensions/omo-dag/package.json +0 -33
  63. package/extensions/omo-dag/panel/main.js +0 -176
  64. package/extensions/omo-dag/service/main.js +0 -457
  65. package/extensions/omo-work/README.md +0 -88
  66. package/extensions/omo-work/package.json +0 -30
  67. package/extensions/omo-work/service/main.js +0 -149
  68. /package/extensions/{omo-dag → antechamber}/icon.svg +0 -0
  69. /package/extensions/{omo-work → antechamber}/status/index.html +0 -0
  70. /package/extensions/{omo-work → antechamber}/status/main.js +0 -0
@@ -12,7 +12,7 @@ import { SubagentBridge } from "./subagents.js";
12
12
  import { MonitorSessions } from "./monitor-sessions.js";
13
13
  import { WorkStatusBridge } from "./work-status.js";
14
14
  import { OmoShells } from "./omo-shells.js";
15
- import { SessionStore, type TitleSource } from "./session-store.js";
15
+ import { SessionStore, type InstructionEntryState, type TitleSource } from "./session-store.js";
16
16
  import type { OCAssistantMessage, OCEvent, OCMessageWithParts, OCModelRef, OCPart, OCPromptPart, OCSession, OCSessionStatus, OCToolPart } from "./types.js";
17
17
  export { ADAPTER_VERSION } from "./version.js";
18
18
  interface StreamingState {
@@ -33,6 +33,9 @@ export interface ManagedSession extends ThinkingState {
33
33
  accepted: boolean;
34
34
  delivered: boolean;
35
35
  }>;
36
+ instructions?: Record<string, InstructionEntryState>;
37
+ /** Suppress model-selection events until the session creation record has its final model. */
38
+ initializing?: boolean;
36
39
  tree: SessionTreeState;
37
40
  info: OCSession;
38
41
  /** undefined while dormant (restored from disk, no pi process spawned yet). */
@@ -51,6 +54,8 @@ export interface ManagedSession extends ThinkingState {
51
54
  /** A manual compaction started while idle; its end returns the session to idle. */
52
55
  compactingFromIdle?: boolean;
53
56
  compactionID?: string;
57
+ /** The native reason of the running compaction, for its failure event. */
58
+ compactionReason?: string;
54
59
  /** Retries sessionRpc still owes a `compact` command that OmO cancels on its own. */
55
60
  compactRetry?: {
56
61
  left: number;
@@ -142,6 +147,8 @@ export declare class SessionManager {
142
147
  * global/recent view; `list(directory)` stays the per-project source of truth.
143
148
  */
144
149
  listAll(): OCSession[];
150
+ /** Reuse an already-running agent for directory-scoped catalog reads. Never revives a dormant session. */
151
+ liveClient(directory: string): PiRpcClient | undefined;
145
152
  private sessionInfo;
146
153
  get(sessionId: string): ManagedSession | undefined;
147
154
  remove(sessionId: string): Promise<boolean>;
@@ -158,7 +165,77 @@ export declare class SessionManager {
158
165
  metadata?: Record<string, unknown> | undefined;
159
166
  agent?: string | undefined;
160
167
  }): Promise<OCSession | undefined>;
168
+ /** Mark only the idle boundary that the caller actually observed. */
169
+ view(sessionId: string, idle: number): void;
170
+ putInstruction(sessionId: string, key: string, value: unknown): void;
171
+ removeInstruction(sessionId: string, key: string): void;
161
172
  messages(sessionId: string): OCMessageWithParts[] | undefined;
173
+ compaction(sessionId: string, messageID: string): {
174
+ id: string;
175
+ type: "compaction";
176
+ time: {
177
+ created: number;
178
+ };
179
+ status: "completed";
180
+ reason: "auto" | "manual";
181
+ summary: string;
182
+ } | {
183
+ id: string;
184
+ type: "compaction";
185
+ time: {
186
+ created: number;
187
+ };
188
+ status: "running";
189
+ reason: "manual";
190
+ summary: string;
191
+ } | undefined;
192
+ /** Synchronous admission owns the ID before any native work can yield to another request. */
193
+ admitCompaction(sessionId: string, messageID: string): {
194
+ kind: "conflict";
195
+ item?: never;
196
+ } | {
197
+ kind: "existing";
198
+ item: {
199
+ id: string;
200
+ type: "compaction";
201
+ time: {
202
+ created: number;
203
+ };
204
+ status: "completed";
205
+ reason: "auto" | "manual";
206
+ summary: string;
207
+ } | {
208
+ id: string;
209
+ type: "compaction";
210
+ time: {
211
+ created: number;
212
+ };
213
+ status: "running";
214
+ reason: "manual";
215
+ summary: string;
216
+ };
217
+ } | {
218
+ kind: "admitted";
219
+ item: {
220
+ id: string;
221
+ type: "compaction";
222
+ time: {
223
+ created: number;
224
+ };
225
+ status: "completed";
226
+ reason: "auto" | "manual";
227
+ summary: string;
228
+ } | {
229
+ id: string;
230
+ type: "compaction";
231
+ time: {
232
+ created: number;
233
+ };
234
+ status: "running";
235
+ reason: "manual";
236
+ summary: string;
237
+ };
238
+ };
162
239
  /** Append to canonical history, before the hidden redo branch while reverted. */
163
240
  appendMessage(sessionId: string, message: OCMessageWithParts): void;
164
241
  private operation;
@@ -212,6 +289,13 @@ export declare class SessionManager {
212
289
  private preparePrompt;
213
290
  private publishPrompt;
214
291
  private promptLive;
292
+ /**
293
+ * What the model has is read from the active native branch, not stored beside it: each delivery carries the
294
+ * full delivered map in `details`, so after a revert, unrevert, fork or compaction the last delivery still in
295
+ * context says exactly what to send again. Deliveries before a compaction's kept range are no longer in context
296
+ * (OpenCode rewinds its instruction state the same way, core/session/projector.ts).
297
+ */
298
+ private deliverInstructions;
215
299
  /**
216
300
  * No-reply context (v2 synthetic, v1 noReply) joins the native conversation without a turn:
217
301
  * OmO appends it now when idle, or after the running turn, so the next prompt carries it.
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
+ }
package/dist/sse.d.ts CHANGED
@@ -3,9 +3,7 @@ import type { ServerResponse } from "node:http";
3
3
  import type { OCEvent } from "./types.js";
4
4
  import type { TodoUpdated } from "./todo-bridge.js";
5
5
  export type SSESubscriber = {
6
- flavor: "v1" | "v2";
7
6
  directory?: string;
8
- global: boolean;
9
7
  };
10
8
  /** The session an event belongs to, when it names one. */
11
9
  export declare function eventSessionID(event: OCEvent | TodoUpdated): string | undefined;
package/dist/sse.js CHANGED
@@ -48,9 +48,7 @@ export class SSE {
48
48
  : event.type === "todo.updated"
49
49
  ? [{ id: `evt_${randomUUID()}`, type: event.type, location: { directory }, data: event.properties }]
50
50
  : this.v2events.translate(directory, event);
51
- const records = v2Frames.length === 0
52
- ? [this.record(directory, { v1: event })]
53
- : v2Frames.map((v2, index) => this.record(directory, index === 0 ? { v1: event, v2 } : { v2 }));
51
+ const records = v2Frames.map((v2) => this.record(directory, { v2 }));
54
52
  for (const record of records)
55
53
  this.retain(record);
56
54
  for (const [res, subscriber] of this.clients) {
@@ -59,7 +57,7 @@ export class SSE {
59
57
  continue;
60
58
  }
61
59
  for (const record of records) {
62
- if (!subscriber.global && subscriber.directory !== directory)
60
+ if (subscriber.directory !== undefined && subscriber.directory !== directory)
63
61
  continue;
64
62
  if (!this.writeRecord(res, subscriber, record))
65
63
  break;
@@ -102,22 +100,14 @@ export class SSE {
102
100
  this.clients.delete(res);
103
101
  });
104
102
  // OpenCode emits server.connected on subscribe; OpenChamber waits for it.
105
- const connected = subscriber.flavor === "v2"
106
- ? {
107
- id: `evt_${Date.now()}`,
108
- created: Date.now(),
109
- type: "server.connected",
110
- location: { directory: subscriber.directory ?? this.options.defaultDirectory },
111
- data: {},
112
- }
113
- : { type: "server.connected", properties: {} };
114
- const initial = subscriber.flavor === "v1" && subscriber.global
115
- ? {
116
- directory: subscriber.directory ?? this.options.defaultDirectory,
117
- payload: { id: `evt_${randomUUID()}`, ...connected },
118
- }
119
- : connected;
120
- write(res, `data: ${JSON.stringify(initial)}\n\n`);
103
+ const connected = {
104
+ id: `evt_${Date.now()}`,
105
+ created: Date.now(),
106
+ type: "server.connected",
107
+ location: { directory: subscriber.directory ?? this.options.defaultDirectory },
108
+ data: {},
109
+ };
110
+ write(res, `data: ${JSON.stringify(connected)}\n\n`);
121
111
  const cursor = Number(lastEventID);
122
112
  if (!Number.isSafeInteger(cursor) || cursor < 0)
123
113
  return;
@@ -135,35 +125,12 @@ export class SSE {
135
125
  this.replay.shift();
136
126
  }
137
127
  writeRecord(res, subscriber, record) {
138
- if (!subscriber.global && subscriber.directory !== record.directory)
128
+ if (subscriber.directory !== undefined && subscriber.directory !== record.directory)
139
129
  return true;
140
- const data = subscriber.flavor === "v2"
141
- ? record.v2
142
- : record.v1 === undefined
143
- ? undefined
144
- : subscriber.global
145
- ? {
146
- directory: record.directory,
147
- project: record.directory,
148
- payload: {
149
- id: `evt_${record.id}`,
150
- ...record.v1,
151
- properties: {
152
- ...record.v1.properties,
153
- ...(record.v1.type === "session.created" ||
154
- record.v1.type === "session.updated" ||
155
- record.v1.type === "session.deleted"
156
- ? { sessionID: record.v1.properties.info.id }
157
- : {}),
158
- },
159
- },
160
- }
161
- : record.v1;
130
+ const data = record.v2;
162
131
  if (data === undefined)
163
132
  return true;
164
- const frame = subscriber.flavor === "v1" && !subscriber.global
165
- ? `data: ${JSON.stringify(data)}\n\n`
166
- : `id: ${record.id}\ndata: ${JSON.stringify(data)}\n\n`;
133
+ const frame = `id: ${record.id}\ndata: ${JSON.stringify(data)}\n\n`;
167
134
  return this.writeSSE(res, frame);
168
135
  }
169
136
  }
@@ -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 {};