agent-relay-runner 0.12.2 → 0.12.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.12.2",
4
+ "version": "0.12.4",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -355,6 +355,13 @@ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[]
355
355
  command: "agent-relay context-probe --wrap",
356
356
  refreshInterval: 30,
357
357
  },
358
+ // Force readable thinking text for managed sessions so the session-mirror can
359
+ // surface reasoning in the dashboard. With showThinkingSummaries:false the API
360
+ // redacts thinking to a signature-only stub (empty text), leaving the transcript
361
+ // tail nothing to mirror. --settings merges per-key, so this overrides only this
362
+ // key for managed sessions — a host rig default of false still governs the
363
+ // operator's own interactive TUI sessions.
364
+ showThinkingSummaries: true,
358
365
  })];
359
366
  }
360
367
 
@@ -448,12 +455,19 @@ export function claudePaneLooksReady(text: string): boolean {
448
455
  || text.includes("Claude Code");
449
456
  }
450
457
 
458
+ // The working-spinner footer carries a live elapsed-time counter while a turn is in
459
+ // flight, e.g. "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)" — gerund, "… (", then
460
+ // "[Nh ][Nm ]Ns". Anchored on the gerund ellipsis so it can't match the "… +N lines
461
+ // (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
462
+ // "/btw … without interrupting Claude's current work" queue hint.
463
+ const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
464
+
451
465
  export function claudePaneIsBusy(text: string): boolean {
452
- // Claude renders "(esc to interrupt)" in its working spinner footer while a turn
453
- // is in flight and removes it once the turn completes and the input box is idle.
454
- // The persistent "…without interrupting Claude" queue hint does NOT contain this
455
- // exact phrase, so it won't false-positive.
456
- return text.includes("esc to interrupt");
466
+ // Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
467
+ // dropped that hint but kept the "(<elapsed>" counter, which is the stable busy
468
+ // signal across versions. Match either so the busy probe (and the reconciler
469
+ // backstop that depends on it) keep working as the footer wording changes.
470
+ return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
457
471
  }
458
472
 
459
473
  async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
@@ -453,6 +453,18 @@ export function codexToolSummary(type: string | undefined, item: Record<string,
453
453
  if (type === "webSearch") {
454
454
  return { label: "Search", body: clip(oneLine(item.query) || "web search") };
455
455
  }
456
+ if (type === "plan") {
457
+ return { label: "Plan", body: clip(oneLine(item.text) || "updated plan") };
458
+ }
459
+ if (type === "collabAgentToolCall") {
460
+ const tool = stringValue(item.tool) ?? "collab";
461
+ const prompt = oneLine(item.prompt);
462
+ const targets = Array.isArray(item.receiverThreadIds)
463
+ ? item.receiverThreadIds.filter((t): t is string => typeof t === "string").length
464
+ : 0;
465
+ const detail = prompt || (targets ? `${targets} agent${targets === 1 ? "" : "s"}` : tool);
466
+ return { label: `Collab/${tool}`, body: clip(detail) };
467
+ }
456
468
  return null;
457
469
  }
458
470
 
package/src/runner.ts CHANGED
@@ -63,6 +63,9 @@ const CLAIM_RENEW_INTERVAL_MS = 5 * 60 * 1000;
63
63
  const HTTP_LIVENESS_INTERVAL_MS = 20_000;
64
64
  const HTTP_LIVENESS_LOG_INTERVAL_MS = 5 * 60 * 1000;
65
65
  const TOKEN_RENEW_RETRY_MS = 60_000;
66
+ // Debounce reactive token recovery so a burst of 401-ing calls in the same window
67
+ // triggers a single re-mint attempt, not one per failing request.
68
+ const REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS = 10_000;
66
69
  const UNEXPECTED_EXIT_WINDOW_MS = 2 * 60 * 1000;
67
70
  const RAPID_EXIT_MS = 30 * 1000;
68
71
  const MAX_RAPID_UNEXPECTED_EXITS = 3;
@@ -128,6 +131,7 @@ export class AgentRunner {
128
131
  private tokenRenewTimer?: Timer;
129
132
  private tokenRenewInFlight = false;
130
133
  private tokenRenewLastLog?: { key: string; at: number };
134
+ private reactiveTokenRecoveryAt?: number;
131
135
  private processStartedAt = 0;
132
136
  private providerSessionId = crypto.randomUUID();
133
137
  private lifecycleAction?: "shutting-down" | "killing" | "restarting";
@@ -972,6 +976,7 @@ export class AgentRunner {
972
976
  });
973
977
  } catch (error) {
974
978
  this.logRunnerDiagnostic(`session ${input.session.type} capture failed: ${error instanceof Error ? error.message : String(error)}`);
979
+ if (isHttpAuthError(error)) this.recoverRuntimeTokenAfterAuthFailure("session-capture");
975
980
  }
976
981
  }
977
982
 
@@ -1139,15 +1144,29 @@ export class AgentRunner {
1139
1144
  // and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
1140
1145
  const seen = new Set<string>();
1141
1146
  const turnIdAtStart = this.currentTurnId;
1147
+ // On the first poll the new prompt usually hasn't landed in the transcript yet,
1148
+ // so extractLatestTurnSteps still returns the PRIOR (completed) turn. Seed those
1149
+ // signatures as already-seen so we don't replay last turn's reasoning/tools as
1150
+ // this turn's activity. Once our prompt lands the window resets at the new user
1151
+ // boundary and genuinely-new steps emit normally. Only seed when the transcript
1152
+ // is complete (last entry is an end_turn assistant) — otherwise we're already
1153
+ // inside the new turn and those steps are legitimately ours.
1154
+ let seeded = false;
1142
1155
  const poll = async (): Promise<void> => {
1143
1156
  let jsonl: string;
1144
1157
  try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1145
1158
  let steps: ReturnType<typeof extractLatestTurnSteps>;
1146
1159
  try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
1160
+ if (!seeded) {
1161
+ seeded = true;
1162
+ if (transcriptLooksComplete(jsonl)) {
1163
+ for (const s of steps) seen.add(JSON.stringify([s.type, s.label ?? "", s.text]));
1164
+ }
1165
+ }
1147
1166
  const turnId = this.currentTurnId ?? turnIdAtStart;
1148
1167
  let emitted = 0;
1149
1168
  for (const step of steps) {
1150
- const sig = `${step.type}${step.label ?? ""}${step.text}`;
1169
+ const sig = JSON.stringify([step.type, step.label ?? "", step.text]);
1151
1170
  if (seen.has(sig)) continue;
1152
1171
  seen.add(sig);
1153
1172
  emitted += 1;
@@ -1245,6 +1264,25 @@ export class AgentRunner {
1245
1264
  this.httpLivenessAuthFailed = true;
1246
1265
  if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
1247
1266
  this.httpLivenessTimer = undefined;
1267
+ // A 401/403 here is the only timely signal that the token died — stopping the
1268
+ // liveness timer means there is no second chance, so recover from THIS failure.
1269
+ this.recoverRuntimeTokenAfterAuthFailure("http-liveness");
1270
+ }
1271
+
1272
+ // A definitive relay auth failure (401/403) means the runtime token is dead right
1273
+ // now — expired, or (the common case) revoked when the relay marked this agent
1274
+ // stale across its own restart/reconnect. The proactive renew timer is keyed to
1275
+ // TTL and structurally cannot catch a revocation, so the auth failure itself must
1276
+ // drive recovery. renewRuntimeToken() prefers an orchestrator re-mint, which heals
1277
+ // even a revoked token. Debounced so a burst of failing calls re-mints once.
1278
+ private recoverRuntimeTokenAfterAuthFailure(source: string): void {
1279
+ if (this.stopped || this.tokenRenewInFlight) return;
1280
+ if (!this.isRuntimeTokenRenewable() && !this.canRemintViaOrchestrator()) return;
1281
+ const now = Date.now();
1282
+ if (this.reactiveTokenRecoveryAt && now - this.reactiveTokenRecoveryAt < REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS) return;
1283
+ this.reactiveTokenRecoveryAt = now;
1284
+ this.logRunnerDiagnostic(`[runner] relay auth failure on ${source}; recovering runtime token`);
1285
+ void this.renewRuntimeToken();
1248
1286
  }
1249
1287
 
1250
1288
  private logHttpLivenessFailure(error: unknown, authFailed: boolean): void {
@@ -1418,6 +1456,11 @@ export class AgentRunner {
1418
1456
  this.http.setToken(token);
1419
1457
  this.bus.setToken(token);
1420
1458
  this.httpLivenessAuthFailed = false;
1459
+ this.reactiveTokenRecoveryAt = undefined;
1460
+ // An earlier auth failure may have stopped the liveness loop; restart it so the
1461
+ // agent reports live again on the fresh token. startHttpLiveness clears any
1462
+ // existing timer first, so this is safe on the normal (proactive) renew path too.
1463
+ this.startHttpLiveness();
1421
1464
  this.pendingTimelineEvent = { status, id: record.jti, timestamp: Date.now() };
1422
1465
  this.bus.reconnectTransport(status === "runtime-token-reminted" ? "runtime token re-minted" : "runtime token renewed");
1423
1466
  this.publishStatus();