@koalaauto/agentchannel 0.0.0-stage → 3.0.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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +154 -2
  3. package/dist/src/adapters/claude.d.ts +65 -0
  4. package/dist/src/adapters/claude.js +243 -0
  5. package/dist/src/adapters/codex.d.ts +74 -0
  6. package/dist/src/adapters/codex.js +237 -0
  7. package/dist/src/adapters/fake.d.ts +65 -0
  8. package/dist/src/adapters/fake.js +105 -0
  9. package/dist/src/adapters/index.d.ts +11 -0
  10. package/dist/src/adapters/index.js +26 -0
  11. package/dist/src/adapters/paths.d.ts +35 -0
  12. package/dist/src/adapters/paths.js +113 -0
  13. package/dist/src/adapters/procgroup.d.ts +14 -0
  14. package/dist/src/adapters/procgroup.js +49 -0
  15. package/dist/src/adapters/types.d.ts +57 -0
  16. package/dist/src/adapters/types.js +1 -0
  17. package/dist/src/args.d.ts +14 -0
  18. package/dist/src/args.js +257 -0
  19. package/dist/src/attachments.d.ts +63 -0
  20. package/dist/src/attachments.js +227 -0
  21. package/dist/src/attention.d.ts +85 -0
  22. package/dist/src/attention.js +262 -0
  23. package/dist/src/avatars.d.ts +40 -0
  24. package/dist/src/avatars.js +90 -0
  25. package/dist/src/cli.d.ts +45 -0
  26. package/dist/src/cli.js +1822 -0
  27. package/dist/src/client.d.ts +520 -0
  28. package/dist/src/client.js +449 -0
  29. package/dist/src/config.d.ts +62 -0
  30. package/dist/src/config.js +121 -0
  31. package/dist/src/desknotify.d.ts +154 -0
  32. package/dist/src/desknotify.js +426 -0
  33. package/dist/src/doctor.d.ts +75 -0
  34. package/dist/src/doctor.js +375 -0
  35. package/dist/src/env.d.ts +31 -0
  36. package/dist/src/env.js +52 -0
  37. package/dist/src/import.d.ts +245 -0
  38. package/dist/src/import.js +1008 -0
  39. package/dist/src/index.d.ts +13 -0
  40. package/dist/src/index.js +13 -0
  41. package/dist/src/invite.d.ts +103 -0
  42. package/dist/src/invite.js +229 -0
  43. package/dist/src/invite_person.d.ts +16 -0
  44. package/dist/src/invite_person.js +75 -0
  45. package/dist/src/keystore.d.ts +55 -0
  46. package/dist/src/keystore.js +108 -0
  47. package/dist/src/mcp.d.ts +62 -0
  48. package/dist/src/mcp.js +503 -0
  49. package/dist/src/names.d.ts +30 -0
  50. package/dist/src/names.js +108 -0
  51. package/dist/src/netproxy.d.ts +20 -0
  52. package/dist/src/netproxy.js +36 -0
  53. package/dist/src/onboard.d.ts +105 -0
  54. package/dist/src/onboard.js +270 -0
  55. package/dist/src/perm.d.ts +12 -0
  56. package/dist/src/perm.js +20 -0
  57. package/dist/src/prompts.d.ts +166 -0
  58. package/dist/src/prompts.js +409 -0
  59. package/dist/src/runner/runner.d.ts +212 -0
  60. package/dist/src/runner/runner.js +1148 -0
  61. package/dist/src/runner/state.d.ts +68 -0
  62. package/dist/src/runner/state.js +80 -0
  63. package/dist/src/runner/workspace.d.ts +70 -0
  64. package/dist/src/runner/workspace.js +265 -0
  65. package/dist/src/service.d.ts +29 -0
  66. package/dist/src/service.js +101 -0
  67. package/dist/src/version.d.ts +3 -0
  68. package/dist/src/version.js +3 -0
  69. package/dist/src/wiki.d.ts +74 -0
  70. package/dist/src/wiki.js +222 -0
  71. package/package.json +48 -4
@@ -0,0 +1,74 @@
1
+ import type { SandboxMode, ThreadOptions, Usage } from '@openai/codex-sdk';
2
+ import type { Perm } from '../perm.js';
3
+ import type { AdapterResult, AdapterRunOptions, RuntimeAdapter } from './types.js';
4
+ /** USD per 1M tokens. Configurable via ACH_CODEX_PRICE='{"input":..,"cached_input":..,"output":..}'. */
5
+ export interface PriceTable {
6
+ input: number;
7
+ cached_input: number;
8
+ output: number;
9
+ }
10
+ export declare const DEFAULT_CODEX_PRICE: PriceTable;
11
+ export declare function priceFromEnv(env?: Record<string, string | undefined>): PriceTable;
12
+ export declare function estimateCost(u: Partial<Usage>, p: PriceTable): number;
13
+ export declare function codexSandbox(perm: Perm): SandboxMode;
14
+ /**
15
+ * Thread options (§14.3 as amended by round 1). Pure, unit-tested.
16
+ * - Answer runs: read-only sandbox, network off. The agent returns JSON
17
+ * (outputSchema) and the runner posts it, so it never needs the hub (S4).
18
+ * - Work runs keep network on for `ach`.
19
+ * - No additionalDirectories: the main repo's .git is never writable (S10);
20
+ * the runner commits after the run.
21
+ */
22
+ export declare function codexThreadOptions(perm: Perm, cwd: string, model?: string, mode?: 'work' | 'answer' | 'mention'): ThreadOptions;
23
+ /** Codex `--config` overrides set by the runner (key names checked against the bundled codex 0.165 binary). */
24
+ export type CodexRunnerConfig = {
25
+ allow_login_shell: boolean;
26
+ sandbox_workspace_write: {
27
+ exclude_slash_tmp: boolean;
28
+ exclude_tmpdir_env_var: boolean;
29
+ };
30
+ };
31
+ /**
32
+ * Codex client options.
33
+ * - allow_login_shell=false: by default Codex runs tool commands in a login
34
+ * shell, whose profile (path_helper, `brew shellenv`) reorders PATH, so a
35
+ * globally installed `ach` (another program's) shadowed the runner's
36
+ * <home>/bin/ach shim and every `ach complete` failed (live test).
37
+ * - sandbox_workspace_write.exclude_slash_tmp / exclude_tmpdir_env_var = true:
38
+ * in workspace-write, Codex by default also lets the agent write /tmp and
39
+ * $TMPDIR, which other processes of the user read (live test). Writes are now
40
+ * the working directory only.
41
+ */
42
+ export declare function codexClientOptions(env: Record<string, string | undefined>): {
43
+ env: Record<string, string>;
44
+ config: CodexRunnerConfig;
45
+ };
46
+ /** `root` and all its descendants, from `ps -axo pid=,ppid=,command=` output. Pure. */
47
+ export declare function processTree(psOut: string, root: number): number[];
48
+ /** Pids of `codex exec ... --cd <cwd>` processes that are children of `parent`. Pure. */
49
+ export declare function codexChildren(psOut: string, parent: number, cwd: string): number[];
50
+ /**
51
+ * C7: @openai/codex-sdk 0.165.0 spawns `codex exec` itself (no new process
52
+ * group, pid not exposed) and on abort SIGTERMs only that process. Codex runs
53
+ * each tool command in its own process group, so those survived a cancel
54
+ * (live test: `sleep 300 && echo done > done.txt` kept running, re-parented to
55
+ * init). On abort we find this run's codex child by its `--cd`, then SIGTERM
56
+ * every descendant and its process group, and SIGKILL survivors after a grace.
57
+ */
58
+ export declare function killCodexTree(cwd: string, o?: {
59
+ parent?: number;
60
+ ps?: () => string;
61
+ kill?: (pid: number, sig: NodeJS.Signals) => void;
62
+ graceMs?: number;
63
+ }): number[];
64
+ /** Codex runtime adapter (@openai/codex-sdk). Cancel: see killCodexTree. */
65
+ export declare class CodexAdapter implements RuntimeAdapter {
66
+ private o;
67
+ readonly name = "codex";
68
+ readonly structuredAnswers = true;
69
+ constructor(o?: {
70
+ model?: string;
71
+ price?: PriceTable;
72
+ });
73
+ run(opts: AdapterRunOptions): Promise<AdapterResult>;
74
+ }
@@ -0,0 +1,237 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { KILL_GRACE_MS } from './procgroup.js';
3
+ export const DEFAULT_CODEX_PRICE = { input: 1.25, cached_input: 0.125, output: 10 };
4
+ export function priceFromEnv(env = process.env) {
5
+ try {
6
+ if (env.ACH_CODEX_PRICE)
7
+ return { ...DEFAULT_CODEX_PRICE, ...JSON.parse(env.ACH_CODEX_PRICE) };
8
+ }
9
+ catch {
10
+ /* fall through */
11
+ }
12
+ return DEFAULT_CODEX_PRICE;
13
+ }
14
+ export function estimateCost(u, p) {
15
+ const cached = u.cached_input_tokens ?? 0;
16
+ const input = Math.max(0, (u.input_tokens ?? 0) - cached);
17
+ const output = (u.output_tokens ?? 0) + (u.reasoning_output_tokens ?? 0);
18
+ return (input * p.input + cached * p.cached_input + output * p.output) / 1_000_000;
19
+ }
20
+ export function codexSandbox(perm) {
21
+ return perm === 'read' ? 'read-only' : perm === 'workdir' ? 'workspace-write' : 'danger-full-access';
22
+ }
23
+ /**
24
+ * Thread options (§14.3 as amended by round 1). Pure, unit-tested.
25
+ * - Answer runs: read-only sandbox, network off. The agent returns JSON
26
+ * (outputSchema) and the runner posts it, so it never needs the hub (S4).
27
+ * - Work runs keep network on for `ach`.
28
+ * - No additionalDirectories: the main repo's .git is never writable (S10);
29
+ * the runner commits after the run.
30
+ */
31
+ export function codexThreadOptions(perm, cwd, model, mode = 'work') {
32
+ // answer and mention runs are read-only and offline; the runner posts their JSON
33
+ const answer = mode === 'answer' || mode === 'mention';
34
+ return {
35
+ workingDirectory: cwd,
36
+ sandboxMode: answer ? 'read-only' : codexSandbox(perm),
37
+ networkAccessEnabled: !answer,
38
+ approvalPolicy: 'never',
39
+ skipGitRepoCheck: true,
40
+ ...(model ? { model } : {}),
41
+ };
42
+ }
43
+ /**
44
+ * Codex client options.
45
+ * - allow_login_shell=false: by default Codex runs tool commands in a login
46
+ * shell, whose profile (path_helper, `brew shellenv`) reorders PATH, so a
47
+ * globally installed `ach` (another program's) shadowed the runner's
48
+ * <home>/bin/ach shim and every `ach complete` failed (live test).
49
+ * - sandbox_workspace_write.exclude_slash_tmp / exclude_tmpdir_env_var = true:
50
+ * in workspace-write, Codex by default also lets the agent write /tmp and
51
+ * $TMPDIR, which other processes of the user read (live test). Writes are now
52
+ * the working directory only.
53
+ */
54
+ export function codexClientOptions(env) {
55
+ const clean = {};
56
+ for (const [k, v] of Object.entries(env))
57
+ if (typeof v === 'string')
58
+ clean[k] = v;
59
+ return {
60
+ env: clean,
61
+ config: {
62
+ allow_login_shell: false,
63
+ sandbox_workspace_write: { exclude_slash_tmp: true, exclude_tmpdir_env_var: true },
64
+ },
65
+ };
66
+ }
67
+ /** `root` and all its descendants, from `ps -axo pid=,ppid=,command=` output. Pure. */
68
+ export function processTree(psOut, root) {
69
+ const kids = new Map();
70
+ for (const line of psOut.split('\n')) {
71
+ const m = line.trim().match(/^(\d+)\s+(\d+)/);
72
+ if (!m)
73
+ continue;
74
+ const pid = Number(m[1]);
75
+ const ppid = Number(m[2]);
76
+ if (!kids.has(ppid))
77
+ kids.set(ppid, []);
78
+ kids.get(ppid).push(pid);
79
+ }
80
+ const out = [];
81
+ const stack = [root];
82
+ while (stack.length) {
83
+ const p = stack.pop();
84
+ out.push(p);
85
+ for (const c of kids.get(p) ?? [])
86
+ if (!out.includes(c))
87
+ stack.push(c);
88
+ }
89
+ return out;
90
+ }
91
+ /** Pids of `codex exec ... --cd <cwd>` processes that are children of `parent`. Pure. */
92
+ export function codexChildren(psOut, parent, cwd) {
93
+ const res = [];
94
+ for (const line of psOut.split('\n')) {
95
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
96
+ if (!m || Number(m[2]) !== parent)
97
+ continue;
98
+ const cmd = m[3];
99
+ if (/codex/.test(cmd) && / exec /.test(cmd) && (cmd.includes(`--cd ${cwd} `) || cmd.endsWith(`--cd ${cwd}`)))
100
+ res.push(Number(m[1]));
101
+ }
102
+ return res;
103
+ }
104
+ /**
105
+ * C7: @openai/codex-sdk 0.165.0 spawns `codex exec` itself (no new process
106
+ * group, pid not exposed) and on abort SIGTERMs only that process. Codex runs
107
+ * each tool command in its own process group, so those survived a cancel
108
+ * (live test: `sleep 300 && echo done > done.txt` kept running, re-parented to
109
+ * init). On abort we find this run's codex child by its `--cd`, then SIGTERM
110
+ * every descendant and its process group, and SIGKILL survivors after a grace.
111
+ */
112
+ export function killCodexTree(cwd, o = {}) {
113
+ if (process.platform === 'win32' && !o.ps)
114
+ return [];
115
+ const ps = o.ps ?? (() => execFileSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
116
+ const kill = o.kill ?? ((pid, sig) => process.kill(pid, sig));
117
+ let out;
118
+ try {
119
+ out = ps();
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ const pids = codexChildren(out, o.parent ?? process.pid, cwd).flatMap((c) => processTree(out, c));
125
+ const send = (sig) => {
126
+ for (const pid of pids) {
127
+ for (const target of [-pid, pid]) {
128
+ try {
129
+ kill(target, sig);
130
+ }
131
+ catch {
132
+ /* not a group leader, or already gone */
133
+ }
134
+ }
135
+ }
136
+ };
137
+ send('SIGTERM');
138
+ if (pids.length)
139
+ setTimeout(() => send('SIGKILL'), o.graceMs ?? KILL_GRACE_MS).unref();
140
+ return pids;
141
+ }
142
+ /** Codex runtime adapter (@openai/codex-sdk). Cancel: see killCodexTree. */
143
+ export class CodexAdapter {
144
+ o;
145
+ name = 'codex';
146
+ structuredAnswers = true;
147
+ constructor(o = {}) {
148
+ this.o = o;
149
+ }
150
+ async run(opts) {
151
+ const { Codex } = await import('@openai/codex-sdk');
152
+ const codex = new Codex(codexClientOptions(opts.env));
153
+ const to = codexThreadOptions(opts.perm, opts.cwd, this.o.model, opts.mode);
154
+ const thread = opts.resume ? codex.resumeThread(opts.resume, to) : codex.startThread(to);
155
+ const price = this.o.price ?? priceFromEnv(opts.env);
156
+ let sessionId = opts.resume;
157
+ if (sessionId)
158
+ opts.onEvent({ type: 'session', data: sessionId });
159
+ let finalText = '';
160
+ let cost = 0;
161
+ let sawUsage = false;
162
+ let error;
163
+ // registered before the SDK spawns codex, so it runs before the SDK's own kill
164
+ const onAbort = () => killCodexTree(opts.cwd);
165
+ opts.signal.addEventListener('abort', onAbort, { once: true });
166
+ try {
167
+ const { events } = await thread.runStreamed(opts.prompt, {
168
+ signal: opts.signal,
169
+ ...(opts.outputSchema ? { outputSchema: opts.outputSchema } : {}),
170
+ });
171
+ for await (const ev of events) {
172
+ switch (ev.type) {
173
+ case 'thread.started':
174
+ if (ev.thread_id !== sessionId) {
175
+ sessionId = ev.thread_id;
176
+ opts.onEvent({ type: 'session', data: sessionId });
177
+ }
178
+ break;
179
+ case 'item.completed':
180
+ if (ev.item.type !== 'agent_message' && ev.item.type !== 'reasoning')
181
+ opts.onEvent({ type: 'tool_end', data: { id: ev.item.id } });
182
+ if (ev.item.type === 'agent_message') {
183
+ finalText = ev.item.text;
184
+ opts.onEvent({ type: 'text', data: ev.item.text });
185
+ }
186
+ else if (ev.item.type === 'error') {
187
+ opts.onEvent({ type: 'error', data: ev.item.message });
188
+ }
189
+ else {
190
+ opts.onEvent({ type: 'tool', data: { type: ev.item.type } });
191
+ }
192
+ break;
193
+ case 'item.started':
194
+ if (ev.item.type !== 'agent_message' && ev.item.type !== 'reasoning') {
195
+ opts.onEvent({ type: 'tool_start', data: { id: ev.item.id, type: ev.item.type } });
196
+ }
197
+ else
198
+ opts.onEvent({ type: 'tool', data: { type: ev.type } });
199
+ break;
200
+ case 'item.updated':
201
+ case 'turn.started':
202
+ opts.onEvent({ type: 'tool', data: { type: ev.type } });
203
+ break;
204
+ case 'turn.completed':
205
+ sawUsage = true;
206
+ cost += estimateCost(ev.usage, price);
207
+ opts.onEvent({ type: 'usage', data: { usage: ev.usage, costUsd: cost, estimated: true } });
208
+ break;
209
+ case 'turn.failed':
210
+ error = ev.error.message;
211
+ opts.onEvent({ type: 'error', data: error });
212
+ break;
213
+ case 'error':
214
+ error = ev.message;
215
+ opts.onEvent({ type: 'error', data: error });
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ catch (e) {
221
+ error = opts.signal.aborted ? 'aborted' : String(e?.message ?? e);
222
+ opts.onEvent({ type: 'error', data: error });
223
+ }
224
+ finally {
225
+ opts.signal.removeEventListener('abort', onAbort);
226
+ }
227
+ sessionId = sessionId ?? thread.id ?? undefined;
228
+ return {
229
+ finalText,
230
+ sessionId,
231
+ costUsd: sawUsage ? cost : undefined,
232
+ estimated: true,
233
+ ok: !error,
234
+ error,
235
+ };
236
+ }
237
+ }
@@ -0,0 +1,65 @@
1
+ import type { AdapterResult, AdapterRunOptions, RuntimeAdapter } from './types.js';
2
+ export interface AchCallResult {
3
+ code: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ /** Invokes the `ach` CLI with the spawned agent's env. */
8
+ export type AchCall = (args: string[], env: Record<string, string>, cwd: string) => Promise<AchCallResult>;
9
+ export type FakeStep = {
10
+ ach: string[];
11
+ unset?: string[];
12
+ set?: Record<string, string>;
13
+ } | {
14
+ text: string;
15
+ } | {
16
+ wait: number;
17
+ } | {
18
+ waitForAbort: true;
19
+ } | {
20
+ fail: string;
21
+ } | {
22
+ cost: number;
23
+ } | {
24
+ toolStart: string;
25
+ } | {
26
+ toolEnd: string;
27
+ } | {
28
+ throw: string;
29
+ };
30
+ export interface FakeRunInfo {
31
+ index: number;
32
+ prompt: string;
33
+ resume?: string;
34
+ cwd: string;
35
+ perm: string;
36
+ mode?: 'work' | 'answer' | 'mention';
37
+ env: Record<string, string>;
38
+ denyReadPaths?: string[];
39
+ readRoots?: string[];
40
+ outputSchema?: unknown;
41
+ netAllow?: string[];
42
+ }
43
+ export interface FakeAdapterOptions {
44
+ /** Steps for each run; receives the run info so a script can branch on prompt / resume. */
45
+ script: (info: FakeRunInfo) => FakeStep[];
46
+ ach?: AchCall;
47
+ /** Behave like codex: answer runs return JSON and the runner posts it. */
48
+ structuredAnswers?: boolean;
49
+ }
50
+ /** Default: spawn this package's CLI with node. */
51
+ export declare const spawnAch: AchCall;
52
+ /** Scripted runtime for tests. Never calls a model. */
53
+ export declare class FakeAdapter implements RuntimeAdapter {
54
+ private o;
55
+ readonly name = "fake";
56
+ readonly structuredAnswers: boolean;
57
+ runs: FakeRunInfo[];
58
+ achCalls: {
59
+ args: string[];
60
+ env: Record<string, string>;
61
+ result: AchCallResult;
62
+ }[];
63
+ constructor(o: FakeAdapterOptions);
64
+ run(opts: AdapterRunOptions): Promise<AdapterResult>;
65
+ }
@@ -0,0 +1,105 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ /** Default: spawn this package's CLI with node. */
4
+ export const spawnAch = (args, env, cwd) => new Promise((res) => {
5
+ const cli = fileURLToPath(new URL('../cli.js', import.meta.url));
6
+ const p = spawn(process.execPath, [cli, ...args], { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] });
7
+ let stdout = '';
8
+ let stderr = '';
9
+ p.stdout.on('data', (d) => (stdout += d));
10
+ p.stderr.on('data', (d) => (stderr += d));
11
+ p.on('close', (code) => res({ code: code ?? 1, stdout, stderr }));
12
+ });
13
+ function sleep(ms, signal) {
14
+ return new Promise((res, rej) => {
15
+ if (signal.aborted)
16
+ return rej(new Error('aborted'));
17
+ const t = setTimeout(() => {
18
+ signal.removeEventListener('abort', on);
19
+ res();
20
+ }, ms);
21
+ const on = () => {
22
+ clearTimeout(t);
23
+ rej(new Error('aborted'));
24
+ };
25
+ signal.addEventListener('abort', on, { once: true });
26
+ });
27
+ }
28
+ /** Scripted runtime for tests. Never calls a model. */
29
+ export class FakeAdapter {
30
+ o;
31
+ name = 'fake';
32
+ structuredAnswers;
33
+ runs = [];
34
+ achCalls = [];
35
+ constructor(o) {
36
+ this.o = o;
37
+ this.structuredAnswers = !!o.structuredAnswers;
38
+ }
39
+ async run(opts) {
40
+ const info = {
41
+ index: this.runs.length,
42
+ prompt: opts.prompt,
43
+ resume: opts.resume,
44
+ cwd: opts.cwd,
45
+ perm: opts.perm,
46
+ mode: opts.mode,
47
+ env: opts.env,
48
+ denyReadPaths: opts.denyReadPaths,
49
+ readRoots: opts.readRoots,
50
+ outputSchema: opts.outputSchema,
51
+ netAllow: opts.netAllow,
52
+ };
53
+ this.runs.push(info);
54
+ const sessionId = opts.resume ?? `fake-session-${info.index}-${Math.random().toString(36).slice(2, 8)}`;
55
+ opts.onEvent({ type: 'session', data: sessionId });
56
+ const ach = this.o.ach ?? spawnAch;
57
+ let text = '';
58
+ let cost;
59
+ try {
60
+ for (const step of this.o.script(info)) {
61
+ if (opts.signal.aborted)
62
+ throw new Error('aborted');
63
+ if ('ach' in step) {
64
+ // unset/set: what a misbehaving agent could do to its own env (`env -u ACH_RUN_TOKEN ach ...`)
65
+ const env = { ...opts.env, ...(step.set ?? {}) };
66
+ for (const k of step.unset ?? [])
67
+ delete env[k];
68
+ const result = await ach(step.ach, env, opts.cwd);
69
+ this.achCalls.push({ args: step.ach, env, result });
70
+ opts.onEvent({ type: 'tool', data: { ach: step.ach, code: result.code } });
71
+ }
72
+ else if ('text' in step) {
73
+ text = step.text;
74
+ opts.onEvent({ type: 'text', data: step.text });
75
+ }
76
+ else if ('wait' in step) {
77
+ await sleep(step.wait, opts.signal);
78
+ }
79
+ else if ('waitForAbort' in step) {
80
+ await sleep(2 ** 30, opts.signal);
81
+ }
82
+ else if ('cost' in step) {
83
+ cost = step.cost;
84
+ opts.onEvent({ type: 'usage', data: { costUsd: cost } });
85
+ }
86
+ else if ('toolStart' in step) {
87
+ opts.onEvent({ type: 'tool_start', data: { id: step.toolStart } });
88
+ }
89
+ else if ('toolEnd' in step) {
90
+ opts.onEvent({ type: 'tool_end', data: { id: step.toolEnd } });
91
+ }
92
+ else if ('fail' in step) {
93
+ return { finalText: text, sessionId, costUsd: cost, ok: false, error: step.fail };
94
+ }
95
+ else if ('throw' in step) {
96
+ throw new Error(step.throw);
97
+ }
98
+ }
99
+ }
100
+ catch (e) {
101
+ return { finalText: text, sessionId, costUsd: cost, ok: false, error: opts.signal.aborted ? 'aborted' : String(e?.message ?? e) };
102
+ }
103
+ return { finalText: text, sessionId, costUsd: cost, ok: true };
104
+ }
105
+ }
@@ -0,0 +1,11 @@
1
+ import type { RuntimeAdapter } from './types.js';
2
+ export * from './types.js';
3
+ export { ClaudeAdapter } from './claude.js';
4
+ export { CodexAdapter } from './codex.js';
5
+ export { FakeAdapter } from './fake.js';
6
+ export * from './paths.js';
7
+ /** The git common dir of a worktree (readable for git log/diff; never writable, S10). */
8
+ export declare function gitCommonDir(cwd: string): string[];
9
+ export declare function makeAdapter(runtime: string, o?: {
10
+ model?: string;
11
+ }): RuntimeAdapter;
@@ -0,0 +1,26 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { resolve } from 'node:path';
3
+ import { ClaudeAdapter } from './claude.js';
4
+ import { CodexAdapter } from './codex.js';
5
+ export * from './types.js';
6
+ export { ClaudeAdapter } from './claude.js';
7
+ export { CodexAdapter } from './codex.js';
8
+ export { FakeAdapter } from './fake.js';
9
+ export * from './paths.js';
10
+ /** The git common dir of a worktree (readable for git log/diff; never writable, S10). */
11
+ export function gitCommonDir(cwd) {
12
+ try {
13
+ const out = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
14
+ return out ? [resolve(cwd, out)] : [];
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ }
20
+ export function makeAdapter(runtime, o = {}) {
21
+ if (runtime === 'claude')
22
+ return new ClaudeAdapter({ model: o.model });
23
+ if (runtime === 'codex')
24
+ return new CodexAdapter({ model: o.model });
25
+ throw new Error(`Unknown runtime "${runtime}" (use claude or codex)`);
26
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * realpath of p, or of its nearest existing ancestor plus the rest (for files
3
+ * that do not exist yet). macOS maps /tmp to /private/tmp and /var to
4
+ * /private/var, so every comparison goes through this.
5
+ */
6
+ export declare function realish(p: string, depth?: number): string;
7
+ export declare function isInside(root: string, p: string): boolean;
8
+ /** Root of this npm package (dist/src/adapters/paths.js -> package dir): the `ach` shim runs code from here. */
9
+ export declare function packageRoot(): string;
10
+ /**
11
+ * Paths under $HOME a spawned agent may read besides its cwd (S4): the node
12
+ * install, npm and git config, and this package (the `ach` CLI). The git
13
+ * common dir of the worktree is added by the caller.
14
+ */
15
+ export declare function toolchainReadRoots(home?: string): string[];
16
+ export interface PathPolicy {
17
+ home: string;
18
+ cwd: string;
19
+ /** Readable roots under $HOME besides cwd. */
20
+ readRoots: string[];
21
+ /** Writable roots (empty = read-only). */
22
+ writeRoots: string[];
23
+ /** Never readable or writable, even inside an allowed root. */
24
+ deny: string[];
25
+ }
26
+ /** Undefined = allowed, else the reason for denying a read of p. */
27
+ export declare function readDenied(pol: PathPolicy, p: string): string | undefined;
28
+ export declare function writeDenied(pol: PathPolicy, p: string): string | undefined;
29
+ export declare const READ_TOOLS: Set<string>;
30
+ export declare const WRITE_TOOLS: Set<string>;
31
+ /** File paths a tool call touches (relative paths are resolved against cwd by the policy). */
32
+ export declare function toolPaths(toolName: string, input: Record<string, unknown>): string[];
33
+ /** Deny reason for a tool call under the policy, or undefined to allow. */
34
+ export declare function toolDenied(pol: PathPolicy, toolName: string, input: Record<string, unknown>): string | undefined;
35
+ export declare function existing(paths: string[]): string[];
@@ -0,0 +1,113 @@
1
+ import { existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /**
6
+ * realpath of p, or of its nearest existing ancestor plus the rest (for files
7
+ * that do not exist yet). macOS maps /tmp to /private/tmp and /var to
8
+ * /private/var, so every comparison goes through this.
9
+ */
10
+ export function realish(p, depth = 0) {
11
+ let cur = resolve(p);
12
+ const rest = [];
13
+ for (;;) {
14
+ try {
15
+ return join(realpathSync(cur), ...rest.reverse());
16
+ }
17
+ catch {
18
+ // A dangling symlink still points somewhere: follow it.
19
+ try {
20
+ if (depth < 40 && lstatSync(cur).isSymbolicLink()) {
21
+ return realish(join(resolve(dirname(cur), readlinkSync(cur)), ...rest.reverse()), depth + 1);
22
+ }
23
+ }
24
+ catch {
25
+ /* not there at all */
26
+ }
27
+ const up = dirname(cur);
28
+ if (up === cur)
29
+ return resolve(p);
30
+ rest.push(basename(cur));
31
+ cur = up;
32
+ }
33
+ }
34
+ }
35
+ export function isInside(root, p) {
36
+ const rel = relative(realish(root), realish(p));
37
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
38
+ }
39
+ /** Root of this npm package (dist/src/adapters/paths.js -> package dir): the `ach` shim runs code from here. */
40
+ export function packageRoot() {
41
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
42
+ }
43
+ /**
44
+ * Paths under $HOME a spawned agent may read besides its cwd (S4): the node
45
+ * install, npm and git config, and this package (the `ach` CLI). The git
46
+ * common dir of the worktree is added by the caller.
47
+ */
48
+ export function toolchainReadRoots(home = homedir()) {
49
+ const nodePrefix = resolve(dirname(process.execPath), '..');
50
+ return [
51
+ nodePrefix,
52
+ packageRoot(),
53
+ join(home, '.gitconfig'),
54
+ join(home, '.config', 'git'),
55
+ join(home, '.npmrc'),
56
+ join(home, '.npm'),
57
+ ];
58
+ }
59
+ /** Undefined = allowed, else the reason for denying a read of p. */
60
+ export function readDenied(pol, p) {
61
+ const abs = resolve(pol.cwd, p);
62
+ if (pol.deny.some((d) => isInside(d, abs)))
63
+ return `${p} is a protected AgentChannel file`;
64
+ if (!isInside(pol.home, abs))
65
+ return undefined;
66
+ if ([pol.cwd, ...pol.readRoots].some((r) => isInside(r, abs)))
67
+ return undefined;
68
+ return `reads under ${pol.home} are limited to the task directory and toolchain paths`;
69
+ }
70
+ export function writeDenied(pol, p) {
71
+ const abs = resolve(pol.cwd, p);
72
+ if (pol.deny.some((d) => isInside(d, abs)))
73
+ return `${p} is a protected AgentChannel file`;
74
+ if (pol.writeRoots.some((r) => isInside(r, abs)))
75
+ return undefined;
76
+ return `writes are only allowed inside ${pol.cwd}`;
77
+ }
78
+ export const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead']);
79
+ export const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
80
+ /** File paths a tool call touches (relative paths are resolved against cwd by the policy). */
81
+ export function toolPaths(toolName, input) {
82
+ const out = [];
83
+ for (const k of ['file_path', 'notebook_path', 'path']) {
84
+ if (typeof input[k] === 'string' && input[k])
85
+ out.push(input[k]);
86
+ }
87
+ // Glob/Grep patterns may be absolute ("/Users/x/**/*.pem"): check their fixed prefix.
88
+ for (const k of ['pattern', 'glob']) {
89
+ const v = input[k];
90
+ if ((toolName === 'Glob' || k === 'glob') && typeof v === 'string' && (isAbsolute(v) || v.startsWith('~'))) {
91
+ const fixed = v.replace(/^~(?=\/|$)/, homedir()).split(/[*?[{]/)[0];
92
+ out.push(fixed || '/');
93
+ }
94
+ }
95
+ if ((READ_TOOLS.has(toolName) || toolName === 'Glob' || toolName === 'Grep') && !out.length)
96
+ out.push('.');
97
+ return out;
98
+ }
99
+ /** Deny reason for a tool call under the policy, or undefined to allow. */
100
+ export function toolDenied(pol, toolName, input) {
101
+ const write = WRITE_TOOLS.has(toolName);
102
+ if (!write && !READ_TOOLS.has(toolName))
103
+ return undefined;
104
+ for (const p of toolPaths(toolName, input)) {
105
+ const why = write ? writeDenied(pol, p) ?? readDenied(pol, p) : readDenied(pol, p);
106
+ if (why)
107
+ return why;
108
+ }
109
+ return undefined;
110
+ }
111
+ export function existing(paths) {
112
+ return paths.filter((p) => existsSync(p));
113
+ }