agent-relay-server 0.122.0 → 0.122.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/docs/openapi.json +1 -1
- package/package.json +5 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +672 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +508 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +43 -0
- package/src/gate-resolver.ts +7 -12
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Provider-agnostic core for the #184 context-gathering signal (epic #183).
|
|
2
|
+
//
|
|
3
|
+
// The transcript *format* is provider-specific (Claude JSONL, Codex app-server items,
|
|
4
|
+
// future providers), so each adapter normalizes its session into the same `SessionEvent`
|
|
5
|
+
// stream via `collectSessionEvents`. Everything downstream — the gathering/action
|
|
6
|
+
// classifier and the ratio math — lives here once and is shared, so a tool reclassified
|
|
7
|
+
// for one provider is reclassified for all, and a new provider only implements the
|
|
8
|
+
// normalization.
|
|
9
|
+
//
|
|
10
|
+
// The classifier is model-free and runs in the runner, so it costs zero agent tokens and
|
|
11
|
+
// the agent can't game it.
|
|
12
|
+
|
|
13
|
+
// A normalized, ordered session event. Order is significant: `leadingGather` counts the
|
|
14
|
+
// run of gathering tools before the first action.
|
|
15
|
+
export type SessionEvent =
|
|
16
|
+
// A tool invocation. Gathering-vs-action is decided here by `isGatheringTool(name)`.
|
|
17
|
+
| { type: "tool"; name: string }
|
|
18
|
+
// A failed tool result (paired outcome proxy — failures/workarounds the agent hit).
|
|
19
|
+
| { type: "tool_error" }
|
|
20
|
+
// A real user prompt (paired outcome proxy — more back-and-forth ~ clarification/correction).
|
|
21
|
+
| { type: "user_prompt" }
|
|
22
|
+
// A substantive assistant turn (one that produced text or a tool call).
|
|
23
|
+
| { type: "turn" };
|
|
24
|
+
|
|
25
|
+
// Tools that acquire context without changing anything. Anything not matched here is
|
|
26
|
+
// treated as an action (mutation, execution, or a delegation/direction decision) — Bash
|
|
27
|
+
// counts as an action because it executes (a conservative, documented choice for v0;
|
|
28
|
+
// `cat`/`ls` via Bash are misclassified, refine later if the data warrants it).
|
|
29
|
+
const GATHERING_TOOLS = new Set([
|
|
30
|
+
"Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch",
|
|
31
|
+
]);
|
|
32
|
+
const GATHERING_NAME = /(?:^|[._-])(read|get|list|search|grep|glob|find|fetch|query|browse|view|show|cat|status|inspect|lookup|symbols|snippet)/i;
|
|
33
|
+
|
|
34
|
+
export function isGatheringTool(name: string): boolean {
|
|
35
|
+
if (GATHERING_TOOLS.has(name)) return true;
|
|
36
|
+
// MCP / custom tools: classify by name shape (e.g. mcp__callmux__searxng_web_search).
|
|
37
|
+
return GATHERING_NAME.test(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ContextRatioMetric {
|
|
41
|
+
/** Session-wide gathering fraction: gatheringCalls / totalToolCalls. The headline metric. */
|
|
42
|
+
ratio: number;
|
|
43
|
+
gatheringCalls: number;
|
|
44
|
+
actionCalls: number;
|
|
45
|
+
totalToolCalls: number;
|
|
46
|
+
/** Consecutive gathering calls before the first action — the "read N files before moving" signal. */
|
|
47
|
+
leadingGather: number;
|
|
48
|
+
/** Substantive assistant turns (turns that produced text or a tool call). */
|
|
49
|
+
turns: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SessionOutcomeProxy {
|
|
53
|
+
/** Real user prompts in the session — more back-and-forth ~ more clarification/correction. */
|
|
54
|
+
userPrompts: number;
|
|
55
|
+
/** tool_result blocks flagged is_error — failures/workarounds the agent hit. */
|
|
56
|
+
toolErrors: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SessionAnalysis {
|
|
60
|
+
metric: ContextRatioMetric;
|
|
61
|
+
outcome: SessionOutcomeProxy;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reduce a normalized event stream to the context-gathering ratio plus paired outcome
|
|
66
|
+
* proxies. Returns null when there's nothing substantive to measure (no tool calls) —
|
|
67
|
+
* trivial segments have nothing to learn from and shouldn't pollute the baselines.
|
|
68
|
+
*
|
|
69
|
+
* Per-segment by construction: callers pass only the events since the last capture
|
|
70
|
+
* boundary (compact/clear/restart/shutdown), so each result describes one work chunk.
|
|
71
|
+
*/
|
|
72
|
+
export function computeContextRatio(events: SessionEvent[]): SessionAnalysis | null {
|
|
73
|
+
let gatheringCalls = 0;
|
|
74
|
+
let actionCalls = 0;
|
|
75
|
+
let leadingGather = 0;
|
|
76
|
+
let sawAction = false;
|
|
77
|
+
let userPrompts = 0;
|
|
78
|
+
let toolErrors = 0;
|
|
79
|
+
let turns = 0;
|
|
80
|
+
|
|
81
|
+
for (const event of events) {
|
|
82
|
+
switch (event.type) {
|
|
83
|
+
case "user_prompt":
|
|
84
|
+
userPrompts++;
|
|
85
|
+
break;
|
|
86
|
+
case "tool_error":
|
|
87
|
+
toolErrors++;
|
|
88
|
+
break;
|
|
89
|
+
case "turn":
|
|
90
|
+
turns++;
|
|
91
|
+
break;
|
|
92
|
+
case "tool":
|
|
93
|
+
if (isGatheringTool(event.name)) {
|
|
94
|
+
gatheringCalls++;
|
|
95
|
+
if (!sawAction) leadingGather++;
|
|
96
|
+
} else {
|
|
97
|
+
actionCalls++;
|
|
98
|
+
sawAction = true;
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const totalToolCalls = gatheringCalls + actionCalls;
|
|
105
|
+
if (totalToolCalls === 0) return null;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
metric: {
|
|
109
|
+
ratio: gatheringCalls / totalToolCalls,
|
|
110
|
+
gatheringCalls,
|
|
111
|
+
actionCalls,
|
|
112
|
+
totalToolCalls,
|
|
113
|
+
leadingGather,
|
|
114
|
+
turns,
|
|
115
|
+
},
|
|
116
|
+
outcome: { userPrompts, toolErrors },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
|
+
|
|
7
|
+
const SCRATCH_DIR_NAME = ".agent-relay";
|
|
8
|
+
// The local-ignore entry. Leading + trailing slash scopes it to the dir at the
|
|
9
|
+
// base, matching git's gitignore semantics.
|
|
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;
|
|
13
|
+
|
|
14
|
+
export interface SessionScratchLayout {
|
|
15
|
+
baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
|
|
16
|
+
rootDir: string; // <base>/.agent-relay
|
|
17
|
+
sessionsDir: string; // <base>/.agent-relay/sessions
|
|
18
|
+
sessionDir: string; // <base>/.agent-relay/sessions/<id>
|
|
19
|
+
tmpDir: string; // <base>/.agent-relay/sessions/<id>/tmp
|
|
20
|
+
replyFile: string; // <tmp>/reply.md
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SessionScratchTarget {
|
|
24
|
+
agentId: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
// Orchestrator base dir, used only when cwd is not writable. NEVER home — a
|
|
27
|
+
// home fallback would place scratch above the orchestrator base dir and break
|
|
28
|
+
// cwd containment.
|
|
29
|
+
fallbackBaseDir?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SessionLauncherTarget {
|
|
33
|
+
launcherScript?: string;
|
|
34
|
+
launcherDir?: string;
|
|
35
|
+
tmuxSession?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isWritableDir(dir: string): boolean {
|
|
39
|
+
try {
|
|
40
|
+
if (!statSync(dir).isDirectory()) return false;
|
|
41
|
+
accessSync(dir, constants.W_OK);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Prefer cwd; fall back to the orchestrator base dir only when cwd is not a
|
|
49
|
+
// writable dir. Returns cwd as a last resort so callers can still attempt mkdir
|
|
50
|
+
// (and surface the real error) rather than silently picking an unrelated path.
|
|
51
|
+
export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): string {
|
|
52
|
+
if (isWritableDir(cwd)) return cwd;
|
|
53
|
+
if (fallbackBaseDir && isWritableDir(fallbackBaseDir)) return fallbackBaseDir;
|
|
54
|
+
return cwd;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
|
|
58
|
+
const rootDir = join(baseDir, SCRATCH_DIR_NAME);
|
|
59
|
+
const sessionsDir = join(rootDir, "sessions");
|
|
60
|
+
const sessionDir = join(sessionsDir, agentId);
|
|
61
|
+
const tmpDir = join(sessionDir, "tmp");
|
|
62
|
+
return { baseDir, rootDir, sessionsDir, sessionDir, tmpDir, replyFile: join(tmpDir, "reply.md") };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve git's exclude file for baseDir, correctly handling plain repos,
|
|
66
|
+
// worktrees, and submodules (git rev-parse returns the shared commondir's
|
|
67
|
+
// info/exclude). Returns null when baseDir is not inside a git work tree.
|
|
68
|
+
function gitExcludePath(baseDir: string): string | null {
|
|
69
|
+
try {
|
|
70
|
+
const out = execFileSync("git", ["-C", baseDir, "rev-parse", "--git-path", "info/exclude"], {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
73
|
+
}).trim();
|
|
74
|
+
if (!out) return null;
|
|
75
|
+
return isAbsolute(out) ? out : resolve(baseDir, out);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Idempotently append EXCLUDE_ENTRY to an ignore file (grep-before-write).
|
|
82
|
+
function appendIgnoreEntry(file: string): void {
|
|
83
|
+
let existing = "";
|
|
84
|
+
try {
|
|
85
|
+
existing = readFileSync(file, "utf8");
|
|
86
|
+
} catch {}
|
|
87
|
+
const wanted = EXCLUDE_ENTRY.trim();
|
|
88
|
+
if (existing.split("\n").some((line) => line.trim() === wanted)) return;
|
|
89
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
90
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
91
|
+
appendFileSync(file, `${prefix}${EXCLUDE_ENTRY}\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Ensure `.agent-relay/` is locally ignored. Prefers .git/info/exclude (git's
|
|
95
|
+
// local, uncommitted ignore list) so no tracked file is ever modified; falls
|
|
96
|
+
// back to .gitignore only when baseDir is not a git repo. Returns which file was
|
|
97
|
+
// used (for logging/tests).
|
|
98
|
+
export function ensureScratchIgnored(baseDir: string): "exclude" | "gitignore" {
|
|
99
|
+
const excludePath = gitExcludePath(baseDir);
|
|
100
|
+
if (excludePath) {
|
|
101
|
+
appendIgnoreEntry(excludePath);
|
|
102
|
+
return "exclude";
|
|
103
|
+
}
|
|
104
|
+
appendIgnoreEntry(join(baseDir, ".gitignore"));
|
|
105
|
+
return "gitignore";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// SessionStart: create the session tmp dir and ensure local ignore. Dir
|
|
109
|
+
// creation is the load-bearing part; ignore setup is best-effort.
|
|
110
|
+
export function ensureSessionScratch(target: SessionScratchTarget): SessionScratchLayout {
|
|
111
|
+
const base = resolveScratchBase(target.cwd, target.fallbackBaseDir);
|
|
112
|
+
const layout = sessionScratchLayout(base, target.agentId);
|
|
113
|
+
mkdirSync(layout.tmpDir, { recursive: true });
|
|
114
|
+
try {
|
|
115
|
+
ensureScratchIgnored(base);
|
|
116
|
+
} catch {
|
|
117
|
+
// best-effort: a missing ignore entry never pollutes git because the dir is
|
|
118
|
+
// already created and the entry is the only thing at risk.
|
|
119
|
+
}
|
|
120
|
+
return layout;
|
|
121
|
+
}
|
|
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
|
+
|
|
132
|
+
function dedupeBases(bases: Array<string | undefined>): string[] {
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const out: string[] = [];
|
|
135
|
+
for (const b of bases) {
|
|
136
|
+
if (!b || seen.has(b)) continue;
|
|
137
|
+
seen.add(b);
|
|
138
|
+
out.push(b);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
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
|
+
|
|
154
|
+
// SessionEnd: remove this session's dir from every candidate base.
|
|
155
|
+
export function reapSessionScratch(target: SessionScratchTarget): void {
|
|
156
|
+
for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
|
|
157
|
+
const { sessionDir } = sessionScratchLayout(base, target.agentId);
|
|
158
|
+
try {
|
|
159
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
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
|
+
|
|
182
|
+
interface SweepOptions {
|
|
183
|
+
cwd: string;
|
|
184
|
+
fallbackBaseDir?: string;
|
|
185
|
+
// Agent ids to keep (currently-known agents + self). Any session dir whose id
|
|
186
|
+
// is not in this set AND older than minAgeMs is removed.
|
|
187
|
+
keepAgentIds: Set<string>;
|
|
188
|
+
now: number;
|
|
189
|
+
minAgeMs?: number;
|
|
190
|
+
}
|
|
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
|
+
|
|
219
|
+
// Periodic sweep of leftover session dirs from agents no longer known to the
|
|
220
|
+
// relay. The minAgeMs grace avoids racing a peer that just created its dir but
|
|
221
|
+
// has not yet appeared in the agent list.
|
|
222
|
+
export function sweepStaleSessions(opts: SweepOptions): string[] {
|
|
223
|
+
const minAge = opts.minAgeMs ?? 60 * 60 * 1000;
|
|
224
|
+
const removed: string[] = [];
|
|
225
|
+
for (const base of dedupeBases([opts.cwd, opts.fallbackBaseDir])) {
|
|
226
|
+
const sessionsDir = join(base, SCRATCH_DIR_NAME, "sessions");
|
|
227
|
+
let ids: string[];
|
|
228
|
+
try {
|
|
229
|
+
ids = readdirSync(sessionsDir);
|
|
230
|
+
} catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
for (const id of ids) {
|
|
234
|
+
if (opts.keepAgentIds.has(id)) continue;
|
|
235
|
+
const dir = join(sessionsDir, id);
|
|
236
|
+
try {
|
|
237
|
+
if (opts.now - statSync(dir).mtimeMs < minAge) continue;
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
removed.push(dir);
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return removed;
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RelayHttpClient } from "agent-relay-sdk";
|
|
2
|
+
import { RUNNER_PROMPT_TEMPLATE_DEFAULTS } from "agent-relay-sdk/prompt-template-defaults";
|
|
3
|
+
|
|
4
|
+
const RUNNER_DEFAULTS = new Map<string, string>(RUNNER_PROMPT_TEMPLATE_DEFAULTS.map((template) => [template.slug, template.defaultTemplate]));
|
|
5
|
+
const templateCache = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
function renderValue(value: string | undefined): string {
|
|
8
|
+
return value ?? "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function applyVariables(template: string, vars: Record<string, string>): string {
|
|
12
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => renderValue(vars[key]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function initializeRunnerTemplates(http: RelayHttpClient): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
const templates = await http.listPromptTemplates();
|
|
18
|
+
templateCache.clear();
|
|
19
|
+
for (const template of templates) templateCache.set(template.slug, template.template);
|
|
20
|
+
} catch {
|
|
21
|
+
templateCache.clear();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderRunnerTemplate(slug: string, vars: Record<string, string> = {}): string {
|
|
26
|
+
const template = templateCache.get(slug) ?? RUNNER_DEFAULTS.get(slug);
|
|
27
|
+
if (template === undefined) throw new Error(`unknown runner prompt template slug: ${slug}`);
|
|
28
|
+
return applyVariables(template, vars);
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { CONTRACT_VERSIONS } from "agent-relay-sdk";
|
|
5
|
+
import { getManifest } from "agent-relay-providers";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
|
|
9
|
+
|
|
10
|
+
export const PACKAGE_NAME = pkg.name || "agent-relay-runner";
|
|
11
|
+
export const VERSION = pkg.version || "0.0.0";
|
|
12
|
+
// Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
|
|
13
|
+
export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
|
|
14
|
+
export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
|
|
15
|
+
|
|
16
|
+
export const CONTRACTS = {
|
|
17
|
+
runnerProtocol: RUNNER_PROTOCOL_VERSION,
|
|
18
|
+
providerPluginProtocol: PROVIDER_PLUGIN_PROTOCOL_VERSION,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const RUNTIME_CAPABILITIES = {
|
|
22
|
+
relayCommandBus: true,
|
|
23
|
+
managedRegistration: true,
|
|
24
|
+
lifecycleCommands: true,
|
|
25
|
+
claimRenewal: true,
|
|
26
|
+
deliveryRetry: true,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export function runtimeMetadata(provider?: string) {
|
|
30
|
+
const manifest = provider ? getManifest(provider) : undefined;
|
|
31
|
+
return {
|
|
32
|
+
package: {
|
|
33
|
+
name: PACKAGE_NAME,
|
|
34
|
+
version: VERSION,
|
|
35
|
+
},
|
|
36
|
+
contracts: CONTRACTS,
|
|
37
|
+
capabilities: {
|
|
38
|
+
...RUNTIME_CAPABILITIES,
|
|
39
|
+
...(manifest?.provisioning?.bundledPlugin ? { bundledPlugin: true } : {}),
|
|
40
|
+
...(manifest?.provisioning?.bundledSkills ? { bundledSkills: true } : {}),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/gate-resolver.ts
CHANGED
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
// package manager (from `packageManager` field or lockfile). A repo whose
|
|
12
12
|
// `scripts.test` is `jest` gets `<pm> run test`, never `bun test`. When the repo
|
|
13
13
|
// declares a full-gate script (`ci:land` preferred, else `ci`), that IS its gate
|
|
14
|
-
// (the set the release
|
|
15
|
-
// release gates (#1044), instead of a
|
|
16
|
-
// drift/lint/doc breakage land.
|
|
17
|
-
// the note at the derivation for why land gates can't run every release check.
|
|
14
|
+
// (the set the repo wants enforced before land/release): the steward runs it
|
|
15
|
+
// wholesale so land gates == release gates (#1044/#1089), instead of a
|
|
16
|
+
// file-relevance subset that would let drift/lint/doc breakage land.
|
|
18
17
|
// 3. Default — the legacy bun heuristic, only when there's nothing to derive from
|
|
19
18
|
// (no repo path, or no readable package.json).
|
|
20
19
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -109,14 +108,10 @@ export function resolveStewardChecks(files: string[], repoRoot?: string): Stewar
|
|
|
109
108
|
// drift/lint/doc breakage that only a narrower gate misses and that would otherwise
|
|
110
109
|
// surface at release time (failing `*:check`/lint gates).
|
|
111
110
|
//
|
|
112
|
-
// Prefer
|
|
113
|
-
//
|
|
114
|
-
// `
|
|
115
|
-
//
|
|
116
|
-
// that check false-fails in every worktree (its topology-determinism root fix is
|
|
117
|
-
// tracked in #1045). `ci:land` is the worktree-safe subset of `ci` (ci == the two
|
|
118
|
-
// bundle steps + ci:land), so the steward and the release share ONE source of truth
|
|
119
|
-
// for the land-relevant gates without the steward hitting a structural false failure.
|
|
111
|
+
// Prefer an explicit `ci:land` script when the repo declares one: it is the repo's
|
|
112
|
+
// named pre-land full gate, so the steward should respect that contract over a more
|
|
113
|
+
// generic `ci`. Repos that want exact parity can make `ci` delegate to `ci:land`;
|
|
114
|
+
// repos that need a different land-safe full gate still have a first-class name for it.
|
|
120
115
|
const fullGate = pkg.scripts["ci:land"] ? "ci:land" : pkg.scripts.ci ? "ci" : undefined;
|
|
121
116
|
if (fullGate && files.length) {
|
|
122
117
|
return [{ command: runScript(fullGate), reason: `repo declares a full "${fullGate}" gate (land==release parity)` }];
|