@zgeoff/atc 0.1.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.
package/src/cli.ts ADDED
@@ -0,0 +1,101 @@
1
+ // atc CLI entry: no subcommand opens the client TUI; `hook-report` and
2
+ // `statusline` are the commands injected into wrangled sessions.
3
+ import { defineCommand, runMain } from 'citty';
4
+ import pkg from '../package.json';
5
+ import { getBuild } from './get-build';
6
+
7
+ const main = defineCommand({
8
+ meta: {
9
+ name: 'atc',
10
+ version: pkg.version,
11
+ description: 'Terminal control tower for Claude Code sessions',
12
+ },
13
+ default: 'tui',
14
+ subCommands: {
15
+ tui: () =>
16
+ defineCommand({
17
+ meta: {
18
+ name: 'tui',
19
+ description: 'Open the session list client',
20
+ hidden: true,
21
+ },
22
+ async run() {
23
+ await import('./index');
24
+ },
25
+ }),
26
+ mcp: () =>
27
+ defineCommand({
28
+ meta: {
29
+ name: 'mcp',
30
+ description: 'Run an MCP server over stdio exposing the fleet as tools',
31
+ },
32
+ async run() {
33
+ const server = await import('./mcp-server');
34
+
35
+ await server.runMCPServer(getBuild());
36
+ },
37
+ }),
38
+ daemon: () =>
39
+ defineCommand({
40
+ meta: {
41
+ name: 'daemon',
42
+ description: 'Run the atc daemon in the foreground',
43
+ },
44
+ async run() {
45
+ const daemon = await import('./daemon');
46
+ const config = await import('./config');
47
+ const claude = await import('./claude-adapter');
48
+ const headless = await import('./start-headless-run');
49
+
50
+ // Test harnesses shrink the outbound queue to force overflow
51
+ // deterministically; unset means the production default.
52
+ const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
53
+
54
+ const handle = daemon.startDaemon({
55
+ headlessRunner: (runOpts, hooks) => headless.startHeadlessRun(runOpts, hooks),
56
+ socketPath: config.daemonSocketPath,
57
+ reporterSocketPath: config.socketPath,
58
+ build: getBuild(),
59
+ adapter: new claude.ClaudeAdapter(config.loadConfig()),
60
+ dbPath: config.dbFile,
61
+ legacyFleetPath: config.legacyFleetFile,
62
+ pidPath: config.daemonPidFile,
63
+ ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
64
+ });
65
+
66
+ process.on('SIGTERM', () => {
67
+ handle.stop();
68
+ process.exit(0);
69
+ });
70
+ },
71
+ }),
72
+ 'hook-report': () =>
73
+ defineCommand({
74
+ meta: {
75
+ name: 'hook-report',
76
+ description: 'Forward a hook event from a wrangled session to the atc socket',
77
+ hidden: true,
78
+ },
79
+ async run() {
80
+ const reporter = await import('./hook-report');
81
+
82
+ await reporter.runHookReport();
83
+ },
84
+ }),
85
+ statusline: () =>
86
+ defineCommand({
87
+ meta: {
88
+ name: 'statusline',
89
+ description: 'Render the chained statusline for a wrangled session',
90
+ hidden: true,
91
+ },
92
+ async run() {
93
+ const statusline = await import('./statusline');
94
+
95
+ await statusline.runStatusline();
96
+ },
97
+ }),
98
+ },
99
+ });
100
+
101
+ await runMain(main);
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The process environment with any enclosing Claude Code session scrubbed
3
+ * out. A claude spawned with those variables intact behaves as a child of
4
+ * the enclosing session — its transcript lands inside the parent's instead
5
+ * of its own project directory, which breaks resume and handoff.
6
+ */
7
+ export function collectCleanEnv(
8
+ extra: Readonly<Record<string, string>> = {},
9
+ ): Record<string, string> {
10
+ const env: Record<string, string> = {};
11
+
12
+ for (const [key, value] of Object.entries(process.env)) {
13
+ if (value === undefined || key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE_')) {
14
+ continue;
15
+ }
16
+
17
+ env[key] = value;
18
+ }
19
+
20
+ return { ...env, ...extra };
21
+ }
package/src/config.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from './report';
5
+
6
+ export interface Config {
7
+ claudeBin: string;
8
+ claudeArgs: string[];
9
+ }
10
+
11
+ const DEFAULTS: Config = {
12
+ claudeBin: 'claude',
13
+ claudeArgs: [],
14
+ };
15
+
16
+ const configDir = join(homedir(), '.config', 'atc');
17
+
18
+ export const stateDir = join(homedir(), '.local', 'state', 'atc');
19
+ export const socketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc.sock');
20
+ export const daemonSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.sock');
21
+ export const statusFile = join(stateDir, 'status.json');
22
+ export const dbFile = join(stateDir, 'atc.db');
23
+ export const legacyFleetFile = join(stateDir, 'fleet.json');
24
+ export const daemonPidFile = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.pid');
25
+
26
+ export function loadConfig(): Config {
27
+ mkdirSync(configDir, { recursive: true });
28
+ mkdirSync(stateDir, { recursive: true });
29
+
30
+ const file = join(configDir, 'config.json');
31
+
32
+ if (!existsSync(file)) {
33
+ writeFileSync(file, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
34
+
35
+ return { ...DEFAULTS };
36
+ }
37
+
38
+ try {
39
+ const raw = readFileSync(file, 'utf8');
40
+ const parsed: unknown = JSON.parse(raw);
41
+
42
+ if (!isRecord(parsed)) {
43
+ return { ...DEFAULTS };
44
+ }
45
+
46
+ const claudeBin =
47
+ typeof parsed['claudeBin'] === 'string' ? parsed['claudeBin'] : DEFAULTS.claudeBin;
48
+
49
+ const claudeArgs = Array.isArray(parsed['claudeArgs'])
50
+ ? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
51
+ : DEFAULTS.claudeArgs;
52
+
53
+ return { claudeBin, claudeArgs };
54
+ } catch {
55
+ return { ...DEFAULTS };
56
+ }
57
+ }
@@ -0,0 +1,131 @@
1
+ import { DaemonError } from './daemon-error';
2
+ import { OutboundQueue } from './outbound-queue';
3
+ import { PROTOCOL_V, decodeMessage, encodeMessage } from './protocol';
4
+ import type { EventMsg, ResponseMsg } from './protocol';
5
+
6
+ interface Pending {
7
+ readonly resolve: (ok: Readonly<Record<string, unknown>>) => void;
8
+ readonly reject: (err: Readonly<DaemonError>) => void;
9
+ }
10
+
11
+ /**
12
+ * A protocol connection to the daemon: correlated request/response plus an
13
+ * event callback. `open` connects the socket; `sendHello` completes the
14
+ * handshake and must come first.
15
+ */
16
+ export class DaemonClient {
17
+ onEvent: (event: EventMsg) => void = () => {};
18
+
19
+ private queue: OutboundQueue | null = null;
20
+
21
+ private buffer = '';
22
+
23
+ private nextID = 1;
24
+
25
+ private readonly pending = new Map<number, Pending>();
26
+
27
+ private socket: { end: () => void } | null = null;
28
+
29
+ static async open(socketPath: string): Promise<DaemonClient> {
30
+ const client = new DaemonClient();
31
+
32
+ const socket = await Bun.connect({
33
+ unix: socketPath,
34
+ socket: {
35
+ data(_s, buf) {
36
+ client.applyChunk(buf.toString());
37
+ },
38
+ drain() {
39
+ client.queue?.drain();
40
+ },
41
+ close() {
42
+ client.drainPending('connection closed');
43
+ },
44
+ error() {},
45
+ },
46
+ });
47
+
48
+ client.socket = socket;
49
+
50
+ client.queue = new OutboundQueue(socket, 8 * 1024 * 1024);
51
+
52
+ return client;
53
+ }
54
+
55
+ sendHello(build: string): Promise<Readonly<Record<string, unknown>>> {
56
+ return this.sendRequest('daemon.hello', { client: build, auth: { scheme: 'none' } });
57
+ }
58
+
59
+ sendRequest(
60
+ m: string,
61
+ p?: Readonly<Record<string, unknown>>,
62
+ ): Promise<Readonly<Record<string, unknown>>> {
63
+ const id = this.nextID++;
64
+ const resolvers = Promise.withResolvers<Readonly<Record<string, unknown>>>();
65
+
66
+ this.pending.set(id, { resolve: resolvers.resolve, reject: resolvers.reject });
67
+ this.queue?.send(encodeMessage({ v: PROTOCOL_V, id, m, ...(p === undefined ? {} : { p }) }));
68
+
69
+ return resolvers.promise;
70
+ }
71
+
72
+ stop(): void {
73
+ this.socket?.end();
74
+ this.drainPending('client closed');
75
+ }
76
+
77
+ private applyChunk(chunk: string): void {
78
+ this.buffer += chunk;
79
+
80
+ const lines = this.buffer.split('\n');
81
+
82
+ this.buffer = lines.pop() ?? '';
83
+
84
+ for (const line of lines) {
85
+ if (line.trim() === '') {
86
+ continue;
87
+ }
88
+
89
+ this.applyLine(line);
90
+ }
91
+ }
92
+
93
+ private applyLine(line: string): void {
94
+ const decoded = decodeMessage(line);
95
+
96
+ if (decoded.kind === 'event') {
97
+ this.onEvent(decoded.msg);
98
+
99
+ return;
100
+ }
101
+
102
+ if (decoded.kind !== 'response') {
103
+ return;
104
+ }
105
+
106
+ this.applyResponse(decoded.msg);
107
+ }
108
+
109
+ private applyResponse(msg: ResponseMsg): void {
110
+ const waiter = this.pending.get(msg.id);
111
+
112
+ if (waiter === undefined) {
113
+ return;
114
+ }
115
+
116
+ this.pending.delete(msg.id);
117
+
118
+ if (msg.err === undefined) {
119
+ waiter.resolve(msg.ok ?? {});
120
+ } else {
121
+ waiter.reject(new DaemonError(msg.err.code, msg.err.msg));
122
+ }
123
+ }
124
+
125
+ private drainPending(reason: string): void {
126
+ for (const [id, waiter] of this.pending) {
127
+ this.pending.delete(id);
128
+ waiter.reject(new DaemonError('internal', reason));
129
+ }
130
+ }
131
+ }