antechamber 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +17 -14
  3. package/bin/opencode-antechamber +10 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +21 -3
  18. package/dist/pi-event-translation.js +317 -65
  19. package/dist/pi-rpc.d.ts +26 -2
  20. package/dist/pi-rpc.js +102 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +110 -65
  26. package/dist/routes-v2.js +376 -73
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +20 -15
  30. package/dist/session-commands.js +61 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +113 -42
  34. package/dist/session-hosts.js +2 -2
  35. package/dist/session-store.d.ts +8 -4
  36. package/dist/session-store.js +183 -33
  37. package/dist/session-tree.d.ts +9 -2
  38. package/dist/session-tree.js +51 -63
  39. package/dist/sessions.d.ts +34 -6
  40. package/dist/sessions.js +340 -146
  41. package/dist/sse.d.ts +16 -6
  42. package/dist/sse.js +77 -21
  43. package/dist/subagents.d.ts +5 -0
  44. package/dist/subagents.js +86 -69
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +164 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +141 -50
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/extensions/omo-dag/README.md +2 -0
  52. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  53. package/extensions/omo-dag/panel/index.html +1 -1
  54. package/extensions/omo-dag/panel/main.js +60 -60
  55. package/extensions/omo-dag/service/main.js +72 -8
  56. package/package.json +1 -1
package/dist/sessions.js CHANGED
@@ -2,24 +2,29 @@
2
2
  * Session registry: one OpenCode session maps to one `pi --mode rpc` process.
3
3
  * Composes the live event bridge and restored-session history reconstruction.
4
4
  */
5
- import { id, PiEventTranslator } from "./pi-event-translation.js";
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
- import { PiRpcClient, RPC_COMMAND_TIMEOUT_MS } from "./pi-rpc.js";
10
+ import { COMPACT_RPC_TIMEOUT_MS, PiRpcClient, RPC_COMMAND_TIMEOUT_MS, } from "./pi-rpc.js";
10
11
  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 { bindMessageEntries, durableShellTree, forkConversation, getEntries, navigate, readSessionTree, revertConversation, revertNotice, SessionTreeError, unrevertConversation, } from "./session-tree.js";
15
+ import { getAgentName, getExposedAgentNames } from "./agent-name.js";
16
+ import { RequestBodyError } from "./http.js";
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
- import { announceSessionNotice } from "./session-commands.js";
19
- import { ocIdFor, piSessionDir, piSessionsRootMtime, scanAllPiSessions, scanPiSessions, SessionPersistenceError, SessionStore, readSessionTitle, } from "./session-store.js";
20
+ import { announceSessionNotice, slashTitlePrompt } from "./session-commands.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
  }
@@ -324,11 +382,7 @@ export class SessionManager {
324
382
  properties: { sessionID: s.info.id, todos: s.todoBridge.todos },
325
383
  });
326
384
  }
327
- const notice = revertNotice(s);
328
- if (!notice)
329
- return messages;
330
- const boundary = messages.findIndex((message) => message.info.id === s.tree.revert?.messageID);
331
- return [...messages.slice(0, boundary), notice, ...messages.slice(boundary)];
385
+ return messages;
332
386
  }
333
387
  /** Append to canonical history, before the hidden redo branch while reverted. */
334
388
  appendMessage(sessionId, message) {
@@ -353,6 +407,25 @@ export class SessionManager {
353
407
  this.operations.delete(s.info.id);
354
408
  }
355
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
+ }
356
429
  async syncTree(s, treeOperation = false) {
357
430
  const pi = s.pi;
358
431
  if (!pi)
@@ -409,7 +482,8 @@ export class SessionManager {
409
482
  });
410
483
  }
411
484
  async fork(sessionId, messageID) {
412
- 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 () => {
413
487
  const tree = await this.syncTree(s, true);
414
488
  const state = await forkConversation(s, pi, tree, messageID);
415
489
  this.createdSessions.set(state.sessionId, s.info.directory);
@@ -427,6 +501,12 @@ export class SessionManager {
427
501
  child.piSessionId = state.sessionId;
428
502
  delete child.restored;
429
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);
430
510
  const childTree = await getEntries(pi);
431
511
  child.tree.leafId = childTree.leafId;
432
512
  this.ensureMessages(child);
@@ -435,50 +515,39 @@ export class SessionManager {
435
515
  this.persistMeta(child);
436
516
  this.emit(child.info.directory, { type: "session.created", properties: { info: child.info } });
437
517
  return child.info;
438
- });
518
+ }));
439
519
  }
440
520
  async revert(sessionId, messageID) {
441
521
  return this.treeSession(sessionId, async (s, pi) => {
442
522
  const tree = await this.syncTree(s, true);
443
- const previousNotice = revertNotice(s);
523
+ // OpenChamber renders its own reverted-message dock from session.revert.
444
524
  if (!(await revertConversation(s, pi, tree, messageID)))
445
525
  return s.info;
446
- this.removeRevertNotice(s, previousNotice);
447
526
  this.persistMeta(s);
448
527
  this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
449
- const notice = revertNotice(s);
450
- if (notice) {
451
- this.emit(s.info.directory, { type: "message.updated", properties: { info: notice.info } });
452
- for (const part of notice.parts)
453
- this.events.emitPart(s, part);
454
- }
528
+ this.emit(s.info.directory, {
529
+ type: "session.revert.staged",
530
+ properties: { sessionID: s.info.id, revert: { messageID } },
531
+ });
455
532
  return s.info;
456
533
  });
457
534
  }
458
535
  async unrevert(sessionId) {
459
536
  return this.treeSession(sessionId, async (s, pi) => {
460
537
  await this.syncTree(s, true);
461
- const previousNotice = revertNotice(s);
462
538
  if (!(await unrevertConversation(s, pi)))
463
539
  return s.info;
464
- this.removeRevertNotice(s, previousNotice);
465
540
  this.persistMeta(s);
466
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 } });
467
543
  return s.info;
468
544
  });
469
545
  }
470
- removeRevertNotice(s, notice) {
471
- if (notice)
472
- this.emit(s.info.directory, {
473
- type: "message.removed",
474
- properties: { sessionID: s.info.id, messageID: notice.info.id },
475
- });
476
- }
477
546
  async reloadMessages(sessionId, signal) {
478
547
  const s = this.get(sessionId);
479
548
  if (!s)
480
549
  return undefined;
481
- return this.operation(s, () => this.reloadMessagesLive(s, signal));
550
+ return this.waitForOperation(this.operation(s, () => this.reloadMessagesLive(s, signal)), signal);
482
551
  }
483
552
  /** Called only within the conversation operation queue, including RPC refreshes. */
484
553
  async reloadMessagesLive(s, signal) {
@@ -489,13 +558,13 @@ export class SessionManager {
489
558
  const pi = s.pi;
490
559
  if (!pi)
491
560
  throw new SessionTreeError("Session cannot be resumed");
492
- const tree = await getEntries(pi, signal);
561
+ const tree = await getEntries(pi);
493
562
  signal?.throwIfAborted();
494
563
  bindMessageEntries(s, tree);
495
564
  // Compact/bash and extension slash commands can append without agent_end.
496
565
  // Binding follows new descendants, never a stale ancestor of the selection.
497
566
  if (s.tree.revert && s.tree.leafId !== undefined && tree.leafId !== s.tree.leafId)
498
- await navigate(pi, s.tree.leafId, signal);
567
+ await navigate(pi, s.tree.leafId);
499
568
  signal?.throwIfAborted();
500
569
  const previous = s.messages ?? [];
501
570
  const messages = this.refreshMessageCache(s);
@@ -513,7 +582,7 @@ export class SessionManager {
513
582
  for (const part of message.parts)
514
583
  this.events.emitPart(s, part);
515
584
  }
516
- this.update(sessionId, {});
585
+ await this.update(sessionId, {});
517
586
  return messages;
518
587
  }
519
588
  statuses() {
@@ -529,11 +598,45 @@ export class SessionManager {
529
598
  this.ensureMessages(s);
530
599
  return s.todoBridge?.todos ?? [];
531
600
  }
532
- 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 = {}) {
533
631
  const s = this.get(sessionId);
534
632
  if (s === undefined)
535
633
  throw new Error(`unknown session: ${sessionId}`);
536
- 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);
537
640
  }
538
641
  emitDiff(s) {
539
642
  if (!this.dirtyDiffs.delete(s.info.id))
@@ -564,9 +667,17 @@ export class SessionManager {
564
667
  if (!s)
565
668
  return false;
566
669
  if (s.pi !== undefined) {
567
- const response = await s.pi.command({ type: "abort" });
568
- if (!response.success)
569
- 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
+ }
570
681
  }
571
682
  s.cancelShell?.();
572
683
  s.approvals?.cancelAll();
@@ -620,27 +731,14 @@ export class SessionManager {
620
731
  if (pi === undefined)
621
732
  throw new Error(`session cannot be resumed: ${sessionId}`);
622
733
  this.lifecycle.touch(s);
623
- const response = await pi.command(command, RPC_COMMAND_TIMEOUT_MS, signal, onDispatch);
734
+ const timeout = command.type === "compact" ? COMPACT_RPC_TIMEOUT_MS : RPC_COMMAND_TIMEOUT_MS;
735
+ signal?.throwIfAborted();
736
+ const response = await pi.command(command, timeout, undefined, onDispatch);
624
737
  if (!signal?.aborted && response.success && (command.type === "compact" || command.type === "bash"))
625
738
  await this.reloadMessagesLive(s, signal);
626
739
  return response;
627
740
  });
628
- if (!signal)
629
- return pending;
630
- // Cancel the caller's wait without releasing the conversation queue. The
631
- // queued action checks the signal before dispatch when its predecessor ends.
632
- let onAbort;
633
- const aborted = new Promise((_, reject) => {
634
- onAbort = () => reject(signal.reason);
635
- signal.addEventListener("abort", onAbort, { once: true });
636
- });
637
- try {
638
- return await Promise.race([pending, aborted]);
639
- }
640
- finally {
641
- if (onAbort)
642
- signal.removeEventListener("abort", onAbort);
643
- }
741
+ return this.waitForOperation(pending, signal);
644
742
  }
645
743
  /**
646
744
  * POST /api/session/{id}/model — OpenChamber awaits this before every prompt
@@ -712,6 +810,7 @@ export class SessionManager {
712
810
  return false;
713
811
  const model = { providerID: provider, modelID: modelId };
714
812
  s.model = model;
813
+ s.info.model = { id: modelId, providerID: provider, variant: s.thinkingLevel ?? "default" };
715
814
  this.persistMeta(s);
716
815
  this.emit(s.info.directory, {
717
816
  type: "model.changed",
@@ -719,13 +818,13 @@ export class SessionManager {
719
818
  });
720
819
  return true;
721
820
  }
722
- async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer") {
821
+ async prompt(sessionId, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
723
822
  const s = this.get(sessionId);
724
823
  if (!s)
725
824
  throw new Error(`unknown session: ${sessionId}`);
726
- 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));
727
826
  }
728
- async promptLive(s, text, model, noReply = false, messageID, delivery = "steer") {
827
+ async preparePrompt(s, model) {
729
828
  const sessionId = s.info.id;
730
829
  if (s.pi === undefined)
731
830
  await this.revive(s);
@@ -734,42 +833,29 @@ export class SessionManager {
734
833
  throw new Error(`session cannot be resumed: ${sessionId}`);
735
834
  this.lifecycle.touch(s);
736
835
  this.ensureMessages(s);
737
- if (s.tree.revert) {
738
- const boundary = s.messages?.findIndex((message) => message.info.id === s.tree.revert?.messageID) ?? -1;
739
- if (boundary >= 0) {
740
- for (const message of s.messages?.splice(boundary) ?? []) {
741
- this.emit(s.info.directory, {
742
- type: "message.removed",
743
- properties: { sessionID: s.info.id, messageID: message.info.id },
744
- });
745
- }
746
- }
747
- this.removeRevertNotice(s, revertNotice(s));
748
- delete s.tree.revert;
749
- delete s.info.revert;
750
- bindMessageEntries(s, await getEntries(pi));
751
- this.persistMeta(s);
752
- this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
753
- }
754
- // Same funnel as POST /api/session/{id}/model. Failing loudly matters: the
755
- // previous code swallowed pi's refusal and then reported the *requested*
756
- // model as though the switch had happened.
836
+ await this.commitRevertLive(s, pi);
757
837
  if (model !== undefined) {
758
838
  try {
759
839
  // This prompt already owns the operation queue; do not enqueue recursively.
760
840
  await this.setModelLive(s, model);
761
841
  }
762
- catch (err) {
842
+ catch (error) {
763
843
  this.emit(s.info.directory, {
764
844
  type: "session.error",
765
845
  properties: {
766
846
  sessionID: s.info.id,
767
- 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
+ },
768
851
  },
769
852
  });
770
- throw err;
853
+ throw error;
771
854
  }
772
855
  }
856
+ return pi;
857
+ }
858
+ publishPrompt(s, text, model, messageID, inputParts, metadata, context = false) {
773
859
  // OpenChamber sends a client-generated messageID with its optimistic
774
860
  // insert; the server MUST reuse it so the echoed message.part.updated /
775
861
  // message.updated events reconcile the optimistic entry in place instead
@@ -781,12 +867,23 @@ export class SessionManager {
781
867
  time: { created: Date.now() },
782
868
  agent: getAgentName(),
783
869
  model: s.model ?? model ?? { providerID: "unknown", modelID: "unknown" },
870
+ ...(context ? { synthetic: true } : {}),
871
+ ...(metadata !== undefined ? { metadata } : {}),
784
872
  };
785
- 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];
786
883
  s.messages?.push({ info: userMsg, parts });
787
884
  s.info.time.updated = Date.now();
788
- if (s.info.title === "New session" && s.titleSource !== "user" && s.titleSource !== "native") {
789
- 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";
790
887
  s.titleSource = "fallback";
791
888
  }
792
889
  this.persistMeta(s);
@@ -795,19 +892,74 @@ export class SessionManager {
795
892
  // Echo the user text part: the reducer replaces the client's sessionID-less
796
893
  // optimistic part (same type, new id) in place — without this echo a page
797
894
  // fetch merges the optimistic part back in and the text shows twice.
798
- for (const p of parts)
799
- this.events.emitPart(s, p);
800
- if (noReply)
801
- 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) {
802
950
  const wasIdle = s.status.type === "idle";
803
951
  if (wasIdle) {
804
952
  s.toolByCallId = new Map();
805
953
  this.events.setStatus(s, { type: "busy" });
806
954
  }
807
- const input = { text, delivery, accepted: false, delivered: false };
955
+ const input = { text: native.message, delivery, accepted: false, delivered: false };
808
956
  (s.inputs ??= new Map()).set(userMsg.id, input);
809
957
  const rejectInput = () => {
810
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];
811
963
  s.messages = s.messages?.filter((message) => message.info.id !== userMsg.id);
812
964
  this.emit(s.info.directory, {
813
965
  type: "message.removed",
@@ -826,9 +978,10 @@ export class SessionManager {
826
978
  const res = await pi
827
979
  .command({
828
980
  type: "prompt",
829
- message: text,
981
+ ...native,
830
982
  streamingBehavior: delivery === "queue" ? "followUp" : "steer",
831
983
  clientMessageId: userMsg.id,
984
+ ...slashTitlePrompt(text),
832
985
  })
833
986
  .catch((error) => {
834
987
  rejectInput();
@@ -846,12 +999,24 @@ export class SessionManager {
846
999
  error: { name: "UnknownError", data: { message: res.error ?? "prompt rejected" } },
847
1000
  },
848
1001
  });
849
- return;
1002
+ return false;
850
1003
  }
851
1004
  input.accepted = true;
852
1005
  this.emit(s.info.directory, {
853
1006
  type: "session.inbox.enqueued",
854
- 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
+ },
855
1020
  });
856
1021
  if (input.delivered)
857
1022
  this.events.deliverInput(s, userMsg.id);
@@ -860,9 +1025,14 @@ export class SessionManager {
860
1025
  typeof res.data === "object" &&
861
1026
  "disposition" in res.data &&
862
1027
  res.data.disposition === "handled") {
1028
+ if (!input.delivered)
1029
+ this.events.deliverInput(s, userMsg.id);
863
1030
  if (wasIdle)
864
1031
  this.events.setStatus(s, { type: "idle" });
865
1032
  }
1033
+ return true;
1034
+ }
1035
+ async bindPromptState(s) {
866
1036
  // First message makes pi write the session file — bind it now so the
867
1037
  // session (and its current title) survives an adapter restart.
868
1038
  if (s.piPath === undefined && s.piSessionId !== undefined) {
@@ -969,6 +1139,8 @@ export class SessionManager {
969
1139
  this.subagents.discover(s, s.messages);
970
1140
  if (admissions)
971
1141
  this.persistMeta(s);
1142
+ if (syncSessionCost(s))
1143
+ this.emit(s.info.directory, { type: "session.updated", properties: { info: s.info } });
972
1144
  }
973
1145
  return s.messages;
974
1146
  }
@@ -1039,7 +1211,7 @@ export class SessionManager {
1039
1211
  }
1040
1212
  }
1041
1213
  adopt(sc) {
1042
- const ocId = ocIdFor(sc.piSessionId);
1214
+ const ocId = this.store.idFor(sc.piSessionId) ?? ocIdFor(sc.piSessionId);
1043
1215
  const reg = this.store.get(ocId);
1044
1216
  const titleSource = reg?.titleSource ??
1045
1217
  (reg?.title && reg.title !== "New session" && reg.title !== sc.fallbackTitle ? "user" : "fallback");
@@ -1049,6 +1221,19 @@ export class SessionManager {
1049
1221
  projectID: sc.directory,
1050
1222
  directory: sc.directory,
1051
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
+ : {}),
1052
1237
  ...(reg?.tree?.revert ? { revert: { messageID: reg.tree.revert.messageID } } : {}),
1053
1238
  version: ADAPTER_VERSION,
1054
1239
  time: {
@@ -1106,6 +1291,11 @@ export class SessionManager {
1106
1291
  }
1107
1292
  /** Registry write for adapter-owned metadata (title override, pi path). */
1108
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
+ }
1109
1299
  const piPath = s.piPath ?? s.restored?.piPath;
1110
1300
  const piSessionId = s.piSessionId ?? s.restored?.piSessionId;
1111
1301
  if (piPath === undefined || piSessionId === undefined)
@@ -1120,6 +1310,10 @@ export class SessionManager {
1120
1310
  ...(s.info.title !== "New session" ? { title: s.info.title } : {}),
1121
1311
  ...(s.titleSource !== undefined ? { titleSource: s.titleSource } : {}),
1122
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 } : {}),
1123
1317
  ...(s.model !== undefined ? { model: { providerID: s.model.providerID, modelID: s.model.modelID } } : {}),
1124
1318
  created: s.info.time.created,
1125
1319
  updated: s.info.time.updated,
@@ -1127,11 +1321,11 @@ export class SessionManager {
1127
1321
  }
1128
1322
  /** Async event callbacks cannot reject an HTTP operation; report save failures through SSE. */
1129
1323
  reportPersistenceError(s, error) {
1130
- if (!(error instanceof SessionPersistenceError))
1131
- throw error;
1324
+ const name = error instanceof Error ? error.name : "UnknownError";
1325
+ const message = error instanceof Error ? error.message : String(error);
1132
1326
  this.emit(s.info.directory, {
1133
1327
  type: "session.error",
1134
- properties: { sessionID: s.info.id, error: { name: error.name, data: { message: error.message } } },
1328
+ properties: { sessionID: s.info.id, error: { name, data: { message } } },
1135
1329
  });
1136
1330
  }
1137
1331
  /** Keep the existing event entry point for RPC wiring and test harnesses. */