@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,375 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+ import { Client } from './client.js';
6
+ import { achHome, loadAgentKey, loadConfig } from './config.js';
7
+ import { hubTransportOk, identifyAch, isOurs, npmSpec, thisCliPath, userBin, whichAll } from './invite.js';
8
+ import { openKeyStore } from './keystore.js';
9
+ import { PKG_NAME, VERSION } from './version.js';
10
+ /** Ask-the-admin fix: a fresh join line carries a new key and whatever the hub needs to let you in. */
11
+ export const NEW_LINE_FIX = 'ask your admin to run `ach admin invite <your agent>` again and paste the new line';
12
+ /** Allowed clock difference with the hub before warning (login links and tokens are time-bound). */
13
+ export const CLOCK_SKEW_WARN_MS = 5 * 60_000;
14
+ function causeCode(e) {
15
+ return String(e?.cause?.code ?? e?.code ?? '');
16
+ }
17
+ /** GET /api/health without following redirects, and classify what came back. */
18
+ export async function probeHub(hub, headers, fetchFn) {
19
+ let res;
20
+ try {
21
+ res = await fetchFn(`${hub}/api/health`, { method: 'GET', headers, redirect: 'manual' });
22
+ }
23
+ catch (e) {
24
+ const code = causeCode(e);
25
+ const message = String(e?.cause?.message ?? e?.message ?? e);
26
+ if (/ENOTFOUND|EAI_AGAIN|EAI_NONAME/.test(code))
27
+ return { kind: 'dns', message };
28
+ if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH/.test(code))
29
+ return { kind: 'refused', message };
30
+ if (/TIMEOUT|ETIMEDOUT|ABORT/i.test(code) || /timeout/i.test(message))
31
+ return { kind: 'timeout', message };
32
+ if (/CERT|SSL|TLS|SELF_SIGNED/i.test(code + message))
33
+ return { kind: 'tls', message };
34
+ return { kind: 'network', message };
35
+ }
36
+ const loc = res.headers.get('location') ?? '';
37
+ if (res.status >= 300 && res.status < 400 && /cloudflareaccess\.com|\/cdn-cgi\/access\//i.test(loc))
38
+ return { kind: 'gate', status: res.status, where: loc };
39
+ if ((res.status === 401 || res.status === 403) && (res.headers.get('cf-ray') || /cloudflare/i.test(res.headers.get('server') ?? '')) && !/application\/json/.test(res.headers.get('content-type') ?? '')) {
40
+ return { kind: 'gate', status: res.status, where: 'the hub address' };
41
+ }
42
+ if (res.status >= 300 && res.status < 400)
43
+ return { kind: 'gate', status: res.status, where: loc || 'a sign-in page' };
44
+ if (!res.ok)
45
+ return { kind: 'http', status: res.status };
46
+ return { kind: 'ok', status: res.status, date: res.headers.get('date') ?? undefined };
47
+ }
48
+ function probeText(p, host) {
49
+ switch (p.kind) {
50
+ case 'dns': return { detail: `cannot find ${host} (${p.message})`, fix: 'check the hub address in the line from your admin, and that you are on the right network or VPN' };
51
+ case 'refused': return { detail: `${host} refused the connection (${p.message})`, fix: 'the hub may be down: ask your admin whether it is running' };
52
+ case 'timeout': return { detail: `${host} did not answer in time`, fix: 'check your network or VPN; if others can reach it, ask your admin' };
53
+ case 'tls': return { detail: `the secure connection to ${host} failed (${p.message})`, fix: 'check the date and time on this machine; otherwise ask your admin to check the hub certificate' };
54
+ case 'http': return { detail: `${host} answered HTTP ${p.status}`, fix: 'ask your admin to check the hub' };
55
+ default: return { detail: p.message ?? String(p.kind), fix: 'check your network, then run ach doctor again' };
56
+ }
57
+ }
58
+ /** Runs a status command with a timeout; never throws. */
59
+ export const defaultExec = (file, args, env) => new Promise((res) => {
60
+ execFile(file, args, { env, timeout: 15_000, encoding: 'utf8', maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
61
+ res({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
62
+ });
63
+ });
64
+ /** The Codex CLI the runner's SDK uses when `codex` is not on PATH. */
65
+ function bundledCodex() {
66
+ try {
67
+ const req = createRequire(import.meta.url);
68
+ const f = join(dirname(req.resolve('@openai/codex/package.json')), 'bin', 'codex.js');
69
+ return existsSync(f) ? f : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ function firstLine(s) {
76
+ return (s.split('\n').find((l) => l.trim()) ?? '').trim().slice(0, 160);
77
+ }
78
+ /**
79
+ * `ach doctor`: a read-only setup check. Never prints the key
80
+ * or the CF secret, and never reads Claude/Codex credential files: login state
81
+ * comes from `claude auth status` and `codex login status`.
82
+ */
83
+ export async function runDoctor(o) {
84
+ const env = o.env;
85
+ const exec = o.exec ?? defaultExec;
86
+ const out = [];
87
+ const add = (name, status, detail, section, fix) => out.push({ name, status, detail, ...(section && section !== 'machine' ? { section } : {}), ...(fix && status !== 'ok' && status !== 'info' ? { fix } : {}) });
88
+ add('version', 'info', `ach ${VERSION} (${o.self ?? thisCliPath()})`);
89
+ // Home
90
+ const home = achHome(env);
91
+ add('home', existsSync(home) ? 'ok' : 'warn', `${home}${env.AGENTCHANNEL_HOME ? ' (AGENTCHANNEL_HOME)' : ''}${existsSync(home) ? '' : ' does not exist yet: run the `ach join ...` line'}`);
92
+ // Config and key
93
+ const cfg = loadConfig(env);
94
+ add('hub', cfg.hub ? 'ok' : 'fail', cfg.hub ? `${cfg.hub}${env.ACH_HUB ? ' (ACH_HUB)' : ''}` : 'no hub configured', 'hub', `paste the join line from your admin (npx -y ${npmSpec()} join ach2....)`);
95
+ let key;
96
+ let keyErr = '';
97
+ try {
98
+ key = env.ACH_KEY || loadAgentKey(env);
99
+ }
100
+ catch (e) {
101
+ keyErr = String(e?.message ?? e);
102
+ }
103
+ const where = env.ACH_KEY ? 'ACH_KEY' : openKeyStore(home, env).kind === 'keychain' ? 'OS keychain' : 'agent-key file';
104
+ add('agent key', key ? 'ok' : 'fail', key ? `present (${where}; not shown)` : `missing${keyErr ? ` (${keyErr})` : ''}`, 'key', key ? undefined : 'paste the join line from your admin again');
105
+ // Hub: reachable over HTTPS; front door (if any); the key works; clock
106
+ let runtime;
107
+ let agentName;
108
+ if (cfg.hub) {
109
+ const fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
110
+ let host = cfg.hub;
111
+ try {
112
+ const u = new URL(cfg.hub);
113
+ host = u.host;
114
+ if (!hubTransportOk(cfg.hub)) {
115
+ add('hub https', 'warn', `${cfg.hub} is plain http:// to ${u.host} (not this machine): your key and every task travel unencrypted`, 'hub', 'ask your admin for the https:// address of the hub, then paste the new join line');
116
+ }
117
+ else if (u.protocol !== 'https:')
118
+ add('hub https', 'info', `${cfg.hub} is plain http:// to a local address (${u.host})`, 'hub');
119
+ }
120
+ catch {
121
+ add('hub', 'fail', `${cfg.hub} is not a valid address`, 'hub', 'paste the join line from your admin again');
122
+ }
123
+ const hasPass = !!(cfg.cfId && cfg.cfSecret);
124
+ const pass = hasPass ? { 'CF-Access-Client-Id': cfg.cfId, 'CF-Access-Client-Secret': cfg.cfSecret } : {};
125
+ const bare = await probeHub(cfg.hub, { accept: 'application/json' }, fetchFn);
126
+ const withPass = hasPass && bare.kind === 'gate' ? await probeHub(cfg.hub, { accept: 'application/json', ...pass }, fetchFn) : bare;
127
+ let reachable = false;
128
+ let date;
129
+ if (bare.kind === 'gate') {
130
+ add('hub reachable', 'ok', `${host} answers (behind a front door)`, 'hub');
131
+ const passSrc = env.ACH_CF_ID && env.ACH_CF_SECRET ? 'from ACH_CF_ID/ACH_CF_SECRET' : 'stored by join; not shown';
132
+ if (withPass.kind === 'ok') {
133
+ reachable = true;
134
+ date = withPass.date;
135
+ add('front door', 'ok', `the hub's front door lets this machine in (${passSrc})`, 'front door');
136
+ }
137
+ else if (!hasPass) {
138
+ add('front door', 'fail', `the hub's front door (sign-in page at ${shortWhere(bare.where)}) blocks this machine: no pass for it is stored`, 'front door', NEW_LINE_FIX);
139
+ }
140
+ else {
141
+ add('front door', 'fail', `the hub's front door refused this machine's pass (HTTP ${withPass.status ?? '?'}): it expired or was revoked`, 'front door', NEW_LINE_FIX);
142
+ }
143
+ }
144
+ else if (bare.kind === 'ok') {
145
+ reachable = true;
146
+ date = bare.date;
147
+ add('hub reachable', 'ok', `${cfg.hub}/api/health`, 'hub');
148
+ if (hasPass)
149
+ add('front door', 'info', 'a front-door pass is stored, but the hub does not ask for one', 'front door');
150
+ }
151
+ else {
152
+ const t = probeText(bare, host);
153
+ add('hub reachable', 'fail', t.detail, 'hub', t.fix);
154
+ }
155
+ if (date) {
156
+ const skew = Date.parse(date) - Date.now();
157
+ if (Number.isFinite(skew) && Math.abs(skew) > CLOCK_SKEW_WARN_MS) {
158
+ add('clock', 'warn', `this machine's clock is ${Math.round(Math.abs(skew) / 60_000)} min ${skew > 0 ? 'behind' : 'ahead of'} the hub`, 'hub', 'turn on automatic date and time (login links and tokens are time-limited)');
159
+ }
160
+ else if (Number.isFinite(skew))
161
+ add('clock', 'ok', 'in sync with the hub', 'hub');
162
+ }
163
+ if (key && reachable) {
164
+ const c = new Client({ hub: cfg.hub, token: key, cfId: cfg.cfId, cfSecret: cfg.cfSecret, fetch: o.fetch });
165
+ try {
166
+ const me = await c.me();
167
+ runtime = typeof me.runtime === 'string' ? me.runtime : undefined;
168
+ agentName = me.name;
169
+ add('hub login', 'ok', `agent ${me.name}${me.owner_name ? ` (owner ${me.owner_name})` : ''}, runtime ${me.runtime ?? '?'}`, 'key');
170
+ }
171
+ catch (e) {
172
+ const st = e?.status;
173
+ const why = st === 401 ? (e?.code === 'owner_deactivated' ? "the agent's owner is deactivated" : 'the key was replaced or the agent was turned off') : `${st ?? ''} ${e?.message ?? e}`.trim();
174
+ add('hub login', 'fail', `the hub refused the key: ${why}`, 'key', NEW_LINE_FIX);
175
+ }
176
+ }
177
+ }
178
+ // Which `ach` runs
179
+ const self = o.self ?? thisCliPath();
180
+ const achs = whichAll('ach', env).map((p) => identifyAch(p, self));
181
+ if (!achs.length) {
182
+ add('ach on PATH', 'warn', 'no `ach` command on PATH', 'machine', `npm install -g ${npmSpec()} (needed only to type \`ach\` yourself or for the background helper: the join line with --service does it)`);
183
+ }
184
+ else {
185
+ const desc = (a) => `${a.path}${a.real && a.real !== a.path ? ` -> ${a.real}` : ''} (${a.ours ? 'this CLI' : `${a.name ?? 'unknown'} ${a.version ?? ''}`.trim()})`;
186
+ const first = achs[0];
187
+ if (isOurs(first))
188
+ add('ach on PATH', 'ok', desc(first));
189
+ else {
190
+ add('ach on PATH', 'fail', `${desc(first)} is another program called \`ach\` and comes first on PATH, so \`ach ...\` runs it. MCP entries made by \`ach join\` use this CLI's absolute path meanwhile.`, 'machine', `remove it or put this CLI first on PATH (or use npx -y ${npmSpec()} …); see below`);
191
+ add('fix', 'info', shadowFix(first, achs.find((a) => isOurs(a)), self));
192
+ }
193
+ for (const a of achs.slice(1))
194
+ add('ach (shadowed)', isOurs(a) ? 'info' : 'warn', desc(a));
195
+ }
196
+ if (/[\\/]_npx[\\/]/.test(self)) {
197
+ add('install', 'warn', 'this `ach` runs from the npx cache, which npm may clear', 'machine', `npm install -g ${npmSpec()}`);
198
+ }
199
+ // Background runner (ach run --install-service): is a unit file there?
200
+ if (agentName) {
201
+ try {
202
+ const { serviceFile } = await import('./service.js');
203
+ const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
204
+ if (platform === 'darwin' || platform === 'linux') {
205
+ const f = serviceFile({ platform, agentName, node: '', cli: '', args: [], env: {}, logFile: '' }, env.ACH_SERVICE_DIR || undefined);
206
+ add('background', 'info', existsSync(f.path) ? `tasks are picked up in the background (${f.path})` : 'no background runner on this machine (optional: ach run --dir <repo> --install-service --interactive-first)');
207
+ }
208
+ }
209
+ catch {
210
+ /* informational only */
211
+ }
212
+ }
213
+ // Claude / Codex login (their own status commands; credential files are never read)
214
+ const claude = userBin('claude', env);
215
+ if (!claude)
216
+ add('claude', 'info', 'not on PATH (needed only for the claude runtime or Claude Code)');
217
+ else {
218
+ const r = await exec(claude, ['auth', 'status'], env);
219
+ let detail = firstLine(r.stdout || r.stderr);
220
+ let ok = r.code === 0;
221
+ try {
222
+ const j = JSON.parse(r.stdout);
223
+ ok = ok && j.loggedIn === true;
224
+ detail = j.loggedIn ? `logged in (${j.authMethod ?? 'unknown method'})` : 'not logged in: run `claude auth login`';
225
+ }
226
+ catch {
227
+ /* text output */
228
+ }
229
+ // round 2: only the agent's own runtime login matters; the other is informational
230
+ add('claude login', ok ? 'ok' : runtime && runtime !== 'claude' ? 'info' : 'warn', detail || `exit ${r.code}`);
231
+ await mcpCheck('claude', claude);
232
+ }
233
+ // a codex that npx put on PATH (this package's SDK dependency) is not the user's Codex
234
+ const codexPath = userBin('codex', env);
235
+ const bundled = codexPath ? undefined : bundledCodex();
236
+ if (!codexPath && !bundled)
237
+ add('codex', 'info', 'not found (needed only for the codex runtime)');
238
+ else {
239
+ const r = codexPath ? await exec(codexPath, ['login', 'status'], env) : await exec(process.execPath, [bundled, 'login', 'status'], env);
240
+ const detail = firstLine(r.stdout || r.stderr) || `exit ${r.code}`;
241
+ add('codex login', r.code === 0 ? 'ok' : runtime && runtime !== 'codex' ? 'info' : 'warn', `${detail}${codexPath ? '' : ' (bundled codex)'}`);
242
+ if (codexPath)
243
+ await mcpCheck('codex', codexPath);
244
+ }
245
+ return out;
246
+ /**
247
+ * The `ach` MCP entry as the runtime itself reports it (`<runtime> mcp get
248
+ * ach`): what command it runs and whether that is this CLI. Never assumed from
249
+ * what join did.
250
+ */
251
+ async function mcpCheck(name, bin) {
252
+ const mine = !runtime || runtime === name;
253
+ const r = await exec(bin, ['mcp', 'get', 'ach'], env);
254
+ const fix = `run the join line again on this machine: it adds it to ${name === 'claude' ? 'Claude Code' : 'Codex'}`;
255
+ if (r.code !== 0) {
256
+ add(`${name} mcp`, mine ? 'warn' : 'info', `no \`ach\` MCP entry (\`${name} mcp get ach\` exit ${r.code})`, 'machine', fix);
257
+ }
258
+ else {
259
+ const e = parseMcpEntry(r.stdout, 'ach');
260
+ if (!e.command)
261
+ add(`${name} mcp`, 'warn', `an \`ach\` entry exists but \`${name} mcp get ach\` did not show its command: ${firstLine(r.stdout) || '(no output)'}`, 'machine', fix);
262
+ else {
263
+ const v = entryVersion(e, env, o.self ?? thisCliPath());
264
+ const shown = `runs \`${[e.command, ...e.args].join(' ')}\`${e.home ? ` with AGENTCHANNEL_HOME=${e.home}` : ''}`;
265
+ if (!v.ok)
266
+ add(`${name} mcp`, 'warn', `\`ach\` entry ${shown}: ${v.why}`, 'machine', fix);
267
+ else if (e.home && !e.home.includes('*') && e.home !== achHome(env))
268
+ add(`${name} mcp`, 'warn', `\`ach\` entry ${shown}, but this machine's AgentChannel home is ${achHome(env)}`, 'machine', fix);
269
+ else
270
+ add(`${name} mcp`, 'ok', `\`ach\` entry ${shown} (${v.why})`);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ /**
276
+ * Command, args and AGENTCHANNEL_HOME of an MCP entry from `claude mcp get`
277
+ * ("Command: npx" / "Args: -y @koalaauto/agentchannel@3 mcp" / "AGENTCHANNEL_HOME=...")
278
+ * or `codex mcp get` ("command: npx" / "args: ..." / "env: AGENTCHANNEL_HOME=..."),
279
+ * or a `name: command args` line (mcp list style).
280
+ */
281
+ export function parseMcpEntry(text, name) {
282
+ const cmd = /^\s*command:\s*(.+?)\s*$/im.exec(text)?.[1];
283
+ const args = /^\s*args:\s*(.*?)\s*$/im.exec(text)?.[1];
284
+ const home = /AGENTCHANNEL_HOME=([^\s,}]+)/.exec(text)?.[1];
285
+ if (cmd)
286
+ return { command: cmd, args: args && args !== '-' && args !== '[]' ? args.replace(/^\[|\]$/g, '').split(/[\s,]+/).map((x) => x.replace(/^"|"$/g, '')).filter(Boolean) : [], home };
287
+ const line = new RegExp(`^\\s*${name}:\\s+(\\S.*?)(?:\\s+-\\s+[✓✗!].*)?$`, 'm').exec(text)?.[1];
288
+ if (!line)
289
+ return { args: [], home };
290
+ const parts = line.split(/\s+/);
291
+ return { command: parts[0], args: parts.slice(1), home };
292
+ }
293
+ /** Whether an MCP entry starts this CLI's package, and why (or why not). */
294
+ function entryVersion(e, env, self) {
295
+ const all = [e.command, ...e.args];
296
+ if (all.at(-1) !== 'mcp')
297
+ return { ok: false, why: 'it does not end with `mcp`' };
298
+ if (/(^|[\\/])npx$/.test(e.command)) {
299
+ const spec = all.find((a) => a === PKG_NAME || a.startsWith(`${PKG_NAME}@`));
300
+ return spec ? { ok: true, why: `through npx (${spec})` } : { ok: false, why: `npx does not start ${PKG_NAME}` };
301
+ }
302
+ // node <path>/cli.js mcp, or an ach executable
303
+ const target = /(^|[\\/])node(\.exe)?$/.test(e.command) ? e.args[0] : e.command;
304
+ if (!target)
305
+ return { ok: false, why: 'no CLI path' };
306
+ const path = target.includes('/') || target.includes('\\') ? target : whichAll(target, env)[0];
307
+ if (!path)
308
+ return { ok: false, why: `\`${target}\` is not on PATH` };
309
+ if (!existsSync(path))
310
+ return { ok: false, why: `${path} does not exist` };
311
+ const i = identifyAch(path, self);
312
+ return isOurs(i) ? { ok: true, why: i.ours ? 'this CLI' : `${PKG_NAME} ${i.version}` } : { ok: false, why: `${path} is ${i.name ?? 'not AgentChannel'} ${i.version ?? ''}`.trim() + `, not ${PKG_NAME}` };
313
+ }
314
+ /** The package directory of a CLI path (…/<pkg>/dist/src/cli.js -> …/<pkg>). */
315
+ function packageDirOf(cli) {
316
+ let d = dirname(cli);
317
+ for (let i = 0; i < 6; i++, d = dirname(d)) {
318
+ if (existsSync(join(d, 'package.json')))
319
+ return d;
320
+ if (dirname(d) === d)
321
+ break;
322
+ }
323
+ return dirname(dirname(dirname(cli)));
324
+ }
325
+ function shellQuote(s) {
326
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
327
+ }
328
+ /**
329
+ * The commands that make this CLI the `ach` on PATH when another program
330
+ * called `ach` comes first: remove that link, or put this CLI's directory
331
+ * first (or install this CLI when it is not on PATH at all).
332
+ */
333
+ export function shadowFix(other, ours, self) {
334
+ const lines = ['to make this CLI the `ach` on PATH, run:'];
335
+ lines.push(` # ${other.path}${other.real && other.real !== other.path ? ` -> ${other.real}` : ''} is another \`ach\`: remove it, or put this CLI first:`);
336
+ lines.push(ours
337
+ ? ` export PATH=${shellQuote(dirname(ours.path))}:"$PATH" # add this line to ~/.zshrc or ~/.bashrc`
338
+ : ` npm install -g ${shellQuote(packageDirOf(self))} # installs this CLI as \`ach\``);
339
+ lines.push(' hash -r');
340
+ lines.push(' ach doctor # should now show this CLI first');
341
+ return lines.join('\n');
342
+ }
343
+ function shortWhere(u) {
344
+ try {
345
+ return new URL(u).host;
346
+ }
347
+ catch {
348
+ return u;
349
+ }
350
+ }
351
+ const SECTIONS = [
352
+ { id: 'hub', title: 'Hub (reachable over HTTPS)' },
353
+ { id: 'front door', title: 'Front door' },
354
+ { id: 'key', title: 'Key' },
355
+ { id: 'machine', title: 'This machine' },
356
+ ];
357
+ /** Grouped report: hub, front door (only when there is one), key, this machine; each problem with a one-line fix. */
358
+ export function formatDoctor(checks) {
359
+ const mark = { ok: '[ok] ', warn: '[warn]', fail: '[FAIL]', info: '[info]' };
360
+ const out = [];
361
+ for (const s of SECTIONS) {
362
+ const list = checks.filter((c) => (c.section ?? 'machine') === s.id);
363
+ if (!list.length)
364
+ continue;
365
+ if (out.length)
366
+ out.push('');
367
+ out.push(s.title);
368
+ for (const c of list) {
369
+ out.push(`${mark[c.status]} ${c.name}: ${c.detail}`);
370
+ if (c.fix)
371
+ out.push(` fix: ${c.fix}`);
372
+ }
373
+ }
374
+ return out.join('\n');
375
+ }
@@ -0,0 +1,31 @@
1
+ /** Env vars that make a spawned Claude Code think it is nested inside another session. */
2
+ export declare const STRIPPED_ENV: readonly ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SSE_PORT", "CLAUDE_CODE_EXECPATH"];
3
+ /**
4
+ * The only parent env vars a spawned agent inherits (S4). Everything else
5
+ * (cloud credentials, tokens, the runner's own ACH_KEY, Claude Code messaging
6
+ * sockets) is dropped unless the runner names it with `--pass-env NAME`.
7
+ */
8
+ export declare const ENV_ALLOWLIST: readonly ["PATH", "HOME", "LANG", "TERM", "SHELL", "USER", "TMPDIR"];
9
+ /**
10
+ * Never passed, even with --pass-env: runner credentials and the Claude Code
11
+ * nesting / messaging / socket / host-auth vars of the parent session.
12
+ */
13
+ export declare function neverPass(name: string): boolean;
14
+ export interface AgentEnvInput {
15
+ hub: string;
16
+ runToken: string;
17
+ taskId: string;
18
+ agentName: string;
19
+ /** §16 mention runs: the thread (root post id) the agent replies in. */
20
+ threadId?: string;
21
+ /** Work runs: the handoff version the worker was shown; `ach complete` sends it so the hub can refuse an outdated finish. */
22
+ handoffVersion?: number;
23
+ /** §19: base URL for links in agent output (ACH_PUBLIC_URL). */
24
+ publicUrl?: string;
25
+ cfId?: string;
26
+ cfSecret?: string;
27
+ /** Extra parent env var names to pass through (`ach run --pass-env NAME`). */
28
+ passEnv?: string[];
29
+ }
30
+ /** Env for a spawned agent: allowlisted parent vars + --pass-env names + task-scoped ACH vars. */
31
+ export declare function buildAgentEnv(base: NodeJS.ProcessEnv | Record<string, string | undefined>, a: AgentEnvInput): Record<string, string>;
@@ -0,0 +1,52 @@
1
+ /** Env vars that make a spawned Claude Code think it is nested inside another session. */
2
+ export const STRIPPED_ENV = [
3
+ 'CLAUDECODE',
4
+ 'CLAUDE_CODE_SESSION_ID',
5
+ 'CLAUDE_CODE_ENTRYPOINT',
6
+ 'CLAUDE_CODE_SSE_PORT',
7
+ 'CLAUDE_CODE_EXECPATH',
8
+ ];
9
+ /**
10
+ * The only parent env vars a spawned agent inherits (S4). Everything else
11
+ * (cloud credentials, tokens, the runner's own ACH_KEY, Claude Code messaging
12
+ * sockets) is dropped unless the runner names it with `--pass-env NAME`.
13
+ */
14
+ export const ENV_ALLOWLIST = ['PATH', 'HOME', 'LANG', 'TERM', 'SHELL', 'USER', 'TMPDIR'];
15
+ /**
16
+ * Never passed, even with --pass-env: runner credentials and the Claude Code
17
+ * nesting / messaging / socket / host-auth vars of the parent session.
18
+ */
19
+ export function neverPass(name) {
20
+ if (STRIPPED_ENV.includes(name))
21
+ return true;
22
+ if (/^ACH_/.test(name))
23
+ return true; // the ACH_* run vars are set explicitly
24
+ if (name === 'AGENTCHANNEL_HOME')
25
+ return true;
26
+ return /^CLAUDE_CODE_.*(SESSION|ENTRYPOINT|EXECPATH|SSE|PORT|SOCK|IPC|MESSAG|HOST|OAUTH|TOKEN|AUTH|BRIDGE|REMOTE)/.test(name);
27
+ }
28
+ /** Env for a spawned agent: allowlisted parent vars + --pass-env names + task-scoped ACH vars. */
29
+ export function buildAgentEnv(base, a) {
30
+ const env = {};
31
+ for (const k of [...ENV_ALLOWLIST, ...(a.passEnv ?? [])]) {
32
+ const v = base[k];
33
+ if (v === undefined || neverPass(k))
34
+ continue;
35
+ env[k] = v;
36
+ }
37
+ env.ACH_HUB = a.hub;
38
+ env.ACH_RUN_TOKEN = a.runToken;
39
+ env.ACH_TASK_ID = a.taskId;
40
+ env.ACH_AGENT = a.agentName;
41
+ if (a.threadId)
42
+ env.ACH_THREAD_ID = a.threadId;
43
+ if (a.handoffVersion)
44
+ env.ACH_HANDOFF_VERSION = String(a.handoffVersion);
45
+ if (a.publicUrl)
46
+ env.ACH_PUBLIC_URL = a.publicUrl;
47
+ if (a.cfId)
48
+ env.ACH_CF_ID = a.cfId;
49
+ if (a.cfSecret)
50
+ env.ACH_CF_SECRET = a.cfSecret;
51
+ return env;
52
+ }