agent-relay-runner 0.117.0 → 0.117.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
package/src/adapters/claude.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
611
|
-
chmodSync(
|
|
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");
|
package/src/runner-core.ts
CHANGED
|
@@ -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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|
package/src/session-scratch.ts
CHANGED
|
@@ -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
|
+
}
|