@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39

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.
@@ -15,6 +15,7 @@
15
15
  import { readFileSync } from "node:fs";
16
16
  import { open, stat } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
+ import { setTimeout as sleep } from "node:timers/promises";
18
19
  /** A sub-agent (Task) writes its own transcript to
19
20
  * `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
20
21
  * `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
@@ -60,6 +61,21 @@ function stringifyToolContent(content) {
60
61
  const BASH_INPUT_RE = /<bash-input>([\s\S]*?)<\/bash-input>/;
61
62
  const BASH_STDOUT_RE = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
62
63
  const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
64
+ /** Parse either half of Claude's shell-mode record. Newer and older Claude
65
+ * builds may split `<bash-input>` from the later stdout/stderr record. */
66
+ export function parseTerminalCommandFragments(content) {
67
+ const input = BASH_INPUT_RE.exec(content);
68
+ const stdout = BASH_STDOUT_RE.exec(content);
69
+ const stderr = BASH_STDERR_RE.exec(content);
70
+ if (!input && !stdout && !stderr)
71
+ return undefined;
72
+ return {
73
+ ...(input ? { command: input[1].trim() } : {}),
74
+ ...(stdout ? { stdout: stdout[1] } : {}),
75
+ ...(stderr ? { stderr: stderr[1] } : {}),
76
+ hasOutput: Boolean(stdout || stderr),
77
+ };
78
+ }
63
79
  /**
64
80
  * Parse a claude local-command (`!` bash mode) user record's string content into
65
81
  * a {@link TerminalCommandData}. Claude records the command and its captured
@@ -69,16 +85,14 @@ const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
69
85
  * `<system-reminder>`) are bookkeeping, not terminal commands.
70
86
  */
71
87
  export function parseTerminalCommand(content) {
72
- const input = BASH_INPUT_RE.exec(content);
73
- if (!input)
88
+ const fragments = parseTerminalCommandFragments(content);
89
+ if (!fragments || fragments.command === undefined)
74
90
  return undefined;
75
- const data = { command: input[1].trim() };
76
- const stdout = BASH_STDOUT_RE.exec(content)?.[1];
77
- const stderr = BASH_STDERR_RE.exec(content)?.[1];
78
- if (stdout)
79
- data.stdout = stdout;
80
- if (stderr)
81
- data.stderr = stderr;
91
+ const data = { command: fragments.command };
92
+ if (fragments.stdout)
93
+ data.stdout = fragments.stdout;
94
+ if (fragments.stderr)
95
+ data.stderr = fragments.stderr;
82
96
  return data;
83
97
  }
84
98
  /**
@@ -113,26 +127,42 @@ export function parseTranscriptRecord(record, opts) {
113
127
  if (rec.isSidechain && !parent)
114
128
  return []; // sub-agent turns are replayed separately
115
129
  const message = rec.message;
116
- if (!message || !Array.isArray(message.content))
130
+ if (!message)
117
131
  return [];
118
132
  const out = [];
119
133
  if (rec.type === "assistant" && message.role === "assistant") {
134
+ const content = typeof message.content === "string"
135
+ ? [{ type: "text", text: message.content }]
136
+ : message.content;
137
+ if (!Array.isArray(content))
138
+ return [];
120
139
  // Claude writes this zero-token synthetic filler after an accidental empty
121
140
  // submit. It is transcript bookkeeping, not an assistant response. Filter
122
141
  // by provider provenance and exact shape here, before normalization loses
123
142
  // `model: "<synthetic>"`; a UI string filter could hide legitimate output.
124
143
  if (!rec.isApiErrorMessage &&
125
144
  message.model === "<synthetic>" &&
126
- message.content.length === 1 &&
127
- message.content[0]?.type === "text" &&
128
- message.content[0].text === "No response requested.") {
145
+ content.length === 1 &&
146
+ content[0]?.type === "text" &&
147
+ content[0].text === "No response requested.") {
129
148
  return [];
130
149
  }
131
150
  const itemId = message.id;
132
- const texts = [];
133
- for (const block of message.content) {
151
+ const textBlockCount = content.filter((block) => block.type === "text" && block.text).length;
152
+ for (const [blockIndex, block] of content.entries()) {
134
153
  if (block.type === "text" && block.text) {
135
- texts.push(block.text);
154
+ // Preserve Claude's block order. Keep a single text block's
155
+ // native message id for MessageDisplay correlation; only derive an id
156
+ // when one record contains multiple independently ordered text blocks.
157
+ const textItemId = itemId && textBlockCount > 1
158
+ ? `${itemId}:text:${blockIndex}`
159
+ : itemId;
160
+ out.push({
161
+ type: "message_completed",
162
+ text: block.text,
163
+ ...(textItemId ? { itemId: textItemId } : {}),
164
+ ...parentTag,
165
+ });
136
166
  }
137
167
  else if (block.type === "thinking" && block.thinking) {
138
168
  out.push({ type: "reasoning_completed", summary: [block.thinking], ...(itemId ? { itemId } : {}), ...parentTag });
@@ -143,17 +173,19 @@ export function parseTranscriptRecord(record, opts) {
143
173
  event: "on_tool_start",
144
174
  name: block.name,
145
175
  input: { ...(isObject(block.input) ? block.input : {}), id: block.id },
146
- data: { id: block.id },
176
+ // A transcript tool_use is already a completed Provider record. Live
177
+ // runtimes still omit this marker and remain in_progress until their
178
+ // completion event arrives.
179
+ data: { id: block.id, itemStatus: "completed" },
147
180
  ...parentTag,
148
181
  });
149
182
  }
150
183
  }
151
- if (texts.length) {
152
- out.push({ type: "message_completed", text: texts.join(""), ...(itemId ? { itemId } : {}), ...parentTag });
153
- }
154
184
  return out;
155
185
  }
156
186
  if (rec.type === "user" && message.role === "user") {
187
+ if (!Array.isArray(message.content))
188
+ return [];
157
189
  for (const block of message.content) {
158
190
  if (block.type === "tool_result") {
159
191
  out.push({
@@ -166,7 +198,7 @@ export function parseTranscriptRecord(record, opts) {
166
198
  exitCode: null,
167
199
  },
168
200
  ...(block.is_error ? { error: "tool_error" } : {}),
169
- data: { tool_use_id: block.tool_use_id },
201
+ data: { tool_use_id: block.tool_use_id, itemStatus: "completed" },
170
202
  ...parentTag,
171
203
  });
172
204
  }
@@ -179,13 +211,14 @@ function isObject(value) {
179
211
  }
180
212
  /**
181
213
  * Whether a transcript is a `/fork` (branch) of another session: claude stamps a
182
- * `forkedFrom: { sessionId }` marker in an early record pointing at the source
214
+ * `forkedFrom: { sessionId }` marker on copied records pointing at the source
183
215
  * session (reference implementation's `transcript_has_forked_from_marker`). Used to distinguish a
184
216
  * fork from an ordinary `resume` (both arrive as `SessionStart source="resume"`).
185
- * Scans only the head of the file (the marker lands up front). NOTE: unverified on
186
- * this host no local transcript carries the marker so it follows reference implementation's shape.
217
+ * The marker must belong to the announced target and expected source; sample
218
+ * the first and last 200 records because long copied histories can place it at
219
+ * either edge.
187
220
  */
188
- export function transcriptHasForkedFrom(path, currentSessionId) {
221
+ export function transcriptHasForkedFrom(path, claudeSessionId, sourceClaudeSessionId) {
189
222
  let text;
190
223
  try {
191
224
  text = readFileSync(path, "utf8");
@@ -193,17 +226,60 @@ export function transcriptHasForkedFrom(path, currentSessionId) {
193
226
  catch {
194
227
  return false;
195
228
  }
196
- let scanned = 0;
197
- for (const line of text.split("\n")) {
198
- if (++scanned > 200)
199
- break;
229
+ const lines = text.split("\n");
230
+ const sampled = lines.length <= 400
231
+ ? lines
232
+ : [...lines.slice(0, 200), ...lines.slice(-200)];
233
+ for (const line of sampled) {
200
234
  const trimmed = line.trim();
201
235
  if (!trimmed || !trimmed.includes("forkedFrom"))
202
236
  continue;
203
237
  try {
204
238
  const rec = JSON.parse(trimmed);
239
+ if (rec.sessionId !== claudeSessionId)
240
+ continue;
205
241
  const from = rec.forkedFrom?.sessionId;
206
- if (typeof from === "string" && from && from !== currentSessionId)
242
+ if (typeof from === "string" &&
243
+ from &&
244
+ from !== claudeSessionId &&
245
+ (sourceClaudeSessionId === undefined || from === sourceClaudeSessionId))
246
+ return true;
247
+ }
248
+ catch {
249
+ // skip malformed
250
+ }
251
+ }
252
+ return false;
253
+ }
254
+ const RECENT_LOCAL_COMMAND_LINE_LIMIT = 200;
255
+ const RECENT_LOCAL_COMMAND_WINDOW_MS = 10_000;
256
+ /** Claude versions without a copied-record marker still persist `/fork` and
257
+ * `/branch` as a recent top-level local command. Match only the new native
258
+ * Session and the hook's narrow time window. */
259
+ export function transcriptHasRecentLocalCommand(path, claudeSessionId, recordedAtMs, commandNames = new Set(["/fork", "/branch"])) {
260
+ let text;
261
+ try {
262
+ text = readFileSync(path, "utf8");
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ for (const line of text.split("\n").slice(-RECENT_LOCAL_COMMAND_LINE_LIMIT)) {
268
+ const trimmed = line.trim();
269
+ if (!trimmed)
270
+ continue;
271
+ try {
272
+ const rec = JSON.parse(trimmed);
273
+ if (rec.sessionId !== claudeSessionId || rec.subtype !== "local_command")
274
+ continue;
275
+ const timestamp = transcriptTimestampMs(rec.timestamp);
276
+ if (timestamp === undefined ||
277
+ Math.abs(timestamp - recordedAtMs) > RECENT_LOCAL_COMMAND_WINDOW_MS ||
278
+ typeof rec.content !== "string")
279
+ continue;
280
+ const command = /<command-name>([\s\S]*?)<\/command-name>/
281
+ .exec(rec.content)?.[1]?.trim();
282
+ if (command && commandNames.has(command))
207
283
  return true;
208
284
  }
209
285
  catch {
@@ -212,6 +288,31 @@ export function transcriptHasForkedFrom(path, currentSessionId) {
212
288
  }
213
289
  return false;
214
290
  }
291
+ /** Wait briefly for Claude to flush either fork signal after SessionStart.
292
+ * The observer hook calls this before recording the edge, allowing a
293
+ * one-second late-marker window without delaying ordinary transcript polling. */
294
+ export async function waitForTranscriptForkSignal(path, claudeSessionId, sourceClaudeSessionId, recordedAtMs, options = {}) {
295
+ const timeoutMs = Math.max(0, options.timeoutMs ?? 1_000);
296
+ const pollMs = Math.max(1, options.pollMs ?? 50);
297
+ const deadline = Date.now() + timeoutMs;
298
+ do {
299
+ if (transcriptHasForkedFrom(path, claudeSessionId, sourceClaudeSessionId) ||
300
+ transcriptHasRecentLocalCommand(path, claudeSessionId, recordedAtMs))
301
+ return true;
302
+ if (Date.now() >= deadline)
303
+ return false;
304
+ await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
305
+ } while (true);
306
+ }
307
+ function transcriptTimestampMs(value) {
308
+ if (typeof value === "number" && Number.isFinite(value)) {
309
+ return value < 10_000_000_000 ? value * 1_000 : value;
310
+ }
311
+ if (typeof value !== "string" || !value)
312
+ return undefined;
313
+ const parsed = Date.parse(value);
314
+ return Number.isFinite(parsed) ? parsed : undefined;
315
+ }
215
316
  /**
216
317
  * Read a sub-agent (Task) transcript in full and map it to {@link AgentEvent}s
217
318
  * tagged with `parentToolUseId`. Called once the parent Task tool_result arrives
@@ -8,6 +8,23 @@ export interface CodexSessionRecord {
8
8
  * metadata remains self-contained; this only tells a runner where the
9
9
  * Provider persisted its rollout/state. */
10
10
  runtimeHomeOwnerSessionId?: string;
11
+ /** Claude hook/forwarder rendezvous ownership. A rotated target keeps the
12
+ * physical pane's bridge while the retired logical source is re-keyed to an
13
+ * isolated bridge; both may still share one Provider runtime home. */
14
+ bridgeOwnerSessionId?: string;
15
+ /** Durable recovery edge for a Claude-native `/clear`·`/fork`. The target
16
+ * binding is written before IPC publication; `published` is flipped only
17
+ * after the parent durably creates the logical target. */
18
+ nativeRotationSourceSessionId?: string;
19
+ /** Authoritative latest published target when this record is the source.
20
+ * Avoids choosing an arbitrary historical edge after repeated rotations. */
21
+ nativeRotationTargetSessionId?: string;
22
+ nativeRotationKind?: "clear" | "fork";
23
+ nativeRotationPublished?: boolean;
24
+ /** Stable transcript boundary captured by the rotating forwarder at the
25
+ * target SessionStart. Clear starts at zero; fork starts after the copied
26
+ * source prefix. Recovery must not replace this with a later file EOF. */
27
+ nativeRotationInitialTranscriptOffset?: number;
11
28
  updatedAt: string;
12
29
  }
13
30
  export interface ClaudeForkIntent {
@@ -15,6 +32,12 @@ export interface ClaudeForkIntent {
15
32
  sourceSessionId: string;
16
33
  sourceClaudeSessionId: string;
17
34
  targetClaudeSessionId: string;
35
+ /** Target transcript atomically cloned before the target runner starts. Its
36
+ * presence means recovery must resume this exact file, never re-clone a
37
+ * source that may have advanced since the fork boundary. */
38
+ forkTranscriptPath?: string;
39
+ /** Exact byte length of the cloned prefix, measured before Claude starts. */
40
+ forkTranscriptPrefixBytes?: number;
18
41
  updatedAt: string;
19
42
  }
20
43
  export interface CodexSessionStore {
@@ -107,6 +107,27 @@ export class FileCodexSessionStore {
107
107
  ...(record.runtimeHomeOwnerSessionId
108
108
  ? { runtimeHomeOwnerSessionId: record.runtimeHomeOwnerSessionId }
109
109
  : {}),
110
+ ...(record.bridgeOwnerSessionId
111
+ ? { bridgeOwnerSessionId: record.bridgeOwnerSessionId }
112
+ : {}),
113
+ ...(record.nativeRotationSourceSessionId
114
+ ? { nativeRotationSourceSessionId: record.nativeRotationSourceSessionId }
115
+ : {}),
116
+ ...(record.nativeRotationTargetSessionId
117
+ ? { nativeRotationTargetSessionId: record.nativeRotationTargetSessionId }
118
+ : {}),
119
+ ...(record.nativeRotationKind
120
+ ? { nativeRotationKind: record.nativeRotationKind }
121
+ : {}),
122
+ ...(record.nativeRotationPublished
123
+ ? { nativeRotationPublished: true }
124
+ : {}),
125
+ ...(Number.isSafeInteger(record.nativeRotationInitialTranscriptOffset) &&
126
+ record.nativeRotationInitialTranscriptOffset >= 0
127
+ ? {
128
+ nativeRotationInitialTranscriptOffset: record.nativeRotationInitialTranscriptOffset,
129
+ }
130
+ : {}),
110
131
  updatedAt: record.updatedAt,
111
132
  };
112
133
  }
package/dist/host.d.ts CHANGED
@@ -9,6 +9,27 @@ import type { ModelListResponse, ThreadGoal } from "./codex-app-server/protocol.
9
9
  import { type TerminalInjector } from "./claude/native-integration.js";
10
10
  import { FileCodexSessionStore, resolveCodexSessionStorePath, type ClaudeForkIntent, type CodexSessionRecord, type CodexSessionStore } from "./codex-session-store.js";
11
11
  import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
12
+ /** Delivery policy carried only inside the runner process. It is not part of
13
+ * the public SessionEvent protocol: the same canonical event has different
14
+ * retry semantics depending on whether it came from a transcript item, a
15
+ * terminal hook, or a best-effort live snapshot. */
16
+ export type MirrorDeliveryPolicy = "ordinary" | "terminal-status" | "best-effort" | "compaction" | "compaction-hook";
17
+ export type MirrorDeliveryOutcome = "confirmed" | "ambiguous" | "dropped" | "superseded";
18
+ export interface MirrorDeliveryOptions {
19
+ policy: MirrorDeliveryPolicy;
20
+ /** Stable local source key used by the Claude forwarder to checkpoint one
21
+ * item only after this delivery is handled. It is not sent to SQLite. */
22
+ sourceId?: string;
23
+ /** Independent retry lane. Claude native sub-agents use one lane each so a
24
+ * blocked child cannot stop its siblings or the parent transcript. */
25
+ lane?: string;
26
+ /** Claude-local write-only dead letter for an exhausted permanent ordinary
27
+ * item. No startup path replays this file automatically. */
28
+ deadLetterPath?: string;
29
+ /** Internal completion detail for state machines (not part of the wire). */
30
+ onOutcome?: (outcome: MirrorDeliveryOutcome) => void;
31
+ }
32
+ export type MirrorEmitter = (event: SessionEvent, delivery?: MirrorDeliveryOptions) => void | Promise<void>;
12
33
  export { FileCodexSessionStore, resolveCodexSessionStorePath, type CodexSessionRecord, type CodexSessionStore, type ClaudeForkIntent, };
13
34
  export interface CodexRuntimeStatus {
14
35
  codex_available: boolean;
@@ -99,7 +120,10 @@ export type RetargetMirror = (newSessionId: string, meta: {
99
120
  workspace: SessionWorkspaceSnapshot;
100
121
  execution: ResolvedExecutionSnapshot;
101
122
  parentSessionId?: string;
102
- }) => void;
123
+ /** Runs after parent target persistence + child resource transfer, but
124
+ * before the child proves `rotate.applied` to the parent. */
125
+ beforeApplied?: () => void | Promise<void>;
126
+ }) => void | Promise<void>;
103
127
  export interface LiveSessionOpts {
104
128
  /** Immutable Session-owned snapshots. Provider launch/resume never re-opens a
105
129
  * Project or Agent template. */
@@ -212,7 +236,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
212
236
  * existing session, the app-server resumes the persisted id. Rynx never writes
213
237
  * or repairs Codex's private rollout files.
214
238
  */
215
- ensureLiveCodexSession(localThreadId: string, emit: (event: SessionEvent) => void, opts: LiveSessionOpts): Promise<boolean>;
239
+ ensureLiveCodexSession(localThreadId: string, emit: MirrorEmitter, opts: LiveSessionOpts): Promise<boolean>;
216
240
  private startLiveCodexSession;
217
241
  /** Bind a session's codex thread id once known (TUI broadcast or store): persist
218
242
  * it, unblock injection, and kick off the resume-subscribe loop (once). */
@@ -301,6 +325,11 @@ export declare class LocalAgentHost implements CodexCapabilities {
301
325
  * Provider startup never re-opens an Agent template or process-level plugin
302
326
  * snapshot. */
303
327
  private prepareExecutionSkills;
328
+ /** Repair the only cross-process crash window in native rotation. A target
329
+ * started through the Session registry proves that logical publication is
330
+ * durable; a published target in turn proves its source must no longer own
331
+ * the shared live bridge. */
332
+ private reconcileClaudeNativeRotation;
304
333
  private startLiveClaudeSession;
305
334
  /** Persist claude's discovered session id (reusing the `codexSessionId` store
306
335
  * field, as the claude executor already does) and release the readiness gate. */