SLOPSHOPPER

atc-bridge

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

newguardprompttoolprocess
★ 3v1.0.0MITupdated 2026-10-09zgeoff/atc/mods/atc-bridge
A shopper browsing a rack in a slop shop
README

<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">

Install

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.

Use

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:

MarkState
●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.

KeyAction
Ctrl-Spaceopen or close the list
Tabattach the session that needs you most
Enterattach the selected session
/filter by name or directory
aacknowledge a notification without attaching
Kkill the selected session; on a host atc can destroy, a dead session asks once more before forgetting destroys the host
qquit 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.

Grok and Codex

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.

Beyond the keyboard

  • 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.
  • A Claude session takes messages from other tools. 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.
  • A Claude-compatible backend runs as its own agent in the same fleet. The gateways section covers the setup.

Configuration

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.

Documentation

docs/ covers the architecture, the daemon, and the wire protocol.

Source 2 files
hooks/register.ts 355 lines
1import 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>', '&lt;/atc-message&gt;');
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('&', '&amp;')
276    .replaceAll('"', '&quot;')
277    .replaceAll('<', '&lt;')
278    .replaceAll('>', '&gt;');
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}
355
hooks/atc-cli.ts 2 lines
1export const ATC_CLI: readonly string[] = ['atc'];
2