From 469fe95975cc885bf436117778426d918b8b957c Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:21:07 +0800 Subject: [PATCH 01/24] spec: workflow auto runner phase selection and retry semantics --- packages/control-plane/src/index.ts | 1 + .../control-plane/src/workflow-runner.test.ts | 200 ++++++++++++++++++ packages/control-plane/src/workflow-runner.ts | 48 +++++ 3 files changed, 249 insertions(+) create mode 100644 packages/control-plane/src/workflow-runner.test.ts create mode 100644 packages/control-plane/src/workflow-runner.ts diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index b037d2a..739ff31 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -1,2 +1,3 @@ export * from "./types.js"; export * from "./control-plane.js"; +export * from "./workflow-runner.js"; diff --git a/packages/control-plane/src/workflow-runner.test.ts b/packages/control-plane/src/workflow-runner.test.ts new file mode 100644 index 0000000..aaa01cd --- /dev/null +++ b/packages/control-plane/src/workflow-runner.test.ts @@ -0,0 +1,200 @@ +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { AdapterEvent, AgentAdapter, RunContext } from "@forge/shared-utils"; +import { ForgeWorkflowRunner } from "./workflow-runner.js"; + +const planTemplate = { + metadata: { + project: "forge", + created: new Date().toISOString(), + last_updated: new Date().toISOString(), + spec_version: "v2", + approved: true + }, + context: { + goals: ["x"], + constraints: ["y"], + tech_decisions: {}, + architecture: "modulith" + }, + tasks: [ + { + id: "task-1", + task_type: "implementation", + name: "Task 1", + description: "do things", + files: ["a.ts"], + dependencies: [], + acceptance_criteria: ["ok"], + verification_command: "echo ok", + tests: { + bdd_scenarios: ["Given state When action Then result"], + property_invariants: [], + contract_tests: [] + }, + documentation: { + updates: ["docs/architecture.md"], + decision_notes: "Initial behavior" + }, + status: "" + }, + { + id: "task-2", + task_type: "implementation", + name: "Task 2", + description: "do more", + files: ["b.ts"], + dependencies: ["task-1"], + acceptance_criteria: ["ok"], + verification_command: "echo ok", + tests: { + bdd_scenarios: ["Given state When action Then result"], + property_invariants: [], + contract_tests: [] + }, + documentation: { + updates: ["docs/architecture.md"], + decision_notes: "Initial behavior" + }, + status: "" + } + ] +}; + +function stubAdapter(observed: { contexts: RunContext[] }): AgentAdapter { + return { + async startRun(context) { + observed.contexts.push(context); + return { runId: `run-${context.taskId}-${String(observed.contexts.length)}` }; + }, + async *streamEvents(runId) { + yield { type: "run.started", runId, at: new Date().toISOString() } as const; + yield { type: "run.output", runId, stream: "stdout", chunk: "ok", at: new Date().toISOString() } as const; + yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; + }, + async resume(runId) { + return { runId, externalRunId: `external-${runId}` }; + }, + async cancel() { + return; + } + }; +} + +describe("ForgeWorkflowRunner", () => { + it("runs spec phase first for the next runnable task and records status", async () => { + // Given a workspace with a simple plan and all gates passing + const workspace = await mkdtemp(join(tmpdir(), "forge-workflow-runner-")); + const planPath = join(workspace, "plan.json"); + await writeFile(planPath, JSON.stringify(planTemplate, null, 2), "utf8"); + await mkdir(join(workspace, ".forge"), { recursive: true }); + + const observed = { contexts: [] as RunContext[] }; + const runner = new ForgeWorkflowRunner(workspace, () => stubAdapter(observed), { + gateRunner: async () => ({ ok: true, stdout: "pass", stderr: "", exitCode: 0 }), + git: { + async currentBranch() { + return "codex/test"; + }, + async commit() { + return; + }, + async push() { + return; + } + } + }); + + // When we run one workflow step + const result = await runner.runAuto(planPath, "codex", { maxRetries: 1, push: false }); + + // Then it starts with the first runnable task in spec phase + expect(result.state).toBe("running"); + expect(result.taskId).toBe("task-1"); + expect(result.phase).toBe("spec"); + expect(observed.contexts[0]?.taskId).toBe("task-1"); + expect(observed.contexts[0]?.prompt).toContain("Phase: spec"); + + // And it records spec completion status into the plan file + const updated = JSON.parse(await readFile(planPath, "utf8")) as typeof planTemplate; + expect(updated.tasks[0]?.status).toBe("spec"); + expect(updated.tasks[1]?.status).toBe(""); + }); + + it("skips blocked tasks until dependencies are completed", async () => { + // Given a plan where task-1 is completed + const workspace = await mkdtemp(join(tmpdir(), "forge-workflow-runner-deps-")); + const plan = structuredClone(planTemplate); + plan.tasks[0]!.status = "completed"; + const planPath = join(workspace, "plan.json"); + await writeFile(planPath, JSON.stringify(plan, null, 2), "utf8"); + await mkdir(join(workspace, ".forge"), { recursive: true }); + + const observed = { contexts: [] as RunContext[] }; + const runner = new ForgeWorkflowRunner(workspace, () => stubAdapter(observed), { + gateRunner: async () => ({ ok: true, stdout: "pass", stderr: "", exitCode: 0 }), + git: { + async currentBranch() { + return "codex/test"; + }, + async commit() { + return; + }, + async push() { + return; + } + } + }); + + // When we run one workflow step + const result = await runner.runAuto(planPath, "codex", { maxRetries: 1, push: false }); + + // Then it picks task-2 as runnable and starts at spec + expect(result.state).toBe("running"); + expect(result.taskId).toBe("task-2"); + expect(result.phase).toBe("spec"); + }); + + it("retries a phase when the gate fails and pauses after max retries", async () => { + // Given a gate that fails twice + const workspace = await mkdtemp(join(tmpdir(), "forge-workflow-runner-retry-")); + const planPath = join(workspace, "plan.json"); + await writeFile(planPath, JSON.stringify(planTemplate, null, 2), "utf8"); + await mkdir(join(workspace, ".forge"), { recursive: true }); + + const observed = { contexts: [] as RunContext[] }; + let attempts = 0; + const events: AdapterEvent[] = []; + const adapter = stubAdapter(observed); + const runner = new ForgeWorkflowRunner(workspace, () => adapter, { + gateRunner: async () => { + attempts += 1; + return { ok: false, stdout: "nope", stderr: "", exitCode: 1, name: "gate" }; + }, + git: { + async currentBranch() { + return "codex/test"; + }, + async commit() { + return; + }, + async push() { + return; + } + }, + onAdapterEvent: (event) => events.push(event) + }); + + // When we run auto with maxRetries=2 + const result = await runner.runAuto(planPath, "codex", { maxRetries: 2, push: false }); + + // Then it pauses the workflow after exhausting retries + expect(result.state).toBe("paused"); + expect(result.taskId).toBe("task-1"); + expect(result.phase).toBe("spec"); + expect(attempts).toBe(2); + }); +}); + diff --git a/packages/control-plane/src/workflow-runner.ts b/packages/control-plane/src/workflow-runner.ts new file mode 100644 index 0000000..89fa9a9 --- /dev/null +++ b/packages/control-plane/src/workflow-runner.ts @@ -0,0 +1,48 @@ +import type { AdapterEvent } from "@forge/shared-utils"; +import type { AdapterFactory, AdapterType } from "./types.js"; + +export type GateResult = { + ok: boolean; + name?: string; + stdout: string; + stderr: string; + exitCode: number; +}; + +export type WorkflowGitClient = { + currentBranch(): Promise; + commit(message: string): Promise; + push(remote: string): Promise; +}; + +export type WorkflowRunnerDeps = { + gateRunner: (phase: string, cwd: string) => Promise; + git: WorkflowGitClient; + onAdapterEvent?: (event: AdapterEvent) => void; +}; + +export type WorkflowAutoOptions = { + maxRetries: number; + push: boolean; + remote?: string; +}; + +export type WorkflowAutoResult = + | { state: "running"; taskId: string; phase: string } + | { state: "paused"; taskId: string; phase: string; message: string } + | { state: "completed"; message: string }; + +export class ForgeWorkflowRunner { + constructor( + private readonly workspaceRoot: string, + private readonly adapterFactory: AdapterFactory, + private readonly deps: WorkflowRunnerDeps + ) {} + + async runAuto(planPath: string, adapterType: AdapterType, options: WorkflowAutoOptions): Promise { + void planPath; + void adapterType; + void options; + throw new Error("not implemented"); + } +} From 7471cfdde0c82b67ee2cd174239874defba3ed06 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:22:46 +0800 Subject: [PATCH 02/24] implement: workflow auto runner executes one phase and updates plan status --- packages/control-plane/src/workflow-runner.ts | 151 +++++++++++++++++- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/packages/control-plane/src/workflow-runner.ts b/packages/control-plane/src/workflow-runner.ts index 89fa9a9..a924477 100644 --- a/packages/control-plane/src/workflow-runner.ts +++ b/packages/control-plane/src/workflow-runner.ts @@ -1,4 +1,5 @@ -import type { AdapterEvent } from "@forge/shared-utils"; +import { readFile, writeFile } from "node:fs/promises"; +import type { AdapterEvent, RunContext } from "@forge/shared-utils"; import type { AdapterFactory, AdapterType } from "./types.js"; export type GateResult = { @@ -40,9 +41,149 @@ export class ForgeWorkflowRunner { ) {} async runAuto(planPath: string, adapterType: AdapterType, options: WorkflowAutoOptions): Promise { - void planPath; - void adapterType; - void options; - throw new Error("not implemented"); + const remote = options.remote ?? "origin"; + + const branch = await this.deps.git.currentBranch(); + if (branch === "main") { + return { state: "paused", taskId: "", phase: "", message: "Refusing to run on main; use a codex/* branch." }; + } + + const plan = await readJson(planPath); + const nextTask = selectNextRunnableTask(plan); + if (!nextTask) { + return { state: "completed", message: "No runnable tasks remain" }; + } + + const phase = nextPhase(nextTask.status); + if (!phase) { + // Should not happen because completed tasks are filtered out. + nextTask.status = "completed"; + await writeJson(planPath, plan); + return { state: "running", taskId: nextTask.id, phase: "completed" }; + } + + const adapter = this.adapterFactory(adapterType); + const approvalMode = resolveApprovalMode(); + + const basePrompt = renderPrompt(planPath, nextTask, phase); + for (let attempt = 1; attempt <= Math.max(1, options.maxRetries); attempt += 1) { + const prompt = attempt === 1 ? basePrompt : `${basePrompt}\n\nRetry ${String(attempt)}: Fix gate failures and try again.`; + const ctx: RunContext = { + taskId: nextTask.id, + prompt, + workingDirectory: this.workspaceRoot, + allowedTools: [], + approvalMode + }; + + const handle = await adapter.startRun(ctx); + for await (const event of adapter.streamEvents(handle.runId)) { + this.deps.onAdapterEvent?.(event); + } + + const gate = await this.deps.gateRunner(phase, this.workspaceRoot); + if (gate.ok) { + // Mark phase as completed (plan schema does not track "commit" as a status). + if (phase !== "commit") { + nextTask.status = phase; + await writeJson(planPath, plan); + } + + const commitPrefix = phaseCommitPrefix(phase); + if (commitPrefix) { + await this.deps.git.commit(`${commitPrefix}: ${nextTask.id} - ${nextTask.name}`); + } + + if (phase === "commit") { + nextTask.status = "completed"; + await writeJson(planPath, plan); + if (options.push) { + await this.deps.git.push(remote); + } + } + + return { state: "running", taskId: nextTask.id, phase }; + } + } + + return { + state: "paused", + taskId: nextTask.id, + phase, + message: `Gate failed for phase '${phase}' after ${String(options.maxRetries)} attempts.` + }; } } + +type PlanLike = { + tasks: Array<{ + id: string; + task_type: string; + name: string; + description: string; + dependencies: string[]; + status?: "" | "spec" | "implement" | "refactor" | "document" | "completed"; + }>; +}; + +async function readJson(path: string): Promise { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as PlanLike; +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function resolveApprovalMode(): NonNullable { + return process.stdin.isTTY ? "suggest" : "full-auto"; +} + +function isCompletedStatus(status: PlanLike["tasks"][number]["status"]): boolean { + return status === "completed"; +} + +function selectNextRunnableTask(plan: PlanLike): PlanLike["tasks"][number] | undefined { + const tasksById = new Map(plan.tasks.map((t) => [t.id, t])); + return plan.tasks.find((task) => { + if (isCompletedStatus(task.status)) return false; + return task.dependencies.every((dep) => tasksById.get(dep)?.status === "completed"); + }); +} + +type Phase = "spec" | "implement" | "refactor" | "document" | "commit"; + +function nextPhase(status: PlanLike["tasks"][number]["status"]): Phase | undefined { + const s = status ?? ""; + if (s === "") return "spec"; + if (s === "spec") return "implement"; + if (s === "implement") return "refactor"; + if (s === "refactor") return "document"; + if (s === "document") return "commit"; + return undefined; +} + +function phaseCommitPrefix(phase: Phase): string | null { + switch (phase) { + case "spec": + return "spec"; + case "implement": + return "implement"; + case "refactor": + return "refactor"; + case "document": + return "docs"; + case "commit": + return null; + } +} + +function renderPrompt(planPath: string, task: PlanLike["tasks"][number], phase: Phase): string { + return [ + `Plan: ${planPath}`, + `Task: ${task.id} - ${task.name}`, + `Phase: ${phase}`, + "", + task.description + ].join("\n"); +} From fd30c5da611790ed2b248f2190bb989d115cd925 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:31:24 +0800 Subject: [PATCH 03/24] implement: add forge workflow auto command (dry-run + phase gates) --- bun.lock | 2 + packages/cli/package.json | 2 + packages/cli/src/cli.test.ts | 81 ++++++++++++++++++++ packages/cli/src/cli.ts | 139 ++++++++++++++++++++++++++++++++++- packages/cli/tsconfig.json | 2 + 5 files changed, 225 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index f3db674..8b0f2b7 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,8 @@ "forge": "dist/bin.js", }, "dependencies": { + "@forge/adapter-claude": "0.1.0", + "@forge/adapter-codex": "0.1.0", "@forge/check-runner": "0.1.0", "@forge/contracts": "0.1.0", "@forge/control-plane": "0.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 45fce54..5f0035f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,8 @@ "typecheck": "tsc -b" }, "dependencies": { + "@forge/adapter-claude": "0.1.0", + "@forge/adapter-codex": "0.1.0", "@forge/check-runner": "0.1.0", "@forge/contracts": "0.1.0", "@forge/control-plane": "0.1.0", diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index a387d78..9468104 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -62,6 +62,11 @@ describe("cli", () => { const runCommands = run?.commands.map((command) => command.name()) ?? []; expect(runCommands).toContain("next"); expect(runCommands).toContain("resume"); + + const workflow = cli.commands.find((command) => command.name() === "workflow"); + const workflowCommands = workflow?.commands.map((command) => command.name()) ?? []; + expect(workflowCommands).toContain("check"); + expect(workflowCommands).toContain("auto"); }); it("runs init with --skip-guidance and emits JSON", async () => { @@ -157,6 +162,82 @@ describe("cli", () => { } }); + it("runs workflow auto in --dry-run mode and updates plan task status", async () => { + // Given a temp workspace with a valid plan file + const workspace = await mkdtemp(join(tmpdir(), "forge-cli-workflow-auto-")); + const planPath = join(workspace, "plan.json"); + await writeFile( + planPath, + JSON.stringify( + { + metadata: { + project: "forge", + created: new Date().toISOString(), + last_updated: new Date().toISOString(), + spec_version: "v2", + approved: true + }, + context: { + goals: ["x"], + constraints: ["y"], + tech_decisions: {}, + architecture: "modulith" + }, + tasks: [ + { + id: "task-1", + task_type: "implementation", + name: "Task", + description: "do things", + files: ["a.ts"], + dependencies: [], + acceptance_criteria: ["ok"], + verification_command: "echo ok", + tests: { bdd_scenarios: ["Given x When y Then z"], property_invariants: [], contract_tests: [] }, + documentation: { updates: ["docs/architecture.md"], decision_notes: "x" }, + status: "" + } + ] + }, + null, + 2 + ), + "utf8" + ); + + // When workflow auto is executed in dry-run mode + const previous = process.cwd(); + process.chdir(workspace); + try { + const { stdout, error } = await captureStdio(async () => { + const cli = buildCli(); + await cli.parseAsync([ + "node", + "forge", + "workflow", + "auto", + "--plan", + planPath, + "--adapter", + "codex", + "--dry-run", + "--json" + ]); + }); + + // Then it succeeds and reports completion + expect(error).toBeUndefined(); + const parsed = JSON.parse(stdout) as { state: string }; + expect(parsed.state).toBe("completed"); + + // And the plan file is updated to mark the task completed + const updated = JSON.parse(await readFile(planPath, "utf8")) as { tasks: Array<{ status?: string }> }; + expect(updated.tasks[0]?.status).toBe("completed"); + } finally { + process.chdir(previous); + } + }); + it("fails install-guidance when --source path is used without --path", async () => { const { stderr, error } = await captureStdio(async () => { const cli = buildCli(); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e4b6804..6e7646e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,7 +2,10 @@ import { join, resolve } from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { Command } from "commander"; -import { ForgeControlPlane } from "@forge/control-plane"; +import { ForgeControlPlane, ForgeWorkflowRunner } from "@forge/control-plane"; +import { CodexAdapter } from "@forge/adapter-codex"; +import { ClaudeAdapter } from "@forge/adapter-claude"; +import { exists, readJsonFile, runCommand } from "@forge/shared-utils"; import { getBundledGuidanceRoot, installGuidance, @@ -422,9 +425,143 @@ export function buildCli(): Command { } }); + workflow + .command("auto") + .requiredOption("--plan ", "plan path") + .option("--adapter ", "codex|claude", "codex") + .option("--max-retries ", "max retries per phase", "3") + .option("--push", "push after each completed task", true) + .option("--no-push", "disable pushing") + .option("--remote ", "git remote name", "origin") + .option("--dry-run", "do not run agents/gates/git; only simulate plan status updates", false) + .option("--json", "machine output") + .action(async (options: JsonFlag & { plan: string; adapter: AdapterName; maxRetries: string; push: boolean; remote: string; dryRun?: boolean }) => { + try { + const workspaceRoot = process.cwd(); + const maxRetries = Number.parseInt(options.maxRetries, 10); + if (!Number.isFinite(maxRetries) || maxRetries < 1) { + throw new Error("--max-retries must be a positive integer"); + } + + const runner = new ForgeWorkflowRunner( + workspaceRoot, + (type) => { + if (options.dryRun) { + const startRun = () => Promise.resolve({ runId: "dry-run" }); + const streamEvents = async function* (runId: string) { + // Keep the generator async to match the adapter interface contract. + await Promise.resolve(); + yield { type: "run.started", runId, at: new Date().toISOString() } as const; + yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; + }; + const resume = (runId: string) => Promise.resolve({ runId }); + const cancel = () => Promise.resolve(); + return { + startRun, + streamEvents, + resume, + cancel + }; + } + return type === "codex" ? new CodexAdapter() : new ClaudeAdapter(); + }, + { + gateRunner: async (phase: string, cwd: string) => { + if (options.dryRun) { + return { ok: true, stdout: "dry-run", stderr: "", exitCode: 0 }; + } + // Phase gates are simple scripts; run via bash so executable bits are not required. + const scriptPath = await resolvePhaseGateScript(workspaceRoot, phase); + const result = await runCommand("bash", [scriptPath], cwd); + return { ok: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; + }, + git: { + async currentBranch() { + if (options.dryRun) return "codex/dry-run"; + const res = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot); + return res.stdout.trim(); + }, + async commit(message: string) { + if (options.dryRun) return; + await runCommand("git", ["add", "-A"], workspaceRoot); + const res = await runCommand("git", ["commit", "-m", message], workspaceRoot); + if (res.exitCode !== 0) { + throw new Error(res.stderr || res.stdout || "git commit failed"); + } + }, + async push(remote: string) { + if (options.dryRun) return; + const branchRes = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot); + const branch = branchRes.stdout.trim(); + const res = await runCommand("git", ["push", "-u", remote, branch], workspaceRoot); + if (res.exitCode !== 0) { + throw new Error(res.stderr || res.stdout || "git push failed"); + } + } + } + } + ); + + // Loop until plan is fully completed or the workflow pauses. + // Keep a hard cap to avoid infinite loops on buggy status transitions. + const maxSteps = 5000; + for (let i = 0; i < maxSteps; i += 1) { + const step = await runner.runAuto(resolve(options.plan), options.adapter, { + maxRetries, + push: options.push && !options.dryRun, + remote: options.remote + }); + + if (step.state === "running") { + continue; + } + output(options.json ? step : step.message, options.json); + return; + } + + throw new Error("workflow auto aborted: exceeded max steps"); + } catch (error) { + if (error instanceof CliExit) { + throw error; + } + fail(error); + } + }); + return program; } +async function resolvePhaseGateScript(workspaceRoot: string, phase: string): Promise { + const phaseGatesPath = join(workspaceRoot, ".forge", "phase-gates.json"); + + if (await exists(phaseGatesPath)) { + const parsed = await readJsonFile>(phaseGatesPath); + const phases = + typeof parsed.phases === "object" && parsed.phases ? (parsed.phases as Record) : parsed; + const entry = phases[phase]; + if (typeof entry === "string" && entry.trim()) { + return resolve(workspaceRoot, entry); + } + } + + // Fall back to installed guidance pack defaults (from .forge/guidance.json -> manifest.json). + const guidancePath = join(workspaceRoot, ".forge", "guidance.json"); + const guidance = await readJsonFile<{ pack?: { path?: string } }>(guidancePath); + const packRoot = guidance.pack?.path; + if (!packRoot) { + throw new Error(`Unable to resolve phase gate script for '${phase}': missing .forge/phase-gates.json and .forge/guidance.json`); + } + + const manifest = await readJsonFile<{ default_phase_gate_bindings?: Record }>( + join(packRoot, "manifest.json") + ); + const rel = manifest.default_phase_gate_bindings?.[phase]; + if (!rel) { + throw new Error(`Unable to resolve phase gate script for '${phase}': no binding in pack manifest`); + } + return resolve(packRoot, rel); +} + export async function runCli(argv: string[]): Promise { const cli = buildCli(); await cli.parseAsync(argv); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 0778eb9..dd3c007 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,6 +5,8 @@ "rootDir": "src" }, "references": [ + { "path": "../adapter-claude" }, + { "path": "../adapter-codex" }, { "path": "../contracts" }, { "path": "../control-plane" }, { "path": "../guidance-pack" }, From 469f4edd0681489a2d5e0b0a77701f607d4cf4a8 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:36:07 +0800 Subject: [PATCH 04/24] spec: codex app-server adapter streams deltas and completes --- .../src/app-server-adapter.test.ts | 79 +++++++++++++++++++ .../adapter-codex/src/app-server-adapter.ts | 29 +++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/adapter-codex/src/app-server-adapter.test.ts create mode 100644 packages/adapter-codex/src/app-server-adapter.ts diff --git a/packages/adapter-codex/src/app-server-adapter.test.ts b/packages/adapter-codex/src/app-server-adapter.test.ts new file mode 100644 index 0000000..8c0eede --- /dev/null +++ b/packages/adapter-codex/src/app-server-adapter.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { RunContext } from "@forge/shared-utils"; +import { CodexAppServerAdapter } from "./app-server-adapter.js"; + +describe("CodexAppServerAdapter", () => { + it("initializes, starts a thread, streams deltas, and completes on turn/completed", async () => { + // Given a fake app-server that speaks the JSONL protocol + const dir = await mkdtemp(join(tmpdir(), "forge-codex-appserver-fake-")); + const serverPath = join(dir, "server.mjs"); + await writeFile( + serverPath, + ` +import { createInterface } from "node:readline"; + +const rl = createInterface({ input: process.stdin }); +let initialized = false; +let threadId = "thread-1"; +let turnId = "turn-1"; + +function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } + +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") { + send({ id: msg.id, result: { userAgent: "fake" } }); + return; + } + if (msg.method === "initialized") { + initialized = true; + return; + } + if (!initialized) { + send({ id: msg.id, error: { message: "not initialized" } }); + return; + } + if (msg.method === "thread/start") { + send({ id: msg.id, result: { thread: { id: threadId } } }); + return; + } + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: turnId, status: "inProgress", items: [] } } }); + send({ method: "item/agentMessage/delta", params: { delta: "hello", itemId: "item-1", threadId, turnId } }); + send({ method: "turn/completed", params: { threadId, turn: { id: turnId, status: "completed", items: [] } } }); + return; + } +}); + `.trim(), + "utf8" + ); + + const adapter = new CodexAppServerAdapter({ + spawnCommand: "node", + spawnArgs: [serverPath] + }); + + const context: RunContext = { + taskId: "task-1", + prompt: "do the thing", + workingDirectory: dir, + allowedTools: [], + approvalMode: "full-auto" + }; + + // When a run is started and streamed + const handle = await adapter.startRun(context); + const chunks: string[] = []; + for await (const event of adapter.streamEvents(handle.runId)) { + if (event.type === "run.output") chunks.push(event.chunk); + if (event.type === "run.failed") throw new Error(event.reason); + } + + // Then it streamed assistant deltas and completed + expect(chunks.join("")).toContain("hello"); + }); +}); + diff --git a/packages/adapter-codex/src/app-server-adapter.ts b/packages/adapter-codex/src/app-server-adapter.ts new file mode 100644 index 0000000..e6e05b8 --- /dev/null +++ b/packages/adapter-codex/src/app-server-adapter.ts @@ -0,0 +1,29 @@ +import type { AdapterEvent, AgentAdapter, RunContext, RunHandle } from "@forge/shared-utils"; + +export type CodexAppServerAdapterOptions = { + spawnCommand?: string; + spawnArgs?: string[]; +}; + +export class CodexAppServerAdapter implements AgentAdapter { + constructor(private readonly options: CodexAppServerAdapterOptions = {}) { + void options; + } + + startRun(_context: RunContext): Promise { + return Promise.resolve({ runId: "unimplemented" }); + } + + async *streamEvents(runId: string): AsyncIterable { + yield { type: "run.failed", runId, reason: "not implemented", at: new Date().toISOString() } as const; + } + + resume(runId: string): Promise { + return Promise.resolve({ runId }); + } + + cancel(_runId: string): Promise { + return Promise.resolve(); + } +} + From ead5734ee32cbe93b2ad400536428eaf47a2b3af Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:39:01 +0800 Subject: [PATCH 05/24] implement: codex app-server adapter (thread/turn + delta streaming) --- .../adapter-codex/src/app-server-adapter.ts | 317 +++++++++++++++++- 1 file changed, 309 insertions(+), 8 deletions(-) diff --git a/packages/adapter-codex/src/app-server-adapter.ts b/packages/adapter-codex/src/app-server-adapter.ts index e6e05b8..b73ee3b 100644 --- a/packages/adapter-codex/src/app-server-adapter.ts +++ b/packages/adapter-codex/src/app-server-adapter.ts @@ -1,3 +1,7 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { AdapterEvent, AgentAdapter, RunContext, RunHandle } from "@forge/shared-utils"; export type CodexAppServerAdapterOptions = { @@ -5,25 +9,322 @@ export type CodexAppServerAdapterOptions = { spawnArgs?: string[]; }; -export class CodexAppServerAdapter implements AgentAdapter { - constructor(private readonly options: CodexAppServerAdapterOptions = {}) { - void options; +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +type JsonRpcRequest = { id: number | string; method: string; params?: unknown }; +type JsonRpcResponse = { id: number | string; result?: unknown; error?: unknown }; +type JsonRpcNotification = { method: string; params?: unknown }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function safeJsonParse(line: string): unknown { + try { + return JSON.parse(line) as unknown; + } catch { + return undefined; } +} + +function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + return isRecord(value) && "id" in value && typeof value.method === "string"; +} + +class AsyncQueue { + private readonly items: T[] = []; + private readonly waiters: Array<(value: T) => void> = []; - startRun(_context: RunContext): Promise { - return Promise.resolve({ runId: "unimplemented" }); + push(value: T): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(value); + return; + } + this.items.push(value); } - async *streamEvents(runId: string): AsyncIterable { - yield { type: "run.failed", runId, reason: "not implemented", at: new Date().toISOString() } as const; + async shift(): Promise { + const next = this.items.shift(); + if (next !== undefined) return next; + return await new Promise((resolve) => this.waiters.push(resolve)); + } +} + +class CodexAppServerClient { + private child: ChildProcessWithoutNullStreams | null = null; + private readonly pending = new Map(); + private readonly notifications = new AsyncQueue(); + private nextId = 1; + private initialized = false; + + constructor(private readonly options: CodexAppServerAdapterOptions) {} + + async ensureStarted(): Promise { + if (this.initialized) return; + + if (!this.child) { + const command = this.options.spawnCommand ?? "codex"; + const args = this.options.spawnArgs ?? ["app-server"]; + this.child = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"] + }); + + const stdoutRl = createInterface({ input: this.child.stdout }); + stdoutRl.on("line", (line) => { + const parsed = safeJsonParse(line.trim()); + if (!parsed || !isRecord(parsed)) return; + + // Response + if ("id" in parsed && ("result" in parsed || "error" in parsed)) { + const id = parsed.id as number | string; + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + if ("error" in parsed && parsed.error) { + pending.reject(new Error(JSON.stringify(parsed.error))); + } else { + pending.resolve(parsed.result); + } + return; + } + + // Request or notification + if (typeof parsed.method === "string") { + if ("id" in parsed) { + this.notifications.push(parsed as JsonRpcRequest); + } else { + this.notifications.push(parsed as JsonRpcNotification); + } + } + }); + + this.child.on("exit", () => { + for (const [, pending] of this.pending) { + pending.reject(new Error("codex app-server exited")); + } + this.pending.clear(); + }); + } + + // initialize + const initResult = await this.request("initialize", { + clientInfo: { name: "forge", version: "0.1.0" }, + capabilities: { experimentalApi: true } + }); + void initResult; + this.notify("initialized"); + this.initialized = true; + } + + async request(method: string, params?: unknown): Promise { + if (!this.child) throw new Error("app-server not started"); + const id = this.nextId++; + const payload: JsonRpcRequest = { id, method, ...(params !== undefined ? { params } : {}) }; + const line = JSON.stringify(payload); + this.child.stdin.write(`${line}\n`); + return await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + } + + notify(method: string, params?: unknown): void { + if (!this.child) throw new Error("app-server not started"); + const payload: JsonRpcNotification = { method, ...(params !== undefined ? { params } : {}) }; + this.child.stdin.write(`${JSON.stringify(payload)}\n`); + } + + async nextNotification(): Promise { + return await this.notifications.shift(); + } + + respond(id: number | string, result: unknown): void { + if (!this.child) throw new Error("app-server not started"); + const payload: JsonRpcResponse = { id, result }; + this.child.stdin.write(`${JSON.stringify(payload)}\n`); + } + + kill(): void { + if (!this.child) return; + this.child.kill(); + this.child = null; + this.initialized = false; + } +} + +type RunState = { + context: RunContext; + externalRunId?: string; // threadId +}; + +export class CodexAppServerAdapter implements AgentAdapter { + private readonly client: CodexAppServerClient; + private readonly runs = new Map(); + private readonly threadsByTask = new Map(); + + constructor(private readonly options: CodexAppServerAdapterOptions = {}) { + this.client = new CodexAppServerClient(options); + } + + startRun(context: RunContext): Promise { + const runId = randomUUID(); + this.runs.set(runId, { context }); + return Promise.resolve({ runId }); } resume(runId: string): Promise { + const run = this.runs.get(runId); + if (!run) return Promise.reject(new Error(`run not found: ${runId}`)); + if (run.externalRunId) return Promise.resolve({ runId, externalRunId: run.externalRunId }); return Promise.resolve({ runId }); } - cancel(_runId: string): Promise { + cancel(runId: string): Promise { + this.runs.delete(runId); + // If we ever implement turn/interrupt, we'd do it here. + void runId; return Promise.resolve(); } + + async *streamEvents(runId: string): AsyncIterable { + const run = this.runs.get(runId); + if (!run) { + yield { type: "run.failed", runId, reason: "unknown run", at: new Date().toISOString() } as const; + return; + } + + yield { type: "run.started", runId, at: new Date().toISOString() } as const; + + await this.client.ensureStarted(); + + const threadId = await this.ensureThread(run.context); + run.externalRunId = threadId; + + const turnResult = await this.client.request("turn/start", { + threadId, + cwd: run.context.workingDirectory, + input: [{ type: "text", text: run.context.prompt }] + }); + + const turnId = extractTurnId(turnResult); + if (!turnId) { + yield { + type: "run.failed", + runId, + reason: "codex app-server: missing turn id", + at: new Date().toISOString() + } as const; + return; + } + + for (;;) { + const msg = await this.client.nextNotification(); + + // Auto-respond to server-initiated requests (approvals / user input). + if (isJsonRpcRequest(msg)) { + this.handleServerRequest(msg); + continue; + } + + if (!isRecord(msg) || typeof msg.method !== "string" || !isRecord(msg.params)) { + continue; + } + + const method = msg.method; + const params = msg.params; + + if ( + method === "item/agentMessage/delta" && + params.threadId === threadId && + params.turnId === turnId && + typeof params.delta === "string" + ) { + yield { + type: "run.output", + runId, + stream: "stdout", + chunk: params.delta, + at: new Date().toISOString() + } as const; + continue; + } + + if (method === "turn/completed" && params.threadId === threadId && isRecord(params.turn) && params.turn.id === turnId) { + const status = params.turn.status; + if (status === "failed") { + const reason = isRecord(params.turn.error) && typeof params.turn.error.message === "string" ? params.turn.error.message : "turn failed"; + yield { type: "run.failed", runId, reason, at: new Date().toISOString() } as const; + return; + } + yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; + return; + } + } + } + + private async ensureThread(context: RunContext): Promise { + const existing = this.threadsByTask.get(context.taskId); + if (existing) return existing; + + const approvalPolicy = context.approvalMode === "full-auto" || context.approvalMode === "auto-edit" ? "never" : "on-request"; + + const result = await this.client.request("thread/start", { + cwd: context.workingDirectory, + sandbox: "workspace-write", + approvalPolicy + }); + const threadId = extractThreadId(result); + if (!threadId) { + throw new Error("codex app-server: missing thread id"); + } + this.threadsByTask.set(context.taskId, threadId); + return threadId; + } + + private handleServerRequest(req: JsonRpcRequest): void { + const method = req.method; + const params = req.params; + + if (method === "item/commandExecution/requestApproval") { + this.client.respond(req.id, { decision: "acceptForSession" }); + return; + } + if (method === "item/fileChange/requestApproval") { + this.client.respond(req.id, { decision: "acceptForSession" }); + return; + } + if (method === "item/tool/requestUserInput") { + const answers: Record = {}; + if (isRecord(params) && Array.isArray(params.questions)) { + for (const q of params.questions) { + if (!isRecord(q) || typeof q.id !== "string") continue; + const opts = Array.isArray(q.options) ? q.options : null; + const first = opts && isRecord(opts[0]) && typeof opts[0].label === "string" ? opts[0].label : ""; + answers[q.id] = { answers: first ? [first] : [] }; + } + } + this.client.respond(req.id, { answers }); + return; + } + + // Unknown request: decline by default. + this.client.respond(req.id, {}); + } +} + +function extractThreadId(result: unknown): string | undefined { + if (!isRecord(result)) return undefined; + const thread = result.thread; + if (isRecord(thread) && typeof thread.id === "string") return thread.id; + return undefined; } +function extractTurnId(result: unknown): string | undefined { + if (!isRecord(result)) return undefined; + const turn = result.turn; + if (isRecord(turn) && typeof turn.id === "string") return turn.id; + return undefined; +} From fdafaa5dee813046314d08df62fe6d2088a49ba0 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:45:06 +0800 Subject: [PATCH 06/24] implement: claude interactive adapter via PTY wrapper + hooks bridge --- .../adapter-claude/src/hook-bridge-runner.ts | 35 +++ .../adapter-claude/src/hook-bridge.test.ts | 29 +++ packages/adapter-claude/src/hook-bridge.ts | 50 ++++ packages/adapter-claude/src/index.ts | 1 + packages/adapter-claude/src/pty-adapter.ts | 238 ++++++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 packages/adapter-claude/src/hook-bridge-runner.ts create mode 100644 packages/adapter-claude/src/hook-bridge.test.ts create mode 100644 packages/adapter-claude/src/hook-bridge.ts create mode 100644 packages/adapter-claude/src/pty-adapter.ts diff --git a/packages/adapter-claude/src/hook-bridge-runner.ts b/packages/adapter-claude/src/hook-bridge-runner.ts new file mode 100644 index 0000000..5842c36 --- /dev/null +++ b/packages/adapter-claude/src/hook-bridge-runner.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { handleClaudeHookPayload } from "./hook-bridge.js"; + +async function postJson(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }).catch(() => null); + if (!res || !res.ok) { + // Best-effort: never block Claude Code on hook transport failures. + return; + } +} + +async function main(): Promise { + const raw = readFileSync(0, "utf8"); + const payload = raw.trim() ? (JSON.parse(raw) as unknown) : {}; + const hookUrl = typeof process.env.FORGE_HOOK_URL === "string" ? process.env.FORGE_HOOK_URL : undefined; + const result = handleClaudeHookPayload(payload, hookUrl ? { hookUrl } : {}); + + if (result.post && result.post.url) { + await postJson(result.post.url, result.post.body); + } + + if (result.stdout) { + process.stdout.write(result.stdout); + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/packages/adapter-claude/src/hook-bridge.test.ts b/packages/adapter-claude/src/hook-bridge.test.ts new file mode 100644 index 0000000..c8f3537 --- /dev/null +++ b/packages/adapter-claude/src/hook-bridge.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { handleClaudeHookPayload } from "./hook-bridge.js"; + +describe("handleClaudeHookPayload", () => { + it("emits an allow decision for PreToolUse", () => { + // Given a PreToolUse hook payload + const payload = { hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "echo hi" } }; + + // When it is handled + const result = handleClaudeHookPayload(payload, { hookUrl: "http://localhost:1234/hook" }); + + // Then stdout contains an allow response Claude Code can consume + expect(result.stdout).toBeDefined(); + const parsed = JSON.parse(result.stdout ?? "{}") as { hookSpecificOutput?: { permissionDecision?: string } }; + expect(parsed.hookSpecificOutput?.permissionDecision).toBe("allow"); + }); + + it("does not emit stdout for Stop", () => { + // Given a Stop hook payload + const payload = { hook_event_name: "Stop" }; + + // When it is handled + const result = handleClaudeHookPayload(payload, { hookUrl: "http://localhost:1234/hook" }); + + // Then no stdout override is required + expect(result.stdout).toBeUndefined(); + }); +}); + diff --git a/packages/adapter-claude/src/hook-bridge.ts b/packages/adapter-claude/src/hook-bridge.ts new file mode 100644 index 0000000..b8f0687 --- /dev/null +++ b/packages/adapter-claude/src/hook-bridge.ts @@ -0,0 +1,50 @@ +type HookBridgeEnv = { + hookUrl?: string; +}; + +type HookBridgeResult = { + // If set, printed to stdout for Claude Code to consume as the hook result. + stdout?: string; + // If set, the bridge runner should POST this payload to the orchestrator. + post?: { url: string; body: unknown }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function allowAllToolsResponse(): unknown { + // Claude Code hook output format for PreToolUse: can decide allow/deny. + // See: https://docs.anthropic.com/en/docs/claude-code/hooks + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: "forge workflow auto" + } + }; +} + +export function handleClaudeHookPayload(payload: unknown, env: HookBridgeEnv): HookBridgeResult { + const hookUrl = typeof env.hookUrl === "string" ? env.hookUrl : ""; + + const eventName = + isRecord(payload) && typeof payload.hook_event_name === "string" + ? payload.hook_event_name + : isRecord(payload) && typeof payload.hookEventName === "string" + ? payload.hookEventName + : ""; + + const result: HookBridgeResult = {}; + + if (hookUrl) { + result.post = { url: hookUrl, body: payload }; + } + + if (eventName === "PreToolUse") { + result.stdout = `${JSON.stringify(allowAllToolsResponse())}\n`; + } + + return result; +} + diff --git a/packages/adapter-claude/src/index.ts b/packages/adapter-claude/src/index.ts index e202a52..f1e8b37 100644 --- a/packages/adapter-claude/src/index.ts +++ b/packages/adapter-claude/src/index.ts @@ -1 +1,2 @@ export * from "./adapter.js"; +export * from "./pty-adapter.js"; diff --git a/packages/adapter-claude/src/pty-adapter.ts b/packages/adapter-claude/src/pty-adapter.ts new file mode 100644 index 0000000..63deb59 --- /dev/null +++ b/packages/adapter-claude/src/pty-adapter.ts @@ -0,0 +1,238 @@ +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import type { AdapterEvent, AgentAdapter, RunContext, RunHandle } from "@forge/shared-utils"; + +type HookEvent = { hook_event_name?: string; hookEventName?: string; session_id?: string; sessionId?: string }; + +class AsyncQueue { + private readonly items: T[] = []; + private readonly waiters: Array<(value: T) => void> = []; + + push(value: T): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(value); + return; + } + this.items.push(value); + } + + drain(): void { + this.items.splice(0, this.items.length); + } + + async shift(): Promise { + const next = this.items.shift(); + if (next !== undefined) return next; + return await new Promise((resolve) => this.waiters.push(resolve)); + } +} + +function hookEventName(payload: HookEvent): string { + return payload.hook_event_name ?? payload.hookEventName ?? ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +async function writeJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +class ClaudeHookServer { + private readonly events = new AsyncQueue(); + private readonly server = createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/hook") { + res.statusCode = 404; + res.end(); + return; + } + + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString("utf8"); + }); + req.on("end", () => { + try { + const parsed = JSON.parse(body) as unknown; + if (isRecord(parsed)) { + this.events.push(parsed as HookEvent); + } + } catch { + // ignore + } + res.statusCode = 200; + res.end("ok"); + }); + }); + + private listening: Promise | null = null; + + async url(): Promise { + if (!this.listening) { + this.listening = new Promise((resolve) => { + this.server.listen(0, "127.0.0.1", () => { + const addr = this.server.address(); + if (addr && typeof addr === "object") { + resolve(`http://127.0.0.1:${String(addr.port)}/hook`); + return; + } + resolve("http://127.0.0.1:0/hook"); + }); + }); + } + return await this.listening; + } + + drain(): void { + this.events.drain(); + } + + async waitForStop(): Promise { + for (;;) { + const ev = await this.events.shift(); + if (hookEventName(ev) === "Stop") return ev; + } + } +} + +type RunState = { + context: RunContext; + externalRunId?: string; // claude session id (best-effort) +}; + +type TaskSession = { + child: ChildProcessWithoutNullStreams; + hookServer: ClaudeHookServer; + externalRunId?: string; +}; + +export class ClaudePtyAdapter implements AgentAdapter { + private readonly runs = new Map(); + private readonly sessionsByTask = new Map(); + private readonly hookServer = new ClaudeHookServer(); + + constructor(private readonly command = "claude") {} + + startRun(context: RunContext): Promise { + const runId = randomUUID(); + this.runs.set(runId, { context }); + return Promise.resolve({ runId }); + } + + resume(runId: string): Promise { + const run = this.runs.get(runId); + if (!run) return Promise.reject(new Error(`run not found: ${runId}`)); + if (run.externalRunId) return Promise.resolve({ runId, externalRunId: run.externalRunId }); + return Promise.resolve({ runId }); + } + + cancel(runId: string): Promise { + this.runs.delete(runId); + return Promise.resolve(); + } + + async *streamEvents(runId: string): AsyncIterable { + const run = this.runs.get(runId); + if (!run) { + yield { type: "run.failed", runId, reason: "unknown run", at: new Date().toISOString() } as const; + return; + } + + yield { type: "run.started", runId, at: new Date().toISOString() } as const; + + const session = await this.ensureSession(run.context); + // Drain any stale hook events from previous turns in the same interactive session. + session.hookServer.drain(); + + // Stream PTY output to the caller. + const queue = new AsyncQueue(); + const stdoutRl = createInterface({ input: session.child.stdout }); + const stderrRl = createInterface({ input: session.child.stderr }); + stdoutRl.on("line", (line) => { + queue.push(`${line}\n`); + }); + stderrRl.on("line", (line) => { + queue.push(`${line}\n`); + }); + + // Send the prompt as if typed in the interactive terminal. + session.child.stdin.write(`${run.context.prompt}\n`); + + // Pump output until Stop hook fires, then finish. + const stopPromise = session.hookServer.waitForStop(); + for (;;) { + const race = await Promise.race([queue.shift().then((c) => ({ type: "out" as const, c })), stopPromise.then((e) => ({ type: "stop" as const, e }))]); + if (race.type === "out") { + yield { type: "run.output", runId, stream: "stdout", chunk: race.c, at: new Date().toISOString() } as const; + continue; + } + + // Best-effort capture of the session id for manual resume. + const external = + typeof race.e.session_id === "string" + ? race.e.session_id + : typeof race.e.sessionId === "string" + ? race.e.sessionId + : undefined; + if (external) { + run.externalRunId = external; + session.externalRunId = external; + } + + yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; + return; + } + } + + private async ensureSession(context: RunContext): Promise { + const existing = this.sessionsByTask.get(context.taskId); + if (existing) return existing; + + const hookUrl = await this.hookServer.url(); + const settingsPath = join(context.workingDirectory, ".forge", "claude-hooks", `${context.taskId}.settings.json`); + const hookRunnerPath = fileURLToPath(new URL("./hook-bridge-runner.js", import.meta.url)); + + await writeJson(settingsPath, { + hooks: { + Stop: [ + { + matcher: "*", + hooks: [{ type: "command", command: `node ${JSON.stringify(hookRunnerPath)}` }] + } + ], + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: `node ${JSON.stringify(hookRunnerPath)}` }] + } + ] + } + }); + + // Use /usr/bin/script to allocate a PTY-like environment while keeping stdin/out pipeable. + const child = spawn("/usr/bin/script", ["-q", "/dev/null", this.command, "--settings", settingsPath], { + cwd: context.workingDirectory, + env: { + ...process.env, + FORGE_HOOK_URL: hookUrl + }, + stdio: ["pipe", "pipe", "pipe"] + }); + + const session: TaskSession = { + child, + hookServer: this.hookServer + }; + this.sessionsByTask.set(context.taskId, session); + return session; + } +} From d9df9dfbbaa889f06846134f56e9a5bf2fe5e30e Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:45:55 +0800 Subject: [PATCH 07/24] refactor: workflow auto uses codex app-server and claude PTY adapters --- packages/adapter-codex/src/index.ts | 1 + packages/cli/src/cli.ts | 32 ++++++++++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/adapter-codex/src/index.ts b/packages/adapter-codex/src/index.ts index e202a52..3b4b2ce 100644 --- a/packages/adapter-codex/src/index.ts +++ b/packages/adapter-codex/src/index.ts @@ -1 +1,2 @@ export * from "./adapter.js"; +export * from "./app-server-adapter.js"; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6e7646e..7c0e681 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,8 +3,8 @@ import { join, resolve } from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { Command } from "commander"; import { ForgeControlPlane, ForgeWorkflowRunner } from "@forge/control-plane"; -import { CodexAdapter } from "@forge/adapter-codex"; -import { ClaudeAdapter } from "@forge/adapter-claude"; +import { CodexAppServerAdapter } from "@forge/adapter-codex"; +import { ClaudePtyAdapter } from "@forge/adapter-claude"; import { exists, readJsonFile, runCommand } from "@forge/shared-utils"; import { getBundledGuidanceRoot, @@ -443,12 +443,15 @@ export function buildCli(): Command { throw new Error("--max-retries must be a positive integer"); } + const codexAdapter = options.dryRun ? null : new CodexAppServerAdapter(); + const claudeAdapter = options.dryRun ? null : new ClaudePtyAdapter(); + const runner = new ForgeWorkflowRunner( - workspaceRoot, - (type) => { - if (options.dryRun) { - const startRun = () => Promise.resolve({ runId: "dry-run" }); - const streamEvents = async function* (runId: string) { + workspaceRoot, + (type) => { + if (options.dryRun) { + const startRun = () => Promise.resolve({ runId: "dry-run" }); + const streamEvents = async function* (runId: string) { // Keep the generator async to match the adapter interface contract. await Promise.resolve(); yield { type: "run.started", runId, at: new Date().toISOString() } as const; @@ -460,11 +463,16 @@ export function buildCli(): Command { startRun, streamEvents, resume, - cancel - }; - } - return type === "codex" ? new CodexAdapter() : new ClaudeAdapter(); - }, + cancel + }; + } + if (type === "codex") { + if (!codexAdapter) throw new Error("codex adapter unavailable"); + return codexAdapter; + } + if (!claudeAdapter) throw new Error("claude adapter unavailable"); + return claudeAdapter; + }, { gateRunner: async (phase: string, cwd: string) => { if (options.dryRun) { From ad65307534478fc1e3a559f563bab7f0d5b19e4d Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:48:19 +0800 Subject: [PATCH 08/24] refactor: spec gate script enforces red tests --- packages/guidance-pack/src/assets/pack/AGENTS.md | 6 +++++- packages/guidance-pack/src/assets/pack/codex/config.json | 2 +- packages/guidance-pack/src/assets/pack/manifest.json | 2 +- .../src/assets/pack/scripts/phase-gates/spec.sh | 6 +++++- packages/guidance-pack/src/policy/workflow-policy.v1.json | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/guidance-pack/src/assets/pack/AGENTS.md b/packages/guidance-pack/src/assets/pack/AGENTS.md index bfa5ba6..8c87f37 100644 --- a/packages/guidance-pack/src/assets/pack/AGENTS.md +++ b/packages/guidance-pack/src/assets/pack/AGENTS.md @@ -14,7 +14,11 @@ Workflow policy version: 1.2.0 - Never commit or push directly to `main`. Work on a `codex/*` branch and open a PR. ## Canonical Gates -- gate:spec -> bun run typecheck && bun run test +- gate:spec -> bun run typecheck +if bun run test; then + echo "Spec gate requires RED tests (tests must fail before implementation)." >&2 + exit 1 +fi - gate:green -> bun run test && bun run typecheck && bun run lint - gate:refactor -> bun run test && bun run typecheck && bun run lint - gate:architecture -> bun run architecture:check diff --git a/packages/guidance-pack/src/assets/pack/codex/config.json b/packages/guidance-pack/src/assets/pack/codex/config.json index 504b1d9..35e9c86 100644 --- a/packages/guidance-pack/src/assets/pack/codex/config.json +++ b/packages/guidance-pack/src/assets/pack/codex/config.json @@ -1,5 +1,5 @@ { "approval_mode": "suggest", "workflow_policy_version": "1.2.0", - "workflow_policy_hash": "44d421522adf9a87ba04f5204c40d5c91314357f74e069dff262d8e4754ed81a" + "workflow_policy_hash": "cf885fd4bb907ed9130ddff7cae931bf1dadfcfe7618f2e651c3adf430d8adf9" } diff --git a/packages/guidance-pack/src/assets/pack/manifest.json b/packages/guidance-pack/src/assets/pack/manifest.json index 96ce655..c4dfe24 100644 --- a/packages/guidance-pack/src/assets/pack/manifest.json +++ b/packages/guidance-pack/src/assets/pack/manifest.json @@ -2,7 +2,7 @@ "name": "forge-guidance-pack", "version": "1.0.0", "workflow_policy_version": "1.2.0", - "workflow_policy_hash": "44d421522adf9a87ba04f5204c40d5c91314357f74e069dff262d8e4754ed81a", + "workflow_policy_hash": "cf885fd4bb907ed9130ddff7cae931bf1dadfcfe7618f2e651c3adf430d8adf9", "default_phase_gate_bindings": { "spec": "scripts/phase-gates/spec.sh", "implement": "scripts/phase-gates/implement.sh", diff --git a/packages/guidance-pack/src/assets/pack/scripts/phase-gates/spec.sh b/packages/guidance-pack/src/assets/pack/scripts/phase-gates/spec.sh index 21304e9..81e7d3b 100755 --- a/packages/guidance-pack/src/assets/pack/scripts/phase-gates/spec.sh +++ b/packages/guidance-pack/src/assets/pack/scripts/phase-gates/spec.sh @@ -1,3 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -bun run typecheck && bun run test +bun run typecheck +if bun run test; then + echo "Spec gate requires RED tests (tests must fail before implementation)." >&2 + exit 1 +fi diff --git a/packages/guidance-pack/src/policy/workflow-policy.v1.json b/packages/guidance-pack/src/policy/workflow-policy.v1.json index e8c8c09..cf7dc16 100644 --- a/packages/guidance-pack/src/policy/workflow-policy.v1.json +++ b/packages/guidance-pack/src/policy/workflow-policy.v1.json @@ -111,7 +111,7 @@ }, "commands": { "gates": { - "spec": "bun run typecheck && bun run test", + "spec": "bun run typecheck\nif bun run test; then\n echo \"Spec gate requires RED tests (tests must fail before implementation).\" >&2\n exit 1\nfi", "green": "bun run test && bun run typecheck && bun run lint", "refactor": "bun run test && bun run typecheck && bun run lint", "architecture": "bun run architecture:check", From c115fbdf07d034782b4dbfac260c33e0a9723128 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:49:03 +0800 Subject: [PATCH 09/24] refactor: stream workflow adapter output to stderr during auto runs --- packages/cli/src/cli.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7c0e681..c2d6ba1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -474,6 +474,12 @@ export function buildCli(): Command { return claudeAdapter; }, { + onAdapterEvent: (event) => { + if (options.json || options.dryRun) return; + if (event.type === "run.output") { + process.stderr.write(event.chunk); + } + }, gateRunner: async (phase: string, cwd: string) => { if (options.dryRun) { return { ok: true, stdout: "dry-run", stderr: "", exitCode: 0 }; From be604bdf172f6960b5d14b6d048f2f4bee996c27 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:51:00 +0800 Subject: [PATCH 10/24] refactor: include gate output and resume hints when workflow auto pauses --- packages/control-plane/src/workflow-runner.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/control-plane/src/workflow-runner.ts b/packages/control-plane/src/workflow-runner.ts index a924477..3eb156a 100644 --- a/packages/control-plane/src/workflow-runner.ts +++ b/packages/control-plane/src/workflow-runner.ts @@ -30,7 +30,7 @@ export type WorkflowAutoOptions = { export type WorkflowAutoResult = | { state: "running"; taskId: string; phase: string } - | { state: "paused"; taskId: string; phase: string; message: string } + | { state: "paused"; taskId: string; phase: string; message: string; externalRunId?: string; resumeCommand?: string } | { state: "completed"; message: string }; export class ForgeWorkflowRunner { @@ -66,8 +66,12 @@ export class ForgeWorkflowRunner { const approvalMode = resolveApprovalMode(); const basePrompt = renderPrompt(planPath, nextTask, phase); + let lastGateSummary: string | undefined; + let lastExternalRunId: string | undefined; for (let attempt = 1; attempt <= Math.max(1, options.maxRetries); attempt += 1) { - const prompt = attempt === 1 ? basePrompt : `${basePrompt}\n\nRetry ${String(attempt)}: Fix gate failures and try again.`; + const retryHeader = attempt === 1 ? "" : `\n\nRetry ${String(attempt)}: Fix the gate failures and try again.`; + const gateContext = lastGateSummary ? `\n\nPrevious gate output:\n${lastGateSummary}` : ""; + const prompt = `${basePrompt}${retryHeader}${gateContext}`; const ctx: RunContext = { taskId: nextTask.id, prompt, @@ -81,6 +85,13 @@ export class ForgeWorkflowRunner { this.deps.onAdapterEvent?.(event); } + try { + const resumed = await adapter.resume(handle.runId); + if (resumed.externalRunId) lastExternalRunId = resumed.externalRunId; + } catch { + // ignore + } + const gate = await this.deps.gateRunner(phase, this.workspaceRoot); if (gate.ok) { // Mark phase as completed (plan schema does not track "commit" as a status). @@ -104,17 +115,29 @@ export class ForgeWorkflowRunner { return { state: "running", taskId: nextTask.id, phase }; } + + lastGateSummary = `gate=${gate.name ?? phase} exitCode=${String(gate.exitCode)}\n${gate.stdout}${gate.stderr}`.trim(); } return { state: "paused", taskId: nextTask.id, phase, + ...(lastExternalRunId ? { externalRunId: lastExternalRunId } : {}), + ...(() => { + if (!lastExternalRunId) return {}; + const resumeCommand = buildResumeCommand(adapterType, lastExternalRunId); + return resumeCommand ? { resumeCommand } : {}; + })(), message: `Gate failed for phase '${phase}' after ${String(options.maxRetries)} attempts.` }; } } +function buildResumeCommand(adapterType: AdapterType, externalRunId: string): string | undefined { + return adapterType === "codex" ? `codex resume ${externalRunId}` : `claude --resume ${externalRunId}`; +} + type PlanLike = { tasks: Array<{ id: string; From 0da5610ff3078390f2c029871ab8040b451ce492 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:51:54 +0800 Subject: [PATCH 11/24] docs: document workflow auto ralph loop and adapter integrations --- README.md | 1 + decisions.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 9323644..6b4d4af 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Forge Desktop can download packs (from GitHub Releases) and install a selected p - `forge run next --plan ` - `forge run resume --plan --run-id ` - `forge workflow check --plan ` +- `forge workflow auto --plan --adapter codex|claude` ## Workflow Enforcement diff --git a/decisions.md b/decisions.md index 63bd760..db4d2c9 100644 --- a/decisions.md +++ b/decisions.md @@ -18,6 +18,9 @@ Track repository-level technical decisions and rationale. - Desktop Packs tab now shows a selected pack's workflow phases/gates (as a simple ordered list) and allows binding per-phase validation scripts via a native file picker; bindings persist per project in `.forge/phase-gates.json`. - `forge install-guidance` now writes best-effort `.forge/guidance.json` metadata recording the installed pack name/version/path and timestamp; Desktop surfaces this as the project's pack source. - `forge run next --adapter codex` now performs a lightweight preflight to sync `skills/*/SKILL.md` into `.agents/skills/*/SKILL.md` so Codex-native skill discovery stays consistent. +- Added `forge workflow auto --plan `: a Ralph-loop style runner that advances tasks through spec→implement→refactor→document→commit, runs phase gate scripts, commits after each successful phase, and updates `tasks[].status` in the plan file. +- Workflow auto uses `codex app-server` (JSON-RPC-over-JSONL) for Codex runs and an interactive Claude Code session wrapped via `/usr/bin/script` + hooks for lifecycle signaling. +- Updated the spec gate wrapper script to succeed only when tests are RED (typecheck passes and test suite fails), aligning with the code-first BDD discipline. ## 2026-02-07 (unified Run action & schema improvements) - Unified "Run Next" and "Resume" into a single "Run" action: `runNext()` now auto-resumes paused state (sets task from "paused" to "pending", clears `pausedRun`) instead of returning early, eliminating the need for a separate resume step. From db61a461e795fcbf1360e51e38009a80e9ab71cd Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 19:59:53 +0800 Subject: [PATCH 12/24] test: raise coverage for workflow adapters --- decisions.md | 2 + .../src/hook-bridge-runner.test.ts | 58 ++++++ .../adapter-claude/src/hook-bridge-runner.ts | 68 +++++--- .../adapter-claude/src/pty-adapter.test.ts | 165 ++++++++++++++++++ packages/adapter-claude/src/pty-adapter.ts | 38 +++- .../src/app-server-adapter.test.ts | 100 +++++++++++ 6 files changed, 403 insertions(+), 28 deletions(-) create mode 100644 packages/adapter-claude/src/hook-bridge-runner.test.ts create mode 100644 packages/adapter-claude/src/pty-adapter.test.ts diff --git a/decisions.md b/decisions.md index db4d2c9..48fa83c 100644 --- a/decisions.md +++ b/decisions.md @@ -20,6 +20,8 @@ Track repository-level technical decisions and rationale. - `forge run next --adapter codex` now performs a lightweight preflight to sync `skills/*/SKILL.md` into `.agents/skills/*/SKILL.md` so Codex-native skill discovery stays consistent. - Added `forge workflow auto --plan `: a Ralph-loop style runner that advances tasks through spec→implement→refactor→document→commit, runs phase gate scripts, commits after each successful phase, and updates `tasks[].status` in the plan file. - Workflow auto uses `codex app-server` (JSON-RPC-over-JSONL) for Codex runs and an interactive Claude Code session wrapped via `/usr/bin/script` + hooks for lifecycle signaling. +- Claude hook bridge runner is now import-safe (only executes when run as a script), enabling unit tests while preserving hook CLI behavior; Claude PTY adapter gained small dependency injection points for faking spawn/interfaces in tests. +- Claude hook callback HTTP server calls `unref()` after listening so it won’t keep the process alive on its own (important for tests and short-lived CLI runs). - Updated the spec gate wrapper script to succeed only when tests are RED (typecheck passes and test suite fails), aligning with the code-first BDD discipline. ## 2026-02-07 (unified Run action & schema improvements) diff --git a/packages/adapter-claude/src/hook-bridge-runner.test.ts b/packages/adapter-claude/src/hook-bridge-runner.test.ts new file mode 100644 index 0000000..d842c09 --- /dev/null +++ b/packages/adapter-claude/src/hook-bridge-runner.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { runHookBridgeRunner } from "./hook-bridge-runner.js"; + +describe("hook-bridge-runner", () => { + it("posts hook payload and writes PreToolUse allow response to stdout", async () => { + // Given a PreToolUse hook payload and an orchestrator hook URL + const stdout: string[] = []; + const fetchImpl = vi.fn(async () => ({ ok: true }) as unknown as Response); + + // When the runner executes + await runHookBridgeRunner({ + readStdin: () => JSON.stringify({ hook_event_name: "PreToolUse", session_id: "s-1" }), + env: { FORGE_HOOK_URL: "http://example.test/hook" }, + fetchImpl, + stdout: { write: (chunk: string) => void stdout.push(chunk) }, + stderr: { write: () => {} } + }); + + // Then it posts the payload and emits an allow decision for Claude to consume + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(stdout.join("")).toContain("\"permissionDecision\":\"allow\""); + }); + + it("does not throw if posting hook payload fails", async () => { + // Given a hook payload and a hook URL with a failing transport + const fetchImpl = vi.fn(async () => { + throw new Error("network down"); + }); + + // When the runner executes + await runHookBridgeRunner({ + readStdin: () => JSON.stringify({ hookEventName: "Stop" }), + env: { FORGE_HOOK_URL: "http://example.test/hook" }, + fetchImpl: fetchImpl as unknown as typeof fetch, + stdout: { write: () => {} }, + stderr: { write: () => {} } + }); + + // Then it completes best-effort without throwing + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("throws on invalid JSON input", async () => { + // Given invalid JSON input (Claude hook runner reads stdin) + // When the runner executes + const promise = runHookBridgeRunner({ + readStdin: () => "{not-json", + env: {}, + fetchImpl: vi.fn() as unknown as typeof fetch, + stdout: { write: () => {} }, + stderr: { write: () => {} } + }); + + // Then it rejects with a parse error + await expect(promise).rejects.toBeInstanceOf(Error); + }); +}); + diff --git a/packages/adapter-claude/src/hook-bridge-runner.ts b/packages/adapter-claude/src/hook-bridge-runner.ts index 5842c36..001e720 100644 --- a/packages/adapter-claude/src/hook-bridge-runner.ts +++ b/packages/adapter-claude/src/hook-bridge-runner.ts @@ -1,35 +1,61 @@ import { readFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { handleClaudeHookPayload } from "./hook-bridge.js"; -async function postJson(url: string, body: unknown): Promise { - const res = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body) - }).catch(() => null); - if (!res || !res.ok) { - // Best-effort: never block Claude Code on hook transport failures. - return; - } -} +export type HookBridgeRunnerDeps = { + readStdin: () => string; + env: Record; + fetchImpl: typeof fetch; + stdout: { write: (chunk: string) => void }; + stderr: { write: (chunk: string) => void }; +}; -async function main(): Promise { - const raw = readFileSync(0, "utf8"); +export async function runHookBridgeRunner(deps: HookBridgeRunnerDeps): Promise { + const raw = deps.readStdin(); const payload = raw.trim() ? (JSON.parse(raw) as unknown) : {}; - const hookUrl = typeof process.env.FORGE_HOOK_URL === "string" ? process.env.FORGE_HOOK_URL : undefined; + const hookUrl = typeof deps.env.FORGE_HOOK_URL === "string" ? deps.env.FORGE_HOOK_URL : undefined; const result = handleClaudeHookPayload(payload, hookUrl ? { hookUrl } : {}); if (result.post && result.post.url) { - await postJson(result.post.url, result.post.body); + const res = await deps.fetchImpl(result.post.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(result.post.body) + }).catch(() => null); + if (!res || !res.ok) { + // Best-effort: never block Claude Code on hook transport failures. + } } if (result.stdout) { - process.stdout.write(result.stdout); + deps.stdout.write(result.stdout); + } +} + +async function main(): Promise { + await runHookBridgeRunner({ + readStdin: () => readFileSync(0, "utf8"), + env: process.env, + fetchImpl: fetch, + stdout: process.stdout, + stderr: process.stderr + }); +} + +function isDirectInvocation(): boolean { + const argv1 = process.argv[1]; + if (!argv1) return true; + try { + return pathToFileURL(argv1).href === import.meta.url || fileURLToPath(import.meta.url) === argv1; + } catch { + return true; } } -main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); +if (isDirectInvocation()) { + void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/adapter-claude/src/pty-adapter.test.ts b/packages/adapter-claude/src/pty-adapter.test.ts new file mode 100644 index 0000000..9d7657d --- /dev/null +++ b/packages/adapter-claude/src/pty-adapter.test.ts @@ -0,0 +1,165 @@ +import { EventEmitter } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import type { RunContext } from "@forge/shared-utils"; +import { ClaudePtyAdapter } from "./pty-adapter.js"; + +function makeFakeChild(): any { + const child = new EventEmitter() as any; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + return child; +} + +describe("ClaudePtyAdapter", () => { + it("streams output and completes on Stop hook, capturing external session id", async () => { + // Given an adapter with a fake PTY child process + const dir = await mkdtemp(join(tmpdir(), "forge-claude-pty-fake-")); + + const child = makeFakeChild(); + let hookUrl: string | null = null; + + const adapter = new ClaudePtyAdapter({ + spawnImpl: (_cmd, _args, options: any) => { + hookUrl = String(options.env.FORGE_HOOK_URL); + return child; + } + }); + + const context: RunContext = { + taskId: "task-1", + prompt: "do the thing", + workingDirectory: dir, + allowedTools: [], + approvalMode: "full-auto" + }; + + const handle = await adapter.startRun(context); + + // When a run is started and streamed + const events: string[] = []; + const consume = (async () => { + for await (const ev of adapter.streamEvents(handle.runId)) { + events.push(ev.type); + } + })(); + + // Then it exposes a hook URL for hook callbacks + for (let i = 0; i < 50 && !hookUrl; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(hookUrl).toMatch(/^http:\/\/127\.0\.0\.1:/); + + // And the hook server handles non-hook requests + const notFound = await fetch(hookUrl!, { method: "GET" }); + expect(notFound.status).toBe(404); + + // And invalid hook payloads are ignored (best-effort) + const invalid = await fetch(hookUrl!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not-json" + }); + expect(invalid.status).toBe(200); + + // And it streams PTY output lines while waiting for Stop + child.stdout.write("hello\n"); + child.stderr.write("warn\n"); + + // When the Stop hook fires (camelCase keys) + await fetch(hookUrl!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hookEventName: "Stop", sessionId: "session-123" }) + }); + + await consume; + + // Then it completed successfully + expect(events).toContain("run.started"); + expect(events).toContain("run.output"); + expect(events).toContain("run.completed"); + + // And the adapter can surface a resumable external id (best-effort) + const resumed = await adapter.resume(handle.runId); + expect(resumed.externalRunId).toBe("session-123"); + }); + + it("reuses an interactive session per taskId", async () => { + // Given an adapter and a context that reuses the same taskId + const dir = await mkdtemp(join(tmpdir(), "forge-claude-pty-reuse-")); + const child = makeFakeChild(); + let hookUrl: string | null = null; + let spawnCount = 0; + + const adapter = new ClaudePtyAdapter({ + spawnImpl: (_cmd, _args, options: any) => { + spawnCount++; + hookUrl = String(options.env.FORGE_HOOK_URL); + return child; + } + }); + + const context: RunContext = { + taskId: "task-1", + prompt: "phase 1", + workingDirectory: dir, + allowedTools: [], + approvalMode: "full-auto" + }; + + // When streaming two runs for the same task + const handle1 = await adapter.startRun(context); + const p1 = (async () => { + for await (const _ev of adapter.streamEvents(handle1.runId)) { + // drain + } + })(); + + for (let i = 0; i < 50 && !hookUrl; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + await fetch(hookUrl!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hook_event_name: "Stop" }) + }); + await p1; + + const handle2 = await adapter.startRun({ ...context, prompt: "phase 2" }); + const p2 = (async () => { + for await (const _ev of adapter.streamEvents(handle2.runId)) { + // drain + } + })(); + await fetch(hookUrl!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hook_event_name: "Stop" }) + }); + await p2; + + // Then only one PTY session was spawned + expect(spawnCount).toBe(1); + }); + + it("fails a run if the run id is unknown", async () => { + // Given an adapter with no such run + const adapter = new ClaudePtyAdapter({ spawnImpl: () => makeFakeChild() }); + + // When streaming events for an unknown run + const types: string[] = []; + for await (const ev of adapter.streamEvents("missing")) { + types.push(ev.type); + } + + // Then it emits a failed run + expect(types).toContain("run.failed"); + }); +}); + diff --git a/packages/adapter-claude/src/pty-adapter.ts b/packages/adapter-claude/src/pty-adapter.ts index 63deb59..281ced3 100644 --- a/packages/adapter-claude/src/pty-adapter.ts +++ b/packages/adapter-claude/src/pty-adapter.ts @@ -80,6 +80,8 @@ class ClaudeHookServer { if (!this.listening) { this.listening = new Promise((resolve) => { this.server.listen(0, "127.0.0.1", () => { + // Don't keep the process alive solely because the hook server is listening. + this.server.unref(); const addr = this.server.address(); if (addr && typeof addr === "object") { resolve(`http://127.0.0.1:${String(addr.port)}/hook`); @@ -115,12 +117,34 @@ type TaskSession = { externalRunId?: string; }; +export type ClaudePtyAdapterOptions = { + command?: string; + scriptCommand?: string; + spawnImpl?: typeof spawn; + createInterfaceImpl?: typeof createInterface; + writeJsonImpl?: (path: string, value: unknown) => Promise; + hookServer?: ClaudeHookServer; +}; + export class ClaudePtyAdapter implements AgentAdapter { private readonly runs = new Map(); private readonly sessionsByTask = new Map(); - private readonly hookServer = new ClaudeHookServer(); - - constructor(private readonly command = "claude") {} + private readonly hookServer: ClaudeHookServer; + private readonly spawnImpl: typeof spawn; + private readonly createInterfaceImpl: typeof createInterface; + private readonly writeJsonImpl: (path: string, value: unknown) => Promise; + private readonly scriptCommand: string; + private readonly command: string; + + constructor(commandOrOptions: string | ClaudePtyAdapterOptions = "claude", maybeOptions: ClaudePtyAdapterOptions = {}) { + const options = typeof commandOrOptions === "string" ? { ...maybeOptions, command: commandOrOptions } : commandOrOptions; + this.command = options.command ?? "claude"; + this.scriptCommand = options.scriptCommand ?? "/usr/bin/script"; + this.spawnImpl = options.spawnImpl ?? spawn; + this.createInterfaceImpl = options.createInterfaceImpl ?? createInterface; + this.writeJsonImpl = options.writeJsonImpl ?? writeJson; + this.hookServer = options.hookServer ?? new ClaudeHookServer(); + } startRun(context: RunContext): Promise { const runId = randomUUID(); @@ -155,8 +179,8 @@ export class ClaudePtyAdapter implements AgentAdapter { // Stream PTY output to the caller. const queue = new AsyncQueue(); - const stdoutRl = createInterface({ input: session.child.stdout }); - const stderrRl = createInterface({ input: session.child.stderr }); + const stdoutRl = this.createInterfaceImpl({ input: session.child.stdout }); + const stderrRl = this.createInterfaceImpl({ input: session.child.stderr }); stdoutRl.on("line", (line) => { queue.push(`${line}\n`); }); @@ -201,7 +225,7 @@ export class ClaudePtyAdapter implements AgentAdapter { const settingsPath = join(context.workingDirectory, ".forge", "claude-hooks", `${context.taskId}.settings.json`); const hookRunnerPath = fileURLToPath(new URL("./hook-bridge-runner.js", import.meta.url)); - await writeJson(settingsPath, { + await this.writeJsonImpl(settingsPath, { hooks: { Stop: [ { @@ -219,7 +243,7 @@ export class ClaudePtyAdapter implements AgentAdapter { }); // Use /usr/bin/script to allocate a PTY-like environment while keeping stdin/out pipeable. - const child = spawn("/usr/bin/script", ["-q", "/dev/null", this.command, "--settings", settingsPath], { + const child = this.spawnImpl(this.scriptCommand, ["-q", "/dev/null", this.command, "--settings", settingsPath], { cwd: context.workingDirectory, env: { ...process.env, diff --git a/packages/adapter-codex/src/app-server-adapter.test.ts b/packages/adapter-codex/src/app-server-adapter.test.ts index 8c0eede..7ebc4c7 100644 --- a/packages/adapter-codex/src/app-server-adapter.test.ts +++ b/packages/adapter-codex/src/app-server-adapter.test.ts @@ -75,5 +75,105 @@ rl.on("line", (line) => { // Then it streamed assistant deltas and completed expect(chunks.join("")).toContain("hello"); }); + + it("auto-responds to approval and requestUserInput server requests", async () => { + // Given a fake app-server that emits request messages during a turn + const dir = await mkdtemp(join(tmpdir(), "forge-codex-appserver-fake-")); + const serverPath = join(dir, "server-requests.mjs"); + await writeFile( + serverPath, + ` +import { createInterface } from "node:readline"; + +const rl = createInterface({ input: process.stdin }); +let initialized = false; +let threadId = "thread-1"; +let turnId = "turn-1"; + +let approved = false; +let answered = false; + +function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } + +function maybeComplete() { + if (!approved || !answered) return; + send({ method: "turn/completed", params: { threadId, turn: { id: turnId, status: "completed", items: [] } } }); + process.exit(0); +} + +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") { + send({ id: msg.id, result: { userAgent: "fake" } }); + return; + } + if (msg.method === "initialized") { + initialized = true; + return; + } + if (!initialized) { + send({ id: msg.id, error: { message: "not initialized" } }); + return; + } + if (msg.method === "thread/start") { + send({ id: msg.id, result: { thread: { id: threadId } } }); + return; + } + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: turnId, status: "inProgress", items: [] } } }); + // Server-initiated requests the client must answer. + send({ id: 200, method: "item/commandExecution/requestApproval", params: { threadId, turnId } }); + send({ + id: 201, + method: "item/tool/requestUserInput", + params: { + questions: [{ id: "q1", options: [{ label: "Option A" }, { label: "Option B" }] }] + } + }); + return; + } + + // JSON-RPC responses from the client. + if (msg && typeof msg === "object" && typeof msg.id !== "undefined" && typeof msg.method === "undefined") { + if (msg.id === 200) { + approved = msg.result && msg.result.decision === "acceptForSession"; + maybeComplete(); + return; + } + if (msg.id === 201) { + const ans = msg.result && msg.result.answers && msg.result.answers.q1; + answered = ans && Array.isArray(ans.answers) && ans.answers[0] === "Option A"; + maybeComplete(); + return; + } + } }); + `.trim(), + "utf8" + ); + + const adapter = new CodexAppServerAdapter({ + spawnCommand: "node", + spawnArgs: [serverPath] + }); + const context: RunContext = { + taskId: "task-1", + prompt: "do the thing", + workingDirectory: dir, + allowedTools: [], + approvalMode: "full-auto" + }; + + // When a run is started and streamed + const handle = await adapter.startRun(context); + const types: string[] = []; + for await (const event of adapter.streamEvents(handle.runId)) { + types.push(event.type); + if (event.type === "run.failed") throw new Error(event.reason); + } + + // Then it completes successfully after auto-answering server requests + expect(types).toContain("run.completed"); + }); +}); From 78cf7212200d7a823ade37a7bc3eb4ca9e136d92 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 20:03:12 +0800 Subject: [PATCH 13/24] feat: show workflow progress during auto --- decisions.md | 1 + packages/cli/src/cli.ts | 43 +++++++++- packages/control-plane/src/index.ts | 1 + .../src/workflow-progress.test.ts | 28 +++++++ .../control-plane/src/workflow-progress.ts | 84 +++++++++++++++++++ 5 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 packages/control-plane/src/workflow-progress.test.ts create mode 100644 packages/control-plane/src/workflow-progress.ts diff --git a/decisions.md b/decisions.md index 48fa83c..2d1b40d 100644 --- a/decisions.md +++ b/decisions.md @@ -20,6 +20,7 @@ Track repository-level technical decisions and rationale. - `forge run next --adapter codex` now performs a lightweight preflight to sync `skills/*/SKILL.md` into `.agents/skills/*/SKILL.md` so Codex-native skill discovery stays consistent. - Added `forge workflow auto --plan `: a Ralph-loop style runner that advances tasks through spec→implement→refactor→document→commit, runs phase gate scripts, commits after each successful phase, and updates `tasks[].status` in the plan file. - Workflow auto uses `codex app-server` (JSON-RPC-over-JSONL) for Codex runs and an interactive Claude Code session wrapped via `/usr/bin/script` + hooks for lifecycle signaling. +- `forge workflow auto` now prints a best-effort progress snapshot (per task + per phase markers) to stderr after each successful phase to keep terminal sessions readable while the agent streams output. - Claude hook bridge runner is now import-safe (only executes when run as a script), enabling unit tests while preserving hook CLI behavior; Claude PTY adapter gained small dependency injection points for faking spawn/interfaces in tests. - Claude hook callback HTTP server calls `unref()` after listening so it won’t keep the process alive on its own (important for tests and short-lived CLI runs). - Updated the spec gate wrapper script to succeed only when tests are RED (typecheck passes and test suite fails), aligning with the code-first BDD discipline. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c2d6ba1..4e5dbcd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,7 +2,7 @@ import { join, resolve } from "node:path"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { Command } from "commander"; -import { ForgeControlPlane, ForgeWorkflowRunner } from "@forge/control-plane"; +import { ForgeControlPlane, ForgeWorkflowRunner, renderWorkflowProgress } from "@forge/control-plane"; import { CodexAppServerAdapter } from "@forge/adapter-codex"; import { ClaudePtyAdapter } from "@forge/adapter-claude"; import { exists, readJsonFile, runCommand } from "@forge/shared-utils"; @@ -438,6 +438,7 @@ export function buildCli(): Command { .action(async (options: JsonFlag & { plan: string; adapter: AdapterName; maxRetries: string; push: boolean; remote: string; dryRun?: boolean }) => { try { const workspaceRoot = process.cwd(); + const planPath = resolve(options.plan); const maxRetries = Number.parseInt(options.maxRetries, 10); if (!Number.isFinite(maxRetries) || maxRetries < 1) { throw new Error("--max-retries must be a positive integer"); @@ -520,15 +521,53 @@ export function buildCli(): Command { // Keep a hard cap to avoid infinite loops on buggy status transitions. const maxSteps = 5000; for (let i = 0; i < maxSteps; i += 1) { - const step = await runner.runAuto(resolve(options.plan), options.adapter, { + const step = await runner.runAuto(planPath, options.adapter, { maxRetries, push: options.push && !options.dryRun, remote: options.remote }); if (step.state === "running") { + if (!options.json && !options.dryRun) { + try { + const raw = await readFile(planPath, "utf8"); + const plan = JSON.parse(raw) as { + tasks: Array<{ + id: string; + task_type: string; + name: string; + description: string; + dependencies: string[]; + status?: "" | "spec" | "implement" | "refactor" | "document" | "completed"; + }>; + }; + process.stderr.write(renderWorkflowProgress(plan)); + process.stderr.write(`Last step: ${step.taskId} phase=${step.phase}\n`); + } catch { + // ignore (best-effort UX) + } + } continue; } + + if (!options.json && !options.dryRun) { + try { + const raw = await readFile(planPath, "utf8"); + const plan = JSON.parse(raw) as { + tasks: Array<{ + id: string; + task_type: string; + name: string; + description: string; + dependencies: string[]; + status?: "" | "spec" | "implement" | "refactor" | "document" | "completed"; + }>; + }; + process.stderr.write(renderWorkflowProgress(plan)); + } catch { + // ignore (best-effort UX) + } + } output(options.json ? step : step.message, options.json); return; } diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index 739ff31..e21b7ef 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -1,3 +1,4 @@ export * from "./types.js"; export * from "./control-plane.js"; export * from "./workflow-runner.js"; +export * from "./workflow-progress.js"; diff --git a/packages/control-plane/src/workflow-progress.test.ts b/packages/control-plane/src/workflow-progress.test.ts new file mode 100644 index 0000000..c9ef455 --- /dev/null +++ b/packages/control-plane/src/workflow-progress.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { renderWorkflowProgress } from "./workflow-progress.js"; + +describe("renderWorkflowProgress", () => { + it("renders task completion, per-phase markers, and blocked dependencies", () => { + // Given a plan with completed, in-progress, and blocked tasks + const plan = { + tasks: [ + { id: "task-1", task_type: "implementation", name: "Task 1", description: "", dependencies: [], status: "completed" as const }, + { id: "task-2", task_type: "implementation", name: "Task 2", description: "", dependencies: ["task-1"], status: "spec" as const }, + { id: "task-3", task_type: "implementation", name: "Task 3", description: "", dependencies: ["task-2"], status: "" as const } + ] + }; + + // When progress is rendered + const out = renderWorkflowProgress(plan); + + // Then completed tasks are marked and phases reflect next work + expect(out).toContain("[x] task-1 - Task 1"); + expect(out).toContain("phases: [x] spec [x] implement [x] refactor [x] document [x] commit"); + expect(out).toContain("[ ] task-2 - Task 2"); + expect(out).toContain("phases: [x] spec [>] implement [ ] refactor [ ] document [ ] commit"); + + // And blocked tasks show unmet dependencies + expect(out).toContain("[ ] task-3 - Task 3 (blocked: task-2)"); + }); +}); + diff --git a/packages/control-plane/src/workflow-progress.ts b/packages/control-plane/src/workflow-progress.ts new file mode 100644 index 0000000..0235c6c --- /dev/null +++ b/packages/control-plane/src/workflow-progress.ts @@ -0,0 +1,84 @@ +type Phase = "spec" | "implement" | "refactor" | "document" | "commit"; + +type TaskStatus = "" | "spec" | "implement" | "refactor" | "document" | "completed" | undefined; + +type PlanLike = { + tasks: Array<{ + id: string; + task_type: string; + name: string; + description: string; + dependencies: string[]; + status?: TaskStatus; + }>; +}; + +function isCompleted(status: TaskStatus): boolean { + return status === "completed"; +} + +function completedPhases(status: TaskStatus): Set { + const phases = new Set(); + if (status === "completed") { + phases.add("spec"); + phases.add("implement"); + phases.add("refactor"); + phases.add("document"); + phases.add("commit"); + return phases; + } + if (status === "spec") phases.add("spec"); + if (status === "implement") { + phases.add("spec"); + phases.add("implement"); + } + if (status === "refactor") { + phases.add("spec"); + phases.add("implement"); + phases.add("refactor"); + } + if (status === "document") { + phases.add("spec"); + phases.add("implement"); + phases.add("refactor"); + phases.add("document"); + } + return phases; +} + +function nextPhase(status: TaskStatus): Phase | undefined { + const s = status ?? ""; + if (s === "") return "spec"; + if (s === "spec") return "implement"; + if (s === "implement") return "refactor"; + if (s === "refactor") return "document"; + if (s === "document") return "commit"; + return undefined; +} + +export function renderWorkflowProgress(plan: PlanLike): string { + const tasksById = new Map(plan.tasks.map((t) => [t.id, t])); + const phases: Phase[] = ["spec", "implement", "refactor", "document", "commit"]; + + const lines: string[] = []; + lines.push("Workflow progress:"); + + for (const task of plan.tasks) { + const done = isCompleted(task.status); + const depsMissing = task.dependencies.filter((dep) => !isCompleted(tasksById.get(dep)?.status)); + const blockedSuffix = !done && depsMissing.length > 0 ? ` (blocked: ${depsMissing.join(", ")})` : ""; + lines.push(`${done ? "[x]" : "[ ]"} ${task.id} - ${task.name}${blockedSuffix}`); + + const completed = completedPhases(task.status); + const next = done ? undefined : depsMissing.length > 0 ? undefined : nextPhase(task.status); + const phaseParts = phases.map((p) => { + if (completed.has(p)) return `[x] ${p}`; + if (next === p) return `[>] ${p}`; + return `[ ] ${p}`; + }); + lines.push(` phases: ${phaseParts.join(" ")}`); + } + + return `${lines.join("\n")}\n`; +} + From d13f9ed32f905a7e18151253c0e7ba17fb5d2588 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 20:05:22 +0800 Subject: [PATCH 14/24] feat(adapter-codex): handle requestUserInput interactively --- decisions.md | 1 + .../src/app-server-adapter.test.ts | 9 ++- .../adapter-codex/src/app-server-adapter.ts | 67 +++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/decisions.md b/decisions.md index 2d1b40d..2b850a8 100644 --- a/decisions.md +++ b/decisions.md @@ -21,6 +21,7 @@ Track repository-level technical decisions and rationale. - Added `forge workflow auto --plan `: a Ralph-loop style runner that advances tasks through spec→implement→refactor→document→commit, runs phase gate scripts, commits after each successful phase, and updates `tasks[].status` in the plan file. - Workflow auto uses `codex app-server` (JSON-RPC-over-JSONL) for Codex runs and an interactive Claude Code session wrapped via `/usr/bin/script` + hooks for lifecycle signaling. - `forge workflow auto` now prints a best-effort progress snapshot (per task + per phase markers) to stderr after each successful phase to keep terminal sessions readable while the agent streams output. +- Codex app-server `tool/requestUserInput` is handled interactively when `stdin` is a TTY (prompt user to pick an option); in non-interactive mode it auto-selects the first option (best-effort) so automation does not hang. - Claude hook bridge runner is now import-safe (only executes when run as a script), enabling unit tests while preserving hook CLI behavior; Claude PTY adapter gained small dependency injection points for faking spawn/interfaces in tests. - Claude hook callback HTTP server calls `unref()` after listening so it won’t keep the process alive on its own (important for tests and short-lived CLI runs). - Updated the spec gate wrapper script to succeed only when tests are RED (typecheck passes and test suite fails), aligning with the code-first BDD discipline. diff --git a/packages/adapter-codex/src/app-server-adapter.test.ts b/packages/adapter-codex/src/app-server-adapter.test.ts index 7ebc4c7..c91f45a 100644 --- a/packages/adapter-codex/src/app-server-adapter.test.ts +++ b/packages/adapter-codex/src/app-server-adapter.test.ts @@ -91,12 +91,13 @@ let threadId = "thread-1"; let turnId = "turn-1"; let approved = false; +let fileApproved = false; let answered = false; function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } function maybeComplete() { - if (!approved || !answered) return; + if (!approved || !fileApproved || !answered) return; send({ method: "turn/completed", params: { threadId, turn: { id: turnId, status: "completed", items: [] } } }); process.exit(0); } @@ -123,6 +124,7 @@ rl.on("line", (line) => { send({ id: msg.id, result: { turn: { id: turnId, status: "inProgress", items: [] } } }); // Server-initiated requests the client must answer. send({ id: 200, method: "item/commandExecution/requestApproval", params: { threadId, turnId } }); + send({ id: 202, method: "item/fileChange/requestApproval", params: { threadId, turnId } }); send({ id: 201, method: "item/tool/requestUserInput", @@ -140,6 +142,11 @@ rl.on("line", (line) => { maybeComplete(); return; } + if (msg.id === 202) { + fileApproved = msg.result && msg.result.decision === "acceptForSession"; + maybeComplete(); + return; + } if (msg.id === 201) { const ans = msg.result && msg.result.answers && msg.result.answers.q1; answered = ans && Array.isArray(ans.answers) && ans.answers[0] === "Option A"; diff --git a/packages/adapter-codex/src/app-server-adapter.ts b/packages/adapter-codex/src/app-server-adapter.ts index b73ee3b..d89cc9e 100644 --- a/packages/adapter-codex/src/app-server-adapter.ts +++ b/packages/adapter-codex/src/app-server-adapter.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; +import { createInterface as createPromptInterface } from "node:readline/promises"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { AdapterEvent, AgentAdapter, RunContext, RunHandle } from "@forge/shared-utils"; @@ -225,7 +226,7 @@ export class CodexAppServerAdapter implements AgentAdapter { // Auto-respond to server-initiated requests (approvals / user input). if (isJsonRpcRequest(msg)) { - this.handleServerRequest(msg); + await this.handleServerRequest(msg); continue; } @@ -284,7 +285,7 @@ export class CodexAppServerAdapter implements AgentAdapter { return threadId; } - private handleServerRequest(req: JsonRpcRequest): void { + private async handleServerRequest(req: JsonRpcRequest): Promise { const method = req.method; const params = req.params; @@ -297,6 +298,18 @@ export class CodexAppServerAdapter implements AgentAdapter { return; } if (method === "item/tool/requestUserInput") { + const answers = await this.buildUserInputAnswers(params); + this.client.respond(req.id, { answers }); + return; + } + + // Unknown request: decline by default. + this.client.respond(req.id, {}); + } + + private async buildUserInputAnswers(params: unknown): Promise> { + // Non-interactive: choose the first option (best-effort) so the workflow can continue. + if (!process.stdin.isTTY) { const answers: Record = {}; if (isRecord(params) && Array.isArray(params.questions)) { for (const q of params.questions) { @@ -306,12 +319,54 @@ export class CodexAppServerAdapter implements AgentAdapter { answers[q.id] = { answers: first ? [first] : [] }; } } - this.client.respond(req.id, { answers }); - return; + return answers; } - // Unknown request: decline by default. - this.client.respond(req.id, {}); + const answers: Record = {}; + const rl = createPromptInterface({ input: process.stdin, output: process.stderr }); + try { + const qs = isRecord(params) && Array.isArray(params.questions) ? params.questions : []; + for (const q of qs) { + if (!isRecord(q) || typeof q.id !== "string") continue; + const questionText = + typeof q.question === "string" + ? q.question + : typeof q.prompt === "string" + ? q.prompt + : `Question ${q.id}`; + + process.stderr.write(`\nCodex requests user input: ${questionText}\n`); + + const opts = Array.isArray(q.options) ? q.options : []; + const labels: string[] = []; + for (const opt of opts) { + if (isRecord(opt) && typeof opt.label === "string") labels.push(opt.label); + } + + let selected = ""; + if (labels.length > 0) { + for (let i = 0; i < labels.length; i += 1) { + process.stderr.write(` ${String(i + 1)}. ${labels[i] ?? ""}\n`); + } + const raw = (await rl.question("> ")).trim(); + const idx = Number.parseInt(raw, 10); + if (Number.isFinite(idx) && idx >= 1 && idx <= labels.length) { + selected = labels[idx - 1] ?? ""; + } else if (labels.includes(raw)) { + selected = raw; + } else { + selected = labels[0] ?? ""; + } + } else { + selected = (await rl.question("> ")).trim(); + } + + answers[q.id] = { answers: selected ? [selected] : [] }; + } + } finally { + rl.close(); + } + return answers; } } From 977502003a940592fe739ccaa789618a13a96685 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 20:35:02 +0800 Subject: [PATCH 15/24] spec: codex emits structured user input requests --- .../src/app-server-adapter.test.ts | 97 +++++++++++++++++++ packages/shared-utils/src/types.ts | 12 +++ 2 files changed, 109 insertions(+) diff --git a/packages/adapter-codex/src/app-server-adapter.test.ts b/packages/adapter-codex/src/app-server-adapter.test.ts index c91f45a..553115a 100644 --- a/packages/adapter-codex/src/app-server-adapter.test.ts +++ b/packages/adapter-codex/src/app-server-adapter.test.ts @@ -183,4 +183,101 @@ rl.on("line", (line) => { // Then it completes successfully after auto-answering server requests expect(types).toContain("run.completed"); }); + + it("emits a user_input.requested event when the server requests structured user input", async () => { + // Given a fake app-server that requests user input + const dir = await mkdtemp(join(tmpdir(), "forge-codex-appserver-fake-")); + const serverPath = join(dir, "server-userinput.mjs"); + await writeFile( + serverPath, + ` +import { createInterface } from "node:readline"; + +const rl = createInterface({ input: process.stdin }); +let initialized = false; +let threadId = "thread-1"; +let turnId = "turn-1"; + +let answered = false; + +function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } + +function maybeComplete() { + if (!answered) return; + send({ method: "turn/completed", params: { threadId, turn: { id: turnId, status: "completed", items: [] } } }); + process.exit(0); +} + +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") { + send({ id: msg.id, result: { userAgent: "fake" } }); + return; + } + if (msg.method === "initialized") { + initialized = true; + return; + } + if (!initialized) { + send({ id: msg.id, error: { message: "not initialized" } }); + return; + } + if (msg.method === "thread/start") { + send({ id: msg.id, result: { thread: { id: threadId } } }); + return; + } + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: turnId, status: "inProgress", items: [] } } }); + send({ + id: 300, + method: "item/tool/requestUserInput", + params: { + questions: [ + { + id: "q1", + question: "Pick one", + options: [{ label: "Option A" }, { label: "Other", isOther: true }] + } + ] + } + }); + return; + } + + if (msg && typeof msg === "object" && typeof msg.id !== "undefined" && typeof msg.method === "undefined") { + if (msg.id === 300) { + answered = true; + maybeComplete(); + return; + } + } +}); + `.trim(), + "utf8" + ); + + const adapter = new CodexAppServerAdapter({ + spawnCommand: "node", + spawnArgs: [serverPath] + }); + + const context: RunContext = { + taskId: "task-1", + prompt: "do the thing", + workingDirectory: dir, + allowedTools: [], + approvalMode: "full-auto" + }; + + // When a run is started and streamed + const handle = await adapter.startRun(context); + const eventTypes: string[] = []; + for await (const event of adapter.streamEvents(handle.runId)) { + eventTypes.push(event.type); + if (event.type === "run.failed") throw new Error(event.reason); + } + + // Then it surfaces the structured prompt request as an adapter event + expect(eventTypes).toContain("run.user_input.requested"); + }); }); diff --git a/packages/shared-utils/src/types.ts b/packages/shared-utils/src/types.ts index d2e50b4..6264fcb 100644 --- a/packages/shared-utils/src/types.ts +++ b/packages/shared-utils/src/types.ts @@ -13,6 +13,18 @@ export type AdapterEvent = | { type: "run.started"; runId: RunId; at: string } | { type: "run.output"; runId: RunId; stream: "stdout" | "stderr"; chunk: string; raw?: string; at: string } | { type: "run.tool"; runId: RunId; tool: string; status: "started" | "completed" | "failed"; at: string } + | { + type: "run.user_input.requested"; + runId: RunId; + requestId: string; + questions: Array<{ + id: string; + header?: string; + question: string; + options: Array<{ label: string; description?: string; isOther?: boolean }>; + }>; + at: string; + } | { type: "run.completed"; runId: RunId; exitCode: number; at: string } | { type: "run.failed"; runId: RunId; reason: string; at: string }; From 9bcd5fc7e812d161d2a89eeffda75842003ec04a Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 20:37:13 +0800 Subject: [PATCH 16/24] implement(adapter-codex): surface requestUserInput as adapter event --- .../adapter-codex/src/app-server-adapter.ts | 153 +++++++++++++++--- 1 file changed, 133 insertions(+), 20 deletions(-) diff --git a/packages/adapter-codex/src/app-server-adapter.ts b/packages/adapter-codex/src/app-server-adapter.ts index d89cc9e..0d63a65 100644 --- a/packages/adapter-codex/src/app-server-adapter.ts +++ b/packages/adapter-codex/src/app-server-adapter.ts @@ -19,6 +19,8 @@ type JsonRpcRequest = { id: number | string; method: string; params?: unknown }; type JsonRpcResponse = { id: number | string; result?: unknown; error?: unknown }; type JsonRpcNotification = { method: string; params?: unknown }; +type UserInputAnswers = Record; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -55,6 +57,49 @@ class AsyncQueue { } } +class StdinJsonRouter { + private readonly pending = new Map void>(); + private readonly rl = createInterface({ input: process.stdin }); + + constructor() { + this.rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed) as unknown; + } catch { + return; + } + if (!isRecord(parsed) || parsed.type !== "user_input.response") return; + const requestId = typeof parsed.requestId === "string" ? parsed.requestId : typeof parsed.requestId === "number" ? String(parsed.requestId) : ""; + if (!requestId) return; + const resolver = this.pending.get(requestId); + if (!resolver) return; + this.pending.delete(requestId); + resolver(parsed); + }); + } + + waitForResponse(requestId: string): Promise { + return new Promise((resolve) => { + this.pending.set(requestId, (payload: unknown) => { + if (isRecord(payload) && isRecord(payload.answers)) { + resolve(payload.answers as UserInputAnswers); + return; + } + resolve({}); + }); + }); + } +} + +let globalStdinRouter: StdinJsonRouter | null = null; +function stdinRouter(): StdinJsonRouter { + if (!globalStdinRouter) globalStdinRouter = new StdinJsonRouter(); + return globalStdinRouter; +} + class CodexAppServerClient { private child: ChildProcessWithoutNullStreams | null = null; private readonly pending = new Map(); @@ -226,7 +271,23 @@ export class CodexAppServerAdapter implements AgentAdapter { // Auto-respond to server-initiated requests (approvals / user input). if (isJsonRpcRequest(msg)) { - await this.handleServerRequest(msg); + if (msg.method === "item/tool/requestUserInput") { + const requestId = String(msg.id); + const extracted = extractUserInputQuestions(msg.params); + yield { + type: "run.user_input.requested", + runId, + requestId, + questions: extracted, + at: new Date().toISOString() + } as const; + + const answers = await this.resolveUserInputAnswers(msg.params, requestId); + this.client.respond(msg.id, { answers }); + continue; + } + + this.handleServerRequest(msg); continue; } @@ -285,9 +346,8 @@ export class CodexAppServerAdapter implements AgentAdapter { return threadId; } - private async handleServerRequest(req: JsonRpcRequest): Promise { + private handleServerRequest(req: JsonRpcRequest): void { const method = req.method; - const params = req.params; if (method === "item/commandExecution/requestApproval") { this.client.respond(req.id, { decision: "acceptForSession" }); @@ -297,32 +357,37 @@ export class CodexAppServerAdapter implements AgentAdapter { this.client.respond(req.id, { decision: "acceptForSession" }); return; } - if (method === "item/tool/requestUserInput") { - const answers = await this.buildUserInputAnswers(params); - this.client.respond(req.id, { answers }); - return; - } // Unknown request: decline by default. this.client.respond(req.id, {}); } - private async buildUserInputAnswers(params: unknown): Promise> { + private async resolveUserInputAnswers(params: unknown, requestId: string): Promise { + if (process.stdin.isTTY) { + return await this.promptUserInputAnswers(params); + } + + // Desktop/automation: allow an external UI to answer via stdin JSON. + if (process.env.FORGE_INTERACTIVE === "1") { + return await stdinRouter().waitForResponse(requestId); + } + // Non-interactive: choose the first option (best-effort) so the workflow can continue. - if (!process.stdin.isTTY) { - const answers: Record = {}; - if (isRecord(params) && Array.isArray(params.questions)) { - for (const q of params.questions) { - if (!isRecord(q) || typeof q.id !== "string") continue; - const opts = Array.isArray(q.options) ? q.options : null; - const first = opts && isRecord(opts[0]) && typeof opts[0].label === "string" ? opts[0].label : ""; - answers[q.id] = { answers: first ? [first] : [] }; - } + const answers: Record = {}; + if (isRecord(params) && Array.isArray(params.questions)) { + for (const q of params.questions) { + if (!isRecord(q) || typeof q.id !== "string") continue; + const opts = Array.isArray(q.options) ? q.options : null; + const first = opts && isRecord(opts[0]) && typeof opts[0].label === "string" ? opts[0].label : ""; + answers[q.id] = { answers: first ? [first] : [] }; } - return answers; } + return answers; + } + private async promptUserInputAnswers(params: unknown): Promise { const answers: Record = {}; + const rl = createPromptInterface({ input: process.stdin, output: process.stderr }); try { const qs = isRecord(params) && Array.isArray(params.questions) ? params.questions : []; @@ -339,8 +404,14 @@ export class CodexAppServerAdapter implements AgentAdapter { const opts = Array.isArray(q.options) ? q.options : []; const labels: string[] = []; + const isOtherLabels = new Set(); for (const opt of opts) { - if (isRecord(opt) && typeof opt.label === "string") labels.push(opt.label); + if (isRecord(opt) && typeof opt.label === "string") { + labels.push(opt.label); + if (opt.isOther === true) { + isOtherLabels.add(opt.label); + } + } } let selected = ""; @@ -357,6 +428,14 @@ export class CodexAppServerAdapter implements AgentAdapter { } else { selected = labels[0] ?? ""; } + + // If the user chose an isOther option, allow free-form input. + if (selected && isOtherLabels.has(selected)) { + const free = (await rl.question("Other: ")).trim(); + if (free) { + selected = free; + } + } } else { selected = (await rl.question("> ")).trim(); } @@ -370,6 +449,40 @@ export class CodexAppServerAdapter implements AgentAdapter { } } +function extractUserInputQuestions(params: unknown): Array<{ + id: string; + header?: string; + question: string; + options: Array<{ label: string; description?: string; isOther?: boolean }>; +}> { + const out: Array<{ + id: string; + header?: string; + question: string; + options: Array<{ label: string; description?: string; isOther?: boolean }>; + }> = []; + + if (!isRecord(params) || !Array.isArray(params.questions)) return out; + for (const q of params.questions) { + if (!isRecord(q) || typeof q.id !== "string") continue; + const question = typeof q.question === "string" ? q.question : typeof q.prompt === "string" ? q.prompt : ""; + const header = typeof q.header === "string" ? q.header : undefined; + const optsRaw = Array.isArray(q.options) ? q.options : []; + const options: Array<{ label: string; description?: string; isOther?: boolean }> = []; + for (const opt of optsRaw) { + if (!isRecord(opt) || typeof opt.label !== "string") continue; + options.push({ + label: opt.label, + ...(typeof opt.description === "string" ? { description: opt.description } : {}), + ...(opt.isOther === true ? { isOther: true } : {}) + }); + } + + out.push({ id: q.id, question, ...(header ? { header } : {}), options }); + } + return out; +} + function extractThreadId(result: unknown): string | undefined { if (!isRecord(result)) return undefined; const thread = result.thread; From aad087713ecc74069fe2b8ccd44d47442ba660c2 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 20:39:26 +0800 Subject: [PATCH 17/24] refactor(adapter-codex): drop legacy exec transport --- packages/adapter-codex/src/adapter.test.ts | 175 ------------ packages/adapter-codex/src/adapter.ts | 265 +----------------- packages/adapter-codex/src/render.test.ts | 62 ---- packages/adapter-codex/src/render.ts | 87 ------ .../control-plane/src/adapter-parity.test.ts | 55 +++- 5 files changed, 56 insertions(+), 588 deletions(-) delete mode 100644 packages/adapter-codex/src/adapter.test.ts delete mode 100644 packages/adapter-codex/src/render.test.ts delete mode 100644 packages/adapter-codex/src/render.ts diff --git a/packages/adapter-codex/src/adapter.test.ts b/packages/adapter-codex/src/adapter.test.ts deleted file mode 100644 index 7d938a2..0000000 --- a/packages/adapter-codex/src/adapter.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CodexAdapter } from "./adapter.js"; -import "./index.js"; - -describe("CodexAdapter", () => { - it("emits started output and completed events", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 0, - stdout: '{"type":"message","text":"ok"}\n', - stderr: "" - })); - - const handle = await adapter.startRun({ - taskId: "task-1", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [] - }); - - const events: any[] = []; - for await (const event of adapter.streamEvents(handle.runId)) events.push(event); - - expect(events.map((e) => e.type)).toContain("run.started"); - expect(events.map((e) => e.type)).toContain("run.output"); - expect(events.map((e) => e.type)).toContain("run.completed"); - - const output = events.find((e) => e.type === "run.output"); - expect(output?.chunk).toBe("ok"); - expect(output?.raw).toBe('{"type":"message","text":"ok"}'); - }); - - it("captures external run id from json output and exposes it on resume", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 0, - stdout: '{"type":"session.started","thread_id":"thread-123"}\n', - stderr: "" - })); - - const handle = await adapter.startRun({ - taskId: "task-2", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [] - }); - - for await (const _event of adapter.streamEvents(handle.runId)) { - // drain - } - - const resumed = await adapter.resume(handle.runId); - expect(resumed.externalRunId).toBe("thread-123"); - }); - - it("does not pass --full-auto for suggest approval mode", async () => { - let seenArgs: string[] = []; - const adapter = new CodexAdapter(async (_command, args) => { - seenArgs = args; - return { exitCode: 0, stdout: "{}\n", stderr: "" }; - }); - - const handle = await adapter.startRun({ - taskId: "task-3", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [], - approvalMode: "suggest" - }); - - for await (const _event of adapter.streamEvents(handle.runId)) { - // drain - } - - expect(seenArgs).toEqual(["exec", "--json", "hello"]); - }); - - it("maps full-auto approval mode to --full-auto flag", async () => { - let seenArgs: string[] = []; - const adapter = new CodexAdapter(async (_command, args) => { - seenArgs = args; - return { exitCode: 0, stdout: "{}\n", stderr: "" }; - }); - - const handle = await adapter.startRun({ - taskId: "task-3b", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [], - approvalMode: "full-auto" - }); - - for await (const _event of adapter.streamEvents(handle.runId)) { - // drain - } - - expect(seenArgs).toEqual(["exec", "--json", "--full-auto", "hello"]); - }); - - it("emits run.failed for unknown run id", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 0, stdout: "", stderr: "" - })); - - const events: any[] = []; - for await (const event of adapter.streamEvents("nonexistent")) events.push(event); - - expect(events).toHaveLength(1); - expect(events[0].type).toBe("run.failed"); - expect(events[0].reason).toBe("unknown run"); - }); - - it("emits run.failed when codex exits non-zero", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 1, stdout: "", stderr: "error output" - })); - - const handle = await adapter.startRun({ - taskId: "task-fail", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [] - }); - - const events: any[] = []; - for await (const event of adapter.streamEvents(handle.runId)) events.push(event); - - const failed = events.find((e) => e.type === "run.failed"); - expect(failed).toBeDefined(); - expect(failed.reason).toBe("codex exited with code 1"); - - const stderr = events.find((e) => e.type === "run.output" && e.stream === "stderr"); - expect(stderr?.chunk).toBe("error output"); - }); - - it("rejects resume for unknown run id", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 0, stdout: "", stderr: "" - })); - await expect(adapter.resume("nonexistent")).rejects.toThrow("run not found"); - }); - - it("cancels a run", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 0, stdout: "", stderr: "" - })); - const handle = await adapter.startRun({ - taskId: "task-cancel", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [] - }); - await adapter.cancel(handle.runId); - await expect(adapter.resume(handle.runId)).rejects.toThrow("run not found"); - }); - - it("fails resume when external run id was not observed", async () => { - const adapter = new CodexAdapter(async () => ({ - exitCode: 0, - stdout: '{"type":"message","text":"ok"}\n', - stderr: "" - })); - - const handle = await adapter.startRun({ - taskId: "task-4", - prompt: "hello", - workingDirectory: process.cwd(), - allowedTools: [] - }); - - for await (const _event of adapter.streamEvents(handle.runId)) { - // drain - } - - await expect(adapter.resume(handle.runId)).rejects.toThrow("external run id not found"); - }); -}); diff --git a/packages/adapter-codex/src/adapter.ts b/packages/adapter-codex/src/adapter.ts index cd5e49b..334bc3b 100644 --- a/packages/adapter-codex/src/adapter.ts +++ b/packages/adapter-codex/src/adapter.ts @@ -1,263 +1,10 @@ -import { randomUUID } from "node:crypto"; -import { spawn } from "node:child_process"; -import { createInterface } from "node:readline"; -import type { AdapterEvent, AgentAdapter, RunContext, RunHandle } from "@forge/shared-utils"; -import { renderCodexJsonLine } from "./render.js"; +import type { CodexAppServerAdapterOptions } from "./app-server-adapter.js"; +import { CodexAppServerAdapter } from "./app-server-adapter.js"; -type CommandExecutor = ( - command: string, - args: string[], - cwd: string, - env: Record -) => Promise<{ exitCode: number; stdout: string; stderr: string }>; - -type RunState = { - context: RunContext; - externalRunId?: string; -}; - -const externalRunIdKeys = new Set(["thread_id", "threadId", "session_id", "sessionId"]); - -function extractExternalRunId(payload: unknown): string | undefined { - const queue: unknown[] = [payload]; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current || typeof current !== "object") { - continue; - } - - for (const [key, value] of Object.entries(current)) { - if (externalRunIdKeys.has(key) && typeof value === "string" && value.trim().length > 0) { - return value; - } - - if (value && typeof value === "object") { - queue.push(value); - } - } +// Canonical Codex transport: app-server (JSON-RPC over JSONL). +export class CodexAdapter extends CodexAppServerAdapter { + constructor(options: CodexAppServerAdapterOptions = {}) { + super(options); } - - return undefined; } -export class CodexAdapter implements AgentAdapter { - private readonly runs = new Map(); - - constructor( - private readonly execute?: CommandExecutor, - private readonly command = "codex" - ) {} - - startRun(context: RunContext): Promise { - const runId = randomUUID(); - this.runs.set(runId, { context }); - return Promise.resolve({ runId }); - } - - async *streamEvents(runId: string): AsyncIterable { - const run = this.runs.get(runId); - if (!run) { - yield { - type: "run.failed", - runId, - reason: "unknown run", - at: new Date().toISOString() - }; - return; - } - - yield { type: "run.started", runId, at: new Date().toISOString() }; - - const args = ["exec", "--json"]; - if (run.context.approvalMode === "full-auto" || run.context.approvalMode === "auto-edit") { - args.push("--full-auto"); - } - args.push(run.context.prompt); - - // Test harnesses inject an executor that returns buffered stdout/stderr. - // Production uses a streaming spawn so the control plane can surface live output. - if (this.execute) { - const result = await this.execute( - this.command, - args, - run.context.workingDirectory, - run.context.env ?? {} - ); - - const lines = result.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - for (const line of lines) { - const rendered = renderCodexJsonLine(line); - if (rendered.parsed) { - const externalRunId = extractExternalRunId(rendered.parsed); - if (externalRunId) run.externalRunId = externalRunId; - } - - yield { - type: "run.output", - runId, - stream: "stdout", - chunk: rendered.chunk, - ...(rendered.raw ? { raw: rendered.raw } : {}), - at: new Date().toISOString() - }; - } - - if (result.stderr.trim()) { - yield { - type: "run.output", - runId, - stream: "stderr", - chunk: result.stderr.trim(), - at: new Date().toISOString() - }; - } - - if (result.exitCode === 0) { - yield { - type: "run.completed", - runId, - exitCode: 0, - at: new Date().toISOString() - }; - return; - } - - yield { - type: "run.failed", - runId, - reason: `codex exited with code ${String(result.exitCode)}`, - at: new Date().toISOString() - }; - return; - } - - const child = spawn(this.command, args, { - cwd: run.context.workingDirectory, - env: { ...process.env, ...(run.context.env ?? {}) }, - // Inherit stdin so callers can feed approval/input prompts via the parent process stdin. - stdio: ["inherit", "pipe", "pipe"] - }); - - const queue: Array<{ stream: "stdout" | "stderr"; line: string }> = []; - let notify: (() => void) | null = null; - let closed = false; - let exitCode: number | null = null; - - const push = (item: { stream: "stdout" | "stderr"; line: string }) => { - queue.push(item); - if (notify) { - const n = notify; - notify = null; - n(); - } - }; - const close = () => { - closed = true; - if (notify) { - const n = notify; - notify = null; - n(); - } - }; - - child.on("error", () => { - exitCode = 1; - close(); - }); - child.on("close", (code) => { - exitCode = code ?? 1; - close(); - }); - - const stdoutRl = createInterface({ input: child.stdout }); - const stderrRl = createInterface({ input: child.stderr }); - - stdoutRl.on("line", (line) => { - push({ stream: "stdout", line }); - }); - stderrRl.on("line", (line) => { - push({ stream: "stderr", line }); - }); - - // Drain lines as they arrive. - for (;;) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `closed` is updated via child process event handlers. - if (closed && queue.length === 0) { - break; - } - if (queue.length === 0) { - await new Promise((resolve) => { - notify = resolve; - }); - continue; - } - - const next = queue.shift(); - if (!next) continue; - const line = next.line.trim(); - if (!line) continue; - - if (next.stream === "stdout") { - const rendered = renderCodexJsonLine(line); - if (rendered.parsed) { - const externalRunId = extractExternalRunId(rendered.parsed); - if (externalRunId) run.externalRunId = externalRunId; - } - - yield { - type: "run.output", - runId, - stream: "stdout", - chunk: rendered.chunk, - ...(rendered.raw ? { raw: rendered.raw } : {}), - at: new Date().toISOString() - }; - continue; - } - - yield { - type: "run.output", - runId, - stream: next.stream, - chunk: line, - at: new Date().toISOString() - }; - } - - const finalExitCode: number = typeof exitCode === "number" ? exitCode : 1; - if (finalExitCode === 0) { - yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() }; - return; - } - - yield { - type: "run.failed", - runId, - reason: `codex exited with code ${String(finalExitCode)}`, - at: new Date().toISOString() - }; - } - - resume(runId: string): Promise { - const run = this.runs.get(runId); - if (!run) { - return Promise.reject(new Error(`run not found: ${runId}`)); - } - - if (!run.externalRunId) { - return Promise.reject(new Error(`external run id not found: ${runId}`)); - } - - return Promise.resolve({ runId, externalRunId: run.externalRunId }); - } - - cancel(runId: string): Promise { - this.runs.delete(runId); - return Promise.resolve(); - } -} diff --git a/packages/adapter-codex/src/render.test.ts b/packages/adapter-codex/src/render.test.ts deleted file mode 100644 index a5d1629..0000000 --- a/packages/adapter-codex/src/render.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderCodexJsonLine } from "./render.js"; - -describe("renderCodexJsonLine", () => { - it("returns empty chunk for empty input", () => { - expect(renderCodexJsonLine("")).toEqual({ chunk: "" }); - expect(renderCodexJsonLine(" ")).toEqual({ chunk: "" }); - }); - - it("returns non-JSON lines as-is with newline", () => { - const result = renderCodexJsonLine("plain text output"); - expect(result.chunk).toBe("plain text output\n"); - expect(result.raw).toBe("plain text output"); - }); - - it("extracts top-level text field", () => { - const result = renderCodexJsonLine('{"type":"message","text":"hello world"}'); - expect(result.chunk).toBe("hello world"); - expect(result.parsed).toEqual({ type: "message", text: "hello world" }); - }); - - it("extracts text from message.text", () => { - const result = renderCodexJsonLine('{"message":{"text":"nested msg"}}'); - expect(result.chunk).toBe("nested msg"); - }); - - it("extracts text from delta.text", () => { - const result = renderCodexJsonLine('{"delta":{"text":"delta chunk"}}'); - expect(result.chunk).toBe("delta chunk"); - }); - - it("extracts text from content array", () => { - const input = JSON.stringify({ - content: [ - { type: "text", text: "part1" }, - { type: "image", url: "x" }, - { type: "text", text: "part2" } - ] - }); - const result = renderCodexJsonLine(input); - expect(result.chunk).toBe("part1part2"); - }); - - it("falls back to nested text search", () => { - const input = JSON.stringify({ outer: { inner: { text: "deep value" } } }); - const result = renderCodexJsonLine(input); - expect(result.chunk).toBe("deep value"); - }); - - it("returns empty chunk for JSON with no text", () => { - const result = renderCodexJsonLine('{"type":"status","code":200}'); - expect(result.chunk).toBe(""); - expect(result.raw).toBe('{"type":"status","code":200}'); - expect(result.parsed).toEqual({ type: "status", code: 200 }); - }); - - it("returns empty chunk for non-object JSON", () => { - const result = renderCodexJsonLine("42"); - expect(result.chunk).toBe(""); - expect(result.raw).toBe("42"); - }); -}); diff --git a/packages/adapter-codex/src/render.ts b/packages/adapter-codex/src/render.ts deleted file mode 100644 index 0a8ad8d..0000000 --- a/packages/adapter-codex/src/render.ts +++ /dev/null @@ -1,87 +0,0 @@ -export type CodexRendered = { - chunk: string; - raw?: string; - parsed?: unknown; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function extractTextFromContentArray(value: unknown): string | undefined { - if (!Array.isArray(value)) return undefined; - const parts: string[] = []; - for (const item of value) { - if (!isRecord(item)) continue; - if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) { - parts.push(item.text); - } - } - if (parts.length === 0) return undefined; - return parts.join(""); -} - -function findNestedText(value: unknown, maxNodes = 200): string | undefined { - const queue: unknown[] = [value]; - let visited = 0; - - while (queue.length > 0 && visited < maxNodes) { - visited++; - const current = queue.shift(); - if (Array.isArray(current)) { - for (const item of current) queue.push(item); - continue; - } - if (!isRecord(current)) continue; - - for (const [key, v] of Object.entries(current)) { - if (key === "text" && typeof v === "string" && v.length > 0) { - return v; - } - if (isRecord(v) || Array.isArray(v)) { - queue.push(v); - } - } - } - - return undefined; -} - -export function renderCodexJsonLine(rawLine: string): CodexRendered { - const trimmed = rawLine.trim(); - if (!trimmed) return { chunk: "" }; - - let parsed: unknown; - try { - parsed = JSON.parse(trimmed) as unknown; - } catch { - // Codex can emit non-JSON lines in mixed streams. - return { chunk: `${trimmed}\n`, raw: trimmed }; - } - - if (!isRecord(parsed)) { - return { chunk: "", raw: trimmed, parsed }; - } - - if (typeof parsed.text === "string" && parsed.text.length > 0) { - return { chunk: parsed.text, raw: trimmed, parsed }; - } - - const message = parsed.message; - if (isRecord(message) && typeof message.text === "string" && message.text.length > 0) { - return { chunk: message.text, raw: trimmed, parsed }; - } - - const delta = parsed.delta; - if (isRecord(delta) && typeof delta.text === "string" && delta.text.length > 0) { - return { chunk: delta.text, raw: trimmed, parsed }; - } - - const contentText = extractTextFromContentArray(parsed.content); - if (contentText) return { chunk: contentText, raw: trimmed, parsed }; - - const nestedText = findNestedText(parsed); - if (nestedText) return { chunk: nestedText, raw: trimmed, parsed }; - - return { chunk: "", raw: trimmed, parsed }; -} diff --git a/packages/control-plane/src/adapter-parity.test.ts b/packages/control-plane/src/adapter-parity.test.ts index bba3bc7..1ac9bcc 100644 --- a/packages/control-plane/src/adapter-parity.test.ts +++ b/packages/control-plane/src/adapter-parity.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { CodexAdapter } from "@forge/adapter-codex"; import { ClaudeAdapter } from "@forge/adapter-claude"; import type { AgentAdapter, RunContext } from "@forge/shared-utils"; @@ -23,11 +26,53 @@ async function collectTypes(adapter: AgentAdapter) { describe("adapter parity", () => { it("codex and claude adapters emit compatible event sequence", async () => { - const codex = new CodexAdapter(async () => ({ - exitCode: 0, - stdout: "line\n", - stderr: "" - })); + const dir = await mkdtemp(join(tmpdir(), "forge-codex-parity-")); + const serverPath = join(dir, "server.mjs"); + await writeFile( + serverPath, + ` +import { createInterface } from "node:readline"; + +const rl = createInterface({ input: process.stdin }); +let initialized = false; +let threadId = "thread-1"; +let turnId = "turn-1"; + +function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); } + +rl.on("line", (line) => { + const msg = JSON.parse(line); + if (msg.method === "initialize") { + send({ id: msg.id, result: { userAgent: "fake" } }); + return; + } + if (msg.method === "initialized") { + initialized = true; + return; + } + if (!initialized) { + send({ id: msg.id, error: { message: "not initialized" } }); + return; + } + if (msg.method === "thread/start") { + send({ id: msg.id, result: { thread: { id: threadId } } }); + return; + } + if (msg.method === "turn/start") { + send({ id: msg.id, result: { turn: { id: turnId, status: "inProgress", items: [] } } }); + send({ method: "item/agentMessage/delta", params: { delta: "line", itemId: "item-1", threadId, turnId } }); + send({ method: "turn/completed", params: { threadId, turn: { id: turnId, status: "completed", items: [] } } }); + return; + } +}); + `.trim(), + "utf8" + ); + + const codex = new CodexAdapter({ + spawnCommand: "node", + spawnArgs: [serverPath] + }); const claude = new ClaudeAdapter(async () => ({ exitCode: 0, From 2750627ecec3a5e477e90800a3155c6e1a92fb40 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 21:08:15 +0800 Subject: [PATCH 18/24] feat(desktop): run plans via workflow auto + codex app-server session --- apps/desktop/src-tauri/src/forge_cli.rs | 7 +- apps/desktop/src-tauri/src/http_server.rs | 373 ++++++++++++++++-- apps/desktop/src/App.vue | 221 +++++++---- .../src/components/NewPlanDialog.test.ts | 11 +- apps/desktop/src/components/NewPlanDialog.vue | 267 ++++++++++++- .../src/components/newPlanSpawnConfig.ts | 13 - .../src/composables/useControlPlane.test.ts | 71 +++- .../src/composables/useControlPlane.ts | 47 ++- decisions.md | 2 +- .../src/app-server-adapter.prompt.test.ts | 134 +++++++ packages/adapter-codex/src/index.test.ts | 10 + packages/cli/src/cli.ts | 85 +++- packages/cli/src/codex-session.test.ts | 81 ++++ 13 files changed, 1137 insertions(+), 185 deletions(-) create mode 100644 packages/adapter-codex/src/app-server-adapter.prompt.test.ts create mode 100644 packages/adapter-codex/src/index.test.ts create mode 100644 packages/cli/src/codex-session.test.ts diff --git a/apps/desktop/src-tauri/src/forge_cli.rs b/apps/desktop/src-tauri/src/forge_cli.rs index eb9eebc..69f930a 100644 --- a/apps/desktop/src-tauri/src/forge_cli.rs +++ b/apps/desktop/src-tauri/src/forge_cli.rs @@ -85,10 +85,11 @@ pub fn run_forge_json(app: &AppHandle, cwd: &Path, args: &[String]) -> Result Result { let (bin, base_args) = resolve_forge_command(app); let mut cmd = tokio::process::Command::new(&bin); @@ -100,6 +101,10 @@ pub fn spawn_forge_stream( .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.spawn() .map_err(|error| format!("failed to spawn forge command {bin:?}: {error}")) } diff --git a/apps/desktop/src-tauri/src/http_server.rs b/apps/desktop/src-tauri/src/http_server.rs index 5bbc0a4..76523d9 100644 --- a/apps/desktop/src-tauri/src/http_server.rs +++ b/apps/desktop/src-tauri/src/http_server.rs @@ -22,7 +22,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio_stream::wrappers::ReceiverStream; use uuid::Uuid; -use crate::forge_cli::{run_forge_json, spawn_forge_stream}; +use crate::forge_cli::{run_forge_json, spawn_forge_stream_with_env}; use crate::packs::{ compute_update_status, download_and_install_pack, fetch_packs_index, merge_bundled_packs, read_bundled_packs, read_installed_packs, read_pack_content, PackContent, @@ -198,15 +198,16 @@ async fn plan_validate( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RunNextStreamQuery { +struct WorkflowAutoStreamQuery { project_root: String, plan_path: String, adapter: String, + push: Option, } -async fn run_next_stream( +async fn workflow_auto_stream( State(state): State, - Query(query): Query, + Query(query): Query, ) -> Result>>, (StatusCode, String)> { let stream_id = Uuid::new_v4().to_string(); @@ -236,17 +237,28 @@ async fn run_next_stream( .await; let cwd = PathBuf::from(&query.project_root); - let args = vec![ - "run".to_string(), - "next".to_string(), + let mut args = vec![ + "workflow".to_string(), + "auto".to_string(), "--plan".to_string(), query.plan_path, "--adapter".to_string(), query.adapter, "--jsonl".to_string(), ]; + if query.push == Some(false) { + args.push("--no-push".to_string()); + } - let mut child = match spawn_forge_stream(&app, &cwd, &args) { + let mut child = match spawn_forge_stream_with_env( + &app, + &cwd, + &args, + &[ + ("FORGE_INTERACTIVE", "1"), + ("FORGE_DESKTOP", "1"), + ], + ) { Ok(c) => c, Err(error) => { let _ = out_tx @@ -409,14 +421,261 @@ async fn run_next_stream( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RunNextStreamInputRequest { +struct CodexSessionStreamQuery { + project_root: String, +} + +async fn codex_session_stream( + State(state): State, + Query(query): Query, +) -> Result>>, (StatusCode, String)> +{ + let stream_id = Uuid::new_v4().to_string(); + let (out_tx, out_rx) = tokio::sync::mpsc::channel::(256); + let (input_tx, mut input_rx) = tokio::sync::mpsc::channel::(64); + let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel::(false); + + state.run_streams.insert( + stream_id.clone(), + RunStreamControls { + input_tx, + cancel_tx, + }, + ); + + let app = state.app.clone(); + let run_streams = state.run_streams.clone(); + tokio::spawn(async move { + let _ = out_tx + .send( + serde_json::json!({ + "type": "sse.meta", + "streamId": stream_id + }) + .to_string(), + ) + .await; + + let cwd = PathBuf::from(&query.project_root); + let args = vec![ + "codex".to_string(), + "session".to_string(), + "--jsonl".to_string(), + ]; + + let mut child = match spawn_forge_stream_with_env( + &app, + &cwd, + &args, + &[ + ("FORGE_INTERACTIVE", "1"), + ("FORGE_DESKTOP", "1"), + ], + ) { + Ok(c) => c, + Err(error) => { + let _ = out_tx + .send( + serde_json::json!({ + "type": "sse.error", + "message": error + }) + .to_string(), + ) + .await; + run_streams.remove(&stream_id); + return; + } + }; + + let mut stdin = match child.stdin.take() { + Some(s) => s, + None => { + let _ = out_tx + .send( + serde_json::json!({ + "type": "sse.error", + "message": "child stdin missing" + }) + .to_string(), + ) + .await; + run_streams.remove(&stream_id); + return; + } + }; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let mut stdout_lines = stdout.map(|s| BufReader::new(s).lines()); + let mut stderr_lines = stderr.map(|s| BufReader::new(s).lines()); + + let pid = child.id(); + let _ = out_tx + .send( + serde_json::json!({ + "type": "process.spawned", + "pid": pid + }) + .to_string(), + ) + .await; + + loop { + if *cancel_rx.borrow() { + let _ = child.start_kill(); + let _ = out_tx + .send(serde_json::json!({ "type": "process.killed" }).to_string()) + .await; + break; + } + + tokio::select! { + _ = cancel_rx.changed() => {} + maybe_input = input_rx.recv() => { + if let Some(text) = maybe_input { + let mut bytes = text.into_bytes(); + if !bytes.ends_with(b"\\n") { + bytes.push(b'\n'); + } + let _ = stdin.write_all(&bytes).await; + let _ = stdin.flush().await; + } + } + res = async { + if let Some(lines) = &mut stdout_lines { + lines.next_line().await + } else { + Ok(None) + } + } => { + match res { + Ok(Some(text)) => { + let trimmed = text.trim(); + if !trimmed.is_empty() { + let _ = out_tx.send(trimmed.to_string()).await; + } + } + Ok(None) => { stdout_lines = None; } + Err(error) => { + let _ = out_tx + .send(serde_json::json!({"type":"process.stdout_error","message": error.to_string()}).to_string()) + .await; + stdout_lines = None; + } + } + } + res = async { + if let Some(lines) = &mut stderr_lines { + lines.next_line().await + } else { + Ok(None) + } + } => { + match res { + Ok(Some(text)) => { + let trimmed = text.trim(); + if !trimmed.is_empty() { + let _ = out_tx + .send(serde_json::json!({"type":"process.stderr","line": trimmed}).to_string()) + .await; + } + } + Ok(None) => { stderr_lines = None; } + Err(error) => { + let _ = out_tx + .send(serde_json::json!({"type":"process.stderr_error","message": error.to_string()}).to_string()) + .await; + stderr_lines = None; + } + } + } + } + + if stdout_lines.is_none() && stderr_lines.is_none() { + break; + } + } + + let status = child.wait().await.ok(); + let code = status.as_ref().and_then(|s| s.code()).unwrap_or(-1); + let _ = out_tx + .send(serde_json::json!({ "type": "process.exit", "code": code }).to_string()) + .await; + + run_streams.remove(&stream_id); + }); + + let stream = ReceiverStream::new(out_rx).map(|line| Ok(Event::default().data(line))); + Ok(Sse::new(stream).keep_alive( + axum::response::sse::KeepAlive::new() + .interval(std::time::Duration::from_secs(15)) + .text("keep-alive"), + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkflowAutoPromptRespondRequest { + stream_id: String, + request_id: String, + answers: serde_json::Value, +} + +async fn workflow_auto_prompt_respond( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let controls = state + .run_streams + .get(&body.stream_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "stream not found"))?; + + let line = serde_json::json!({ + "type": "user_input.response", + "requestId": body.request_id, + "answers": body.answers + }) + .to_string(); + controls + .input_tx + .send(line) + .await + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "stream input channel closed"))?; + Ok(Json(true)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkflowAutoStreamCancelRequest { + stream_id: String, +} + +async fn workflow_auto_stream_cancel( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let controls = state + .run_streams + .get(&body.stream_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "stream not found"))?; + controls + .cancel_tx + .send(true) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "stream cancel channel closed"))?; + Ok(Json(true)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CodexSessionSendRequest { stream_id: String, text: String, } -async fn run_next_stream_input( +async fn codex_session_send( State(state): State, - Json(body): Json, + Json(body): Json, ) -> Result, (StatusCode, String)> { let controls = state .run_streams @@ -432,13 +691,45 @@ async fn run_next_stream_input( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RunNextStreamCancelRequest { +struct CodexSessionPromptRespondRequest { stream_id: String, + request_id: String, + answers: serde_json::Value, } -async fn run_next_stream_cancel( +async fn codex_session_prompt_respond( State(state): State, - Json(body): Json, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let controls = state + .run_streams + .get(&body.stream_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "stream not found"))?; + + let line = serde_json::json!({ + "type": "user_input.response", + "requestId": body.request_id, + "answers": body.answers + }) + .to_string(); + + controls + .input_tx + .send(line) + .await + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "stream input channel closed"))?; + Ok(Json(true)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CodexSessionCancelRequest { + stream_id: String, +} + +async fn codex_session_cancel( + State(state): State, + Json(body): Json, ) -> Result, (StatusCode, String)> { let controls = state .run_streams @@ -1081,33 +1372,33 @@ async fn plans_status( Query(query): Query, ) -> Result, (StatusCode, String)> { tauri::async_runtime::spawn_blocking(move || { - let state_path = PathBuf::from(&query.project_root).join(".forge").join("state.json"); - if !state_path.exists() { + let plan_path = { + let p = PathBuf::from(&query.plan_path); + if p.is_absolute() { + p + } else { + PathBuf::from(&query.project_root).join(p) + } + }; + if !plan_path.exists() { return Ok::<_, String>(PlanStatusResult { tasks: vec![] }); } - let raw = std::fs::read_to_string(&state_path) - .map_err(|e| format!("read state.json: {e}"))?; + let raw = std::fs::read_to_string(&plan_path) + .map_err(|e| format!("read plan: {e}"))?; let value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| format!("parse state.json: {e}"))?; - - // state.json may scope by plan path: look for tasks under the plan key or at top level - let tasks_value = value - .get(&query.plan_path) - .and_then(|v| v.get("tasks")) - .or_else(|| value.get("tasks")); + .map_err(|e| format!("parse plan: {e}"))?; + let tasks_value = value.get("tasks"); let mut tasks = Vec::new(); - if let Some(tasks_obj) = tasks_value.and_then(|v| v.as_object()) { - for (id, task_val) in tasks_obj { - let state = task_val - .get("state") - .and_then(|v| v.as_str()) - .unwrap_or("pending") - .to_string(); - tasks.push(TaskStatus { - id: id.clone(), - state, - }); + if let Some(items) = tasks_value.and_then(|v| v.as_array()) { + for item in items { + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if id.is_empty() { + continue; + } + let status = item.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let state = if status.trim().is_empty() { "pending" } else { status }.to_string(); + tasks.push(TaskStatus { id, state }); } } tasks.sort_by(|a, b| a.id.cmp(&b.id)); @@ -1335,9 +1626,13 @@ pub async fn serve(app: AppHandle) -> Result<(), String> { .route("/api/cwd", get(get_cwd)) .route("/api/debug/status", get(debug_status)) .route("/api/plan/validate", post(plan_validate)) - .route("/api/run/next/stream", get(run_next_stream)) - .route("/api/run/next/input", post(run_next_stream_input)) - .route("/api/run/next/cancel", post(run_next_stream_cancel)) + .route("/api/workflow/auto/stream", get(workflow_auto_stream)) + .route("/api/workflow/auto/prompt/respond", post(workflow_auto_prompt_respond)) + .route("/api/workflow/auto/cancel", post(workflow_auto_stream_cancel)) + .route("/api/codex/session/stream", get(codex_session_stream)) + .route("/api/codex/session/send", post(codex_session_send)) + .route("/api/codex/session/prompt/respond", post(codex_session_prompt_respond)) + .route("/api/codex/session/cancel", post(codex_session_cancel)) .route("/api/evidence", get(get_evidence)) .route("/api/project/guidance-status", get(project_get_guidance_status)) .route("/api/packs/installed", get(packs_list_installed)) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 9fa3fc6..d8bfffd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -58,27 +58,12 @@
New Plan - Run + Run Open Evidence
- - - Send - + Cancel @@ -100,7 +85,7 @@

Current Task

ID: {{ current.taskId || "-" }}

Status: {{ current.state || "-" }}

-

Run ID: {{ current.runId || "-" }}

+

Phase: {{ current.phase || "-" }}

External Run ID: {{ current.externalRunId || "-" }}

Resume: {{ current.resumeCommand || "-" }}

Stream ID: {{ streamId || "-" }}

@@ -113,7 +98,7 @@ @@ -344,6 +329,34 @@ + + + +

Codex Needs Input

+
+
{{ q.header }}
+
{{ q.question }}
+ + + + +
+
+ Cancel + Submit +
+
+
@@ -367,9 +380,9 @@ import { plansStatus, projectGetGuidanceStatus, projectInstallGuidance, - runNextStreamCancel, - runNextStreamInput, - runNextStreamUrl, + workflowAutoCancel, + workflowAutoPromptRespond, + workflowAutoStreamUrl, selectFile, selectFolder, type InstalledPack, @@ -378,7 +391,6 @@ import { type PhaseGateBindings, type PlanFileEntry, type ProjectGuidanceStatus, - type RunNextResult, type TaskStatus, type ValidationIssue } from "./composables/useControlPlane"; @@ -410,6 +422,9 @@ const logs = ref([]); const evidence = ref([]); const LAST_PROJECT_ROOT_KEY = "forge.desktop.lastProjectRoot"; +const PUSH_AFTER_TASK_KEY = "forge.desktop.pushAfterTask"; + +const pushEnabled = ref(false); const guidanceStatus = ref(); const guidanceError = ref(""); @@ -542,14 +557,22 @@ function syncPackSelection(): void { selectedPackPath.value = (matchingInstalled ?? versions[0]!).path; } -const current = reactive({ +type WorkflowAutoUiState = { + state: "idle" | "running" | "paused" | "completed" | "failed"; + taskId?: string; + phase?: string; + externalRunId?: string; + resumeCommand?: string; + message: string; +}; + +const current = reactive({ state: "idle", message: "Not started" }); const streaming = ref(false); const streamId = ref(""); -const streamInput = ref(""); let eventSource: EventSource | null = null; type LiveOutputSegment = { @@ -694,7 +717,18 @@ watch(tab, (newTab) => { } }); +function loadPushSetting(): void { + try { + const raw = localStorage.getItem(`${PUSH_AFTER_TASK_KEY}:${projectRoot.value}`) ?? ""; + pushEnabled.value = raw === "true"; + } catch { + pushEnabled.value = false; + } +} + watch(projectRoot, () => { + loadPushSetting(); + // When switching projects, refresh pack + guidance context automatically. onRefreshGuidance(); onListInstalledPacks(); @@ -704,6 +738,14 @@ watch(projectRoot, () => { } }); +watch(pushEnabled, (value) => { + try { + localStorage.setItem(`${PUSH_AFTER_TASK_KEY}:${projectRoot.value}`, value ? "true" : "false"); + } catch { + // best-effort + } +}); + watch(selectedPackName, () => { // Keep selectedPackPath aligned to the selected pack name. const versions = downloadedVersionsForSelected.value; @@ -859,7 +901,56 @@ function pushLog(line: string): void { } } -async function onRunNextStream(): Promise { +type UserInputOption = { label: string; description?: string; isOther?: boolean }; +type UserInputQuestion = { id: string; header?: string; question: string; options: UserInputOption[] }; + +const userInputOpen = ref(false); +const userInputRequestId = ref(""); +const userInputQuestions = ref([]); +const userInputSelectedByQuestionId = ref>({}); +const userInputOtherTextByQuestionId = ref>({}); + +function openUserInput(requestId: string, questions: UserInputQuestion[]): void { + userInputRequestId.value = requestId; + userInputQuestions.value = questions; + userInputSelectedByQuestionId.value = {}; + userInputOtherTextByQuestionId.value = {}; + for (const q of questions) { + const first = q.options[0]?.label ?? ""; + if (first) userInputSelectedByQuestionId.value[q.id] = first; + } + userInputOpen.value = true; +} + +function closeUserInput(): void { + userInputOpen.value = false; + userInputRequestId.value = ""; + userInputQuestions.value = []; + userInputSelectedByQuestionId.value = {}; + userInputOtherTextByQuestionId.value = {}; +} + +async function cancelUserInput(): Promise { + closeUserInput(); + await onCancelStream(); +} + +async function submitUserInput(): Promise { + if (!streamId.value || !userInputRequestId.value) return; + const answers: Record = {}; + for (const q of userInputQuestions.value) { + const selected = userInputSelectedByQuestionId.value[q.id] ?? ""; + const option = q.options.find((o) => o.label === selected); + const value = + option?.isOther === true ? (userInputOtherTextByQuestionId.value[q.id] ?? "").trim() || selected : selected; + answers[q.id] = { answers: value ? [value] : [] }; + } + await workflowAutoPromptRespond(streamId.value, userInputRequestId.value, answers); + pushLog(` [prompt] responded to ${userInputRequestId.value}`); + closeUserInput(); +} + +async function onWorkflowAutoStream(): Promise { stopStream(); clearLiveOutput(); @@ -872,17 +963,16 @@ async function onRunNextStream(): Promise { current.state = "running"; current.taskId = undefined; - current.runId = undefined; + current.phase = undefined; current.externalRunId = undefined; current.resumeCommand = undefined; - current.message = "Running (stream)..."; + current.message = "Running (workflow auto)..."; streamId.value = ""; - streamInput.value = ""; streaming.value = true; - pushLog("Run (stream) -> started"); + pushLog("Workflow auto (stream) -> started"); - const url = runNextStreamUrl(projectRoot.value, planPath.value, adapter.value); + const url = workflowAutoStreamUrl(projectRoot.value, planPath.value, adapter.value, pushEnabled.value); eventSource = new EventSource(url); eventSource.addEventListener("message", (event) => { @@ -917,6 +1007,9 @@ async function onRunNextStream(): Promise { const tool = String(e.tool ?? "tool"); const status = String(e.status ?? ""); appendLiveOutput("system", `[tool] ${tool}${status ? ` ${status}` : ""}\n`); + } else if (e?.type === "run.user_input.requested") { + openUserInput(String(e.requestId ?? ""), (e.questions ?? []) as UserInputQuestion[]); + appendLiveOutput("system", `[prompt] waiting for user input (${String(e.requestId ?? "")})\n`); } else if (e?.type === "run.failed") { appendLiveOutput("stderr", `[failed] ${String(e.reason ?? "")}\n`); } else if (e?.type) { @@ -925,34 +1018,24 @@ async function onRunNextStream(): Promise { return; } - if (type === "run.next.result") { - const result = parsed.result as RunNextResult | undefined; - if (result) { - current.state = result.state; - current.taskId = result.taskId; - current.runId = result.runId; - current.externalRunId = result.externalRunId; - current.resumeCommand = result.resumeCommand; - current.message = result.message; - pushLog(`Run (stream) -> ${result.message}`); - if (result.runId) pushLog(` runId: ${result.runId}`); - if (result.externalRunId) pushLog(` externalRunId: ${result.externalRunId}`); - if (result.resumeCommand) pushLog(` resume: ${result.resumeCommand}`); - if (result.classification) pushLog(` classification: ${result.classification}`); - if (result.checksSummary?.length) { - for (const check of result.checksSummary) { - pushLog(` ${check}`); - } - } - if (result.llmOutput?.length) { - pushLog(" adapter output (tail):"); - for (const line of result.llmOutput) { - pushLog(` ${line}`); - } - } - } else { - pushLog("Run (stream) -> missing result payload"); - } + if (type === "workflow.auto.step") { + const taskId = String(parsed.taskId ?? ""); + const phase = String(parsed.phase ?? ""); + current.taskId = taskId || current.taskId; + current.phase = phase || current.phase; + current.message = taskId && phase ? `Running ${taskId} (${phase})` : "Running..."; + return; + } + + if (type === "workflow.auto.paused" || type === "workflow.auto.completed") { + const step = parsed.step ?? {}; + current.state = step.state === "paused" ? "paused" : step.state === "completed" ? "completed" : current.state; + current.taskId = typeof step.taskId === "string" ? step.taskId : current.taskId; + current.phase = typeof step.phase === "string" ? step.phase : current.phase; + current.externalRunId = typeof step.externalRunId === "string" ? step.externalRunId : undefined; + current.resumeCommand = typeof step.resumeCommand === "string" ? step.resumeCommand : undefined; + current.message = typeof step.message === "string" ? step.message : current.message; + pushLog(`Workflow auto (stream) -> ${current.message}`); stopStream(); return; } @@ -966,29 +1049,17 @@ async function onRunNextStream(): Promise { }); eventSource.addEventListener("error", () => { - pushLog("Run (stream) -> SSE error/disconnected"); + pushLog("Workflow auto (stream) -> SSE error/disconnected"); }); } -async function onSendStreamInput(): Promise { - const text = streamInput.value.trim(); - if (!text || !streamId.value) return; - try { - await runNextStreamInput(streamId.value, text); - pushLog(` [input] ${text}`); - streamInput.value = ""; - } catch (error) { - pushLog(` [input error] ${String(error)}`); - } -} - async function onCancelStream(): Promise { if (!streamId.value) return; try { - await runNextStreamCancel(streamId.value); - pushLog("Run (stream) -> cancel requested"); + await workflowAutoCancel(streamId.value); + pushLog("Workflow auto (stream) -> cancel requested"); } catch (error) { - pushLog(`Run (stream) -> cancel error: ${String(error)}`); + pushLog(`Workflow auto (stream) -> cancel error: ${String(error)}`); } finally { stopStream(); } diff --git a/apps/desktop/src/components/NewPlanDialog.test.ts b/apps/desktop/src/components/NewPlanDialog.test.ts index 61afd71..aed7b8e 100644 --- a/apps/desktop/src/components/NewPlanDialog.test.ts +++ b/apps/desktop/src/components/NewPlanDialog.test.ts @@ -95,15 +95,8 @@ describe("new plan spawn config", () => { expect(config).toEqual({ command: "claude", cwd: "/tmp/project" }); }); - it("builds codex config with TERM/env and no-alt-screen", () => { + it("builds default config for other adapters", () => { const config = buildNewPlanSpawnConfig("codex", "/tmp/project"); - expect(config.command).toBe("codex"); - expect(config.cwd).toBe("/tmp/project"); - expect(config.args).toEqual(["--no-alt-screen"]); - expect(config.env).toEqual({ - TERM: "xterm-256color", - COLORTERM: "truecolor", - RUST_BACKTRACE: "1" - }); + expect(config).toEqual({ command: "codex", cwd: "/tmp/project" }); }); }); diff --git a/apps/desktop/src/components/NewPlanDialog.vue b/apps/desktop/src/components/NewPlanDialog.vue index 33995f8..5eabb41 100644 --- a/apps/desktop/src/components/NewPlanDialog.vue +++ b/apps/desktop/src/components/NewPlanDialog.vue @@ -12,7 +12,7 @@

Instructions

  1. - The terminal on the right is running {{ adapterLabel }}. + The panel on the right is running {{ adapterLabel }}.
  2. {{ skillInstruction }} to start guided plan creation. @@ -32,8 +32,8 @@ {{ error }} - - Starting terminal... + + {{ isCodex ? "Starting Codex session..." : "Starting terminal..." }} @@ -76,19 +76,45 @@ {{ error }} - -
    - -
    -
    - -
    + +
@@ -100,12 +126,42 @@ + + + +

Codex Needs Input

+
+
{{ q.header }}
+
{{ q.question }}
+ + + + +
+
+ Cancel + Submit +
+
+