@fitlab-ai/agent-infra 0.9.4 → 0.9.6

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 (130) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +13 -3
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +492 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +175 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +255 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +3 -3
  9. package/dist/lib/git/workflow.js +0 -1
  10. package/dist/lib/git/worktree-identity.js +64 -0
  11. package/dist/lib/internal/codex-lifecycle.js +300 -0
  12. package/dist/lib/internal/release-workflow.js +108 -11
  13. package/dist/lib/internal/sandbox-control.js +43 -6
  14. package/dist/lib/internal/task-create.js +56 -0
  15. package/dist/lib/internal/task-orchestration.js +71 -31
  16. package/dist/lib/platform/issue-comments.js +2 -1
  17. package/dist/lib/release/workflow.js +29 -21
  18. package/dist/lib/sandbox/commands/create.js +16 -16
  19. package/dist/lib/sandbox/commands/prune.js +40 -16
  20. package/dist/lib/sandbox/commands/rm.js +318 -89
  21. package/dist/lib/sandbox/control/client.js +152 -22
  22. package/dist/lib/sandbox/control/executor.js +136 -0
  23. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  24. package/dist/lib/sandbox/control/protocol.js +73 -18
  25. package/dist/lib/sandbox/control/server.js +235 -80
  26. package/dist/lib/sandbox/control/state.js +181 -0
  27. package/dist/lib/sandbox/index.js +2 -2
  28. package/dist/lib/sandbox/managed-fs.js +14 -2
  29. package/dist/lib/sandbox/mounts.js +35 -24
  30. package/dist/lib/sandbox/recovery.js +262 -45
  31. package/dist/lib/sandbox/workspace-view.js +80 -11
  32. package/dist/lib/sandbox/worktree-safety.js +196 -0
  33. package/dist/lib/server/process-control.js +6 -0
  34. package/dist/lib/task/activity-log.js +32 -5
  35. package/dist/lib/task/artifact-lifecycle.js +5 -4
  36. package/dist/lib/task/codex-orchestration.js +161 -0
  37. package/dist/lib/task/commands/status.js +3 -0
  38. package/dist/lib/task/commands/validate.js +289 -0
  39. package/dist/lib/task/commit-finalization.js +50 -12
  40. package/dist/lib/task/create-service.js +137 -0
  41. package/dist/lib/task/create.js +333 -0
  42. package/dist/lib/task/delegation-receipts.js +25 -0
  43. package/dist/lib/task/events.js +10 -4
  44. package/dist/lib/task/finalization-artifacts.js +2 -1
  45. package/dist/lib/task/implementation-inputs.js +1 -7
  46. package/dist/lib/task/index.js +9 -0
  47. package/dist/lib/task/orchestration.js +283 -18
  48. package/dist/lib/task/review-fingerprint.js +4 -0
  49. package/dist/lib/task/verification-engine.js +38 -6
  50. package/dist/lib/task/verification.js +1 -0
  51. package/dist/lib/task/workspace-snapshot.js +78 -3
  52. package/lib/agent-clients/adapter.ts +4 -4
  53. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +569 -0
  54. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +323 -0
  55. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +298 -0
  56. package/lib/agent-clients/adapters/codex.ts +3 -3
  57. package/lib/git/workflow.ts +0 -1
  58. package/lib/git/worktree-identity.ts +86 -0
  59. package/lib/internal/codex-lifecycle.ts +308 -0
  60. package/lib/internal/release-workflow.ts +107 -11
  61. package/lib/internal/sandbox-control.ts +38 -6
  62. package/lib/internal/task-create.ts +61 -0
  63. package/lib/internal/task-orchestration.ts +57 -31
  64. package/lib/platform/issue-comments.ts +2 -1
  65. package/lib/release/workflow.ts +69 -20
  66. package/lib/sandbox/commands/create.ts +18 -21
  67. package/lib/sandbox/commands/prune.ts +53 -15
  68. package/lib/sandbox/commands/rm.ts +368 -103
  69. package/lib/sandbox/control/client.ts +173 -25
  70. package/lib/sandbox/control/executor.ts +163 -0
  71. package/lib/sandbox/control/lifecycle.ts +342 -0
  72. package/lib/sandbox/control/protocol.ts +110 -48
  73. package/lib/sandbox/control/server.ts +252 -85
  74. package/lib/sandbox/control/state.ts +196 -0
  75. package/lib/sandbox/index.ts +2 -2
  76. package/lib/sandbox/managed-fs.ts +17 -2
  77. package/lib/sandbox/mounts.ts +31 -13
  78. package/lib/sandbox/recovery.ts +301 -60
  79. package/lib/sandbox/workspace-view.ts +89 -12
  80. package/lib/sandbox/worktree-safety.ts +234 -0
  81. package/lib/server/process-control.ts +11 -0
  82. package/lib/task/activity-log.ts +46 -7
  83. package/lib/task/artifact-lifecycle.ts +5 -4
  84. package/lib/task/codex-orchestration.ts +233 -0
  85. package/lib/task/commands/status.ts +2 -0
  86. package/lib/task/commands/validate.ts +282 -0
  87. package/lib/task/commit-finalization.ts +75 -12
  88. package/lib/task/create-service.ts +171 -0
  89. package/lib/task/create.ts +372 -0
  90. package/lib/task/delegation-receipts.ts +52 -3
  91. package/lib/task/events.ts +9 -4
  92. package/lib/task/finalization-artifacts.ts +2 -1
  93. package/lib/task/implementation-inputs.ts +1 -5
  94. package/lib/task/index.ts +8 -0
  95. package/lib/task/orchestration.ts +381 -22
  96. package/lib/task/review-fingerprint.ts +9 -0
  97. package/lib/task/verification-engine.ts +42 -8
  98. package/lib/task/verification.ts +2 -1
  99. package/lib/task/workspace-snapshot.ts +98 -4
  100. package/package.json +7 -7
  101. package/templates/.agents/hooks/lifecycle-delegation.js +88 -8
  102. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  103. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  104. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  105. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  106. package/templates/.agents/rules/task-management.en.md +1 -0
  107. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  108. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  110. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  111. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  112. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  113. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  114. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  115. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  116. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  117. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  118. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  119. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  120. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  121. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  122. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  123. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  124. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  125. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  126. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  127. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  128. package/templates/.codex/hooks.json +50 -0
  129. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  130. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -39,8 +39,8 @@ type AgentClientModelSelectionContext =
39
39
  }>;
40
40
 
41
41
  type AgentClientDelegationEvidence = Readonly<{
42
- actualModel: 'host-event' | 'unavailable';
43
- actualReasoningEffort: 'host-event' | 'spawn-ack' | 'unavailable';
42
+ actualModel: 'host-event' | 'app-server' | 'unavailable';
43
+ actualReasoningEffort: 'host-event' | 'app-server' | 'spawn-ack' | 'unavailable';
44
44
  }>;
45
45
 
46
46
  type AgentClientSeedCommand = Readonly<{
@@ -331,8 +331,8 @@ function defineAgentClientAdapter(
331
331
  const evidence = candidate.delegationEvidence;
332
332
  if (
333
333
  !evidence
334
- || !['host-event', 'unavailable'].includes(evidence.actualModel)
335
- || !['host-event', 'spawn-ack', 'unavailable'].includes(evidence.actualReasoningEffort)
334
+ || !['host-event', 'app-server', 'unavailable'].includes(evidence.actualModel)
335
+ || !['host-event', 'app-server', 'spawn-ack', 'unavailable'].includes(evidence.actualReasoningEffort)
336
336
  ) {
337
337
  throw new Error(`Agent Client '${candidate.id}' has invalid delegation evidence`);
338
338
  }
@@ -0,0 +1,569 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import semver from 'semver';
7
+
8
+ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
9
+ import type { CodexLifecycleEvent } from './evidence.ts';
10
+
11
+ type JsonObject = Record<string, unknown>;
12
+ type ThreadResolution = Readonly<{
13
+ thread: Extract<CodexLifecycleEvent, { type: 'app-thread' }>;
14
+ settings: Extract<CodexLifecycleEvent, { type: 'app-settings' }>;
15
+ }>;
16
+
17
+ type DiscoveredHook = Readonly<{
18
+ eventName: string;
19
+ matcher: string | null;
20
+ command: string;
21
+ enabled: boolean;
22
+ }>;
23
+
24
+ type AppServerTransportOptions = Readonly<{
25
+ command?: string;
26
+ args?: readonly string[];
27
+ cwd?: string;
28
+ env?: NodeJS.ProcessEnv;
29
+ timeoutMs?: number;
30
+ rolloutReadAttempts?: number;
31
+ rolloutRetryMs?: number;
32
+ }>;
33
+
34
+ type CodexRuntimeIdentity = Readonly<{
35
+ sessionId: string;
36
+ turnId: string;
37
+ toolUseId: string;
38
+ }>;
39
+
40
+ const LIFECYCLE_HOOKS = Object.freeze([
41
+ Object.freeze({ event: 'PreToolUse', matcher: '^collaborationspawn_agent$', phase: 'pre-tool' }),
42
+ Object.freeze({ event: 'PostToolUse', matcher: '', phase: 'post-tool' }),
43
+ Object.freeze({ event: 'SubagentStart', matcher: '', phase: 'subagent-start' }),
44
+ Object.freeze({ event: 'SubagentStop', matcher: '', phase: 'subagent-stop' })
45
+ ]);
46
+ const DEFAULT_ROLLOUT_READ_ATTEMPTS = 81;
47
+ const DEFAULT_ROLLOUT_RETRY_MS = 100;
48
+
49
+ function object(value: unknown): JsonObject | null {
50
+ return value && typeof value === 'object' && !Array.isArray(value)
51
+ ? value as JsonObject
52
+ : null;
53
+ }
54
+
55
+ function nonEmpty(value: unknown): string | null {
56
+ return typeof value === 'string' && value.trim() ? value : null;
57
+ }
58
+
59
+ function resolveCodexSpawnedChild(
60
+ transcriptPath: string,
61
+ expected: Readonly<{
62
+ sessionId: string;
63
+ toolUseId: string;
64
+ nativeAgent: string;
65
+ taskName: string;
66
+ requestedModel?: string;
67
+ requestedReasoningEffort?: string;
68
+ }>
69
+ ): string {
70
+ const name = path.basename(transcriptPath);
71
+ if (!transcriptPath || (!name.endsWith(`-${expected.sessionId}.jsonl`) && name !== `${expected.sessionId}.jsonl`)) {
72
+ throw new Error('Codex parent rollout path is invalid');
73
+ }
74
+ const stat = fs.lstatSync(transcriptPath);
75
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Codex parent rollout is not a regular file');
76
+ const maxBytes = 8 * 1024 * 1024;
77
+ const offset = Math.max(0, stat.size - maxBytes);
78
+ const buffer = Buffer.alloc(stat.size - offset);
79
+ const descriptor = fs.openSync(transcriptPath, 'r');
80
+ try {
81
+ fs.readSync(descriptor, buffer, 0, buffer.length, offset);
82
+ } finally {
83
+ fs.closeSync(descriptor);
84
+ }
85
+ let text = buffer.toString('utf8');
86
+ if (offset) text = text.slice(text.indexOf('\n') + 1);
87
+ const records = text.split(/\r?\n/).filter(Boolean).map((line) => object(JSON.parse(line))).filter(Boolean);
88
+ const calls = records.filter((record) => {
89
+ const payload = object(record?.payload);
90
+ return record?.type === 'response_item'
91
+ && payload?.type === 'function_call'
92
+ && payload.call_id === expected.toolUseId
93
+ && payload.namespace === 'collaboration'
94
+ && payload.name === 'spawn_agent';
95
+ });
96
+ if (calls.length !== 1) throw new Error('Codex parent rollout spawn call was not found uniquely');
97
+ const call = object(calls[0]?.payload);
98
+ const args = object(JSON.parse(nonEmpty(call?.arguments) ?? '{}'));
99
+ if (
100
+ args?.agent_type !== expected.nativeAgent
101
+ || args.task_name !== expected.taskName
102
+ || (expected.requestedModel && args.model !== expected.requestedModel)
103
+ || (expected.requestedReasoningEffort && args.reasoning_effort !== expected.requestedReasoningEffort)
104
+ ) throw new Error('Codex parent rollout spawn identity does not match the hook event');
105
+ const activities = records.filter((record) => {
106
+ const payload = object(record?.payload);
107
+ return record?.type === 'event_msg'
108
+ && payload?.type === 'sub_agent_activity'
109
+ && payload.event_id === expected.toolUseId
110
+ && payload.kind === 'started'
111
+ && payload.agent_path === `/root/${expected.taskName}`
112
+ && nonEmpty(payload.agent_thread_id);
113
+ });
114
+ if (activities.length !== 1) throw new Error('Codex parent rollout child activity was not found uniquely');
115
+ return nonEmpty(object(activities[0]?.payload)?.agent_thread_id)!;
116
+ }
117
+
118
+ function validateCodexLifecycleHookConfig(value: unknown): void {
119
+ const hooks = object(object(value)?.hooks);
120
+ const valid = hooks && LIFECYCLE_HOOKS.every(({ event, matcher, phase }) => {
121
+ const entries = hooks[event];
122
+ if (!Array.isArray(entries)) return false;
123
+ return entries.some((entryValue) => {
124
+ const entry = object(entryValue);
125
+ if (entry?.matcher !== matcher || !Array.isArray(entry.hooks)) return false;
126
+ return entry.hooks.some((hookValue) => {
127
+ const hook = object(hookValue);
128
+ return hook?.type === 'command'
129
+ && hook.timeout === 15
130
+ && hook.command === `node "$(git rev-parse --show-toplevel)/.agents/hooks/lifecycle-delegation.js" --client codex --event ${phase}`;
131
+ });
132
+ });
133
+ });
134
+ if (!valid) throw new Error('CODEX_PREFLIGHT_HOOKS_INVALID: lifecycle hooks are invalid');
135
+ }
136
+
137
+ function parseCodexHooksList(value: unknown, repoRoot: string): readonly DiscoveredHook[] {
138
+ const root = object(value);
139
+ const entries = Array.isArray(root?.data) ? root.data : [];
140
+ const entry = entries.map(object).find((candidate) => candidate?.cwd === repoRoot);
141
+ const errors = Array.isArray(entry?.errors) ? entry.errors : [];
142
+ if (errors.length) throw new Error(`CODEX_PREFLIGHT_HOOK_DISCOVERY_FAILED: ${errors.map(String).join('; ')}`);
143
+ const hooks = (Array.isArray(entry?.hooks) ? entry.hooks : [])
144
+ .map(object)
145
+ .filter((hook): hook is JsonObject => hook !== null)
146
+ .map((hook) => Object.freeze({
147
+ eventName: String(hook.eventName ?? ''),
148
+ matcher: typeof hook.matcher === 'string' ? hook.matcher : null,
149
+ command: String(hook.command ?? ''),
150
+ enabled: hook.enabled === true
151
+ }));
152
+ const valid = LIFECYCLE_HOOKS.every(({ event, matcher, phase }) => hooks.some((hook) =>
153
+ hook.eventName === event.replace(/^./, (character) => character.toLowerCase())
154
+ && hook.matcher === matcher
155
+ && hook.command === `node "$(git rev-parse --show-toplevel)/.agents/hooks/lifecycle-delegation.js" --client codex --event ${phase}`
156
+ && hook.enabled
157
+ ));
158
+ if (!valid) throw new Error('CODEX_PREFLIGHT_HOOKS_NOT_LOADED: lifecycle hooks are not loaded for this workspace');
159
+ return Object.freeze(hooks);
160
+ }
161
+
162
+ function hasCodexRuntimeLiveness(
163
+ runtimeRoot: string,
164
+ hookDefinitionHash: string,
165
+ identity?: CodexRuntimeIdentity
166
+ ): boolean {
167
+ if (!identity || !fs.existsSync(runtimeRoot)) return false;
168
+ return fs.readdirSync(runtimeRoot)
169
+ .filter((name) => /^[a-f0-9]{64}\.json$/.test(name))
170
+ .some((name) => {
171
+ try {
172
+ const record = object(JSON.parse(fs.readFileSync(path.join(runtimeRoot, name), 'utf8')));
173
+ const state = object(record?.state);
174
+ const spawnEvent = object(state?.spawn);
175
+ return !['invalid', 'expired'].includes(String(state?.status))
176
+ && spawnEvent?.hookDefinitionHash === hookDefinitionHash
177
+ && spawnEvent.sessionId === identity.sessionId
178
+ && spawnEvent.turnId === identity.turnId
179
+ && spawnEvent.toolUseId === identity.toolUseId;
180
+ } catch {
181
+ return false;
182
+ }
183
+ });
184
+ }
185
+
186
+ function parseCodexThreadResolution(readValue: unknown, rolloutRecords: readonly unknown[]): ThreadResolution {
187
+ const readRoot = object(readValue);
188
+ const thread = object(readRoot?.thread);
189
+ const source = object(thread?.source);
190
+ const subAgent = object(source?.subAgent);
191
+ const threadSpawn = object(subAgent?.thread_spawn);
192
+ const sessionMeta = rolloutRecords
193
+ .map(object)
194
+ .find((record) => record?.type === 'session_meta');
195
+ const sessionPayload = object(sessionMeta?.payload);
196
+ const turnContext = rolloutRecords
197
+ .map(object)
198
+ .filter((record) => record?.type === 'turn_context')
199
+ .at(-1);
200
+ const turnPayload = object(turnContext?.payload);
201
+ const childThreadId = nonEmpty(thread?.id);
202
+ const parentThreadId = nonEmpty(thread?.parentThreadId);
203
+ const sourceParentThreadId = nonEmpty(threadSpawn?.parent_thread_id);
204
+ const rolloutChildThreadId = nonEmpty(sessionPayload?.id);
205
+ const rolloutParentThreadId = nonEmpty(sessionPayload?.parent_thread_id);
206
+ const agentRole = nonEmpty(sessionPayload?.agent_role);
207
+ const model = nonEmpty(turnPayload?.model);
208
+ const reasoningEffort = nonEmpty(turnPayload?.effort);
209
+ const forkedFromId = thread?.forkedFromId;
210
+ if (
211
+ !childThreadId
212
+ || rolloutChildThreadId !== childThreadId
213
+ || !parentThreadId
214
+ || !sourceParentThreadId
215
+ || rolloutParentThreadId !== parentThreadId
216
+ || rolloutParentThreadId !== sourceParentThreadId
217
+ || !/^agent-infra-lifecycle-(executor|reviewer)$/.test(agentRole ?? '')
218
+ || !model
219
+ || !reasoningEffort
220
+ || (forkedFromId !== null && typeof forkedFromId !== 'string')
221
+ ) {
222
+ throw new Error('Codex App Server thread resolution is invalid');
223
+ }
224
+ return Object.freeze({
225
+ thread: Object.freeze({
226
+ type: 'app-thread',
227
+ childThreadId,
228
+ parentThreadId,
229
+ forkedFromId,
230
+ sourceParentThreadId,
231
+ nativeAgent: agentRole!
232
+ }),
233
+ settings: Object.freeze({
234
+ type: 'app-settings',
235
+ childThreadId,
236
+ model,
237
+ reasoningEffort
238
+ })
239
+ });
240
+ }
241
+
242
+ function readCodexRolloutRecords(readValue: unknown, childThreadId: string): readonly unknown[] {
243
+ const thread = object(object(readValue)?.thread);
244
+ const rolloutPath = nonEmpty(thread?.path);
245
+ const rolloutName = rolloutPath ? path.basename(rolloutPath) : '';
246
+ if (!rolloutPath || (rolloutName !== `${childThreadId}.jsonl` && !rolloutName.endsWith(`-${childThreadId}.jsonl`))) {
247
+ throw new Error('Codex App Server rollout path is invalid');
248
+ }
249
+ const stat = fs.statSync(rolloutPath);
250
+ if (!stat.isFile()) throw new Error('Codex App Server rollout path is not a regular file');
251
+ const maxBytes = 1024 * 1024;
252
+ const bytes = Math.min(stat.size, maxBytes);
253
+ const descriptor = fs.openSync(rolloutPath, 'r');
254
+ try {
255
+ const buffer = Buffer.alloc(bytes);
256
+ fs.readSync(descriptor, buffer, 0, bytes, 0);
257
+ const text = buffer.toString('utf8');
258
+ const completeText = stat.size <= maxBytes ? text : text.slice(0, text.lastIndexOf('\n'));
259
+ return Object.freeze(completeText.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)));
260
+ } finally {
261
+ fs.closeSync(descriptor);
262
+ }
263
+ }
264
+
265
+ async function resolveCodexRolloutRecords(
266
+ readValue: unknown,
267
+ childThreadId: string,
268
+ attempts: number,
269
+ retryMs: number
270
+ ): Promise<readonly unknown[]> {
271
+ let lastError: unknown;
272
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
273
+ try {
274
+ const records = readCodexRolloutRecords(readValue, childThreadId);
275
+ parseCodexThreadResolution(readValue, records);
276
+ return records;
277
+ } catch (error) {
278
+ lastError = error;
279
+ if (attempt + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, retryMs));
280
+ }
281
+ }
282
+ throw lastError;
283
+ }
284
+
285
+ function parseCodexTurnCompleted(value: unknown): Extract<CodexLifecycleEvent, { type: 'app-terminal' }> {
286
+ const root = object(value);
287
+ const turn = object(root?.turn);
288
+ const childThreadId = nonEmpty(root?.threadId);
289
+ const turnId = nonEmpty(turn?.id);
290
+ const status = turn?.status;
291
+ if (!childThreadId || !turnId || !['completed', 'interrupted', 'failed'].includes(String(status))) {
292
+ throw new Error('Codex App Server turn completion is invalid');
293
+ }
294
+ return Object.freeze({
295
+ type: 'app-terminal',
296
+ childThreadId,
297
+ turnId,
298
+ status: status as 'completed' | 'interrupted' | 'failed'
299
+ });
300
+ }
301
+
302
+ function parseCodexModelReroute(value: unknown): Extract<CodexLifecycleEvent, { type: 'app-reroute' }> {
303
+ const root = object(value);
304
+ const childThreadId = nonEmpty(root?.threadId);
305
+ const turnId = nonEmpty(root?.turnId);
306
+ const fromModel = nonEmpty(root?.fromModel);
307
+ const toModel = nonEmpty(root?.toModel);
308
+ const reasonValue = root?.reason;
309
+ const reason = nonEmpty(reasonValue) ?? (object(reasonValue) ? JSON.stringify(reasonValue) : null);
310
+ if (!childThreadId || !turnId || !fromModel || !toModel || !reason) {
311
+ throw new Error('Codex App Server model reroute is invalid');
312
+ }
313
+ return Object.freeze({ type: 'app-reroute', childThreadId, turnId, fromModel, toModel, reason });
314
+ }
315
+
316
+ class CodexAppServerTransport {
317
+ readonly #options: Required<Pick<AppServerTransportOptions, 'command' | 'args' | 'timeoutMs'>> & AppServerTransportOptions;
318
+ #child: ChildProcessWithoutNullStreams | null = null;
319
+ #buffer = '';
320
+ #stderr = '';
321
+ #nextId = 1;
322
+ #pending = new Map<number, Readonly<{
323
+ resolve: (value: unknown) => void;
324
+ reject: (error: Error) => void;
325
+ timer: NodeJS.Timeout;
326
+ }>>();
327
+ #notifications: Readonly<{ method: string; params: unknown }>[] = [];
328
+
329
+ constructor(options: AppServerTransportOptions = {}) {
330
+ this.#options = {
331
+ ...options,
332
+ command: options.command ?? 'codex',
333
+ args: options.args ?? ['app-server', '--stdio'],
334
+ timeoutMs: options.timeoutMs ?? 5_000
335
+ };
336
+ }
337
+
338
+ get diagnostics(): readonly string[] {
339
+ return Object.freeze(this.#stderr.split(/\r?\n/).filter(Boolean));
340
+ }
341
+
342
+ get notifications(): readonly Readonly<{ method: string; params: unknown }>[] {
343
+ return Object.freeze([...this.#notifications]);
344
+ }
345
+
346
+ start(): void {
347
+ if (this.#child) throw new Error('Codex App Server transport is already started');
348
+ const child = spawn(this.#options.command, [...this.#options.args], {
349
+ cwd: this.#options.cwd,
350
+ env: this.#options.env,
351
+ stdio: ['pipe', 'pipe', 'pipe']
352
+ });
353
+ this.#child = child;
354
+ child.stdout.setEncoding('utf8');
355
+ child.stderr.setEncoding('utf8');
356
+ child.stdout.on('data', (chunk: string) => this.#onData(chunk));
357
+ child.stderr.on('data', (chunk: string) => {
358
+ this.#stderr = `${this.#stderr}${chunk}`.slice(-32 * 1024);
359
+ });
360
+ child.on('error', (error) => this.#rejectAll(error));
361
+ child.on('exit', (code, signal) => {
362
+ if (this.#pending.size) {
363
+ this.#rejectAll(new Error(`Codex App Server exited before responding (code=${code}, signal=${signal})`));
364
+ }
365
+ });
366
+ }
367
+
368
+ #onData(chunk: string): void {
369
+ this.#buffer += chunk;
370
+ for (;;) {
371
+ const newline = this.#buffer.indexOf('\n');
372
+ if (newline < 0) return;
373
+ const line = this.#buffer.slice(0, newline).trim();
374
+ this.#buffer = this.#buffer.slice(newline + 1);
375
+ if (!line) continue;
376
+ let message: JsonObject;
377
+ try {
378
+ const parsed = object(JSON.parse(line));
379
+ if (!parsed) throw new Error('message is not an object');
380
+ message = parsed;
381
+ } catch (error) {
382
+ this.#rejectAll(new Error(`Codex App Server returned invalid JSON: ${String(error)}`));
383
+ continue;
384
+ }
385
+ if (typeof message.id === 'number') {
386
+ const pending = this.#pending.get(message.id);
387
+ if (!pending) continue;
388
+ clearTimeout(pending.timer);
389
+ this.#pending.delete(message.id);
390
+ if (message.error) pending.reject(new Error(`Codex App Server request failed: ${JSON.stringify(message.error)}`));
391
+ else pending.resolve(message.result);
392
+ } else if (typeof message.method === 'string') {
393
+ this.#notifications.push(Object.freeze({ method: message.method, params: message.params }));
394
+ }
395
+ }
396
+ }
397
+
398
+ #rejectAll(error: Error): void {
399
+ for (const [id, pending] of this.#pending) {
400
+ clearTimeout(pending.timer);
401
+ this.#pending.delete(id);
402
+ pending.reject(error);
403
+ }
404
+ }
405
+
406
+ request(method: string, params: JsonObject): Promise<unknown> {
407
+ if (!this.#child || !this.#child.stdin.writable) {
408
+ return Promise.reject(new Error('Codex App Server transport is not running'));
409
+ }
410
+ const id = this.#nextId++;
411
+ return new Promise((resolve, reject) => {
412
+ const timer = setTimeout(() => {
413
+ this.#pending.delete(id);
414
+ reject(new Error(`Codex App Server request '${method}' timed out`));
415
+ }, this.#options.timeoutMs);
416
+ this.#pending.set(id, { resolve, reject, timer });
417
+ this.#child!.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
418
+ });
419
+ }
420
+
421
+ notify(method: string, params: JsonObject = {}): void {
422
+ if (!this.#child || !this.#child.stdin.writable) throw new Error('Codex App Server transport is not running');
423
+ this.#child.stdin.write(`${JSON.stringify({ method, params })}\n`);
424
+ }
425
+
426
+ async initialize(): Promise<unknown> {
427
+ const result = await this.request('initialize', {
428
+ clientInfo: { name: 'agent-infra', title: 'agent-infra', version: '0.9.5' },
429
+ capabilities: null
430
+ });
431
+ this.notify('initialized');
432
+ return result;
433
+ }
434
+
435
+ close(): void {
436
+ if (!this.#child) return;
437
+ this.#child.stdin.end();
438
+ this.#child.kill();
439
+ this.#child = null;
440
+ }
441
+ }
442
+
443
+ async function resolveCodexThread(
444
+ childThreadId: string,
445
+ options: AppServerTransportOptions = {}
446
+ ): Promise<Readonly<{ resolution: ThreadResolution; reroutes: readonly Extract<CodexLifecycleEvent, { type: 'app-reroute' }>[]; diagnostics: readonly string[] }>> {
447
+ if (!nonEmpty(childThreadId)) throw new Error('Codex child thread id is required');
448
+ const transport = new CodexAppServerTransport(options);
449
+ transport.start();
450
+ try {
451
+ await transport.initialize();
452
+ const readResult = object(await transport.request('thread/read', { threadId: childThreadId, includeTurns: false }));
453
+ const readThread = object(readResult?.thread);
454
+ if (nonEmpty(readThread?.id) !== childThreadId) throw new Error('Codex App Server returned the wrong child thread');
455
+ let resolution: ThreadResolution;
456
+ try {
457
+ const rolloutRecords = await resolveCodexRolloutRecords(
458
+ readResult,
459
+ childThreadId,
460
+ options.rolloutReadAttempts ?? DEFAULT_ROLLOUT_READ_ATTEMPTS,
461
+ options.rolloutRetryMs ?? DEFAULT_ROLLOUT_RETRY_MS
462
+ );
463
+ resolution = parseCodexThreadResolution(readResult, rolloutRecords);
464
+ } catch {
465
+ throw new Error('Codex App Server rollout metadata is unavailable');
466
+ }
467
+ const reroutes = transport.notifications
468
+ .filter((entry) => entry.method === 'model/rerouted')
469
+ .map((entry) => parseCodexModelReroute(entry.params))
470
+ .filter((event) => event.childThreadId === childThreadId);
471
+ return Object.freeze({ resolution, reroutes: Object.freeze(reroutes), diagnostics: transport.diagnostics });
472
+ } finally {
473
+ try {
474
+ await transport.request('thread/unsubscribe', { threadId: childThreadId });
475
+ } catch {
476
+ // Closing the short-lived transport still removes the subscription.
477
+ }
478
+ transport.close();
479
+ }
480
+ }
481
+
482
+ async function resolveCodexTerminal(
483
+ childThreadId: string,
484
+ options: AppServerTransportOptions = {}
485
+ ): Promise<Extract<CodexLifecycleEvent, { type: 'app-terminal' }>> {
486
+ const transport = new CodexAppServerTransport(options);
487
+ transport.start();
488
+ try {
489
+ await transport.initialize();
490
+ const result = object(await transport.request('thread/read', { threadId: childThreadId, includeTurns: true }));
491
+ const thread = object(result?.thread);
492
+ if (nonEmpty(thread?.id) !== childThreadId) throw new Error('Codex App Server returned the wrong child thread');
493
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
494
+ const turn = turns.at(-1);
495
+ const turnValue = object(turn);
496
+ if (!turnValue || turnValue.status === 'inProgress') throw new Error('CODEX_TURN_NOT_TERMINAL');
497
+ return parseCodexTurnCompleted({ threadId: childThreadId, turn });
498
+ } finally {
499
+ transport.close();
500
+ }
501
+ }
502
+
503
+ async function preflightCodexLifecycleEvidence(
504
+ repoRoot: string = process.cwd(),
505
+ runtimeIdentity?: CodexRuntimeIdentity
506
+ ) {
507
+ const versionRun = spawnSync('codex', ['--version'], { encoding: 'utf8' });
508
+ const match = /codex-cli\s+(\d+\.\d+\.\d+)/.exec(versionRun.stdout ?? '');
509
+ if (versionRun.status !== 0 || !match?.[1] || !semver.gte(match[1], '0.147.0')) {
510
+ throw new Error('CODEX_PREFLIGHT_VERSION_UNSUPPORTED');
511
+ }
512
+ const featuresRun = spawnSync('codex', ['features', 'list'], { encoding: 'utf8' });
513
+ if (
514
+ featuresRun.status !== 0
515
+ || !/^hooks\s+\S+\s+true$/m.test(featuresRun.stdout)
516
+ || !/^multi_agent\s+\S+\s+true$/m.test(featuresRun.stdout)
517
+ ) throw new Error('CODEX_PREFLIGHT_FEATURE_DISABLED');
518
+
519
+ const hooksPath = path.join(repoRoot, '.codex', 'hooks.json');
520
+ const hooksRaw = fs.readFileSync(hooksPath, 'utf8');
521
+ validateCodexLifecycleHookConfig(JSON.parse(hooksRaw));
522
+
523
+ const schemaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-codex-schema-'));
524
+ try {
525
+ const schemaRun = spawnSync('codex', ['app-server', 'generate-json-schema', '--out', schemaDir], { encoding: 'utf8' });
526
+ if (schemaRun.status !== 0) throw new Error('CODEX_PREFLIGHT_SCHEMA_UNAVAILABLE');
527
+ const schema = fs.readFileSync(path.join(schemaDir, 'codex_app_server_protocol.v2.schemas.json'), 'utf8');
528
+ for (const field of ['parentThreadId', 'forkedFromId', 'ThreadSettings', 'turn/completed', 'model/rerouted']) {
529
+ if (!schema.includes(field)) throw new Error(`CODEX_PREFLIGHT_SCHEMA_MISSING:${field}`);
530
+ }
531
+ } finally {
532
+ fs.rmSync(schemaDir, { recursive: true, force: true });
533
+ }
534
+
535
+ const hookDefinitionHash = crypto.createHash('sha256').update(hooksRaw).digest('hex');
536
+ const runtimeRoot = path.join(repoRoot, '.agents', 'workspace', '.runtime', 'codex-lifecycle');
537
+ const runtimeLiveness = hasCodexRuntimeLiveness(runtimeRoot, hookDefinitionHash, runtimeIdentity);
538
+
539
+ const transport = new CodexAppServerTransport();
540
+ transport.start();
541
+ let discoveredHooks: readonly DiscoveredHook[];
542
+ try {
543
+ await transport.initialize();
544
+ discoveredHooks = parseCodexHooksList(await transport.request('hooks/list', { cwds: [repoRoot] }), repoRoot);
545
+ } finally { transport.close(); }
546
+ return Object.freeze({
547
+ cliVersion: match[1],
548
+ hookDefinitionHash,
549
+ staticReady: true,
550
+ discoveredHooks,
551
+ runtimeLiveness,
552
+ diagnostics: transport.diagnostics
553
+ });
554
+ }
555
+
556
+ export {
557
+ CodexAppServerTransport,
558
+ hasCodexRuntimeLiveness,
559
+ parseCodexModelReroute,
560
+ parseCodexHooksList,
561
+ parseCodexThreadResolution,
562
+ parseCodexTurnCompleted,
563
+ resolveCodexSpawnedChild,
564
+ preflightCodexLifecycleEvidence,
565
+ resolveCodexTerminal,
566
+ resolveCodexThread,
567
+ validateCodexLifecycleHookConfig
568
+ };
569
+ export type { AppServerTransportOptions, CodexRuntimeIdentity, ThreadResolution };