agent-relay-sdk 0.2.0 → 0.2.2

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,257 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import type { ContextProbeMetrics } from "./types.js";
6
+
7
+ export interface ContextProbeOptions {
8
+ wrapCommand?: string;
9
+ agentId?: string;
10
+ stateDir?: string;
11
+ standalone?: boolean;
12
+ now?: number;
13
+ env?: Record<string, string | undefined>;
14
+ parsePatterns?: ParsePattern[];
15
+ }
16
+
17
+ export interface ParsePattern {
18
+ name: string;
19
+ regex: RegExp;
20
+ extract(match: RegExpMatchArray): Partial<ContextProbeMetrics>;
21
+ }
22
+
23
+ export interface ContextProbeRunResult {
24
+ metrics: ContextProbeMetrics | null;
25
+ stateFile?: string;
26
+ wrappedExitCode?: number;
27
+ wrappedStdout?: string;
28
+ wrappedStderr?: string;
29
+ output: string;
30
+ }
31
+
32
+ export const DEFAULT_CONTEXT_PROBE_STATE_DIR = tmpdir();
33
+
34
+ export const DEFAULT_CONTEXT_PROBE_PATTERNS: ParsePattern[] = [
35
+ {
36
+ name: "percent",
37
+ regex: /(\d+(?:\.\d+)?)%\s*(?:context|ctx)?/i,
38
+ extract: (match) => ({ contextPercent: Number(match[1]) }),
39
+ },
40
+ {
41
+ name: "token-count",
42
+ regex: /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*(?:tokens?|tok)/i,
43
+ extract: (match) => ({
44
+ tokensUsed: parseInt(String(match[1]).replace(/,/g, ""), 10),
45
+ tokensMax: parseInt(String(match[2]).replace(/,/g, ""), 10),
46
+ }),
47
+ },
48
+ {
49
+ name: "quota",
50
+ regex: /quota[:\s]+(\d+(?:\.\d+)?)\s*%/i,
51
+ extract: (match) => ({ quotaUsed: Number(match[1]) }),
52
+ },
53
+ ];
54
+
55
+ export function contextProbeStatePath(agentId: string, stateDir = DEFAULT_CONTEXT_PROBE_STATE_DIR): string {
56
+ return join(stateDir, `agent-relay-context-${safeStateId(agentId)}.json`);
57
+ }
58
+
59
+ export function parseContextProbeMetrics(
60
+ input: string,
61
+ options: Pick<ContextProbeOptions, "agentId" | "now" | "parsePatterns" | "env"> = {},
62
+ ): ContextProbeMetrics | null {
63
+ const timestamp = options.now ?? Date.now();
64
+ const fromJson = parseClaudeStatusLineInput(input, options.agentId, timestamp, options.env);
65
+ if (fromJson) return fromJson;
66
+
67
+ const partial: Partial<ContextProbeMetrics> = {};
68
+ for (const pattern of options.parsePatterns ?? DEFAULT_CONTEXT_PROBE_PATTERNS) {
69
+ const match = input.match(pattern.regex);
70
+ if (match) Object.assign(partial, pattern.extract(match));
71
+ }
72
+ if (partial.contextPercent === undefined && partial.tokensUsed !== undefined && partial.tokensMax) {
73
+ partial.contextPercent = (partial.tokensUsed / partial.tokensMax) * 100;
74
+ }
75
+ if (partial.contextPercent === undefined) return null;
76
+ return normalizeMetrics({
77
+ agentId: options.agentId || envAgentId(options.env) || "unknown",
78
+ contextPercent: partial.contextPercent,
79
+ tokensUsed: partial.tokensUsed,
80
+ tokensMax: partial.tokensMax,
81
+ quotaUsed: partial.quotaUsed,
82
+ quotaLimit: partial.quotaLimit,
83
+ quotaResetIn: partial.quotaResetIn,
84
+ source: partial.source ?? "statusline",
85
+ confidence: partial.confidence ?? "estimated",
86
+ timestamp,
87
+ });
88
+ }
89
+
90
+ export function contextStateFromProbeMetrics(metrics: ContextProbeMetrics) {
91
+ return {
92
+ utilization: Math.max(0, Math.min(1, metrics.contextPercent / 100)),
93
+ ...(metrics.tokensUsed !== undefined ? { tokensUsed: metrics.tokensUsed } : {}),
94
+ ...(metrics.tokensMax !== undefined ? { tokensMax: metrics.tokensMax } : {}),
95
+ lifecycleState: "working" as const,
96
+ warmTopics: [],
97
+ activeMemories: [],
98
+ tasksSinceCompact: 0,
99
+ lastUpdatedAt: metrics.timestamp,
100
+ source: metrics.source,
101
+ confidence: metrics.confidence,
102
+ };
103
+ }
104
+
105
+ export function writeContextProbeState(metrics: ContextProbeMetrics, stateDir = DEFAULT_CONTEXT_PROBE_STATE_DIR): string {
106
+ const file = contextProbeStatePath(metrics.agentId, stateDir);
107
+ mkdirSync(dirname(file), { recursive: true });
108
+ const tmp = `${file}.${process.pid}.tmp`;
109
+ writeFileSync(tmp, JSON.stringify(metrics, null, 2));
110
+ renameSync(tmp, file);
111
+ return file;
112
+ }
113
+
114
+ export function readContextProbeState(agentId: string, stateDir = DEFAULT_CONTEXT_PROBE_STATE_DIR): ContextProbeMetrics | null {
115
+ try {
116
+ const parsed = JSON.parse(readFileSync(contextProbeStatePath(agentId, stateDir), "utf8")) as unknown;
117
+ return isContextProbeMetrics(parsed) ? parsed : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ export function runContextProbe(input: string, options: ContextProbeOptions = {}): ContextProbeRunResult {
124
+ let wrappedExitCode: number | undefined;
125
+ let wrappedStdout = "";
126
+ let wrappedStderr = "";
127
+ if (options.wrapCommand) {
128
+ const result = spawnSync(options.wrapCommand, {
129
+ input,
130
+ shell: true,
131
+ encoding: "utf8",
132
+ env: { ...process.env, ...(options.env ?? {}) },
133
+ });
134
+ wrappedExitCode = typeof result.status === "number" ? result.status : result.error ? 1 : 0;
135
+ wrappedStdout = result.stdout ?? "";
136
+ wrappedStderr = result.stderr ?? "";
137
+ }
138
+
139
+ const metrics = parseContextProbeMetrics(input, options) ??
140
+ (wrappedStdout ? parseContextProbeMetrics(wrappedStdout, { ...options, now: options.now }) : null);
141
+ const stateFile = metrics ? writeContextProbeState(metrics, options.stateDir) : undefined;
142
+ const output = options.wrapCommand ? wrappedStdout : standaloneOutput(metrics);
143
+ return { metrics, stateFile, wrappedExitCode, wrappedStdout, wrappedStderr, output };
144
+ }
145
+
146
+ function parseClaudeStatusLineInput(
147
+ input: string,
148
+ explicitAgentId: string | undefined,
149
+ timestamp: number,
150
+ env: Record<string, string | undefined> | undefined,
151
+ ): ContextProbeMetrics | null {
152
+ let data: unknown;
153
+ try {
154
+ data = JSON.parse(input);
155
+ } catch {
156
+ return null;
157
+ }
158
+ if (!isRecord(data)) return null;
159
+ const context = isRecord(data.context_window) ? data.context_window : undefined;
160
+ if (!context) return null;
161
+ const contextPercent = numberValue(context.used_percentage);
162
+ const tokensMax = numberValue(context.context_window_size);
163
+ const usage = isRecord(context.current_usage) ? context.current_usage : undefined;
164
+ const tokensUsed = sumNumbers(
165
+ usage?.input_tokens,
166
+ usage?.cache_creation_input_tokens,
167
+ usage?.cache_read_input_tokens,
168
+ );
169
+ if (contextPercent === undefined && tokensUsed === undefined) return null;
170
+ const rateLimits = isRecord(data.rate_limits) ? data.rate_limits : undefined;
171
+ const fiveHour = isRecord(rateLimits?.five_hour) ? rateLimits.five_hour : undefined;
172
+ const model = stringValue(data.model) ?? envString(env, "CLAUDE_MODEL");
173
+ const effort = isRecord(data.effort) ? stringValue(data.effort.level) : stringValue(data.effort);
174
+ const resolvedEffort = effort ?? envString(env, "CLAUDE_EFFORT") ?? envString(env, "CLAUDE_CODE_EFFORT_LEVEL");
175
+ return normalizeMetrics({
176
+ agentId: explicitAgentId || envAgentId(env) || stringValue(data.session_id) || "unknown",
177
+ contextPercent: contextPercent ?? ((tokensUsed ?? 0) / (tokensMax || 1)) * 100,
178
+ tokensUsed,
179
+ tokensMax,
180
+ quotaUsed: numberValue(fiveHour?.used_percentage),
181
+ quotaResetIn: secondsUntil(numberValue(fiveHour?.resets_at), timestamp),
182
+ ...(model ? { model } : {}),
183
+ ...(resolvedEffort ? { effort: resolvedEffort } : {}),
184
+ source: "statusline",
185
+ confidence: "exact",
186
+ timestamp,
187
+ });
188
+ }
189
+
190
+ function normalizeMetrics(metrics: ContextProbeMetrics): ContextProbeMetrics {
191
+ return {
192
+ ...metrics,
193
+ contextPercent: Math.max(0, Math.min(100, metrics.contextPercent)),
194
+ };
195
+ }
196
+
197
+ function standaloneOutput(metrics: ContextProbeMetrics | null): string {
198
+ if (!metrics) return "agent-relay context: unavailable";
199
+ const pct = Math.round(metrics.contextPercent);
200
+ const tokens = metrics.tokensUsed && metrics.tokensMax ? ` ${formatTokens(metrics.tokensUsed)}/${formatTokens(metrics.tokensMax)}` : "";
201
+ return `agent-relay context: ${pct}%${tokens}`;
202
+ }
203
+
204
+ function formatTokens(tokens: number): string {
205
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
206
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
207
+ return String(tokens);
208
+ }
209
+
210
+ function safeStateId(agentId: string): string {
211
+ return basename(agentId).replace(/[^a-zA-Z0-9._-]/g, "-") || "unknown";
212
+ }
213
+
214
+ function envAgentId(env: Record<string, string | undefined> | undefined): string | undefined {
215
+ if (env !== undefined) return stringValue(env.AGENT_RELAY_AGENT_ID) ?? stringValue(env.AGENT_RELAY_ID);
216
+ return stringValue(process.env.AGENT_RELAY_AGENT_ID) ?? stringValue(process.env.AGENT_RELAY_ID);
217
+ }
218
+
219
+ function envString(env: Record<string, string | undefined> | undefined, key: string): string | undefined {
220
+ if (env !== undefined) return stringValue(env[key]);
221
+ return stringValue(process.env[key]);
222
+ }
223
+
224
+ function numberValue(value: unknown): number | undefined {
225
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
226
+ }
227
+
228
+ function stringValue(value: unknown): string | undefined {
229
+ return typeof value === "string" && value ? value : undefined;
230
+ }
231
+
232
+ function sumNumbers(...values: unknown[]): number | undefined {
233
+ let total = 0;
234
+ let seen = false;
235
+ for (const value of values) {
236
+ if (typeof value === "number" && Number.isFinite(value)) {
237
+ total += value;
238
+ seen = true;
239
+ }
240
+ }
241
+ return seen ? total : undefined;
242
+ }
243
+
244
+ function secondsUntil(epochSeconds: number | undefined, nowMs: number): number | undefined {
245
+ return epochSeconds === undefined ? undefined : Math.max(0, Math.round(epochSeconds - nowMs / 1000));
246
+ }
247
+
248
+ function isContextProbeMetrics(value: unknown): value is ContextProbeMetrics {
249
+ return isRecord(value) &&
250
+ typeof value.agentId === "string" &&
251
+ typeof value.contextPercent === "number" &&
252
+ typeof value.timestamp === "number";
253
+ }
254
+
255
+ function isRecord(value: unknown): value is Record<string, unknown> {
256
+ return typeof value === "object" && value !== null && !Array.isArray(value);
257
+ }
@@ -0,0 +1,47 @@
1
+ import type { ContractCompatibility, ContractCompatibilityIssue, RuntimeContractName, RuntimeContracts } from "./types.js";
2
+
3
+ export const CONTRACT_VERSIONS = {
4
+ relayApi: 1,
5
+ orchestratorProtocol: 3,
6
+ runnerProtocol: 1,
7
+ providerPluginProtocol: 1,
8
+ } as const satisfies Record<RuntimeContractName, number>;
9
+
10
+ export const CONTRACT_REQUIREMENTS = {
11
+ orchestratorProtocol: { min: 3, maxExclusive: 4 },
12
+ runnerProtocol: { min: 1, maxExclusive: 2 },
13
+ providerPluginProtocol: { min: 1, maxExclusive: 2 },
14
+ } as const;
15
+
16
+ export interface ContractRequirement {
17
+ min: number;
18
+ maxExclusive: number;
19
+ }
20
+
21
+ export function contractCompatibility(
22
+ contracts: RuntimeContracts,
23
+ requirements: Partial<Record<RuntimeContractName, ContractRequirement>>,
24
+ ): ContractCompatibility {
25
+ const issues: ContractCompatibilityIssue[] = [];
26
+ let unknown = false;
27
+
28
+ for (const [contract, requirement] of Object.entries(requirements) as Array<[RuntimeContractName, ContractRequirement]>) {
29
+ const actual = contracts[contract];
30
+ const expected = `>=${requirement.min} <${requirement.maxExclusive}`;
31
+ if (!Number.isFinite(actual)) {
32
+ unknown = true;
33
+ issues.push({ contract, expected });
34
+ continue;
35
+ }
36
+ if (actual! < requirement.min || actual! >= requirement.maxExclusive) {
37
+ issues.push({ contract, expected, actual });
38
+ }
39
+ }
40
+
41
+ const incompatible = issues.some((issue) => issue.actual !== undefined);
42
+ return {
43
+ status: incompatible ? "incompatible" : unknown ? "unknown" : "compatible",
44
+ compatible: !incompatible && !unknown,
45
+ issues,
46
+ };
47
+ }
@@ -1,4 +1,4 @@
1
- import type { AgentCard, PollQuery, RegisterAgentInput, SendMessageInput, Message } from "./types.js";
1
+ import type { AgentCard, Artifact, ArtifactKind, ArtifactSensitivity, PollQuery, RegisterAgentInput, SendMessageInput, Message, MessageDeliveryState, MessageDeliveryStatus, ReplyObligation, Task, TaskStatusInput } from "./types.js";
2
2
 
3
3
  export interface HttpClientOptions {
4
4
  baseUrl: string;
@@ -20,7 +20,7 @@ export class RelayHttpError extends Error {
20
20
 
21
21
  export class RelayHttpClient {
22
22
  private readonly baseUrl: string;
23
- private readonly token?: string;
23
+ private token?: string;
24
24
  private readonly timeout: number;
25
25
 
26
26
  constructor(options: HttpClientOptions) {
@@ -29,6 +29,10 @@ export class RelayHttpClient {
29
29
  this.timeout = options.timeout ?? 10_000;
30
30
  }
31
31
 
32
+ setToken(token?: string): void {
33
+ this.token = token;
34
+ }
35
+
32
36
  registerAgent(input: RegisterAgentInput): Promise<AgentCard> {
33
37
  return this.json("POST", "/api/agents", input) as Promise<AgentCard>;
34
38
  }
@@ -39,6 +43,10 @@ export class RelayHttpClient {
39
43
  return this.parseResponse(response, "GET", `/api/agents/${id}`) as Promise<AgentCard>;
40
44
  }
41
45
 
46
+ listReplyObligations(agentId: string): Promise<ReplyObligation[]> {
47
+ return this.json("GET", `/api/agents/${encodeURIComponent(agentId)}/reply-obligations`) as Promise<ReplyObligation[]>;
48
+ }
49
+
42
50
  listAgents(filter: { tag?: string; machine?: string; status?: string } = {}): Promise<AgentCard[]> {
43
51
  const url = this.url("/api/agents");
44
52
  for (const [key, value] of Object.entries(filter)) {
@@ -47,16 +55,16 @@ export class RelayHttpClient {
47
55
  return this.jsonUrl("GET", url) as Promise<AgentCard[]>;
48
56
  }
49
57
 
50
- async heartbeat(agentId: string): Promise<void> {
51
- await this.json("POST", `/api/agents/${encodeURIComponent(agentId)}/heartbeat`);
58
+ async heartbeat(agentId: string, instanceId?: string): Promise<void> {
59
+ await this.json("POST", `/api/agents/${encodeURIComponent(agentId)}/heartbeat`, instanceId ? { instanceId } : undefined);
52
60
  }
53
61
 
54
- async setStatus(agentId: string, status: string): Promise<void> {
55
- await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/status`, { status });
62
+ async setStatus(agentId: string, status: string, instanceId?: string): Promise<void> {
63
+ await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/status`, { status, ...(instanceId ? { instanceId } : {}) });
56
64
  }
57
65
 
58
- async setReady(agentId: string, ready: boolean): Promise<void> {
59
- await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/ready`, { ready });
66
+ async setReady(agentId: string, ready: boolean, instanceId?: string): Promise<void> {
67
+ await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/ready`, { ready, ...(instanceId ? { instanceId } : {}) });
60
68
  }
61
69
 
62
70
  async setLabel(agentId: string, label: string | null): Promise<void> {
@@ -93,13 +101,54 @@ export class RelayHttpClient {
93
101
  return this.json("GET", `/api/messages/${messageId}/thread`) as Promise<Message[]>;
94
102
  }
95
103
 
104
+ getMessageDelivery(messageId: number): Promise<MessageDeliveryState> {
105
+ return this.json("GET", `/api/messages/${messageId}/delivery`) as Promise<MessageDeliveryState>;
106
+ }
107
+
108
+ recordMessageDeliveryAttempt(messageId: number, input: {
109
+ agentId?: string;
110
+ status: MessageDeliveryStatus;
111
+ error?: string;
112
+ nextRetryAt?: number;
113
+ poisonReason?: string;
114
+ }): Promise<MessageDeliveryState> {
115
+ return this.json("POST", `/api/messages/${messageId}/delivery/attempts`, input) as Promise<MessageDeliveryState>;
116
+ }
117
+
118
+ updateMessageDelivery(messageId: number, input: {
119
+ action: "retry-now" | "mark-dead" | "clear";
120
+ reason?: string;
121
+ agentId?: string;
122
+ }): Promise<MessageDeliveryState> {
123
+ return this.json("POST", `/api/messages/${messageId}/delivery/actions`, input) as Promise<MessageDeliveryState>;
124
+ }
125
+
96
126
  async claimMessage(messageId: number, agentId: string): Promise<boolean> {
127
+ return (await this.claimMessageResult(messageId, agentId)).ok;
128
+ }
129
+
130
+ async claimMessageResult(messageId: number, agentId: string): Promise<{ ok: boolean; claimExpiresAt?: number; task?: Task }> {
97
131
  const path = `/api/messages/${messageId}/claim`;
98
132
  const response = await this.request("POST", path, { agentId });
99
- if (response.ok) return true;
100
- if ([400, 404, 409].includes(response.status)) return false;
133
+ if (response.ok) return this.parseResponse(response, "POST", path) as Promise<{ ok: boolean; claimExpiresAt?: number; task?: Task }>;
134
+ if ([400, 404, 409].includes(response.status)) return { ok: false };
135
+ await this.parseResponse(response, "POST", path);
136
+ return { ok: false };
137
+ }
138
+
139
+ async renewMessageClaim(messageId: number, agentId: string): Promise<{ ok: boolean; claimExpiresAt?: number }> {
140
+ const path = `/api/messages/${messageId}/claim/renew`;
141
+ const response = await this.request("POST", path, { agentId });
142
+ if (response.ok) return this.parseResponse(response, "POST", path) as Promise<{ ok: boolean; claimExpiresAt?: number }>;
143
+ if ([400, 404, 409].includes(response.status)) return { ok: false };
101
144
  await this.parseResponse(response, "POST", path);
102
- return false;
145
+ return { ok: false };
146
+ }
147
+
148
+ async updateTaskStatus(taskId: number, input: TaskStatusInput): Promise<Task> {
149
+ const payload = await this.json("PATCH", `/api/tasks/${taskId}/status`, input) as { task?: Task };
150
+ if (!payload.task) throw new Error(`task status update failed: missing task ${taskId}`);
151
+ return payload.task;
103
152
  }
104
153
 
105
154
  async markRead(messageId: number, agentId: string): Promise<void> {
@@ -114,6 +163,90 @@ export class RelayHttpClient {
114
163
  return this.json("GET", "/api/messages/cursor") as Promise<{ latestId: number }>;
115
164
  }
116
165
 
166
+ async uploadArtifact(
167
+ file: Blob | Buffer | ReadableStream,
168
+ opts: {
169
+ filename?: string;
170
+ mediaType?: string;
171
+ digest?: string;
172
+ kind?: ArtifactKind;
173
+ sensitivity?: ArtifactSensitivity;
174
+ metadata?: Record<string, unknown>;
175
+ expiresAt?: number;
176
+ } = {},
177
+ ): Promise<Artifact> {
178
+ if (opts.metadata && !(file instanceof ReadableStream)) {
179
+ const form = new FormData();
180
+ if (file instanceof Blob) {
181
+ form.append("file", file, opts.filename);
182
+ } else {
183
+ const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength) as ArrayBuffer;
184
+ form.append("file", new Blob([arrayBuffer], { type: opts.mediaType }), opts.filename);
185
+ }
186
+ if (opts.filename) form.append("filename", opts.filename);
187
+ if (opts.digest) form.append("digest", opts.digest);
188
+ if (opts.kind) form.append("kind", opts.kind);
189
+ if (opts.sensitivity) form.append("sensitivity", opts.sensitivity);
190
+ if (opts.expiresAt) form.append("expiresAt", String(opts.expiresAt));
191
+ form.append("metadata", JSON.stringify(opts.metadata));
192
+ const response = await this.requestRaw("POST", "/api/artifacts", form);
193
+ return this.parseResponse(response, "POST", "/api/artifacts") as Promise<Artifact>;
194
+ }
195
+
196
+ const headers: Record<string, string> = {};
197
+ if (opts.mediaType) headers["Content-Type"] = opts.mediaType;
198
+ if (opts.filename) headers["X-Artifact-Filename"] = opts.filename;
199
+ if (opts.digest) headers["X-Artifact-Digest"] = opts.digest;
200
+ if (opts.kind) headers["X-Artifact-Kind"] = opts.kind;
201
+ if (opts.sensitivity) headers["X-Artifact-Sensitivity"] = opts.sensitivity;
202
+ if (opts.expiresAt) headers["X-Artifact-Expires-At"] = String(opts.expiresAt);
203
+
204
+ let body: BodyInit;
205
+ if (file instanceof Blob) body = file;
206
+ else if (file instanceof ReadableStream) body = file;
207
+ else body = file as unknown as BodyInit;
208
+
209
+ const response = await this.requestRaw("POST", "/api/artifacts", body, headers);
210
+ return this.parseResponse(response, "POST", "/api/artifacts") as Promise<Artifact>;
211
+ }
212
+
213
+ async downloadArtifact(id: string): Promise<{ stream: ReadableStream<Uint8Array>; meta: Artifact }> {
214
+ const meta = await this.getArtifact(id);
215
+ const path = `/api/artifacts/${encodeURIComponent(id)}/content`;
216
+ const response = await this.requestRaw("GET", path);
217
+ await this.parseResponseForOk(response, "GET", path);
218
+ if (!response.body) throw new RelayHttpError("GET", path, response.status, response.statusText, "missing response body");
219
+ return { stream: response.body as ReadableStream<Uint8Array>, meta };
220
+ }
221
+
222
+ async getArtifact(id: string): Promise<Artifact> {
223
+ return this.json("GET", `/api/artifacts/${encodeURIComponent(id)}`) as Promise<Artifact>;
224
+ }
225
+
226
+ async artifactExists(id: string): Promise<boolean> {
227
+ const response = await this.requestRaw("HEAD", `/api/artifacts/${encodeURIComponent(id)}/content`);
228
+ if (response.status === 404) return false;
229
+ await this.parseResponseForOk(response, "HEAD", `/api/artifacts/${id}/content`);
230
+ return true;
231
+ }
232
+
233
+ async deleteArtifact(id: string): Promise<void> {
234
+ await this.json("DELETE", `/api/artifacts/${encodeURIComponent(id)}`);
235
+ }
236
+
237
+ listArtifacts(query: { messageId?: number; taskId?: number; createdBy?: string; limit?: number } = {}): Promise<Artifact[]> {
238
+ const url = this.url("/api/artifacts");
239
+ if (query.messageId !== undefined) url.searchParams.set("messageId", String(query.messageId));
240
+ if (query.taskId !== undefined) url.searchParams.set("taskId", String(query.taskId));
241
+ if (query.createdBy) url.searchParams.set("createdBy", query.createdBy);
242
+ if (query.limit !== undefined) url.searchParams.set("limit", String(query.limit));
243
+ return this.jsonUrl("GET", url) as Promise<Artifact[]>;
244
+ }
245
+
246
+ renewRuntimeToken(): Promise<{ token: string; record: { jti: string; profileId?: string; expiresAt?: number }; previous: { jti: string; expiresAt?: number } }> {
247
+ return this.json("POST", "/api/runtime-tokens/renew") as Promise<{ token: string; record: { jti: string; profileId?: string; expiresAt?: number }; previous: { jti: string; expiresAt?: number } }>;
248
+ }
249
+
117
250
  private json(method: string, path: string, body?: unknown): Promise<unknown> {
118
251
  return this.jsonUrl(method, this.url(path), body);
119
252
  }
@@ -138,6 +271,28 @@ export class RelayHttpClient {
138
271
  }
139
272
  }
140
273
 
274
+ private async requestRaw(method: string, pathOrUrl: string | URL, body?: BodyInit, headers: Record<string, string> = {}): Promise<Response> {
275
+ const controller = new AbortController();
276
+ const timeout = setTimeout(() => controller.abort(), this.timeout);
277
+ try {
278
+ return await fetch(pathOrUrl instanceof URL ? pathOrUrl : this.url(pathOrUrl), {
279
+ method,
280
+ headers: this.headers(headers),
281
+ body,
282
+ signal: controller.signal,
283
+ });
284
+ } finally {
285
+ clearTimeout(timeout);
286
+ }
287
+ }
288
+
289
+ private async parseResponseForOk(response: Response, method: string, path: string): Promise<void> {
290
+ if (!response.ok) {
291
+ const text = await response.text();
292
+ throw new RelayHttpError(method, path, response.status, response.statusText, text);
293
+ }
294
+ }
295
+
141
296
  private async parseResponse(response: Response, method: string, path: string): Promise<unknown> {
142
297
  if (!response.ok) {
143
298
  const text = await response.text();
package/src/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export * from "./types.js";
2
+ export * from "./contracts.js";
3
+ export * from "./provider-catalog.js";
2
4
  export * from "./protocol.js";
3
5
  export * from "./reconnect.js";
4
6
  export * from "./http-client.js";
5
7
  export * from "./bus-client.js";
6
8
  export * from "./provider-base.js";
7
9
  export * from "./claim-tracker.js";
10
+ export * from "./context-probe.js";
package/src/protocol.ts CHANGED
@@ -79,6 +79,9 @@ export interface RegisteredFrame extends BusFrame {
79
79
  epoch: number;
80
80
  cursor: number;
81
81
  sessionId: string;
82
+ features?: {
83
+ heartbeatAck?: boolean;
84
+ };
82
85
  };
83
86
  }
84
87
 
@@ -141,6 +144,7 @@ export type ClientBusFrame =
141
144
  | ResumeFrame;
142
145
 
143
146
  export type ServerBusFrame =
147
+ | AckFrame
144
148
  | RegisteredFrame
145
149
  | EventFrame
146
150
  | CommandResultFrame