agent-relay-runner 0.117.0 → 0.118.0

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.117.0",
3
+ "version": "0.118.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "agent-relay-providers": "0.104.1",
24
- "agent-relay-sdk": "0.2.99",
24
+ "agent-relay-sdk": "0.2.100",
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.117.0",
4
+ "version": "0.118.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -1,11 +1,11 @@
1
1
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
- import { homedir, tmpdir } from "node:os";
4
- import { join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { type Message } from "agent-relay-sdk";
6
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
7
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
7
8
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
8
- import { sanitizeFsName } from "agent-relay-sdk/fs-name";
9
9
  import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
10
10
  import { computeLivenessSignal, type LivenessInputs } from "../liveness";
11
11
  import type { LivenessSignal } from "agent-relay-sdk";
@@ -17,6 +17,7 @@ import { claudeProviderMessageText } from "./claude-delivery";
17
17
  import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
18
18
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
19
19
  import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
20
+ import { launcherScriptPathForSession } from "../session-scratch";
20
21
 
21
22
  export class ClaudeAdapter implements ProviderAdapter {
22
23
  readonly provider = "claude";
@@ -346,7 +347,7 @@ export class ClaudeAdapter implements ProviderAdapter {
346
347
  }
347
348
 
348
349
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
349
- const { sessionName, socketName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
350
+ const { sessionName, socketName, args: tmuxArgs, launcherScript } = this.buildTmuxArgs(config, spawnArgs);
350
351
  this.modelUnavailableReported = false;
351
352
  this.connectionRetryActive = false;
352
353
  this.promptGatePaneState = initialClaudePromptGatePaneState();
@@ -385,6 +386,7 @@ export class ClaudeAdapter implements ProviderAdapter {
385
386
  monitorless: !profileAllowsRelayFeature(config, "plugins"),
386
387
  tmuxSession: sessionName,
387
388
  tmuxSocket: socketName,
389
+ launcherScript,
388
390
  },
389
391
  };
390
392
  }
@@ -604,13 +606,9 @@ async function submitTextToTmux(sessionName: string, text: string, socketName?:
604
606
  }
605
607
  }
606
608
 
607
- const LAUNCHER_DIR = join(tmpdir(), `agent-relay-launchers-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
608
-
609
609
  function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
610
- mkdirSync(LAUNCHER_DIR, { recursive: true, mode: 0o700 });
611
- chmodSync(LAUNCHER_DIR, 0o700);
612
- const sanitized = sanitizeFsName(sessionName, { replacement: "-", collapse: false });
613
- const scriptPath = join(LAUNCHER_DIR, `${sanitized}.sh`);
610
+ const scriptPath = launcherScriptPathForSession(sessionName);
611
+ const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
614
612
  const exports = Object.entries(env)
615
613
  .map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
616
614
  .join("\n");
@@ -23,7 +23,7 @@ import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./re
23
23
  import { RelayMcpProxy } from "./relay-mcp-proxy";
24
24
  import { runtimeMetadata } from "./version";
25
25
  import { logger, parseLogLevel } from "./logger";
26
- import { ensureSessionScratch, reapSessionScratch, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
26
+ import { ensureSessionScratch, reapSessionLauncher, reapSessionScratch, sweepStaleLaunchers, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
27
27
  import { deliverBufferedMcpCall as deliverBufferedMcpOutboxCall, deliverRunnerOutboxEvent } from "./mcp-outbox";
28
28
  import { RunnerInsights } from "./runner-insights";
29
29
  import { BusyReconciler } from "./busy-reconciler";
@@ -466,11 +466,9 @@ export class AgentRunner {
466
466
  async stop(): Promise<void> {
467
467
  const alreadyStopped = this.stopped;
468
468
  this.stopped = true;
469
- reapSessionScratch({
470
- agentId: this.agentId,
471
- cwd: this.options.cwd,
472
- fallbackBaseDir: orchestratorBaseDirFromEnv(),
473
- });
469
+ const launcherScript = typeof this.process?.meta?.launcherScript === "string" ? this.process.meta.launcherScript : undefined; const tmuxSession = typeof this.process?.meta?.tmuxSession === "string" ? this.process.meta.tmuxSession : undefined;
470
+ reapSessionScratch({ agentId: this.agentId, cwd: this.options.cwd, fallbackBaseDir: orchestratorBaseDirFromEnv() });
471
+ reapSessionLauncher({ launcherScript, tmuxSession });
474
472
  if (!alreadyStopped) await this.bus.statusAsync({ agentStatus: "offline", ready: false });
475
473
  if (this.process && !alreadyStopped) {
476
474
  await this.options.adapter.shutdown(this.process, {
@@ -2053,15 +2051,15 @@ export class AgentRunner {
2053
2051
  private async sweepStaleScratch(): Promise<void> {
2054
2052
  try {
2055
2053
  const agents = await this.http.listAgents().catch(() => [] as Awaited<ReturnType<RelayHttpClient["listAgents"]>>);
2056
- const keep = new Set(agents.map((a) => a.id));
2057
- keep.add(this.agentId);
2054
+ const keep = new Set(agents.map((a) => a.id)); keep.add(this.agentId); const now = Date.now();
2058
2055
  const removed = sweepStaleSessions({
2059
2056
  cwd: this.options.cwd,
2060
2057
  fallbackBaseDir: orchestratorBaseDirFromEnv(),
2061
2058
  keepAgentIds: keep,
2062
- now: Date.now(),
2059
+ now,
2063
2060
  });
2064
2061
  if (removed.length) this.logRunnerDiagnostic(`swept ${removed.length} stale session scratch dir(s)`);
2062
+ const removedLaunchers = sweepStaleLaunchers({ keepAgentIds: keep, now }); if (removedLaunchers.length) this.logRunnerDiagnostic(`swept ${removedLaunchers.length} stale launcher script(s)`);
2065
2063
  } catch {}
2066
2064
  }
2067
2065
 
@@ -1,11 +1,15 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
3
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
6
 
5
7
  const SCRATCH_DIR_NAME = ".agent-relay";
6
8
  // The local-ignore entry. Leading + trailing slash scopes it to the dir at the
7
9
  // base, matching git's gitignore semantics.
8
10
  const EXCLUDE_ENTRY = "/.agent-relay/";
11
+ const LAUNCHER_DIR = join(tmpdir(), `agent-relay-launchers-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
12
+ const DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS = 24 * 60 * 60 * 1000;
9
13
 
10
14
  export interface SessionScratchLayout {
11
15
  baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
@@ -25,6 +29,12 @@ interface SessionScratchTarget {
25
29
  fallbackBaseDir?: string;
26
30
  }
27
31
 
32
+ interface SessionLauncherTarget {
33
+ launcherScript?: string;
34
+ launcherDir?: string;
35
+ tmuxSession?: string;
36
+ }
37
+
28
38
  function isWritableDir(dir: string): boolean {
29
39
  try {
30
40
  if (!statSync(dir).isDirectory()) return false;
@@ -110,6 +120,15 @@ export function ensureSessionScratch(target: SessionScratchTarget): SessionScrat
110
120
  return layout;
111
121
  }
112
122
 
123
+ function resolveLauncherDir(launcherDir?: string): string {
124
+ return launcherDir ?? LAUNCHER_DIR;
125
+ }
126
+
127
+ export function launcherScriptPathForSession(sessionName: string, launcherDir?: string): string {
128
+ const sanitized = sanitizeFsName(sessionName, { replacement: "-", collapse: false });
129
+ return join(resolveLauncherDir(launcherDir), `${sanitized}.sh`);
130
+ }
131
+
113
132
  function dedupeBases(bases: Array<string | undefined>): string[] {
114
133
  const seen = new Set<string>();
115
134
  const out: string[] = [];
@@ -121,6 +140,17 @@ function dedupeBases(bases: Array<string | undefined>): string[] {
121
140
  return out;
122
141
  }
123
142
 
143
+ function dedupePaths(paths: Array<string | undefined>): string[] {
144
+ const seen = new Set<string>();
145
+ const out: string[] = [];
146
+ for (const path of paths) {
147
+ if (!path || seen.has(path)) continue;
148
+ seen.add(path);
149
+ out.push(path);
150
+ }
151
+ return out;
152
+ }
153
+
124
154
  // SessionEnd: remove this session's dir from every candidate base.
125
155
  export function reapSessionScratch(target: SessionScratchTarget): void {
126
156
  for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
@@ -131,6 +161,24 @@ export function reapSessionScratch(target: SessionScratchTarget): void {
131
161
  }
132
162
  }
133
163
 
164
+ // Claude headless sessions externalize their provider launch command into a
165
+ // per-session script under /tmp. Remove it on runner shutdown alongside the
166
+ // scratch dir reap to avoid leaking one file per spawn.
167
+ export function reapSessionLauncher(target: SessionLauncherTarget): string[] {
168
+ const removed: string[] = [];
169
+ for (const path of dedupePaths([
170
+ target.launcherScript,
171
+ target.tmuxSession ? launcherScriptPathForSession(target.tmuxSession, target.launcherDir) : undefined,
172
+ ])) {
173
+ try {
174
+ const exists = statSync(path).isFile();
175
+ rmSync(path, { force: true });
176
+ if (exists) removed.push(path);
177
+ } catch {}
178
+ }
179
+ return removed;
180
+ }
181
+
134
182
  interface SweepOptions {
135
183
  cwd: string;
136
184
  fallbackBaseDir?: string;
@@ -141,6 +189,33 @@ interface SweepOptions {
141
189
  minAgeMs?: number;
142
190
  }
143
191
 
192
+ interface LauncherSweepOptions {
193
+ // Agent ids to keep (currently-known agents + self). Launcher scripts embed
194
+ // AGENT_RELAY_ID, so a stale sweep can safely remove only scripts whose agent
195
+ // is no longer live.
196
+ keepAgentIds: Set<string>;
197
+ launcherDir?: string;
198
+ now: number;
199
+ minAgeMs?: number;
200
+ }
201
+
202
+ function maybeShellUnquote(value: string): string {
203
+ const trimmed = value.trim();
204
+ if (!trimmed.startsWith("'") || !trimmed.endsWith("'")) return trimmed;
205
+ return trimmed.slice(1, -1).replace(/'\\''/g, "'");
206
+ }
207
+
208
+ function launcherAgentId(scriptPath: string): string | null {
209
+ try {
210
+ const match = readFileSync(scriptPath, "utf8").match(/^export AGENT_RELAY_ID=(.+)$/m);
211
+ if (!match) return null;
212
+ const agentId = maybeShellUnquote(match[1] ?? "");
213
+ return agentId || null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
144
219
  // Periodic sweep of leftover session dirs from agents no longer known to the
145
220
  // relay. The minAgeMs grace avoids racing a peer that just created its dir but
146
221
  // has not yet appeared in the agent list.
@@ -167,3 +242,30 @@ export function sweepStaleSessions(opts: SweepOptions): string[] {
167
242
  }
168
243
  return removed;
169
244
  }
245
+
246
+ // Periodic sweep of leaked per-spawn launcher scripts. Old scripts whose agent
247
+ // id is no longer live are safe to prune; old unparsable files are treated as
248
+ // stale too so the next run clears historical buildup without manual cleanup.
249
+ export function sweepStaleLaunchers(opts: LauncherSweepOptions): string[] {
250
+ const minAge = opts.minAgeMs ?? DEFAULT_LAUNCHER_SWEEP_MIN_AGE_MS;
251
+ const removed: string[] = [];
252
+ const launcherDir = resolveLauncherDir(opts.launcherDir);
253
+ let entries: string[];
254
+ try {
255
+ entries = readdirSync(launcherDir);
256
+ } catch {
257
+ return removed;
258
+ }
259
+ for (const entry of entries) {
260
+ const scriptPath = join(launcherDir, entry);
261
+ try {
262
+ if (!statSync(scriptPath).isFile()) continue;
263
+ if (opts.now - statSync(scriptPath).mtimeMs < minAge) continue;
264
+ const agentId = launcherAgentId(scriptPath);
265
+ if (agentId && opts.keepAgentIds.has(agentId)) continue;
266
+ rmSync(scriptPath, { force: true });
267
+ removed.push(scriptPath);
268
+ } catch {}
269
+ }
270
+ return removed;
271
+ }