@ryanfw/prompt-orchestration-pipeline 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -0
  2. package/docs/pop-task-guide.md +45 -0
  3. package/package.json +3 -3
  4. package/src/core/__tests__/agent-step.test.ts +402 -35
  5. package/src/core/__tests__/task-runner.test.ts +104 -0
  6. package/src/core/agent-step.ts +295 -41
  7. package/src/core/agent-types.ts +61 -0
  8. package/src/core/file-io.ts +8 -74
  9. package/src/core/orchestrator.ts +2 -1
  10. package/src/core/pipeline-definition.ts +1 -1
  11. package/src/core/pipeline-runner.ts +54 -3
  12. package/src/core/status-writer.ts +44 -26
  13. package/src/core/task-runner.ts +44 -1
  14. package/src/core/validation.ts +1 -1
  15. package/src/harness/__tests__/discovery.test.ts +235 -0
  16. package/src/harness/discovery.ts +109 -0
  17. package/src/harness/index.ts +22 -0
  18. package/src/harness/mcp-io-server.ts +1 -1
  19. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  20. package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
  21. package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
  22. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  23. package/src/ui/dist/index.html +2 -2
  24. package/src/ui/embedded-assets.js +6 -6
  25. package/src/ui/pages/Code.tsx +135 -0
  26. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  27. package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
  28. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  29. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  30. package/src/ui/server/config-bridge.ts +1 -0
  31. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  32. package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
  33. package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
  34. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  35. package/src/ui/server/utils/http-utils.ts +6 -1
  36. package/src/ui/server/utils/path-containment.ts +18 -0
  37. package/src/ui/server/utils/status-corruption.ts +27 -0
  38. package/src/harness/__tests__/descriptors.test.ts +0 -378
  39. package/src/harness/__tests__/executor.test.ts +0 -193
  40. package/src/harness/__tests__/resolve.test.ts +0 -200
  41. package/src/harness/__tests__/types.test.ts +0 -297
  42. package/src/harness/descriptors/claude.ts +0 -132
  43. package/src/harness/descriptors/codex.ts +0 -126
  44. package/src/harness/descriptors/index.ts +0 -10
  45. package/src/harness/descriptors/opencode.ts +0 -147
  46. package/src/harness/executor.ts +0 -128
  47. package/src/harness/resolve.ts +0 -176
  48. package/src/harness/types.ts +0 -100
  49. package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
  50. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -112,7 +112,8 @@ import { buildReexecArgs } from "../cli/self-reexec";
112
112
  import { writeJobStatus } from "./status-writer";
113
113
  import { initializeStatusFromArtifacts } from "./status-initializer";
114
114
  import { materializeNormalizedPipelineDefinition } from "./pipeline-definition";
115
- import { applyHarnessDiscovery, discoverHarnesses } from "../harness/resolve";
115
+ import { discoverHarnesses } from "../harness/index.ts";
116
+ import { applyHarnessDiscovery } from "../harness/discovery.ts";
116
117
  import {
117
118
  listQueuedSeeds,
118
119
  releaseJobSlot,
@@ -1,6 +1,6 @@
1
1
  import { dirname } from "node:path";
2
2
  import { mkdir } from "node:fs/promises";
3
- import type { AgentEntryConfig } from "../harness/types";
3
+ import type { AgentEntryConfig } from "./agent-types.ts";
4
4
 
5
5
  export interface PipelineTaskEntry {
6
6
  name: string;
@@ -5,7 +5,7 @@ import { unlinkSync } from "node:fs";
5
5
  import { getConfig, getPipelineConfig } from "./config";
6
6
  import { validatePipelineOrThrow } from "./validation";
7
7
  import { loadFreshModule } from "./module-loader";
8
- import { atomicWrite, writeJobStatus, type GateInfo } from "./status-writer";
8
+ import { atomicWrite, writeJobStatus, flushJobStatus, StatusCorruptError, type GateInfo } from "./status-writer";
9
9
  import { decideTransition } from "./lifecycle-policy";
10
10
  import { runPipeline } from "./task-runner";
11
11
  import type { AuditLogEntry, PipelineResult } from "./task-runner";
@@ -672,8 +672,14 @@ export async function runPipelineJob(jobId: string): Promise<void> {
672
672
  while (true) {
673
673
  const pipeline = await loadPipeline(config.pipelineJsonPath);
674
674
  const pipelineTasks = normalizePipelineTasks(pipeline);
675
- const statusText = await Bun.file(config.statusPath).text();
676
- const status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
675
+ let status: { tasks: Record<string, { state?: string }> };
676
+ try {
677
+ const statusText = await Bun.file(config.statusPath).text();
678
+ status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
679
+ } catch (parseErr: unknown) {
680
+ if (parseErr instanceof SyntaxError) throw new StatusCorruptError(config.statusPath, { cause: parseErr });
681
+ throw parseErr;
682
+ }
677
683
 
678
684
  let selectedEntry: PipelineTaskEntry | null = null;
679
685
  let selectedTaskState: TaskStateValue = TaskState.PENDING;
@@ -1092,6 +1098,49 @@ export async function runPipelineJob(jobId: string): Promise<void> {
1092
1098
  }
1093
1099
  await releaseJobSlotBestEffort(dataDir, jobId);
1094
1100
  } catch (err) {
1101
+ if (err instanceof StatusCorruptError) {
1102
+ console.error(`Status corruption detected for job "${jobId}": ${err.statusPath} — refusing to overwrite`);
1103
+ if (workDir !== undefined) {
1104
+ try {
1105
+ const failureIO = createTaskFileIO({
1106
+ workDir,
1107
+ taskName: "orchestrator",
1108
+ trackTaskFiles: false,
1109
+ getStage: () => "runPipelineJob",
1110
+ statusPath: join(workDir, "tasks-status.json"),
1111
+ });
1112
+ const failureLogName = generateLogName(
1113
+ "orchestrator",
1114
+ "runPipelineJob",
1115
+ LogEvent.FAILURE_DETAILS,
1116
+ LogFileExtension.JSON,
1117
+ );
1118
+ await failureIO.writeLog(
1119
+ failureLogName,
1120
+ JSON.stringify({
1121
+ type: "status_corrupt",
1122
+ jobId,
1123
+ statusPath: err.statusPath,
1124
+ message: err.message,
1125
+ }, null, 2),
1126
+ );
1127
+ } catch {
1128
+ // Do not mask the original error if log-write fails
1129
+ }
1130
+ try {
1131
+ await cleanupPidFile(workDir);
1132
+ } catch {
1133
+ // Do not mask the original failure if PID cleanup fails
1134
+ }
1135
+ }
1136
+ if (dataDir !== undefined) {
1137
+ await releaseJobSlotBestEffort(dataDir, jobId);
1138
+ }
1139
+ process.exitCode = 1;
1140
+ setTimeout(() => process.exit(1), 5000).unref();
1141
+ process.exit(1);
1142
+ return;
1143
+ }
1095
1144
  const normalized = normalizeError(err);
1096
1145
  console.error(normalized.message);
1097
1146
  if (workDir !== undefined) {
@@ -1171,6 +1220,8 @@ export async function completeJob(
1171
1220
  // Create complete/ directory if needed
1172
1221
  await mkdir(config.completeDir, { recursive: true });
1173
1222
 
1223
+ await flushJobStatus(config.workDir);
1224
+
1174
1225
  // Move job directory from current/ to complete/
1175
1226
  await rename(config.workDir, destDir);
1176
1227
 
@@ -1,5 +1,5 @@
1
- import { rename, unlink, mkdir } from "node:fs/promises";
2
- import { basename, join } from "node:path";
1
+ import { rename, unlink, mkdir, open } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { createJobLogger } from "./logger";
4
4
  import type { NormalizedError } from "./pipeline-runner";
5
5
 
@@ -71,6 +71,19 @@ export interface UploadArtifact {
71
71
  content: string;
72
72
  }
73
73
 
74
+ export class StatusCorruptError extends Error {
75
+ readonly statusPath: string;
76
+ constructor(statusPath: string, options?: { cause?: unknown }) {
77
+ super(
78
+ `tasks-status.json is corrupt (unparseable) at ${statusPath}; refusing to overwrite — ` +
79
+ `repair the file or delete it to let a fresh status be created`,
80
+ options,
81
+ );
82
+ this.name = "StatusCorruptError";
83
+ this.statusPath = statusPath;
84
+ }
85
+ }
86
+
74
87
  export const STATUS_FILENAME = "tasks-status.json";
75
88
 
76
89
  function clearResetTaskMetadata(task: TaskEntry): void {
@@ -150,13 +163,40 @@ export async function atomicWrite(filePath: string, content: string): Promise<vo
150
163
  const tmp = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
151
164
  try {
152
165
  await Bun.write(tmp, content);
166
+ const fh = await open(tmp, "r+");
167
+ try { await fh.sync(); } finally { await fh.close(); }
153
168
  await rename(tmp, filePath);
169
+ const dirFh = await open(dirname(filePath), "r");
170
+ try { await dirFh.sync(); } finally { await dirFh.close(); }
154
171
  } catch (err) {
155
172
  await unlink(tmp).catch(() => {});
156
173
  throw err;
157
174
  }
158
175
  }
159
176
 
177
+ async function readSnapshotForUpdate(statusPath: string, jobDir: string): Promise<unknown> {
178
+ try {
179
+ const text = await Bun.file(statusPath).text();
180
+ return JSON.parse(text);
181
+ } catch (err: unknown) {
182
+ const code = (err as NodeJS.ErrnoException).code;
183
+ if (code === "ENOENT") return createDefaultStatus(jobDir);
184
+ if (err instanceof SyntaxError) throw new StatusCorruptError(statusPath, { cause: err });
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ export async function flushJobStatus(jobDir: string): Promise<void> {
190
+ const pending = writeQueues.get(jobDir);
191
+ if (pending) {
192
+ try {
193
+ await pending;
194
+ } catch {
195
+ // drain only
196
+ }
197
+ }
198
+ }
199
+
160
200
  const writeQueues = new Map<string, Promise<StatusSnapshot>>();
161
201
 
162
202
  export async function readJobStatus(jobDir: string): Promise<StatusSnapshot | null> {
@@ -195,18 +235,7 @@ export function updateTaskStatus(jobDir: string, taskId: string, taskUpdateFn: T
195
235
  return next;
196
236
 
197
237
  async function runTaskWrite(): Promise<StatusSnapshot> {
198
- let raw: unknown;
199
- try {
200
- const text = await Bun.file(statusPath).text();
201
- raw = JSON.parse(text);
202
- } catch (err: unknown) {
203
- const code = (err as NodeJS.ErrnoException).code;
204
- if (code === "ENOENT" || err instanceof SyntaxError) {
205
- raw = createDefaultStatus(jobDir);
206
- } else {
207
- throw err;
208
- }
209
- }
238
+ const raw = await readSnapshotForUpdate(statusPath, jobDir);
210
239
 
211
240
  const snapshot = validateStatusSnapshot(raw, jobDir);
212
241
 
@@ -361,18 +390,7 @@ export function writeJobStatus(jobDir: string, updateFn: StatusUpdateFn): Promis
361
390
  return next;
362
391
 
363
392
  async function runWrite(): Promise<StatusSnapshot> {
364
- let raw: unknown;
365
- try {
366
- const text = await Bun.file(statusPath).text();
367
- raw = JSON.parse(text);
368
- } catch (err: unknown) {
369
- const code = (err as NodeJS.ErrnoException).code;
370
- if (code === "ENOENT" || err instanceof SyntaxError) {
371
- raw = createDefaultStatus(jobDir);
372
- } else {
373
- throw err;
374
- }
375
- }
393
+ const raw = await readSnapshotForUpdate(statusPath, jobDir);
376
394
 
377
395
  let snapshot = validateStatusSnapshot(raw, jobDir);
378
396
 
@@ -14,14 +14,19 @@ import { loadFreshModule } from "./module-loader";
14
14
  import { loadEnvironment } from "./environment";
15
15
  import { createJobLogger } from "./logger";
16
16
  import { createHighLevelLLM, createLLMWithOverride, getLLMEvents } from "../llm/index";
17
+ import { executeAgent } from "./agent-step";
17
18
  import { TaskState } from "../config/statuses";
18
19
  import { LogEvent, LogFileExtension } from "../config/log-events";
19
20
  // LLMClient: src/llm/index.ts exports HighLevelLLM (no LLMClient type exists there).
20
21
  import type { HighLevelLLM } from "../providers/types";
22
+ import type { AgentStepResult, TaskAgentOptions, TaskAgentRunner } from "./agent-types";
21
23
 
22
24
  /** Opaque alias — the LLM client passed into execution contexts. */
23
25
  export type LLMClient = HighLevelLLM;
24
26
 
27
+ /** The CLI-agent harness runner injected into JavaScript task stages. */
28
+ export type { TaskAgentOptions, TaskAgentRunner } from "./agent-types";
29
+
25
30
  /** Validation result returned by schema validators. */
26
31
  export type { SchemaValidationResult } from "../api/validators/json";
27
32
 
@@ -55,6 +60,7 @@ export interface StageResult {
55
60
  export interface ExecutionContext {
56
61
  io: TaskFileIO;
57
62
  llm: LLMClient;
63
+ runAgent: TaskAgentRunner;
58
64
  meta: ExecutionMeta;
59
65
  data: Record<string, unknown>;
60
66
  flags: Record<string, unknown>;
@@ -85,6 +91,7 @@ export interface ModelConfig {
85
91
  export interface StageContext {
86
92
  io: TaskFileIO;
87
93
  llm: LLMClient;
94
+ runAgent: TaskAgentRunner;
88
95
  meta: ExecutionMeta;
89
96
  data: Record<string, unknown>;
90
97
  flags: Record<string, unknown>;
@@ -171,6 +178,8 @@ export interface InitialContext {
171
178
  envLoaded?: boolean;
172
179
  llm?: LLMClient;
173
180
  llmOverride?: unknown;
181
+ /** Override the injected `runAgent` helper (used by tests/custom hosts). */
182
+ runAgent?: TaskAgentRunner;
174
183
  seed?: unknown;
175
184
  modelConfig?: ModelConfig;
176
185
  pipelineTasks?: string[];
@@ -245,6 +254,21 @@ export function deriveModelKeyAndTokens(metric: Record<string, unknown>): TokenU
245
254
  return [modelKey, inputTokens, outputTokens, cost];
246
255
  }
247
256
 
257
+ function deriveAgentUsageTuple(options: TaskAgentOptions, result: AgentStepResult): TokenUsageTuple | null {
258
+ const costUsd =
259
+ typeof result.costUsd === "number" && Number.isFinite(result.costUsd)
260
+ ? result.costUsd
261
+ : undefined;
262
+ if (!result.usage && costUsd === undefined) return null;
263
+
264
+ return [
265
+ `${options.harness}:${options.model ?? "default"}`,
266
+ result.usage?.inputTokens ?? 0,
267
+ result.usage?.outputTokens ?? 0,
268
+ costUsd ?? 0,
269
+ ];
270
+ }
271
+
248
272
  // ─── Safe clone ─────────────────────────────────────────────────────────────
249
273
 
250
274
  function safeClone<T>(value: T): T {
@@ -516,8 +540,11 @@ export async function runPipeline(
516
540
  };
517
541
  llmMetrics.push(record);
518
542
 
519
- // Append token usage to status file, serialized via promise queue
520
543
  const tuple = deriveModelKeyAndTokens(metric);
544
+ enqueueTokenUsage(tuple);
545
+ };
546
+
547
+ const enqueueTokenUsage = (tuple: TokenUsageTuple) => {
521
548
  tokenWriteQueue = tokenWriteQueue.then(async () => {
522
549
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
523
550
  const tasks = snapshot.tasks;
@@ -557,6 +584,20 @@ export async function runPipeline(
557
584
  statusPath,
558
585
  });
559
586
 
587
+ // 9b. Build the CLI-agent harness runner injected into stages. It reuses the
588
+ // task's own `io`, so an agent reads and writes the same artifacts the task
589
+ // sees. A caller may override it (tests/custom hosts).
590
+ const runAgentCore: TaskAgentRunner =
591
+ initialContext.runAgent ??
592
+ ((options: TaskAgentOptions) =>
593
+ executeAgent({ io, entry: { ...options, name: taskName } }));
594
+ const runAgent: TaskAgentRunner = async (options: TaskAgentOptions) => {
595
+ const result = await runAgentCore(options);
596
+ const tuple = deriveAgentUsageTuple(options, result);
597
+ if (tuple) enqueueTokenUsage(tuple);
598
+ return result;
599
+ };
600
+
560
601
  // 10. Build execution context
561
602
  const pipelineTasks = initialContext.pipelineTasks ?? initialContext.meta?.pipelineTasks;
562
603
 
@@ -568,6 +609,7 @@ export async function runPipeline(
568
609
  const context: ExecutionContext = {
569
610
  io,
570
611
  llm,
612
+ runAgent,
571
613
  meta: {
572
614
  taskName,
573
615
  workDir,
@@ -644,6 +686,7 @@ export async function runPipeline(
644
686
  const stageContext: StageContext = {
645
687
  io,
646
688
  llm,
689
+ runAgent,
647
690
  meta: context.meta,
648
691
  data: safeClone(context.data),
649
692
  flags: safeClone(context.flags),
@@ -2,7 +2,7 @@ import Ajv from "ajv";
2
2
  import addFormats from "ajv-formats";
3
3
  import { getConfig } from "./config";
4
4
  import { isNonEmptyString, isPlainObject } from "./object-utils";
5
- import type { HarnessName } from "../harness/types";
5
+ import type { HarnessName } from "../harness/index.ts";
6
6
 
7
7
  export interface ValidationError {
8
8
  message: string;
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { healedPath, popBinEnvVar, adapterBinEnvVar, applyHarnessDiscovery } from "../discovery.ts";
6
+ import type { HarnessName, HarnessStatus } from "../index.ts";
7
+
8
+ function makeStatus(overrides: Partial<HarnessStatus> & { name: HarnessName }): HarnessStatus {
9
+ return { installed: false, binPath: null, version: null, authenticated: null, ...overrides };
10
+ }
11
+
12
+ describe("healedPath", () => {
13
+ let existingDir: string;
14
+
15
+ beforeEach(() => {
16
+ existingDir = mkdtempSync(join(tmpdir(), "discovery-test-"));
17
+ });
18
+ afterEach(() => {
19
+ rmSync(existingDir, { recursive: true, force: true });
20
+ });
21
+
22
+ it("prepends existing standard dirs and preserves the base PATH suffix", () => {
23
+ const result = healedPath("/usr/bin:/bin");
24
+ expect(result.endsWith("/usr/bin:/bin")).toBe(true);
25
+ });
26
+
27
+ it("does not duplicate dirs already on the base PATH", () => {
28
+ const result = healedPath(`${existingDir}:/usr/bin`, [existingDir]);
29
+ expect(result.split(":").filter((d) => d === existingDir)).toHaveLength(1);
30
+ });
31
+
32
+ it("defaults to process.env.PATH when basePath is undefined", () => {
33
+ const original = process.env.PATH;
34
+ process.env.PATH = "/usr/bin";
35
+ try {
36
+ const result = healedPath();
37
+ expect(result).toContain("/usr/bin");
38
+ } finally {
39
+ process.env.PATH = original;
40
+ }
41
+ });
42
+
43
+ it("handles empty basePath", () => {
44
+ const result = healedPath("");
45
+ // Should still include any existing standard dirs
46
+ expect(typeof result).toBe("string");
47
+ });
48
+ });
49
+
50
+ describe("popBinEnvVar", () => {
51
+ it("maps harness names to POP_<HARNESS>_BIN", () => {
52
+ expect(popBinEnvVar("opencode")).toBe("POP_OPENCODE_BIN");
53
+ expect(popBinEnvVar("claude")).toBe("POP_CLAUDE_BIN");
54
+ expect(popBinEnvVar("codex")).toBe("POP_CODEX_BIN");
55
+ });
56
+ });
57
+
58
+ describe("adapterBinEnvVar", () => {
59
+ it("maps harness names to LLM_ADAPTER_<HARNESS>_BIN", () => {
60
+ expect(adapterBinEnvVar("opencode")).toBe("LLM_ADAPTER_OPENCODE_BIN");
61
+ expect(adapterBinEnvVar("claude")).toBe("LLM_ADAPTER_CLAUDE_BIN");
62
+ expect(adapterBinEnvVar("codex")).toBe("LLM_ADAPTER_CODEX_BIN");
63
+ });
64
+ });
65
+
66
+ describe("applyHarnessDiscovery", () => {
67
+ it("populates both POP_*_BIN and LLM_ADAPTER_*_BIN for discovered binaries", () => {
68
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
69
+ const statuses: Record<HarnessName, HarnessStatus> = {
70
+ opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: true }),
71
+ claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
72
+ codex: makeStatus({ name: "codex", installed: false }),
73
+ };
74
+
75
+ applyHarnessDiscovery(statuses, env);
76
+
77
+ expect(env.POP_OPENCODE_BIN).toBe("/x/opencode");
78
+ expect(env.LLM_ADAPTER_OPENCODE_BIN).toBe("/x/opencode");
79
+ expect(env.POP_CLAUDE_BIN).toBe("/y/claude");
80
+ expect(env.LLM_ADAPTER_CLAUDE_BIN).toBe("/y/claude");
81
+ });
82
+
83
+ it("keeps the original path suffix", () => {
84
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin:/bin" };
85
+ const statuses: Record<HarnessName, HarnessStatus> = {
86
+ opencode: makeStatus({ name: "opencode", installed: false }),
87
+ claude: makeStatus({ name: "claude", installed: false }),
88
+ codex: makeStatus({ name: "codex", installed: false }),
89
+ };
90
+
91
+ applyHarnessDiscovery(statuses, env);
92
+
93
+ expect(env.PATH).toContain("/usr/bin:/bin");
94
+ });
95
+
96
+ it("produces a warning for missing harnesses", () => {
97
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
98
+ const statuses: Record<HarnessName, HarnessStatus> = {
99
+ opencode: makeStatus({ name: "opencode", installed: false }),
100
+ claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
101
+ codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
102
+ };
103
+
104
+ const messages = applyHarnessDiscovery(statuses, env);
105
+ const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
106
+
107
+ expect(opencodeMsg).toBeDefined();
108
+ expect(opencodeMsg!.level).toBe("warn");
109
+ expect(opencodeMsg!.message).toContain("not found");
110
+ });
111
+
112
+ it("produces a warning for unauthenticated harnesses", () => {
113
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
114
+ const statuses: Record<HarnessName, HarnessStatus> = {
115
+ opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: false }),
116
+ claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
117
+ codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
118
+ };
119
+
120
+ const messages = applyHarnessDiscovery(statuses, env);
121
+ const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
122
+
123
+ expect(opencodeMsg).toBeDefined();
124
+ expect(opencodeMsg!.level).toBe("warn");
125
+ expect(opencodeMsg!.message).toContain("not authenticated");
126
+ });
127
+
128
+ it("produces an info message for authenticated harnesses", () => {
129
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
130
+ const statuses: Record<HarnessName, HarnessStatus> = {
131
+ opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: true }),
132
+ claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
133
+ codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
134
+ };
135
+
136
+ const messages = applyHarnessDiscovery(statuses, env);
137
+ const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
138
+
139
+ expect(opencodeMsg).toBeDefined();
140
+ expect(opencodeMsg!.level).toBe("info");
141
+ expect(opencodeMsg!.message).toContain("authenticated");
142
+ });
143
+
144
+ it("produces an info message for installed harnesses with unknown auth", () => {
145
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
146
+ const statuses: Record<HarnessName, HarnessStatus> = {
147
+ opencode: makeStatus({ name: "opencode", installed: true, binPath: "/x/opencode", authenticated: null }),
148
+ claude: makeStatus({ name: "claude", installed: true, binPath: "/y/claude", authenticated: true }),
149
+ codex: makeStatus({ name: "codex", installed: true, binPath: "/z/codex", authenticated: true }),
150
+ };
151
+
152
+ const messages = applyHarnessDiscovery(statuses, env);
153
+ const opencodeMsg = messages.find((m) => m.message.includes('"opencode"'));
154
+
155
+ expect(opencodeMsg).toBeDefined();
156
+ expect(opencodeMsg!.level).toBe("info");
157
+ expect(opencodeMsg!.message).toContain("auth status unknown");
158
+ });
159
+
160
+ it("does not set bin env vars for missing harnesses", () => {
161
+ const env: Record<string, string | undefined> = { PATH: "/usr/bin" };
162
+ const statuses: Record<HarnessName, HarnessStatus> = {
163
+ opencode: makeStatus({ name: "opencode", installed: false }),
164
+ claude: makeStatus({ name: "claude", installed: false }),
165
+ codex: makeStatus({ name: "codex", installed: false }),
166
+ };
167
+
168
+ applyHarnessDiscovery(statuses, env);
169
+
170
+ expect(env.POP_OPENCODE_BIN).toBeUndefined();
171
+ expect(env.LLM_ADAPTER_OPENCODE_BIN).toBeUndefined();
172
+ expect(env.POP_CLAUDE_BIN).toBeUndefined();
173
+ expect(env.LLM_ADAPTER_CLAUDE_BIN).toBeUndefined();
174
+ expect(env.POP_CODEX_BIN).toBeUndefined();
175
+ expect(env.LLM_ADAPTER_CODEX_BIN).toBeUndefined();
176
+ });
177
+
178
+ it("bridges existing POP_* binary overrides to adapter env vars when discovery misses", () => {
179
+ const env: Record<string, string | undefined> = {
180
+ PATH: "/usr/bin",
181
+ POP_OPENCODE_BIN: "/custom/opencode",
182
+ };
183
+ const statuses: Record<HarnessName, HarnessStatus> = {
184
+ opencode: makeStatus({ name: "opencode", installed: false }),
185
+ claude: makeStatus({ name: "claude", installed: false }),
186
+ codex: makeStatus({ name: "codex", installed: false }),
187
+ };
188
+
189
+ const messages = applyHarnessDiscovery(statuses, env);
190
+
191
+ expect(env.POP_OPENCODE_BIN).toBe("/custom/opencode");
192
+ expect(env.LLM_ADAPTER_OPENCODE_BIN).toBe("/custom/opencode");
193
+ expect(messages.find((m) => m.message.includes('"opencode"'))).toMatchObject({
194
+ level: "info",
195
+ });
196
+ });
197
+
198
+ it("bridges existing adapter binary overrides back to POP env vars when discovery misses", () => {
199
+ const env: Record<string, string | undefined> = {
200
+ PATH: "/usr/bin",
201
+ LLM_ADAPTER_CODEX_BIN: "/custom/codex",
202
+ };
203
+ const statuses: Record<HarnessName, HarnessStatus> = {
204
+ opencode: makeStatus({ name: "opencode", installed: false }),
205
+ claude: makeStatus({ name: "claude", installed: false }),
206
+ codex: makeStatus({ name: "codex", installed: false }),
207
+ };
208
+
209
+ applyHarnessDiscovery(statuses, env);
210
+
211
+ expect(env.LLM_ADAPTER_CODEX_BIN).toBe("/custom/codex");
212
+ expect(env.POP_CODEX_BIN).toBe("/custom/codex");
213
+ });
214
+
215
+ it("keeps POP_* binary overrides ahead of discovered PATH binaries", () => {
216
+ const env: Record<string, string | undefined> = {
217
+ PATH: "/usr/bin",
218
+ POP_CODEX_BIN: "/custom/codex",
219
+ };
220
+ const statuses: Record<HarnessName, HarnessStatus> = {
221
+ opencode: makeStatus({ name: "opencode", installed: false }),
222
+ claude: makeStatus({ name: "claude", installed: false }),
223
+ codex: makeStatus({ name: "codex", installed: true, binPath: "/path/codex", authenticated: true }),
224
+ };
225
+
226
+ const messages = applyHarnessDiscovery(statuses, env);
227
+
228
+ expect(env.POP_CODEX_BIN).toBe("/custom/codex");
229
+ expect(env.LLM_ADAPTER_CODEX_BIN).toBe("/custom/codex");
230
+ expect(messages.find((m) => m.message.includes('"codex"'))).toMatchObject({
231
+ level: "info",
232
+ message: 'harness "codex": using configured binary override /custom/codex',
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,109 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { HarnessName, HarnessStatus } from "./index.ts";
5
+
6
+ type EnvLike = Record<string, string | undefined>;
7
+
8
+ export interface PreflightMessage {
9
+ level: "info" | "warn";
10
+ message: string;
11
+ }
12
+
13
+ /**
14
+ * Standard user/local bin dirs that interactive shells add (via .zshrc etc.) but
15
+ * GUI/launchd/cron launches usually miss.
16
+ */
17
+ function standardBinDirs(): string[] {
18
+ const home = homedir();
19
+ return [
20
+ join(home, ".opencode/bin"),
21
+ join(home, ".local/bin"),
22
+ join(home, ".bun/bin"),
23
+ join(home, ".cargo/bin"),
24
+ join(home, ".npm-global/bin"),
25
+ join(home, ".deno/bin"),
26
+ "/opt/homebrew/bin",
27
+ "/usr/local/bin",
28
+ ];
29
+ }
30
+
31
+ /**
32
+ * Build a PATH with known bin dirs prepended — keeping only dirs that exist
33
+ * and are not already present.
34
+ */
35
+ export function healedPath(basePath?: string, candidateDirs = standardBinDirs()): string {
36
+ const current = (basePath ?? process.env.PATH ?? "").split(":").filter(Boolean);
37
+ const seen = new Set(current);
38
+ const prepend: string[] = [];
39
+ for (const dir of candidateDirs) {
40
+ if (dir && !seen.has(dir) && existsSync(dir)) {
41
+ prepend.push(dir);
42
+ seen.add(dir);
43
+ }
44
+ }
45
+ return [...prepend, ...current].join(":");
46
+ }
47
+
48
+ /** Env var that pins a harness CLI to an absolute path, e.g. "opencode" → POP_OPENCODE_BIN. */
49
+ export function popBinEnvVar(name: HarnessName): string {
50
+ return `POP_${name.toUpperCase()}_BIN`;
51
+ }
52
+
53
+ /** Env var the adapter reads for binary override, e.g. "opencode" → LLM_ADAPTER_OPENCODE_BIN. */
54
+ export function adapterBinEnvVar(name: HarnessName): string {
55
+ return `LLM_ADAPTER_${name.toUpperCase()}_BIN`;
56
+ }
57
+
58
+ /**
59
+ * Apply discovery results to an env object — heal PATH, cache resolved absolute
60
+ * paths as both POP_*_BIN and LLM_ADAPTER_*_BIN, and return human-readable
61
+ * status/warning messages.
62
+ */
63
+ export function applyHarnessDiscovery(
64
+ statuses: Record<HarnessName, HarnessStatus>,
65
+ env: EnvLike,
66
+ ): PreflightMessage[] {
67
+ env.PATH = healedPath(env.PATH ?? "");
68
+
69
+ const messages: PreflightMessage[] = [];
70
+ for (const status of Object.values(statuses) as HarnessStatus[]) {
71
+ const adapterEnv = adapterBinEnvVar(status.name);
72
+ const popEnv = popBinEnvVar(status.name);
73
+ const explicitBin = env[popEnv] ?? env[adapterEnv];
74
+ const configuredBin = explicitBin ?? status.binPath;
75
+
76
+ if (configuredBin) {
77
+ env[adapterEnv] = configuredBin;
78
+ env[popEnv] = configuredBin;
79
+ }
80
+
81
+ if (explicitBin) {
82
+ messages.push({
83
+ level: "info",
84
+ message: `harness "${status.name}": using configured binary override ${configuredBin}`,
85
+ });
86
+ } else if (!status.installed && !configuredBin) {
87
+ messages.push({
88
+ level: "warn",
89
+ message: `harness "${status.name}": CLI not found on PATH or known install dirs; agent steps using it will fail until it is installed`,
90
+ });
91
+ } else if (status.authenticated === false) {
92
+ messages.push({
93
+ level: "warn",
94
+ message: `harness "${status.name}": found at ${status.binPath} but not authenticated; runs may fail until you log in`,
95
+ });
96
+ } else if (status.authenticated === null) {
97
+ messages.push({
98
+ level: "info",
99
+ message: `harness "${status.name}": ${status.binPath} (auth status unknown)`,
100
+ });
101
+ } else {
102
+ messages.push({
103
+ level: "info",
104
+ message: `harness "${status.name}": ${status.binPath} (authenticated)`,
105
+ });
106
+ }
107
+ }
108
+ return messages;
109
+ }