@rynx-ai/runtime 0.1.11-beta.24 → 0.1.11-beta.26

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
  }
@@ -176,6 +176,43 @@ function permissionRequest(id, payload, toolInput, suggestions) {
176
176
  const toolName = asString(payload.tool_name) ?? "tool";
177
177
  const command = asString(toolInput.command) ?? asString(toolInput.file_path);
178
178
  const cwd = asString(payload.cwd);
179
+ if (toolName === "ExitPlanMode") {
180
+ const plan = asString(toolInput.plan) ?? "Plan details were not provided by Claude.";
181
+ return {
182
+ interactionId: id,
183
+ kind: "permission",
184
+ title: "Plan review",
185
+ fields: [{
186
+ id: "feedback",
187
+ type: "text",
188
+ label: "What should change about the plan?",
189
+ required: false,
190
+ multiline: true,
191
+ placeholder: "Revision feedback (optional)",
192
+ }],
193
+ actions: [
194
+ {
195
+ id: "allow_auto",
196
+ label: "Yes, and use auto mode",
197
+ style: "primary",
198
+ requiresAnswers: false,
199
+ },
200
+ {
201
+ id: "allow_manual",
202
+ label: "Yes, manually approve edits",
203
+ requiresAnswers: false,
204
+ },
205
+ {
206
+ id: "deny_feedback",
207
+ label: "Reject with feedback",
208
+ style: "danger",
209
+ requiresAnswers: false,
210
+ },
211
+ ],
212
+ context: { toolName, summary: plan },
213
+ createdAt: Date.now(),
214
+ };
215
+ }
179
216
  return {
180
217
  interactionId: id,
181
218
  kind: "permission",
@@ -261,6 +298,31 @@ function nativeVerdict(hookKind, payload, result, suggestions) {
261
298
  },
262
299
  };
263
300
  }
301
+ if (toolName === "ExitPlanMode") {
302
+ const feedback = answerText(resolution.answers?.feedback).trim();
303
+ const behavior = resolution.actionId === "allow_auto" ||
304
+ resolution.actionId === "allow_manual"
305
+ ? "allow"
306
+ : "deny";
307
+ return {
308
+ hookSpecificOutput: {
309
+ hookEventName: "PermissionRequest",
310
+ decision: {
311
+ behavior,
312
+ ...(behavior === "deny" && feedback ? { message: feedback } : {}),
313
+ ...(behavior === "allow"
314
+ ? {
315
+ updatedPermissions: [{
316
+ type: "setMode",
317
+ mode: resolution.actionId === "allow_auto" ? "auto" : "default",
318
+ destination: "session",
319
+ }],
320
+ }
321
+ : {}),
322
+ },
323
+ },
324
+ };
325
+ }
264
326
  const suggestionMatch = /^allow_suggestion_(\d+)$/.exec(resolution.actionId);
265
327
  const suggestionIndex = suggestionMatch ? Number(suggestionMatch[1]) : -1;
266
328
  const selectedSuggestion = Number.isSafeInteger(suggestionIndex)
@@ -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. */
@@ -29,7 +29,7 @@ const MAX_SUBMISSION_OBSERVATIONS = 64;
29
29
  const INTERACTION_ACK_TIMEOUT_MS = 5_000;
30
30
  // Claude writes this synthetic user record after Escape (including a tool-use
31
31
  // interruption). It is lifecycle, not a new prompt. Kept aligned with
32
- // Omnigent's `_CLAUDE_INTERRUPT_RECORD_RE`.
32
+ // reference implementation's `_CLAUDE_INTERRUPT_RECORD_RE`.
33
33
  const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
34
34
  const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
35
35
  function processIsAlive(pid) {
@@ -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) {
@@ -24,7 +24,7 @@
24
24
  import type { AgentEvent, UserContentPart } from "@rynx-ai/core";
25
25
  import type { CodexAppServerClient } from "./client.js";
26
26
  import type { McpStartupPlan } from "./mcp-startup.js";
27
- import type { ResumedTurn } from "./protocol.js";
27
+ import type { CollaborationModeKind, ResumedTurn } from "./protocol.js";
28
28
  export interface CodexForwarderSink {
29
29
  /** A turn began. `turnId` is codex's turn id, used to derive a stable
30
30
  * `responseId`. Start a fresh normalizer/response. */
@@ -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;
@@ -58,6 +63,16 @@ export interface CodexForwarderSink {
58
63
  /** The thread showed activity (a turn/item began, so its rollout now exists).
59
64
  * Fired once; lets a parked `thread/resume` retry (reference implementation's ready signal). */
60
65
  onThreadActive?(): void;
66
+ /** The native TUI or another app-server client changed collaboration mode. */
67
+ onCollaborationModeChanged?(mode: CollaborationModeKind): void;
68
+ /** Codex's terminal-local Plan picker is not emitted by app-server today.
69
+ * Synthesize it only after a live Plan item and its Turn both complete. */
70
+ onPlanImplementationPrompt?(prompt: CodexPlanImplementationPrompt): void;
71
+ }
72
+ export interface CodexPlanImplementationPrompt {
73
+ threadId: string;
74
+ turnId: string;
75
+ text: string;
61
76
  }
62
77
  export interface CodexSessionForwarderOptions {
63
78
  /** Some Codex-lineage runtimes publish the final item one frame after
@@ -101,6 +116,11 @@ export declare class CodexSessionForwarder {
101
116
  /** Per-(thread,turn) position counter for items lacking a stable codex id
102
117
  * (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
103
118
  private readonly anonCounters;
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;
123
+ private replayingBackfill;
104
124
  constructor(client: CodexAppServerClient, sink: CodexForwarderSink, options?: CodexSessionForwarderOptions);
105
125
  /** Begin mirroring. Idempotent. */
106
126
  start(): void;
@@ -136,6 +156,9 @@ export declare class CodexSessionForwarder {
136
156
  * not doubled.
137
157
  */
138
158
  replayBackfill(turns: ResumedTurn[]): void;
159
+ /** Suppress our synthesized picker when a future app-server emits the native
160
+ * `plan_implementation` request itself. */
161
+ noteNativePlanImplementationPrompt(turnId?: string): void;
139
162
  /** Fail an open response exactly once when its observer or terminal exits. */
140
163
  failOpenTurn(error: Error): boolean;
141
164
  private handle;
@@ -164,6 +187,7 @@ export declare class CodexSessionForwarder {
164
187
  private completedItemKey;
165
188
  private advanceAnonCounter;
166
189
  private ensureTurn;
190
+ private consumeTurnDiff;
167
191
  /** Start (or confirm) the app-server's authoritative active turn. A newer
168
192
  * start supersedes an older response whose terminal edge arrived late; a
169
193
  * pending Traex completion is flushed first so its final item grace remains