@osdlc/agent-call 0.0.1-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +181 -0
- package/CONTRIBUTING.md +65 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +298 -1
- package/SECURITY.md +23 -0
- package/dist/adapters/antigravity.d.ts +15 -0
- package/dist/adapters/antigravity.js +262 -0
- package/dist/adapters/claude.d.ts +29 -0
- package/dist/adapters/claude.js +314 -0
- package/dist/adapters/codex.d.ts +18 -0
- package/dist/adapters/codex.js +374 -0
- package/dist/adapters/custom.d.ts +31 -0
- package/dist/adapters/custom.js +42 -0
- package/dist/adapters/opencode.d.ts +12 -0
- package/dist/adapters/opencode.js +207 -0
- package/dist/adapters/shared.d.ts +40 -0
- package/dist/adapters/shared.js +101 -0
- package/dist/adapters/template.d.ts +43 -0
- package/dist/adapters/template.js +62 -0
- package/dist/adapters/types.d.ts +55 -0
- package/dist/adapters/types.js +1 -0
- package/dist/conversation.d.ts +73 -0
- package/dist/conversation.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +12 -0
- package/dist/model-args.d.ts +37 -0
- package/dist/model-args.js +58 -0
- package/dist/models.d.ts +9 -0
- package/dist/models.js +12 -0
- package/dist/plan.d.ts +56 -0
- package/dist/plan.js +9 -0
- package/dist/registry.d.ts +21 -0
- package/dist/registry.js +41 -0
- package/dist/run.d.ts +75 -0
- package/dist/run.js +172 -0
- package/dist/spawn.d.ts +10 -0
- package/dist/spawn.js +143 -0
- package/dist/spec.d.ts +132 -0
- package/dist/spec.js +11 -0
- package/package.json +67 -4
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AgentAdapter } from './adapters/types.js';
|
|
2
|
+
import type { AgentName } from './spec.js';
|
|
3
|
+
/** Instantiate a built-in adapter by name. */
|
|
4
|
+
export declare function adapterFor(name: AgentName, options?: {
|
|
5
|
+
binary?: string;
|
|
6
|
+
}): AgentAdapter;
|
|
7
|
+
/** The names `adapterFor` accepts, in a fixed order. Frozen at run time. */
|
|
8
|
+
export declare const BUILTIN_AGENTS: readonly AgentName[];
|
|
9
|
+
/** Whether one agent CLI is installed, as `detectAgents` reports it. */
|
|
10
|
+
export interface AgentAvailability {
|
|
11
|
+
/** The adapter's name. */
|
|
12
|
+
name: string;
|
|
13
|
+
/** `<binary> --version` ran, exited 0 and finished within 10 seconds. */
|
|
14
|
+
available: boolean;
|
|
15
|
+
/** First line of the `--version` output, when available. */
|
|
16
|
+
version?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Probe which agent CLIs are installed (via `<binary> --version`). */
|
|
19
|
+
export declare function detectAgents(adapters?: AgentAdapter[]): Promise<AgentAvailability[]>;
|
|
20
|
+
/** @internal `timeoutMs` is a parameter so a test can shorten it. */
|
|
21
|
+
export declare function probeVersion(binary: string, timeoutMs?: number): Promise<string | undefined>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { claudeAdapter } from './adapters/claude.js';
|
|
2
|
+
import { codexAdapter } from './adapters/codex.js';
|
|
3
|
+
import { antigravityAdapter } from './adapters/antigravity.js';
|
|
4
|
+
import { opencodeAdapter } from './adapters/opencode.js';
|
|
5
|
+
import { spawnCollect } from './spawn.js';
|
|
6
|
+
/** Instantiate a built-in adapter by name. */
|
|
7
|
+
export function adapterFor(name, options = {}) {
|
|
8
|
+
switch (name) {
|
|
9
|
+
case 'claude':
|
|
10
|
+
return claudeAdapter(options);
|
|
11
|
+
case 'codex':
|
|
12
|
+
return codexAdapter(options);
|
|
13
|
+
case 'antigravity':
|
|
14
|
+
return antigravityAdapter(options);
|
|
15
|
+
case 'opencode':
|
|
16
|
+
return opencodeAdapter(options);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The names `adapterFor` accepts, in a fixed order. Frozen at run time. */
|
|
20
|
+
export const BUILTIN_AGENTS = Object.freeze([
|
|
21
|
+
'claude',
|
|
22
|
+
'codex',
|
|
23
|
+
'antigravity',
|
|
24
|
+
'opencode',
|
|
25
|
+
]);
|
|
26
|
+
/** Probe which agent CLIs are installed (via `<binary> --version`). */
|
|
27
|
+
export async function detectAgents(adapters = BUILTIN_AGENTS.map((name) => adapterFor(name))) {
|
|
28
|
+
return Promise.all(adapters.map(async (adapter) => {
|
|
29
|
+
const version = await probeVersion(adapter.binary);
|
|
30
|
+
return {
|
|
31
|
+
name: adapter.name,
|
|
32
|
+
available: version !== undefined,
|
|
33
|
+
version,
|
|
34
|
+
};
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
/** @internal `timeoutMs` is a parameter so a test can shorten it. */
|
|
38
|
+
export async function probeVersion(binary, timeoutMs = 10_000) {
|
|
39
|
+
const raw = await spawnCollect([binary, '--version'], { env: process.env, timeoutMs });
|
|
40
|
+
return raw.exitCode === 0 && !raw.timedOut ? raw.stdout.trim().split('\n')[0] : undefined;
|
|
41
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AgentAdapter, ParsedOutput, RawProcessOutput } from './adapters/types.js';
|
|
2
|
+
import type { Conversation } from './conversation.js';
|
|
3
|
+
import { type PlannedCall } from './plan.js';
|
|
4
|
+
import type { AgentCallSpec } from './spec.js';
|
|
5
|
+
/**
|
|
6
|
+
* Why a run failed, on `AgentResult.error`. `callAgent` returns it instead of
|
|
7
|
+
* throwing.
|
|
8
|
+
*
|
|
9
|
+
* `reason` is open: a later minor release may add reasons. Handle the ones you
|
|
10
|
+
* know and keep a default branch for the rest.
|
|
11
|
+
*/
|
|
12
|
+
export interface CallError {
|
|
13
|
+
/**
|
|
14
|
+
* - `spawn-failed` — the process never started (binary missing, empty argv).
|
|
15
|
+
* - `timeout` — `timeoutMs` elapsed or the caller aborted.
|
|
16
|
+
* - `nonzero-exit` — the process exited with a nonzero code, or ended on a
|
|
17
|
+
* signal the runner did not send (such as an out-of-memory kill); `detail`
|
|
18
|
+
* names the signal.
|
|
19
|
+
* - `agent-error` — the process exited 0 but the agent reported failure, or
|
|
20
|
+
* the adapter's parser threw on the output.
|
|
21
|
+
*/
|
|
22
|
+
reason: 'spawn-failed' | 'timeout' | 'nonzero-exit' | 'agent-error';
|
|
23
|
+
/** A human-readable explanation, taken from the agent's output where there is one. */
|
|
24
|
+
detail: string;
|
|
25
|
+
}
|
|
26
|
+
/** Everything a run produced: normalized fields plus the untouched raw output. */
|
|
27
|
+
export interface AgentResult extends ParsedOutput {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
exitCode: number | null;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
error?: CallError | undefined;
|
|
32
|
+
/** The exact invocation that ran — argv, env additions, notes, spec. */
|
|
33
|
+
planned: PlannedCall;
|
|
34
|
+
/** Raw process output, always preserved. */
|
|
35
|
+
raw: RawProcessOutput;
|
|
36
|
+
/** Normalized transcript, when `captureConversation` was requested. */
|
|
37
|
+
conversation?: Conversation | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Per-run options for `callAgent`, separate from the spec. */
|
|
40
|
+
export interface CallOptions {
|
|
41
|
+
/** Also lift the transcript into the normalized conversation model. */
|
|
42
|
+
captureConversation?: boolean | undefined;
|
|
43
|
+
/** Abort the run early: kills the child, or skips the spawn if already aborted. */
|
|
44
|
+
signal?: AbortSignal | undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Plan a call without running it: the exact argv, stdin, env, temp files,
|
|
48
|
+
* and capability notes the adapter would use. Pure and side-effect free.
|
|
49
|
+
*
|
|
50
|
+
* @throws Whatever the adapter's `plan` throws: see `templateAdapter`'s
|
|
51
|
+
* `@throws`; and `customAdapter` lets its `args` function throw.
|
|
52
|
+
*/
|
|
53
|
+
export declare function planCall(adapter: AgentAdapter, spec: AgentCallSpec): PlannedCall;
|
|
54
|
+
/**
|
|
55
|
+
* Translate the spec through the adapter, run the CLI, normalize the result.
|
|
56
|
+
* Never throws on agent failure: a failed run returns `ok: false` with an
|
|
57
|
+
* `error`.
|
|
58
|
+
*
|
|
59
|
+
* Runs the child in its own process group and kills the group on timeout or
|
|
60
|
+
* abort, so it needs POSIX process groups (Linux, macOS). The child inherits
|
|
61
|
+
* the parent's environment plus `spec.env`.
|
|
62
|
+
*
|
|
63
|
+
* Because the agent has its own process group, a terminal Ctrl+C or a kill of
|
|
64
|
+
* the host does not reach it, and it keeps running. A host that can be
|
|
65
|
+
* interrupted must pass a `signal` and abort it from its own SIGINT/SIGTERM
|
|
66
|
+
* handler. The runner installs no exit handlers.
|
|
67
|
+
*
|
|
68
|
+
* @throws The returned promise rejects, rather than resolving `ok: false`,
|
|
69
|
+
* when planning throws (see {@link planCall}), when a `TempFile.name` is not a
|
|
70
|
+
* plain file name (empty, or holding `/`, `\` or `..`), or when the per-call
|
|
71
|
+
* temp directory cannot be created or written. A directory that was made is
|
|
72
|
+
* removed. A parser that throws on the output does not reject: the result is
|
|
73
|
+
* `ok: false` with reason `agent-error`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function callAgent(adapter: AgentAdapter, spec: AgentCallSpec, options?: CallOptions): Promise<AgentResult>;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { TMP_TOKEN } from './plan.js';
|
|
5
|
+
import { spawnCollect } from './spawn.js';
|
|
6
|
+
/**
|
|
7
|
+
* Plan a call without running it: the exact argv, stdin, env, temp files,
|
|
8
|
+
* and capability notes the adapter would use. Pure and side-effect free.
|
|
9
|
+
*
|
|
10
|
+
* @throws Whatever the adapter's `plan` throws: see `templateAdapter`'s
|
|
11
|
+
* `@throws`; and `customAdapter` lets its `args` function throw.
|
|
12
|
+
*/
|
|
13
|
+
export function planCall(adapter, spec) {
|
|
14
|
+
return adapter.plan(spec);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Translate the spec through the adapter, run the CLI, normalize the result.
|
|
18
|
+
* Never throws on agent failure: a failed run returns `ok: false` with an
|
|
19
|
+
* `error`.
|
|
20
|
+
*
|
|
21
|
+
* Runs the child in its own process group and kills the group on timeout or
|
|
22
|
+
* abort, so it needs POSIX process groups (Linux, macOS). The child inherits
|
|
23
|
+
* the parent's environment plus `spec.env`.
|
|
24
|
+
*
|
|
25
|
+
* Because the agent has its own process group, a terminal Ctrl+C or a kill of
|
|
26
|
+
* the host does not reach it, and it keeps running. A host that can be
|
|
27
|
+
* interrupted must pass a `signal` and abort it from its own SIGINT/SIGTERM
|
|
28
|
+
* handler. The runner installs no exit handlers.
|
|
29
|
+
*
|
|
30
|
+
* @throws The returned promise rejects, rather than resolving `ok: false`,
|
|
31
|
+
* when planning throws (see {@link planCall}), when a `TempFile.name` is not a
|
|
32
|
+
* plain file name (empty, or holding `/`, `\` or `..`), or when the per-call
|
|
33
|
+
* temp directory cannot be created or written. A directory that was made is
|
|
34
|
+
* removed. A parser that throws on the output does not reject: the result is
|
|
35
|
+
* `ok: false` with reason `agent-error`.
|
|
36
|
+
*/
|
|
37
|
+
export async function callAgent(adapter, spec, options = {}) {
|
|
38
|
+
const planned = adapter.plan(spec);
|
|
39
|
+
const raw = await execute(planned, spec, options.signal);
|
|
40
|
+
let error;
|
|
41
|
+
if (raw.exitCode === null && !raw.timedOut) {
|
|
42
|
+
error =
|
|
43
|
+
raw.signal === undefined
|
|
44
|
+
? { reason: 'spawn-failed', detail: raw.stderr.trim() }
|
|
45
|
+
: {
|
|
46
|
+
reason: 'nonzero-exit',
|
|
47
|
+
detail: `${planned.argv[0]} was killed by signal ${raw.signal}: ${raw.stderr.trim().slice(0, 500)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
else if (raw.timedOut) {
|
|
51
|
+
error = {
|
|
52
|
+
reason: 'timeout',
|
|
53
|
+
detail: raw.cutShortBy === 'abort'
|
|
54
|
+
? 'aborted by the caller'
|
|
55
|
+
: `killed after ${spec.timeoutMs ?? 0}ms`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
let parsed = { text: raw.stdout.trim() };
|
|
59
|
+
if (error === undefined) {
|
|
60
|
+
try {
|
|
61
|
+
parsed = adapter.parseResult(planned, raw);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
error = {
|
|
65
|
+
reason: 'agent-error',
|
|
66
|
+
detail: `the adapter's parser threw: ${errorMessage(err)}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (error === undefined && raw.exitCode !== 0) {
|
|
71
|
+
const stderrTail = raw.stderr.trim().slice(0, 500);
|
|
72
|
+
error = {
|
|
73
|
+
reason: 'nonzero-exit',
|
|
74
|
+
detail: [
|
|
75
|
+
parsed.errorDetail,
|
|
76
|
+
stderrTail === ''
|
|
77
|
+
? `${planned.argv[0]} exited ${raw.exitCode}`
|
|
78
|
+
: `${planned.argv[0]} exited ${raw.exitCode}: ${stderrTail}`,
|
|
79
|
+
]
|
|
80
|
+
.filter((part) => part !== undefined && part !== '')
|
|
81
|
+
.join('; '),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
else if (error === undefined && parsed.isError === true) {
|
|
85
|
+
error = {
|
|
86
|
+
reason: 'agent-error',
|
|
87
|
+
detail: parsed.errorDetail ?? 'the agent reported an error',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const result = {
|
|
91
|
+
...parsed,
|
|
92
|
+
ok: error === undefined,
|
|
93
|
+
exitCode: raw.exitCode,
|
|
94
|
+
durationMs: raw.durationMs,
|
|
95
|
+
error,
|
|
96
|
+
planned,
|
|
97
|
+
raw,
|
|
98
|
+
};
|
|
99
|
+
if (options.captureConversation === true && adapter.toConversation) {
|
|
100
|
+
try {
|
|
101
|
+
result.conversation = adapter.toConversation(planned, raw);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
result.ok = false;
|
|
105
|
+
result.error ??= {
|
|
106
|
+
reason: 'agent-error',
|
|
107
|
+
detail: `the adapter's parser threw: ${errorMessage(err)}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
function isPlainFileName(name) {
|
|
114
|
+
return name !== '' && !name.includes('/') && !name.includes('\\') && !name.includes('..');
|
|
115
|
+
}
|
|
116
|
+
function errorMessage(err) {
|
|
117
|
+
return err instanceof Error ? err.message : String(err);
|
|
118
|
+
}
|
|
119
|
+
async function execute(planned, spec, signal) {
|
|
120
|
+
for (const file of planned.tempFiles) {
|
|
121
|
+
if (!isPlainFileName(file.name)) {
|
|
122
|
+
throw new Error(`temp file name ${JSON.stringify(file.name)} is not a plain file name`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let tempDir;
|
|
126
|
+
if (planned.tempFiles.length > 0) {
|
|
127
|
+
tempDir = await mkdtemp(join(tmpdir(), 'agent-call-'));
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
if (tempDir !== undefined) {
|
|
131
|
+
for (const file of planned.tempFiles) {
|
|
132
|
+
await writeFile(join(tempDir, file.name), file.content, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const resolveTmp = (value) => tempDir === undefined ? value : value.replaceAll(TMP_TOKEN, tempDir);
|
|
136
|
+
const argv = planned.argv.map(resolveTmp);
|
|
137
|
+
const env = { ...process.env };
|
|
138
|
+
for (const [key, value] of Object.entries(planned.env)) {
|
|
139
|
+
env[key] = resolveTmp(value);
|
|
140
|
+
}
|
|
141
|
+
const startedAt = Date.now();
|
|
142
|
+
const raw = await spawnCollect(argv, {
|
|
143
|
+
stdin: planned.stdin,
|
|
144
|
+
cwd: planned.cwd,
|
|
145
|
+
env,
|
|
146
|
+
timeoutMs: spec.timeoutMs,
|
|
147
|
+
signal,
|
|
148
|
+
});
|
|
149
|
+
raw.durationMs = Date.now() - startedAt;
|
|
150
|
+
if (tempDir !== undefined) {
|
|
151
|
+
const files = {};
|
|
152
|
+
for (const file of planned.tempFiles) {
|
|
153
|
+
if (file.readBack !== true)
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
files[file.name] = await readFile(join(tempDir, file.name), 'utf8');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// The CLI may not have written it (e.g. errored before finishing).
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (Object.keys(files).length > 0)
|
|
163
|
+
raw.files = files;
|
|
164
|
+
}
|
|
165
|
+
return raw;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (tempDir !== undefined) {
|
|
169
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/dist/spawn.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RawProcessOutput } from './adapters/types.js';
|
|
2
|
+
export interface SpawnOptions {
|
|
3
|
+
stdin?: string | undefined;
|
|
4
|
+
cwd?: string | undefined;
|
|
5
|
+
env: NodeJS.ProcessEnv;
|
|
6
|
+
timeoutMs?: number | undefined;
|
|
7
|
+
signal?: AbortSignal | undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Spawn, collect output, honor timeout and abort. @internal */
|
|
10
|
+
export declare function spawnCollect(argv: string[], options: SpawnOptions): Promise<RawProcessOutput>;
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Grace period between SIGTERM and SIGKILL on timeout/abort. */
|
|
3
|
+
const KILL_GRACE_MS = 5_000;
|
|
4
|
+
/** Spawn, collect output, honor timeout and abort. @internal */
|
|
5
|
+
export function spawnCollect(argv, options) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const [binary, ...args] = argv;
|
|
8
|
+
if (binary === undefined) {
|
|
9
|
+
resolve({
|
|
10
|
+
stdout: '',
|
|
11
|
+
stderr: 'empty argv',
|
|
12
|
+
exitCode: null,
|
|
13
|
+
durationMs: 0,
|
|
14
|
+
timedOut: false,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
// An already-aborted signal never fires `abort` again, so it has to be
|
|
19
|
+
// honored before spawn — otherwise the agent runs the full turn. The
|
|
20
|
+
// result mirrors a mid-run abort: cut short, with no exit code.
|
|
21
|
+
if (options.signal?.aborted === true) {
|
|
22
|
+
resolve({
|
|
23
|
+
stdout: '',
|
|
24
|
+
stderr: '',
|
|
25
|
+
exitCode: null,
|
|
26
|
+
durationMs: 0,
|
|
27
|
+
timedOut: true,
|
|
28
|
+
cutShortBy: 'abort',
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Raw `node:child_process`, not the command seam: the package stays
|
|
33
|
+
// dependency-free and this manages a long-lived process group (D-07).
|
|
34
|
+
const child = spawn(binary, args, {
|
|
35
|
+
cwd: options.cwd,
|
|
36
|
+
env: options.env,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
// Own process group, so a timeout/abort can kill the whole tree. Without
|
|
39
|
+
// it, a shell child (e.g. `sh -c "sleep 30"`) leaves its grandchild
|
|
40
|
+
// orphaned holding the stdio pipe open, and `close` never fires — the
|
|
41
|
+
// call hangs until the real command ends instead of honoring timeoutMs.
|
|
42
|
+
detached: true,
|
|
43
|
+
});
|
|
44
|
+
let stdout = '';
|
|
45
|
+
let stderr = '';
|
|
46
|
+
let timedOut = false;
|
|
47
|
+
let cutShortBy;
|
|
48
|
+
let stdinError;
|
|
49
|
+
let settled = false;
|
|
50
|
+
let killTimer;
|
|
51
|
+
let graceTimer;
|
|
52
|
+
const killTree = (signal) => {
|
|
53
|
+
const pid = child.pid;
|
|
54
|
+
try {
|
|
55
|
+
// Negative pid targets the whole process group (child is a group
|
|
56
|
+
// leader via `detached`), so grandchildren die too and release the
|
|
57
|
+
// stdio pipe. Fall back to a direct kill if the group is already gone
|
|
58
|
+
// or the platform lacks process groups.
|
|
59
|
+
if (pid !== undefined)
|
|
60
|
+
process.kill(-pid, signal);
|
|
61
|
+
else
|
|
62
|
+
child.kill(signal);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
try {
|
|
66
|
+
child.kill(signal);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* already exited */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const terminate = () => {
|
|
74
|
+
if (graceTimer !== undefined)
|
|
75
|
+
return;
|
|
76
|
+
killTree('SIGTERM');
|
|
77
|
+
graceTimer = setTimeout(() => killTree('SIGKILL'), KILL_GRACE_MS);
|
|
78
|
+
};
|
|
79
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
80
|
+
killTimer = setTimeout(() => {
|
|
81
|
+
timedOut = true;
|
|
82
|
+
cutShortBy ??= 'timeout';
|
|
83
|
+
terminate();
|
|
84
|
+
}, options.timeoutMs);
|
|
85
|
+
}
|
|
86
|
+
const onAbort = () => {
|
|
87
|
+
timedOut = true;
|
|
88
|
+
cutShortBy ??= 'abort';
|
|
89
|
+
terminate();
|
|
90
|
+
};
|
|
91
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
92
|
+
const settle = (exitCode, spawnError, signal) => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
if (killTimer !== undefined)
|
|
97
|
+
clearTimeout(killTimer);
|
|
98
|
+
if (graceTimer !== undefined)
|
|
99
|
+
clearTimeout(graceTimer);
|
|
100
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
101
|
+
if (spawnError !== undefined && stderr === '') {
|
|
102
|
+
stderr = spawnError.message;
|
|
103
|
+
}
|
|
104
|
+
resolve({
|
|
105
|
+
stdout,
|
|
106
|
+
stderr,
|
|
107
|
+
exitCode,
|
|
108
|
+
durationMs: 0,
|
|
109
|
+
timedOut,
|
|
110
|
+
cutShortBy,
|
|
111
|
+
stdinError,
|
|
112
|
+
signal: signal ?? undefined,
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
// Decode through the stream, not per chunk: a UTF-8 sequence straddling
|
|
116
|
+
// two reads must be held over the boundary instead of becoming two U+FFFD.
|
|
117
|
+
child.stdout.setEncoding('utf8');
|
|
118
|
+
child.stderr.setEncoding('utf8');
|
|
119
|
+
child.stdout.on('data', (chunk) => {
|
|
120
|
+
stdout += chunk;
|
|
121
|
+
});
|
|
122
|
+
child.stderr.on('data', (chunk) => {
|
|
123
|
+
stderr += chunk;
|
|
124
|
+
});
|
|
125
|
+
child.on('error', (err) => settle(null, err));
|
|
126
|
+
child.on('close', (code, signal) => settle(code, undefined, signal));
|
|
127
|
+
// The child may exit before draining stdin (bad flag, auth failure, or a
|
|
128
|
+
// kill from the timeout/abort path), which fails the write with EPIPE.
|
|
129
|
+
// Without a listener that stream error is an uncaught exception that takes
|
|
130
|
+
// the host process down. It is recorded rather than dropped: the failure
|
|
131
|
+
// means the agent saw a truncated prompt, and nothing else reveals that
|
|
132
|
+
// when the child still exits 0. Recorded at `settle` so the diagnostic
|
|
133
|
+
// rides on the resolved value; node closes stdin before emitting `close`,
|
|
134
|
+
// so the error always lands first.
|
|
135
|
+
child.stdin.on('error', (err) => {
|
|
136
|
+
stdinError ??= err.message;
|
|
137
|
+
});
|
|
138
|
+
if (options.stdin !== undefined) {
|
|
139
|
+
child.stdin.write(options.stdin);
|
|
140
|
+
}
|
|
141
|
+
child.stdin.end();
|
|
142
|
+
});
|
|
143
|
+
}
|
package/dist/spec.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical, agent-agnostic description of one headless agent call.
|
|
3
|
+
*
|
|
4
|
+
* A spec says WHAT you want — prompt, permission posture, context, output
|
|
5
|
+
* shape, session behavior — never HOW a particular CLI expresses it. Each
|
|
6
|
+
* built-in adapter translates the spec into a concrete invocation on a
|
|
7
|
+
* best-effort basis and records every approximation as a `CapabilityNote` (see
|
|
8
|
+
* plan.ts). Custom and template adapters note the fields they fold into the prompt and
|
|
9
|
+
* `permissions.mode`; any other field the `args` builder leaves out is not noted.
|
|
10
|
+
*/
|
|
11
|
+
import type { EffortLevel } from './models.js';
|
|
12
|
+
/** Adapters shipped with this package. Custom adapters may use any name. */
|
|
13
|
+
export type AgentName = 'claude' | 'codex' | 'antigravity' | 'opencode';
|
|
14
|
+
/**
|
|
15
|
+
* Canonical permission posture, ordered from most to least restricted.
|
|
16
|
+
*
|
|
17
|
+
* - `read-only` — the agent may inspect but not mutate anything.
|
|
18
|
+
* - `default` — whatever the agent's own default posture is.
|
|
19
|
+
* - `edits` — file edits are pre-approved; shell/network still gated
|
|
20
|
+
* (gated means denied under headless runs — nothing prompts).
|
|
21
|
+
* - `full-auto` — edits and workspace-scoped commands pre-approved, inside
|
|
22
|
+
* whatever sandbox the agent offers.
|
|
23
|
+
* - `bypass` — skip all approvals and sandboxing. Dangerous; intended for
|
|
24
|
+
* externally sandboxed environments only.
|
|
25
|
+
*/
|
|
26
|
+
export type PermissionMode = 'read-only' | 'default' | 'edits' | 'full-auto' | 'bypass';
|
|
27
|
+
/** Permission posture for a call: a canonical mode, plus per-tool rules where the CLI has them. */
|
|
28
|
+
export interface PermissionSpec {
|
|
29
|
+
mode?: PermissionMode | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Tool patterns to pre-approve. Passed through verbatim where the agent
|
|
32
|
+
* supports tool rules (claude's `--allowedTools "Bash(git *) Edit"`);
|
|
33
|
+
* recorded as dropped by the other built-in adapters.
|
|
34
|
+
*/
|
|
35
|
+
allowTools?: string[] | undefined;
|
|
36
|
+
/** Tool patterns to deny. Same passthrough semantics as `allowTools`. */
|
|
37
|
+
denyTools?: string[] | undefined;
|
|
38
|
+
}
|
|
39
|
+
/** What the agent should know about beyond the prompt: files, directories, free text. */
|
|
40
|
+
export interface ContextSpec {
|
|
41
|
+
/**
|
|
42
|
+
* Files the agent should consider. Attached natively where the CLI supports
|
|
43
|
+
* it (opencode `-f`), otherwise emulated with a prompt preamble that lists
|
|
44
|
+
* the paths (and `@path` references for agents with @-inclusion syntax).
|
|
45
|
+
*/
|
|
46
|
+
files?: string[] | undefined;
|
|
47
|
+
/** Extra directories the agent may access (`--add-dir` and friends). */
|
|
48
|
+
dirs?: string[] | undefined;
|
|
49
|
+
/** Freeform context prepended to the prompt on every agent. */
|
|
50
|
+
text?: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
/** The shape of the CLI's output; see {@link OutputSpec.format}. */
|
|
53
|
+
export type OutputFormat = 'text' | 'json' | 'stream-json';
|
|
54
|
+
/** The output shape a call asks for: a format, and optionally a JSON Schema for the answer. */
|
|
55
|
+
export interface OutputSpec {
|
|
56
|
+
/**
|
|
57
|
+
* - `text` — final answer as plain text.
|
|
58
|
+
* - `json` — one machine-readable envelope with metadata.
|
|
59
|
+
* - `stream-json` — line-delimited JSON events as the run progresses.
|
|
60
|
+
*
|
|
61
|
+
* When omitted, adapters default to their richest aggregate format so
|
|
62
|
+
* usage/cost/session metadata can be captured; `AgentResult.text` always
|
|
63
|
+
* carries the final answer regardless.
|
|
64
|
+
*/
|
|
65
|
+
format?: OutputFormat | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* JSON Schema the final answer must satisfy. Native where supported
|
|
68
|
+
* (claude `--json-schema`, codex `--output-schema`), otherwise emulated by
|
|
69
|
+
* appending a strict respond-only-with-JSON instruction to the prompt.
|
|
70
|
+
* The parsed value lands in `AgentResult.structured`.
|
|
71
|
+
*/
|
|
72
|
+
schema?: Record<string, unknown> | undefined;
|
|
73
|
+
}
|
|
74
|
+
/** Session behaviour: pin an id, resume, continue the last session, or keep the run off disk. */
|
|
75
|
+
export interface SessionSpec {
|
|
76
|
+
/** Pin the new session's id (claude only). */
|
|
77
|
+
id?: string | undefined;
|
|
78
|
+
/** Resume an existing session/thread by id. */
|
|
79
|
+
resume?: string | undefined;
|
|
80
|
+
/** Resume the most recent session instead of naming one. */
|
|
81
|
+
continueLast?: boolean | undefined;
|
|
82
|
+
/** Set false to keep the run off disk where supported (default: agent's own). */
|
|
83
|
+
persist?: boolean | undefined;
|
|
84
|
+
}
|
|
85
|
+
/** MCP servers to expose to the agent for this call. */
|
|
86
|
+
export interface McpSpec {
|
|
87
|
+
/**
|
|
88
|
+
* MCP servers to expose to the agent, in the common `mcpServers` config
|
|
89
|
+
* shape: name → `{command, args?, env?}` or `{url}`. Native on claude
|
|
90
|
+
* (`--mcp-config`), rendered to `-c mcp_servers.*` overrides on codex,
|
|
91
|
+
* config-file-only (dropped, with a note) on antigravity/opencode.
|
|
92
|
+
*/
|
|
93
|
+
servers?: Record<string, Record<string, unknown>> | undefined;
|
|
94
|
+
/** Use ONLY these servers, ignoring user/project config (claude only). */
|
|
95
|
+
strict?: boolean | undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Plugins for this session only (claude only). `dirs` load with
|
|
99
|
+
* `--plugin-dir`; `disable` lists installed plugin ids (`name@marketplace`)
|
|
100
|
+
* turned off through `--settings`.
|
|
101
|
+
*/
|
|
102
|
+
export interface PluginSpec {
|
|
103
|
+
dirs: string[];
|
|
104
|
+
disable: string[];
|
|
105
|
+
}
|
|
106
|
+
/** The canonical call. Only `prompt` is required. */
|
|
107
|
+
export interface AgentCallSpec {
|
|
108
|
+
prompt: string;
|
|
109
|
+
/** Concrete model id or alias for the target agent, passed through as given. */
|
|
110
|
+
model?: string | undefined;
|
|
111
|
+
effort?: EffortLevel | undefined;
|
|
112
|
+
mcp?: McpSpec | undefined;
|
|
113
|
+
plugins?: PluginSpec | undefined;
|
|
114
|
+
/** Replace the agent's system prompt (native on claude; else emulated). */
|
|
115
|
+
systemPrompt?: string | undefined;
|
|
116
|
+
/** Append to the agent's system prompt (native on claude; else emulated). */
|
|
117
|
+
appendSystemPrompt?: string | undefined;
|
|
118
|
+
permissions?: PermissionSpec | undefined;
|
|
119
|
+
context?: ContextSpec | undefined;
|
|
120
|
+
output?: OutputSpec | undefined;
|
|
121
|
+
session?: SessionSpec | undefined;
|
|
122
|
+
/** Hard dollar cap where supported (claude `--max-budget-usd`). */
|
|
123
|
+
budgetUsd?: number | undefined;
|
|
124
|
+
/** Working directory for the agent process. */
|
|
125
|
+
cwd?: string | undefined;
|
|
126
|
+
/** Extra environment variables for the agent process. */
|
|
127
|
+
env?: Record<string, string> | undefined;
|
|
128
|
+
/** Kill the process after this long (SIGTERM, then SIGKILL after 5s). */
|
|
129
|
+
timeoutMs?: number | undefined;
|
|
130
|
+
/** Escape hatch: appended verbatim to the built argv, after everything. */
|
|
131
|
+
extraArgs?: string[] | undefined;
|
|
132
|
+
}
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical, agent-agnostic description of one headless agent call.
|
|
3
|
+
*
|
|
4
|
+
* A spec says WHAT you want — prompt, permission posture, context, output
|
|
5
|
+
* shape, session behavior — never HOW a particular CLI expresses it. Each
|
|
6
|
+
* built-in adapter translates the spec into a concrete invocation on a
|
|
7
|
+
* best-effort basis and records every approximation as a `CapabilityNote` (see
|
|
8
|
+
* plan.ts). Custom and template adapters note the fields they fold into the prompt and
|
|
9
|
+
* `permissions.mode`; any other field the `args` builder leaves out is not noted.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|