agent-relay-runner 0.119.0 → 0.119.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.119.0",
3
+ "version": "0.119.1",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-providers": "0.104.1",
24
- "agent-relay-sdk": "0.2.102",
23
+ "agent-relay-providers": "0.104.2",
24
+ "agent-relay-sdk": "0.2.103",
25
25
  "callmux": "0.23.0"
26
26
  },
27
27
  "devDependencies": {
@@ -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.119.0",
4
+ "version": "0.119.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { mkdirSync, renameSync, writeFileSync } = require("node:fs");
5
+ const { tmpdir } = require("node:os");
6
+ const { basename, dirname, join } = require("node:path");
7
+
8
+ let input = "";
9
+ process.stdin.setEncoding("utf8");
10
+ process.stdin.on("data", (chunk) => { input += chunk; });
11
+ process.stdin.on("end", () => {
12
+ const metrics = parseClaudeStatusLineInput(input);
13
+ if (metrics) writeState(metrics);
14
+ process.stdout.write(`${standaloneOutput(metrics)}\n`);
15
+ });
16
+
17
+ function parseClaudeStatusLineInput(raw) {
18
+ let data;
19
+ try {
20
+ data = JSON.parse(raw);
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (!isRecord(data) || !isRecord(data.context_window)) return null;
25
+ const context = data.context_window;
26
+ const timestamp = Date.now();
27
+ const contextPercent = numberValue(context.used_percentage);
28
+ const tokensMax = numberValue(context.context_window_size);
29
+ const usage = isRecord(context.current_usage) ? context.current_usage : undefined;
30
+ const tokensUsed = sumNumbers(
31
+ usage && usage.input_tokens,
32
+ usage && usage.cache_creation_input_tokens,
33
+ usage && usage.cache_read_input_tokens,
34
+ );
35
+ if (contextPercent === undefined && tokensUsed === undefined) return null;
36
+
37
+ const rateLimits = isRecord(data.rate_limits) ? data.rate_limits : undefined;
38
+ const fiveHour = isRecord(rateLimits && rateLimits.five_hour) ? rateLimits.five_hour : undefined;
39
+ const quotaWindows = quotaWindowsFromRateLimits(rateLimits);
40
+ const model = stringValue(data.model) || stringValue(process.env.CLAUDE_MODEL);
41
+ const effort = isRecord(data.effort) ? stringValue(data.effort.level) : stringValue(data.effort);
42
+ const resolvedEffort = effort || stringValue(process.env.CLAUDE_EFFORT) || stringValue(process.env.CLAUDE_CODE_EFFORT_LEVEL);
43
+
44
+ const metrics = {
45
+ agentId: stringValue(process.env.AGENT_RELAY_AGENT_ID) || stringValue(process.env.AGENT_RELAY_ID) || stringValue(data.session_id) || "unknown",
46
+ contextPercent: contextPercent !== undefined ? contextPercent : ((tokensUsed || 0) / (tokensMax || 1)) * 100,
47
+ source: "statusline",
48
+ confidence: "exact",
49
+ timestamp,
50
+ };
51
+ if (tokensUsed !== undefined) metrics.tokensUsed = tokensUsed;
52
+ if (tokensMax !== undefined) metrics.tokensMax = tokensMax;
53
+ const quotaUsed = numberValue(fiveHour && fiveHour.used_percentage);
54
+ if (quotaUsed !== undefined) metrics.quotaUsed = quotaUsed;
55
+ const quotaResetIn = secondsUntil(numberValue(fiveHour && fiveHour.resets_at), timestamp);
56
+ if (quotaResetIn !== undefined) metrics.quotaResetIn = quotaResetIn;
57
+ if (quotaWindows.length > 0) metrics.quotaWindows = quotaWindows;
58
+ if (model) metrics.model = model;
59
+ if (resolvedEffort) metrics.effort = resolvedEffort;
60
+ metrics.contextPercent = Math.max(0, Math.min(100, metrics.contextPercent));
61
+ return metrics;
62
+ }
63
+
64
+ function writeState(metrics) {
65
+ const file = join(process.env.AGENT_RELAY_CONTEXT_STATE_DIR || tmpdir(), `agent-relay-context-${safeStateId(metrics.agentId)}.json`);
66
+ mkdirSync(dirname(file), { recursive: true });
67
+ const tmp = `${file}.${process.pid}.tmp`;
68
+ writeFileSync(tmp, JSON.stringify(metrics, null, 2));
69
+ renameSync(tmp, file);
70
+ }
71
+
72
+ function quotaWindowsFromRateLimits(rateLimits) {
73
+ const windows = [];
74
+ for (const name of ["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus"]) {
75
+ const raw = isRecord(rateLimits && rateLimits[name]) ? rateLimits[name] : undefined;
76
+ const usedPercentage = numberValue(raw && raw.used_percentage);
77
+ if (usedPercentage === undefined) continue;
78
+ const window = { name, utilization: usedPercentage / 100, unit: "%" };
79
+ const resetsAt = epochSecondsToMs(numberValue(raw && raw.resets_at));
80
+ if (resetsAt !== undefined) window.resetsAt = resetsAt;
81
+ windows.push(window);
82
+ }
83
+ return windows;
84
+ }
85
+
86
+ function standaloneOutput(metrics) {
87
+ if (!metrics) return "agent-relay context: unavailable";
88
+ const pct = Math.round(metrics.contextPercent);
89
+ const tokens = metrics.tokensUsed && metrics.tokensMax ? ` ${formatTokens(metrics.tokensUsed)}/${formatTokens(metrics.tokensMax)}` : "";
90
+ return `agent-relay context: ${pct}%${tokens}`;
91
+ }
92
+
93
+ function formatTokens(tokens) {
94
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
95
+ if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`;
96
+ return String(tokens);
97
+ }
98
+
99
+ function safeStateId(agentId) {
100
+ const safe = basename(agentId).replace(/[^\w.-]/g, "-");
101
+ return safe || "unknown";
102
+ }
103
+
104
+ function isRecord(value) {
105
+ return value !== null && typeof value === "object" && !Array.isArray(value);
106
+ }
107
+
108
+ function stringValue(value) {
109
+ return typeof value === "string" && value.length > 0 ? value : undefined;
110
+ }
111
+
112
+ function numberValue(value) {
113
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
114
+ }
115
+
116
+ function sumNumbers(...values) {
117
+ let total = 0;
118
+ let seen = false;
119
+ for (const value of values) {
120
+ if (typeof value === "number" && Number.isFinite(value)) {
121
+ total += value;
122
+ seen = true;
123
+ }
124
+ }
125
+ return seen ? total : undefined;
126
+ }
127
+
128
+ function secondsUntil(epochSeconds, nowMs) {
129
+ return epochSeconds === undefined ? undefined : Math.max(0, Math.round(epochSeconds - nowMs / 1000));
130
+ }
131
+
132
+ function epochSecondsToMs(epochSeconds) {
133
+ return epochSeconds === undefined ? undefined : epochSeconds * 1000;
134
+ }
@@ -71,7 +71,7 @@ var claudeProviderManifest = {
71
71
  }
72
72
  },
73
73
  catalog: {
74
- defaultModel: "sonnet-4.6",
74
+ defaultModel: "sonnet-5",
75
75
  models: [
76
76
  { alias: "fable-5", label: "Fable 5", providerModel: "claude-fable-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", unavailable: "suspended by export-control directive, 2026-06-12", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
77
77
  { alias: "opus-4.8", label: "Opus 4.8", providerModel: "claude-opus-4-8", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
@@ -80,8 +80,8 @@ var claudeProviderManifest = {
80
80
  { alias: "opus-4.7[1m]", label: "Opus 4.7 1M", providerModel: "claude-opus-4-7[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
81
81
  { alias: "opus-4.6", label: "Opus 4.6", providerModel: "claude-opus-4-6", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
82
82
  { alias: "opus-4.6[1m]", label: "Opus 4.6 1M", providerModel: "claude-opus-4-6[1m]", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
83
- { alias: "sonnet-4.6", label: "Sonnet 4.6", providerModel: "claude-sonnet-4-6", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
84
- { alias: "sonnet-4.6[1m]", label: "Sonnet 4.6 1M", providerModel: "claude-sonnet-4-6[1m]", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
83
+ { alias: "sonnet-5", label: "Sonnet 5", providerModel: "claude-sonnet-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
84
+ { alias: "sonnet-5[1m]", label: "Sonnet 5 1M", providerModel: "claude-sonnet-5[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
85
85
  { alias: "haiku", label: "Haiku", providerModel: "haiku", efforts: [], ...codeModel() }
86
86
  ]
87
87
  },
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { resolve, join } from "node:path";
3
3
  import type { AgentProfile, AgentProfileBase, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
4
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
4
5
  import {
5
6
  profileAllowsRelayFeature,
6
7
  profileIsVanillaBase,
@@ -186,11 +187,15 @@ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[]
186
187
 
187
188
  function relayStatusLineSettings(): Record<string, unknown> {
188
189
  return {
189
- statusLine: { type: "command", command: "agent-relay context-probe --wrap", refreshInterval: 30 },
190
+ statusLine: { type: "command", command: `node ${shellQuote(claudeStatusLineProbePath())}`, refreshInterval: 30 },
190
191
  showThinkingSummaries: true,
191
192
  };
192
193
  }
193
194
 
195
+ function claudeStatusLineProbePath(): string {
196
+ return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
197
+ }
198
+
194
199
  function claudeSettingsArgs(
195
200
  projectSettings: Record<string, unknown>,
196
201
  includeStatusLine: boolean,
@@ -125,6 +125,8 @@ const REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS = 10_000;
125
125
  const UNEXPECTED_EXIT_WINDOW_MS = 2 * 60 * 1000;
126
126
  const RAPID_EXIT_MS = 30 * 1000;
127
127
  const MAX_RAPID_UNEXPECTED_EXITS = 3;
128
+ const UNDETERMINED_PROVIDER_EXIT_CONFIRM_MS = 1_500;
129
+ const PROVIDER_EXIT_CONFIRM_POLL_MS = 250;
128
130
  // A UserPromptSubmit echo matching a runner-injected prompt within this window is
129
131
  // the same prompt arriving back from the provider — drop it to avoid a duplicate.
130
132
  const PROMPT_ECHO_DEDUP_MS = 30_000;
@@ -996,39 +998,67 @@ export class AgentRunner {
996
998
 
997
999
  if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
998
1000
  const hasResumeId = typeof diagnostics.claudeResumeId === "string" && diagnostics.claudeResumeId.length > 0;
999
- logger.warn("lifecycle", `${this.options.provider} exited; leaving agent offline for manual recovery`);
1000
- this.publishRunnerTimelineEvent({
1001
- status: "provider.restart_decision",
1002
- id: `provider-restart-decision-${this.providerSessionId}-${now}`,
1003
- timestamp: Date.now(),
1004
- title: "Provider restart skipped",
1005
- body: hasResumeId
1006
- ? "Claude exited; runner will not auto-resume. Resume id captured for manual recovery."
1007
- : "Claude exited; runner will not restart automatically.",
1008
- icon: "ti-player-stop",
1009
- metadata: {
1010
- eventType: "provider.restart_decision",
1011
- decision: "stop-surface",
1001
+ if (!hasResumeId) {
1002
+ const recoveredStatus = await this.recoveredStatusAfterUndeterminedProviderExit();
1003
+ if (recoveredStatus) {
1004
+ logger.warn("lifecycle", `${this.options.provider} exit signal recovered as ${recoveredStatus}; suppressing terminal exit`);
1005
+ this.publishRunnerTimelineEvent({
1006
+ status: "provider.restart_decision",
1007
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
1008
+ timestamp: Date.now(),
1009
+ title: "Provider exit suppressed",
1010
+ body: `${this.options.provider} reported ${recoveredStatus} during exit confirmation; runner kept the provider live.`,
1011
+ icon: "ti-player-play",
1012
+ metadata: {
1013
+ eventType: "provider.restart_decision",
1014
+ decision: "suppress-recovered",
1015
+ reason: "undetermined-provider-exit-recovered",
1016
+ recoveredStatus,
1017
+ confirmWindowMs: UNDETERMINED_PROVIDER_EXIT_CONFIRM_MS,
1018
+ ...diagnostics,
1019
+ },
1020
+ });
1021
+ this.setProviderStatus(recoveredStatus);
1022
+ return;
1023
+ }
1024
+ }
1025
+ if (!hasResumeId && this.shouldRestartRecoverableProviderExit(diagnostics)) {
1026
+ logger.warn("lifecycle", `${this.options.provider} exit signal is early/recoverable; allowing fresh restart`);
1027
+ } else {
1028
+ logger.warn("lifecycle", `${this.options.provider} exited; leaving agent offline for manual recovery`);
1029
+ this.publishRunnerTimelineEvent({
1030
+ status: "provider.restart_decision",
1031
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
1032
+ timestamp: Date.now(),
1033
+ title: "Provider restart skipped",
1034
+ body: hasResumeId
1035
+ ? "Claude exited; runner will not auto-resume. Resume id captured for manual recovery."
1036
+ : "Claude exited; runner will not restart automatically.",
1037
+ icon: "ti-player-stop",
1038
+ metadata: {
1039
+ eventType: "provider.restart_decision",
1040
+ decision: "stop-surface",
1041
+ reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
1042
+ ...diagnostics,
1043
+ },
1044
+ });
1045
+ this.process = undefined;
1046
+ // #633: the runner stays alive (manual-resume hold), so neither the orchestrator tmux-gone
1047
+ // path nor the heartbeat reaper will ever fire. Stamp the terminal marker so the SAME offline
1048
+ // status frame (published by setProviderStatus below) carries it; the server's bus status
1049
+ // handler turns that edge into a terminal `agent.exited` event with a #636 diagnosis.
1050
+ this.terminalProviderExit = {
1012
1051
  reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
1013
1052
  ...diagnostics,
1014
- },
1015
- });
1016
- this.process = undefined;
1017
- // #633: the runner stays alive (manual-resume hold), so neither the orchestrator tmux-gone
1018
- // path nor the heartbeat reaper will ever fire. Stamp the terminal marker so the SAME offline
1019
- // status frame (published by setProviderStatus below) carries it; the server's bus status
1020
- // handler turns that edge into a terminal `agent.exited` event with a #636 diagnosis.
1021
- this.terminalProviderExit = {
1022
- reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
1023
- ...diagnostics,
1024
- };
1025
- this.setProviderStatus({
1026
- status,
1027
- reason: "provider-turn",
1028
- id: `provider-exit-${this.providerSessionId}`,
1029
- clear: ["provider-turn", "subagent", "background-script"],
1030
- });
1031
- return;
1053
+ };
1054
+ this.setProviderStatus({
1055
+ status,
1056
+ reason: "provider-turn",
1057
+ id: `provider-exit-${this.providerSessionId}`,
1058
+ clear: ["provider-turn", "subagent", "background-script"],
1059
+ });
1060
+ return;
1061
+ }
1032
1062
  }
1033
1063
 
1034
1064
  if (runtimeMs < RAPID_EXIT_MS && recent.length > MAX_RAPID_UNEXPECTED_EXITS) {
@@ -1094,6 +1124,28 @@ export class AgentRunner {
1094
1124
  return getManifest(this.options.provider)?.launch?.managedPolicyPrompt?.alwaysOn === "append-system-prompt" && diagnostics.exitCommandInProgress !== true;
1095
1125
  }
1096
1126
 
1127
+ private shouldRestartRecoverableProviderExit(diagnostics: Record<string, unknown>): boolean {
1128
+ return diagnostics.exitSource === "tmux-session-ended"
1129
+ && typeof diagnostics.runtimeMs === "number"
1130
+ && diagnostics.runtimeMs >= 0
1131
+ && diagnostics.runtimeMs < RAPID_EXIT_MS;
1132
+ }
1133
+
1134
+ private async recoveredStatusAfterUndeterminedProviderExit(): Promise<"busy" | "idle" | undefined> {
1135
+ if (!this.process || !this.options.adapter.probeActivity) return undefined;
1136
+ const deadline = Date.now() + UNDETERMINED_PROVIDER_EXIT_CONFIRM_MS;
1137
+ do {
1138
+ const activity = await this.options.adapter.probeActivity(this.process).catch((error) => {
1139
+ this.sessionLog(`provider-exit confirmation probe failed: ${errMessage(error)}`);
1140
+ return "unknown" as const;
1141
+ });
1142
+ if (activity === "busy" || activity === "idle") return activity;
1143
+ if (Date.now() >= deadline) return undefined;
1144
+ await Bun.sleep(PROVIDER_EXIT_CONFIRM_POLL_MS);
1145
+ } while (!this.stopped && !this.exitCommandInProgress);
1146
+ return undefined;
1147
+ }
1148
+
1097
1149
  private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<void> {
1098
1150
  this.lifecycleAction = hard ? "killing" : "shutting-down";
1099
1151
  this.publishStatus();