@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 +25 -5
- package/package.json +5 -1
- package/src/agents/build-cli-command.ts +5 -4
- package/src/agents/build-hook-settings.ts +51 -12
- package/src/agents/gateway-adapter.ts +1 -0
- package/src/cli.ts +2 -2
- package/src/client/boot-daemon.ts +4 -5
- package/src/client/collect-path-completions.ts +59 -0
- package/src/client/collect-root-dirs.ts +32 -0
- package/src/client/collect-zoxide-dirs.ts +15 -0
- package/src/client/dirs.ts +21 -13
- package/src/client/resolve-path-input.ts +23 -0
- package/src/client/spawn-picker.ts +33 -11
- package/src/daemon/resolve-headless-executable.ts +27 -0
- package/src/daemon/start-headless-run.ts +4 -0
- package/src/shared/collect-dir-roots.ts +33 -0
- package/src/shared/collect-gateways.ts +12 -0
- package/src/shared/config.ts +25 -1
- package/src/shared/is-compiled-binary.ts +12 -0
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
|
|
35
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
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 {
|
|
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 (
|
|
12
|
-
return `"${exec}"
|
|
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
|
-
|
|
35
|
-
hooks:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/client/dirs.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 [
|
|
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
|
-
|
|
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 =
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
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 =
|
|
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),
|
package/src/shared/config.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|