@zgeoff/atc 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,12 +27,30 @@ that needs an answer turns red, and `Tab` takes you to it. Quit atc and the sess
27
27
 
28
28
  ## Install
29
29
 
30
+ Homebrew:
31
+
32
+ ```sh
33
+ brew install zgeoff/tap/atc
34
+ ```
35
+
36
+ Or download a binary:
37
+
38
+ ```sh
39
+ curl -fsSL https://raw.githubusercontent.com/zgeoff/atc/main/install.sh | sh
40
+ ```
41
+
42
+ Prebuilt binaries cover macOS and Linux, arm64 and x64. The install script puts `atc` in
43
+ `~/.local/bin`; set `ATC_INSTALL_DIR` to change that. Checksums are on the
44
+ [releases page](https://github.com/zgeoff/atc/releases).
45
+
46
+ From source, with [Bun](https://bun.sh):
47
+
30
48
  ```sh
31
49
  bun add -g @zgeoff/atc
32
50
  ```
33
51
 
34
- atc needs [Bun](https://bun.sh) and at least one of the `claude`, `grok`, or `codex` CLIs on your
35
- PATH. The agent picker lists the ones it finds.
52
+ atc needs at least one of the `claude`, `grok`, or `codex` CLIs on your PATH. The agent picker lists
53
+ the ones it finds.
36
54
 
37
55
  ## Use
38
56
 
@@ -67,9 +85,11 @@ Sessions that need you sort to the top, so the one you want is nearly always fir
67
85
  | `q` | quit the client; sessions keep running |
68
86
  | `?` | every other key |
69
87
 
70
- The directory picker reads your [zoxide](https://github.com/ajeetdsouza/zoxide) list when zoxide is
71
- installed, and atc's own spawn history otherwise. Inside a Claude session your statusline gains a
72
- fleet segment, so `● 2 need you: auth-bug` is visible without opening the list.
88
+ The directory picker lists the directory you ran `atc` from, atc's own spawn history, every project
89
+ under the roots in `config.json`, and your [zoxide](https://github.com/ajeetdsouza/zoxide) list when
90
+ zoxide is installed. A typed path such as `~/pro` completes like a shell. Inside a Claude session
91
+ your statusline gains a fleet segment, so `● 2 need you: auth-bug` is visible without opening the
92
+ list.
73
93
 
74
94
  If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
75
95
  After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -21,11 +21,13 @@
21
21
  "scripts": {
22
22
  "start": "bun src/cli.ts",
23
23
  "build:agents": "bash scripts/build-agents-md.sh agents/shared.md agents/project.md AGENTS.md",
24
+ "build:binaries": "bash scripts/build-binaries.sh",
24
25
  "audit": "bun audit",
25
26
  "deadcode": "knip",
26
27
  "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
27
28
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
28
29
  "test": "bun test",
30
+ "test:binary": "bun test --timeout 20000 test/daemon-e2e.test.ts",
29
31
  "typecheck": "tsc -p tsconfig.json --noEmit",
30
32
  "format": "oxfmt . && format-codemod --quiet .",
31
33
  "format:check": "oxfmt --check . && format-codemod --check --quiet .",
@@ -61,6 +63,8 @@
61
63
  },
62
64
  "overrides": {
63
65
  "fast-uri": "3.1.6",
66
+ "hono": ">=4.13.5",
67
+ "js-yaml": ">=4.3.2 <5",
64
68
  "qs": "6.16.0"
65
69
  },
66
70
  "packageManager": "bun@1.3.10"
@@ -1,4 +1,5 @@
1
- import { basename, join } from 'node:path';
1
+ import { join } from 'node:path';
2
+ import { isCompiledBinary } from '../shared/is-compiled-binary';
2
3
 
3
4
  /**
4
5
  * Command line that wrangled sessions invoke for atc subcommands: under bun
@@ -8,9 +9,9 @@ import { basename, join } from 'node:path';
8
9
  export function buildCLICommand(subcommand: string): string {
9
10
  const exec = process.execPath;
10
11
 
11
- if (basename(exec) === 'bun' || basename(exec) === 'bun.exe') {
12
- return `"${exec}" "${join(import.meta.dir, '..', 'cli.ts')}" ${subcommand}`;
12
+ if (isCompiledBinary()) {
13
+ return `"${exec}" ${subcommand}`;
13
14
  }
14
15
 
15
- return `"${exec}" ${subcommand}`;
16
+ return `"${exec}" "${join(import.meta.dir, '..', 'cli.ts')}" ${subcommand}`;
16
17
  }
@@ -1,15 +1,19 @@
1
+ import { z } from 'zod';
1
2
  import type { AgentID } from './agent-adapter';
2
3
  import { buildCLICommand } from './build-cli-command';
3
4
 
4
5
  /**
5
6
  * What one agent id needs on top of the shared instrumentation. The
6
7
  * environment block and the credential helper point the CLI at a backend
7
- * other than the default one; both are absent for the stock agent.
8
+ * other than the default one; both are absent for the stock agent. The
9
+ * settings are whatever else the config asked this agent's sessions to start
10
+ * with.
8
11
  */
9
12
  export interface HookSettingsProfile {
10
13
  readonly id: AgentID;
11
14
  readonly env?: Readonly<Record<string, string>>;
12
15
  readonly apiKeyHelper?: string;
16
+ readonly settings?: Readonly<Record<string, unknown>>;
13
17
  }
14
18
 
15
19
  /**
@@ -31,17 +35,20 @@ export function buildHookSettings(
31
35
  { hooks: [{ type: 'command', command: buildCLICommand('hook-report'), timeout: 5 }] },
32
36
  ];
33
37
 
34
- return {
35
- hooks: {
36
- // SessionStart carries the session id at spawn/resume time, before any
37
- // interaction — without it a session only enters the fleet file after
38
- // its first prompt/notification.
39
- SessionStart: entry,
40
- Notification: entry,
41
- Stop: entry,
42
- UserPromptSubmit: entry,
43
- SessionEnd: entry,
44
- },
38
+ const own = {
39
+ hooks: mergeSessionHooks(
40
+ {
41
+ // SessionStart carries the session id at spawn/resume time, before any
42
+ // interaction — without it a session only enters the fleet file after
43
+ // its first prompt/notification.
44
+ SessionStart: entry,
45
+ Notification: entry,
46
+ Stop: entry,
47
+ UserPromptSubmit: entry,
48
+ SessionEnd: entry,
49
+ },
50
+ profile.settings?.['hooks'],
51
+ ),
45
52
 
46
53
  // Fleet status renders inside Claude Code's own status line; the injected
47
54
  // command chains the user's configured statusline first, so mirror their
@@ -56,4 +63,36 @@ export function buildHookSettings(
56
63
  : { env: profile.env }),
57
64
  ...(profile.apiKeyHelper === undefined ? {} : { apiKeyHelper: profile.apiKeyHelper }),
58
65
  };
66
+
67
+ // The configured settings fill in around what atc writes and never replace
68
+ // it: a session atc did not instrument is a session the fleet cannot track.
69
+ return { ...profile.settings, ...own };
70
+ }
71
+
72
+ type SessionHooks = Readonly<Record<string, readonly unknown[]>>;
73
+
74
+ // One hook list per event name, which is the shape every harness that reads
75
+ // this file takes.
76
+ const CONFIGURED_HOOKS_SCHEMA = z.record(z.string(), z.array(z.unknown()));
77
+
78
+ /**
79
+ * atc's own reporter plus whatever the config added, per event. The reporter
80
+ * runs first and the configured entries follow it, so an added hook joins the
81
+ * fleet's rather than displacing it. A block that is not hook lists is left out
82
+ * whole, and costs its own entries rather than atc's.
83
+ */
84
+ function mergeSessionHooks(own: SessionHooks, configured: unknown): SessionHooks {
85
+ const parsed = CONFIGURED_HOOKS_SCHEMA.safeParse(configured);
86
+
87
+ if (!parsed.success) {
88
+ return own;
89
+ }
90
+
91
+ const merged: Record<string, readonly unknown[]> = { ...own };
92
+
93
+ for (const [event, entries] of Object.entries(parsed.data)) {
94
+ merged[event] = [...(merged[event] ?? []), ...entries];
95
+ }
96
+
97
+ return merged;
59
98
  }
@@ -99,6 +99,7 @@ export class GatewayAdapter implements AgentAdapter {
99
99
  ...(this.gateway.apiKeyHelper === undefined
100
100
  ? {}
101
101
  : { apiKeyHelper: this.gateway.apiKeyHelper }),
102
+ ...(this.gateway.settings === undefined ? {} : { settings: this.gateway.settings }),
102
103
  });
103
104
 
104
105
  return this.settingsFile;
package/src/cli.ts CHANGED
@@ -64,7 +64,7 @@ const main = defineCommand({
64
64
  Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
65
65
 
66
66
  const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
67
- headless.startHeadlessRun(runOpts, hooks),
67
+ headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
68
68
  );
69
69
 
70
70
  const grokAdapter = new grok.GrokAdapter(cfg);
@@ -73,7 +73,7 @@ const main = defineCommand({
73
73
  const gatewayAdapters = cfg.gateways.map(
74
74
  (entry) =>
75
75
  new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
76
- headless.startHeadlessRun(runOpts, hooks),
76
+ headless.startHeadlessRun({ ...runOpts, claudeBin: entry.bin }, hooks),
77
77
  ),
78
78
  );
79
79
 
@@ -1,10 +1,11 @@
1
1
  import { spawn as spawnChild } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
- import { basename, join } from 'node:path';
3
+ import { join } from 'node:path';
4
4
  import { toAgentID } from '../agents/agent-adapter';
5
5
  import type { AgentID } from '../agents/agent-adapter';
6
6
  import { daemonPidFile, daemonSocketPath } from '../shared/config';
7
7
  import { getBuild } from '../shared/get-build';
8
+ import { isCompiledBinary } from '../shared/is-compiled-binary';
8
9
  import { isRecord } from '../shared/report';
9
10
  import { DaemonClient } from './daemon-client';
10
11
 
@@ -119,9 +120,7 @@ async function stopStaleDaemon(): Promise<void> {
119
120
  }
120
121
 
121
122
  function spawnDaemonDetached() {
122
- const exec = process.execPath;
123
- const isBun = basename(exec) === 'bun' || basename(exec) === 'bun.exe';
124
- const args = isBun ? [join(import.meta.dir, '..', 'cli.ts'), 'daemon'] : ['daemon'];
123
+ const args = isCompiledBinary() ? ['daemon'] : [join(import.meta.dir, '..', 'cli.ts'), 'daemon'];
125
124
 
126
- spawnChild(exec, args, { detached: true, stdio: 'ignore' }).unref();
125
+ spawnChild(process.execPath, args, { detached: true, stdio: 'ignore' }).unref();
127
126
  }
@@ -0,0 +1,59 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { resolvePathInput } from './resolve-path-input';
4
+
5
+ /**
6
+ * Shell-style completion of a typed path: the child directories of the
7
+ * typed parent whose names start with the last segment, and the typed
8
+ * directory itself first when it exists, so Enter takes it. A trailing
9
+ * slash lists every child. Hidden directories show only when the segment
10
+ * starts with a dot. Input that is not a path completes to nothing.
11
+ */
12
+ export function collectPathCompletions(input: string, cwd: string, home: string): string[] {
13
+ const target = resolvePathInput(input, cwd, home);
14
+
15
+ if (target === null) {
16
+ return [];
17
+ }
18
+
19
+ const listsChildren = input.endsWith('/') || input === '~' || input === '.' || input === '..';
20
+ const parent = listsChildren ? target : dirname(target);
21
+ const partial = listsChildren ? '' : basename(target);
22
+
23
+ const children = collectChildDirs(parent).filter((child) => {
24
+ const name = basename(child);
25
+
26
+ return (
27
+ name.toLowerCase().startsWith(partial.toLowerCase()) &&
28
+ (partial.startsWith('.') || !name.startsWith('.'))
29
+ );
30
+ });
31
+
32
+ if (listsChildren && isDirectory(target)) {
33
+ return [target, ...children.filter((child) => child !== target)];
34
+ }
35
+
36
+ return [
37
+ ...children.filter((child) => child === target),
38
+ ...children.filter((child) => child !== target),
39
+ ];
40
+ }
41
+
42
+ function collectChildDirs(dir: string): string[] {
43
+ try {
44
+ return readdirSync(dir, { withFileTypes: true })
45
+ .filter((entry) => entry.isDirectory())
46
+ .map((entry) => join(dir, entry.name))
47
+ .toSorted();
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ function isDirectory(path: string): boolean {
54
+ try {
55
+ return statSync(path).isDirectory();
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
@@ -0,0 +1,32 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The directories one level under each configured root, plus every
6
+ * worktree under those: `~/projects/atc` and `~/projects/atc/.worktrees/fix`
7
+ * both list for a root of `~/projects`. Hidden directories are skipped, a
8
+ * root that cannot be read contributes nothing, and the result is sorted
9
+ * per root so the list is stable between opens.
10
+ */
11
+ export function collectRootDirs(roots: readonly string[]): string[] {
12
+ const found: string[] = [];
13
+
14
+ for (const root of roots) {
15
+ for (const child of collectChildDirs(root)) {
16
+ found.push(child, ...collectChildDirs(join(child, '.worktrees')));
17
+ }
18
+ }
19
+
20
+ return found;
21
+ }
22
+
23
+ function collectChildDirs(dir: string): string[] {
24
+ try {
25
+ return readdirSync(dir, { withFileTypes: true })
26
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
27
+ .map((entry) => join(dir, entry.name))
28
+ .toSorted();
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * zoxide's frecency list, most visited first. An absent zoxide or a failed
3
+ * query contributes nothing, so the picker never depends on it.
4
+ */
5
+ export async function collectZoxideDirs(): Promise<string[]> {
6
+ try {
7
+ const proc = Bun.spawn(['zoxide', 'query', '-l'], { stdout: 'pipe', stderr: 'ignore' });
8
+
9
+ const text = await new Response(proc.stdout).text();
10
+
11
+ return text.split('\n').filter((line) => line !== '');
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
@@ -1,25 +1,33 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename } from 'node:path';
4
+ import { collectRootDirs } from './collect-root-dirs';
4
5
 
5
- // Spawn-history first (most recently used, reported by the daemon), then
6
- // zoxide's frecency list.
7
- export async function collectDirs(recent: readonly string[]): Promise<string[]> {
8
- let zoxide: string[] = [];
9
-
10
- try {
11
- const proc = Bun.spawn(['zoxide', 'query', '-l'], { stdout: 'pipe', stderr: 'ignore' });
12
-
13
- const text = await new Response(proc.stdout).text();
14
-
15
- zoxide = text.split('\n').filter((line) => line !== '');
16
- } catch {}
6
+ /**
7
+ * Every source the picker lists, merged in the order the reader expects
8
+ * to find a directory: where the client runs, then the daemon's spawn
9
+ * history (most recent first), then the configured roots, then zoxide's
10
+ * frecency list. A path that no longer exists is dropped, a duplicate keeps
11
+ * its first position, and an empty merge falls back to the home directory.
12
+ */
13
+ export interface DirSources {
14
+ readonly cwd: string;
15
+ readonly recent: readonly string[];
16
+ readonly roots: readonly string[];
17
+ readonly zoxide: readonly string[];
18
+ }
17
19
 
20
+ export function collectDirs(sources: DirSources): string[] {
18
21
  const seen = new Set<string>();
19
22
 
20
23
  const found: string[] = [];
21
24
 
22
- for (const d of [...recent, ...zoxide]) {
25
+ for (const d of [
26
+ sources.cwd,
27
+ ...sources.recent,
28
+ ...collectRootDirs(sources.roots),
29
+ ...sources.zoxide,
30
+ ]) {
23
31
  if (!seen.has(d) && existsSync(d)) {
24
32
  seen.add(d);
25
33
  found.push(d);
@@ -0,0 +1,23 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ /**
4
+ * Where the picker looks a typed path up: `~` and `~/…` under the home
5
+ * directory, `.`, `..`, and `./…` under the client's working directory, and
6
+ * an absolute path as written. Anything else is a filter over the known
7
+ * list, not a path, and resolves to null.
8
+ */
9
+ export function resolvePathInput(input: string, cwd: string, home: string): string | null {
10
+ if (input === '~' || input.startsWith('~/')) {
11
+ return resolve(home, input.slice(2));
12
+ }
13
+
14
+ if (input.startsWith('/') || isRelativePathInput(input)) {
15
+ return resolve(cwd, input);
16
+ }
17
+
18
+ return null;
19
+ }
20
+
21
+ function isRelativePathInput(input: string): boolean {
22
+ return input === '.' || input === '..' || input.startsWith('./') || input.startsWith('../');
23
+ }
@@ -1,9 +1,13 @@
1
+ import { homedir } from 'node:os';
1
2
  import type { AgentID } from '../agents/agent-adapter';
2
3
  import { loadConfig } from '../shared/config';
3
4
  import { collectAgentPicks } from './collect-agent-picks';
4
5
  import type { AgentPick } from './collect-agent-picks';
6
+ import { collectPathCompletions } from './collect-path-completions';
7
+ import { collectZoxideDirs } from './collect-zoxide-dirs';
5
8
  import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
6
9
  import { planTextEdit } from './keys';
10
+ import { resolvePathInput } from './resolve-path-input';
7
11
  import { ansi, cols, drawPicker } from './ui';
8
12
 
9
13
  type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
@@ -45,6 +49,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
45
49
 
46
50
  private dirs: string[] = [];
47
51
 
52
+ // The configured roots, read with the agents each time the menu opens.
53
+ private roots: readonly string[] = [];
54
+
48
55
  private dir = '';
49
56
 
50
57
  private name = '';
@@ -59,7 +66,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
59
66
 
60
67
  open(resume = false) {
61
68
  this.resume = resume;
62
- this.picks = collectAgentPicks(loadConfig());
69
+
70
+ const config = loadConfig();
71
+
72
+ this.picks = collectAgentPicks(config);
73
+ this.roots = config.dirs.roots;
63
74
  this.input = '';
64
75
 
65
76
  // A last-used agent that is no longer installed is not in the menu, so
@@ -139,7 +150,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
139
150
  hint,
140
151
  });
141
152
  } else if (this.step === 'dir') {
142
- const items = pickMatches(this.dirs, this.input).map((d) => formatDir(d));
153
+ const items = this.collectDirItems().map((d) => formatDir(d));
143
154
 
144
155
  this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
145
156
 
@@ -148,7 +159,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
148
159
  items,
149
160
  selected: this.selected,
150
161
  input: this.input,
151
- hint: 'type to filter · ↑↓ move · ⏎ select · esc cancel',
162
+ hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
152
163
  });
153
164
  } else if (this.step === 'name') {
154
165
  drawPicker({
@@ -216,13 +227,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
216
227
  }
217
228
 
218
229
  if (this.step === 'dir') {
219
- const items = pickMatches(this.dirs, this.input);
220
- const raw = this.input.trim();
221
- let chosen = items[this.selected] ?? null;
222
-
223
- if (chosen === null && (raw.startsWith('/') || raw.startsWith('~'))) {
224
- chosen = raw.replace(/^~/u, process.env['HOME'] ?? '~');
225
- }
230
+ const chosen =
231
+ this.collectDirItems()[this.selected] ??
232
+ resolvePathInput(this.input.trim(), process.cwd(), homedir());
226
233
 
227
234
  if (chosen === null) {
228
235
  return;
@@ -254,6 +261,16 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
254
261
  this.render();
255
262
  }
256
263
 
264
+ /**
265
+ * A typed path completes against the filesystem, like a shell; anything
266
+ * else fuzzy-filters the merged list.
267
+ */
268
+ private collectDirItems(): string[] {
269
+ return resolvePathInput(this.input, process.cwd(), homedir()) === null
270
+ ? pickMatches(this.dirs, this.input)
271
+ : collectPathCompletions(this.input, process.cwd(), homedir());
272
+ }
273
+
257
274
  private async openDirStep() {
258
275
  let recent: string[] = [];
259
276
 
@@ -267,7 +284,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
267
284
  }
268
285
  } catch {}
269
286
 
270
- this.dirs = await collectDirs(recent);
287
+ this.dirs = collectDirs({
288
+ cwd: process.cwd(),
289
+ recent,
290
+ roots: this.roots,
291
+ zoxide: await collectZoxideDirs(),
292
+ });
271
293
 
272
294
  this.input = '';
273
295
  this.selected = 0;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The Claude Code executable a headless turn runs under. The Agent SDK
3
+ * carries its own copy of the CLI as a platform package, which a compiled
4
+ * atc binary cannot reach, so a compiled binary hands the SDK the same
5
+ * `claude` the terminal sessions spawn, resolved on PATH. An npm-installed
6
+ * `claude` is a JavaScript entry and runs under node, since the SDK would
7
+ * otherwise spawn it under a bun that is not installed. Under a source run
8
+ * the SDK's own copy stays in use and the result is empty.
9
+ */
10
+ export function resolveHeadlessExecutable(
11
+ claudeBin: string,
12
+ compiled: boolean,
13
+ ): { readonly pathToClaudeCodeExecutable: string; readonly executable?: 'node' } | null {
14
+ if (!compiled) {
15
+ return null;
16
+ }
17
+
18
+ // Spawns inherit this process's PATH, so the search list is read live
19
+ // rather than left to the snapshot Bun.which defaults to.
20
+ const path = claudeBin.includes('/')
21
+ ? claudeBin
22
+ : (Bun.which(claudeBin, { PATH: process.env['PATH'] ?? '' }) ?? claudeBin);
23
+
24
+ return /\.[cm]?js$/u.test(path)
25
+ ? { pathToClaudeCodeExecutable: path, executable: 'node' }
26
+ : { pathToClaudeCodeExecutable: path };
27
+ }
@@ -1,7 +1,9 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { AgentSessionID } from '../shared/agent-session-id';
3
3
  import { collectCleanEnv } from '../shared/collect-clean-env';
4
+ import { isCompiledBinary } from '../shared/is-compiled-binary';
4
5
  import { isRecord } from '../shared/report';
6
+ import { resolveHeadlessExecutable } from './resolve-headless-executable';
5
7
 
6
8
  const PERMISSION_MODES = [
7
9
  'default',
@@ -13,6 +15,7 @@ const PERMISSION_MODES = [
13
15
  ] as const;
14
16
 
15
17
  interface HeadlessRunOptions {
18
+ readonly claudeBin: string;
16
19
  readonly cwd: string;
17
20
  readonly prompt: string;
18
21
  readonly resume?: AgentSessionID;
@@ -62,6 +65,7 @@ export function startHeadlessRun(
62
65
  // The same generated file the terminal spawn passes, so the turn
63
66
  // runs against the session's own backend and instrumentation.
64
67
  ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
68
+ ...resolveHeadlessExecutable(opts.claudeBin, isCompiledBinary()),
65
69
  },
66
70
  });
67
71
 
@@ -0,0 +1,33 @@
1
+ import { homedir } from 'node:os';
2
+ import { isRecord } from './report';
3
+
4
+ /**
5
+ * Reads `dirs.roots`: the directories whose children the spawn picker
6
+ * lists. A non-string entry is dropped, a leading `~` expands to the home
7
+ * directory, and trailing slashes are trimmed so a root compares equal to
8
+ * the paths under it.
9
+ */
10
+ export function collectDirRoots(raw: unknown): readonly string[] {
11
+ if (!isRecord(raw) || !Array.isArray(raw['roots'])) {
12
+ return [];
13
+ }
14
+
15
+ const roots: string[] = [];
16
+
17
+ for (const item of raw['roots']) {
18
+ if (typeof item !== 'string' || item === '') {
19
+ continue;
20
+ }
21
+
22
+ roots.push(normalizeRoot(item));
23
+ }
24
+
25
+ return roots;
26
+ }
27
+
28
+ function normalizeRoot(dir: string): string {
29
+ const expanded = dir === '~' ? homedir() : dir.replace(/^~\//u, `${homedir()}/`);
30
+ const trimmed = expanded.replace(/\/+$/u, '');
31
+
32
+ return trimmed === '' ? '/' : trimmed;
33
+ }
@@ -18,6 +18,7 @@ export interface GatewayConfig {
18
18
  readonly baseURL: string;
19
19
  readonly apiKeyHelper?: string;
20
20
  readonly env: Readonly<Record<string, string>>;
21
+ readonly settings?: Readonly<Record<string, unknown>>;
21
22
  }
22
23
 
23
24
  // Ids the built-in adapters answer to; a gateway may not take one.
@@ -34,6 +35,7 @@ const GATEWAY_ENTRY_SCHEMA = z.object({
34
35
  args: buildOptionalStringArray(),
35
36
  apiKeyHelper: buildOptionalNonEmptyString(),
36
37
  env: buildStringEnvRecord(),
38
+ settings: buildOptionalSettings(),
37
39
  });
38
40
 
39
41
  /**
@@ -76,12 +78,22 @@ export function collectGateways(
76
78
  baseURL: parsed.data.baseURL,
77
79
  ...(parsed.data.apiKeyHelper === undefined ? {} : { apiKeyHelper: parsed.data.apiKeyHelper }),
78
80
  env: parsed.data.env,
81
+ ...(parsed.data.settings === undefined ? {} : { settings: parsed.data.settings }),
79
82
  });
80
83
  }
81
84
 
82
85
  return gateways;
83
86
  }
84
87
 
88
+ // Claude Code settings the sessions of this gateway are started with, on top of
89
+ // the ones atc writes itself. Anything but an object is no extra settings.
90
+ function buildOptionalSettings() {
91
+ return z.preprocess(
92
+ (v) => (isRecord(v) ? v : undefined),
93
+ z.record(z.string(), z.unknown()).optional(),
94
+ );
95
+ }
96
+
85
97
  function buildOptionalNonEmptyString() {
86
98
  return z.preprocess(
87
99
  (v) => (typeof v === 'string' && v !== '' ? v : undefined),
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { buildOptionalString } from './build-optional-string';
6
6
  import { buildOptionalStringArray } from './build-optional-string-array';
7
+ import { collectDirRoots } from './collect-dir-roots';
7
8
  import { collectGateways } from './collect-gateways';
8
9
  import type { GatewayConfig } from './collect-gateways';
9
10
  import { collectHooks } from './collect-hooks';
@@ -16,11 +17,20 @@ export interface Config {
16
17
  grokArgs: string[];
17
18
  codexBin: string;
18
19
  codexArgs: string[];
20
+ dirs: DirsConfig;
19
21
  gateways: GatewayConfig[];
20
22
  hooks: HooksConfig;
21
23
  leader: LeaderKey;
22
24
  }
23
25
 
26
+ /**
27
+ * Where the spawn picker looks for directories beyond its own history: each
28
+ * root contributes its child directories and their worktrees.
29
+ */
30
+ interface DirsConfig {
31
+ readonly roots: readonly string[];
32
+ }
33
+
24
34
  interface LeaderKey {
25
35
  readonly code: number;
26
36
  readonly label: string;
@@ -33,6 +43,7 @@ const DEFAULTS: Config = {
33
43
  grokArgs: [],
34
44
  codexBin: 'codex',
35
45
  codexArgs: [],
46
+ dirs: { roots: [] },
36
47
  gateways: [],
37
48
  hooks: {},
38
49
  leader: { code: 0, label: '^Space' },
@@ -59,6 +70,7 @@ const CONFIG_SCHEMA = z.object({
59
70
  grokArgs: buildOptionalStringArray(),
60
71
  codexBin: buildOptionalString(),
61
72
  codexArgs: buildOptionalStringArray(),
73
+ dirs: z.unknown().optional(),
62
74
  gateways: z.unknown().optional(),
63
75
  hooks: z.unknown().optional(),
64
76
  leader: buildOptionalString(),
@@ -102,13 +114,25 @@ export function parseConfig(raw: unknown): Config {
102
114
  const grokArgs = parsed.data.grokArgs ?? DEFAULTS.grokArgs;
103
115
  const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
104
116
  const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
117
+ const dirs = { roots: collectDirRoots(parsed.data.dirs) };
105
118
  const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
106
119
  const hooks = collectHooks(parsed.data.hooks);
107
120
 
108
121
  const leader =
109
122
  (parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
110
123
 
111
- return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, gateways, hooks, leader };
124
+ return {
125
+ claudeBin,
126
+ claudeArgs,
127
+ grokBin,
128
+ grokArgs,
129
+ codexBin,
130
+ codexArgs,
131
+ dirs,
132
+ gateways,
133
+ hooks,
134
+ leader,
135
+ };
112
136
  }
113
137
 
114
138
  // Control bytes the terminal needs for its own input: enter, tab, and esc
@@ -0,0 +1,12 @@
1
+ import { basename } from 'node:path';
2
+
3
+ /**
4
+ * Whether this process is a compiled atc binary rather than bun running the
5
+ * source tree: the binary is then its own CLI entry, and nothing under
6
+ * node_modules exists at runtime.
7
+ */
8
+ export function isCompiledBinary(): boolean {
9
+ const exec = basename(process.execPath);
10
+
11
+ return exec !== 'bun' && exec !== 'bun.exe';
12
+ }