antechamber 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +24 -14
  3. package/bin/opencode-antechamber +14 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +10 -6
  18. package/dist/pi-event-translation.js +237 -80
  19. package/dist/pi-rpc.d.ts +20 -2
  20. package/dist/pi-rpc.js +96 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +113 -65
  26. package/dist/routes-v2.js +407 -95
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +12 -15
  30. package/dist/session-commands.js +54 -13
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.d.ts +1 -1
  34. package/dist/session-history.js +110 -44
  35. package/dist/session-hosts.js +2 -2
  36. package/dist/session-store.d.ts +8 -4
  37. package/dist/session-store.js +191 -38
  38. package/dist/session-tree.d.ts +10 -2
  39. package/dist/session-tree.js +55 -26
  40. package/dist/sessions.d.ts +32 -5
  41. package/dist/sessions.js +331 -120
  42. package/dist/sse.d.ts +16 -6
  43. package/dist/sse.js +77 -21
  44. package/dist/subagents.d.ts +5 -0
  45. package/dist/subagents.js +84 -71
  46. package/dist/tool-map.d.ts +3 -2
  47. package/dist/types.d.ts +152 -1
  48. package/dist/v2.d.ts +18 -4
  49. package/dist/v2.js +128 -44
  50. package/dist/vcs.d.ts +5 -0
  51. package/dist/vcs.js +199 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/omo-dag/README.md +2 -0
  55. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  56. package/extensions/omo-dag/panel/index.html +1 -1
  57. package/extensions/omo-dag/panel/main.js +60 -60
  58. package/extensions/omo-dag/service/main.js +72 -8
  59. package/package.json +1 -1
@@ -2,6 +2,7 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultMetadata, resultText, } from "./tool-map.js";
4
4
  import { TodoBridge } from "./todo-bridge.js";
5
+ import { contextMessageID } from "./prompt-parts.js";
5
6
  import { bindMessageEntries } from "./session-tree.js";
6
7
  import { taskParts, updateTaskParts, taskCompletions } from "./subagents.js";
7
8
  import { shellTitle, shellUserMessage } from "./session-commands.js";
@@ -38,9 +39,7 @@ export function parsePiSessionFile(s, agentName) {
38
39
  }
39
40
  return parsePiSessionText(s, agentName, raw);
40
41
  }
41
- export function parsePiSessionText(s, agentName, raw) {
42
- const sessionID = s.info.id;
43
- const directory = s.info.directory;
42
+ function parseEntries(raw) {
44
43
  const entries = [];
45
44
  for (const line of raw.split("\n")) {
46
45
  if (line.length === 0)
@@ -54,13 +53,44 @@ export function parsePiSessionText(s, agentName, raw) {
54
53
  /* torn trailing write — ignore */
55
54
  }
56
55
  }
57
- const indexOf = new Map();
58
- entries.forEach((e, i) => indexOf.set(e, i));
56
+ return entries;
57
+ }
58
+ function nativeTree(entries, leaf) {
59
+ return {
60
+ leafId: leaf?.id ?? null,
61
+ entries: entries.flatMap((entry) => {
62
+ if (entry.id === undefined || entry.type === undefined)
63
+ return [];
64
+ const message = entry.message;
65
+ return [
66
+ {
67
+ type: entry.type,
68
+ id: entry.id,
69
+ parentId: entry.parentId ?? null,
70
+ ...(message?.role === undefined
71
+ ? {}
72
+ : {
73
+ message: {
74
+ role: message.role,
75
+ ...(message.clientMessageId === undefined ? {} : { clientMessageId: message.clientMessageId }),
76
+ ...(message.role === "user" ? { content: [{ type: "text", text: textOf(message.content) }] } : {}),
77
+ ...(message.command === undefined ? {} : { command: message.command }),
78
+ ...(message.output === undefined ? {} : { output: message.output }),
79
+ ...(message.exitCode === undefined ? {} : { exitCode: message.exitCode }),
80
+ ...(message.cancelled === undefined ? {} : { cancelled: message.cancelled }),
81
+ },
82
+ }),
83
+ },
84
+ ];
85
+ }),
86
+ };
87
+ }
88
+ function activeChain(s, entries) {
59
89
  const byId = new Map();
60
- for (const e of entries)
61
- if (typeof e.id === "string")
62
- byId.set(e.id, e);
63
- let leaf = [...entries].reverse().find((entry) => entry.type !== "session" && typeof entry.id === "string");
90
+ for (const entry of entries)
91
+ if (typeof entry.id === "string")
92
+ byId.set(entry.id, entry);
93
+ let leaf = entries.findLast((entry) => entry.type !== "session" && typeof entry.id === "string");
64
94
  let selectedLeaf = s.tree.revert ? s.tree.revert.previousLeafId : s.tree.leafId;
65
95
  if (!s.tree.revert && s.tree.localShellTurns?.length) {
66
96
  // Follow a newly recorded descendant, never a sibling of the selected native branch.
@@ -77,40 +107,21 @@ export function parsePiSessionText(s, agentName, raw) {
77
107
  leaf = selectedLeaf === null ? undefined : byId.get(selectedLeaf);
78
108
  const chain = [];
79
109
  const seen = new Set();
80
- for (let cur = leaf; cur !== undefined && !seen.has(cur);) {
81
- seen.add(cur);
82
- chain.push(cur);
83
- cur = typeof cur.parentId === "string" ? byId.get(cur.parentId) : undefined;
110
+ for (let entry = leaf; entry !== undefined && !seen.has(entry);) {
111
+ seen.add(entry);
112
+ chain.push(entry);
113
+ entry = typeof entry.parentId === "string" ? byId.get(entry.parentId) : undefined;
84
114
  }
85
115
  chain.reverse();
86
- const nativeTree = {
87
- leafId: leaf?.id ?? null,
88
- entries: entries.flatMap((entry) => {
89
- if (entry.id === undefined || entry.type === undefined)
90
- return [];
91
- const m = entry.message;
92
- return [
93
- {
94
- type: entry.type,
95
- id: entry.id,
96
- parentId: entry.parentId ?? null,
97
- ...(m?.role === undefined
98
- ? {}
99
- : {
100
- message: {
101
- role: m.role,
102
- ...(m.clientMessageId === undefined ? {} : { clientMessageId: m.clientMessageId }),
103
- ...(m.role === "user" ? { content: [{ type: "text", text: textOf(m.content) }] } : {}),
104
- ...(m.command === undefined ? {} : { command: m.command }),
105
- ...(m.output === undefined ? {} : { output: m.output }),
106
- ...(m.exitCode === undefined ? {} : { exitCode: m.exitCode }),
107
- ...(m.cancelled === undefined ? {} : { cancelled: m.cancelled }),
108
- },
109
- }),
110
- },
111
- ];
112
- }),
113
- };
116
+ return { chain, nativeTree: nativeTree(entries, leaf) };
117
+ }
118
+ export function parsePiSessionText(s, agentName, raw) {
119
+ const sessionID = s.info.id;
120
+ const directory = s.info.directory;
121
+ const entries = parseEntries(raw);
122
+ const indexOf = new Map();
123
+ entries.forEach((e, i) => indexOf.set(e, i));
124
+ const { chain, nativeTree } = activeChain(s, entries);
114
125
  if (s.tree.localShellTurns?.length)
115
126
  bindMessageEntries(s, nativeTree);
116
127
  const out = [];
@@ -133,7 +144,41 @@ export function parsePiSessionText(s, agentName, raw) {
133
144
  }
134
145
  if (e.type === "compaction" && typeof e.summary === "string" && typeof e.firstKeptEntryId === "string") {
135
146
  // Not a run boundary: an automatic compaction can sit between steps of one turn.
136
- out.push(...compactionMessages(s, e.firstKeptEntryId, e.summary, entryTime(e), currentModel));
147
+ out.push(...compactionMessages(s, e.id ?? `r${indexOf.get(e) ?? 0}`, e.summary, entryTime(e), currentModel));
148
+ continue;
149
+ }
150
+ const contextID = contextMessageID(e);
151
+ if (contextID !== undefined) {
152
+ endRun();
153
+ const content = Array.isArray(e.content)
154
+ ? e.content
155
+ : typeof e.content === "string"
156
+ ? [{ type: "text", text: e.content }]
157
+ : [];
158
+ const time = entryTime(e);
159
+ if (e.id)
160
+ s.tree.messageEntries[contextID] = e.id;
161
+ out.push({
162
+ info: {
163
+ id: contextID,
164
+ sessionID,
165
+ role: "user",
166
+ time: { created: time },
167
+ agent: agentName,
168
+ model: currentModel,
169
+ synthetic: true,
170
+ },
171
+ parts: [
172
+ {
173
+ id: `prt_${contextID}_context`,
174
+ sessionID,
175
+ messageID: contextID,
176
+ type: "text",
177
+ text: textOf(content),
178
+ time: { start: time, end: time },
179
+ },
180
+ ],
181
+ });
137
182
  continue;
138
183
  }
139
184
  if (e.type !== "message")
@@ -366,13 +411,15 @@ export function parsePiSessionText(s, agentName, raw) {
366
411
  }
367
412
  // system / custom entries are not rendered
368
413
  }
369
- const lastAssistant = [...chain].reverse().find((entry) => entry.message?.role === "assistant");
414
+ const lastAssistant = chain.findLast((entry) => entry.message?.role === "assistant");
370
415
  const stopReason = lastAssistant?.message?.stopReason;
371
416
  if (lastAssistant !== undefined && ["stop", "length", "error", "aborted"].includes(stopReason ?? "")) {
372
417
  finishProviderToolParts([...toolParts.values()].flat(), entryTime(lastAssistant), stopReason === "aborted" ? "Cancelled" : "No result");
373
418
  }
374
419
  s.todoBridge = new TodoBridge(sessionID);
375
- s.todoBridge.restore(entries, new Map([...toolParts].flatMap(([callID, parts]) => (parts[0] ? [[callID, parts[0]]] : []))));
420
+ // Tree-less snapshots predate branch selection and have no competing ancestor chain.
421
+ const todoEntries = chain.length === 0 && s.tree.leafId === undefined && !s.tree.revert ? entries : chain;
422
+ s.todoBridge.restore(todoEntries, new Map([...toolParts].flatMap(([callID, parts]) => (parts[0] ? [[callID, parts[0]]] : []))));
376
423
  // Completions can be appended after the newest message, outside its ancestor chain.
377
424
  for (const entry of entries) {
378
425
  if (entry.customType !== "omo-senpi:wake")
@@ -417,5 +464,24 @@ export function parsePiSessionText(s, agentName, raw) {
417
464
  });
418
465
  (s.inputs ??= new Map()).set(messageID, { text, delivery, accepted: true, delivered: false });
419
466
  }
467
+ for (const message of out) {
468
+ const metadata = s.tree.promptMetadata?.[message.info.id];
469
+ if (message.info.role === "user" && metadata !== undefined)
470
+ message.info.metadata = metadata;
471
+ const inputParts = s.tree.promptParts?.[message.info.id];
472
+ if (message.info.role === "user" && inputParts !== undefined)
473
+ message.parts = inputParts.map((part, index) => ({
474
+ ...part,
475
+ id: part.id ?? `prt_${message.info.id}_${index}`,
476
+ sessionID,
477
+ messageID: message.info.id,
478
+ }));
479
+ const identities = s.tree.messageParts?.[message.info.id];
480
+ for (const [index, part] of message.parts.entries()) {
481
+ const identity = identities?.[index];
482
+ if (identity !== undefined)
483
+ part.id = identity;
484
+ }
485
+ }
420
486
  return out;
421
487
  }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { once } from "node:events";
4
- import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
4
+ import { closeSync, constants, openSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
  import { findPidfdPython } from "./pidfd.js";
7
7
  /** Capability and warning state are captured once, before any shutdown work. */
@@ -144,7 +144,7 @@ async function stopSupportedSessionHosts(created, python) {
144
144
  try {
145
145
  // The original leader must still exist after the process-table scan:
146
146
  // a recycled group number cannot confer its ownership on new members.
147
- return readlinkSync(`/proc/self/fd/${info.fd}/cwd`) === directory;
147
+ return readlinkSync(`/proc/self/fd/${info.fd}/cwd`) === realpathSync(directory);
148
148
  }
149
149
  catch (error) {
150
150
  if (error instanceof Error && "code" in error && ["ENOENT", "ESRCH"].includes(String(error.code)))
@@ -37,6 +37,9 @@ export interface SessionRegistryEntry {
37
37
  connectorOwned?: true;
38
38
  tree?: SessionTreeState;
39
39
  parentID?: string | undefined;
40
+ parentMessageID?: string;
41
+ agent?: string;
42
+ metadata?: Record<string, unknown>;
40
43
  task?: ChildTask;
41
44
  piSessionId: string;
42
45
  piPath: string;
@@ -53,6 +56,7 @@ export interface SessionRegistryEntry {
53
56
  model?: {
54
57
  providerID: string;
55
58
  modelID: string;
59
+ variant?: string;
56
60
  };
57
61
  created: number;
58
62
  updated: number;
@@ -81,18 +85,18 @@ export declare function piSessionsRootMtime(): number | undefined;
81
85
  export declare function scanAllPiSessions(): ScannedSession[];
82
86
  /** All restorable pi sessions recorded for `directory`. Corrupt files are skipped. */
83
87
  export declare function scanPiSessions(directory: string): ScannedSession[];
84
- /**
85
- * Persistent oc-session-id → metadata registry (title overrides, known pi
86
- * paths). Atomic writes, lazy load, corrupt file degrades to an empty map.
87
- */
88
88
  export declare class SessionStore {
89
89
  readonly stateDir: string;
90
90
  private readonly file;
91
91
  private entries;
92
92
  constructor(stateDir?: string);
93
93
  private load;
94
+ private readEntries;
95
+ private withRegistryLock;
94
96
  private persist;
97
+ private mutate;
95
98
  get(ocSessionId: string): SessionRegistryEntry | undefined;
99
+ idFor(piSessionId: string): string | undefined;
96
100
  childIds(): string[];
97
101
  set(ocSessionId: string, entry: SessionRegistryEntry): void;
98
102
  /** Merge a partial update (e.g. title override, updated timestamp). */
@@ -14,9 +14,10 @@
14
14
  * the same OpenChamber id, even if the registry file is lost.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
17
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { piAgentDir } from "./agent-dir.js";
20
+ import { isPromptPart } from "./prompt-parts.js";
20
21
  import { defaultStateDir, envSetting } from "./config.js";
21
22
  /** pi's session directory for a given cwd (see pi docs/session-format.md). */
22
23
  export function piSessionDir(cwd) {
@@ -38,6 +39,12 @@ export class SessionPersistenceError extends Error {
38
39
  /** Read at most this many leading bytes when sniffing headers/titles. */
39
40
  const SNIFF_BYTES = 256 * 1024;
40
41
  const TITLE_MAX_LINES = 200;
42
+ const TITLE_MAX_CHARS = 60;
43
+ const REGISTRY_DIRECTORY_MODE = 0o700;
44
+ const REGISTRY_FILE_MODE = 0o600;
45
+ const REGISTRY_LOCK_RETRY_MS = 10;
46
+ const REGISTRY_LOCK_STALE_MS = 5_000;
47
+ const registryLockWaiter = new Int32Array(new SharedArrayBuffer(4));
41
48
  function readHead(path, bytes = SNIFF_BYTES) {
42
49
  const fd = openSync(path, "r");
43
50
  try {
@@ -64,13 +71,16 @@ function titleFromHead(head, piSessionId) {
64
71
  }
65
72
  if (entry.type !== "message" || entry.message?.role !== "user")
66
73
  continue;
67
- const text = (entry.message.content ?? [])
68
- .filter((b) => b?.type === "text" && typeof b.text === "string")
69
- .map((b) => b.text)
70
- .join("\n")
71
- .trim();
74
+ const content = entry.message.content ?? [];
75
+ // pi allows plain-string message content (extensions, append_user_message).
76
+ const text = (typeof content === "string"
77
+ ? content
78
+ : content
79
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
80
+ .map((b) => b.text)
81
+ .join("\n")).trim();
72
82
  if (text !== "")
73
- return text.slice(0, 60);
83
+ return text.slice(0, TITLE_MAX_CHARS);
74
84
  }
75
85
  return `pi session ${piSessionId.slice(0, 8)}`;
76
86
  }
@@ -261,6 +271,105 @@ export function scanPiSessions(directory) {
261
271
  * Persistent oc-session-id → metadata registry (title overrides, known pi
262
272
  * paths). Atomic writes, lazy load, corrupt file degrades to an empty map.
263
273
  */
274
+ function record(value) {
275
+ return value !== null && typeof value === "object" && !Array.isArray(value);
276
+ }
277
+ function stringRecord(value) {
278
+ return record(value) && Object.values(value).every((entry) => typeof entry === "string");
279
+ }
280
+ function booleanRecord(value) {
281
+ return record(value) && Object.values(value).every((entry) => typeof entry === "boolean");
282
+ }
283
+ function persistedMessage(value) {
284
+ return (record(value) &&
285
+ record(value.info) &&
286
+ typeof value.info.id === "string" &&
287
+ typeof value.info.role === "string" &&
288
+ Array.isArray(value.parts));
289
+ }
290
+ function sessionTreeState(value) {
291
+ if (!record(value) || !stringRecord(value.messageEntries))
292
+ return false;
293
+ if (value.promptParts !== undefined &&
294
+ (!record(value.promptParts) ||
295
+ !Object.values(value.promptParts).every((parts) => Array.isArray(parts) && parts.every(isPromptPart))))
296
+ return false;
297
+ if (value.promptMetadata !== undefined &&
298
+ (!record(value.promptMetadata) || !Object.values(value.promptMetadata).every((metadata) => record(metadata))))
299
+ return false;
300
+ if (value.messageParts !== undefined &&
301
+ (!record(value.messageParts) ||
302
+ !Object.values(value.messageParts).every((parts) => Array.isArray(parts) && parts.every((part) => typeof part === "string"))))
303
+ return false;
304
+ if (value.shellDispatch !== undefined && !booleanRecord(value.shellDispatch))
305
+ return false;
306
+ if (value.leafId !== undefined && value.leafId !== null && typeof value.leafId !== "string")
307
+ return false;
308
+ if (value.revert !== undefined) {
309
+ if (!record(value.revert) ||
310
+ typeof value.revert.messageID !== "string" ||
311
+ (value.revert.previousLeafId !== null && typeof value.revert.previousLeafId !== "string") ||
312
+ (value.revert.targetId !== null && typeof value.revert.targetId !== "string"))
313
+ return false;
314
+ }
315
+ return (value.localShellTurns === undefined ||
316
+ (Array.isArray(value.localShellTurns) &&
317
+ value.localShellTurns.every((turn) => record(turn) &&
318
+ (turn.afterEntryId === null || typeof turn.afterEntryId === "string") &&
319
+ Array.isArray(turn.messages) &&
320
+ turn.messages.length === 2 &&
321
+ persistedMessage(turn.messages[0]) &&
322
+ persistedMessage(turn.messages[1]))));
323
+ }
324
+ function childTask(value) {
325
+ return (record(value) &&
326
+ typeof value.id === "string" &&
327
+ typeof value.parentPiId === "string" &&
328
+ record(value.input) &&
329
+ record(value.details) &&
330
+ (value.terminalAt === undefined || (typeof value.terminalAt === "number" && Number.isFinite(value.terminalAt))));
331
+ }
332
+ function titleSource(value) {
333
+ return value === "user" || value === "native" || value === "fallback" || value === "generated";
334
+ }
335
+ function registryEntry(value) {
336
+ if (!record(value) ||
337
+ typeof value.piPath !== "string" ||
338
+ typeof value.piSessionId !== "string" ||
339
+ typeof value.directory !== "string" ||
340
+ typeof value.created !== "number" ||
341
+ !Number.isFinite(value.created) ||
342
+ typeof value.updated !== "number" ||
343
+ !Number.isFinite(value.updated))
344
+ return false;
345
+ if (value.connectorOwned !== undefined && value.connectorOwned !== true)
346
+ return false;
347
+ if (value.tree !== undefined && !sessionTreeState(value.tree))
348
+ return false;
349
+ if (value.parentID !== undefined && typeof value.parentID !== "string")
350
+ return false;
351
+ if (value.parentMessageID !== undefined && typeof value.parentMessageID !== "string")
352
+ return false;
353
+ if (value.agent !== undefined && typeof value.agent !== "string")
354
+ return false;
355
+ if (value.metadata !== undefined && !record(value.metadata))
356
+ return false;
357
+ if (value.task !== undefined && !childTask(value.task))
358
+ return false;
359
+ if (value.title !== undefined && typeof value.title !== "string")
360
+ return false;
361
+ if (value.titleSource !== undefined && !titleSource(value.titleSource))
362
+ return false;
363
+ if (value.archived !== undefined && (typeof value.archived !== "number" || !Number.isFinite(value.archived)))
364
+ return false;
365
+ if (value.model !== undefined &&
366
+ (!record(value.model) || typeof value.model.providerID !== "string" || typeof value.model.modelID !== "string"))
367
+ return false;
368
+ return true;
369
+ }
370
+ function errorCode(error) {
371
+ return error instanceof Error && "code" in error ? String(error.code) : undefined;
372
+ }
264
373
  export class SessionStore {
265
374
  stateDir;
266
375
  file;
@@ -273,64 +382,108 @@ export class SessionStore {
273
382
  load() {
274
383
  if (this.entries !== undefined)
275
384
  return this.entries;
276
- this.entries = new Map();
385
+ this.entries = this.readEntries();
386
+ return this.entries;
387
+ }
388
+ readEntries() {
389
+ const entries = new Map();
277
390
  try {
278
391
  const raw = JSON.parse(readFileSync(this.file, "utf8"));
279
- for (const [k, v] of Object.entries(raw)) {
280
- if (v && typeof v.piPath === "string" && typeof v.piSessionId === "string") {
281
- // A hand-edited or partially written registry must not be able to
282
- // poison the model a dormant session advertises.
283
- if (v.model !== undefined &&
284
- (typeof v.model.providerID !== "string" || typeof v.model.modelID !== "string")) {
285
- delete v.model;
286
- }
287
- this.entries.set(k, v);
288
- }
289
- }
392
+ if (!record(raw))
393
+ return entries;
394
+ for (const [id, value] of Object.entries(raw))
395
+ if (registryEntry(value))
396
+ entries.set(id, value);
290
397
  }
291
398
  catch {
292
399
  /* missing or corrupt — start empty */
293
400
  }
294
- return this.entries;
401
+ return entries;
295
402
  }
296
- persist(entries) {
297
- const obj = Object.fromEntries(entries);
403
+ withRegistryLock(action) {
404
+ const lock = `${this.file}.lock`;
405
+ let fd;
298
406
  try {
299
- mkdirSync(join(this.file, ".."), { recursive: true });
300
- const tmp = `${this.file}.${process.pid}.tmp`;
301
- writeFileSync(tmp, JSON.stringify(obj, null, 2));
302
- renameSync(tmp, this.file);
407
+ mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
408
+ chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
409
+ for (;;) {
410
+ try {
411
+ fd = openSync(lock, "wx", REGISTRY_FILE_MODE);
412
+ break;
413
+ }
414
+ catch (error) {
415
+ if (errorCode(error) !== "EEXIST")
416
+ throw error;
417
+ try {
418
+ if (Date.now() - statSync(lock).mtimeMs >= REGISTRY_LOCK_STALE_MS) {
419
+ unlinkSync(lock);
420
+ continue;
421
+ }
422
+ }
423
+ catch (staleLockError) {
424
+ if (errorCode(staleLockError) !== "ENOENT")
425
+ throw staleLockError;
426
+ continue;
427
+ }
428
+ Atomics.wait(registryLockWaiter, 0, 0, REGISTRY_LOCK_RETRY_MS);
429
+ }
430
+ }
431
+ return action();
303
432
  }
304
433
  catch (error) {
305
434
  throw new SessionPersistenceError(this.file, error);
306
435
  }
307
- this.entries = entries;
436
+ finally {
437
+ if (fd !== undefined) {
438
+ closeSync(fd);
439
+ unlinkSync(lock);
440
+ }
441
+ }
442
+ }
443
+ persist(entries) {
444
+ const obj = Object.fromEntries(entries);
445
+ const tmp = `${this.file}.${process.pid}.tmp`;
446
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: REGISTRY_FILE_MODE });
447
+ chmodSync(tmp, REGISTRY_FILE_MODE);
448
+ renameSync(tmp, this.file);
449
+ }
450
+ mutate(change) {
451
+ this.withRegistryLock(() => {
452
+ const entries = this.readEntries();
453
+ if (change(entries))
454
+ this.persist(entries);
455
+ this.entries = entries;
456
+ });
308
457
  }
309
458
  get(ocSessionId) {
310
459
  return this.load().get(ocSessionId);
311
460
  }
461
+ idFor(piSessionId) {
462
+ return [...this.load()].find(([, entry]) => entry.piSessionId === piSessionId)?.[0];
463
+ }
312
464
  childIds() {
313
465
  return [...this.load()]
314
466
  .filter(([, entry]) => entry.task !== undefined && entry.parentID !== undefined)
315
467
  .map(([id]) => id);
316
468
  }
317
469
  set(ocSessionId, entry) {
318
- const entries = new Map(this.load());
319
- entries.set(ocSessionId, entry);
320
- this.persist(entries);
470
+ this.mutate((entries) => {
471
+ entries.set(ocSessionId, entry);
472
+ return true;
473
+ });
321
474
  }
322
475
  /** Merge a partial update (e.g. title override, updated timestamp). */
323
476
  update(ocSessionId, patch) {
324
- const map = this.load();
325
- const prev = map.get(ocSessionId);
326
- if (prev === undefined)
327
- return;
328
- this.set(ocSessionId, { ...prev, ...patch });
477
+ this.mutate((entries) => {
478
+ const previous = entries.get(ocSessionId);
479
+ if (previous === undefined)
480
+ return false;
481
+ entries.set(ocSessionId, { ...previous, ...patch });
482
+ return true;
483
+ });
329
484
  }
330
485
  remove(ocSessionId) {
331
- const entries = new Map(this.load());
332
- if (entries.delete(ocSessionId))
333
- this.persist(entries);
486
+ this.mutate((entries) => entries.delete(ocSessionId));
334
487
  }
335
488
  /** Every directory the registry knows about — used to resolve bare session ids. */
336
489
  directories() {
@@ -1,8 +1,15 @@
1
1
  import { type PiRpcClient } from "./pi-rpc.js";
2
2
  import type { ManagedSession } from "./sessions.js";
3
3
  import type { OCMessageWithParts } from "./types.js";
4
+ import type { OCPromptPart } from "./types.js";
4
5
  export interface SessionTreeState {
5
6
  messageEntries: Record<string, string>;
7
+ /** Published part identities in native content order, keyed by durable message identity. */
8
+ messageParts?: Record<string, string[]>;
9
+ /** Original rich user input; native RPC stores only adapted text and images. */
10
+ promptParts?: Record<string, OCPromptPart[]>;
11
+ /** Client prompt metadata; native RPC does not store it. */
12
+ promptMetadata?: Record<string, Record<string, unknown>>;
6
13
  /** Actual transport dispatch, not inferred from a card's terminal state. */
7
14
  shellDispatch?: Record<string, boolean>;
8
15
  /** Identity-only dispatched admissions; never rendered without a native entry. */
@@ -21,10 +28,12 @@ export interface TreeEntry {
21
28
  type: string;
22
29
  id: string;
23
30
  parentId: string | null;
31
+ customType?: string;
32
+ details?: unknown;
24
33
  message?: {
25
34
  role: string;
26
35
  clientMessageId?: string;
27
- content?: Array<{
36
+ content?: string | Array<{
28
37
  type: string;
29
38
  text?: string;
30
39
  }>;
@@ -48,7 +57,6 @@ export declare function recordTurn(s: ManagedSession): void;
48
57
  export declare function branchEntries(tree: Entries, leafId?: string | null): TreeEntry[];
49
58
  /** Live ids belong to the UI; restored ids belong to JSONL offsets. Save both. */
50
59
  export declare function bindMessageEntries(s: ManagedSession, tree: Entries): void;
51
- export declare function entryForMessage(s: ManagedSession, tree: Entries, messageID: string): TreeEntry;
52
60
  export declare function forkConversation(s: ManagedSession, pi: PiRpcClient, tree: Entries, messageID?: string): Promise<{
53
61
  sessionId: string;
54
62
  sessionFile: string;