antechamber 0.1.1 → 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 +28 -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 +9 -5
  18. package/dist/pi-event-translation.js +233 -79
  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 +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 +12 -15
  30. package/dist/session-commands.js +52 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +108 -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 -1
  38. package/dist/session-tree.js +51 -26
  39. package/dist/sessions.d.ts +32 -5
  40. package/dist/sessions.js +330 -119
  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 +84 -71
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +152 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +128 -44
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/extensions/omo-dag/README.md +2 -0
  52. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  53. package/extensions/omo-dag/panel/index.html +1 -1
  54. package/extensions/omo-dag/panel/main.js +60 -60
  55. package/extensions/omo-dag/service/main.js +72 -8
  56. package/package.json +1 -1
package/dist/sessions.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { id, PiEventTranslator, syncSessionCost } from "./pi-event-translation.js";
6
6
  import { parsePiSessionFile, parsePiSessionText } from "./session-history.js";
7
+ import { CONTEXT_MESSAGE_TYPE, nativePromptInput, v2PromptAttachments } from "./prompt-parts.js";
7
8
  import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
8
9
  import { join } from "node:path";
9
10
  import { COMPACT_RPC_TIMEOUT_MS, PiRpcClient, RPC_COMMAND_TIMEOUT_MS, } from "./pi-rpc.js";
@@ -11,15 +12,19 @@ import { stopSessionHosts } from "./session-hosts.js";
11
12
  import { setThinkingLevel } from "./thinking-levels.js";
12
13
  import { SessionLifecycle } from "./lifecycle.js";
13
14
  import { ApprovalBridge, extensionUiRequestFrom } from "./approvals.js";
14
- import { getAgentName } from "./agent-name.js";
15
+ import { getAgentName, getExposedAgentNames } from "./agent-name.js";
16
+ import { RequestBodyError } from "./http.js";
15
17
  import { bindMessageEntries, durableShellTree, forkConversation, getEntries, navigate, readSessionTree, revertConversation, SessionTreeError, unrevertConversation, } from "./session-tree.js";
16
18
  import { SubagentBridge } from "./subagents.js";
17
19
  import { sessionDiff } from "./session-diff.js";
18
20
  import { announceSessionNotice, slashTitlePrompt } from "./session-commands.js";
19
- import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionPersistenceError, SessionStore, readSessionTitle, } from "./session-store.js";
21
+ import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionStore, readSessionTitle, } from "./session-store.js";
20
22
  import { ADAPTER_VERSION } from "./version.js";
21
23
  export { ADAPTER_VERSION } from "./version.js";
22
24
  const SESSION_SCAN_CACHE_TTL_MS = 5_000;
25
+ const PI_FILE_RESOLVE_ATTEMPTS = 10;
26
+ const PI_FILE_RESOLVE_RETRY_MS = 200;
27
+ const SESSION_TITLE_MAX_CHARS = 60;
23
28
  export class SessionManager {
24
29
  emit;
25
30
  defaultDirectory;
@@ -57,10 +62,14 @@ export class SessionManager {
57
62
  persistMeta: (s) => this.persistMeta(s),
58
63
  });
59
64
  }
60
- async create(directory, title, parentID) {
61
- return this.lifecycle.run(() => this.createLive(directory, title, parentID));
65
+ async create(directory, title, parentID, options = {}) {
66
+ if (options.agent !== undefined && !getExposedAgentNames().includes(options.agent))
67
+ throw new RequestBodyError(400, `unknown agent: ${options.agent}`);
68
+ return this.lifecycle.run(() => this.createLive(directory, title, parentID, options));
62
69
  }
63
- async createLive(directory, title, parentID) {
70
+ async createLive(directory, title, parentID, options = {}) {
71
+ if (options.id !== undefined && (options.id === "" || this.get(options.id) !== undefined))
72
+ throw new RequestBodyError(400, "session id must be nonempty and unused");
64
73
  await this.lifecycle.makeRoom();
65
74
  const pi = new PiRpcClient({ cwd: directory, clock: this.lifecycle.clock });
66
75
  const state = await pi.command({ type: "get_state" }).catch(async (error) => {
@@ -78,12 +87,18 @@ export class SessionManager {
78
87
  if (piSessionId !== undefined)
79
88
  this.createdSessions.set(piSessionId, directory);
80
89
  const info = {
81
- id: piSessionId !== undefined ? ocIdFor(piSessionId) : id("ses"),
90
+ id: options.id ?? (piSessionId !== undefined ? ocIdFor(piSessionId) : id("ses")),
82
91
  projectID: directory,
83
92
  directory,
84
93
  title: title?.trim() || "New session",
85
94
  version: ADAPTER_VERSION,
86
95
  time: { created: now, updated: now },
96
+ agent: options.agent ?? getAgentName(),
97
+ slug: piSessionId ?? id("session"),
98
+ path: "",
99
+ summary: { additions: 0, deletions: 0, files: 0 },
100
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
101
+ ...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
87
102
  ...(parentID !== undefined ? { parentID } : {}),
88
103
  };
89
104
  const managed = {
@@ -101,44 +116,73 @@ export class SessionManager {
101
116
  ...(piSessionId !== undefined ? { piSessionId } : {}),
102
117
  ...(state.data.thinkingLevel !== undefined ? { thinkingLevel: state.data.thinkingLevel } : {}),
103
118
  };
104
- managed.approvals = this.approvalsFor(managed);
105
- this.sessions.set(info.id, managed);
106
- this.subagents.wire(managed);
107
- this.lifecycle.touch(managed);
108
- this.wirePi(managed, pi);
109
- this.emit(directory, { type: "session.created", properties: { info } });
110
- // Announce pi's real model *after* session.created, so the client already
111
- // knows the session when the switch marker lands in its transcript.
112
- this.applyPiModel(managed, state.data.model?.provider, state.data.model?.id);
113
- // Bind pi's session file so the session survives adapter restarts. pi
114
- // reports the path directly; fall back to a background scan for older pi
115
- // versions, and re-bind after the first prompt (pi persists lazily).
116
- if (typeof state.data.sessionFile === "string" && state.data.sessionFile !== "") {
117
- managed.piPath = state.data.sessionFile;
118
- this.persistMeta(managed);
119
- }
120
- else if (piSessionId !== undefined) {
121
- void this.resolvePiFile(directory, piSessionId)
122
- .then((piPath) => {
123
- if (piPath !== undefined && managed.piPath === undefined) {
119
+ let published = false;
120
+ try {
121
+ managed.approvals = this.approvalsFor(managed);
122
+ this.sessions.set(info.id, managed);
123
+ published = true;
124
+ this.subagents.wire(managed);
125
+ this.lifecycle.touch(managed);
126
+ this.wirePi(managed, pi);
127
+ if (options.model !== undefined)
128
+ await this.setModel(info.id, options.model);
129
+ this.emit(directory, { type: "session.created", properties: { info } });
130
+ // Announce pi's real model *after* session.created, so the client already
131
+ // knows the session when the switch marker lands in its transcript.
132
+ if (options.model === undefined)
133
+ this.applyPiModel(managed, state.data.model?.provider, state.data.model?.id);
134
+ // Bind pi's session file so the session survives adapter restarts. pi
135
+ // reports the path directly; fall back to a background scan for older pi
136
+ // versions, and re-bind after the first prompt (pi persists lazily).
137
+ if (typeof state.data.sessionFile === "string" && state.data.sessionFile !== "") {
138
+ managed.piPath = state.data.sessionFile;
139
+ this.persistMeta(managed);
140
+ }
141
+ else if (piSessionId !== undefined) {
142
+ void this.resolvePiFile(directory, piSessionId)
143
+ .then((piPath) => {
144
+ if (this.sessions.get(info.id) !== managed || piPath === undefined || managed.piPath !== undefined)
145
+ return;
124
146
  managed.piPath = piPath;
125
147
  this.persistMeta(managed);
126
- }
127
- })
128
- .catch((error) => this.reportPersistenceError(managed, error));
129
- }
130
- if (managed.titleSource === "user") {
131
- const renamed = await pi.command({ type: "set_session_name", name: info.title });
132
- if (!renamed.success)
133
- throw new Error(renamed.error ?? "session rename rejected");
148
+ })
149
+ .catch((error) => {
150
+ if (this.sessions.get(info.id) === managed)
151
+ this.reportPersistenceError(managed, error);
152
+ });
153
+ }
154
+ if (managed.titleSource === "user") {
155
+ const renamed = await pi.command({ type: "set_session_name", name: info.title });
156
+ if (!renamed.success)
157
+ throw new Error(renamed.error ?? "session rename rejected");
158
+ }
159
+ else {
160
+ this.applyNativeTitle(managed, state.data.sessionName);
161
+ }
162
+ const reconciled = await pi.command({ type: "get_state" });
163
+ if (reconciled.success)
164
+ this.applyNativeTitle(managed, reconciled.data?.sessionName);
165
+ return info;
134
166
  }
135
- else {
136
- this.applyNativeTitle(managed, state.data.sessionName);
167
+ catch (error) {
168
+ if (published)
169
+ await this.rollbackCreate(managed, piSessionId);
170
+ else
171
+ await pi.dispose();
172
+ throw error;
137
173
  }
138
- const reconciled = await pi.command({ type: "get_state" });
139
- if (reconciled.success)
140
- this.applyNativeTitle(managed, reconciled.data?.sessionName);
141
- return info;
174
+ }
175
+ async rollbackCreate(managed, piSessionId) {
176
+ this.sessions.delete(managed.info.id);
177
+ if (piSessionId !== undefined)
178
+ this.createdSessions.delete(piSessionId);
179
+ managed.unwirePi?.();
180
+ const pi = managed.pi;
181
+ managed.pi = undefined;
182
+ if (pi !== undefined)
183
+ await pi.dispose();
184
+ this.store.remove(managed.info.id);
185
+ this.emit(managed.info.directory, { type: "session.deleted", properties: { info: managed.info } });
142
186
  }
143
187
  applyNativeTitle(s, value) {
144
188
  if (s.titleSource === "user" || typeof value !== "string")
@@ -186,12 +230,15 @@ export class SessionManager {
186
230
  const onExit = (code) => {
187
231
  if (s.pi !== pi)
188
232
  return;
189
- s.approvals?.finishTurn();
233
+ this.events.finishInterruptedRun(s, {
234
+ name: "UnknownError",
235
+ data: { message: `pi process exited (code=${code})` },
236
+ });
237
+ delete s.interruptRequested;
190
238
  this.subagents.stopParent(s.info.id);
191
239
  void this.lifecycle
192
240
  .dormant(s)
193
241
  .catch((error) => console.error("[lifecycle] exit cleanup failed:", error));
194
- this.events.setStatus(s, { type: "idle" });
195
242
  this.emit(s.info.directory, {
196
243
  type: "session.error",
197
244
  properties: {
@@ -199,6 +246,7 @@ export class SessionManager {
199
246
  error: { name: "UnknownError", data: { message: `pi process exited (code=${code})` } },
200
247
  },
201
248
  });
249
+ this.events.setStatus(s, { type: "idle" });
202
250
  };
203
251
  pi.onEvent(onEvent);
204
252
  pi.onExtensionUI(onExtensionUI);
@@ -214,7 +262,7 @@ export class SessionManager {
214
262
  async resolvePiFile(directory, piSessionId) {
215
263
  const dir = piSessionDir(directory);
216
264
  const suffix = `_${piSessionId}.jsonl`;
217
- for (let attempt = 0; attempt < 10; attempt++) {
265
+ for (let attempt = 0; attempt < PI_FILE_RESOLVE_ATTEMPTS; attempt++) {
218
266
  try {
219
267
  for (const name of readdirSync(dir)) {
220
268
  if (name.endsWith(suffix))
@@ -224,7 +272,7 @@ export class SessionManager {
224
272
  catch {
225
273
  /* session dir may not exist until pi writes the first entry */
226
274
  }
227
- await new Promise((resolve) => setTimeout(resolve, 200));
275
+ await new Promise((resolve) => setTimeout(resolve, PI_FILE_RESOLVE_RETRY_MS));
228
276
  }
229
277
  return undefined;
230
278
  }
@@ -277,8 +325,9 @@ export class SessionManager {
277
325
  try {
278
326
  unlinkSync(piPath);
279
327
  }
280
- catch {
281
- /* file already gone — deleting the registry entry is enough */
328
+ catch (error) {
329
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
330
+ throw error;
282
331
  }
283
332
  }
284
333
  this.store.remove(sessionId);
@@ -290,14 +339,26 @@ export class SessionManager {
290
339
  * (SDK v2 session.update); `title` is only touched when actually sent,
291
340
  * otherwise an archive-only PATCH would blank the title.
292
341
  */
293
- update(sessionId, patch) {
342
+ async update(sessionId, patch) {
294
343
  const s = this.get(sessionId);
295
344
  if (!s)
296
345
  return undefined;
346
+ if (patch.agent !== undefined && !getExposedAgentNames().includes(patch.agent))
347
+ throw new RequestBodyError(400, `unknown agent: ${patch.agent}`);
297
348
  if (typeof patch.title === "string" && patch.title.trim() !== "") {
298
- s.info.title = patch.title.trim().replace(/\s+/g, " ");
349
+ const title = patch.title.trim().replace(/\s+/g, " ");
350
+ if (s.pi !== undefined) {
351
+ const renamed = await s.pi.command({ type: "set_session_name", name: title });
352
+ if (!renamed.success)
353
+ throw new Error(renamed.error ?? "session rename rejected");
354
+ }
355
+ s.info.title = title;
299
356
  s.titleSource = "user";
300
357
  }
358
+ if (patch.metadata !== undefined)
359
+ s.info.metadata = patch.metadata;
360
+ if (patch.agent !== undefined)
361
+ s.info.agent = patch.agent;
301
362
  if (patch.archived !== undefined) {
302
363
  if (patch.archived > 0)
303
364
  s.info.time.archived = patch.archived;
@@ -306,9 +367,6 @@ export class SessionManager {
306
367
  }
307
368
  s.info.time.updated = Date.now();
308
369
  this.persistMeta(s);
309
- if (typeof patch.title === "string" && patch.title !== "" && s.pi !== undefined) {
310
- void s.pi.command({ type: "set_session_name", name: s.info.title }).catch(() => undefined);
311
- }
312
370
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
313
371
  return s.info;
314
372
  }
@@ -349,6 +407,25 @@ export class SessionManager {
349
407
  this.operations.delete(s.info.id);
350
408
  }
351
409
  }
410
+ /** Cancellation releases only the caller; the native action keeps its queue lease. */
411
+ async waitForOperation(pending, signal) {
412
+ if (!signal)
413
+ return pending;
414
+ let onAbort;
415
+ const aborted = new Promise((_, reject) => {
416
+ onAbort = () => reject(signal.reason);
417
+ signal.addEventListener("abort", onAbort, { once: true });
418
+ if (signal.aborted)
419
+ onAbort();
420
+ });
421
+ try {
422
+ return await Promise.race([pending, aborted]);
423
+ }
424
+ finally {
425
+ if (onAbort)
426
+ signal.removeEventListener("abort", onAbort);
427
+ }
428
+ }
352
429
  async syncTree(s, treeOperation = false) {
353
430
  const pi = s.pi;
354
431
  if (!pi)
@@ -405,7 +482,8 @@ export class SessionManager {
405
482
  });
406
483
  }
407
484
  async fork(sessionId, messageID) {
408
- return this.treeSession(sessionId, async (s, pi) => {
485
+ // Admission must see either the parent or child owner throughout the transfer.
486
+ return this.treeSession(sessionId, (s, pi) => this.lifecycle.run(async () => {
409
487
  const tree = await this.syncTree(s, true);
410
488
  const state = await forkConversation(s, pi, tree, messageID);
411
489
  this.createdSessions.set(state.sessionId, s.info.directory);
@@ -423,6 +501,12 @@ export class SessionManager {
423
501
  child.piSessionId = state.sessionId;
424
502
  delete child.restored;
425
503
  child.model = s.model;
504
+ child.info.parentID = s.info.id;
505
+ if (messageID !== undefined)
506
+ child.info.parentMessageID = messageID;
507
+ child.info.agent = s.info.agent ?? getAgentName();
508
+ if (s.info.metadata !== undefined)
509
+ child.info.metadata = structuredClone(s.info.metadata);
426
510
  const childTree = await getEntries(pi);
427
511
  child.tree.leafId = childTree.leafId;
428
512
  this.ensureMessages(child);
@@ -431,7 +515,7 @@ export class SessionManager {
431
515
  this.persistMeta(child);
432
516
  this.emit(child.info.directory, { type: "session.created", properties: { info: child.info } });
433
517
  return child.info;
434
- });
518
+ }));
435
519
  }
436
520
  async revert(sessionId, messageID) {
437
521
  return this.treeSession(sessionId, async (s, pi) => {
@@ -441,6 +525,10 @@ export class SessionManager {
441
525
  return s.info;
442
526
  this.persistMeta(s);
443
527
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
528
+ this.emit(s.info.directory, {
529
+ type: "session.revert.staged",
530
+ properties: { sessionID: s.info.id, revert: { messageID } },
531
+ });
444
532
  return s.info;
445
533
  });
446
534
  }
@@ -451,6 +539,7 @@ export class SessionManager {
451
539
  return s.info;
452
540
  this.persistMeta(s);
453
541
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
542
+ this.emit(s.info.directory, { type: "session.revert.cleared", properties: { sessionID: s.info.id } });
454
543
  return s.info;
455
544
  });
456
545
  }
@@ -458,7 +547,7 @@ export class SessionManager {
458
547
  const s = this.get(sessionId);
459
548
  if (!s)
460
549
  return undefined;
461
- return this.operation(s, () => this.reloadMessagesLive(s, signal));
550
+ return this.waitForOperation(this.operation(s, () => this.reloadMessagesLive(s, signal)), signal);
462
551
  }
463
552
  /** Called only within the conversation operation queue, including RPC refreshes. */
464
553
  async reloadMessagesLive(s, signal) {
@@ -469,13 +558,13 @@ export class SessionManager {
469
558
  const pi = s.pi;
470
559
  if (!pi)
471
560
  throw new SessionTreeError("Session cannot be resumed");
472
- const tree = await getEntries(pi, signal);
561
+ const tree = await getEntries(pi);
473
562
  signal?.throwIfAborted();
474
563
  bindMessageEntries(s, tree);
475
564
  // Compact/bash and extension slash commands can append without agent_end.
476
565
  // Binding follows new descendants, never a stale ancestor of the selection.
477
566
  if (s.tree.revert && s.tree.leafId !== undefined && tree.leafId !== s.tree.leafId)
478
- await navigate(pi, s.tree.leafId, signal);
567
+ await navigate(pi, s.tree.leafId);
479
568
  signal?.throwIfAborted();
480
569
  const previous = s.messages ?? [];
481
570
  const messages = this.refreshMessageCache(s);
@@ -493,7 +582,7 @@ export class SessionManager {
493
582
  for (const part of message.parts)
494
583
  this.events.emitPart(s, part);
495
584
  }
496
- this.update(sessionId, {});
585
+ await this.update(sessionId, {});
497
586
  return messages;
498
587
  }
499
588
  statuses() {
@@ -509,11 +598,45 @@ export class SessionManager {
509
598
  this.ensureMessages(s);
510
599
  return s.todoBridge?.todos ?? [];
511
600
  }
512
- async diff(sessionId) {
601
+ async commitRevert(sessionId) {
602
+ await this.treeSession(sessionId, async (s, pi) => {
603
+ await this.commitRevertLive(s, pi);
604
+ return s.info;
605
+ });
606
+ }
607
+ async commitRevertLive(s, pi) {
608
+ const revert = s.tree.revert;
609
+ if (revert === undefined)
610
+ return;
611
+ const boundary = s.messages?.findIndex((message) => message.info.id === revert.messageID) ?? -1;
612
+ if (boundary >= 0) {
613
+ for (const message of s.messages?.splice(boundary) ?? []) {
614
+ this.emit(s.info.directory, {
615
+ type: "message.removed",
616
+ properties: { sessionID: s.info.id, messageID: message.info.id },
617
+ });
618
+ }
619
+ }
620
+ delete s.tree.revert;
621
+ delete s.info.revert;
622
+ bindMessageEntries(s, await getEntries(pi));
623
+ this.persistMeta(s);
624
+ this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
625
+ this.emit(s.info.directory, {
626
+ type: "session.revert.committed",
627
+ properties: { sessionID: s.info.id, to: revert.messageID },
628
+ });
629
+ }
630
+ async diff(sessionId, options = {}) {
513
631
  const s = this.get(sessionId);
514
632
  if (s === undefined)
515
633
  throw new Error(`unknown session: ${sessionId}`);
516
- return sessionDiff(s.info.directory, this.ensureMessages(s));
634
+ const messages = this.ensureMessages(s);
635
+ const start = options.from === undefined ? 0 : messages.findIndex((message) => message.info.id === options.from);
636
+ const end = options.to === undefined ? messages.length : messages.findIndex((message) => message.info.id === options.to);
637
+ if (start < 0 || end < 0 || start > end)
638
+ throw new RequestBodyError(400, "invalid diff message range");
639
+ return sessionDiff(s.info.directory, messages.slice(start, end), options.context);
517
640
  }
518
641
  emitDiff(s) {
519
642
  if (!this.dirtyDiffs.delete(s.info.id))
@@ -544,9 +667,17 @@ export class SessionManager {
544
667
  if (!s)
545
668
  return false;
546
669
  if (s.pi !== undefined) {
547
- const response = await s.pi.command({ type: "abort" });
548
- if (!response.success)
549
- throw new Error(response.error ?? "pi abort failed");
670
+ if (s.status.type !== "idle")
671
+ s.interruptRequested = true;
672
+ try {
673
+ const response = await s.pi.command({ type: "abort" });
674
+ if (!response.success)
675
+ throw new Error(response.error ?? "pi abort failed");
676
+ }
677
+ catch (error) {
678
+ delete s.interruptRequested;
679
+ throw error;
680
+ }
550
681
  }
551
682
  s.cancelShell?.();
552
683
  s.approvals?.cancelAll();
@@ -601,27 +732,13 @@ export class SessionManager {
601
732
  throw new Error(`session cannot be resumed: ${sessionId}`);
602
733
  this.lifecycle.touch(s);
603
734
  const timeout = command.type === "compact" ? COMPACT_RPC_TIMEOUT_MS : RPC_COMMAND_TIMEOUT_MS;
604
- const response = await pi.command(command, timeout, signal, onDispatch);
735
+ signal?.throwIfAborted();
736
+ const response = await pi.command(command, timeout, undefined, onDispatch);
605
737
  if (!signal?.aborted && response.success && (command.type === "compact" || command.type === "bash"))
606
738
  await this.reloadMessagesLive(s, signal);
607
739
  return response;
608
740
  });
609
- if (!signal)
610
- return pending;
611
- // Cancel the caller's wait without releasing the conversation queue. The
612
- // queued action checks the signal before dispatch when its predecessor ends.
613
- let onAbort;
614
- const aborted = new Promise((_, reject) => {
615
- onAbort = () => reject(signal.reason);
616
- signal.addEventListener("abort", onAbort, { once: true });
617
- });
618
- try {
619
- return await Promise.race([pending, aborted]);
620
- }
621
- finally {
622
- if (onAbort)
623
- signal.removeEventListener("abort", onAbort);
624
- }
741
+ return this.waitForOperation(pending, signal);
625
742
  }
626
743
  /**
627
744
  * POST /api/session/{id}/model — OpenChamber awaits this before every prompt
@@ -693,6 +810,7 @@ export class SessionManager {
693
810
  return false;
694
811
  const model = { providerID: provider, modelID: modelId };
695
812
  s.model = model;
813
+ s.info.model = { id: modelId, providerID: provider, variant: s.thinkingLevel ?? "default" };
696
814
  this.persistMeta(s);
697
815
  this.emit(s.info.directory, {
698
816
  type: "model.changed",
@@ -700,13 +818,13 @@ export class SessionManager {
700
818
  });
701
819
  return true;
702
820
  }
703
- async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer") {
821
+ async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
704
822
  const s = this.get(sessionId);
705
823
  if (!s)
706
824
  throw new Error(`unknown session: ${sessionId}`);
707
- return this.operation(s, () => this.promptLive(s, text, model, noReply, messageID, delivery));
825
+ return this.operation(s, () => this.promptLive(s, text, model, noReply, messageID, delivery, parts, metadata));
708
826
  }
709
- async promptLive(s, text, model, noReply = false, messageID, delivery = "steer") {
827
+ async preparePrompt(s, model) {
710
828
  const sessionId = s.info.id;
711
829
  if (s.pi === undefined)
712
830
  await this.revive(s);
@@ -715,41 +833,29 @@ export class SessionManager {
715
833
  throw new Error(`session cannot be resumed: ${sessionId}`);
716
834
  this.lifecycle.touch(s);
717
835
  this.ensureMessages(s);
718
- if (s.tree.revert) {
719
- const boundary = s.messages?.findIndex((message) => message.info.id === s.tree.revert?.messageID) ?? -1;
720
- if (boundary >= 0) {
721
- for (const message of s.messages?.splice(boundary) ?? []) {
722
- this.emit(s.info.directory, {
723
- type: "message.removed",
724
- properties: { sessionID: s.info.id, messageID: message.info.id },
725
- });
726
- }
727
- }
728
- delete s.tree.revert;
729
- delete s.info.revert;
730
- bindMessageEntries(s, await getEntries(pi));
731
- this.persistMeta(s);
732
- this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
733
- }
734
- // Same funnel as POST /api/session/{id}/model. Failing loudly matters: the
735
- // previous code swallowed pi's refusal and then reported the *requested*
736
- // model as though the switch had happened.
836
+ await this.commitRevertLive(s, pi);
737
837
  if (model !== undefined) {
738
838
  try {
739
839
  // This prompt already owns the operation queue; do not enqueue recursively.
740
840
  await this.setModelLive(s, model);
741
841
  }
742
- catch (err) {
842
+ catch (error) {
743
843
  this.emit(s.info.directory, {
744
844
  type: "session.error",
745
845
  properties: {
746
846
  sessionID: s.info.id,
747
- error: { name: "UnknownError", data: { message: err instanceof Error ? err.message : String(err) } },
847
+ error: {
848
+ name: "UnknownError",
849
+ data: { message: error instanceof Error ? error.message : String(error) },
850
+ },
748
851
  },
749
852
  });
750
- throw err;
853
+ throw error;
751
854
  }
752
855
  }
856
+ return pi;
857
+ }
858
+ publishPrompt(s, text, model, messageID, inputParts, metadata, context = false) {
753
859
  // OpenChamber sends a client-generated messageID with its optimistic
754
860
  // insert; the server MUST reuse it so the echoed message.part.updated /
755
861
  // message.updated events reconcile the optimistic entry in place instead
@@ -761,12 +867,23 @@ export class SessionManager {
761
867
  time: { created: Date.now() },
762
868
  agent: getAgentName(),
763
869
  model: s.model ?? model ?? { providerID: "unknown", modelID: "unknown" },
870
+ ...(context ? { synthetic: true } : {}),
871
+ ...(metadata !== undefined ? { metadata } : {}),
764
872
  };
765
- const parts = [{ id: id("prt"), sessionID: s.info.id, messageID: userMsg.id, type: "text", text }];
873
+ if (metadata !== undefined)
874
+ (s.tree.promptMetadata ??= {})[userMsg.id] = metadata;
875
+ const parts = (inputParts ?? [{ type: "text", text }]).map((part) => ({
876
+ ...part,
877
+ id: part.id ?? id("prt"),
878
+ sessionID: s.info.id,
879
+ messageID: userMsg.id,
880
+ }));
881
+ if (inputParts?.some((part) => part.type !== "text"))
882
+ (s.tree.promptParts ??= {})[userMsg.id] = [...inputParts];
766
883
  s.messages?.push({ info: userMsg, parts });
767
884
  s.info.time.updated = Date.now();
768
- if (s.info.title === "New session" && s.titleSource !== "user" && s.titleSource !== "native") {
769
- s.info.title = text.trim().replace(/\s+/g, " ").slice(0, 60) || "New session";
885
+ if (!context && s.info.title === "New session" && s.titleSource !== "user" && s.titleSource !== "native") {
886
+ s.info.title = text.trim().replace(/\s+/g, " ").slice(0, SESSION_TITLE_MAX_CHARS) || "New session";
770
887
  s.titleSource = "fallback";
771
888
  }
772
889
  this.persistMeta(s);
@@ -775,19 +892,74 @@ export class SessionManager {
775
892
  // Echo the user text part: the reducer replaces the client's sessionID-less
776
893
  // optimistic part (same type, new id) in place — without this echo a page
777
894
  // fetch merges the optimistic part back in and the text shows twice.
778
- for (const p of parts)
779
- this.events.emitPart(s, p);
780
- if (noReply)
781
- return; // register-only semantics: record the message, do not run the agent
895
+ for (const part of parts)
896
+ this.events.emitPart(s, part);
897
+ return userMsg;
898
+ }
899
+ async promptLive(s, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
900
+ const pi = await this.preparePrompt(s, model);
901
+ const native = nativePromptInput(text, parts ?? []);
902
+ const userMsg = this.publishPrompt(s, text, model, messageID, parts, metadata, noReply);
903
+ if (noReply) {
904
+ await this.admitContext(s, pi, userMsg, native.message);
905
+ return;
906
+ }
907
+ if (!(await this.dispatchPrompt(s, pi, userMsg, text, delivery, native, parts ?? [])))
908
+ return;
909
+ await this.bindPromptState(s);
910
+ }
911
+ /**
912
+ * No-reply context (v2 synthetic, v1 noReply) joins the native conversation without a turn:
913
+ * OmO appends it now when idle, or after the running turn, so the next prompt carries it.
914
+ */
915
+ async admitContext(s, pi, userMsg, text) {
916
+ const res = await pi.command({
917
+ type: "send_custom_message",
918
+ customType: CONTEXT_MESSAGE_TYPE,
919
+ content: [{ type: "text", text }],
920
+ display: true,
921
+ details: { messageID: userMsg.id },
922
+ triggerTurn: false,
923
+ });
924
+ if (!res.success) {
925
+ if (s.tree.promptParts)
926
+ delete s.tree.promptParts[userMsg.id];
927
+ if (s.tree.promptMetadata)
928
+ delete s.tree.promptMetadata[userMsg.id];
929
+ s.messages = s.messages?.filter((message) => message.info.id !== userMsg.id);
930
+ this.emit(s.info.directory, {
931
+ type: "message.removed",
932
+ properties: { sessionID: s.info.id, messageID: userMsg.id },
933
+ });
934
+ throw new Error(res.error ?? "context admission rejected");
935
+ }
936
+ // Bind the appended entry and persist the new leaf before announcing delivery, or a restart
937
+ // restores the old branch without the context.
938
+ await this.bindPromptState(s);
939
+ this.emit(s.info.directory, {
940
+ type: "session.inbox.enqueued",
941
+ properties: {
942
+ sessionID: s.info.id,
943
+ inboxID: userMsg.id,
944
+ item: { type: "synthetic", payload: { text, ...(userMsg.metadata ? { metadata: userMsg.metadata } : {}) } },
945
+ },
946
+ });
947
+ this.events.deliverInput(s, userMsg.id);
948
+ }
949
+ async dispatchPrompt(s, pi, userMsg, text, delivery, native, parts) {
782
950
  const wasIdle = s.status.type === "idle";
783
951
  if (wasIdle) {
784
952
  s.toolByCallId = new Map();
785
953
  this.events.setStatus(s, { type: "busy" });
786
954
  }
787
- const input = { text, delivery, accepted: false, delivered: false };
955
+ const input = { text: native.message, delivery, accepted: false, delivered: false };
788
956
  (s.inputs ??= new Map()).set(userMsg.id, input);
789
957
  const rejectInput = () => {
790
958
  s.inputs?.delete(userMsg.id);
959
+ if (s.tree.promptParts)
960
+ delete s.tree.promptParts[userMsg.id];
961
+ if (s.tree.promptMetadata)
962
+ delete s.tree.promptMetadata[userMsg.id];
791
963
  s.messages = s.messages?.filter((message) => message.info.id !== userMsg.id);
792
964
  this.emit(s.info.directory, {
793
965
  type: "message.removed",
@@ -806,7 +978,7 @@ export class SessionManager {
806
978
  const res = await pi
807
979
  .command({
808
980
  type: "prompt",
809
- message: text,
981
+ ...native,
810
982
  streamingBehavior: delivery === "queue" ? "followUp" : "steer",
811
983
  clientMessageId: userMsg.id,
812
984
  ...slashTitlePrompt(text),
@@ -827,12 +999,24 @@ export class SessionManager {
827
999
  error: { name: "UnknownError", data: { message: res.error ?? "prompt rejected" } },
828
1000
  },
829
1001
  });
830
- return;
1002
+ return false;
831
1003
  }
832
1004
  input.accepted = true;
833
1005
  this.emit(s.info.directory, {
834
1006
  type: "session.inbox.enqueued",
835
- properties: { sessionID: s.info.id, inboxID: userMsg.id, item: { type: "user", delivery, payload: { text } } },
1007
+ properties: {
1008
+ sessionID: s.info.id,
1009
+ inboxID: userMsg.id,
1010
+ item: {
1011
+ type: "user",
1012
+ delivery,
1013
+ payload: {
1014
+ text,
1015
+ ...v2PromptAttachments(parts),
1016
+ ...(userMsg.metadata ? { metadata: userMsg.metadata } : {}),
1017
+ },
1018
+ },
1019
+ },
836
1020
  });
837
1021
  if (input.delivered)
838
1022
  this.events.deliverInput(s, userMsg.id);
@@ -841,9 +1025,14 @@ export class SessionManager {
841
1025
  typeof res.data === "object" &&
842
1026
  "disposition" in res.data &&
843
1027
  res.data.disposition === "handled") {
1028
+ if (!input.delivered)
1029
+ this.events.deliverInput(s, userMsg.id);
844
1030
  if (wasIdle)
845
1031
  this.events.setStatus(s, { type: "idle" });
846
1032
  }
1033
+ return true;
1034
+ }
1035
+ async bindPromptState(s) {
847
1036
  // First message makes pi write the session file — bind it now so the
848
1037
  // session (and its current title) survives an adapter restart.
849
1038
  if (s.piPath === undefined && s.piSessionId !== undefined) {
@@ -1022,7 +1211,7 @@ export class SessionManager {
1022
1211
  }
1023
1212
  }
1024
1213
  adopt(sc) {
1025
- const ocId = ocIdFor(sc.piSessionId);
1214
+ const ocId = this.store.idFor(sc.piSessionId) ?? ocIdFor(sc.piSessionId);
1026
1215
  const reg = this.store.get(ocId);
1027
1216
  const titleSource = reg?.titleSource ??
1028
1217
  (reg?.title && reg.title !== "New session" && reg.title !== sc.fallbackTitle ? "user" : "fallback");
@@ -1032,6 +1221,19 @@ export class SessionManager {
1032
1221
  projectID: sc.directory,
1033
1222
  directory: sc.directory,
1034
1223
  title: useRegistry ? (reg?.title ?? sc.title) : sc.title,
1224
+ agent: reg?.agent ?? getAgentName(),
1225
+ slug: sc.piSessionId,
1226
+ path: "",
1227
+ summary: { additions: 0, deletions: 0, files: 0 },
1228
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
1229
+ ...(reg?.metadata !== undefined ? { metadata: reg.metadata } : {}),
1230
+ ...(reg?.parentID !== undefined ? { parentID: reg.parentID } : {}),
1231
+ ...(reg?.parentMessageID !== undefined ? { parentMessageID: reg.parentMessageID } : {}),
1232
+ ...(reg?.model !== undefined
1233
+ ? {
1234
+ model: { id: reg.model.modelID, providerID: reg.model.providerID, variant: reg.model.variant ?? "default" },
1235
+ }
1236
+ : {}),
1035
1237
  ...(reg?.tree?.revert ? { revert: { messageID: reg.tree.revert.messageID } } : {}),
1036
1238
  version: ADAPTER_VERSION,
1037
1239
  time: {
@@ -1089,6 +1291,11 @@ export class SessionManager {
1089
1291
  }
1090
1292
  /** Registry write for adapter-owned metadata (title override, pi path). */
1091
1293
  persistMeta(s) {
1294
+ for (const message of s.messages ?? []) {
1295
+ if (s.localMessageIds?.has(message.info.id))
1296
+ continue;
1297
+ (s.tree.messageParts ??= {})[message.info.id] = message.parts.map((part) => part.id);
1298
+ }
1092
1299
  const piPath = s.piPath ?? s.restored?.piPath;
1093
1300
  const piSessionId = s.piSessionId ?? s.restored?.piSessionId;
1094
1301
  if (piPath === undefined || piSessionId === undefined)
@@ -1103,6 +1310,10 @@ export class SessionManager {
1103
1310
  ...(s.info.title !== "New session" ? { title: s.info.title } : {}),
1104
1311
  ...(s.titleSource !== undefined ? { titleSource: s.titleSource } : {}),
1105
1312
  ...(s.info.time.archived !== undefined ? { archived: s.info.time.archived } : {}),
1313
+ ...(s.info.metadata !== undefined ? { metadata: s.info.metadata } : {}),
1314
+ ...(s.info.agent !== undefined ? { agent: s.info.agent } : {}),
1315
+ ...(s.info.parentID !== undefined ? { parentID: s.info.parentID } : {}),
1316
+ ...(s.info.parentMessageID !== undefined ? { parentMessageID: s.info.parentMessageID } : {}),
1106
1317
  ...(s.model !== undefined ? { model: { providerID: s.model.providerID, modelID: s.model.modelID } } : {}),
1107
1318
  created: s.info.time.created,
1108
1319
  updated: s.info.time.updated,
@@ -1110,11 +1321,11 @@ export class SessionManager {
1110
1321
  }
1111
1322
  /** Async event callbacks cannot reject an HTTP operation; report save failures through SSE. */
1112
1323
  reportPersistenceError(s, error) {
1113
- if (!(error instanceof SessionPersistenceError))
1114
- throw error;
1324
+ const name = error instanceof Error ? error.name : "UnknownError";
1325
+ const message = error instanceof Error ? error.message : String(error);
1115
1326
  this.emit(s.info.directory, {
1116
1327
  type: "session.error",
1117
- properties: { sessionID: s.info.id, error: { name: error.name, data: { message: error.message } } },
1328
+ properties: { sessionID: s.info.id, error: { name, data: { message } } },
1118
1329
  });
1119
1330
  }
1120
1331
  /** Keep the existing event entry point for RPC wiring and test harnesses. */