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
@@ -1,17 +1,35 @@
1
- import { existsSync, readdirSync } from "node:fs";
1
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { basename, join, resolve } from "node:path";
3
- import type { Message } from "agent-relay-sdk";
4
- import { providerMessageText, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
4
+ import type { ContextState, Message } from "agent-relay-sdk";
5
+ import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
6
+ import { prepareCodexProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
5
7
  import { CodexAppClient, type ClientEvent } from "./codex-client";
6
8
 
9
+ type PendingCodexApproval = {
10
+ id: string;
11
+ requestId: string | number;
12
+ method: string;
13
+ params: Record<string, unknown>;
14
+ view: Record<string, unknown>;
15
+ };
16
+
7
17
  export class CodexAdapter implements ProviderAdapter {
8
18
  readonly provider = "codex";
9
- private statusCb: (status: SemanticStatus) => void = () => {};
19
+ private statusCb: (status: ProviderStatusUpdate) => void = () => {};
20
+ private readonly subagentThreads = new Map<string, { label?: string; role?: string; parentId?: string }>();
21
+ private readonly pendingApprovals = new Map<string, PendingCodexApproval>();
10
22
 
11
- onStatusChange(cb: (status: SemanticStatus) => void): void {
23
+ onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
12
24
  this.statusCb = cb;
13
25
  }
14
26
 
27
+ // The Codex app-server is headless and has no tmux session, but an unexpected
28
+ // exit should still be restarted with backoff rather than resolved as a final exit.
29
+ supportsUnexpectedExitRestart(): boolean {
30
+ return true;
31
+ }
32
+
15
33
  async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
16
34
  const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
17
35
  const appServer = Bun.spawn([args.command, ...args.args], {
@@ -26,11 +44,12 @@ export class CodexAdapter implements ProviderAdapter {
26
44
  if (client) {
27
45
  await connectWithRetry(client);
28
46
  await client.initialize().catch(() => undefined);
29
- client.onEvent((event) => this.handleCodexEvent(event));
30
47
  }
31
48
 
49
+ const relaySkillArgs = profileAllowsRelayFeature(config, "skills") ? bundledSkillConfigArgs() : [];
50
+ const defaultArgs = profileUsesHostProviderGlobals(config) ? config.providerConfig.defaultArgs : [];
32
51
  const tui = !config.headless && isCodexCliCommand(config.providerConfig.command)
33
- ? Bun.spawn([config.providerConfig.command, "--remote", appServerUrl, ...bundledSkillConfigArgs(), ...config.providerConfig.defaultArgs, ...config.providerArgs], {
52
+ ? Bun.spawn([config.providerConfig.command, "--remote", appServerUrl, ...relaySkillArgs, ...defaultArgs, ...config.providerArgs, ...codexManagedConfigArgs()], {
34
53
  cwd: config.cwd,
35
54
  env: args.env,
36
55
  stdin: "inherit",
@@ -39,9 +58,32 @@ export class CodexAdapter implements ProviderAdapter {
39
58
  })
40
59
  : undefined;
41
60
 
42
- void appServer.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
43
- if (tui) void tui.exited.then((code) => this.statusCb(code === 0 ? "idle" : "error"));
44
- return { pid: tui?.pid ?? appServer.pid, process: appServer, meta: { client, threadId: config.threadId, cwd: config.cwd, appServer, tui } };
61
+ // app-server and tui exit independently; the first exit defines the agent's
62
+ // fate. Latch so a trailing second exit can't fire a duplicate terminal event.
63
+ let exitReported = false;
64
+ const reportExit = (status: ProviderStatusUpdate): void => {
65
+ if (exitReported) return;
66
+ exitReported = true;
67
+ this.statusCb(status);
68
+ };
69
+ void appServer.exited.then((code) => reportExit(code === 0 ? "offline" : "error"));
70
+ if (tui) void tui.exited.then((code) => reportExit(code === 0 ? "idle" : "error"));
71
+ const process: ManagedProcess = {
72
+ pid: tui?.pid ?? appServer.pid,
73
+ process: appServer,
74
+ meta: {
75
+ client,
76
+ cwd: config.cwd,
77
+ appServer,
78
+ appServerUrl,
79
+ tui,
80
+ config,
81
+ attachCommand: resolveRealCodexCommand(config.providerConfig.command, args.env.PATH),
82
+ },
83
+ };
84
+ process.meta = { ...process.meta, getContext: () => latestCodexContext(process) };
85
+ if (client) client.onEvent((event) => this.handleCodexEvent(event, process));
86
+ return process;
45
87
  }
46
88
 
47
89
  async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
@@ -50,58 +92,605 @@ export class CodexAdapter implements ProviderAdapter {
50
92
  await terminateProcess(process, opts);
51
93
  }
52
94
 
95
+ async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
96
+ const client = process.meta?.client as CodexAppClient | undefined;
97
+ if (!client) throw new Error("Codex App Server client is unavailable");
98
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
99
+ if (!threadId) throw new Error("Codex thread is not ready");
100
+ await client.threadCompactStart(threadId);
101
+ const currentContext = isContextState(process.meta?.context) ? process.meta.context : undefined;
102
+ if (currentContext) {
103
+ process.meta = {
104
+ ...(process.meta ?? {}),
105
+ context: { ...currentContext, lifecycleState: "compacting", lastUpdatedAt: Date.now() },
106
+ };
107
+ }
108
+ return { threadId };
109
+ }
110
+
111
+ async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
112
+ const client = process.meta?.client as CodexAppClient | undefined;
113
+ if (!client) throw new Error("Codex App Server client is unavailable");
114
+ const previousThreadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : undefined;
115
+ const started = await client.threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() });
116
+ process.meta = {
117
+ ...(process.meta ?? {}),
118
+ threadId: started.thread.id,
119
+ relayContextSent: false,
120
+ context: {
121
+ utilization: 0,
122
+ lifecycleState: "fresh",
123
+ warmTopics: [],
124
+ activeMemories: [],
125
+ tasksSinceCompact: 0,
126
+ lastUpdatedAt: Date.now(),
127
+ source: "api",
128
+ confidence: "reported",
129
+ } satisfies ContextState,
130
+ };
131
+ return { previousThreadId, threadId: started.thread.id };
132
+ }
133
+
134
+ async terminalAttachSpec(process: ManagedProcess): Promise<TerminalAttachSpec> {
135
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
136
+ if (!threadId) throw new Error("Codex thread is not ready");
137
+ const appServerUrl = typeof process.meta?.appServerUrl === "string" ? process.meta.appServerUrl : "";
138
+ if (!appServerUrl) throw new Error("Codex app-server URL is unavailable");
139
+ const command = typeof process.meta?.attachCommand === "string" && process.meta.attachCommand
140
+ ? process.meta.attachCommand
141
+ : resolveRealCodexCommand("codex");
142
+ return {
143
+ mode: "guest",
144
+ provider: "codex",
145
+ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd(),
146
+ command: [command, ...codexManagedConfigArgs(), "resume", threadId, "--remote", appServerUrl, "--no-alt-screen"],
147
+ title: `Codex ${threadId.slice(0, 8)}`,
148
+ ttlMs: 60 * 60 * 1000,
149
+ };
150
+ }
151
+
152
+ async deliverInitialPrompt(process: ManagedProcess, prompt: string): Promise<void> {
153
+ const text = prompt.trim();
154
+ if (!text) return;
155
+ const threadId = await ensureCodexThread(process);
156
+ let input = [
157
+ codexLaunchContext(process),
158
+ "[agent-relay initial prompt from dashboard]",
159
+ text,
160
+ ].filter(Boolean).join("\n\n");
161
+ if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
162
+ input = RELAY_CONTEXT + "\n\n" + input;
163
+ process.meta = { ...(process.meta ?? {}), relayContextSent: true };
164
+ }
165
+ console.error(`[agent-relay] starting Codex initial prompt in thread ${threadId}`);
166
+ const client = process.meta?.client as CodexAppClient;
167
+ await client.turnStart(threadId, input);
168
+ }
169
+
53
170
  async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
171
+ const threadId = await ensureCodexThread(process);
172
+ let text = [codexLaunchContext(process), providerMessageText(messages)].filter(Boolean).join("\n\n");
173
+ if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
174
+ text = RELAY_CONTEXT + "\n\n" + text;
175
+ process.meta = { ...(process.meta ?? {}), relayContextSent: true };
176
+ }
177
+ console.error(codexDeliveryNotice(messages, threadId));
178
+ const client = process.meta?.client as CodexAppClient;
179
+ await client.turnStart(threadId, text);
180
+ }
181
+
182
+ async respondToPermissionDecision(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown>> {
183
+ const pending = this.pendingApprovals.get(input.approvalId);
184
+ if (!pending) throw new Error("approval request not found or already resolved");
54
185
  const client = process.meta?.client as CodexAppClient | undefined;
55
186
  if (!client) throw new Error("Codex App Server client is unavailable");
56
- let threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
57
- if (!threadId) {
58
- const started = await client.threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() });
59
- threadId = started.thread.id;
60
- process.meta = { ...(process.meta ?? {}), threadId };
187
+ const response = codexApprovalResponse(pending.method, pending.params, input.decision);
188
+ // Always drop the pending entry: a failed send means the request is dead on
189
+ // the Codex side too, so a stale entry would only mislead later lookups.
190
+ try {
191
+ client.respondToServerRequest(pending.requestId, response);
192
+ } finally {
193
+ this.pendingApprovals.delete(input.approvalId);
61
194
  }
62
- await client.turnStart(threadId, providerMessageText(messages));
195
+ this.statusCb({
196
+ status: "busy",
197
+ reason: "provider-turn",
198
+ providerState: {
199
+ state: "active",
200
+ reason: "permissionResolved",
201
+ label: "Codex approval resolved",
202
+ source: "codex",
203
+ updatedAt: Date.now(),
204
+ },
205
+ });
206
+ return { approvalId: input.approvalId, decision: input.decision, provider: "codex" };
63
207
  }
64
208
 
65
209
  buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
66
210
  const appServerUrl = String(config.appServerUrl || process.env.CODEX_APP_SERVER_URL || `ws://127.0.0.1:${config.controlPort + 1000}`);
211
+ const profileHome = prepareCodexProfileHome(config);
212
+ const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
67
213
  const args = isCodexCliCommand(providerConfig.command)
68
214
  ? [
69
215
  "app-server",
70
- ...codexAppServerConfigArgs(providerConfig.defaultArgs, config.providerArgs),
71
- ...bundledSkillConfigArgs(),
216
+ ...codexAppServerConfigArgs(defaultArgs, config.providerArgs),
217
+ ...codexModelConfigArgs(config.model, config.effort),
218
+ ...codexApprovalConfigArgs(config.approvalMode),
219
+ ...(profileAllowsRelayFeature(config, "skills") ? bundledSkillConfigArgs() : []),
220
+ ...codexManagedConfigArgs(),
72
221
  "--listen",
73
222
  appServerUrl,
74
223
  ]
75
- : [...providerConfig.defaultArgs, ...config.providerArgs];
224
+ : [...defaultArgs, ...config.providerArgs];
76
225
  return {
77
226
  command: providerConfig.command,
78
227
  args,
79
228
  cwd: config.cwd,
80
229
  env: {
81
230
  ...config.env,
231
+ ...(profileHome ? { CODEX_HOME: profileHome.path } : {}),
82
232
  CODEX_APP_SERVER_URL: appServerUrl,
83
233
  AGENT_RELAY_PROVIDER: "codex",
84
234
  },
85
235
  };
86
236
  }
87
237
 
88
- private handleCodexEvent(event: ClientEvent): void {
238
+ private handleCodexEvent(event: ClientEvent, process?: ManagedProcess): void {
239
+ if (event.type === "server-request" && process) {
240
+ const approval = codexApprovalFromServerRequest(event.message);
241
+ if (approval) {
242
+ this.pendingApprovals.set(approval.pending.id, approval.pending);
243
+ this.statusCb({
244
+ status: "busy",
245
+ reason: "provider-turn",
246
+ providerState: {
247
+ state: "blocked",
248
+ reason: "permissionRequest",
249
+ label: approval.view.title,
250
+ recommendedAction: "Review the permission request in Agent Relay.",
251
+ source: "codex",
252
+ pendingApproval: approval.view,
253
+ updatedAt: Date.now(),
254
+ },
255
+ });
256
+ }
257
+ return;
258
+ }
259
+
89
260
  const method = event.type === "notification" ? event.message.method : "";
90
- if (method.includes("turn/started") || method.includes("turn.started")) this.statusCb("busy");
91
- if (method.includes("turn/completed") || method.includes("turn.completed")) this.statusCb("idle");
261
+ const params = event.type === "notification" ? event.message.params : undefined;
262
+ const thread = isRecord(params?.thread) ? params.thread : undefined;
263
+ const threadId = stringValue(params?.threadId) ?? stringValue(thread?.id);
264
+ const isSubagent = Boolean(threadId && this.subagentThreads.has(threadId)) || isSubagentThread(thread, params);
265
+
266
+ if (threadId && process && !isSubagent) {
267
+ const threadPath = stringValue(thread?.path) ?? stringValue(params?.path);
268
+ process.meta = {
269
+ ...(process.meta ?? {}),
270
+ threadId,
271
+ ...(threadPath ? { threadPath } : {}),
272
+ };
273
+ }
274
+
275
+ if (method.includes("thread/tokenUsage/updated") || method.includes("thread.tokenUsage.updated") || method.includes("thread/token_usage/updated") || method.includes("thread.token_usage.updated")) {
276
+ const context = codexTokenUsageContext(params);
277
+ if (context && process) process.meta = { ...(process.meta ?? {}), context };
278
+ }
279
+
280
+ if ((method.includes("thread/started") || method.includes("thread.started")) && threadId && isSubagent) {
281
+ const details = subagentThreadDetails(thread, params);
282
+ this.subagentThreads.set(threadId, details);
283
+ this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...details });
284
+ return;
285
+ }
286
+
287
+ if (method.includes("turn/started") || method.includes("turn.started")) {
288
+ if (threadId && this.subagentThreads.has(threadId)) {
289
+ this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
290
+ } else {
291
+ this.statusCb({ status: "busy", reason: "provider-turn" });
292
+ }
293
+ }
294
+ if (method.includes("turn/completed") || method.includes("turn.completed")) {
295
+ if (threadId && this.subagentThreads.has(threadId)) {
296
+ this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
297
+ } else {
298
+ this.statusCb({ status: "idle", reason: "provider-turn" });
299
+ }
300
+ }
92
301
  if (method.includes("thread/status")) {
93
- const params = event.type === "notification" ? event.message.params : undefined;
94
- const status = params?.status;
95
- if (typeof status === "string" && status.includes("active")) this.statusCb("busy");
96
- if (typeof status === "string" && status.includes("idle")) this.statusCb("idle");
302
+ const status = statusType(params?.status);
303
+ if (threadId && this.subagentThreads.has(threadId)) {
304
+ if (status === "active") this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
305
+ if (status === "idle" || status === "notLoaded") this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
306
+ } else {
307
+ if (status === "active") this.statusCb({ status: "busy", reason: "provider-turn", providerState: this.providerStateFromThreadStatus(params?.status, params) });
308
+ if (status === "idle") this.statusCb({ status: "idle", reason: "provider-turn" });
309
+ }
97
310
  }
98
311
  }
312
+
313
+ private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): Record<string, unknown> | undefined {
314
+ const state = codexProviderStateFromThreadStatus(status, params);
315
+ if (state?.state !== "blocked" || state.reason !== "waitingOnApproval" || state.pendingApproval) return state;
316
+ const pending = [...this.pendingApprovals.values()].at(-1);
317
+ return pending ? { ...state, pendingApproval: pending.view } : state;
318
+ }
319
+ }
320
+
321
+ function codexApprovalFromServerRequest(message: { id: string | number; method: string; params?: unknown }): { pending: PendingCodexApproval; view: Record<string, unknown> } | null {
322
+ if (!isRecord(message.params)) return null;
323
+ const method = message.method;
324
+ if (!codexApprovalMethod(method)) return null;
325
+ const id = `${method}:${String(message.id)}`;
326
+ const view = codexApprovalView(id, method, message.params);
327
+ return {
328
+ pending: { id, requestId: message.id, method, params: message.params, view },
329
+ view,
330
+ };
331
+ }
332
+
333
+ function codexApprovalMethod(method: string): boolean {
334
+ return method === "execCommandApproval" ||
335
+ method === "applyPatchApproval" ||
336
+ method === "item/commandExecution/requestApproval" ||
337
+ method === "item/fileChange/requestApproval" ||
338
+ method === "item/permissions/requestApproval";
339
+ }
340
+
341
+ function codexApprovalView(id: string, method: string, params: Record<string, unknown>): Record<string, unknown> {
342
+ const command = typeof params.command === "string" ? params.command
343
+ : Array.isArray(params.command) ? params.command.filter((v): v is string => typeof v === "string").join(" ")
344
+ : "";
345
+ const cwd = stringValue(params.cwd) ?? stringValue(params.conversationId) ?? stringValue(params.threadId);
346
+ const reason = stringValue(params.reason);
347
+ const grantRoot = stringValue(params.grantRoot);
348
+ const fileChanges = isRecord(params.fileChanges) ? Object.keys(params.fileChanges) : [];
349
+ const permissions = isRecord(params.permissions) ? params.permissions : undefined;
350
+ const body = [
351
+ command,
352
+ grantRoot ? `Grant write access: ${grantRoot}` : "",
353
+ fileChanges.length ? `Files: ${fileChanges.join(", ")}` : "",
354
+ permissions ? `Permissions: ${JSON.stringify(permissions)}` : "",
355
+ reason ? `Reason: ${reason}` : "",
356
+ cwd ? `Context: ${cwd}` : "",
357
+ ].filter(Boolean).join("\n");
358
+ const isSessionCapable = method !== "item/permissions/requestApproval";
359
+ return {
360
+ id,
361
+ provider: "codex",
362
+ kind: method.includes("file") || method === "applyPatchApproval" ? "file-change" : method.includes("permission") ? "permission" : "command",
363
+ title: method.includes("file") || method === "applyPatchApproval"
364
+ ? "Approve file changes"
365
+ : method.includes("permission")
366
+ ? "Approve extra permissions"
367
+ : "Approve command",
368
+ body: body || "Codex is requesting permission.",
369
+ method,
370
+ choices: [
371
+ { id: "approve", label: method.includes("permission") ? "Allow this turn" : "Approve" },
372
+ ...(isSessionCapable ? [{ id: "approve-session", label: "Approve session" }] : [{ id: "approve-session", label: "Allow session" }]),
373
+ { id: "deny", label: "Deny" },
374
+ ],
375
+ };
376
+ }
377
+
378
+ function codexApprovalResponse(method: string, params: Record<string, unknown>, decision: ProviderPermissionDecisionInput["decision"]): Record<string, unknown> {
379
+ if (method === "item/commandExecution/requestApproval") {
380
+ return { decision: decision === "approve-session" ? "acceptForSession" : decision === "deny" ? "decline" : decision === "abort" ? "cancel" : "accept" };
381
+ }
382
+ if (method === "item/fileChange/requestApproval") {
383
+ return { decision: decision === "approve-session" ? "acceptForSession" : decision === "deny" ? "decline" : decision === "abort" ? "cancel" : "accept" };
384
+ }
385
+ if (method === "item/permissions/requestApproval") {
386
+ const permissions = decision === "deny" || decision === "abort" ? {} : isRecord(params.permissions) ? params.permissions : {};
387
+ return { permissions, scope: decision === "approve-session" ? "session" : "turn" };
388
+ }
389
+ return { decision: decision === "approve-session" ? "approved_for_session" : decision === "deny" ? "denied" : decision === "abort" ? "abort" : "approved" };
390
+ }
391
+
392
+ async function ensureCodexThread(process: ManagedProcess): Promise<string> {
393
+ const client = process.meta?.client as CodexAppClient | undefined;
394
+ if (!client) throw new Error("Codex App Server client is unavailable");
395
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
396
+ if (threadId) return threadId;
397
+ // Memoize the in-flight start so concurrent deliveries don't create two threads
398
+ // and orphan the first turn's context.
399
+ const pending = process.meta?.threadStartPromise as Promise<string> | undefined;
400
+ if (pending) return pending;
401
+ const startPromise = client
402
+ .threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() })
403
+ .then((started) => {
404
+ process.meta = { ...(process.meta ?? {}), threadId: started.thread.id, threadStartPromise: undefined };
405
+ return started.thread.id;
406
+ })
407
+ .catch((error) => {
408
+ if (process.meta) process.meta.threadStartPromise = undefined;
409
+ throw error;
410
+ });
411
+ process.meta = { ...(process.meta ?? {}), threadStartPromise: startPromise };
412
+ return startPromise;
413
+ }
414
+
415
+ export function codexDeliveryNotice(messages: Message[], threadId: string): string {
416
+ const ids = messages.map((message) => `#${message.id}`).join(", ");
417
+ const senders = [...new Set(messages.map((message) => message.from))].join(", ");
418
+ return `[agent-relay] received ${messages.length} relay message${messages.length === 1 ? "" : "s"} ${ids} from ${senders}; injecting into Codex thread ${threadId}`;
419
+ }
420
+
421
+ export function codexProviderStateFromThreadStatus(status: unknown, params?: Record<string, unknown>, now = Date.now()): Record<string, unknown> | undefined {
422
+ const type = statusType(status);
423
+ const flags = activeFlags(status);
424
+ const threadId = stringValue(params?.threadId) ?? (isRecord(params?.thread) ? stringValue(params.thread.id) : undefined);
425
+ const raw = { ...(threadId ? { threadId } : {}), status: isRecord(status) ? status : { type } };
426
+ const reason = flags[0] ?? type;
427
+
428
+ if (type === "active" && flags.some((flag) => flag.toLowerCase() === "waitingonapproval")) {
429
+ return {
430
+ state: "blocked",
431
+ reason: "waitingOnApproval",
432
+ label: "waiting for Codex approval",
433
+ recommendedAction: "Open the attached TUI to approve, or restart with non-interactive approval.",
434
+ source: "codex",
435
+ raw,
436
+ updatedAt: now,
437
+ };
438
+ }
439
+
440
+ if (type === "active" && flags.some((flag) => flag.toLowerCase().includes("auth"))) {
441
+ return {
442
+ state: "blocked",
443
+ reason: "authRequired",
444
+ label: "Codex authentication required",
445
+ recommendedAction: "Open the provider session and complete authentication, then retry.",
446
+ source: "codex",
447
+ raw,
448
+ updatedAt: now,
449
+ };
450
+ }
451
+
452
+ if (type === "systemError") {
453
+ return {
454
+ state: "blocked",
455
+ reason: "systemError",
456
+ label: "Codex system error",
457
+ recommendedAction: "View provider logs, then restart the provider if the error is not transient.",
458
+ source: "codex",
459
+ raw,
460
+ updatedAt: now,
461
+ };
462
+ }
463
+
464
+ if (type === "active") {
465
+ return {
466
+ state: "active",
467
+ reason,
468
+ label: flags.length ? `Codex active: ${flags.join(", ")}` : "Codex turn active",
469
+ source: "codex",
470
+ raw,
471
+ updatedAt: now,
472
+ };
473
+ }
474
+
475
+ return undefined;
476
+ }
477
+
478
+ export function codexTokenUsageContext(params: Record<string, unknown> | undefined, now = Date.now()): ContextState | undefined {
479
+ const usage = isRecord(params?.tokenUsage) ? params.tokenUsage
480
+ : isRecord(params?.token_usage) ? params.token_usage
481
+ : isRecord(params?.usage) ? params.usage
482
+ : isRecord(params?.info) ? params.info
483
+ : params;
484
+ if (!usage) return undefined;
485
+ const total = isRecord(usage.total) ? usage.total
486
+ : isRecord(usage.totalTokenUsage) ? usage.totalTokenUsage
487
+ : isRecord(usage.total_token_usage) ? usage.total_token_usage
488
+ : undefined;
489
+ const last = isRecord(usage.last) ? usage.last
490
+ : isRecord(usage.lastTokenUsage) ? usage.lastTokenUsage
491
+ : isRecord(usage.last_token_usage) ? usage.last_token_usage
492
+ : undefined;
493
+ const tokensUsed = numberValue(last?.totalTokens) ?? numberValue(last?.total_tokens) ??
494
+ numberValue(total?.totalTokens) ?? numberValue(total?.total_tokens) ??
495
+ numberValue(usage.totalTokens) ?? numberValue(usage.total_tokens);
496
+ const tokensMax = numberValue(usage.modelContextWindow) ?? numberValue(usage.model_context_window) ??
497
+ numberValue(params?.modelContextWindow) ?? numberValue(params?.model_context_window);
498
+ if (tokensUsed === undefined || tokensMax === undefined || tokensMax <= 0) return undefined;
499
+ return {
500
+ utilization: Math.max(0, Math.min(1, tokensUsed / tokensMax)),
501
+ tokensUsed,
502
+ tokensMax,
503
+ lifecycleState: "working",
504
+ warmTopics: [],
505
+ activeMemories: [],
506
+ tasksSinceCompact: 0,
507
+ lastUpdatedAt: now,
508
+ source: "api",
509
+ confidence: "reported",
510
+ };
511
+ }
512
+
513
+ function latestCodexContext(process: ManagedProcess): ContextState | undefined {
514
+ const meta = process.meta ?? {};
515
+ const current = isContextState(meta.context) ? meta.context : undefined;
516
+ const explicitPath = stringValue(meta.threadPath);
517
+ const threadId = stringValue(meta.threadId);
518
+ const rolloutPath = explicitPath ?? (threadId ? findCodexRolloutPath(threadId) : undefined);
519
+ if (!rolloutPath) return current;
520
+ const context = readCodexRolloutContext(rolloutPath);
521
+ if (!context) return current;
522
+ process.meta = { ...meta, context, threadPath: rolloutPath };
523
+ return context;
524
+ }
525
+
526
+ function readCodexRolloutContext(path: string): ContextState | undefined {
527
+ try {
528
+ return codexRolloutContextFromText(readFileSync(path, "utf8"));
529
+ } catch {
530
+ return undefined;
531
+ }
532
+ }
533
+
534
+ export function codexRolloutContextFromText(input: string): ContextState | undefined {
535
+ const lines = input.split(/\r?\n/);
536
+ for (let i = lines.length - 1; i >= 0; i--) {
537
+ const line = lines[i]?.trim();
538
+ if (!line) continue;
539
+ let event: unknown;
540
+ try {
541
+ event = JSON.parse(line);
542
+ } catch {
543
+ continue;
544
+ }
545
+ if (!isRecord(event)) continue;
546
+ const payload = isRecord(event.payload) ? event.payload : undefined;
547
+ if (payload?.type !== "token_count") continue;
548
+ const timestamp = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : Date.now();
549
+ return codexTokenUsageContext(payload, Number.isFinite(timestamp) ? timestamp : Date.now());
550
+ }
551
+ return undefined;
552
+ }
553
+
554
+ function findCodexRolloutPath(threadId: string): string | undefined {
555
+ const root = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
556
+ return findFileContaining(root, threadId);
557
+ }
558
+
559
+ function findFileContaining(dir: string, needle: string, depth = 0): string | undefined {
560
+ if (depth > 8) return undefined;
561
+ let entries: Array<{ name: string; isFile(): boolean; isDirectory(): boolean }>;
562
+ try {
563
+ entries = readdirSync(dir, { withFileTypes: true });
564
+ } catch {
565
+ return undefined;
566
+ }
567
+ entries.sort((a, b) => b.name.localeCompare(a.name));
568
+ for (const entry of entries) {
569
+ const path = join(dir, entry.name);
570
+ if (entry.isFile() && entry.name.includes(needle) && entry.name.endsWith(".jsonl")) return path;
571
+ if (entry.isDirectory()) {
572
+ const found = findFileContaining(path, needle, depth + 1);
573
+ if (found) return found;
574
+ }
575
+ }
576
+ return undefined;
577
+ }
578
+
579
+ function isSubagentThread(thread: Record<string, unknown> | undefined, params: Record<string, unknown> | undefined): boolean {
580
+ if (thread?.threadSource === "subagent" || params?.threadSource === "subagent") return true;
581
+ const source = isRecord(thread?.sessionSource) ? thread.sessionSource : isRecord(params?.sessionSource) ? params.sessionSource : undefined;
582
+ return isRecord(source?.subagent);
583
+ }
584
+
585
+ function subagentThreadDetails(thread: Record<string, unknown> | undefined, params: Record<string, unknown> | undefined): { label?: string; role?: string; parentId?: string } {
586
+ const source = isRecord(thread?.sessionSource) ? thread.sessionSource : isRecord(params?.sessionSource) ? params.sessionSource : undefined;
587
+ const subagent = isRecord(source?.subagent) ? source.subagent : undefined;
588
+ const threadSpawn = isRecord(subagent?.thread_spawn) ? subagent.thread_spawn : undefined;
589
+ return {
590
+ label: stringValue(thread?.agentNickname) ?? stringValue(params?.agentNickname) ?? stringValue(threadSpawn?.agent_nickname),
591
+ role: stringValue(thread?.agentRole) ?? stringValue(params?.agentRole) ?? stringValue(threadSpawn?.agent_role),
592
+ parentId: stringValue(threadSpawn?.parent_thread_id) ?? stringValue(params?.parentThreadId),
593
+ };
594
+ }
595
+
596
+ function statusType(value: unknown): string | undefined {
597
+ if (typeof value === "string") return value;
598
+ return isRecord(value) ? stringValue(value.type) : undefined;
599
+ }
600
+
601
+ function activeFlags(value: unknown): string[] {
602
+ if (!isRecord(value) || !Array.isArray(value.activeFlags)) return [];
603
+ return value.activeFlags.filter((flag): flag is string => typeof flag === "string" && flag.length > 0);
604
+ }
605
+
606
+ function stringValue(value: unknown): string | undefined {
607
+ return typeof value === "string" && value ? value : undefined;
608
+ }
609
+
610
+ function numberValue(value: unknown): number | undefined {
611
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
612
+ }
613
+
614
+ function isContextState(value: unknown): value is ContextState {
615
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
616
+ const state = value as Record<string, unknown>;
617
+ return typeof state.utilization === "number" &&
618
+ typeof state.lifecycleState === "string" &&
619
+ typeof state.source === "string" &&
620
+ typeof state.confidence === "string";
621
+ }
622
+
623
+ function isRecord(value: unknown): value is Record<string, unknown> {
624
+ return typeof value === "object" && value !== null && !Array.isArray(value);
625
+ }
626
+
627
+ export function codexModelConfigArgs(model?: string, effort?: string): string[] {
628
+ const args: string[] = [];
629
+ if (model) args.push("-c", `model=${tomlString(model)}`);
630
+ if (effort) args.push("-c", `model_reasoning_effort=${tomlString(effort)}`);
631
+ return args;
632
+ }
633
+
634
+ export function codexApprovalConfigArgs(approvalMode?: string): string[] {
635
+ const sandboxMode = approvalMode === "open"
636
+ ? "danger-full-access"
637
+ : approvalMode === "read-only"
638
+ ? "read-only"
639
+ : "workspace-write";
640
+ return [
641
+ "-c",
642
+ 'approval_policy="never"',
643
+ "-c",
644
+ `sandbox_mode=${tomlString(sandboxMode)}`,
645
+ ];
646
+ }
647
+
648
+ export function codexManagedConfigArgs(): string[] {
649
+ return ["-c", "check_for_update_on_startup=false"];
99
650
  }
100
651
 
101
652
  export function isCodexCliCommand(command: string): boolean {
102
653
  return basename(command) === "codex";
103
654
  }
104
655
 
656
+ export function resolveRealCodexCommand(command: string, pathValue = process.env.PATH || ""): string {
657
+ const candidates: string[] = [];
658
+ if (command.includes("/")) candidates.push(command);
659
+ for (const dir of pathValue.split(":").filter(Boolean)) candidates.push(join(dir, "codex"));
660
+
661
+ const seen = new Set<string>();
662
+ for (const candidate of candidates) {
663
+ if (seen.has(candidate)) continue;
664
+ seen.add(candidate);
665
+ if (!isExecutableFile(candidate)) continue;
666
+ const real = realPath(candidate);
667
+ if (isAgentRelayCodexShim(candidate) || isAgentRelayCodexShim(real)) continue;
668
+ return candidate;
669
+ }
670
+ return command;
671
+ }
672
+
673
+ function isExecutableFile(path: string): boolean {
674
+ try {
675
+ accessSync(path, constants.X_OK);
676
+ return true;
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+
682
+ function realPath(path: string): string {
683
+ try {
684
+ return realpathSync(path);
685
+ } catch {
686
+ return path;
687
+ }
688
+ }
689
+
690
+ function isAgentRelayCodexShim(path: string): boolean {
691
+ return path.includes("/.agent-relay/codex/bin/codex");
692
+ }
693
+
105
694
  export function bundledCodexSkillDirs(baseDir = resolve(import.meta.dir, "../../plugins/codex/skills")): string[] {
106
695
  if (!existsSync(baseDir)) return [];
107
696
  return readdirSync(baseDir, { withFileTypes: true })
@@ -142,6 +731,9 @@ function tomlString(value: string): string {
142
731
  }
143
732
 
144
733
  async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<void> {
734
+ // Give the freshly-spawned app-server a moment to bind its socket before the
735
+ // first attempt, so we don't spend the retry budget on guaranteed refusals.
736
+ await Bun.sleep(150);
145
737
  let lastError: unknown;
146
738
  for (let i = 0; i < attempts; i++) {
147
739
  try {
@@ -221,6 +813,23 @@ function killPid(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
221
813
  } catch {}
222
814
  }
223
815
 
816
+ function codexRelayContextEnabled(process: ManagedProcess): boolean {
817
+ const config = process.meta?.config as RunnerSpawnConfig | undefined;
818
+ return config ? profileAllowsRelayFeature(config, "context") : true;
819
+ }
820
+
821
+ function codexLaunchContext(process: ManagedProcess): string | undefined {
822
+ const config = process.meta?.config as RunnerSpawnConfig | undefined;
823
+ const text = config?.systemPromptAppend?.trim();
824
+ if (!text || process.meta?.systemPromptAppendSent) return undefined;
825
+ process.meta = { ...(process.meta ?? {}), systemPromptAppendSent: true };
826
+ return [
827
+ "[agent-relay launch context]",
828
+ "The following context was attached when this provider session was launched. Treat it as background context, not as a user request and not as your own prior actions.",
829
+ text,
830
+ ].join("\n");
831
+ }
832
+
224
833
  function isPidAlive(pid: number): boolean {
225
834
  try {
226
835
  process.kill(pid, 0);