Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
- batch: graphile-unit
packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin graphile/graphile-storage-registry'
- batch: agentic
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/db-tools agentic/pi agentic/dsh agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/metering agentic/agent-conversation agentic/pi-host agentic/run-log-client agentic/run-log-gate agentic/machine-protocol agentic/machine-runner'
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/db-tools agentic/pi agentic/dsh agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/metering agentic/agent-conversation agentic/pi-host agentic/run-log-client agentic/run-log-gate agentic/machine-protocol agentic/machine-runner agentic/agent-cli'
- batch: pgpm-unit
packages: 'pgpm/types pgpm/naming-spec pgpm/diff pgpm/import pgpm/slice pgpm/transform'
- batch: pglite
Expand Down
21 changes: 21 additions & 0 deletions agentic/agent-cli/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Interweb, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
39 changes: 39 additions & 0 deletions agentic/agent-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# @constructive-db/agent-cli

`constructive-agent-cli`: a coding-agent CLI (`claude`, `codex`) adapted to the
machine protocol's **agent stdio contract**, so that a machine session bound to
an agent run can run one as an ordinary command.

```
constructive-agent-cli <claude|codex> [--resume <session-id>]
[--approval-timeout-ms <n>] [--on-timeout deny|allow] [-- <cli args...>]
```

- **stdin** — the prompt, one line; then further prompts (turns, where the CLI
supports them) or `{"kind":"approval_decision",...}` JSON lines answering a
tool approval the CLI asked for.
- **stdout** — `AgentEvent` JSON lines, nothing else.
- **stderr** — everything the CLI says that is not its protocol.
- **exit** — the CLI's own status.

`--approval-timeout-ms` and `--on-timeout` default from
`CONSTRUCTIVE_AGENT_CLI_APPROVAL_TIMEOUT_MS` / `CONSTRUCTIVE_AGENT_CLI_ON_TIMEOUT`,
so a machine owner sets them once, in the runner policy's `env.set`.

## Where it sits

The **machine runner** (`@constructive-db/machine-runner`) is a remote control:
it runs the commands its policy allows, on pipes or in a pty, and streams bytes.
It knows nothing about agents. This program is what gives a session its agent
shape — the relay asks the runner to run `constructive-agent-cli claude` for a
`cli`-bound session, reads the events off its stdout, puts an approval on the
run's log, and writes the decision back as a stdin line. The runner reads
neither direction.

For this program to run on a machine, its policy must allow the command
(`"allowedCommands": ["constructive-agent-cli", ...]`) and the CLI itself must
be on the `PATH` that policy projects. The runner never runs `claude` or
`codex` directly.

The adapters (`ClaudeCodeAdapter`, `CodexExecAdapter`) and the session loop
(`runAgentCliSession`) are exported for use in-process.
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,16 @@ describe('CodexExecAdapter', () => {
'--json',
'resume',
'01a0a38b-442f-7f91-a250-72f305c839f7',
'--',
'hello'
]
});
});

it('hands the prompt over as a positional, never as an option', () => {
expect(codex.spawnArgs('--dangerously-bypass-approvals-and-sandbox rm -rf /')).toEqual({
command: 'codex',
args: ['exec', '--json', '--', '--dangerously-bypass-approvals-and-sandbox rm -rf /']
});
});
});
20 changes: 20 additions & 0 deletions agentic/agent-cli/__tests__/fixtures/bin/codex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env node

const args = process.argv.slice(2);
const resumeIndex = args.indexOf('resume');
const sessionId = resumeIndex >= 0 ? args[resumeIndex + 1] : '01a0a38b-442f-7f91-a250-72f305c839f7';
// Like the real CLI: the prompt is the positional after `--`.
const separator = args.indexOf('--');
if (separator < 0 || separator === args.length - 1) {
process.stderr.write('codex fixture: prompt must follow --\n');
process.exit(2);
}
const prompt = args[separator + 1];
const emit = value => process.stdout.write(`${JSON.stringify(value)}\n`);

emit({ type: 'thread.started', thread_id: sessionId });
emit({
type: 'item.completed',
item: { id: 'msg_fake', type: 'agent_message', text: `echo:${prompt}` }
});
emit({ type: 'turn.completed', usage: {} });
62 changes: 62 additions & 0 deletions agentic/agent-cli/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import path from 'path';
import { PassThrough } from 'stream';

import { ClaudeCodeAdapter, runAgentCliSession } from '../src';

const fixtures = path.join(__dirname, 'fixtures', 'bin');
const env = { ...process.env, PATH: `${fixtures}:${process.env.PATH}` };

const collect = (stream: PassThrough): string[] => {
const chunks: string[] = [];
stream.on('data', chunk => chunks.push(String(chunk)));
return chunks;
};

describe('runAgentCliSession', () => {
it('runs the prompt through the CLI and reports its exit', async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const stderr = new PassThrough();
const out = collect(stdout);
collect(stderr);
const abort = new AbortController();
const done = runAgentCliSession({
adapter: new ClaudeCodeAdapter(),
io: { stdin, stdout, stderr },
env,
abort: abort.signal
});
stdin.write('hello\n');
await new Promise<void>(resolve => {
stdout.on('data', () => {
if (out.join('').includes('"kind":"result"')) resolve();
});
});
abort.abort();
expect((await done).signal).toBe('SIGTERM');
const kinds = out.join('').trim().split('\n').map(line => JSON.parse(line).kind);
expect(kinds).toContain('session');
expect(kinds).toContain('text');
});

it('starts nothing once the session has concluded', async () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const stderr = new PassThrough();
const out = collect(stdout);
collect(stderr);
const abort = new AbortController();
const done = runAgentCliSession({
adapter: new ClaudeCodeAdapter(),
io: { stdin, stdout, stderr },
env,
abort: abort.signal
});
abort.abort();
expect(await done).toEqual({ exitCode: -1, signal: 'SIGTERM' });
stdin.write('hello\n');
stdin.end();
await new Promise(resolve => setTimeout(resolve, 300));
expect(out).toEqual([]);
});
});
9 changes: 9 additions & 0 deletions agentic/agent-cli/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
// A built package carries a copy of its own manifest, which jest's module map
// reads as a second package of the same name.
modulePathIgnorePatterns: ['<rootDir>/dist/']
};
43 changes: 43 additions & 0 deletions agentic/agent-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@constructive-db/agent-cli",
"version": "0.1.0",
"description": "constructive-agent-cli: a coding-agent CLI (claude, codex) adapted to the machine protocol's agent stdio contract — prompts and approval decisions in, AgentEvent JSON lines out. Runs on the user's own machine as an ordinary command under the machine runner, which knows nothing about it.",
"author": "Constructive <developers@constructive.io>",
"license": "MIT",
"homepage": "https://github.com/constructive-io/constructive",
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive",
"directory": "agentic/agent-cli"
},
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"keywords": [
"agent",
"claude",
"codex",
"remote-control"
],
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"bin": {
"constructive-agent-cli": "run.js"
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"scripts": {
"build": "makage build && cp LICENSE dist/LICENSE",
"build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
"clean": "makage clean",
"lint": "eslint . --fix",
"start": "node dist/run.js",
"test": "jest"
},
"dependencies": {
"@constructive-db/machine-protocol": "workspace:*"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,16 @@ export class CodexExecAdapter implements AgentCliAdapter {
spawnArgs(prompt: string, resume?: string): AgentCliSpawn {
return {
command: 'codex',
args: ['exec', '--json', ...this.extraArgs, ...(resume ? ['resume', resume] : []), prompt]
// `--` ends option parsing, so a prompt is a prompt even when it starts
// with a dash.
args: [
'exec',
'--json',
...this.extraArgs,
...(resume ? ['resume', resume] : []),
'--',
prompt
]
};
}

Expand Down Expand Up @@ -248,6 +257,6 @@ export function adapterForCommand(command: string, extraArgs: string[]): AgentCl
case 'codex':
return new CodexExecAdapter(extraArgs);
default:
throw new Error(`machine-runner: no CLI adapter for command '${command}'`);
throw new Error(`agent-cli: no CLI adapter for command '${command}'`);
}
}
81 changes: 81 additions & 0 deletions agentic/agent-cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// The command line: `constructive-agent-cli <claude|codex> [--resume <id>]
// [--approval-timeout-ms <n>] [--on-timeout deny|allow] [-- <cli args...>]`.
// Which CLI is the one thing the caller must say; everything after `--` is
// handed to that CLI untouched. The approval flags default from the
// environment (`CONSTRUCTIVE_AGENT_CLI_APPROVAL_TIMEOUT_MS`,
// `CONSTRUCTIVE_AGENT_CLI_ON_TIMEOUT`): how long a machine waits on a question
// is the machine owner's setting, projected into the command by their policy.

import type { ApprovalDecision } from '@constructive-db/machine-protocol';

export interface AgentCliArgs {
cli: 'claude' | 'codex';
resume?: string;
approvalTimeoutMs?: number;
onTimeout?: ApprovalDecision;
extraArgs: string[];
}

export const USAGE =
'usage: constructive-agent-cli <claude|codex> [--resume <session-id>] ' +
'[--approval-timeout-ms <n>] [--on-timeout deny|allow] [-- <cli args...>]';

export class UsageError extends Error {
constructor(message: string) {
super(`${message}\n${USAGE}`);
this.name = 'UsageError';
}
}

export const APPROVAL_TIMEOUT_ENV = 'CONSTRUCTIVE_AGENT_CLI_APPROVAL_TIMEOUT_MS';
export const ON_TIMEOUT_ENV = 'CONSTRUCTIVE_AGENT_CLI_ON_TIMEOUT';

export function parseArgs(
argv: readonly string[],
env: Readonly<Record<string, string | undefined>> = {}
): AgentCliArgs {
const cli = argv[0];
if (cli !== 'claude' && cli !== 'codex') {
throw new UsageError(cli === undefined ? 'a CLI name is required' : `unknown CLI '${cli}'`);
}
const values: Partial<Record<'resume' | 'approval-timeout-ms' | 'on-timeout', string>> = {};
let extraArgs: string[] = [];
for (let i = 1; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--') {
extraArgs = argv.slice(i + 1);
break;
}
const eq = arg.indexOf('=');
const flag = arg.startsWith('--') ? arg.slice(2, eq === -1 ? undefined : eq) : undefined;
if (flag !== 'resume' && flag !== 'approval-timeout-ms' && flag !== 'on-timeout') {
throw new UsageError(`unexpected argument '${arg}'`);
}
const value = eq === -1 ? argv[++i] : arg.slice(eq + 1);
if (value === undefined || value.length === 0 || (eq === -1 && value.startsWith('--'))) {
throw new UsageError(`--${flag} needs a value`);
}
if (values[flag] !== undefined) throw new UsageError(`--${flag} given twice`);
values[flag] = value;
}
const args: AgentCliArgs = { cli, extraArgs };
if (values.resume !== undefined) args.resume = values.resume;
const timeout = values['approval-timeout-ms'] ?? env[APPROVAL_TIMEOUT_ENV];
if (timeout !== undefined) {
const ms = Number(timeout);
if (!Number.isFinite(ms) || ms <= 0) {
throw new UsageError(
`--approval-timeout-ms (or ${APPROVAL_TIMEOUT_ENV}) must be a positive number of milliseconds`
);
}
args.approvalTimeoutMs = ms;
}
const onTimeout = values['on-timeout'] ?? env[ON_TIMEOUT_ENV];
if (onTimeout !== undefined) {
if (onTimeout !== 'deny' && onTimeout !== 'allow') {
throw new UsageError(`--on-timeout (or ${ON_TIMEOUT_ENV}) must be 'deny' or 'allow'`);
}
args.onTimeout = onTimeout;
}
return args;
}
12 changes: 12 additions & 0 deletions agentic/agent-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type { AgentCliAdapter, AgentCliSpawn } from './adapters';
export { adapterForCommand, ClaudeCodeAdapter, CodexExecAdapter } from './adapters';
export type { AgentCliArgs } from './cli';
export { APPROVAL_TIMEOUT_ENV, ON_TIMEOUT_ENV, parseArgs, USAGE, UsageError } from './cli';
export type { AgentCliExit, AgentCliIo, AgentCliSessionOptions } from './session';
export {
APPROVAL_AUTO_DENY_REASON,
APPROVAL_EXIT_REASON,
APPROVAL_TIMEOUT_REASON,
DEFAULT_APPROVAL_TIMEOUT_MS,
runAgentCliSession
} from './session';
42 changes: 42 additions & 0 deletions agentic/agent-cli/src/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// `constructive-agent-cli`: a coding-agent CLI on the agent stdio contract.
// SIGTERM/SIGINT stop the CLI; its own exit status is this program's.

import { adapterForCommand } from './adapters';
import { parseArgs, UsageError } from './cli';
import { runAgentCliSession } from './session';

async function main(): Promise<number> {
const args = parseArgs(process.argv.slice(2), process.env);
const stop = new AbortController();
process.once('SIGTERM', () => stop.abort());
process.once('SIGINT', () => stop.abort());
const exit = await runAgentCliSession({
adapter: adapterForCommand(args.cli, args.extraArgs),
resume: args.resume,
approvalTimeoutMs: args.approvalTimeoutMs,
onTimeout: args.onTimeout,
io: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr },
abort: stop.signal
});
if (exit.signal) {
process.kill(process.pid, exit.signal);
return 128;
}
return exit.exitCode;
}

// The CLI's exit ends this program even while the caller still holds its
// stdin open (a one-shot `codex exec` under a session that has not been
// closed); stdout is flushed before the exit takes effect.
const exit = (code: number): void => {
process.stdout.write('', () => process.exit(code));
};

main().then(exit, (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
const prefixed = err instanceof UsageError || message.startsWith('agent-cli: ');
process.stderr.write(`${prefixed ? '' : 'agent-cli: '}${message}\n`, () => {
exit(err instanceof UsageError ? 2 : 1);
});
});
Loading
Loading