@koalaauto/agentchannel 3.10.0 → 3.12.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 (42) hide show
  1. package/README.md +5 -1
  2. package/dist/src/adapters/claude.d.ts +19 -0
  3. package/dist/src/adapters/claude.js +52 -1
  4. package/dist/src/adapters/codex.d.ts +11 -0
  5. package/dist/src/adapters/codex.js +34 -2
  6. package/dist/src/adapters/fake.d.ts +10 -0
  7. package/dist/src/adapters/fake.js +9 -0
  8. package/dist/src/adapters/paths.d.ts +9 -1
  9. package/dist/src/adapters/paths.js +28 -3
  10. package/dist/src/adapters/types.d.ts +3 -2
  11. package/dist/src/args.d.ts +1 -1
  12. package/dist/src/args.js +18 -9
  13. package/dist/src/cli.js +81 -17
  14. package/dist/src/client.d.ts +48 -4
  15. package/dist/src/client.js +41 -5
  16. package/dist/src/desknotify.d.ts +2 -2
  17. package/dist/src/desknotify.js +2 -2
  18. package/dist/src/doctor.d.ts +4 -0
  19. package/dist/src/doctor.js +12 -0
  20. package/dist/src/envcheck.d.ts +22 -0
  21. package/dist/src/envcheck.js +98 -0
  22. package/dist/src/mcp.d.ts +1 -1
  23. package/dist/src/mcp.js +24 -12
  24. package/dist/src/names.d.ts +6 -1
  25. package/dist/src/names.js +15 -2
  26. package/dist/src/onboard.d.ts +11 -2
  27. package/dist/src/onboard.js +54 -2
  28. package/dist/src/prompts.d.ts +3 -3
  29. package/dist/src/prompts.js +4 -4
  30. package/dist/src/redact.d.ts +5 -0
  31. package/dist/src/redact.js +41 -0
  32. package/dist/src/runner/runner.d.ts +32 -1
  33. package/dist/src/runner/runner.js +214 -20
  34. package/dist/src/sandboxcheck.d.ts +97 -0
  35. package/dist/src/sandboxcheck.js +236 -0
  36. package/dist/src/service.d.ts +9 -3
  37. package/dist/src/service.js +39 -1
  38. package/dist/src/trace.d.ts +51 -0
  39. package/dist/src/trace.js +137 -0
  40. package/dist/src/version.d.ts +1 -1
  41. package/dist/src/version.js +1 -1
  42. package/package.json +1 -1
package/dist/src/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from 'node:child_process';
2
+ import { execFileSync, execSync, spawn } from 'node:child_process';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
5
5
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { HELP, parseCli, UsageError } from './args.js';
8
8
  import { channelRef, Client, clientFromConfig, HubError, isZh, parseLinkRepo } from './client.js';
9
- import { closest, decisionTo, explainToUser, matchChannel, mentionNames, unknownMentions } from './names.js';
9
+ import { channelTails, closest, decisionTo, explainToUser, matchChannel, mentionNames, unknownMentions } from './names.js';
10
10
  import { refOf, untrustedBlock as wrapUntrusted } from './prompts.js';
11
11
  import { achHome, loadConfig, readFileConfig, resolveClaimDelay, saveAdminToken, saveAgentKey, saveCfSecret, writeFileConfig } from './config.js';
12
12
  import { achCommand, agentInvitePrompt, decodeInvite, encodeInvite, JOIN_HINT, joinLine, mcpCommands, hubTransportOk, shadowHint } from './invite.js';
@@ -415,7 +415,7 @@ async function dispatch(p, io) {
415
415
  : `joined ${inv.hub} as agent ${me.name}${me.owner_name ? ` (owner ${me.owner_name})` : ''}; key stored in the ${where === 'keychain' ? 'OS keychain' : 'agent-key file (0600)'}.`);
416
416
  const { runSetup, doneLine } = await import('./onboard.js');
417
417
  const { defaultExec } = await import('./doctor.js');
418
- const r = await runSetup({ env, out: io.out, err: io.err, exec: io.exec ?? defaultExec, ask: io.ask, cwd: io.cwd ?? process.cwd() }, { yes: !!v.yes, service: !!v.service, runtime: typeof me.runtime === 'string' ? me.runtime : undefined, zh });
418
+ const r = await runSetup({ env, out: io.out, err: io.err, exec: io.exec ?? defaultExec, ask: io.ask, cwd: io.cwd ?? process.cwd(), ...(io.ask ? { runTty: (line) => runInTerminal(line, env) } : {}) }, { yes: !!v.yes, service: !!v.service, runtime: typeof me.runtime === 'string' ? me.runtime : undefined, zh });
419
419
  out(r.failed.length ? (zh ? `部分步骤失败(${r.failed.join(', ')}):运行 ach doctor 查看怎么修。` : `Some steps failed (${r.failed.join(', ')}): run ach doctor to see how to fix them.`) : r.tools.length ? doneLine(r.tools, zh) : zh ? '密钥已保存,但还没有接入任何工具(见上面)。' : 'The key is stored, but no tool is connected yet (see above).');
420
420
  return r.failed.length ? 1 : 0;
421
421
  }
@@ -489,6 +489,7 @@ async function dispatch(p, io) {
489
489
  handoff,
490
490
  ...(str(v['key-question']) ? { key_question: str(v['key-question']) } : {}),
491
491
  ...(Array.isArray(v.depends) && v.depends.length ? { depends_on: v.depends } : {}),
492
+ ...(v.review ? { review: true } : {}),
492
493
  ...(links ? { links } : {}),
493
494
  ...(str(v.channel) ? { channel: channelRef(str(v.channel)) } : {}),
494
495
  }, files());
@@ -643,7 +644,7 @@ async function dispatch(p, io) {
643
644
  out('no channels');
644
645
  else
645
646
  for (const c of list)
646
- out(`#${c.name}${c.archived ? ' [archived]' : ''} ${c.post_count ?? 0} posts${c.last_post_at ? `, last ${c.last_post_at}` : ''}${c.description ? ` ${c.description}` : ''}`);
647
+ out(`#${c.name}${c.archived ? ' [archived]' : ''}${c.access && c.access !== 'team' ? ` [${c.access}]` : ''} ${c.post_count ?? 0} posts${c.last_post_at ? `, last ${c.last_post_at}` : ''}${c.description ? ` ${c.description}` : ''}`);
647
648
  return 0;
648
649
  }
649
650
  case 'channel': {
@@ -683,6 +684,9 @@ async function dispatch(p, io) {
683
684
  const chans = await c.channels().catch(() => []);
684
685
  const hit = matchChannel(name, chans);
685
686
  if (!hit && chans.length) {
687
+ const many = channelTails(name, chans);
688
+ if (many.length > 1)
689
+ throw new UsageError(`post: "${channelRef(name)}" is more than one channel (${many.map((x) => '#' + x.name).join(', ')}): write the full path.`);
686
690
  const near = closest(channelRef(name), chans.flatMap((x) => [x.name, String(x.title ?? '')]).filter(Boolean));
687
691
  const nearName = near && (chans.find((x) => x.name === near || x.title === near)?.name ?? near);
688
692
  throw new UsageError(`post: no channel "${channelRef(name)}" you can post in${nearName ? ` (did you mean #${nearName}?)` : ''}. List them with \`ach channels\`.`);
@@ -853,23 +857,45 @@ async function dispatch(p, io) {
853
857
  const runId = str(v['run-id']) ?? (env.ACH_RUN_TOKEN ? undefined : await implicitRunId(c, id, true));
854
858
  // A spawned worker says which handoff it worked from; the hub refuses an outdated one.
855
859
  const shown = Number(env.ACH_HANDOFF_VERSION) || undefined;
856
- const r = await c.complete(id, result, runId, att, shown);
860
+ const r = await c.complete(id, result, runId, att, shown, str(v['owner-action']));
857
861
  out(v.json ? r : 'completed');
858
862
  return 0;
859
863
  }
864
+ case 'accept': {
865
+ // the sender's side: a task sent with --review waits for this (or request-changes) when its worker is done
866
+ // `ach accept TASK-12 [note]` or `ach accept --task TASK-12 [note]`
867
+ const id = str(v.task) ?? p.positionals[0];
868
+ if (!id)
869
+ throw new UsageError('accept: <task> is required (TASK-<n>)');
870
+ const note = (str(v.task) ? p.positionals : p.positionals.slice(1)).join(' ').trim();
871
+ const r = await client().acceptTask(id, note || undefined);
872
+ out(v.json ? r : `accepted ${id}`);
873
+ return 0;
874
+ }
875
+ case 'request-changes': {
876
+ const id = str(v.task) ?? p.positionals[0];
877
+ if (!id)
878
+ throw new UsageError('request-changes: <task> is required (TASK-<n>)');
879
+ const text = (str(v.task) ? p.positionals : p.positionals.slice(1)).join(' ').trim();
880
+ if (!text)
881
+ throw new UsageError('request-changes: <task> "<what to change>" is required');
882
+ const r = await client().requestChanges(id, text);
883
+ out(v.json ? r : `sent ${id} back to its worker`);
884
+ return 0;
885
+ }
860
886
  case 'fail': {
861
887
  const c = client();
862
888
  const id = taskIdOf(p, io);
863
889
  const reason = text(p, '<reason>');
864
890
  const runId = str(v['run-id']) ?? (env.ACH_RUN_TOKEN ? undefined : await implicitRunId(c, id, false));
865
- const r = await c.fail(id, reason, runId);
891
+ const r = await c.fail(id, reason, runId, str(v['owner-action']));
866
892
  out(v.json ? r : 'failed');
867
893
  return 0;
868
894
  }
869
895
  case 'claim': {
870
896
  const id = p.positionals[0] ?? str(v.task);
871
897
  if (!id)
872
- throw new UsageError('claim: <task> is required (id or T-<n>)');
898
+ throw new UsageError('claim: <task> is required (id or TASK-<n>)');
873
899
  const c = client();
874
900
  const me = await c.me();
875
901
  const b = await c.claim(id, newInteractiveRunId(), 'interactive');
@@ -894,7 +920,7 @@ async function dispatch(p, io) {
894
920
  case 'remember': {
895
921
  const source = str(v.source);
896
922
  if (!source)
897
- throw new UsageError('remember: --source <link> is required (a hub permalink like #/t/T-12 or a GitHub URL)');
923
+ throw new UsageError('remember: --source <link> is required (a hub permalink like #/t/TASK-12 or a GitHub URL)');
898
924
  const r = await client().remember(text(p, '"<fact or lesson>"'), source, { channel: str(v.channel), topic: str(v.topic), toTeam: !!v['to-team'] });
899
925
  if (v.json)
900
926
  out(r);
@@ -1318,12 +1344,12 @@ async function dispatch(p, io) {
1318
1344
  out(list);
1319
1345
  else
1320
1346
  for (const c of list)
1321
- out(`#${c.name}${c.title && c.title !== c.name ? ` ${c.title}` : ''}${c.archived ? ' [archived]' : ''}${c.visibility === 'private' ? ` [private, ${c.member_count ?? 0} members]` : ''} ${c.post_count ?? 0} posts`);
1347
+ out(`#${c.name}${c.title && c.title !== c.name ? ` ${c.title}` : ''}${c.archived ? ' [archived]' : ''}${c.visibility === 'private' ? ` [${c.access && c.access !== 'private' ? c.access : 'private'}, ${c.member_count ?? 0} members]` : ''} ${c.post_count ?? 0} posts`);
1322
1348
  return 0;
1323
1349
  }
1324
1350
  if (sub === 'create') {
1325
1351
  const title = str(v.title) ?? rest.join(' ').trim();
1326
- const r = await a.request('POST', '/api/ach/channels', { ...(str(v.name) ? { name: str(v.name) } : {}), ...(title ? { title } : {}), ...(str(v.description) ? { description: str(v.description) } : {}) });
1352
+ const r = await a.request('POST', '/api/ach/channels', { ...(str(v.name) ? { name: str(v.name) } : {}), ...(str(v.folder) ? { folder: str(v.folder) } : {}), ...(title ? { title } : {}), ...(str(v.description) ? { description: str(v.description) } : {}) });
1327
1353
  out(v.json ? r : `created #${r.name}`);
1328
1354
  return 0;
1329
1355
  }
@@ -1338,10 +1364,18 @@ async function dispatch(p, io) {
1338
1364
  // private: only members (and admins) see the channel, its posts, wiki pages and files
1339
1365
  if (!rest[0])
1340
1366
  throw new UsageError(`admin channels ${sub}: <#name> is required`);
1341
- const r = await a.request('PATCH', `/api/ach/channels/${encodeURIComponent(channelRef(rest[0]))}`, { visibility: sub });
1367
+ const r = await a.request('PATCH', `/api/ach/channels/${encodeURIComponent(channelRef(rest[0]))}`, { visibility: sub, ...(v.cascade ? { cascade: true } : {}) });
1342
1368
  out(v.json ? r : `#${r.name}: ${r.visibility}`);
1343
1369
  return 0;
1344
1370
  }
1371
+ if (sub === 'move' || sub === 'rename') {
1372
+ // a new path: what is under it goes along, and the old path keeps finding it
1373
+ if (!rest[0] || !rest[1])
1374
+ throw new UsageError(`admin channels ${sub}: <#old-path> <#new-path> is required (e.g. onair-mmgr reela/mmgr)`);
1375
+ const r = await a.request('PATCH', `/api/ach/channels/${encodeURIComponent(channelRef(rest[0]))}`, { name: channelRef(rest[1]) });
1376
+ out(v.json ? r : `#${r.name} (the old path #${channelRef(rest[0])} still finds it)`);
1377
+ return 0;
1378
+ }
1345
1379
  if (sub === 'members') {
1346
1380
  if (!rest[0])
1347
1381
  throw new UsageError('admin channels members: <#name> [--add a,b] [--remove c] is required');
@@ -1356,7 +1390,7 @@ async function dispatch(p, io) {
1356
1390
  out(v.json ? r : `#${r?.name}: ${r?.visibility ?? 'team'}, ${r?.member_count ?? 0} member(s)`);
1357
1391
  return 0;
1358
1392
  }
1359
- throw new UsageError('admin channels: list | create [--name n] [--title t] | archive <#name> | unarchive <#name> | private <#name> | team <#name> | members <#name> [--add a,b] [--remove c]');
1393
+ throw new UsageError('admin channels: list | create [--name path] [--folder f] [--title t] | move <#old> <#new-path> | archive <#name> | unarchive <#name> | private <#name> [--cascade] | team <#name> | members <#name> [--add a,b] [--remove c]');
1360
1394
  }
1361
1395
  case 'admin embeds': {
1362
1396
  const a = admin();
@@ -1397,6 +1431,20 @@ async function dispatch(p, io) {
1397
1431
  }
1398
1432
  return 0;
1399
1433
  }
1434
+ case 'admin memory-maintainers': {
1435
+ const a = admin();
1436
+ const names = p.positionals.flatMap(splitList);
1437
+ const r = (names.length ? await a.request('PUT', '/api/ach/admin/memory/settings', { maintainers: { agents: names } }) : await a.request('GET', '/api/ach/admin/memory/settings'));
1438
+ const list = (r.maintainers?.agents ?? []);
1439
+ out(v.json ? r : list.length ? `Memory maintainers: ${list.join(', ')}` : 'no Memory maintainer is set: the admin agent with no setup problem that was seen most recently is used (name one to choose)');
1440
+ return 0;
1441
+ }
1442
+ case 'admin memory-upkeep': {
1443
+ const name = p.positionals[0];
1444
+ const r = (await admin().request('POST', '/api/ach/admin/memory/overview', name ? { channel: channelRef(name) } : {}));
1445
+ out(v.json ? r : r.upkeep_task ? `queued ${r.upkeep_task.ref}${r.upkeep_task.url ? `: ${r.upkeep_task.url}` : ''}` : 'nothing waiting for upkeep');
1446
+ return 0;
1447
+ }
1400
1448
  case 'admin retention': {
1401
1449
  const name = p.positionals[0];
1402
1450
  if (!name)
@@ -1485,6 +1533,14 @@ function linkOf(env, t) {
1485
1533
  function splitList(v) {
1486
1534
  return v.split(',').map((x) => x.trim()).filter(Boolean);
1487
1535
  }
1536
+ /** A shell line with this terminal's stdin/stdout/stderr (sudo asks for its password there); its exit code. */
1537
+ function runInTerminal(line, env) {
1538
+ return new Promise((res) => {
1539
+ const c = spawn('/bin/sh', ['-c', line], { env, stdio: 'inherit' });
1540
+ c.on('error', () => res(127));
1541
+ c.on('exit', (code) => res(code ?? 1));
1542
+ });
1543
+ }
1488
1544
  function shq(s) {
1489
1545
  return `'${s.replace(/'/g, `'\\''`)}'`;
1490
1546
  }
@@ -1511,7 +1567,7 @@ async function runCommand(p, io) {
1511
1567
  const cfg = loadConfig(env);
1512
1568
  if (!cfg.key)
1513
1569
  throw new UsageError('run: no agent key. Run `ach login` or set ACH_KEY.');
1514
- const c = clientFromConfig(cfg, { fetch: io.fetch });
1570
+ const c = clientFromConfig(cfg, { fetch: io.fetch, role: 'runner' });
1515
1571
  const home = achHome(env);
1516
1572
  const dir = resolve(io.cwd ?? process.cwd(), str(v.dir) ?? '.');
1517
1573
  const { GitWorkspace, parseRepoFlags } = await import('./runner/workspace.js');
@@ -1568,6 +1624,7 @@ async function runCommand(p, io) {
1568
1624
  claimDelayMs: claimDelay * 1000,
1569
1625
  cfId: cfg.cfId,
1570
1626
  cfSecret: cfg.cfSecret,
1627
+ secrets: [cfg.key ?? ''].filter(Boolean),
1571
1628
  publicUrl: env.ACH_PUBLIC_URL || undefined,
1572
1629
  onClientTooOld: () => void updater?.nudge(),
1573
1630
  passEnv: (Array.isArray(v['pass-env']) ? v['pass-env'] : []).flatMap(splitList),
@@ -1576,6 +1633,10 @@ async function runCommand(p, io) {
1576
1633
  return shim ? { ...env, PATH: `${shim}${delimiter}${env.PATH ?? ''}` } : env;
1577
1634
  })(),
1578
1635
  log: io.err,
1636
+ sandboxCheck: async () => {
1637
+ const { probeEnvironment } = await import('./envcheck.js');
1638
+ return probeEnvironment(runtime === 'codex' ? 'codex' : 'claude', { env });
1639
+ },
1579
1640
  });
1580
1641
  io.err(`ach run: agent ${me.name}, runtime ${runtime}, perm min(${me.perm_level}, ${perm}), dir ${dir}` +
1581
1642
  (Object.keys(repos).length ? `, repos ${Object.entries(repos).map(([k, x]) => `${k}=${x}`).join(' ')}` : '') +
@@ -1666,8 +1727,8 @@ async function installService(p, io, o) {
1666
1727
  const { isBundledBin, npmSpec, whichAll } = await import('./invite.js');
1667
1728
  const env = io.env;
1668
1729
  const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
1669
- if (platform !== 'darwin' && platform !== 'linux')
1670
- throw new UsageError(`run --install-service: unsupported platform ${platform} (macOS launchd or Linux systemd only)`);
1730
+ if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32')
1731
+ throw new UsageError(`run --install-service: unsupported platform ${platform} (macOS, Linux and Windows only)`);
1671
1732
  const v = p.values;
1672
1733
  const args = ['--dir', o.dir];
1673
1734
  for (const [name, path] of Object.entries(o.repos))
@@ -1704,14 +1765,17 @@ async function installService(p, io, o) {
1704
1765
  args,
1705
1766
  env: svcEnv,
1706
1767
  logFile: join(o.home, 'logs', 'runner.log'),
1707
- }, env.ACH_SERVICE_DIR || undefined);
1768
+ }, env.ACH_SERVICE_DIR || undefined, env);
1708
1769
  writeServiceFile(f);
1709
1770
  const load = !env.ACH_SERVICE_DIR;
1710
1771
  let failed = '';
1711
1772
  if (load) {
1712
1773
  for (const c of f.start) {
1713
1774
  try {
1714
- execFileSync('/bin/sh', ['-c', c], { env, stdio: ['ignore', 'ignore', 'pipe'] });
1775
+ if (platform === 'win32')
1776
+ execSync(c, { env, stdio: ['ignore', 'ignore', 'pipe'] }); // cmd.exe
1777
+ else
1778
+ execFileSync('/bin/sh', ['-c', c], { env, stdio: ['ignore', 'ignore', 'pipe'] });
1715
1779
  }
1716
1780
  catch (e) {
1717
1781
  failed = `${c}: ${String(e?.stderr ?? e?.message ?? e).trim().split('\n')[0]}`;
@@ -1,6 +1,6 @@
1
1
  import type { Config } from './config.js';
2
2
  import { type Attachment, type StorageConfig, type TaskFile } from './attachments.js';
3
- export type TaskStatus = 'submitted' | 'working' | 'input-required' | 'completed' | 'failed' | 'canceled';
3
+ export type TaskStatus = 'submitted' | 'working' | 'input-required' | 'awaiting_acceptance' | 'completed' | 'failed' | 'canceled';
4
4
  export declare const TERMINAL: readonly TaskStatus[];
5
5
  export declare function isTerminal(s: string | undefined): boolean;
6
6
  export type MessageKind = 'question' | 'answer' | 'decision_request' | 'decision' | 'progress' | 'result' | 'note';
@@ -43,7 +43,7 @@ export interface Task {
43
43
  result?: string;
44
44
  created?: string;
45
45
  updated?: string;
46
- /** §19: human-friendly number ("T-128" = ref) and permalink. */
46
+ /** §19: human-friendly number ("TASK-128" = ref) and permalink. */
47
47
  seq?: number;
48
48
  ref?: string;
49
49
  url?: string;
@@ -142,6 +142,8 @@ export interface ClaimResult extends TaskBundle {
142
142
  export type ClaimMode = 'runner' | 'interactive';
143
143
  export interface Inbox {
144
144
  tasks_to_me: Task[];
145
+ /** Tasks that said done and wait for acceptance, which this agent may accept or send back (accept_task / request_changes). */
146
+ tasks_to_review?: Task[];
145
147
  questions_to_me: Message[];
146
148
  answers_to_me: Message[];
147
149
  decisions_for_me: Message[];
@@ -175,7 +177,15 @@ export interface Post {
175
177
  }
176
178
  export interface Channel {
177
179
  id: string;
180
+ /** The path: `reela/onair` (up to 3 levels). */
178
181
  name: string;
182
+ /** The folder it sits in ('' at the top). */
183
+ parent?: string;
184
+ depth?: number;
185
+ /** team | private | inherited (follows its parent's people) | narrowed (a subset of them). */
186
+ access?: 'team' | 'private' | 'inherited' | 'narrowed';
187
+ /** Paths it had before a rename or move; they still find it. */
188
+ former_names?: string[];
179
189
  description?: string;
180
190
  archived?: boolean;
181
191
  post_count?: number;
@@ -228,6 +238,8 @@ export interface SendTaskInput {
228
238
  key_question?: string;
229
239
  depends_on?: string[];
230
240
  links?: Record<string, unknown>;
241
+ /** The task waits for acceptance when its worker is done (status awaiting_acceptance) until the sender's side accepts it or sends it back. */
242
+ review?: boolean;
231
243
  /** §16: channel name; default = the channel named like links.repo, else #general. */
232
244
  channel?: string;
233
245
  }
@@ -288,6 +300,8 @@ export interface ClientOptions {
288
300
  cfId?: string;
289
301
  cfSecret?: string;
290
302
  fetch?: FetchLike;
303
+ /** `runner`: this is `ach run` polling, not a person's session or an MCP server (the hub notes which agents have a runner). */
304
+ role?: 'runner';
291
305
  }
292
306
  /** Round 2: human-facing CLI text in Chinese when LANG/LC_ALL starts with zh. */
293
307
  export declare function isZh(env: NodeJS.ProcessEnv): boolean;
@@ -295,6 +309,7 @@ export declare function isZh(env: NodeJS.ProcessEnv): boolean;
295
309
  export declare function clientFromConfig(cfg: Config, opts?: {
296
310
  admin?: boolean;
297
311
  fetch?: FetchLike;
312
+ role?: 'runner';
298
313
  }): Client;
299
314
  /** Accept either `{task, handoff, messages, history}` or a bare task record. */
300
315
  export declare function normalizeBundle(raw: any): TaskBundle;
@@ -305,6 +320,7 @@ export declare class Client {
305
320
  private cfSecret?;
306
321
  private fetchFn;
307
322
  constructor(o: ClientOptions);
323
+ private role?;
308
324
  private ownFetch;
309
325
  /**
310
326
  * The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
@@ -338,6 +354,10 @@ export declare class Client {
338
354
  me(): Promise<Agent>;
339
355
  agents(): Promise<Agent[]>;
340
356
  sendTask(input: SendTaskInput, files?: string[]): Promise<Task>;
357
+ /** The sender's side accepts a task that waits for acceptance: it is completed. */
358
+ acceptTask(taskId: string, note?: string): Promise<any>;
359
+ /** The sender's side sends a finished, failed or waiting task back with what to change; the worker redoes it. */
360
+ requestChanges(taskId: string, body: string): Promise<any>;
341
361
  /** Propose a recurring task (runs after the owner approves). */
342
362
  proposeSchedule(input: Record<string, unknown>): Promise<any>;
343
363
  schedules(): Promise<any[]>;
@@ -356,8 +376,9 @@ export declare class Client {
356
376
  }>;
357
377
  postMessage(taskId: string, m: PostMessageInput, files?: string[]): Promise<Message>;
358
378
  /** With an agent key the hub requires the task's current run_id (C3); run tokens carry their own. */
359
- complete(taskId: string, result: string, run_id?: string, files?: string[], handoff_version?: number): Promise<any>;
360
- fail(taskId: string, reason: string, run_id?: string): Promise<any>;
379
+ /** `owner_action`: a step only the owner of the executing agent can do; it reaches them as an Inbox item. */
380
+ complete(taskId: string, result: string, run_id?: string, files?: string[], handoff_version?: number, owner_action?: string): Promise<any>;
381
+ fail(taskId: string, reason: string, run_id?: string, owner_action?: string): Promise<any>;
361
382
  /** §23: a grantee agent answers the part of a request its grant covers. */
362
383
  grantDecide(taskId: string, message_id: string, option: number, grant: string): Promise<any>;
363
384
  createGrant(input: {
@@ -379,6 +400,29 @@ export declare class Client {
379
400
  /** The total size of a work prompt (Memory trial metrics). */
380
401
  promptSize(taskId: string, run_id: string, chars: number, parts?: Record<string, number>): Promise<any>;
381
402
  usage(taskId: string, run_id: string, cost_usd: number, estimated: boolean): Promise<any>;
403
+ /**
404
+ * design §27: what keeps this agent's runner from taking tasks on its machine (null: nothing). The hub shows it as
405
+ * "needs setup" with the fix.
406
+ */
407
+ /**
408
+ * The execution trace of one run (or one stretch of it), already masked and cut down: steps with thinking summaries, commands,
409
+ * exit codes and output excerpts. Idempotent by `traceId`; a second call updates the fields it carries (the commit, say).
410
+ */
411
+ putTrace(taskId: string, traceId: string, body: Record<string, unknown>): Promise<any>;
412
+ /** The traces of a task, oldest first. */
413
+ traces(taskId: string): Promise<any>;
414
+ /** The worker cannot run on its machine (not logged in, sandbox broken): the task goes back to submitted, not failed. */
415
+ release(taskId: string, reason: string, run_id?: string): Promise<any>;
416
+ reportSetup(sandbox: {
417
+ tool: string;
418
+ problem: string;
419
+ detail?: string;
420
+ fix?: {
421
+ en: string;
422
+ zh: string;
423
+ commands: string[];
424
+ };
425
+ } | null): Promise<any>;
382
426
  /** 409 when the run lost the task. Carries a fresh `run_token` when the old one is near expiry (C5). */
383
427
  heartbeat(taskId: string, run_id: string): Promise<{
384
428
  ok: boolean;
@@ -44,7 +44,7 @@ export function clientFromConfig(cfg, opts = {}) {
44
44
  : 'No hub configured: run the `ach join ach2....` line from the panel or your admin (or set ACH_HUB).');
45
45
  }
46
46
  const token = opts.admin ? cfg.adminToken : cfg.runToken || cfg.key;
47
- return new Client({ hub: cfg.hub, token, cfId: cfg.cfId, cfSecret: cfg.cfSecret, fetch: opts.fetch });
47
+ return new Client({ hub: cfg.hub, token, cfId: cfg.cfId, cfSecret: cfg.cfSecret, fetch: opts.fetch, role: opts.role });
48
48
  }
49
49
  function arr(v) {
50
50
  return Array.isArray(v) ? v : [];
@@ -74,7 +74,9 @@ export class Client {
74
74
  this.cfSecret = o.cfSecret;
75
75
  this.fetchFn = o.fetch ?? ((u, i) => fetch(u, i));
76
76
  this.ownFetch = !!o.fetch;
77
+ this.role = o.role;
77
78
  }
79
+ role;
78
80
  ownFetch;
79
81
  /**
80
82
  * The hub's change stream (GET /api/ach/events, a WebSocket that says "something changed", ids only). The token
@@ -95,6 +97,8 @@ export class Client {
95
97
  }
96
98
  headers(json = true) {
97
99
  const h = { ...CLIENT_HEADERS, ...(json ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' }) };
100
+ if (this.role)
101
+ h['x-ach-role'] = this.role;
98
102
  if (this.token) {
99
103
  // Agent keys and run tokens use Bearer; a person's session token is sent as it is.
100
104
  h.authorization = /^ach_(sk|rt)_/.test(this.token) ? `Bearer ${this.token}` : this.token;
@@ -225,6 +229,14 @@ export class Client {
225
229
  const r = await this.ach('POST', '/tasks', input, files);
226
230
  return r?.task ?? r;
227
231
  }
232
+ /** The sender's side accepts a task that waits for acceptance: it is completed. */
233
+ async acceptTask(taskId, note) {
234
+ return this.ach('POST', `/tasks/${enc(taskId)}/accept`, note ? { note } : {});
235
+ }
236
+ /** The sender's side sends a finished, failed or waiting task back with what to change; the worker redoes it. */
237
+ async requestChanges(taskId, body) {
238
+ return this.ach('POST', `/tasks/${enc(taskId)}/request-changes`, { body });
239
+ }
228
240
  /** Propose a recurring task (runs after the owner approves). */
229
241
  async proposeSchedule(input) {
230
242
  return this.ach('POST', '/schedules', input);
@@ -268,11 +280,12 @@ export class Client {
268
280
  return r?.message ?? r;
269
281
  }
270
282
  /** With an agent key the hub requires the task's current run_id (C3); run tokens carry their own. */
271
- async complete(taskId, result, run_id, files, handoff_version) {
272
- return this.ach('POST', `/tasks/${enc(taskId)}/complete`, { result, ...(run_id ? { run_id } : {}), ...(handoff_version ? { handoff_version } : {}) }, files);
283
+ /** `owner_action`: a step only the owner of the executing agent can do; it reaches them as an Inbox item. */
284
+ async complete(taskId, result, run_id, files, handoff_version, owner_action) {
285
+ return this.ach('POST', `/tasks/${enc(taskId)}/complete`, { result, ...(run_id ? { run_id } : {}), ...(handoff_version ? { handoff_version } : {}), ...(owner_action ? { owner_action } : {}) }, files);
273
286
  }
274
- async fail(taskId, reason, run_id) {
275
- return this.ach('POST', `/tasks/${enc(taskId)}/fail`, { reason, ...(run_id ? { run_id } : {}) });
287
+ async fail(taskId, reason, run_id, owner_action) {
288
+ return this.ach('POST', `/tasks/${enc(taskId)}/fail`, { reason, ...(run_id ? { run_id } : {}), ...(owner_action ? { owner_action } : {}) });
276
289
  }
277
290
  /** §23: a grantee agent answers the part of a request its grant covers. */
278
291
  async grantDecide(taskId, message_id, option, grant) {
@@ -297,6 +310,28 @@ export class Client {
297
310
  async usage(taskId, run_id, cost_usd, estimated) {
298
311
  return this.ach('POST', `/tasks/${enc(taskId)}/usage`, { run_id, cost_usd, estimated });
299
312
  }
313
+ /**
314
+ * design §27: what keeps this agent's runner from taking tasks on its machine (null: nothing). The hub shows it as
315
+ * "needs setup" with the fix.
316
+ */
317
+ /**
318
+ * The execution trace of one run (or one stretch of it), already masked and cut down: steps with thinking summaries, commands,
319
+ * exit codes and output excerpts. Idempotent by `traceId`; a second call updates the fields it carries (the commit, say).
320
+ */
321
+ async putTrace(taskId, traceId, body) {
322
+ return this.ach('PUT', `/tasks/${enc(taskId)}/traces/${enc(traceId)}`, body);
323
+ }
324
+ /** The traces of a task, oldest first. */
325
+ async traces(taskId) {
326
+ return this.ach('GET', `/tasks/${enc(taskId)}/traces`);
327
+ }
328
+ /** The worker cannot run on its machine (not logged in, sandbox broken): the task goes back to submitted, not failed. */
329
+ async release(taskId, reason, run_id) {
330
+ return this.ach('POST', `/tasks/${enc(taskId)}/release`, { reason, ...(run_id ? { run_id } : {}) });
331
+ }
332
+ async reportSetup(sandbox) {
333
+ return this.ach('PUT', '/me/setup', { sandbox });
334
+ }
300
335
  /** 409 when the run lost the task. Carries a fresh `run_token` when the old one is near expiry (C5). */
301
336
  async heartbeat(taskId, run_id) {
302
337
  return this.ach('POST', `/tasks/${enc(taskId)}/heartbeat`, { run_id });
@@ -309,6 +344,7 @@ export class Client {
309
344
  const r = await this.ach('GET', '/inbox');
310
345
  return {
311
346
  tasks_to_me: arr(r?.tasks_to_me),
347
+ tasks_to_review: arr(r?.tasks_to_review),
312
348
  questions_to_me: flatMsgs(r?.questions_to_me),
313
349
  answers_to_me: flatMsgs(r?.answers_to_me),
314
350
  decisions_for_me: flatMsgs(r?.decisions_for_me),
@@ -77,13 +77,13 @@ export declare function notifyCommand(o: {
77
77
  } | null;
78
78
  /** The options a decision item offers (labels/details from `options`, tokens from `actions` by option index). Pure. */
79
79
  export declare function itemOptions(x: NotifyItem): NotifyOption[];
80
- /** Task ref and message id of a decision item: fields, else its link `t/T-12/m/<id>`. Pure. */
80
+ /** Task ref and message id of a decision item: fields, else its link `t/TASK-12/m/<id>`. Pure. */
81
81
  export declare function itemTarget(x: NotifyItem): {
82
82
  task?: string;
83
83
  message?: string;
84
84
  };
85
85
  /**
86
- * The panel link with option `i` preselected: `…/#/t/T-12/m/<id>?pick=<i>`
86
+ * The panel link with option `i` preselected: `…/#/t/TASK-12/m/<id>?pick=<i>`
87
87
  * (the query goes inside the hash route, where the panel reads it). Pure.
88
88
  */
89
89
  export declare function pickUrl(url: string, i: number): string;
@@ -121,7 +121,7 @@ export function itemOptions(x) {
121
121
  });
122
122
  return out.slice(0, 4);
123
123
  }
124
- /** Task ref and message id of a decision item: fields, else its link `t/T-12/m/<id>`. Pure. */
124
+ /** Task ref and message id of a decision item: fields, else its link `t/TASK-12/m/<id>`. Pure. */
125
125
  export function itemTarget(x) {
126
126
  const m = /(?:^|#\/|\/)t\/([^/?#]+)\/m\/([A-Za-z0-9_-]+)/.exec(String(x.path ?? '') || String(x.url ?? ''));
127
127
  return { task: x.task_id || x.task_ref || (m ? decodeURIComponent(m[1]) : undefined), message: x.message_id || (m ? m[2] : undefined) };
@@ -132,7 +132,7 @@ function isDecision(x) {
132
132
  return x.decision === true || /decision_request|memory_question/.test(String(x.event ?? '')) || itemOptions(x).length > 0;
133
133
  }
134
134
  /**
135
- * The panel link with option `i` preselected: `…/#/t/T-12/m/<id>?pick=<i>`
135
+ * The panel link with option `i` preselected: `…/#/t/TASK-12/m/<id>?pick=<i>`
136
136
  * (the query goes inside the hash route, where the panel reads it). Pure.
137
137
  */
138
138
  export function pickUrl(url, i) {
@@ -49,6 +49,10 @@ export declare function runDoctor(o: {
49
49
  fetch?: FetchLike;
50
50
  exec?: ExecLike;
51
51
  self?: string;
52
+ /** the sandbox probe (tests) */
53
+ sandbox?: (tool: 'claude' | 'codex', o: {
54
+ env: NodeJS.ProcessEnv;
55
+ }) => Promise<import('./sandboxcheck.js').SandboxStatus>;
52
56
  }): Promise<DoctorCheck[]>;
53
57
  /**
54
58
  * Command, args and AGENTCHANNEL_HOME of an MCP entry from `claude mcp get`
@@ -247,6 +247,7 @@ export async function runDoctor(o) {
247
247
  const { serviceFile, serviceLabel } = await import('./service.js');
248
248
  const platform = (env.ACH_SERVICE_PLATFORM || process.platform);
249
249
  if (platform === 'darwin' || platform === 'linux') {
250
+ // (Windows: the Startup script has no status command; doctor says nothing rather than guess)
250
251
  const f = serviceFile({ platform, agentName, command: [], args: [], env: {}, logFile: '' }, env.ACH_SERVICE_DIR || undefined);
251
252
  if (!existsSync(f.path))
252
253
  add('background', 'info', 'no background runner on this machine (optional: ach run --dir <repo> --install-service --interactive-first)');
@@ -299,6 +300,17 @@ export async function runDoctor(o) {
299
300
  if (codexPath)
300
301
  await mcpCheck('codex', codexPath);
301
302
  }
303
+ // design §27: the agent tool's sandbox, probed for real (every run that answers a question or an @mention is sandboxed)
304
+ const { probeSandbox } = await import('./sandboxcheck.js');
305
+ const tools = runtime === 'codex' ? ['codex'] : runtime === 'claude' ? ['claude'] : [...(claude ? ['claude'] : []), ...(codexPath || bundled ? ['codex'] : [])];
306
+ for (const t of tools) {
307
+ const st = await (o.sandbox ?? probeSandbox)(t, { env });
308
+ const name = `${t} sandbox`;
309
+ if (st.ok)
310
+ add(name, st.unverified ? 'info' : 'ok', st.unverified ? `not checked: ${st.detail ?? 'unknown'}` : 'starts, and stops writes outside the task directory');
311
+ else
312
+ add(name, runtime ? 'fail' : 'warn', `${st.fix?.en ?? st.detail ?? st.problem}${runtime ? ' The background runner takes no tasks until this is fixed.' : ''}`, 'machine', st.fix?.commands.length ? st.fix.commands.join('\n ') : st.fix?.en);
313
+ }
302
314
  return out;
303
315
  /**
304
316
  * The `ach` MCP entry as the runtime itself reports it (`<runtime> mcp get
@@ -0,0 +1,22 @@
1
+ import { type ProbeOptions, type Run, type SandboxStatus, type SandboxTool } from './sandboxcheck.js';
2
+ /** The tool authenticates from the environment (an API key, a cloud provider): there is no login to check. */
3
+ export declare function authFromEnv(tool: SandboxTool, env: NodeJS.ProcessEnv): string | undefined;
4
+ /** Is `tool` logged in here? Never throws. A tool that is not installed, or whose status command cannot be read, does not block (unverified). */
5
+ export declare function probeLogin(tool: SandboxTool, o?: {
6
+ env?: NodeJS.ProcessEnv;
7
+ run?: Run;
8
+ claude?: string;
9
+ codex?: string[];
10
+ platform?: NodeJS.Platform;
11
+ }): Promise<SandboxStatus>;
12
+ /** The whole check: login first (cheap, and what a fresh machine lacks), then the sandbox. The first problem is the answer. */
13
+ export declare function probeEnvironment(tool: SandboxTool, o?: ProbeOptions & {
14
+ claude?: string;
15
+ }): Promise<SandboxStatus>;
16
+ /**
17
+ * A run's error as the task's failure reason: the lines that carry the cause, not just the first one. Noise lines are dropped,
18
+ * lines that look like a cause come first (at most 3), then the first remaining line; capped at 600 characters.
19
+ */
20
+ export declare function summarizeRuntimeError(raw: string): string;
21
+ /** Does a run's error say the tool is not logged in (or its key was refused)? */
22
+ export declare function looksLikeLoginError(s: string): boolean;