agent-relay-runner 0.10.19 → 0.10.21

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.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/plugins/claude/.claude-plugin/plugin.json +4 -1
  3. package/plugins/claude/hooks/hooks.json +114 -0
  4. package/plugins/claude/hooks/permission-request.sh +20 -0
  5. package/plugins/claude/hooks/post-compact.sh +5 -0
  6. package/plugins/claude/hooks/pre-compact.sh +5 -0
  7. package/plugins/claude/hooks/relay-status.sh +66 -0
  8. package/plugins/claude/hooks/session-end.sh +16 -3
  9. package/plugins/claude/hooks/session-start.sh +14 -0
  10. package/plugins/claude/hooks/stop-failure.sh +15 -0
  11. package/plugins/claude/hooks/stop.sh +13 -3
  12. package/plugins/claude/hooks/subagent-start.sh +12 -0
  13. package/plugins/claude/hooks/subagent-stop.sh +12 -0
  14. package/plugins/claude/hooks/user-prompt-submit.sh +2 -3
  15. package/plugins/claude/monitors/relay-monitor.ts +16 -4
  16. package/plugins/claude/skills/react/SKILL.md +18 -0
  17. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  18. package/plugins/claude/skills/reply/SKILL.md +7 -3
  19. package/plugins/codex/skills/guide/SKILL.md +15 -0
  20. package/plugins/codex/skills/react/SKILL.md +17 -0
  21. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  22. package/plugins/codex/skills/reply/SKILL.md +6 -2
  23. package/src/adapter.ts +207 -6
  24. package/src/adapters/claude-delivery.ts +108 -0
  25. package/src/adapters/claude.ts +232 -31
  26. package/src/adapters/codex-client.ts +27 -1
  27. package/src/adapters/codex.ts +635 -26
  28. package/src/attachment-cache.ts +190 -0
  29. package/src/claim-tracker.ts +48 -5
  30. package/src/control-server.ts +193 -6
  31. package/src/index.ts +203 -6
  32. package/src/profile-home.ts +85 -0
  33. package/src/profile-projection.ts +146 -0
  34. package/src/relay-instructions.ts +25 -0
  35. package/src/runner.ts +811 -40
  36. package/src/version.ts +39 -0
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import type { Artifact, Message } from "agent-relay-sdk";
5
+
6
+ const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
7
+
8
+ export interface AttachmentCacheClient {
9
+ downloadArtifact(id: string): Promise<{ stream: ReadableStream<Uint8Array>; meta: Artifact }>;
10
+ }
11
+
12
+ export interface AttachmentCacheOptions {
13
+ agentId: string;
14
+ rootDir?: string;
15
+ maxAgeMs?: number;
16
+ now?: number;
17
+ onError?: (message: string) => void;
18
+ }
19
+
20
+ interface CachedAttachment {
21
+ path: string;
22
+ filename: string;
23
+ mediaType: string;
24
+ size: number;
25
+ digest: string;
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
30
+ }
31
+
32
+ function attachmentRefs(message: Message): Record<string, unknown>[] {
33
+ const payloadRefs = message.payload?.attachments;
34
+ const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
35
+ const refs = Array.isArray(payloadRefs) ? payloadRefs : Array.isArray(topLevelRefs) ? topLevelRefs : [];
36
+ return refs.filter(isRecord);
37
+ }
38
+
39
+ export function attachmentCacheRoot(agentId: string, rootDir = process.env.AGENT_RELAY_ATTACHMENT_CACHE_DIR): string {
40
+ return join(attachmentCacheBase(rootDir), safePathPart(agentId));
41
+ }
42
+
43
+ function attachmentCacheBase(rootDir = process.env.AGENT_RELAY_ATTACHMENT_CACHE_DIR): string {
44
+ return rootDir || join(homedir(), ".agent-relay", "attachments");
45
+ }
46
+
47
+ export async function messagesWithCachedAttachments(
48
+ messages: Message[],
49
+ client: AttachmentCacheClient,
50
+ options: AttachmentCacheOptions,
51
+ ): Promise<Message[]> {
52
+ pruneAttachmentCache(options.agentId, options.rootDir, options.maxAgeMs ?? DEFAULT_CACHE_MAX_AGE_MS, options.now ?? Date.now());
53
+ const enriched: Message[] = [];
54
+ for (const message of messages) enriched.push(await messageWithCachedAttachments(message, client, options));
55
+ return enriched;
56
+ }
57
+
58
+ async function messageWithCachedAttachments(
59
+ message: Message,
60
+ client: AttachmentCacheClient,
61
+ options: AttachmentCacheOptions,
62
+ ): Promise<Message> {
63
+ const refs = attachmentRefs(message);
64
+ if (!refs.length) return message;
65
+ const nextRefs = [];
66
+ let changed = false;
67
+ for (const ref of refs) {
68
+ const artifactId = typeof ref.artifactId === "string" ? ref.artifactId.trim() : "";
69
+ if (!artifactId) {
70
+ nextRefs.push(ref);
71
+ continue;
72
+ }
73
+ try {
74
+ const cached = await cacheArtifact(client, artifactId, options);
75
+ nextRefs.push(withCacheMetadata(ref, cached));
76
+ changed = true;
77
+ } catch (error) {
78
+ options.onError?.(`attachment ${artifactId} cache failed: ${error instanceof Error ? error.message : String(error)}`);
79
+ nextRefs.push(withCacheError(ref, error));
80
+ changed = true;
81
+ }
82
+ }
83
+ if (!changed) return message;
84
+ return {
85
+ ...message,
86
+ payload: {
87
+ ...message.payload,
88
+ attachments: nextRefs,
89
+ },
90
+ };
91
+ }
92
+
93
+ async function cacheArtifact(client: AttachmentCacheClient, artifactId: string, options: AttachmentCacheOptions): Promise<CachedAttachment> {
94
+ const { stream, meta } = await client.downloadArtifact(artifactId);
95
+ const root = attachmentCacheRoot(options.agentId, options.rootDir);
96
+ mkdirSync(root, { recursive: true, mode: 0o700 });
97
+ const filename = safeFilename(meta.filename || artifactId);
98
+ const digestPart = safePathPart((meta.digest || meta.blobDigest || artifactId).replace(/^sha256:/, "")).slice(0, 24) || safePathPart(artifactId);
99
+ const path = join(root, `${digestPart}-${filename}`);
100
+ if (existsSync(path)) {
101
+ const stat = statSync(path);
102
+ if (stat.isFile() && stat.size === meta.size) {
103
+ return { path, filename, mediaType: meta.mediaType, size: meta.size, digest: meta.digest };
104
+ }
105
+ }
106
+ const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
107
+ if (bytes.byteLength !== meta.size) throw new Error(`artifact size mismatch: expected ${meta.size}, got ${bytes.byteLength}`);
108
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
109
+ writeFileSync(temp, bytes, { mode: 0o600 });
110
+ rmSync(path, { force: true });
111
+ renameSync(temp, path);
112
+ return { path, filename, mediaType: meta.mediaType, size: meta.size, digest: meta.digest };
113
+ }
114
+
115
+ function withCacheMetadata(ref: Record<string, unknown>, cached: CachedAttachment): Record<string, unknown> {
116
+ const metadata = isRecord(ref.metadata) ? ref.metadata : {};
117
+ const agentRelay = isRecord(metadata.agentRelay) ? metadata.agentRelay : {};
118
+ return {
119
+ ...ref,
120
+ metadata: {
121
+ ...metadata,
122
+ agentRelay: {
123
+ ...agentRelay,
124
+ localPath: cached.path,
125
+ filename: cached.filename,
126
+ mediaType: cached.mediaType,
127
+ size: cached.size,
128
+ digest: cached.digest,
129
+ cachedAt: new Date().toISOString(),
130
+ },
131
+ },
132
+ };
133
+ }
134
+
135
+ function withCacheError(ref: Record<string, unknown>, error: unknown): Record<string, unknown> {
136
+ const metadata = isRecord(ref.metadata) ? ref.metadata : {};
137
+ const agentRelay = isRecord(metadata.agentRelay) ? metadata.agentRelay : {};
138
+ return {
139
+ ...ref,
140
+ metadata: {
141
+ ...metadata,
142
+ agentRelay: {
143
+ ...agentRelay,
144
+ cacheError: error instanceof Error ? error.message : String(error),
145
+ },
146
+ },
147
+ };
148
+ }
149
+
150
+ export function pruneAttachmentCache(
151
+ agentId: string,
152
+ rootDir = process.env.AGENT_RELAY_ATTACHMENT_CACHE_DIR,
153
+ maxAgeMs = DEFAULT_CACHE_MAX_AGE_MS,
154
+ now = Date.now(),
155
+ ): void {
156
+ const base = attachmentCacheBase(rootDir);
157
+ if (!existsSync(base)) return;
158
+ const roots = [attachmentCacheRoot(agentId, rootDir)];
159
+ for (const entry of readdirSync(base)) roots.push(join(base, entry));
160
+ for (const root of [...new Set(roots)]) pruneAttachmentCacheDir(root, maxAgeMs, now);
161
+ }
162
+
163
+ function pruneAttachmentCacheDir(root: string, maxAgeMs: number, now: number): void {
164
+ if (!existsSync(root)) return;
165
+ const entries = readdirSync(root);
166
+ for (const entry of entries) {
167
+ const path = join(root, entry);
168
+ try {
169
+ const stat = statSync(path);
170
+ if (!stat.isFile()) continue;
171
+ if (now - stat.mtimeMs > maxAgeMs) rmSync(path, { force: true });
172
+ } catch {
173
+ // Best-effort cache cleanup only.
174
+ }
175
+ }
176
+ try {
177
+ if (readdirSync(root).length === 0) rmSync(root, { recursive: true, force: true });
178
+ } catch {
179
+ // Best-effort cache cleanup only.
180
+ }
181
+ }
182
+
183
+ function safeFilename(value: string): string {
184
+ const name = safePathPart(basename(value));
185
+ return name || "artifact";
186
+ }
187
+
188
+ function safePathPart(value: string): string {
189
+ return value.replace(/[^a-zA-Z0-9_.-]/g, "_").replace(/_+/g, "_").slice(0, 180);
190
+ }
@@ -1,5 +1,6 @@
1
1
  type ClaimKind = "message" | "task" | "command";
2
- type RunnerBusyReason = "provider-turn" | ClaimKind;
2
+ type RunnerWorkKind = "provider-turn" | "subagent";
3
+ type RunnerBusyReason = RunnerWorkKind | ClaimKind;
3
4
  type RunnerSemanticStatus = "idle" | "busy" | "offline" | "error";
4
5
 
5
6
  interface ClaimRecord {
@@ -9,14 +10,52 @@ interface ClaimRecord {
9
10
  expiresAt?: number;
10
11
  }
11
12
 
13
+ interface WorkRecord {
14
+ id: string;
15
+ kind: RunnerWorkKind;
16
+ label?: string;
17
+ role?: string;
18
+ parentId?: string;
19
+ metadata?: Record<string, unknown>;
20
+ startedAt: number;
21
+ }
22
+
12
23
  export class ClaimTracker {
13
- private providerBusy = false;
14
24
  private status: RunnerSemanticStatus = "idle";
15
25
  private readonly claims = new Map<string, ClaimRecord>();
26
+ private readonly work = new Map<string, WorkRecord>();
16
27
 
17
28
  setProviderBusy(busy: boolean): boolean {
29
+ return busy ? this.startWork("provider-turn", "provider-turn") : this.finishWork("provider-turn", "provider-turn");
30
+ }
31
+
32
+ startWork(kind: RunnerWorkKind, id: string = kind, details: Omit<Partial<WorkRecord>, "id" | "kind" | "startedAt"> = {}): boolean {
18
33
  const before = this.currentStatus();
19
- this.providerBusy = busy;
34
+ const key = `${kind}:${id}`;
35
+ const existing = this.work.get(key);
36
+ this.work.set(key, {
37
+ id,
38
+ kind,
39
+ label: details.label ?? existing?.label,
40
+ role: details.role ?? existing?.role,
41
+ parentId: details.parentId ?? existing?.parentId,
42
+ metadata: details.metadata ?? existing?.metadata,
43
+ startedAt: existing?.startedAt ?? Date.now(),
44
+ });
45
+ return before !== this.currentStatus();
46
+ }
47
+
48
+ finishWork(kind: RunnerWorkKind, id: string = kind): boolean {
49
+ const before = this.currentStatus();
50
+ this.work.delete(`${kind}:${id}`);
51
+ return before !== this.currentStatus();
52
+ }
53
+
54
+ clearWorkKind(kind: RunnerWorkKind): boolean {
55
+ const before = this.currentStatus();
56
+ for (const key of [...this.work.keys()]) {
57
+ if (key.startsWith(`${kind}:`)) this.work.delete(key);
58
+ }
20
59
  return before !== this.currentStatus();
21
60
  }
22
61
 
@@ -62,12 +101,12 @@ export class ClaimTracker {
62
101
 
63
102
  currentStatus(): RunnerSemanticStatus {
64
103
  if (this.status === "offline" || this.status === "error") return this.status;
65
- return this.providerBusy || this.claims.size > 0 ? "busy" : "idle";
104
+ return this.work.size > 0 || this.claims.size > 0 ? "busy" : "idle";
66
105
  }
67
106
 
68
107
  reasons(): RunnerBusyReason[] {
69
108
  const reasons = new Set<RunnerBusyReason>();
70
- if (this.providerBusy) reasons.add("provider-turn");
109
+ for (const item of this.work.values()) reasons.add(item.kind);
71
110
  for (const claim of this.claims.values()) reasons.add(claim.kind);
72
111
  return [...reasons];
73
112
  }
@@ -75,4 +114,8 @@ export class ClaimTracker {
75
114
  activeClaims(): ClaimRecord[] {
76
115
  return [...this.claims.values()];
77
116
  }
117
+
118
+ activeWork(): WorkRecord[] {
119
+ return [...this.work.values()];
120
+ }
78
121
  }
@@ -1,6 +1,6 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import type { Message } from "agent-relay-sdk";
3
- import type { SemanticStatus } from "./adapter";
2
+ import type { Message, ReplyObligation } from "agent-relay-sdk";
3
+ import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
4
4
 
5
5
  interface MonitorSocketData {
6
6
  kind: "monitor";
@@ -13,15 +13,19 @@ export interface ControlServer {
13
13
  url: string;
14
14
  stop(): void;
15
15
  deliverToMonitor(messages: Message[], timeoutMs?: number): Promise<number[]>;
16
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean;
16
17
  }
17
18
 
18
19
  interface ControlServerOptions {
19
- onStatus(status: SemanticStatus): void;
20
+ onStatus(status: ProviderStatusEvent): void;
21
+ onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
22
+ onReplyObligations?(): Promise<ReplyObligation[]>;
20
23
  }
21
24
 
22
25
  export function startControlServer(options: ControlServerOptions): ControlServer {
23
26
  const monitors = new Set<MonitorSocket>();
24
27
  const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
28
+ const pendingPermissionRequests = new Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>();
25
29
  let server!: Server<MonitorSocketData>;
26
30
 
27
31
  server = Bun.serve<MonitorSocketData>({
@@ -34,6 +38,27 @@ export function startControlServer(options: ControlServerOptions): ControlServer
34
38
  if (url.pathname === "/status" && req.method === "POST") {
35
39
  return handleStatus(req, options);
36
40
  }
41
+ if (url.pathname === "/terminal/attach-spec" && req.method === "GET") {
42
+ if (!options.onTerminalAttachSpec) return Response.json({ error: "terminal attach is unavailable" }, { status: 404 });
43
+ return options.onTerminalAttachSpec()
44
+ .then((spec) => Response.json(spec))
45
+ .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 }));
46
+ }
47
+ if (url.pathname === "/reply-obligations" && req.method === "GET") {
48
+ if (!options.onReplyObligations) return Response.json({ pending: false, count: 0 });
49
+ return options.onReplyObligations()
50
+ .then((obligations) => Response.json(replyObligationSummary(obligations)))
51
+ .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 503 }));
52
+ }
53
+ if (url.pathname === "/reply-obligations/claude-stop" && req.method === "GET") {
54
+ if (!options.onReplyObligations) return Response.json({});
55
+ return options.onReplyObligations()
56
+ .then((obligations) => Response.json(replyObligationStopDecision(obligations)))
57
+ .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 503 }));
58
+ }
59
+ if (url.pathname === "/permissions/request" && req.method === "POST") {
60
+ return handlePermissionRequest(req, options, pendingPermissionRequests);
61
+ }
37
62
  if (url.pathname === "/monitor") {
38
63
  const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
39
64
  return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
@@ -71,6 +96,8 @@ export function startControlServer(options: ControlServerOptions): ControlServer
71
96
  stop() {
72
97
  for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
73
98
  pendingDeliveries.clear();
99
+ for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
100
+ pendingPermissionRequests.clear();
74
101
  server.stop(true);
75
102
  },
76
103
  async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
@@ -86,17 +113,173 @@ export function startControlServer(options: ControlServerOptions): ControlServer
86
113
  for (const monitor of monitors) monitor.send(payload);
87
114
  });
88
115
  },
116
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean {
117
+ const pending = pendingPermissionRequests.get(input.approvalId);
118
+ if (!pending) return false;
119
+ clearTimeout(pending.timer);
120
+ pendingPermissionRequests.delete(input.approvalId);
121
+ pending.resolve(input);
122
+ options.onStatus({
123
+ status: "busy",
124
+ reason: "provider-turn",
125
+ providerState: {
126
+ state: "active",
127
+ reason: "permissionResolved",
128
+ label: "Claude approval resolved",
129
+ source: "claude",
130
+ updatedAt: Date.now(),
131
+ },
132
+ });
133
+ return true;
134
+ },
135
+ };
136
+ }
137
+
138
+ function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
139
+ const obligation = obligations[0];
140
+ if (!obligation) return { pending: false, count: 0 };
141
+ const subject = obligation.subject ? ` (${obligation.subject})` : "";
142
+ return {
143
+ pending: true,
144
+ count: obligations.length,
145
+ obligation,
146
+ reason: `Pending Agent Relay response for message #${obligation.messageId} from ${obligation.from}${subject}. If it still needs an answer, run: ${obligation.replyCommand}. If multiple delivered messages are part of one current request, answer once to the latest relevant message. If the useful response was already delivered through Relay, do not send a separate status-only confirmation.`,
147
+ };
148
+ }
149
+
150
+ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<string, unknown> {
151
+ const summary = replyObligationSummary(obligations);
152
+ if (summary.pending !== true) return {};
153
+ return {
154
+ decision: "block",
155
+ reason: summary.reason,
156
+ };
157
+ }
158
+
159
+ async function handlePermissionRequest(
160
+ req: Request,
161
+ options: ControlServerOptions,
162
+ pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
163
+ ): Promise<Response> {
164
+ const body = await req.json().catch(() => null);
165
+ if (!isRecord(body)) return Response.json({});
166
+ const approvalId = crypto.randomUUID();
167
+ const view = claudePermissionApprovalView(approvalId, body);
168
+ options.onStatus({
169
+ status: "busy",
170
+ reason: "provider-turn",
171
+ providerState: {
172
+ state: "blocked",
173
+ reason: "permissionRequest",
174
+ label: view.title,
175
+ recommendedAction: "Review the permission request in Agent Relay.",
176
+ source: "claude",
177
+ pendingApproval: view,
178
+ updatedAt: Date.now(),
179
+ },
180
+ });
181
+
182
+ const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
183
+ const timer = setTimeout(() => {
184
+ pendingPermissionRequests.delete(approvalId);
185
+ resolve({ approvalId, decision: "deny", reason: "permission request timed out" });
186
+ }, 15 * 60 * 1000);
187
+ pendingPermissionRequests.set(approvalId, { resolve, timer });
188
+ });
189
+
190
+ return Response.json(claudePermissionHookResponse(decision, body));
191
+ }
192
+
193
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>): Record<string, unknown> {
194
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
195
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
196
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
197
+ const description = typeof toolInput.description === "string" ? toolInput.description : "";
198
+ const bodyText = [
199
+ command || description || JSON.stringify(toolInput),
200
+ typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
201
+ typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
202
+ ].filter(Boolean).join("\n");
203
+ return {
204
+ id,
205
+ provider: "claude",
206
+ kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
207
+ title: `Approve ${toolName}`,
208
+ body: bodyText,
209
+ choices: [
210
+ { id: "approve", label: "Approve" },
211
+ { id: "approve-session", label: "Approve session" },
212
+ { id: "deny", label: "Deny" },
213
+ { id: "abort", label: "Abort" },
214
+ ],
215
+ };
216
+ }
217
+
218
+ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
219
+ const hookEventName = "PermissionRequest";
220
+ if (decision.decision === "approve" || decision.decision === "approve-session") {
221
+ const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
222
+ return {
223
+ hookSpecificOutput: {
224
+ hookEventName,
225
+ decision: {
226
+ behavior: "allow",
227
+ ...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
228
+ },
229
+ },
230
+ };
231
+ }
232
+ return {
233
+ hookSpecificOutput: {
234
+ hookEventName,
235
+ decision: {
236
+ behavior: "deny",
237
+ message: decision.reason || "Denied from Agent Relay dashboard",
238
+ interrupt: decision.decision === "abort",
239
+ },
240
+ },
89
241
  };
90
242
  }
91
243
 
92
244
  async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
93
- const body = await req.json().catch(() => null) as { status?: unknown } | null;
245
+ const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
94
246
  const status = body?.status;
95
247
  if (status !== "idle" && status !== "busy" && status !== "offline" && status !== "error") {
96
248
  return Response.json({ error: "status must be idle, busy, offline, or error" }, { status: 400 });
97
249
  }
98
- options.onStatus(status);
99
- return Response.json({ ok: true, status });
250
+ const reason = body?.reason;
251
+ if (reason !== undefined && reason !== "provider-turn" && reason !== "subagent") {
252
+ return Response.json({ error: "reason must be provider-turn or subagent" }, { status: 400 });
253
+ }
254
+ const clear = Array.isArray(body?.clear)
255
+ ? body.clear.filter((item): item is "provider-turn" | "subagent" => item === "provider-turn" || item === "subagent")
256
+ : undefined;
257
+ const timeline = statusTimelineEvent(body);
258
+ const update: ProviderStatusEvent = {
259
+ status,
260
+ ...(typeof body?.providerSessionId === "string" ? { providerSessionId: body.providerSessionId } : {}),
261
+ ...(reason ? { reason } : {}),
262
+ ...(clear?.length ? { clear: [...new Set(clear)] } : {}),
263
+ ...(timeline ? { timeline } : {}),
264
+ ...(typeof body?.id === "string" ? { id: body.id } : {}),
265
+ ...(typeof body?.label === "string" ? { label: body.label } : {}),
266
+ ...(typeof body?.role === "string" ? { role: body.role } : {}),
267
+ ...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
268
+ ...(body?.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? { metadata: body.metadata as Record<string, unknown> } : {}),
269
+ };
270
+ options.onStatus(update);
271
+ return Response.json({ ok: true, ...update });
272
+ }
273
+
274
+ function statusTimelineEvent(body: Partial<ProviderStatusEvent> | null): ProviderStatusEvent["timeline"] | undefined {
275
+ const timeline = body?.timeline;
276
+ if (!timeline || typeof timeline !== "object" || Array.isArray(timeline)) return undefined;
277
+ if (typeof timeline.status !== "string" || !timeline.status) return undefined;
278
+ return {
279
+ status: timeline.status,
280
+ ...(typeof timeline.id === "string" ? { id: timeline.id } : {}),
281
+ ...(typeof timeline.timestamp === "number" && Number.isFinite(timeline.timestamp) ? { timestamp: timeline.timestamp } : {}),
282
+ };
100
283
  }
101
284
 
102
285
  function parseJson(raw: string | Buffer): Record<string, unknown> | null {
@@ -107,3 +290,7 @@ function parseJson(raw: string | Buffer): Record<string, unknown> | null {
107
290
  return null;
108
291
  }
109
292
  }
293
+
294
+ function isRecord(value: unknown): value is Record<string, unknown> {
295
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
296
+ }