Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -882,13 +882,13 @@ On Linux this writes a user systemd service. On macOS it writes a LaunchAgent pl
The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:

- Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
- Codewith uses durable `codewith --ask-for-approval never agent start` background-agent runs by default, then polls `codewith agent read` until the run is terminal. While the step is running, OpenLoops records bounded agent id/status updates as `step_progress` workflow events and as the running step's compact stdout; on terminal completion it records compact status/event evidence. Remote Codewith agent steps fail closed until remote durable readback is implemented, so workflows do not advance immediately after enqueue. OpenLoops rejects Codewith `extraArgs` that try to force `exec`, `--ephemeral`, or other non-durable exec-only flags for task-lifecycle, planner, worker, verifier, reviewer, release, or other long-running agentic work.
- Codewith runs one-shot `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check` workers by default (mirroring Codex), honoring `--auth-profile` and reaching the network so model-run `gh`/`git` commands can complete. The default `workspace-write` sandbox is granted network egress via `-c sandbox_workspace_write.network_access=true`. Set the step/target `codewithMode: "agent"` to opt back into the durable `codewith agent start` background-agent path, which OpenLoops then polls with `codewith agent read` until terminal, recording bounded agent id/status updates as `step_progress` workflow events and compact status/event evidence on completion; remote durable-agent steps fail closed until remote readback is implemented. OpenLoops rejects Codewith `extraArgs` that try to force `exec`, `agent`, `--ephemeral`, `--json`, `-o`, or other flags the adapter manages itself.
- AI Copilot and OpenCode use `run --format json --pure`. OpenCode requires an explicit provider/model id because ambient OpenCode config is machine-specific.
- Cursor is CLI-first for now via the standalone `agent -p` binary. OpenLoops no longer falls back to `cursor agent`; install the standalone Cursor Agent CLI so preflight and scheduled runs use the same executable.
- Codex uses `codex --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories where supported.
- Agent prompts are sent through child stdin instead of argv where the provider supports stdin. Codewith durable background agents currently accept prompts as native `agent start` arguments, so OpenLoops stores only bounded status/event evidence and omits raw Codewith prompt text and raw event payloads from workflow stdout.
- Agent prompts are sent through child stdin instead of argv where the provider supports stdin. Default Codewith exec workers read the prompt from stdin (via `codewith exec -`), keeping it off argv/ps; the opt-in durable `agent start` path has no stdin channel, so it accepts the prompt as a native `agent start` argument and OpenLoops stores only bounded status/event evidence, omitting raw Codewith prompt text and raw event payloads from workflow stdout.
- When `--account` or a step `account` is set, OpenLoops resolves `accounts env <profile> --tool <tool>` before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt.
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` before `agent start/read/logs`; they do not call OpenAccounts.
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` on the `exec` worker (and on durable `agent start/read/logs` when `codewithMode: "agent"`); they do not call OpenAccounts.
- `--sandbox` maps to provider-native sandbox flags. Codewith/Codex accept `read-only`, `workspace-write`, or `danger-full-access`; Cursor accepts `enabled` or `disabled`.
- `--permission-mode` maps `plan`, `auto`, and `bypass` where the provider supports it. Claude uses native permission modes, Cursor maps bypass to `--force`, and OpenCode/AICopilot map bypass to `--dangerously-skip-permissions`.
- `--variant` is provider-specific reasoning/model effort. Claude maps it to `--effort`, Codewith/Codex map it to `model_reasoning_effort`, and OpenCode/AICopilot pass `--variant`.
Expand Down
6 changes: 3 additions & 3 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,13 +991,13 @@ On Linux this writes a user systemd service. On macOS it writes a LaunchAgent pl
The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:

- Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
- Codewith uses durable `codewith --ask-for-approval never agent start` background-agent runs by default, then polls `codewith agent read` until the run is terminal. While the step is running, OpenLoops records bounded agent id/status updates as `step_progress` workflow events and as the running step's compact stdout; on terminal completion it records compact status/event evidence. Remote Codewith agent steps fail closed until remote durable readback is implemented, so workflows do not advance immediately after enqueue. OpenLoops rejects Codewith `extraArgs` that try to force `exec`, `--ephemeral`, or other non-durable exec-only flags for task-lifecycle, planner, worker, verifier, reviewer, release, or other long-running agentic work.
- Codewith runs one-shot `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check` workers by default (mirroring Codex), honoring `--auth-profile` and reaching the network so model-run `gh`/`git` commands can complete. The default `workspace-write` sandbox is granted network egress via `-c sandbox_workspace_write.network_access=true`. Set the step/target `codewithMode: "agent"` to opt back into the durable `codewith agent start` background-agent path, which OpenLoops then polls with `codewith agent read` until terminal, recording bounded agent id/status updates as `step_progress` workflow events and compact status/event evidence on completion; remote durable-agent steps fail closed until remote readback is implemented. OpenLoops rejects Codewith `extraArgs` that try to force `exec`, `agent`, `--ephemeral`, `--json`, `-o`, or other flags the adapter manages itself.
- AI Copilot and OpenCode use `run --format json --pure`. OpenCode requires an explicit provider/model id because ambient OpenCode config is machine-specific.
- Cursor is CLI-first for now via the standalone `agent -p` binary. OpenLoops no longer falls back to `cursor agent`; install the standalone Cursor Agent CLI so preflight and scheduled runs use the same executable.
- Codex uses `codex --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories where supported.
- Agent prompts are sent through child stdin instead of argv where the provider supports stdin. Codewith durable background agents currently accept prompts as native `agent start` arguments, so OpenLoops stores only bounded status/event evidence and omits raw Codewith prompt text and raw event payloads from workflow stdout.
- Agent prompts are sent through child stdin instead of argv where the provider supports stdin. Default Codewith exec workers read the prompt from stdin (via `codewith exec -`), keeping it off argv/ps; the opt-in durable `agent start` path has no stdin channel, so it accepts the prompt as a native `agent start` argument and OpenLoops stores only bounded status/event evidence, omitting raw Codewith prompt text and raw event payloads from workflow stdout.
- When `--account` or a step `account` is set, OpenLoops resolves `accounts env <profile> --tool <tool>` before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt.
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` before `agent start/read/logs`; they do not call OpenAccounts.
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` on the `exec` worker (and on durable `agent start/read/logs` when `codewithMode: "agent"`); they do not call OpenAccounts.
- `--sandbox` maps to provider-native sandbox flags. Codewith/Codex accept `read-only`, `workspace-write`, or `danger-full-access`; Cursor accepts `enabled` or `disabled`.
- `--permission-mode` maps `plan`, `auto`, and `bypass` where the provider supports it. Claude uses native permission modes, Cursor maps bypass to `--force`, and OpenCode/AICopilot map bypass to `--dangerously-skip-permissions`.
- `--variant` is provider-specific reasoning/model effort. Claude maps it to `--effort`, Codewith/Codex map it to `model_reasoning_effort`, and OpenCode/AICopilot pass `--variant`.
Expand Down
105 changes: 96 additions & 9 deletions src/lib/agent-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,73 @@ describe("agent adapters", () => {
}
});

test("runs codewith through the exec worker adapter by default", async () => {
const binDir = mkdtempSync(join(tmpdir(), "loops-codewith-exec-"));
const fake = join(binDir, "codewith");
// Exec workers echo argv + stdin; the durable agent start/read/logs path must
// never be reached. `profile list` is answered because the executor preflights
// the native auth profile before running the worker.
await Bun.write(
fake,
[
"#!/usr/bin/env bash",
'if [[ "${1:-}" == "profile" && "${2:-}" == "list" ]]; then',
" printf 'NAME ACCOUNT PROVIDER MODE PLAN\\naccount001 - ChatGPT chatgpt Pro\\n'",
" exit 0",
"fi",
"printf '%s\\n' \"$@\"",
"printf 'stdin:'",
"cat",
].join("\n"),
);
chmodSync(fake, 0o755);

const store = new Store(":memory:");
try {
const loop = store.createLoop({
name: "codewith-exec-agent",
schedule: { type: "once", at: new Date().toISOString() },
target: {
type: "agent",
provider: "codewith",
authProfile: "account001",
prompt: "say ok",
cwd: ".",
configIsolation: "safe",
},
});
const claim = store.claimRun(loop, new Date().toISOString(), "test");
expect(claim).toBeDefined();
const result = await executeLoop(loop, claim!.run, {
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` },
});
expect(result.status).toBe("succeeded");
const args = result.stdout.trim().split(/\r?\n/);
// Default path is `codewith exec`, not the durable `agent start` daemon.
expect(args).toContain("exec");
expect(args).not.toContain("agent");
expect(args).not.toContain("start");
expect(args).toContain("--json");
expect(args).toContain("--ephemeral");
expect(args).toContain("--ignore-rules");
expect(args).toContain("--skip-git-repo-check");
expect(args).toContain("--ask-for-approval");
expect(args[args.indexOf("--ask-for-approval") + 1]).toBe("never");
// Default workspace-write sandbox must get network egress so gh/git reach GitHub.
expect(args).toContain("-c");
expect(args).toContain("sandbox_workspace_write.network_access=true");
expect(args[args.indexOf("--sandbox") + 1]).toBe("workspace-write");
// Native auth profile is honored and preflighted.
expect(args[args.indexOf("--auth-profile") + 1]).toBe("account001");
expect(args[args.indexOf("--cd") + 1]).toBe(".");
// Prompt travels on stdin, never argv/ps.
expect(args).toContain("stdin:say ok");
expect(args).not.toContain("say ok");
} finally {
store.close();
}
});

test("runs codewith through durable background-agent adapter", async () => {
const binDir = mkdtempSync(join(tmpdir(), "loops-codewith-"));
const invocationsFile = join(binDir, "invocations");
Expand All @@ -148,6 +215,7 @@ describe("agent adapters", () => {
target: {
type: "agent",
provider: "codewith",
codewithMode: "agent",
prompt: "say ok",
cwd: ".",
configIsolation: "safe",
Expand Down Expand Up @@ -193,6 +261,7 @@ describe("agent adapters", () => {
target: {
type: "agent",
provider: "codewith",
codewithMode: "agent",
authProfile: "account001",
prompt: "say ok",
cwd: ".",
Expand Down Expand Up @@ -229,6 +298,7 @@ describe("agent adapters", () => {
target: {
type: "agent",
provider: "codewith",
codewithMode: "agent",
prompt: "say ok",
sandbox: "danger-full-access",
addDirs: ["/tmp/hasna-todos", "/tmp/hasna-loops"],
Expand Down Expand Up @@ -476,6 +546,7 @@ describe("agent adapters", () => {
target: {
type: "agent",
provider: "codewith",
codewithMode: "agent",
prompt: "say ok",
cwd: ".",
idleTimeoutMs: 150,
Expand Down Expand Up @@ -549,7 +620,8 @@ describe("provider adapter contracts", () => {
sandbox: ["read-only", "workspace-write", "danger-full-access"],
durable: true,
remote: false,
promptChannel: "argv",
// Default exec worker reads the prompt from stdin (durable agent mode is opt-in).
promptChannel: "stdin",
});
expect(providerAdapter("claude").capabilities.promptChannel).toBe("stdin");
expect(providerAdapter("claude").capabilities.durable).toBe(false);
Expand All @@ -568,13 +640,22 @@ describe("provider adapter contracts", () => {
expect(invocation.args).not.toContain("secret-prompt");
});

test("documents the codewith argv prompt fallback", () => {
// codewith agent start has no stdin/prompt-file channel; prompt must be the
// trailing positional argument.
const invocation = providerAdapter("codewith").buildInvocation(baseTarget({ provider: "codewith", prompt: "durable-prompt" }));
expect(invocation.command).toBe("codewith");
expect(invocation.stdin).toBeUndefined();
expect(invocation.args[invocation.args.length - 1]).toBe("durable-prompt");
test("keeps codewith exec prompts on stdin and durable prompts on argv", () => {
// Default exec worker reads the prompt from stdin (kept off argv/ps).
const exec = providerAdapter("codewith").buildInvocation(baseTarget({ provider: "codewith", prompt: "exec-prompt" }));
expect(exec.command).toBe("codewith");
expect(exec.stdin).toBe("exec-prompt");
expect(exec.args).not.toContain("exec-prompt");
expect(exec.args).toContain("exec");
expect(exec.args).not.toContain("start");
// Opt-in durable agent start has no stdin/prompt-file channel; the prompt must
// be the trailing positional argument.
const durable = providerAdapter("codewith").buildInvocation(
baseTarget({ provider: "codewith", prompt: "durable-prompt", codewithMode: "agent" }),
);
expect(durable.stdin).toBeUndefined();
expect(durable.args).toContain("start");
expect(durable.args[durable.args.length - 1]).toBe("durable-prompt");
});

test("throws aligned creation/execution validation errors", () => {
Expand All @@ -588,7 +669,13 @@ describe("provider adapter contracts", () => {
"codex.agent is not supported for provider codex",
);
expect(() => providerAdapter("codewith").validate(baseTarget({ provider: "codewith", extraArgs: ["exec"] }))).toThrow(
"codewith.extraArgs cannot include exec; codewith agent steps use durable agent start, not exec/ephemeral flags",
"codewith.extraArgs cannot include exec; the codewith adapter manages exec/agent and output flags itself",
);
expect(() => providerAdapter("codewith").validate(baseTarget({ provider: "codewith", codewithMode: "durable" as "agent" }))).toThrow(
"codewith.codewithMode must be exec or agent",
);
expect(() => providerAdapter("codex").validate(baseTarget({ provider: "codex", codewithMode: "agent" }))).toThrow(
"codex.codewithMode is currently supported only for provider codewith",
);
expect(() => providerAdapter("opencode").validate(baseTarget({ provider: "opencode" }))).toThrow(
"opencode.model is required for provider opencode",
Expand Down
Loading
Loading