holo-codex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,809 @@
1
+ import { spawn } from "node:child_process";
2
+ import { execFileSync } from "node:child_process";
3
+ import { randomUUID, createHash } from "node:crypto";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { evaluatePolicy } from "./command-runner.js";
7
+ import { isRecord } from "./config.js";
8
+ import { writeArtifact } from "./artifacts.js";
9
+ import { AgentLoopError } from "./errors.js";
10
+ import { PR_LOOP_SHAPE } from "./loop-shapes.js";
11
+ import { resolveProfile } from "./profiles.js";
12
+ import { buildWorkerPrompt, workerSandbox } from "./worker-prompts.js";
13
+ import { resolveWorkerPolicy } from "./worker-policy.js";
14
+ import { createWorkerJsonlStreamIngestor } from "./worker-events.js";
15
+ import { captureScopeBaseline, evaluateWorkerScope } from "./scope-guard.js";
16
+ import type { AgentLoopState, ArtifactRecord } from "./state-types.js";
17
+ import type {
18
+ AgentLoopConfig,
19
+ AgentLoopRun,
20
+ AgentLoopStorage,
21
+ ScopeGuardReport,
22
+ WorkerCommandPlan,
23
+ WorkerResult,
24
+ WorkerRun,
25
+ WorkerType
26
+ } from "./types.js";
27
+
28
+ export interface WorkerExecutionResult {
29
+ worker: WorkerRun;
30
+ result?: WorkerResult;
31
+ scope?: ScopeGuardReport;
32
+ artifacts: ArtifactRecord[];
33
+ commandPlan: WorkerCommandPlan;
34
+ }
35
+
36
+ /** Execute or dry-run a delegated Codex worker for one state-machine state. */
37
+ export async function executeWorker(input: {
38
+ repoRoot: string;
39
+ storage: AgentLoopStorage;
40
+ run: AgentLoopRun;
41
+ config: AgentLoopConfig;
42
+ state: AgentLoopState;
43
+ type: WorkerType;
44
+ dryRun: boolean;
45
+ context?: unknown;
46
+ signal?: AbortSignal | undefined;
47
+ }): Promise<WorkerExecutionResult> {
48
+ if (input.config.workerBackend === "codex-app-server") {
49
+ const probe = await probeCodexAppServer(input.repoRoot, input.config.workerTimeoutMs);
50
+ const probeArtifact = writeArtifact(
51
+ input.repoRoot,
52
+ input.storage,
53
+ input.run.id,
54
+ "log",
55
+ "codex-app-server-probe.json",
56
+ `${JSON.stringify(probe, null, 2)}\n`
57
+ );
58
+ const code = probe.status === "success" ? "worker_failed" : "required_tool_unavailable";
59
+ const message = probe.status === "success"
60
+ ? "codex-app-server capability probe succeeded, but worker execution through app-server is not implemented in PR H2."
61
+ : "codex-app-server backend is unavailable.";
62
+ throw new AgentLoopError(code, message, {
63
+ details: { backend: "codex-app-server", probe, artifactId: probeArtifact.id },
64
+ exitCode: 2
65
+ });
66
+ }
67
+ clearOrRejectRunningWorker(input.storage, input.config.workerTimeoutMs);
68
+ const policy = resolveWorkerPolicy({
69
+ config: input.config,
70
+ state: input.state,
71
+ workerType: input.type
72
+ });
73
+ const worker = input.storage.createWorker({
74
+ runId: input.run.id,
75
+ type: input.type,
76
+ backend: input.config.workerBackend,
77
+ attempt: 0,
78
+ resumeUsed: false
79
+ });
80
+ const prompt = buildWorkerPrompt({ ...input, profile: resolveProfile(input.config, input.state), policy });
81
+ const promptArtifact = writeArtifact(
82
+ input.repoRoot,
83
+ input.storage,
84
+ input.run.id,
85
+ "worker-prompt",
86
+ `${worker.id}.md`,
87
+ prompt
88
+ );
89
+ const commandPlan = buildWorkerCommandPlan(input.repoRoot, input.run.id, input.config, input.type, promptArtifact.path, worker.id, policy.sandbox);
90
+ assertWorkerCommandAllowed(commandPlan);
91
+ if (input.dryRun) {
92
+ const planArtifact = writeArtifact(
93
+ input.repoRoot,
94
+ input.storage,
95
+ input.run.id,
96
+ "dry-run-plan",
97
+ `${worker.id}-worker-command.json`,
98
+ `${JSON.stringify(commandPlan, null, 2)}\n`
99
+ );
100
+ const updated = input.storage.updateWorker(worker.id, {
101
+ status: "succeeded",
102
+ completedAt: new Date().toISOString()
103
+ });
104
+ input.storage.appendEvent({
105
+ runId: input.run.id,
106
+ kind: "worker_dry_run",
107
+ message: `Prepared ${input.type} worker prompt without executing codex.`,
108
+ payload: { workerId: worker.id, commandPlan },
109
+ artifactIds: [promptArtifact.id, planArtifact.id]
110
+ });
111
+ return { worker: updated, artifacts: [promptArtifact, planArtifact], commandPlan };
112
+ }
113
+
114
+ return await runWithRetry({
115
+ ...input,
116
+ initialWorker: worker,
117
+ prompt,
118
+ promptArtifact,
119
+ commandPlan
120
+ });
121
+ }
122
+
123
+ /** Return the PR D worker type for a state, if the state delegates to a worker. */
124
+ export function workerTypeForState(state: AgentLoopState, context?: { ciFailed?: boolean }): WorkerType | undefined {
125
+ if (state === "FIX_REVIEW" && context?.ciFailed) {
126
+ return "ci-fix";
127
+ }
128
+ return PR_LOOP_SHAPE.defaultRoleForState(state);
129
+ }
130
+
131
+ function buildWorkerCommandPlan(
132
+ repoRoot: string,
133
+ runId: string,
134
+ config: AgentLoopConfig,
135
+ type: WorkerType,
136
+ promptPath: string,
137
+ workerId: string,
138
+ sandbox: "read-only" | "workspace-write" = workerSandbox(type),
139
+ resumeThreadId?: string
140
+ ): WorkerCommandPlan {
141
+ const outputSchemaPath = join(pluginRoot(), "plugins", "autonomous-pr-loop", "schemas", "worker-result.schema.json");
142
+ const outputLastMessagePath = join(
143
+ repoRoot,
144
+ ".agent-loop",
145
+ "artifacts",
146
+ runId,
147
+ "worker-result",
148
+ `${workerId}-worker-final.json`
149
+ );
150
+ mkdirSync(dirname(outputLastMessagePath), { recursive: true });
151
+ const args = [
152
+ "exec",
153
+ "-C",
154
+ repoRoot,
155
+ "-s",
156
+ sandbox,
157
+ "--json",
158
+ "--output-schema",
159
+ outputSchemaPath,
160
+ "--output-last-message",
161
+ outputLastMessagePath
162
+ ];
163
+ if (config.workerEphemeral) {
164
+ args.push("--ephemeral");
165
+ }
166
+ if (resumeThreadId) {
167
+ args.push("resume", resumeThreadId, "Retry once. Return valid JSON matching the required schema.");
168
+ }
169
+ return {
170
+ file: "codex",
171
+ args,
172
+ cwd: repoRoot,
173
+ sandbox,
174
+ promptPath,
175
+ outputSchemaPath,
176
+ outputLastMessagePath
177
+ };
178
+ }
179
+
180
+ function pluginRoot(): string {
181
+ return resolve(import.meta.dirname, "../../..");
182
+ }
183
+
184
+ function assertWorkerCommandAllowed(plan: WorkerCommandPlan): void {
185
+ const policy = evaluatePolicy({ file: plan.file, args: plan.args });
186
+ if (!policy.allowed) {
187
+ throw new AgentLoopError("policy_violation", policy.reason ?? "Worker command rejected by policy.", {
188
+ details: { plan },
189
+ exitCode: 2
190
+ });
191
+ }
192
+ }
193
+
194
+ async function runWithRetry(input: {
195
+ repoRoot: string;
196
+ storage: AgentLoopStorage;
197
+ run: AgentLoopRun;
198
+ config: AgentLoopConfig;
199
+ state: AgentLoopState;
200
+ type: WorkerType;
201
+ context?: unknown;
202
+ signal?: AbortSignal | undefined;
203
+ initialWorker: WorkerRun;
204
+ prompt: string;
205
+ promptArtifact: ArtifactRecord;
206
+ commandPlan: WorkerCommandPlan;
207
+ }): Promise<WorkerExecutionResult> {
208
+ let worker = input.initialWorker;
209
+ let commandPlan = input.commandPlan;
210
+ let threadId: string | undefined;
211
+ for (let attempt = 0; attempt <= input.config.workerMaxRetries; attempt += 1) {
212
+ const spawnContext = createWorkerSpawnContext(commandPlan.cwd, worker.id, commandPlan.file);
213
+ const baseline = captureScopeBaseline(input.repoRoot);
214
+ const ingestor = createWorkerJsonlStreamIngestor({
215
+ repoRoot: input.repoRoot,
216
+ storage: input.storage,
217
+ runId: input.run.id,
218
+ workerId: worker.id,
219
+ backend: input.config.workerBackend
220
+ });
221
+ const runResult = await spawnCodexWorker(
222
+ commandPlan,
223
+ input.prompt,
224
+ input.config.workerTimeoutMs,
225
+ spawnContext,
226
+ (chunk) => ingestor.ingestChunk(chunk),
227
+ input.signal
228
+ );
229
+ const ingest = ingestor.finalize();
230
+ threadId = ingest.threadId ?? threadId;
231
+ const rawJsonlArtifactId = ingest.rawJsonlArtifactId;
232
+ if (runResult.timedOut) {
233
+ input.storage.updateWorker(worker.id, {
234
+ status: "timed_out",
235
+ ...(threadId ? { threadId } : {}),
236
+ completedAt: new Date().toISOString(),
237
+ exitCode: 124,
238
+ rawJsonlArtifactId,
239
+ error: "Worker timed out."
240
+ });
241
+ throw new AgentLoopError("worker_timeout", "Codex worker timed out.", {
242
+ details: workerGateDetails(worker, {
243
+ ...(threadId ? { threadId } : {}),
244
+ timeoutMs: input.config.workerTimeoutMs
245
+ }),
246
+ exitCode: 2
247
+ });
248
+ }
249
+ if (runResult.exitCode !== 0) {
250
+ input.storage.updateWorker(worker.id, {
251
+ status: "failed",
252
+ ...(threadId ? { threadId } : {}),
253
+ completedAt: new Date().toISOString(),
254
+ exitCode: runResult.exitCode,
255
+ rawJsonlArtifactId,
256
+ error: runResult.stderr || `codex exited ${runResult.exitCode}`
257
+ });
258
+ if (attempt < input.config.workerMaxRetries) {
259
+ worker = input.storage.createWorker({
260
+ runId: input.run.id,
261
+ type: input.type,
262
+ backend: input.config.workerBackend,
263
+ attempt: attempt + 1,
264
+ resumeUsed: threadId !== undefined
265
+ });
266
+ commandPlan = buildWorkerCommandPlan(
267
+ input.repoRoot,
268
+ input.run.id,
269
+ input.config,
270
+ input.type,
271
+ input.promptArtifact.path,
272
+ worker.id,
273
+ resolveWorkerPolicy({ config: input.config, state: input.state, workerType: input.type }).sandbox,
274
+ threadId
275
+ );
276
+ assertWorkerCommandAllowed(commandPlan);
277
+ continue;
278
+ }
279
+ throw new AgentLoopError("worker_failed", "Codex worker failed.", {
280
+ details: workerGateDetails(worker, {
281
+ ...(threadId ? { threadId } : {}),
282
+ exitCode: runResult.exitCode,
283
+ error: runResult.stderr || `codex exited ${runResult.exitCode}`
284
+ }),
285
+ exitCode: 1
286
+ });
287
+ }
288
+ const parsed = parseWorkerResult(commandPlan.outputLastMessagePath);
289
+ if (!parsed.ok) {
290
+ input.storage.updateWorker(worker.id, {
291
+ status: "invalid_output",
292
+ ...(threadId ? { threadId } : {}),
293
+ completedAt: new Date().toISOString(),
294
+ exitCode: 0,
295
+ rawJsonlArtifactId,
296
+ error: parsed.error
297
+ });
298
+ if (attempt < input.config.workerMaxRetries) {
299
+ worker = input.storage.createWorker({
300
+ runId: input.run.id,
301
+ type: input.type,
302
+ backend: input.config.workerBackend,
303
+ attempt: attempt + 1,
304
+ resumeUsed: threadId !== undefined
305
+ });
306
+ commandPlan = buildWorkerCommandPlan(
307
+ input.repoRoot,
308
+ input.run.id,
309
+ input.config,
310
+ input.type,
311
+ input.promptArtifact.path,
312
+ worker.id,
313
+ resolveWorkerPolicy({ config: input.config, state: input.state, workerType: input.type }).sandbox,
314
+ threadId
315
+ );
316
+ assertWorkerCommandAllowed(commandPlan);
317
+ continue;
318
+ }
319
+ throw new AgentLoopError("worker_output_invalid", "Worker output did not match schema.", {
320
+ details: workerGateDetails(worker, {
321
+ ...(threadId ? { threadId } : {}),
322
+ error: parsed.error
323
+ }),
324
+ exitCode: 2
325
+ });
326
+ }
327
+ const resultArtifact = persistExistingResult(
328
+ input.repoRoot,
329
+ input.storage,
330
+ input.run.id,
331
+ commandPlan.outputLastMessagePath,
332
+ `${worker.id}-worker-final.json`
333
+ );
334
+ if (!parsed.result.ok) {
335
+ input.storage.updateWorker(worker.id, {
336
+ status: "failed",
337
+ ...(threadId ? { threadId } : {}),
338
+ completedAt: new Date().toISOString(),
339
+ exitCode: 0,
340
+ resultArtifactId: resultArtifact.id,
341
+ rawJsonlArtifactId,
342
+ error: parsed.result.error?.message ?? parsed.result.summary
343
+ });
344
+ throw new AgentLoopError("worker_failed", "Worker reported failure.", {
345
+ details: workerGateDetails(worker, {
346
+ ...(threadId ? { threadId } : {}),
347
+ error: parsed.result.error?.message ?? parsed.result.summary,
348
+ result: parsed.result
349
+ }),
350
+ exitCode: 1
351
+ });
352
+ }
353
+ const scope = evaluateWorkerScope({
354
+ repoRoot: input.repoRoot,
355
+ storage: input.storage,
356
+ runId: input.run.id,
357
+ workerId: worker.id,
358
+ config: input.config,
359
+ baseline,
360
+ result: parsed.result,
361
+ ...optionalAllowedPaths(input.type, input.config, input.state),
362
+ ...(input.config.loopShape === "generic-loop" ? { outOfScopeGate: "generic_scope_change_requested" as const } : {})
363
+ });
364
+ const updated = input.storage.updateWorker(worker.id, {
365
+ status: "succeeded",
366
+ ...(threadId ? { threadId } : {}),
367
+ completedAt: new Date().toISOString(),
368
+ exitCode: 0,
369
+ resultArtifactId: resultArtifact.id,
370
+ rawJsonlArtifactId
371
+ });
372
+ input.storage.appendEvent({
373
+ runId: input.run.id,
374
+ kind: "worker_completed",
375
+ message: `${input.type} worker completed.`,
376
+ payload: { workerId: worker.id, result: parsed.result, scope },
377
+ artifactIds: [input.promptArtifact.id, resultArtifact.id, rawJsonlArtifactId]
378
+ });
379
+ if (scope.gate) {
380
+ throw new AgentLoopError(scope.gate, "Worker scope guard blocked progress.", {
381
+ details: scope.gate === "generic_scope_change_requested" ? genericScopeGateDetails(input.config, input.state, scope) : scope,
382
+ exitCode: 2
383
+ });
384
+ }
385
+ return {
386
+ worker: updated,
387
+ result: parsed.result,
388
+ scope,
389
+ artifacts: [input.promptArtifact, resultArtifact],
390
+ commandPlan
391
+ };
392
+ }
393
+ throw new AgentLoopError("storage_error", "Worker retry loop ended unexpectedly.");
394
+ }
395
+
396
+ function workerGateDetails(worker: WorkerRun, extra: Record<string, unknown>): Record<string, unknown> {
397
+ return {
398
+ workerId: worker.id,
399
+ workerType: worker.type,
400
+ attempt: worker.attempt,
401
+ ...(worker.threadId === undefined ? {} : { threadId: worker.threadId }),
402
+ ...extra
403
+ };
404
+ }
405
+
406
+ interface AppServerProbeResult {
407
+ success: boolean;
408
+ status: "success" | "command_missing" | "help_failed" | "startup_failed" | "handshake_timeout" | "protocol_mismatch";
409
+ helpExitCode?: number;
410
+ stderr?: string;
411
+ responsePreview?: string;
412
+ }
413
+
414
+ async function probeCodexAppServer(repoRoot: string, workerTimeoutMs: number): Promise<AppServerProbeResult> {
415
+ const codexPath = resolveOptionalExecutable("codex", process.env.PATH ?? "");
416
+ if (!codexPath) {
417
+ return { success: false, status: "command_missing" };
418
+ }
419
+ try {
420
+ execFileSync(codexPath, ["app-server", "--help"], {
421
+ cwd: repoRoot,
422
+ encoding: "utf8",
423
+ stdio: ["ignore", "pipe", "pipe"],
424
+ timeout: Math.min(workerTimeoutMs, 5_000)
425
+ });
426
+ } catch (error) {
427
+ const helpExitCode = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : undefined;
428
+ const result: AppServerProbeResult = {
429
+ success: false,
430
+ status: "help_failed",
431
+ stderr: error instanceof Error ? error.message : String(error)
432
+ };
433
+ if (typeof helpExitCode === "number" && Number.isFinite(helpExitCode)) {
434
+ result.helpExitCode = helpExitCode;
435
+ }
436
+ return result;
437
+ }
438
+ return await new Promise((resolve) => {
439
+ const child = spawn(codexPath, ["app-server", "--listen", "stdio://"], {
440
+ cwd: repoRoot,
441
+ env: process.env,
442
+ shell: false,
443
+ stdio: ["pipe", "pipe", "pipe"]
444
+ });
445
+ let stdout = "";
446
+ let stderr = "";
447
+ let settled = false;
448
+ const finish = (result: AppServerProbeResult): void => {
449
+ if (settled) {
450
+ return;
451
+ }
452
+ settled = true;
453
+ clearTimeout(timer);
454
+ child.kill("SIGTERM");
455
+ resolve(result);
456
+ };
457
+ const timer = setTimeout(() => {
458
+ finish({ success: false, status: "handshake_timeout", responsePreview: stdout.slice(0, 500), stderr: stderr.slice(0, 500) });
459
+ }, 3_000);
460
+ child.stdout.setEncoding("utf8");
461
+ child.stderr.setEncoding("utf8");
462
+ child.stdout.on("data", (chunk: string) => {
463
+ stdout += chunk;
464
+ const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
465
+ for (const line of lines) {
466
+ try {
467
+ const parsed = JSON.parse(line) as { id?: unknown; result?: unknown; error?: unknown };
468
+ if (parsed.id === 1 && parsed.result !== undefined) {
469
+ finish({ success: true, status: "success", responsePreview: line.slice(0, 500) });
470
+ return;
471
+ }
472
+ if (parsed.id === 1 && parsed.error !== undefined) {
473
+ finish({ success: false, status: "protocol_mismatch", responsePreview: line.slice(0, 500) });
474
+ return;
475
+ }
476
+ } catch {
477
+ // Keep waiting until a complete JSON-RPC line or timeout.
478
+ }
479
+ }
480
+ });
481
+ child.stderr.on("data", (chunk: string) => {
482
+ stderr += chunk;
483
+ });
484
+ child.on("error", (error) => {
485
+ finish({ success: false, status: "startup_failed", stderr: error.message });
486
+ });
487
+ child.on("close", () => {
488
+ finish({ success: false, status: stdout ? "protocol_mismatch" : "startup_failed", responsePreview: stdout.slice(0, 500), stderr: stderr.slice(0, 500) });
489
+ });
490
+ child.stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`);
491
+ });
492
+ }
493
+
494
+ async function spawnCodexWorker(
495
+ plan: WorkerCommandPlan,
496
+ prompt: string,
497
+ timeoutMs: number,
498
+ spawnContext: { executablePath: string; env: NodeJS.ProcessEnv },
499
+ onStdoutChunk: (chunk: string) => void,
500
+ signal?: AbortSignal
501
+ ): Promise<{ exitCode: number; stderr: string; timedOut: boolean }> {
502
+ return await new Promise((resolve) => {
503
+ const child = spawn(spawnContext.executablePath, plan.args, {
504
+ cwd: plan.cwd,
505
+ env: spawnContext.env,
506
+ shell: false,
507
+ detached: true,
508
+ stdio: ["pipe", "pipe", "pipe"]
509
+ });
510
+ let stderr = "";
511
+ let settled = false;
512
+ let timedOut = false;
513
+ let killTimer: NodeJS.Timeout | undefined;
514
+ const appendStderr = (message: string): void => {
515
+ stderr = `${stderr}${stderr ? "\n" : ""}${message}`;
516
+ };
517
+ const finish = (result: { exitCode: number; stderr: string; timedOut: boolean }): void => {
518
+ if (!settled) {
519
+ settled = true;
520
+ clearTimeout(timer);
521
+ if (killTimer) {
522
+ clearTimeout(killTimer);
523
+ }
524
+ resolve(result);
525
+ }
526
+ };
527
+ const timer = setTimeout(() => {
528
+ if (!settled) {
529
+ timedOut = true;
530
+ signalProcessTree(child.pid, child, "SIGTERM");
531
+ killTimer = setTimeout(() => {
532
+ signalProcessTree(child.pid, child, "SIGKILL");
533
+ finish({ exitCode: 124, stderr, timedOut: true });
534
+ }, 1_000);
535
+ }
536
+ }, timeoutMs);
537
+ const abort = (): void => {
538
+ if (!settled) {
539
+ timedOut = true;
540
+ signalProcessTree(child.pid, child, "SIGTERM");
541
+ killTimer = setTimeout(() => {
542
+ signalProcessTree(child.pid, child, "SIGKILL");
543
+ finish({ exitCode: 130, stderr, timedOut: true });
544
+ }, 1_000);
545
+ }
546
+ };
547
+ signal?.addEventListener("abort", abort, { once: true });
548
+ child.stdout.setEncoding("utf8");
549
+ child.stderr.setEncoding("utf8");
550
+ child.stdout.on("data", (chunk: string) => {
551
+ onStdoutChunk(chunk);
552
+ });
553
+ child.stderr.on("data", (chunk: string) => {
554
+ stderr += chunk;
555
+ });
556
+ child.on("error", (error) => {
557
+ appendStderr(error.message);
558
+ finish({ exitCode: 1, stderr, timedOut: false });
559
+ });
560
+ child.on("close", (code, closeSignal) => {
561
+ signal?.removeEventListener("abort", abort);
562
+ finish({ exitCode: code ?? 1, stderr, timedOut: timedOut || closeSignal === "SIGTERM" });
563
+ });
564
+ child.stdin.on("error", (error) => {
565
+ if (!isClosedWorkerStdinError(error)) {
566
+ appendStderr(error.message);
567
+ }
568
+ });
569
+ try {
570
+ child.stdin.end(prompt);
571
+ } catch (error) {
572
+ if (!isClosedWorkerStdinError(error)) {
573
+ appendStderr(errorMessage(error));
574
+ }
575
+ }
576
+ });
577
+ }
578
+
579
+ function isClosedWorkerStdinError(error: unknown): boolean {
580
+ const code = typeof error === "object" && error !== null && "code" in error
581
+ ? String((error as { code?: unknown }).code)
582
+ : "";
583
+ return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
584
+ }
585
+
586
+ function errorMessage(error: unknown): string {
587
+ return error instanceof Error ? error.message : String(error);
588
+ }
589
+
590
+ function createWorkerSpawnContext(
591
+ repoRoot: string,
592
+ workerId: string,
593
+ executable: string
594
+ ): { executablePath: string; env: NodeJS.ProcessEnv } {
595
+ const originalPath = process.env.PATH ?? "";
596
+ const executablePath = resolveExecutable(executable, originalPath);
597
+ const binDir = join(repoRoot, ".agent-loop", "worker-policy-bin", workerId);
598
+ mkdirSync(binDir, { recursive: true });
599
+ writeShim(join(binDir, "git"), gitShim(resolveOptionalExecutable("git", originalPath)));
600
+ writeShim(join(binDir, "gh"), ghShim(resolveOptionalExecutable("gh", originalPath)));
601
+ writeShim(join(binDir, "codex"), codexShim(resolveOptionalExecutable("codex", originalPath)));
602
+ return {
603
+ executablePath,
604
+ env: {
605
+ ...process.env,
606
+ PATH: `${binDir}:${originalPath}`,
607
+ AGENT_LOOP_WORKER_POLICY: "1"
608
+ }
609
+ };
610
+ }
611
+
612
+ function writeShim(path: string, content: string): void {
613
+ writeFileSync(path, content);
614
+ chmodSync(path, 0o755);
615
+ }
616
+
617
+ function gitShim(realPath: string | undefined): string {
618
+ return `#!/bin/sh
619
+ cmd="$1"
620
+ while [ "$cmd" = "-c" ] || [ "$cmd" = "-C" ]; do
621
+ shift 2 || exit 126
622
+ cmd="$1"
623
+ done
624
+ case "$cmd" in
625
+ commit|push|rebase|reset|clean|merge) echo "agent-loop worker policy denied git side effect" >&2; exit 126 ;;
626
+ esac
627
+ ${execLine(realPath)}
628
+ `;
629
+ }
630
+
631
+ function ghShim(realPath: string | undefined): string {
632
+ return `#!/bin/sh
633
+ case "$1 $2" in
634
+ "repo delete"|"pr create"|"pr ready"|"pr merge"|"pr close"|"pr comment") echo "agent-loop worker policy denied gh side effect" >&2; exit 126 ;;
635
+ esac
636
+ ${execLine(realPath)}
637
+ `;
638
+ }
639
+
640
+ function codexShim(realPath: string | undefined): string {
641
+ return `#!/bin/sh
642
+ for arg in "$@"; do
643
+ case "$arg" in
644
+ --dangerously-bypass-approvals-and-sandbox|danger-full-access) echo "agent-loop worker policy denied danger sandbox" >&2; exit 126 ;;
645
+ esac
646
+ done
647
+ if [ "$1" = "exec" ]; then
648
+ echo "agent-loop worker policy denied nested codex exec" >&2
649
+ exit 126
650
+ fi
651
+ ${execLine(realPath)}
652
+ `;
653
+ }
654
+
655
+ function execLine(realPath: string | undefined): string {
656
+ return realPath ? `exec ${shellQuote(realPath)} "$@"` : "echo \"command unavailable\" >&2; exit 127";
657
+ }
658
+
659
+ function shellQuote(value: string): string {
660
+ return `'${value.replaceAll("'", "'\"'\"'")}'`;
661
+ }
662
+
663
+ function resolveExecutable(file: string, pathValue: string): string {
664
+ const resolved = resolveOptionalExecutable(file, pathValue);
665
+ if (!resolved) {
666
+ throw new AgentLoopError("required_tool_unavailable", `Required executable not found: ${file}`, {
667
+ details: { file },
668
+ exitCode: 2
669
+ });
670
+ }
671
+ return resolved;
672
+ }
673
+
674
+ function resolveOptionalExecutable(file: string, pathValue: string): string | undefined {
675
+ try {
676
+ return execFileSync("which", [file], {
677
+ encoding: "utf8",
678
+ env: { ...process.env, PATH: pathValue },
679
+ stdio: ["ignore", "pipe", "ignore"]
680
+ }).trim();
681
+ } catch {
682
+ return undefined;
683
+ }
684
+ }
685
+
686
+ function signalProcessTree(
687
+ pid: number | undefined,
688
+ child: ReturnType<typeof spawn>,
689
+ signal: NodeJS.Signals
690
+ ): void {
691
+ try {
692
+ if (pid) {
693
+ process.kill(-pid, signal);
694
+ return;
695
+ }
696
+ } catch {
697
+ // Fall back to the direct child when process-group signaling is unavailable.
698
+ }
699
+ child.kill(signal);
700
+ }
701
+
702
+ function parseWorkerResult(path: string): { ok: true; result: WorkerResult } | { ok: false; error: string } {
703
+ if (!existsSync(path)) {
704
+ return { ok: false, error: `Missing worker final output: ${path}` };
705
+ }
706
+ try {
707
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
708
+ return isWorkerResult(parsed)
709
+ ? { ok: true, result: parsed }
710
+ : { ok: false, error: "Worker final output failed structural validation." };
711
+ } catch (error) {
712
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
713
+ }
714
+ }
715
+
716
+ function isWorkerResult(value: unknown): value is WorkerResult {
717
+ if (!isRecord(value)) {
718
+ return false;
719
+ }
720
+ return typeof value.ok === "boolean" &&
721
+ typeof value.summary === "string" &&
722
+ isStringArray(value.changedFiles) &&
723
+ Array.isArray(value.commandsRun) &&
724
+ value.commandsRun.every(isCommandRun) &&
725
+ isStringArray(value.testsRun) &&
726
+ isRecord(value.gitnexus) &&
727
+ typeof value.gitnexus.impactRun === "boolean" &&
728
+ typeof value.gitnexus.detectChangesRun === "boolean" &&
729
+ Array.isArray(value.outOfScope) &&
730
+ value.outOfScope.every(isOutOfScope) &&
731
+ isStringArray(value.followUps);
732
+ }
733
+
734
+ function isCommandRun(value: unknown): value is { command: string; exitCode: number } {
735
+ return isRecord(value) && typeof value.command === "string" && Number.isInteger(value.exitCode);
736
+ }
737
+
738
+ function isOutOfScope(value: unknown): value is { item: string; reason: string } {
739
+ return isRecord(value) && typeof value.item === "string" && typeof value.reason === "string";
740
+ }
741
+
742
+ function persistExistingResult(
743
+ repoRoot: string,
744
+ storage: AgentLoopStorage,
745
+ runId: string,
746
+ path: string,
747
+ name: string
748
+ ): ArtifactRecord {
749
+ const content = readFileSync(path);
750
+ const record = {
751
+ id: randomUUID(),
752
+ runId,
753
+ kind: "worker-result" as const,
754
+ name,
755
+ path,
756
+ sha256: createHash("sha256").update(content).digest("hex"),
757
+ createdAt: new Date().toISOString()
758
+ };
759
+ storage.insertArtifact(record);
760
+ return record;
761
+ }
762
+
763
+ function clearOrRejectRunningWorker(storage: AgentLoopStorage, workerTimeoutMs: number): void {
764
+ const running = storage.getRunningWorker();
765
+ if (!running) {
766
+ return;
767
+ }
768
+ const ageMs = Date.now() - Date.parse(running.startedAt);
769
+ if (Number.isFinite(ageMs) && ageMs > workerTimeoutMs) {
770
+ storage.updateWorker(running.id, {
771
+ status: "failed",
772
+ completedAt: new Date().toISOString(),
773
+ exitCode: 124,
774
+ error: "Stale running worker cleaned before spawning a new worker."
775
+ });
776
+ storage.appendEvent({
777
+ runId: running.runId,
778
+ kind: "stale_worker_cleaned",
779
+ message: `Cleaned stale running worker ${running.id}.`,
780
+ payload: { workerId: running.id, ageMs, workerTimeoutMs }
781
+ });
782
+ return;
783
+ }
784
+ throw new AgentLoopError("worker_already_running", "Another worker is already running.", {
785
+ details: { workerId: running.id, runId: running.runId, startedAt: running.startedAt },
786
+ exitCode: 2
787
+ });
788
+ }
789
+
790
+ function optionalAllowedPaths(type: WorkerType, config: AgentLoopConfig, state: AgentLoopState): { allowedPaths?: string[] } {
791
+ const allowedPaths = resolveWorkerPolicy({ config, state, workerType: type }).allowedPaths;
792
+ return allowedPaths ? { allowedPaths } : {};
793
+ }
794
+
795
+ function genericScopeGateDetails(config: AgentLoopConfig, state: AgentLoopState, scope: unknown): Record<string, unknown> {
796
+ return {
797
+ ...(typeof scope === "object" && scope !== null && !Array.isArray(scope) ? scope as Record<string, unknown> : {}),
798
+ loopShape: config.loopShape,
799
+ workflowProfile: config.workflowProfile,
800
+ state,
801
+ allowedNextStates: ["PLAN_WORK", "STOPPED"],
802
+ defaultNextState: "PLAN_WORK",
803
+ requiredPayload: { nextState: "PLAN_WORK", source: "ui" }
804
+ };
805
+ }
806
+
807
+ function isStringArray(value: unknown): value is string[] {
808
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
809
+ }