@rynx-ai/runtime 0.1.11-beta.25 → 0.1.11-beta.27

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.
@@ -109,6 +109,8 @@ export interface HookEvent {
109
109
  /** Claude's session uuid — subagent hooks report a `subagents/…` transcript path. */
110
110
  sessionId?: string;
111
111
  source?: string;
112
+ /** Authoritative live background-shell count carried by a Stop hook. */
113
+ backgroundTaskCount?: number;
112
114
  payload: Record<string, unknown>;
113
115
  }
114
116
  /** Tail `hooks.jsonl` from a byte offset into parsed {@link HookEvent}s. */
@@ -267,6 +267,25 @@ function readJsonFile(path) {
267
267
  function asString(value) {
268
268
  return typeof value === "string" && value ? value : undefined;
269
269
  }
270
+ const TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
271
+ "completed",
272
+ "failed",
273
+ "stopped",
274
+ "killed",
275
+ ]);
276
+ function backgroundTaskCount(payload) {
277
+ if (payload.hook_event_name !== "Stop")
278
+ return undefined;
279
+ const tasks = payload.background_tasks;
280
+ if (!Array.isArray(tasks))
281
+ return 0;
282
+ return tasks.filter((task) => {
283
+ if (!task || typeof task !== "object" || Array.isArray(task))
284
+ return true;
285
+ const status = task.status;
286
+ return typeof status !== "string" || !TERMINAL_BACKGROUND_TASK_STATUSES.has(status);
287
+ }).length;
288
+ }
270
289
  export function readClaudeState(bridgeDir) {
271
290
  const raw = readJsonFile(join(bridgeDir, STATE_FILE));
272
291
  if (!raw || typeof raw !== "object")
@@ -348,11 +367,15 @@ export function readHookEventsFrom(bridgeDir, byteOffset) {
348
367
  const payload = rec.payload;
349
368
  if (!payload || typeof payload !== "object")
350
369
  continue;
370
+ const liveBackgroundTasks = backgroundTaskCount(payload);
351
371
  events.push({
352
372
  eventName: asString(payload.hook_event_name),
353
373
  transcriptPath: asString(payload.transcript_path),
354
374
  sessionId: asString(payload.session_id),
355
375
  source: asString(payload.source),
376
+ ...(liveBackgroundTasks === undefined
377
+ ? {}
378
+ : { backgroundTaskCount: liveBackgroundTasks }),
356
379
  payload,
357
380
  });
358
381
  }
@@ -21,7 +21,7 @@ export interface ClaudeForwarderSink {
21
21
  onInteraction(event: RuntimeInteractionEvent): void;
22
22
  /** The current turn finished (a new user prompt, or the inactivity backstop).
23
23
  * `usage` carries the latest statusLine context/cost snapshot, when captured. */
24
- onTurnEnd(usage?: Record<string, unknown>): void;
24
+ onTurnEnd(usage?: Record<string, unknown>, backgroundTaskCount?: number): void;
25
25
  /** The current turn ended because the user explicitly interrupted it. */
26
26
  onTurnInterrupted?(usage?: Record<string, unknown>): void;
27
27
  /** Escape was sent for the open Turn. Publish cancelled UI state immediately;
@@ -34,8 +34,9 @@ export interface ClaudeForwarderSink {
34
34
  * assistant record and the two orderings race; finalizing here would split a
35
35
  * late assistant record into its own turn. The turn is finalized by the next
36
36
  * user prompt or the inactivity backstop, so a late record still joins it. */
37
- onIdle(): void;
38
- /** The current turn failed on the runtime (StopFailure hook). */
37
+ onIdle(backgroundTaskCount?: number): void;
38
+ /** Claude's current Turn or native runtime failed (StopFailure/pane exit).
39
+ * May fire between Turns so Session-level failure can retire stale liveness. */
39
40
  onTurnError(error: Error): void;
40
41
  /** Fired once SessionStart reveals claude's session id + transcript path, so
41
42
  * the host can persist the id and release its readiness gate. */
@@ -115,6 +116,10 @@ export declare class ClaudeLiveSession {
115
116
  private lastActivityAt;
116
117
  /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
117
118
  private stopPendingAt;
119
+ /** Authoritative live background-shell count from the pending Stop hook. */
120
+ private stopBackgroundTaskCount;
121
+ /** Whether the pending Stop count already rode an immediate idle edge. */
122
+ private stopBackgroundTaskCountDelivered;
118
123
  /** An idle status from Claude's session metadata. The short delay keeps a
119
124
  * final transcript record in the response without letting stale tool state
120
125
  * override the provider's terminal status. */
@@ -177,6 +177,10 @@ export class ClaudeLiveSession {
177
177
  lastActivityAt = 0;
178
178
  /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
179
179
  stopPendingAt = null;
180
+ /** Authoritative live background-shell count from the pending Stop hook. */
181
+ stopBackgroundTaskCount;
182
+ /** Whether the pending Stop count already rode an immediate idle edge. */
183
+ stopBackgroundTaskCountDelivered = false;
180
184
  /** An idle status from Claude's session metadata. The short delay keeps a
181
185
  * final transcript record in the response without letting stale tool state
182
186
  * override the provider's terminal status. */
@@ -436,11 +440,15 @@ export class ClaudeLiveSession {
436
440
  if (ev.eventName === "StopFailure") {
437
441
  this.stopSignalPending = false;
438
442
  this.stopPendingAt = null;
443
+ this.stopBackgroundTaskCount = undefined;
444
+ this.stopBackgroundTaskCountDelivered = false;
439
445
  this.turnFailurePending = new Error("claude turn failed");
440
446
  }
441
447
  else {
442
448
  this.stopSignalPending = true;
443
449
  this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
450
+ this.stopBackgroundTaskCount = ev.backgroundTaskCount ?? 0;
451
+ this.stopBackgroundTaskCountDelivered = false;
444
452
  }
445
453
  }
446
454
  }
@@ -452,7 +460,10 @@ export class ClaudeLiveSession {
452
460
  return;
453
461
  this.turnFailurePending = null;
454
462
  this.providerFailureSticky = true;
455
- this.closeTurnError(error);
463
+ if (this.turnOpen)
464
+ this.closeTurnError(error);
465
+ else
466
+ this.sink.onTurnError(error);
456
467
  }
457
468
  /** Emit the Stop idle signal only after this tick has discovered native
458
469
  * interactions. A pending question/permission is active execution, not idle. */
@@ -460,10 +471,10 @@ export class ClaudeLiveSession {
460
471
  if (!this.stopSignalPending)
461
472
  return;
462
473
  this.stopSignalPending = false;
463
- if (this.statusPoller?.active)
464
- return;
465
- if (this.pendingInteractions.size === 0)
466
- this.sink.onIdle();
474
+ if (this.pendingInteractions.size === 0) {
475
+ this.sink.onIdle(this.stopBackgroundTaskCount);
476
+ this.stopBackgroundTaskCountDelivered = true;
477
+ }
467
478
  }
468
479
  /** SessionStart drives discovery (first) and rotation (a later one with a NEW
469
480
  * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
@@ -907,6 +918,8 @@ export class ClaudeLiveSession {
907
918
  this.providerIdleAt = null;
908
919
  this.openToolIds.clear();
909
920
  this.stopPendingAt = null;
921
+ this.stopBackgroundTaskCount = undefined;
922
+ this.stopBackgroundTaskCountDelivered = false;
910
923
  this.sink.onTurnStart(this.currentTurnId);
911
924
  }
912
925
  this.sink.onUserMessage(content);
@@ -1040,6 +1053,11 @@ export class ClaudeLiveSession {
1040
1053
  this.stopPendingAt = null;
1041
1054
  if (status.runnerStatus === "running") {
1042
1055
  this.providerFailureSticky = false;
1056
+ // A new native busy interval supersedes the prior Stop snapshot. The
1057
+ // Session running edge clears its sticky tally; do not re-emit that old
1058
+ // count if the previous transcript Turn closes a moment later.
1059
+ this.stopBackgroundTaskCount = undefined;
1060
+ this.stopBackgroundTaskCountDelivered = false;
1043
1061
  }
1044
1062
  else if (this.providerFailureSticky) {
1045
1063
  return;
@@ -1114,6 +1132,8 @@ export class ClaudeLiveSession {
1114
1132
  this.providerIdleAt = null;
1115
1133
  this.turnOpen = true;
1116
1134
  this.turnInterrupted = false;
1135
+ this.stopBackgroundTaskCount = undefined;
1136
+ this.stopBackgroundTaskCountDelivered = false;
1117
1137
  this.sink.onTurnStart(this.currentTurnId);
1118
1138
  }
1119
1139
  /** Mirror a local `!` command as its own mini-turn: close any open turn, then
@@ -1162,7 +1182,10 @@ export class ClaudeLiveSession {
1162
1182
  /** Retire process-scoped metadata before classifying a terminal exit. */
1163
1183
  noteTerminalExit(error) {
1164
1184
  this.statusPoller?.retire();
1165
- return this.failOpenTurn(error);
1185
+ const failedOpenTurn = this.failOpenTurn(error);
1186
+ if (!failedOpenTurn)
1187
+ this.sink.onTurnError(error);
1188
+ return failedOpenTurn;
1166
1189
  }
1167
1190
  closeTurn() {
1168
1191
  if (!this.turnOpen)
@@ -1179,10 +1202,15 @@ export class ClaudeLiveSession {
1179
1202
  const interrupted = this.turnInterrupted;
1180
1203
  this.turnInterrupted = false;
1181
1204
  const usage = this.statusUsage();
1205
+ const backgroundTaskCount = this.stopBackgroundTaskCountDelivered
1206
+ ? undefined
1207
+ : this.stopBackgroundTaskCount;
1208
+ this.stopBackgroundTaskCount = undefined;
1209
+ this.stopBackgroundTaskCountDelivered = false;
1182
1210
  if (interrupted && this.sink.onTurnInterrupted)
1183
1211
  this.sink.onTurnInterrupted(usage);
1184
1212
  else
1185
- this.sink.onTurnEnd(usage);
1213
+ this.sink.onTurnEnd(usage, backgroundTaskCount);
1186
1214
  }
1187
1215
  closeTurnError(error) {
1188
1216
  if (!this.turnOpen)
@@ -1196,6 +1224,8 @@ export class ClaudeLiveSession {
1196
1224
  this.turnInterrupted = false;
1197
1225
  this.stopSignalPending = false;
1198
1226
  this.stopPendingAt = null;
1227
+ this.stopBackgroundTaskCount = undefined;
1228
+ this.stopBackgroundTaskCountDelivered = false;
1199
1229
  this.resetMessageCorrelation();
1200
1230
  this.sink.onTurnError(error);
1201
1231
  }
@@ -22,11 +22,32 @@ export function subagentTranscriptPath(parentTranscriptPath, agentId) {
22
22
  const dir = parentTranscriptPath.replace(/\.jsonl$/, "");
23
23
  return join(dir, "subagents", `agent-${agentId}.jsonl`);
24
24
  }
25
+ function strippedImagePlaceholder(source) {
26
+ const mediaType = source.media_type;
27
+ const label = typeof mediaType === "string" && mediaType ? `${mediaType} image` : "image";
28
+ return `[${label} omitted from history to save context — re-run the tool call above (e.g. Read the same path) to view it again]`;
29
+ }
30
+ /** Remove Claude's inline image bytes before tool output reaches canonical
31
+ * history. A Read image result can contain a full-resolution base64 payload;
32
+ * replaying it as text only bloats the transcript, while the model cannot use
33
+ * those encoded bytes as text. Keep a small, human-readable marker instead. */
34
+ function stripInlineImageData(value) {
35
+ if (Array.isArray(value))
36
+ return value.map(stripInlineImageData);
37
+ if (!isObject(value))
38
+ return value;
39
+ const source = isObject(value.source) ? value.source : undefined;
40
+ if (value.type === "image" && source) {
41
+ return { type: "text", text: strippedImagePlaceholder(source) };
42
+ }
43
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, stripInlineImageData(nested)]));
44
+ }
25
45
  function stringifyToolContent(content) {
26
- if (typeof content === "string")
27
- return content;
28
- if (Array.isArray(content)) {
29
- return content
46
+ const stripped = stripInlineImageData(content);
47
+ if (typeof stripped === "string")
48
+ return stripped;
49
+ if (Array.isArray(stripped)) {
50
+ return stripped
30
51
  .map((part) => part && typeof part === "object" && "text" in part
31
52
  ? String(part.text ?? "")
32
53
  : typeof part === "string"
@@ -34,18 +55,7 @@ function stringifyToolContent(content) {
34
55
  : JSON.stringify(part))
35
56
  .join("");
36
57
  }
37
- return content == null ? "" : JSON.stringify(content);
38
- }
39
- function toolLabel(name, input) {
40
- if (input && typeof input === "object") {
41
- const rec = input;
42
- for (const key of ["command", "cmd", "path", "file_path", "pattern", "query"]) {
43
- const v = rec[key];
44
- if (typeof v === "string" && v)
45
- return v;
46
- }
47
- }
48
- return name ?? "tool";
58
+ return stripped == null ? "" : JSON.stringify(stripped);
49
59
  }
50
60
  const BASH_INPUT_RE = /<bash-input>([\s\S]*?)<\/bash-input>/;
51
61
  const BASH_STDOUT_RE = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
@@ -132,7 +142,7 @@ export function parseTranscriptRecord(record, opts) {
132
142
  type: "tool",
133
143
  event: "on_tool_start",
134
144
  name: block.name,
135
- input: { ...(isObject(block.input) ? block.input : {}), id: block.id, command: toolLabel(block.name, block.input) },
145
+ input: { ...(isObject(block.input) ? block.input : {}), id: block.id },
136
146
  data: { id: block.id },
137
147
  ...parentTag,
138
148
  });
@@ -1386,7 +1386,17 @@ export function buildTextUserInput(message) {
1386
1386
  export function buildRuntimeUserInput(input) {
1387
1387
  return input.content.map((part) => part.type === "text"
1388
1388
  ? { type: "text", text: part.text, text_elements: [] }
1389
- : { type: "localImage", path: part.path });
1389
+ : part.type === "local_image"
1390
+ ? { type: "localImage", path: part.path }
1391
+ : {
1392
+ type: "text",
1393
+ text: `[[RYNX_FILE_RESOURCE ${JSON.stringify({
1394
+ path: part.path,
1395
+ ...(part.resource.filename ? { filename: part.resource.filename } : {}),
1396
+ mediaType: part.resource.mediaType,
1397
+ })}]]\nInspect this absolute file path with the available file-reading tools before answering.`,
1398
+ text_elements: [],
1399
+ });
1390
1400
  }
1391
1401
  const defaultLogger = {
1392
1402
  log(entry) {
@@ -34,6 +34,9 @@ export interface CodexForwarderSink {
34
34
  onTurnObserved?(turnId?: string): void;
35
35
  /** One mapped event within the current turn. */
36
36
  onEvent(event: AgentEvent): void;
37
+ /** Turn-scoped content arrived while no Provider Turn is active. Attach it
38
+ * to that response without opening a Turn or changing Session status. */
39
+ onTurnContentEvent?(turnId: string | undefined, event: AgentEvent): void;
37
40
  /** Provider startup is session status, not a model item. Hosts that already
38
41
  * published the response can forward it without synthesizing another start. */
39
42
  onStatus?(note: string | undefined, statusKind?: "startup"): void;
@@ -49,6 +52,8 @@ export interface CodexForwarderSink {
49
52
  /** The user's turn text (sourced from codex's `userMessage` item), so a
50
53
  * co-driving TUI's prompt is recorded even though this process never injected it. */
51
54
  onUserMessage?(content: string | UserContentPart[]): void;
55
+ /** Out-of-lifecycle counterpart of `onUserMessage`, scoped when possible. */
56
+ onTurnContentUserMessage?(turnId: string | undefined, content: string | UserContentPart[]): void;
52
57
  /** A managed Core fork also broadcasts `thread/started`, but does not switch
53
58
  * the source TUI. Discard that notification before changing the bound thread. */
54
59
  shouldIgnoreThreadStarted?(threadId: string, forkedFromId?: string): boolean;
@@ -112,6 +117,9 @@ export declare class CodexSessionForwarder {
112
117
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
113
118
  private readonly anonCounters;
114
119
  private pendingPlanImplementation;
120
+ /** Latest aggregate diff per Turn. Codex republishes the complete diff after
121
+ * every edit; only the terminal snapshot belongs in transcript history. */
122
+ private readonly turnDiffByTurn;
115
123
  private replayingBackfill;
116
124
  constructor(client: CodexAppServerClient, sink: CodexForwarderSink, options?: CodexSessionForwarderOptions);
117
125
  /** Begin mirroring. Idempotent. */
@@ -179,6 +187,7 @@ export declare class CodexSessionForwarder {
179
187
  private completedItemKey;
180
188
  private advanceAnonCounter;
181
189
  private ensureTurn;
190
+ private consumeTurnDiff;
182
191
  /** Start (or confirm) the app-server's authoritative active turn. A newer
183
192
  * start supersedes an older response whose terminal edge arrived late; a
184
193
  * pending Traex completion is flushed first so its final item grace remains