agent-relay-runner 0.62.3 → 0.63.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.
@@ -0,0 +1,317 @@
1
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
2
+ import type { AgentProfile, ContextState, Message, ProviderCapabilities } from "agent-relay-sdk";
3
+ import { errMessage } from "agent-relay-sdk";
4
+ import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
5
+ import type { ProviderAdapter, SemanticStatus } from "./adapter";
6
+ import { agentProfileProjectionReport } from "./profile-projection";
7
+
8
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
9
+ const LOG_TAIL_BYTES = 128 * 1024;
10
+ const CLAUDE_RESUME_RE = /\bclaude\s+--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
11
+ const RUNNER_CLAIMED_GUIDANCE_RE = /\s*Claim this task before working it, then update task status when finished\.\s*$/;
12
+
13
+ interface RuntimeProviderOptions {
14
+ provider: string;
15
+ model?: string;
16
+ effort?: string;
17
+ approvalMode: string;
18
+ headless: boolean;
19
+ startedAt: number;
20
+ adapter: ProviderAdapter;
21
+ }
22
+
23
+ export interface ProbeModelInfo {
24
+ model?: string;
25
+ effort?: string;
26
+ }
27
+
28
+ export function runnerMessageMatches(message: Pick<Message, "to" | "resolvedToAgent">, target: {
29
+ agentId: string;
30
+ label?: string;
31
+ provider: string;
32
+ tags: string[];
33
+ capabilities: string[];
34
+ defaultTags: string[];
35
+ defaultCapabilities: string[];
36
+ allowBroadTargets?: boolean;
37
+ }): boolean {
38
+ if (message.resolvedToAgent === target.agentId) return true;
39
+ if (message.to === target.agentId) return true;
40
+ if (target.label && message.to === `label:${target.label}`) return true;
41
+ if (target.allowBroadTargets === false) return false;
42
+ return targetMatchesAgentIdentity(message.to, {
43
+ id: target.agentId,
44
+ label: target.label,
45
+ tags: [...target.tags, ...target.defaultTags, target.provider],
46
+ capabilities: [...target.capabilities, ...target.defaultCapabilities],
47
+ });
48
+ }
49
+
50
+ export function taskIdFromMessage(message: Pick<Message, "payload">): number | undefined {
51
+ const taskId = message.payload?.taskId;
52
+ return Number.isSafeInteger(taskId) ? taskId as number : undefined;
53
+ }
54
+
55
+ export function stripRunnerClaimedGuidance(body: string): string {
56
+ return body.replace(RUNNER_CLAIMED_GUIDANCE_RE, "");
57
+ }
58
+
59
+ export function csvTags(raw: string | undefined): string[] {
60
+ return (raw || "").split(",").map((tag) => tag.trim()).filter(Boolean);
61
+ }
62
+
63
+ export function relayBusUrl(relayUrl: string): string {
64
+ const url = new URL(relayUrl);
65
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
66
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/bus`;
67
+ url.search = "";
68
+ return url.toString();
69
+ }
70
+
71
+ export function runnerAgentStatus(status: "idle" | "busy" | "offline" | "error"): "idle" | "busy" | "offline" {
72
+ return status === "error" ? "offline" : status;
73
+ }
74
+
75
+ export function runnerBusErrorAction(code: string, stopped: boolean): "ignore" | "log" | "stop" {
76
+ if (code === "STALE_SESSION") return stopped ? "ignore" : "stop";
77
+ return "log";
78
+ }
79
+
80
+ export function runnerShouldResolveProviderExit(status: SemanticStatus, exitCommandInProgress: boolean): boolean {
81
+ return !exitCommandInProgress && (status === "offline" || status === "error");
82
+ }
83
+
84
+ export function runnerShouldRestartUnexpectedProviderExit(
85
+ status: SemanticStatus,
86
+ input: {
87
+ exitCommandInProgress: boolean;
88
+ stopped: boolean;
89
+ restartInProgress: boolean;
90
+ provider?: string;
91
+ headless: boolean;
92
+ hasTerminalSession: boolean;
93
+ },
94
+ ): boolean {
95
+ return status === "offline"
96
+ && !input.exitCommandInProgress
97
+ && !input.stopped
98
+ && !input.restartInProgress
99
+ && input.headless
100
+ && input.hasTerminalSession;
101
+ }
102
+
103
+ export function latestClaudeResumeIdFromText(text: string): string | undefined {
104
+ let latest: string | undefined;
105
+ CLAUDE_RESUME_RE.lastIndex = 0;
106
+ for (let match = CLAUDE_RESUME_RE.exec(text); match; match = CLAUDE_RESUME_RE.exec(text)) {
107
+ latest = match[1];
108
+ }
109
+ return latest;
110
+ }
111
+
112
+ export function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
113
+ let fd: number | undefined;
114
+ try {
115
+ const stat = statSync(path);
116
+ const length = Math.min(stat.size, LOG_TAIL_BYTES);
117
+ const offset = Math.max(0, stat.size - length);
118
+ const buffer = Buffer.alloc(length);
119
+ fd = openSync(path, "r");
120
+ readSync(fd, buffer, 0, length, offset);
121
+ return latestClaudeResumeIdFromText(buffer.toString("utf8"));
122
+ } catch {
123
+ return undefined;
124
+ } finally {
125
+ if (fd !== undefined) closeSync(fd);
126
+ }
127
+ }
128
+
129
+ export function commandTimeoutMs(params: Record<string, unknown>, fallback = 10_000): number {
130
+ const raw = params.timeoutMs;
131
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return fallback;
132
+ return Math.min(raw, 60_000);
133
+ }
134
+
135
+ export function shouldLogDeliveryFailure(error: unknown): boolean {
136
+ const message = errMessage(error);
137
+ return message !== "no Claude monitor connected";
138
+ }
139
+
140
+ export function runtimeTokenRenewDelayMs(expiresAtSeconds: number, nowMs = Date.now()): number | undefined {
141
+ const expiresAtMs = expiresAtSeconds * 1000;
142
+ const ttlMs = expiresAtMs - nowMs;
143
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return undefined;
144
+ const leadMs = Math.min(60 * 60 * 1000, Math.max(60_000, Math.floor(ttlMs * 0.2)));
145
+ const jitterMs = Math.floor(Math.random() * Math.min(30_000, Math.max(0, leadMs / 2)));
146
+ return Math.max(1_000, ttlMs - leadMs - jitterMs);
147
+ }
148
+
149
+ export function runtimeTokenRenewTimerSchedule(delayMs: number): { delayMs: number; renew: boolean } | undefined {
150
+ if (!Number.isFinite(delayMs) || delayMs <= 0) return undefined;
151
+ if (delayMs > MAX_TIMER_DELAY_MS) return { delayMs: MAX_TIMER_DELAY_MS, renew: false };
152
+ return { delayMs, renew: true };
153
+ }
154
+
155
+ export function lifecycleCapabilities(): Record<string, true> {
156
+ return {
157
+ shutdownHard: true,
158
+ restartHard: true,
159
+ semanticStatus: true,
160
+ transportReconnect: true,
161
+ };
162
+ }
163
+
164
+ export function runtimeProviderCapabilities(options: RuntimeProviderOptions, contextState?: ContextState, probeModel?: ProbeModelInfo): ProviderCapabilities {
165
+ const model = options.model ?? probeModel?.model;
166
+ const effort = options.effort ?? probeModel?.effort;
167
+ const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
168
+ const shellMode = runtimeShellMode(options);
169
+ return {
170
+ lifecycle: {
171
+ managed: true,
172
+ shutdownHard: options.provider === "claude" ? options.headless : true,
173
+ restartHard: options.provider === "claude" ? options.headless : true,
174
+ semanticStatus: true,
175
+ reconnect: true,
176
+ },
177
+ model: {
178
+ provider: options.provider,
179
+ alias: model,
180
+ id: model,
181
+ effort,
182
+ source: modelSource,
183
+ confidence: options.model ? "reported" : probeModel?.model ? "reported" : "unknown",
184
+ lastUpdatedAt: options.startedAt,
185
+ },
186
+ session: {
187
+ approvalMode: options.approvalMode,
188
+ fileRead: true,
189
+ fileWrite: options.approvalMode !== "read-only",
190
+ shell: shellMode !== "none",
191
+ shellMode,
192
+ source: "runtime",
193
+ confidence: "reported",
194
+ lastUpdatedAt: options.startedAt,
195
+ },
196
+ ...runtimeProviderContextCapabilities(options, contextState),
197
+ ...runtimeProviderTerminalCapabilities(options),
198
+ liveSession: {
199
+ capture: true,
200
+ inject: Boolean(options.adapter.deliverInitialPrompt),
201
+ interrupt: Boolean(options.adapter.interrupt),
202
+ promptEcho: true,
203
+ reasoning: true,
204
+ slashCommands: options.provider === "claude" || options.provider === "codex",
205
+ },
206
+ source: "runtime",
207
+ confidence: "reported",
208
+ lastUpdatedAt: options.startedAt,
209
+ };
210
+ }
211
+
212
+ function runtimeShellMode(options: RuntimeProviderOptions): NonNullable<NonNullable<ProviderCapabilities["session"]>["shellMode"]> {
213
+ if (options.approvalMode === "read-only") {
214
+ return options.provider === "claude" ? "read-only-guarded" : "none";
215
+ }
216
+ return options.approvalMode === "open" ? "unrestricted" : "guarded";
217
+ }
218
+
219
+ function runtimeProviderTerminalCapabilities(options: RuntimeProviderOptions): Pick<ProviderCapabilities, "terminal"> {
220
+ if (options.provider === "claude" && options.headless) {
221
+ return {
222
+ terminal: {
223
+ live: {
224
+ read: true,
225
+ write: true,
226
+ },
227
+ },
228
+ };
229
+ }
230
+ if (options.provider === "codex") {
231
+ return {
232
+ terminal: {
233
+ attach: {
234
+ create: true,
235
+ read: true,
236
+ write: true,
237
+ detach: true,
238
+ },
239
+ },
240
+ };
241
+ }
242
+ return {};
243
+ }
244
+
245
+ export function appliedAgentProfileMetadata(provider: string, profile: AgentProfile): Record<string, unknown> {
246
+ const projection = provider === "claude" || provider === "codex"
247
+ ? agentProfileProjectionReport({ provider, profile })
248
+ : undefined;
249
+ return {
250
+ name: profile.name,
251
+ base: profile.base,
252
+ provider: profile.provider ?? "any",
253
+ relay: profile.relay,
254
+ instructions: {
255
+ repoInstructions: profile.instructions.repoInstructions,
256
+ globalInstructions: profile.instructions.globalInstructions,
257
+ appendCount: profile.instructions.append.length,
258
+ hasSystem: Boolean(profile.instructions.system),
259
+ },
260
+ skills: profile.skills.filter((item) => item.enabled).length,
261
+ plugins: profile.plugins.filter((item) => item.enabled).length,
262
+ mcp: { mode: profile.mcp.mode },
263
+ hooks: { mode: profile.hooks.mode },
264
+ permissions: profile.permissions,
265
+ projection,
266
+ };
267
+ }
268
+
269
+ function runtimeProviderContextCapabilities(options: RuntimeProviderOptions, contextState?: ContextState): Pick<ProviderCapabilities, "context"> {
270
+ const context: NonNullable<ProviderCapabilities["context"]> = { resume: "none" };
271
+ const supportsManagedContext = options.provider === "codex" || (options.provider === "claude" && options.headless);
272
+ if (contextState) {
273
+ context.stats = { source: contextState.source, confidence: contextState.confidence };
274
+ if (typeof contextState.tokensMax === "number" && Number.isFinite(contextState.tokensMax)) {
275
+ context.windowTokens = contextState.tokensMax;
276
+ }
277
+ }
278
+ if (supportsManagedContext) {
279
+ context.compact = true;
280
+ context.clear = true;
281
+ }
282
+ context.inject = true;
283
+ if (supportsManagedContext && options.adapter.compact && options.adapter.compactSupportsInstructions) context.resume = "native";
284
+ else if (supportsManagedContext && options.adapter.clearContext) context.resume = "clear-inject";
285
+ return Object.keys(context).length ? { context } : {};
286
+ }
287
+
288
+ export function providerStateFromActiveWork(activeWork: Array<{ kind: string; metadata?: Record<string, unknown> }>): Record<string, unknown> | null {
289
+ const providerTurn = activeWork.find((item) => item.kind === "provider-turn");
290
+ const state = providerTurn?.metadata?.providerState;
291
+ return state && typeof state === "object" && !Array.isArray(state) ? state as Record<string, unknown> : null;
292
+ }
293
+
294
+ export function isHttpAuthError(error: unknown): boolean {
295
+ const status = typeof error === "object" && error !== null ? (error as { status?: unknown }).status : undefined;
296
+ return status === 401 || status === 403;
297
+ }
298
+
299
+ export function isHttpStatusError(error: unknown, code: number): boolean {
300
+ const status = typeof error === "object" && error !== null ? (error as { status?: unknown }).status : undefined;
301
+ return status === code;
302
+ }
303
+
304
+ export function httpErrorKey(error: unknown): string {
305
+ const status = typeof error === "object" && error !== null ? (error as { status?: unknown }).status : undefined;
306
+ if (typeof status === "number") return `status:${status}`;
307
+ return String(error);
308
+ }
309
+
310
+ export function isContextState(value: unknown): value is ContextState {
311
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
312
+ const state = value as Record<string, unknown>;
313
+ return typeof state.utilization === "number" &&
314
+ typeof state.lifecycleState === "string" &&
315
+ typeof state.source === "string" &&
316
+ typeof state.confidence === "string";
317
+ }
@@ -0,0 +1,106 @@
1
+ import type { WorkspaceMetadata } from "agent-relay-sdk";
2
+ import type { ManagedProcess, ProviderAdapter } from "./adapter";
3
+ import type { Outbox } from "./outbox";
4
+ import { resolveProjectName } from "./config";
5
+ import { computeContextRatio } from "./session-insights";
6
+ import { boundContinuationArchiveSegment } from "./continuation-archive";
7
+
8
+ type SessionDestroyReason = "compact" | "clear" | "restart" | "shutdown" | "kill" | "crash";
9
+
10
+ interface RunnerInsightsDeps {
11
+ agentId: string;
12
+ cwd: string;
13
+ workspace?: WorkspaceMetadata;
14
+ getOutbox(): Outbox;
15
+ getProviderSessionId(): string;
16
+ getProcess(): ManagedProcess | undefined;
17
+ getAdapter(): ProviderAdapter;
18
+ getLastTranscriptPath(): string | undefined;
19
+ sessionLog(message: string): void;
20
+ }
21
+
22
+ export class RunnerInsights {
23
+ private insightsObserved = 0;
24
+ private insightsCursorKey = "";
25
+ private archiveObservedChars = 0;
26
+ private archiveCursorKey = "";
27
+ private projectName?: string;
28
+
29
+ constructor(private readonly deps: RunnerInsightsDeps) {}
30
+
31
+ project(): string {
32
+ return (this.projectName ??= resolveProjectName(this.deps.cwd, this.deps.workspace));
33
+ }
34
+
35
+ queueHookFatal(report: { hook: string; error: string }): void {
36
+ this.deps.getOutbox().enqueue({
37
+ kind: "insight",
38
+ payload: {
39
+ sessionId: this.deps.getProviderSessionId(),
40
+ project: this.project(),
41
+ agentId: this.deps.agentId,
42
+ signal: "hook_fatal",
43
+ value: { hook: report.hook, error: report.error },
44
+ source: "server",
45
+ },
46
+ });
47
+ }
48
+
49
+ async captureContinuationArchive(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
50
+ const adapter = this.deps.getAdapter();
51
+ const process = this.deps.getProcess();
52
+ if (!adapter.collectSessionArchiveSegment || !process) return;
53
+ const transcriptPath = opts?.transcriptPath ?? this.deps.getLastTranscriptPath();
54
+ const archive = await adapter.collectSessionArchiveSegment(process, { transcriptPath });
55
+ if (!archive) return;
56
+ const key = transcriptPath ?? `session:${this.deps.getProviderSessionId()}`;
57
+ if (key !== this.archiveCursorKey || archive.length < this.archiveObservedChars) {
58
+ this.archiveCursorKey = key;
59
+ this.archiveObservedChars = 0;
60
+ }
61
+ const segment = archive.slice(this.archiveObservedChars).trim();
62
+ this.archiveObservedChars = archive.length;
63
+ if (!segment) return;
64
+ const bounded = boundContinuationArchiveSegment(segment);
65
+ if (bounded.droppedBytes > 0) this.deps.sessionLog(`continuation archive truncated at ${bounded.keptBytes} bytes; dropped ${bounded.droppedBytes} bytes (${reason})`);
66
+ this.deps.getOutbox().enqueue({
67
+ kind: "continuation-archive",
68
+ payload: {
69
+ agentId: this.deps.agentId,
70
+ segment: bounded.segment,
71
+ },
72
+ });
73
+ this.deps.sessionLog(`continuation archive queued (${bounded.segment.length} chars, ${reason})`);
74
+ }
75
+
76
+ async captureContextRatio(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
77
+ const adapter = this.deps.getAdapter();
78
+ const process = this.deps.getProcess();
79
+ if (!adapter.collectSessionEvents || !process) return;
80
+ const transcriptPath = opts?.transcriptPath ?? this.deps.getLastTranscriptPath();
81
+ const events = await adapter.collectSessionEvents(process, { transcriptPath });
82
+ if (!events) return;
83
+ const key = transcriptPath ?? `session:${this.deps.getProviderSessionId()}`;
84
+ if (key !== this.insightsCursorKey || events.length < this.insightsObserved) {
85
+ this.insightsCursorKey = key;
86
+ this.insightsObserved = 0;
87
+ }
88
+ const segment = events.slice(this.insightsObserved);
89
+ this.insightsObserved = events.length;
90
+ const analysis = computeContextRatio(segment);
91
+ if (!analysis) return;
92
+ this.deps.getOutbox().enqueue({
93
+ kind: "insight",
94
+ payload: {
95
+ sessionId: this.deps.getProviderSessionId(),
96
+ project: this.project(),
97
+ agentId: this.deps.agentId,
98
+ signal: "context_ratio",
99
+ value: { ...analysis.metric, endReason: reason },
100
+ outcome: { ...analysis.outcome },
101
+ source: "server",
102
+ },
103
+ });
104
+ this.deps.sessionLog(`insights: context_ratio ${analysis.metric.ratio.toFixed(2)} (${analysis.metric.gatheringCalls}/${analysis.metric.totalToolCalls} gathering, ${reason}) queued`);
105
+ }
106
+ }