agent-relay-runner 0.17.0 → 0.19.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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +1 -4
- package/src/adapters/claude-delivery.ts +1 -4
- package/src/adapters/claude.ts +10 -18
- package/src/adapters/codex.ts +2 -31
- package/src/attachment-cache.ts +5 -7
- package/src/config.ts +3 -5
- package/src/control-server.ts +5 -8
- package/src/index.ts +3 -2
- package/src/logger.ts +4 -3
- package/src/outbox.ts +4 -2
- package/src/profile-home.ts +23 -15
- package/src/relay-instructions.ts +20 -2
- package/src/reply-obligation-cache.ts +2 -1
- package/src/runner.ts +5 -5
- package/src/version.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.10"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
package/src/adapter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentProfile, Message } from "agent-relay-sdk";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
3
|
|
|
3
4
|
export type SemanticStatus = "idle" | "busy" | "offline" | "error";
|
|
4
5
|
type ProviderWorkKind = "provider-turn" | "subagent";
|
|
@@ -160,10 +161,6 @@ export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a
|
|
|
160
161
|
|
|
161
162
|
const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
162
163
|
|
|
163
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
164
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
164
|
function attachmentRefs(message: Message): Record<string, unknown>[] {
|
|
168
165
|
const payloadRefs = message.payload?.attachments;
|
|
169
166
|
const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Message } from "agent-relay-sdk";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
3
|
import { providerAttachmentText } from "../adapter";
|
|
3
4
|
|
|
4
5
|
const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
@@ -30,10 +31,6 @@ function stripRelayScaffolding(body: string): string {
|
|
|
30
31
|
return lines.join("\n");
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
function isMemoryInjection(message: Message): boolean {
|
|
38
35
|
return isRecord(message.payload) && message.payload.memoryInjection === true;
|
|
39
36
|
}
|
package/src/adapters/claude.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import type { Message } from "agent-relay-sdk";
|
|
5
|
+
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
6
|
+
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
7
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
5
8
|
import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
6
9
|
import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
|
|
7
10
|
import { claudeProviderMessageText } from "./claude-delivery";
|
|
@@ -410,24 +413,16 @@ function hasSettingsArg(args: string[]): boolean {
|
|
|
410
413
|
}
|
|
411
414
|
|
|
412
415
|
export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
|
|
413
|
-
if (label) return `${prefix}-${label
|
|
416
|
+
if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
|
|
414
417
|
return `${prefix}-${instanceId.slice(0, 8)}`;
|
|
415
418
|
}
|
|
416
419
|
|
|
417
420
|
export function tmuxSocketName(sessionName: string): string {
|
|
418
|
-
return `agent-relay-${sessionName}
|
|
421
|
+
return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
|
|
419
422
|
}
|
|
420
423
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
export function tmuxHasSession(sessionName: string, socketName?: string): boolean {
|
|
426
|
-
const result = Bun.spawnSync(tmuxCommand(socketName, "has-session", "-t", sessionName), {
|
|
427
|
-
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
428
|
-
});
|
|
429
|
-
return result.exitCode === 0;
|
|
430
|
-
}
|
|
424
|
+
// Shared tmux helpers; tmuxHasSession re-exported for ./claude consumers + tests.
|
|
425
|
+
export { tmuxHasSession };
|
|
431
426
|
|
|
432
427
|
function captureTmuxPane(sessionName: string, socketName?: string): string {
|
|
433
428
|
const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
|
|
@@ -520,7 +515,7 @@ const LAUNCHER_DIR = join(tmpdir(), "agent-relay-launchers");
|
|
|
520
515
|
|
|
521
516
|
function writeLauncherScript(sessionName: string, shellCmd: string): string {
|
|
522
517
|
mkdirSync(LAUNCHER_DIR, { recursive: true });
|
|
523
|
-
const sanitized = sessionName
|
|
518
|
+
const sanitized = sanitizeFsName(sessionName, { replacement: "-", collapse: false });
|
|
524
519
|
const scriptPath = join(LAUNCHER_DIR, `${sanitized}.sh`);
|
|
525
520
|
writeFileSync(scriptPath, `#!/usr/bin/env bash\nexec ${shellCmd}\n`, { mode: 0o755 });
|
|
526
521
|
return scriptPath;
|
|
@@ -539,11 +534,8 @@ export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<str
|
|
|
539
534
|
return [...keys].sort();
|
|
540
535
|
}
|
|
541
536
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(arg)) return arg;
|
|
545
|
-
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
546
|
-
}
|
|
537
|
+
// Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
|
|
538
|
+
export { shellQuote };
|
|
547
539
|
|
|
548
540
|
export function findClaudeRigRC(cwd: string): string | null {
|
|
549
541
|
const home = homedir();
|
package/src/adapters/codex.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirS
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
4
|
import type { ContextState, Message } from "agent-relay-sdk";
|
|
5
|
+
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
6
|
+
import { isPidAlive, killPid, waitForPidsExit } from "agent-relay-sdk/process-utils";
|
|
5
7
|
import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
|
|
6
8
|
import { workspaceDepsNoteFromEnv } from "../relay-instructions";
|
|
7
9
|
import { logger } from "../logger";
|
|
@@ -742,10 +744,6 @@ function activeFlags(value: unknown): string[] {
|
|
|
742
744
|
return value.activeFlags.filter((flag): flag is string => typeof flag === "string" && flag.length > 0);
|
|
743
745
|
}
|
|
744
746
|
|
|
745
|
-
function stringValue(value: unknown): string | undefined {
|
|
746
|
-
return typeof value === "string" && value ? value : undefined;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
747
|
function numberValue(value: unknown): number | undefined {
|
|
750
748
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
751
749
|
}
|
|
@@ -759,9 +757,6 @@ function isContextState(value: unknown): value is ContextState {
|
|
|
759
757
|
typeof state.confidence === "string";
|
|
760
758
|
}
|
|
761
759
|
|
|
762
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
763
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
764
|
-
}
|
|
765
760
|
|
|
766
761
|
export function codexModelConfigArgs(model?: string, effort?: string): string[] {
|
|
767
762
|
const args: string[] = [];
|
|
@@ -946,12 +941,6 @@ async function processTreePids(rootPids: number[]): Promise<number[]> {
|
|
|
946
941
|
return processTreePidsFromTable(table, rootPids);
|
|
947
942
|
}
|
|
948
943
|
|
|
949
|
-
function killPid(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
|
|
950
|
-
try {
|
|
951
|
-
process.kill(pid, signal);
|
|
952
|
-
} catch {}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
944
|
function codexRelayContextEnabled(process: ManagedProcess): boolean {
|
|
956
945
|
const config = process.meta?.config as RunnerSpawnConfig | undefined;
|
|
957
946
|
return config ? profileAllowsRelayFeature(config, "context") : true;
|
|
@@ -968,21 +957,3 @@ function codexLaunchContext(process: ManagedProcess): string | undefined {
|
|
|
968
957
|
text,
|
|
969
958
|
].join("\n");
|
|
970
959
|
}
|
|
971
|
-
|
|
972
|
-
function isPidAlive(pid: number): boolean {
|
|
973
|
-
try {
|
|
974
|
-
process.kill(pid, 0);
|
|
975
|
-
return true;
|
|
976
|
-
} catch {
|
|
977
|
-
return false;
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
async function waitForPidsExit(pids: number[], timeoutMs: number): Promise<boolean> {
|
|
982
|
-
const deadline = Date.now() + timeoutMs;
|
|
983
|
-
while (Date.now() < deadline) {
|
|
984
|
-
if (!pids.some(isPidAlive)) return true;
|
|
985
|
-
await Bun.sleep(100);
|
|
986
|
-
}
|
|
987
|
-
return !pids.some(isPidAlive);
|
|
988
|
-
}
|
package/src/attachment-cache.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, write
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
import type { Artifact, Message } from "agent-relay-sdk";
|
|
5
|
+
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
6
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
5
7
|
|
|
6
8
|
const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
9
|
|
|
@@ -25,10 +27,6 @@ interface CachedAttachment {
|
|
|
25
27
|
digest: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
30
|
function attachmentRefs(message: Message): Record<string, unknown>[] {
|
|
33
31
|
const payloadRefs = message.payload?.attachments;
|
|
34
32
|
const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
|
|
@@ -75,7 +73,7 @@ async function messageWithCachedAttachments(
|
|
|
75
73
|
nextRefs.push(withCacheMetadata(ref, cached));
|
|
76
74
|
changed = true;
|
|
77
75
|
} catch (error) {
|
|
78
|
-
options.onError?.(`attachment ${artifactId} cache failed: ${
|
|
76
|
+
options.onError?.(`attachment ${artifactId} cache failed: ${errMessage(error)}`);
|
|
79
77
|
nextRefs.push(withCacheError(ref, error));
|
|
80
78
|
changed = true;
|
|
81
79
|
}
|
|
@@ -141,7 +139,7 @@ function withCacheError(ref: Record<string, unknown>, error: unknown): Record<st
|
|
|
141
139
|
...metadata,
|
|
142
140
|
agentRelay: {
|
|
143
141
|
...agentRelay,
|
|
144
|
-
cacheError:
|
|
142
|
+
cacheError: errMessage(error),
|
|
145
143
|
},
|
|
146
144
|
},
|
|
147
145
|
};
|
|
@@ -186,5 +184,5 @@ function safeFilename(value: string): string {
|
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
function safePathPart(value: string): string {
|
|
189
|
-
return value
|
|
187
|
+
return sanitizeFsName(value, { replacement: "_", collapseReplacement: true, maxLen: 180 });
|
|
190
188
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, hostname } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { stringValue } from "agent-relay-sdk";
|
|
5
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
4
6
|
import type { ProviderConfig } from "./adapter";
|
|
5
7
|
|
|
6
8
|
interface GlobalRunnerConfig {
|
|
@@ -97,7 +99,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
|
|
|
97
99
|
|
|
98
100
|
export function runnerId(provider: string, cwd: string, label?: string): string {
|
|
99
101
|
const project = cwd.split("/").filter(Boolean).at(-1) || "workspace";
|
|
100
|
-
const cleanLabel = (label || project
|
|
102
|
+
const cleanLabel = sanitizeFsName(label || project, { replacement: "-", lowercase: true });
|
|
101
103
|
return `${hostname()}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
|
|
102
104
|
}
|
|
103
105
|
|
|
@@ -115,10 +117,6 @@ function readJson(path: string): Record<string, unknown> {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
function stringValue(value: unknown): string | undefined {
|
|
119
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
120
|
function positiveInteger(value: unknown): number | undefined {
|
|
123
121
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
124
122
|
}
|
package/src/control-server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
2
|
import type { Message, ReplyObligation } from "agent-relay-sdk";
|
|
3
|
+
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
3
4
|
import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
4
5
|
import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
|
|
5
6
|
|
|
@@ -69,19 +70,19 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
69
70
|
if (!options.onTerminalAttachSpec) return Response.json({ error: "terminal attach is unavailable" }, { status: 404 });
|
|
70
71
|
return options.onTerminalAttachSpec()
|
|
71
72
|
.then((spec) => Response.json(spec))
|
|
72
|
-
.catch((error) => Response.json({ error:
|
|
73
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 409 }));
|
|
73
74
|
}
|
|
74
75
|
if (url.pathname === "/reply-obligations" && req.method === "GET") {
|
|
75
76
|
if (!options.onReplyObligations) return Response.json({ pending: false, count: 0 });
|
|
76
77
|
return options.onReplyObligations()
|
|
77
78
|
.then((obligations) => Response.json(replyObligationSummary(obligations)))
|
|
78
|
-
.catch((error) => Response.json({ error:
|
|
79
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
79
80
|
}
|
|
80
81
|
if (url.pathname === "/reply-obligations/claude-stop" && req.method === "GET") {
|
|
81
82
|
if (!options.onReplyObligations) return Response.json({});
|
|
82
83
|
return options.onReplyObligations()
|
|
83
84
|
.then((obligations) => Response.json(replyObligationStopDecision(obligations)))
|
|
84
|
-
.catch((error) => Response.json({ error:
|
|
85
|
+
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
85
86
|
}
|
|
86
87
|
if (url.pathname === "/permissions/request" && req.method === "POST") {
|
|
87
88
|
return handlePermissionRequest(req, options, pendingPermissionRequests);
|
|
@@ -359,7 +360,7 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
|
|
|
359
360
|
await options.onSessionTurn({ transcriptPath, lastAssistantMessage });
|
|
360
361
|
return Response.json({ ok: true });
|
|
361
362
|
} catch (error) {
|
|
362
|
-
return Response.json({ ok: false, reason:
|
|
363
|
+
return Response.json({ ok: false, reason: errMessage(error) }, { status: 500 });
|
|
363
364
|
}
|
|
364
365
|
}
|
|
365
366
|
|
|
@@ -453,7 +454,3 @@ function parseJson(raw: string | Buffer): Record<string, unknown> | null {
|
|
|
453
454
|
return null;
|
|
454
455
|
}
|
|
455
456
|
}
|
|
456
|
-
|
|
457
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
458
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
459
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { AgentRunner } from "./runner";
|
|
|
8
8
|
import { loadGlobalConfig, loadProviderConfig, resolveCwd, runnerId } from "./config";
|
|
9
9
|
import { VERSION } from "./version";
|
|
10
10
|
import type { AgentProfile, WorkspaceMetadata } from "agent-relay-sdk";
|
|
11
|
+
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
11
12
|
|
|
12
13
|
interface CliOptions {
|
|
13
14
|
provider: "claude" | "codex";
|
|
@@ -139,7 +140,7 @@ export async function resolveRunnerToken(input: {
|
|
|
139
140
|
method: "POST",
|
|
140
141
|
headers: {
|
|
141
142
|
"Content-Type": "application/json",
|
|
142
|
-
|
|
143
|
+
[RELAY_TOKEN_HEADER]: input.token,
|
|
143
144
|
},
|
|
144
145
|
body: JSON.stringify({
|
|
145
146
|
provider: input.provider,
|
|
@@ -156,7 +157,7 @@ export async function resolveRunnerToken(input: {
|
|
|
156
157
|
const detail = body?.error ? `: ${body.error}` : "";
|
|
157
158
|
console.warn(`[agent-relay] interactive scoped-token exchange failed (${res.status}${detail}); continuing with existing token. Root-token runtime fallback is deprecated.`);
|
|
158
159
|
} catch (error) {
|
|
159
|
-
console.warn(`[agent-relay] interactive scoped-token exchange failed (${
|
|
160
|
+
console.warn(`[agent-relay] interactive scoped-token exchange failed (${errMessage(error)}); continuing with existing token. Root-token runtime fallback is deprecated.`);
|
|
160
161
|
}
|
|
161
162
|
return { token: input.token, rootFallback: true };
|
|
162
163
|
}
|
package/src/logger.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
3
4
|
|
|
4
5
|
// Phase 1 observability (#198): one leveled, runtime-togglable logger for the
|
|
5
6
|
// Runner and the provider adapters below it. Replaces the ad-hoc scatter of
|
|
@@ -26,10 +27,10 @@ export function parseLogLevel(value: string | undefined | null): LogLevel | unde
|
|
|
26
27
|
return (LOG_LEVELS as string[]).includes(v) ? (v as LogLevel) : undefined;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// Shared with the orchestrator (session-mirror reader) + runner outbox via the
|
|
31
|
+
// SDK helper, so all sites resolve the identical filename for a given agent id.
|
|
31
32
|
function safeLogName(value: string): string {
|
|
32
|
-
return value
|
|
33
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180 });
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface LoggerConfig {
|
package/src/outbox.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
|
+
import { errMessage } from "agent-relay-sdk";
|
|
5
7
|
import { logger } from "./logger";
|
|
6
8
|
|
|
7
9
|
// Phase 2 (#196) — the "nothing is ever lost" half. Runner→server events that used to be
|
|
@@ -230,7 +232,7 @@ export class Outbox {
|
|
|
230
232
|
this.db.query("DELETE FROM outbox WHERE seq = ?").run(row.seq);
|
|
231
233
|
} catch (error) {
|
|
232
234
|
const attempts = row.attempts + 1;
|
|
233
|
-
const reason =
|
|
235
|
+
const reason = errMessage(error);
|
|
234
236
|
if (attempts >= this.maxAttempts) {
|
|
235
237
|
this.db.query("UPDATE outbox SET attempts = ?, poisoned = 1 WHERE seq = ?").run(attempts, row.seq);
|
|
236
238
|
logger.fatal("outbox", `event seq=${row.seq} kind=${row.kind} poisoned after ${attempts} attempts: ${reason}`);
|
|
@@ -284,7 +286,7 @@ export class Outbox {
|
|
|
284
286
|
}
|
|
285
287
|
|
|
286
288
|
function safeName(value: string): string {
|
|
287
|
-
return value
|
|
289
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180, fallback: "agent" });
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
function safeParse(json: string): unknown {
|
package/src/profile-home.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
4
5
|
import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
|
|
5
6
|
import { CLAUDE_RELAY_MANUAL } from "./relay-instructions";
|
|
6
7
|
|
|
@@ -35,34 +36,41 @@ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
|
|
|
35
36
|
// (claude-rig / host-base launches dodge all of this via onboarded host config +
|
|
36
37
|
// --dangerously-skip-permissions; only isolated homes need the bootstrap.)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
// Host auth items symlinked into a fresh isolated home so the provider is
|
|
40
|
+
// launch-ready without a re-login. One list per provider — single source.
|
|
41
|
+
const CODEX_AUTH_ITEMS = ["auth.json", "installation_id"];
|
|
42
|
+
const CLAUDE_AUTH_ITEMS = [".credentials.json", "statsig"];
|
|
43
|
+
|
|
44
|
+
// Shared skeleton for both providers: gate on isolated-profile, make the
|
|
45
|
+
// instance-keyed home, run the provider-specific first-run bootstrap. The
|
|
46
|
+
// bootstrap step is the only genuinely provider-specific part.
|
|
47
|
+
export function prepareProviderHome(provider: "claude" | "codex", config: RunnerSpawnConfig): ProviderHome | undefined {
|
|
39
48
|
if (!profileRequiresIsolatedHome(config)) return undefined;
|
|
40
|
-
const target = providerHomePath(
|
|
49
|
+
const target = providerHomePath(provider, config);
|
|
41
50
|
mkdirSync(target, { recursive: true });
|
|
42
|
-
|
|
51
|
+
const authLinked = provider === "codex"
|
|
52
|
+
? bootstrapCodexFirstRun(target, config)
|
|
53
|
+
: bootstrapClaudeFirstRun(target, config);
|
|
54
|
+
return { path: target, authLinked };
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
export const prepareCodexProfileHome = (config: RunnerSpawnConfig) => prepareProviderHome("codex", config);
|
|
58
|
+
export const prepareClaudeProfileHome = (config: RunnerSpawnConfig) => prepareProviderHome("claude", config);
|
|
59
|
+
|
|
45
60
|
function bootstrapCodexFirstRun(codexHome: string, config: RunnerSpawnConfig): string[] {
|
|
46
61
|
trustWorkspaceForCodex(codexHome, config);
|
|
47
62
|
const sourceHome = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
48
|
-
return linkExistingAuthItems(sourceHome, codexHome,
|
|
63
|
+
return linkExistingAuthItems(sourceHome, codexHome, CODEX_AUTH_ITEMS);
|
|
49
64
|
}
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
if (!profileRequiresIsolatedHome(config)) return undefined;
|
|
53
|
-
const target = providerHomePath("claude", config);
|
|
54
|
-
mkdirSync(target, { recursive: true });
|
|
66
|
+
function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): string[] {
|
|
55
67
|
// Only inject the Relay usage manual when the profile actually wants a Relay
|
|
56
68
|
// surface. An isolated-research profile (relay.context disabled) must not get
|
|
57
69
|
// agent-relay communication instructions written into its config home.
|
|
58
|
-
if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(
|
|
59
|
-
return { path: target, authLinked: bootstrapClaudeFirstRun(target, config) };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): string[] {
|
|
70
|
+
if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(claudeHome);
|
|
63
71
|
seedClaudeConfigIfMissing(claudeHome, config);
|
|
64
72
|
const sourceHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
65
|
-
return linkExistingAuthItems(sourceHome, claudeHome,
|
|
73
|
+
return linkExistingAuthItems(sourceHome, claudeHome, CLAUDE_AUTH_ITEMS);
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig): void {
|
|
@@ -104,7 +112,7 @@ function providerHomePath(provider: "claude" | "codex", config: RunnerSpawnConfi
|
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
function sanitizePathPart(value: string): string {
|
|
107
|
-
return value
|
|
115
|
+
return sanitizeFsName(value, { replacement: "-", collapse: false, maxLen: 120, fallback: "profile" });
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
function linkExistingAuthItems(sourceHome: string, targetHome: string, items: string[]): string[] {
|
|
@@ -37,7 +37,7 @@ export function workspaceDepsNote(input: { mode?: string | null; depsMode?: stri
|
|
|
37
37
|
if (input.mode !== "isolated") return "";
|
|
38
38
|
switch (input.depsMode) {
|
|
39
39
|
case "symlink":
|
|
40
|
-
return "[agent-relay] Isolated workspace: this is a git worktree, and its node_modules are SYMLINKED from the main checkout — dependencies are already installed and ready to use. Do NOT run a clean dependency install (`bun install` / `npm install` / `pnpm install`): it writes through the symlink and mutates the main checkout's shared node_modules. Build caches written under node_modules are shared too. If you genuinely need to change dependencies in isolation, ask the host to spawn with AGENT_RELAY_WORKSPACE_DEPS=install.";
|
|
40
|
+
return "[agent-relay] Isolated workspace: this is a git worktree, and its node_modules are SYMLINKED from the main checkout — dependencies are already installed and ready to use. Do NOT run a clean dependency install (`bun install` / `npm install` / `pnpm install`): it writes through the symlink and mutates the main checkout's shared node_modules. Build caches written under node_modules are shared too. If typecheck/build fails on a missing module (a dependency added to the base AFTER this worktree was created), run `agent-relay workspace deps` — it re-provisions only the stale dirs with a real isolated install, safely, without touching the shared node_modules. If you genuinely need to change dependencies in isolation, ask the host to spawn with AGENT_RELAY_WORKSPACE_DEPS=install.";
|
|
41
41
|
case "none":
|
|
42
42
|
return "[agent-relay] Isolated workspace: dependencies were not provisioned (AGENT_RELAY_WORKSPACE_DEPS=none). You may need to install node_modules before typecheck/test/build work.";
|
|
43
43
|
default:
|
|
@@ -45,6 +45,23 @@ export function workspaceDepsNote(input: { mode?: string | null; depsMode?: stri
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Lifecycle briefing for an agent spawned into an isolated workspace (#205).
|
|
50
|
+
* Tells it, in plain terms, that it is on an isolated branch off a base that
|
|
51
|
+
* other agents move in parallel, and how to hand off — so it doesn't re-derive
|
|
52
|
+
* "I seem to be in a clone, not on main" every session or hand-roll a rebase/push.
|
|
53
|
+
* Returns "" for shared workspaces. Branch/base come from the resolved metadata.
|
|
54
|
+
*/
|
|
55
|
+
export function workspaceLifecycleNote(input: { mode?: string | null; branch?: string | null; baseRef?: string | null }): string {
|
|
56
|
+
if (input.mode !== "isolated") return "";
|
|
57
|
+
const branch = input.branch ? `\`${input.branch}\`` : "an isolated agent branch";
|
|
58
|
+
const base = input.baseRef ? `\`${input.baseRef}\`` : "the base branch";
|
|
59
|
+
return [
|
|
60
|
+
`[agent-relay] Isolated workspace: you are in a git worktree on branch ${branch}, based on ${base} — NOT the main checkout. Other agents may work in parallel and land to ${base}, so ${base} will move under you. That is expected; don't fight it.`,
|
|
61
|
+
`Do NOT push this branch yourself — not with \`git push\`, not with \`tl push\` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, run \`agent-relay workspace ready\` — Relay rebases onto the latest ${base}, lands your work, and pushes for you. (\`agent-relay workspace status\` shows the current state.)`,
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Caveat for untracked paths symlinked from main into an isolated worktree
|
|
50
67
|
* (WorkspaceConfig.symlinkPaths, e.g. AGENTS.md, .claude-rig). Edits to these
|
|
@@ -63,8 +80,9 @@ export function workspaceDepsNoteFromEnv(env: Record<string, string | undefined>
|
|
|
63
80
|
const json = env.AGENT_RELAY_WORKSPACE_JSON;
|
|
64
81
|
if (!json) return "";
|
|
65
82
|
try {
|
|
66
|
-
const parsed = JSON.parse(json) as { mode?: string; deps?: { mode?: string }; symlinks?: { linked?: string[] } };
|
|
83
|
+
const parsed = JSON.parse(json) as { mode?: string; branch?: string; baseRef?: string; deps?: { mode?: string }; symlinks?: { linked?: string[] } };
|
|
67
84
|
return [
|
|
85
|
+
workspaceLifecycleNote({ mode: parsed.mode ?? null, branch: parsed.branch ?? null, baseRef: parsed.baseRef ?? null }),
|
|
68
86
|
workspaceDepsNote({ mode: parsed.mode ?? null, depsMode: parsed.deps?.mode ?? null }),
|
|
69
87
|
parsed.mode === "isolated" ? workspaceSymlinksNote(parsed.symlinks?.linked ?? []) : "",
|
|
70
88
|
].filter(Boolean).join("\n\n");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReplyObligation } from "agent-relay-sdk";
|
|
2
|
+
import { errMessage } from "agent-relay-sdk";
|
|
2
3
|
import { logger } from "./logger";
|
|
3
4
|
|
|
4
5
|
// Phase 2 (#196) — the crux. The Claude Stop hook used to ask the server, synchronously
|
|
@@ -103,7 +104,7 @@ export class ReplyObligationCache {
|
|
|
103
104
|
} catch (error) {
|
|
104
105
|
// Server-down is a non-event: keep serving the last snapshot. Debug, not error —
|
|
105
106
|
// this is expected during outages and must not spam the log.
|
|
106
|
-
logger.debug("obligation-cache", `refresh failed, serving cached snapshot (${this.snapshot.length}): ${
|
|
107
|
+
logger.debug("obligation-cache", `refresh failed, serving cached snapshot (${this.snapshot.length}): ${errMessage(error)}`);
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
}
|
package/src/runner.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { closeSync, mkdirSync, openSync, readSync, statSync, writeFileSync } fro
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import type { AgentProfile, ContextState, Message, MessageSessionMeta, ProviderCapabilities, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
6
|
-
import { RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
6
|
+
import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
7
7
|
import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
|
|
8
8
|
import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
9
9
|
import { messagesWithCachedAttachments } from "./attachment-cache";
|
|
@@ -574,7 +574,7 @@ export class AgentRunner {
|
|
|
574
574
|
...(providerResult ? { providerResult } : {}),
|
|
575
575
|
});
|
|
576
576
|
} catch (error) {
|
|
577
|
-
await this.updateCommand(commandId, "failed", undefined,
|
|
577
|
+
await this.updateCommand(commandId, "failed", undefined, errMessage(error)).catch(() => {});
|
|
578
578
|
} finally {
|
|
579
579
|
this.claims.finishClaim("command", commandId);
|
|
580
580
|
if (exitAfterCommand) {
|
|
@@ -1078,7 +1078,7 @@ export class AgentRunner {
|
|
|
1078
1078
|
},
|
|
1079
1079
|
});
|
|
1080
1080
|
} catch (error) {
|
|
1081
|
-
logger.error("outbox", `failed to queue hook-fatal report: ${
|
|
1081
|
+
logger.error("outbox", `failed to queue hook-fatal report: ${errMessage(error)}`);
|
|
1082
1082
|
}
|
|
1083
1083
|
}
|
|
1084
1084
|
|
|
@@ -1474,7 +1474,7 @@ export class AgentRunner {
|
|
|
1474
1474
|
fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
|
|
1475
1475
|
});
|
|
1476
1476
|
} catch (error) {
|
|
1477
|
-
this.logRunnerDiagnostic(`session scratch setup failed: ${
|
|
1477
|
+
this.logRunnerDiagnostic(`session scratch setup failed: ${errMessage(error)}`);
|
|
1478
1478
|
}
|
|
1479
1479
|
}
|
|
1480
1480
|
|
|
@@ -1873,7 +1873,7 @@ function commandTimeoutMs(params: Record<string, unknown>, fallback = 10_000): n
|
|
|
1873
1873
|
}
|
|
1874
1874
|
|
|
1875
1875
|
export function shouldLogDeliveryFailure(error: unknown): boolean {
|
|
1876
|
-
const message =
|
|
1876
|
+
const message = errMessage(error);
|
|
1877
1877
|
return message !== "no Claude monitor connected";
|
|
1878
1878
|
}
|
|
1879
1879
|
|
package/src/version.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { CONTRACT_VERSIONS } from "agent-relay-sdk";
|
|
4
5
|
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
|
|
7
8
|
|
|
8
9
|
export const PACKAGE_NAME = pkg.name || "agent-relay-runner";
|
|
9
10
|
export const VERSION = pkg.version || "0.0.0";
|
|
10
|
-
|
|
11
|
-
export const
|
|
11
|
+
// Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
|
|
12
|
+
export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
|
|
13
|
+
export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
|
|
12
14
|
|
|
13
15
|
export const CONTRACTS = {
|
|
14
16
|
runnerProtocol: RUNNER_PROTOCOL_VERSION,
|