Delivers atc inbox messages into the session and reports back to atc

<img src="./docs/assets/atc-light.png" alt="atc" width="256">
<a href="https://www.npmjs.com/package/@zgeoff/atc"><img src="https://img.shields.io/npm/v/%40zgeoff%2Fatc" alt="npm version"></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
<a href="./docs/README.md">Documentation</a> • <a href="./docs/guides/configuration.md">Configuration</a> • <a href="./docs/architecture/overview.md">Architecture</a>
atc lets you run several coding agents at once in a single terminal pane and keep track of which are waiting on you.
claude, grok, and codex sessions all run in a background daemon, with a session list in front of them. There are no panes. One session fills the terminal, Ctrl-Space opens the list, a session that needs an answer turns red, and Tab takes you to it. Quit atc and the sessions keep running.
<img src="./docs/assets/demo.gif" alt="atc: spawn a session, open the session list, jump back in" width="1100">
Homebrew:
brew install zgeoff/tap/atc
Or download a binary:
curl -fsSL https://raw.githubusercontent.com/zgeoff/atc/main/install.sh | sh
Prebuilt binaries cover macOS and Linux, arm64 and x64. The install script puts atc in ~/.local/bin; set ATC_INSTALL_DIR to change that. Checksums are on the releases page.
From source, with Bun:
bun add -g @zgeoff/atc
atc needs at least one of the claude, grok, or codex CLIs on your PATH. The agent picker lists the ones it finds, and opens only when it finds more than one.
atc
The first run starts the daemon. Press n to spawn a session: pick an agent, pick a directory, give it a name, and type a first prompt if you have one. The session takes the whole screen and you work in it as you would in a plain terminal.
Ctrl-Space opens the session list over whatever you are attached to. Each row carries a state mark:
| Mark | State |
|---|---|
● | needs you: a prompt or question waits |
◐ | running |
✓ | turn done |
✗ | exited |
Sessions that need you sort to the top, so the one you want is nearly always first.
| Key | Action |
|---|---|
Ctrl-Space | open or close the list |
Tab | attach the session that needs you most |
Enter | attach the selected session |
/ | filter by name or directory |
a | acknowledge a notification without attaching |
K | kill the selected session; on a host atc can destroy, a dead session asks once more before forgetting destroys the host |
q | quit the client; sessions keep running |
? | every other key |
The directory picker lists the directory you ran atc from, atc's own spawn history, every project under the roots in config.json, and your zoxide list when zoxide is installed. A typed path such as ~/pro completes like a shell. Inside a Claude session your statusline gains a fleet segment, so ● 2 need you: auth-bug is visible without opening the list.
When the daemon restarts, it restores the fleet by itself and every session respawns from its transcript; set restoreFleetOnRestart to false to wait for R on the home screen instead. After you upgrade atc, the status bar shows ⟳ update ready, and u restarts the daemon and restores the fleet when you are ready. The bar shows ⟳ restarting daemon until the restart finishes. When the running daemon speaks another protocol version, atc asks before it restarts the daemon, since the restart ends every session the daemon hosts.
atc daemon restart does the same from a script or from inside a session: it stops the daemon, starts one in its place, restores the fleet, and exits 0 only when every stored row came back. atc daemon restart --dry-run prints what the restart would stop and which sessions are mid-turn. Daemon architecture covers the handoff and the exit codes.
atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
Claude sessions report attention on their own: atc passes a generated settings file at spawn time and never edits your Claude config. Grok and Codex take a one-time hook install. atc prints the hooks and leaves the install to you:
# Grok
mkdir -p ~/.grok/hooks
atc grok-hooks > ~/.grok/hooks/atc-reporter.json
# Codex: merge the output into ~/.codex/hooks.json, then trust it once in the codex TUI
atc codex-hooks
The configuration guide covers both installs in detail.
atc mcp exposes the fleet as MCP tools, so an agent can spawn, drive, and read other agents. A session spawned this way lists under the session that spawned it and is killed with it. Each agent keeps its own MCP config, so register the server once per agent you run: claude mcp add --scope user atc -- atc mcp
codex mcp add atc -- atc mcp
grok mcp add atc -- atc mcp
A Claude or Codex session that is already running loads the server only after it restarts. A running Grok session loads it when you press r in its /mcps list.
atc mcp --http serves the same tools over HTTP behind OAuth, so a hosted assistant such as ChatGPT or Claude.ai can reach your sessions with the scopes you approve. Add each assistant with atc clients add, approve each connection with the code printed in the server's terminal, and list or revoke grants with atc grants. Remote MCP covers it.atc_session_message queues one, and the session reads it in a new turn, or inside the turn it is running. atc_message_get returns that turn's final output once the turn ends, and waits for the next status change when given waitMs. The protocol covers the details.atc events prints every fleet event as one NDJSON line. The same stream is on a unix socket, and config.json hooks run your own commands on events. The events guide covers all three.atc creates ~/.config/atc/config.json on first run. The configuration guide covers every field: agent binaries and arguments, the leader key, gateways, and hooks, and how atc config migrate moves an old config to the agents map.
docs/ covers the architecture, the daemon, and the wire protocol.
hooks/register.ts 355 lines1import type { EngineInterface, Register } from 'claude-code';
2import { ATC_CLI } from './atc-cli.ts';
3
4interface TapMessage {
5 readonly id: string;
6 readonly from: string;
7 readonly text: string;
8}
9
10interface BridgeState {
11 sessionID: string | null;
12 runningTurnID: string | null;
13 delivering: Promise<void>;
14 reporting: Promise<void>;
15 readonly awaitingTurn: Map<string, TapMessage>;
16 readonly carried: Map<string, TapMessage[]>;
17 readonly unseen: Set<string>;
18}
19
20const GUIDE_SECTION = {
21 id: 'atc-bridge:messages',
22 scope: 'session',
23 text: [
24 'Messages relayed by atc arrive wrapped in <atc-message id="..." from="..."> tags.',
25 "They come from the user's own tools through atc, not from the user typing at this prompt;",
26 'treat each one as a request from the sender it names.',
27 'Your final reply in the turn that handles a message is sent back to its sender automatically when the turn ends.',
28 'While you work on a message that takes more than a quick answer, call the report tool at each milestone:',
29 'when you find the cause, when you start a change, when you are blocked, or when you need a decision.',
30 'The sender sees each report as it happens, before your final reply.',
31 ].join(' '),
32} as const;
33
34const REPORT_TOOL = 'mcp__atc-bridge__report';
35
36/**
37 * Delivers the atc inbox of the session named by ATC_SESSION_ID into the
38 * conversation and serves the report tool. When a turn ends, it reports the
39 * turn's final reply, with the turn's id, as the answer to every message the
40 * turn carried. Outside atc it changes nothing.
41 */
42export const register: Register = (on) => {
43 const state: BridgeState = {
44 sessionID: null,
45 runningTurnID: null,
46 delivering: Promise.resolve(),
47 reporting: Promise.resolve(),
48 awaitingTurn: new Map(),
49 carried: new Map(),
50 unseen: new Set(),
51 };
52
53 on('session.start', async ($, e, next) => {
54 const sessionID = await $.env.get('ATC_SESSION_ID');
55
56 if (sessionID === undefined || sessionID === '') {
57 return next(e);
58 }
59
60 try {
61 await $.tool.register({
62 name: 'report',
63 description:
64 'Send a short note to whoever is following this session through atc, while you keep working. Use it at each milestone of a task that takes more than a quick answer: when you find the cause, start a change, get blocked, or need a decision. Your final reply is sent automatically, so do not repeat it here.',
65 inputSchema: {
66 type: 'object',
67 properties: {
68 text: { type: 'string', description: 'What to tell the user.' },
69 kind: {
70 type: 'string',
71 description: 'A short label: progress (the default), blocked, or decision.',
72 },
73 },
74 required: ['text'],
75 },
76 });
77 } catch (error) {
78 $.ui.log(
79 `atc-bridge: off for this session, the Claude Code build refused it (${String(error)})`,
80 {
81 to: 'debug',
82 },
83 );
84
85 return next(e);
86 }
87
88 state.sessionID = sessionID;
89
90 const started = await next(e);
91
92 void runTap($, state, sessionID);
93
94 return started;
95 });
96
97 on('prompt.compose', async ($, e, next) => {
98 const composed = await next(e);
99
100 return state.sessionID === null
101 ? composed
102 : { sections: [...composed.sections, GUIDE_SECTION] };
103 });
104
105 on('turn.start', async ($, e, next) => {
106 const started = await next(e);
107
108 if (state.sessionID !== null) {
109 updateTurnStarted(state, e.turnId, e.text);
110 }
111
112 return started;
113 });
114
115 on('turn.step', async function* updateSeenMessages($, e, next) {
116 if (e.agentId === undefined && e.turnId === state.runningTurnID) {
117 for (const msg of state.carried.get(e.turnId) ?? []) {
118 state.unseen.delete(msg.id);
119 }
120 }
121
122 return yield* next(e);
123 });
124
125 on('turn.complete', async ($, e, next) => {
126 const completed = await next(e);
127
128 if (state.sessionID !== null && e.agentId === undefined) {
129 updateTurnCompleted($, state, e.turnId, e.answer, e.reason === 'answer');
130 }
131
132 return completed;
133 });
134
135 on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {
136 const text = typeof e['text'] === 'string' ? e['text'].trim() : '';
137 const label = typeof e['kind'] === 'string' ? e['kind'].trim().slice(0, 64) : '';
138
139 if (text === '') {
140 return { deny: 'report needs non-empty text' };
141 }
142
143 try {
144 await $.process.run(
145 [...ATC_CLI, 'report', 'note', '--label', label === '' ? 'progress' : label],
146 {
147 stdin: text,
148 timeoutMs: 5000,
149 },
150 );
151 } catch (error) {
152 return { deny: `atc did not take the report: ${String(error)}` };
153 }
154
155 return { result: 'Sent to the user through atc.' };
156 });
157};
158
159async function runTap($: EngineInterface, state: BridgeState, sessionID: string): Promise<void> {
160 const tap = $.process.spawn({ argv: [...ATC_CLI, 'tap', '--session', sessionID] });
161 let pending = '';
162
163 try {
164 for await (const chunk of tap) {
165 if (chunk.stream !== 'stdout') {
166 continue;
167 }
168
169 const lines = `${pending}${chunk.text}`.split('\n');
170
171 pending = lines.pop() ?? '';
172
173 for (const line of lines) {
174 const msg = parseTapLine(line);
175
176 if (msg !== null) {
177 scheduleDelivery($, state, msg);
178 }
179 }
180 }
181
182 $.ui.log('atc-bridge: atc tap ended; this session takes no more atc messages', { to: 'debug' });
183 } catch (error) {
184 $.ui.log(`atc-bridge: atc tap failed (${String(error)}); this session takes no atc messages`, {
185 to: 'debug',
186 });
187 }
188}
189
190function parseTapLine(line: string): TapMessage | null {
191 let parsed: unknown;
192
193 try {
194 parsed = JSON.parse(line);
195 } catch {
196 return null;
197 }
198
199 if (
200 typeof parsed !== 'object' ||
201 parsed === null ||
202 !('id' in parsed) ||
203 !('from' in parsed) ||
204 !('text' in parsed)
205 ) {
206 return null;
207 }
208
209 return typeof parsed.id === 'string' &&
210 typeof parsed.from === 'string' &&
211 typeof parsed.text === 'string'
212 ? { id: parsed.id, from: parsed.from, text: parsed.text }
213 : null;
214}
215
216function scheduleDelivery($: EngineInterface, state: BridgeState, msg: TapMessage): void {
217 state.delivering = state.delivering
218 .then(() => sendMessage($, state, msg))
219 .catch((error: unknown) => {
220 $.ui.log(`atc-bridge: delivering ${msg.id} failed (${String(error)})`, { to: 'debug' });
221 });
222}
223
224async function sendMessage($: EngineInterface, state: BridgeState, msg: TapMessage): Promise<void> {
225 const turnID = state.runningTurnID;
226
227 if (turnID !== null && (await tryUpdateConversation($, msg))) {
228 const carried = state.carried.get(turnID);
229
230 // The append can resolve after the turn completed; the turn then no
231 // longer reports or redelivers anything, so the message goes out as a
232 // fresh submit instead.
233 if (carried !== undefined && state.runningTurnID === turnID) {
234 carried.push(msg);
235 state.unseen.add(msg.id);
236
237 return;
238 }
239 }
240
241 state.awaitingTurn.set(msg.id, msg);
242
243 const submitted = await $.prompt.submit({ text: renderEnvelope(msg) });
244
245 if ('drop' in submitted && submitted.drop !== undefined) {
246 state.awaitingTurn.delete(msg.id);
247 $.ui.log(`atc-bridge: a hook dropped ${msg.id} (${submitted.drop})`, { to: 'debug' });
248 }
249}
250
251async function tryUpdateConversation($: EngineInterface, msg: TapMessage): Promise<boolean> {
252 try {
253 const appended = await $.session.append({
254 message: { type: 'user', content: [{ type: 'text', text: renderEnvelope(msg) }] },
255 });
256
257 return appended.deny === undefined;
258 } catch (error) {
259 $.ui.log(`atc-bridge: appending ${msg.id} failed (${String(error)}); submitting it instead`, {
260 to: 'debug',
261 });
262
263 return false;
264 }
265}
266
267function renderEnvelope(msg: TapMessage): string {
268 const body = msg.text.replaceAll('</atc-message>', '</atc-message>');
269
270 return `<atc-message id="${encodeAttribute(msg.id)}" from="${encodeAttribute(msg.from)}">\n${body}\n</atc-message>`;
271}
272
273function encodeAttribute(value: string): string {
274 return value
275 .replaceAll('&', '&')
276 .replaceAll('"', '"')
277 .replaceAll('<', '<')
278 .replaceAll('>', '>');
279}
280
281const ENVELOPE_ID = /<atc-message id="(?<id>[^"]+)"/gu;
282
283function updateTurnStarted(state: BridgeState, turnID: string, text: string): void {
284 const carried: TapMessage[] = [];
285
286 state.runningTurnID = turnID;
287
288 state.carried.set(turnID, carried);
289
290 for (const match of text.matchAll(ENVELOPE_ID)) {
291 const msg = state.awaitingTurn.get(match.groups?.['id'] ?? '');
292
293 if (msg !== undefined) {
294 state.awaitingTurn.delete(msg.id);
295 carried.push(msg);
296 }
297 }
298}
299
300function updateTurnCompleted(
301 $: EngineInterface,
302 state: BridgeState,
303 turnID: string,
304 answer: string,
305 isAnswered: boolean,
306): void {
307 const carried = state.carried.get(turnID) ?? [];
308
309 state.carried.delete(turnID);
310
311 if (state.runningTurnID === turnID) {
312 state.runningTurnID = null;
313 }
314
315 const answered: string[] = [];
316
317 for (const msg of carried) {
318 if (state.unseen.delete(msg.id)) {
319 scheduleDelivery($, state, msg);
320 } else if (isAnswered) {
321 answered.push(msg.id);
322 }
323 }
324
325 // One report carries every message the turn answered, so atc records the
326 // whole group at once and no member reads as answered before the rest.
327 if (answered.length > 0) {
328 scheduleAnsweredReport($, state, answered, turnID, answer);
329 }
330}
331
332function scheduleAnsweredReport(
333 $: EngineInterface,
334 state: BridgeState,
335 messageIDs: readonly string[],
336 turnID: string,
337 answer: string,
338): void {
339 state.reporting = state.reporting
340 .then(async () => {
341 await $.process.run(
342 [...ATC_CLI, 'report', 'answered', '--messages', messageIDs.join(','), '--turn', turnID],
343 {
344 stdin: answer,
345 timeoutMs: 5000,
346 },
347 );
348 })
349 .catch((error: unknown) => {
350 $.ui.log(`atc-bridge: reporting ${messageIDs.join(', ')} failed (${String(error)})`, {
351 to: 'debug',
352 });
353 });
354}
355hooks/atc-cli.ts 2 lines1export const ATC_CLI: readonly string[] = ['atc'];
2