@zgeoff/atc 1.0.3 → 2.0.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/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
  <h1>atc</h1>
3
3
 
4
4
  <p>
5
- Control tower for Claude Code and Grok Build sessions: stock <code>claude</code> and
6
- <code>grok</code> instances in PTYs behind a keyboard-driven session list with hook-driven
5
+ Control tower for coding-agent sessions: stock <code>claude</code>, <code>grok</code>, and
6
+ <code>codex</code> instances in PTYs behind a keyboard-driven session list with hook-driven
7
7
  attention routing — no panes, no tiling, no mouse.
8
8
  </p>
9
9
 
@@ -14,8 +14,8 @@
14
14
 
15
15
  <p>
16
16
  <a href="./docs/README.md">Documentation</a> •
17
- <a href="./docs/architecture/overview.md">Architecture</a> •
18
- <a href="./AGENTS.md">Agent Guidelines</a>
17
+ <a href="./docs/guides/configuration.md">Configuration</a> •
18
+ <a href="./docs/architecture/overview.md">Architecture</a>
19
19
  </p>
20
20
  </div>
21
21
 
@@ -26,210 +26,93 @@ bun add -g @zgeoff/atc
26
26
  atc
27
27
  ```
28
28
 
29
- Needs [Bun](https://bun.sh) (atc runs from source through it) and the `claude` CLI on your PATH.
30
- Grok sessions also need the `grok` CLI, and Codex sessions the `codex` CLI — the agent picker lists
31
- only the agents whose binary it can find. From a checkout, `bun src/cli.ts` runs the same thing. atc
32
- is built to pair with [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker
33
- feeds on its frecency list, so with zoxide installed every directory you visit is two keystrokes
34
- from a session. Without it the picker falls back to atc's own spawn history.
29
+ atc needs [Bun](https://bun.sh) and the `claude` CLI on your PATH. Grok sessions need the `grok`
30
+ CLI, and Codex sessions the `codex` CLI — the agent picker lists only the agents whose binary it can
31
+ find. With [zoxide](https://github.com/ajeetdsouza/zoxide) installed, the spawn directory picker
32
+ feeds on its frecency list, so every directory you visit is two keystrokes from a session; without
33
+ it, the picker falls back to atc's own spawn history.
35
34
 
36
- The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
37
- debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
38
- fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches atc).
35
+ The first invocation auto-spawns the daemon; the TUI is a thin client, so quitting or crashing it
36
+ leaves every session running. atc runs fine nested inside zellij or tmux — give the pane locked mode
37
+ so the leader key reaches atc.
39
38
 
40
39
  ## Keys
41
40
 
42
- | Key | Where | Action |
43
- | --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
44
- | leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
45
- | `n` | home/overlay | spawn: pick agent → dir (zoxide + history, fuzzy) → name → optional first prompt. Fresh clients default to Claude; last-used is the last deliberate-spawn SessionStart. |
46
- | `r` | home/overlay | adopt: pick agent → dir → name. Claude opens `claude --resume`, Codex `codex resume`. Grok opens plain `grok`. |
47
- | `R` | home | restore last fleet after a daemon death — each session with its matching CLI |
48
- | `j`/`k`/`↑`/`↓` | overlay/picker | move |
49
- | `Enter` | overlay | attach (auto-acks) |
50
- | `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
51
- | `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
52
- | `a` | overlay | ack notification without attaching |
53
- | `p` | overlay | pin or unpin the selected session — pinned sessions stay at the top of the list |
54
- | `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
55
- | `H` | overlay | eject to headless. Hidden and ignored on a row whose agent has no headless handoff. |
56
- | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
57
- | `y` | overlay | yank the resume command (`claude --resume <id>`, `grok --resume <id>`, or `codex resume <id>`) |
58
- | `Y` | overlay | eject: yank the resume command, then kill the session here |
59
- | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`, even across daemon restarts; a second `K` forgets it |
60
- | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
61
- | `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
62
- | `q` | home/overlay | quit the client — sessions keep running in the daemon |
41
+ | Key | Where | Action |
42
+ | --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
43
+ | leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable |
44
+ | `n` | home/overlay | spawn: pick agent → dir → name → optional first prompt |
45
+ | `r` | home/overlay | adopt an existing session: pick agent → dir → name |
46
+ | `R` | home | restore the last fleet after a daemon death |
47
+ | `j`/`k`/`↑`/`↓` | overlay/picker | move |
48
+ | `Enter` | overlay | attach (auto-acks) |
49
+ | `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
50
+ | `/` | overlay | fuzzy filter by name/dir, `⏎` attach top match, `esc` clear |
51
+ | `a` | overlay | ack a notification without attaching |
52
+ | `p` | overlay | pin or unpin — pinned sessions stay at the top of the list |
53
+ | `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
54
+ | `H` | overlay | eject to headless — hidden on agents with no headless handoff |
55
+ | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
56
+ | `y` | overlay | yank the resume command for the session's agent |
57
+ | `Y` | overlay | eject: yank the resume command, then kill the session here |
58
+ | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
59
+ | `u` | overlay | restart an outdated daemon and restore the fleet — offered while `⟳ update ready` shows |
60
+ | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
61
+ | `q` | home/overlay | quit the client — sessions keep running in the daemon |
63
62
 
64
63
  The overlay orders sessions by pinned first, then attention state, then most recently attached, so
65
- the session you want is nearly always near the top. The grouped view (`g`) keeps that order but
66
- clusters sessions under dim repository headers, with pinned sessions leading in their own cluster; a
67
- git worktree clusters with its main repository, and a directory outside any repository stands alone.
68
- A reserved column after the pin mark shows a dim letter per agent — `g` on Grok rows, `x` on Codex
69
- rows, and whatever letter a gateway was given; Claude rows keep a space so names stay aligned. The
70
- `atc_session_update` MCP tool renames and pins sessions, so an agent can organise the fleet for you.
71
-
72
- Revive (`P`) resumes the session from its saved transcript, so a session killed before its first
73
- exchange has nothing on disk yet, and the overlay says so in its message column instead of resuming.
74
- Headless eject (`H`) uses the same transcript, and a gateway session ejects to its own backend. Grok
75
- and Codex have no headless handoff, so the key is hidden on their rows.
76
-
77
- Everything else is passed through to the focused session, which owns the full screen. Fleet state
78
- renders inside Claude Code's own status line (injected via the same `--settings` file): your
79
- configured statusline runs first, and atc appends `▏● 2 need you: auth-bug`. atc draws its own
80
- status bar only on the home and overlay screens.
81
-
82
- ## How state tracking works
83
-
84
- Spawned Claude sessions get a `--settings` file injecting `Notification`, `Stop`,
85
- `UserPromptSubmit`, and `SessionEnd` hooks that report to a unix socket
86
- (`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude settings are untouched; sessions you start outside
87
- atc are unaffected. Grok attention comes from a dedicated hook file at
88
- `$GROK_HOME/hooks/atc-reporter.json` (`~/.grok` when `GROK_HOME` is unset). atc never writes that
89
- path. Install it yourself:
64
+ the session you want is nearly always near the top. Session states: red `●` needs you, cyan `◐`
65
+ running, green `✓` turn done, gray `✗` exited. Everything else passes through to the focused
66
+ session, which owns the full screen; while attached, atc appends a fleet segment
67
+ (`▏● 2 need you: auth-bug`) to your own Claude Code statusline.
68
+
69
+ ## Attention hooks
70
+
71
+ Claude sessions report attention automatically: atc injects its hooks through a generated
72
+ `--settings` file per spawn and never touches your own Claude config. Grok and Codex hooks are a
73
+ one-time self-install — atc prints them and never writes into your agent config either:
90
74
 
91
75
  ```sh
76
+ # Grok: install the hook file
92
77
  mkdir -p ~/.grok/hooks
93
78
  atc grok-hooks > ~/.grok/hooks/atc-reporter.json
94
- ```
95
79
 
96
- Codex attention comes from hook entries in `$CODEX_HOME/hooks.json` (`~/.codex` when `CODEX_HOME` is
97
- unset). atc never writes that path either — `atc codex-hooks` prints the entries to merge in:
98
-
99
- ```sh
80
+ # Codex: print the entries, merge them into ~/.codex/hooks.json,
81
+ # then trust them once in the codex TUI
100
82
  atc codex-hooks
101
83
  ```
102
84
 
103
- Codex parses new hooks but never runs them until you trust them once: open `codex`, review the atc
104
- hooks in its hooks list, and approve them. Sessions you start outside atc report events too; the
105
- reporter exits immediately when no atc session id is present.
106
-
107
- `atc grok-hooks` prints this file, with the `hook-report` command resolved for this install:
108
-
109
- ```json
110
- {
111
- "hooks": {
112
- "SessionStart": [
113
- { "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
114
- ],
115
- "SessionEnd": [
116
- { "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
117
- ],
118
- "UserPromptSubmit": [
119
- { "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
120
- ],
121
- "Stop": [{ "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }],
122
- "StopFailure": [
123
- { "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
124
- ],
125
- "StopCancelled": [
126
- { "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
127
- ],
128
- "Notification": [
129
- { "hooks": [{ "type": "command", "command": "atc hook-report", "timeout": 5 }] }
130
- ]
131
- }
132
- }
133
- ```
85
+ The [configuration guide](./docs/guides/configuration.md#attention-hooks-grok-and-codex) covers the
86
+ detail; [agent integration](./docs/architecture/overview.md#agent-integration) covers how the
87
+ reporting works.
134
88
 
135
- A missing file is a Grok PTY without hook-driven attention. States: red `●` needs you, cyan `◐`
136
- running, green `✓` turn done, gray `✗` exited. The status bar turns red and names the most urgent
137
- session.
138
-
139
- ## Config
140
-
141
- `~/.config/atc/config.json` (created with defaults on first run):
142
-
143
- ```json
144
- {
145
- "claudeBin": "claude",
146
- "claudeArgs": [],
147
- "grokBin": "grok",
148
- "grokArgs": [],
149
- "codexBin": "codex",
150
- "codexArgs": [],
151
- "gateways": {},
152
- "leader": "ctrl-space"
153
- }
154
- ```
155
-
156
- | Field | Default | Meaning |
157
- | ------------ | -------------- | ----------------------------------------------------------------------------------------------------------- |
158
- | `claudeBin` | `"claude"` | The binary spawned for Claude sessions. |
159
- | `claudeArgs` | `[]` | Prepended to every Claude spawn, e.g. `["--model", "opus"]`. |
160
- | `grokBin` | `"grok"` | The binary spawned for Grok sessions. |
161
- | `grokArgs` | `[]` | Prepended to every Grok spawn. A user `--leader` in this list is dropped; atc always appends `--no-leader`. |
162
- | `codexBin` | `"codex"` | The binary spawned for Codex sessions. |
163
- | `codexArgs` | `[]` | Prepended to every Codex spawn. |
164
- | `gateways` | `{}` | Claude-compatible backends, keyed by agent id. Each becomes its own row in the agent picker. |
165
- | `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
166
-
167
- Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
168
- `ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
169
- unknown or reserved value falls back to the default.
170
-
171
- ### Gateways
172
-
173
- A gateway runs the Claude CLI against a Claude-compatible backend, under its own agent id. Claude
174
- and GLM sessions then sit side by side in one fleet:
175
-
176
- ```json
177
- {
178
- "gateways": {
179
- "zai": {
180
- "label": "GLM (z.ai)",
181
- "mark": "z",
182
- "baseURL": "https://api.z.ai/api/anthropic",
183
- "apiKeyHelper": "~/.local/bin/atc-zai-key",
184
- "env": { "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.2" }
185
- }
186
- }
187
- }
188
- ```
89
+ ## Configuration
189
90
 
190
- | Field | Default | Meaning |
191
- | -------------- | ----------- | ----------------------------------------------------------------------------------------------- |
192
- | `baseURL` | required | The backend's Anthropic-format endpoint. An entry without one is left out of the picker. |
193
- | `label` | the id | The row shown in the agent picker. |
194
- | `mark` | the id | The overlay column letter; the first character is used. |
195
- | `bin`, `args` | `claudeBin` | The binary and leading arguments, when the backend needs a different build of the CLI. |
196
- | `apiKeyHelper` | none | Command the CLI runs to read the credential, so no token is written into atc's state directory. |
197
- | `env` | `{}` | Extra environment for the session, such as the model each Claude tier maps to. |
91
+ `~/.config/atc/config.json` is created with defaults on first run. The
92
+ [configuration guide](./docs/guides/configuration.md) covers every field:
198
93
 
199
- Two backends may be given the same `mark`. atc does not check, and a clash makes them
200
- indistinguishable in the overlay column.
201
-
202
- The id may not be `claude`, `grok`, or `codex`. atc writes one settings file per id and passes it as
203
- `--settings`, on the terminal spawn and on a headless turn alike, so a gateway session reaches its
204
- own backend rather than whatever the terminal exported.
94
+ - [Agent binaries](./docs/guides/configuration.md) — the binary and prepended arguments per agent,
95
+ e.g. `"claudeArgs": ["--model", "opus"]`.
96
+ - [Leader key](./docs/guides/configuration.md#leader) — rebind the overlay toggle when `Ctrl-Space`
97
+ is taken on your machine.
98
+ - [Gateways](./docs/guides/configuration.md#gateways) — run the Claude CLI against Claude-compatible
99
+ backends (GLM and friends), each as its own agent in one fleet.
100
+ - [Daemon hooks](./docs/guides/configuration.md#daemon-hooks) — run your own commands on fleet
101
+ events, or pipe the full NDJSON event stream from `atc events`.
102
+ - [Attention hooks](./docs/guides/configuration.md#attention-hooks-grok-and-codex) — the Grok and
103
+ Codex self-install in detail.
205
104
 
206
105
  `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
207
- sessions included. `atc_session_spawn` takes an optional `agent` id and defaults to Claude; it never
208
- reads the TUI last-used value.
106
+ sessions included:
209
107
 
210
108
  ```sh
211
109
  claude mcp add --scope user atc -- atc mcp
212
110
  ```
213
111
 
214
- Daemon state — the restorable fleet, spawn-dir history, last-used agent, and the hook-event trail —
215
- lives in `~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected
216
- statusline); the daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
217
-
218
112
  ## Crash safety
219
113
 
220
114
  A client crash or closed window costs nothing: the daemon keeps hosting the fleet, and the next
221
- `atc` reconnects. After an update, a client meeting an older daemon keeps talking to it — killing it
222
- would kill every hosted session — and shows `⟳ update ready` in the status bar; `u` in the overlay
223
- restarts the daemon and restores the fleet at a moment you choose. Only a protocol mismatch, where
224
- the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
225
- live fleet (name, cwd, agent, session id) to its SQLite store. If the daemon itself dies — crash,
226
- SIGKILL, reboot — the child processes die with it, but every session's transcript is already on
227
- disk. Start atc and press `R`: the whole fleet respawns with the matching CLI. Killed sessions (`K`,
228
- `Y` eject) stay in the fleet as exited entries — restore lists them as killed without booting a
229
- terminal, and `P` still revives them. A second `K` forgets an entry for good.
230
-
231
- Restoring shows the whole fleet immediately — every incoming session appears in the list marked
232
- "waiting to restore" — and revives one at a time, most recently active first: the next resume starts
233
- only once the previous one has reported it is up (its `SessionStart` hook), so bringing back a dozen
234
- sessions no longer launches a dozen agent processes at the same instant and pins the machine. Each
235
- row flips live as its session comes back.
115
+ `atc` reconnects. If the daemon itself dies, every session's transcript is already on disk — press
116
+ `R` and the whole fleet respawns with the matching CLI. After an update, the status bar shows
117
+ `⟳ update ready`, and `u` restarts the daemon and restores the fleet at a moment you choose. The
118
+ [recovery model](./docs/architecture/overview.md#recovery-model) covers the detail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "1.0.3",
3
+ "version": "2.0.0",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
package/src/cli.ts CHANGED
@@ -80,12 +80,14 @@ const main = defineCommand({
80
80
  const handle = await daemon.startDaemon({
81
81
  socketPath: config.daemonSocketPath,
82
82
  reporterSocketPath: config.socketPath,
83
+ eventsSocketPath: config.eventsSocketPath,
83
84
  build: getBuild(),
84
85
  adapter: claudeAdapter,
85
86
  adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
86
87
  dbPath: config.dbFile,
87
88
  legacyFleetPath: config.legacyFleetFile,
88
89
  pidPath: config.daemonPidFile,
90
+ hooks: cfg.hooks,
89
91
  restoreBootTimeoutMs,
90
92
  ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
91
93
  onQuit: () => process.exit(0),
@@ -100,6 +102,18 @@ const main = defineCommand({
100
102
  });
101
103
  },
102
104
  }),
105
+ events: () =>
106
+ defineCommand({
107
+ meta: {
108
+ name: 'events',
109
+ description: 'Stream daemon wire events to stdout as NDJSON',
110
+ },
111
+ async run() {
112
+ const events = await import('./events');
113
+
114
+ await events.runEvents();
115
+ },
116
+ }),
103
117
  'codex-hooks': () =>
104
118
  defineCommand({
105
119
  meta: {
@@ -344,16 +344,16 @@ function applyDaemonEvent(raw: EventMsg) {
344
344
  }
345
345
 
346
346
  match(event)
347
- .with({ ev: 'session.output' }, (e) => {
347
+ .with({ ev: 'SessionOutput' }, (e) => {
348
348
  if (service.getSnapshot().value === 'attached' && e.s === focusedID) {
349
349
  stdout.write(e.d);
350
350
  }
351
351
  })
352
- .with({ ev: 'session.added' }, (e) => {
352
+ .with({ ev: 'SessionAdded' }, (e) => {
353
353
  upsertMirror(e.session);
354
354
  refreshScreens();
355
355
  })
356
- .with({ ev: 'session.state' }, (e) => {
356
+ .with({ ev: 'SessionState' }, (e) => {
357
357
  const existing = fleet.find((x) => x.id === e.session.id);
358
358
 
359
359
  if (existing !== undefined) {
@@ -369,7 +369,7 @@ function applyDaemonEvent(raw: EventMsg) {
369
369
  upsertMirror(e.session);
370
370
  refreshScreens();
371
371
  })
372
- .with({ ev: 'session.renamed' }, (e) => {
372
+ .with({ ev: 'SessionRenamed' }, (e) => {
373
373
  const s = fleet.find((x) => x.id === e.s);
374
374
 
375
375
  if (s !== undefined) {
@@ -378,7 +378,7 @@ function applyDaemonEvent(raw: EventMsg) {
378
378
 
379
379
  refreshScreens();
380
380
  })
381
- .with({ ev: 'session.removed' }, (e) => {
381
+ .with({ ev: 'SessionRemoved' }, (e) => {
382
382
  fleet = fleet.filter((x) => x.id !== e.s);
383
383
 
384
384
  doneAt.delete(e.s);
@@ -393,10 +393,10 @@ function applyDaemonEvent(raw: EventMsg) {
393
393
  // Desync recovery arrives as ordinary repaint output; permission and
394
394
  // resize events matter to structured clients, not this passthrough TUI.
395
395
  .with(
396
- { ev: 'session.resized' },
397
- { ev: 'session.desync' },
398
- { ev: 'permission.requested' },
399
- { ev: 'permission.resolved' },
396
+ { ev: 'SessionResized' },
397
+ { ev: 'SessionDesync' },
398
+ { ev: 'PermissionRequested' },
399
+ { ev: 'PermissionResolved' },
400
400
  () => {},
401
401
  )
402
402
  .exhaustive();
@@ -5,66 +5,66 @@ import { toMirrorSession } from './to-mirror-session';
5
5
  import type { MirrorSession } from './to-mirror-session';
6
6
 
7
7
  export type DaemonEvent =
8
- | { readonly ev: 'session.added'; readonly session: MirrorSession }
9
- | { readonly ev: 'session.state'; readonly session: MirrorSession }
8
+ | { readonly ev: 'SessionAdded'; readonly session: MirrorSession }
9
+ | { readonly ev: 'SessionState'; readonly session: MirrorSession }
10
10
  | {
11
- readonly ev: 'session.renamed';
11
+ readonly ev: 'SessionRenamed';
12
12
  readonly s: string;
13
13
  readonly name: string;
14
14
  readonly namedBy: 'user' | 'auto' | 'agent';
15
15
  }
16
- | { readonly ev: 'session.removed'; readonly s: string }
16
+ | { readonly ev: 'SessionRemoved'; readonly s: string }
17
17
  | {
18
- readonly ev: 'session.resized';
18
+ readonly ev: 'SessionResized';
19
19
  readonly s: string;
20
20
  readonly cols: number;
21
21
  readonly rows: number;
22
22
  }
23
- | { readonly ev: 'session.output'; readonly s: string; readonly seq: number; readonly d: string }
24
- | { readonly ev: 'session.desync'; readonly s: string; readonly dropped: number }
23
+ | { readonly ev: 'SessionOutput'; readonly s: string; readonly seq: number; readonly d: string }
24
+ | { readonly ev: 'SessionDesync'; readonly s: string; readonly dropped: number }
25
25
  | {
26
- readonly ev: 'permission.requested';
26
+ readonly ev: 'PermissionRequested';
27
27
  readonly request: string;
28
28
  readonly s: string;
29
29
  readonly message: string;
30
30
  readonly respondable: boolean;
31
31
  }
32
- | { readonly ev: 'permission.resolved'; readonly request: string; readonly decision: string };
32
+ | { readonly ev: 'PermissionResolved'; readonly request: string; readonly decision: string };
33
33
 
34
34
  // Every variant is a looseObject so unrecognized keys never fail a match —
35
35
  // additive evolution on a known event still parses.
36
36
  const DAEMON_EVENT_SCHEMA = z.discriminatedUnion('ev', [
37
- z.looseObject({ ev: z.literal('session.added'), session: z.unknown() }),
38
- z.looseObject({ ev: z.literal('session.state'), session: z.unknown() }),
37
+ z.looseObject({ ev: z.literal('SessionAdded'), session: z.unknown() }),
38
+ z.looseObject({ ev: z.literal('SessionState'), session: z.unknown() }),
39
39
  z.looseObject({
40
- ev: z.literal('session.renamed'),
40
+ ev: z.literal('SessionRenamed'),
41
41
  s: z.string(),
42
42
  name: z.string(),
43
43
  namedBy: z.enum(['user', 'auto', 'agent']),
44
44
  }),
45
- z.looseObject({ ev: z.literal('session.removed'), s: z.string() }),
45
+ z.looseObject({ ev: z.literal('SessionRemoved'), s: z.string() }),
46
46
  z.looseObject({
47
- ev: z.literal('session.resized'),
47
+ ev: z.literal('SessionResized'),
48
48
  s: z.string(),
49
49
  cols: z.number(),
50
50
  rows: z.number(),
51
51
  }),
52
52
  z.looseObject({
53
- ev: z.literal('session.output'),
53
+ ev: z.literal('SessionOutput'),
54
54
  s: z.string(),
55
55
  seq: z.number(),
56
56
  d: z.string(),
57
57
  }),
58
- z.looseObject({ ev: z.literal('session.desync'), s: z.string(), dropped: z.number() }),
58
+ z.looseObject({ ev: z.literal('SessionDesync'), s: z.string(), dropped: z.number() }),
59
59
  z.looseObject({
60
- ev: z.literal('permission.requested'),
60
+ ev: z.literal('PermissionRequested'),
61
61
  request: z.string(),
62
62
  s: z.string(),
63
63
  message: z.string(),
64
64
  respondable: z.boolean(),
65
65
  }),
66
66
  z.looseObject({
67
- ev: z.literal('permission.resolved'),
67
+ ev: z.literal('PermissionResolved'),
68
68
  request: z.string(),
69
69
  decision: z.string(),
70
70
  }),
@@ -83,29 +83,29 @@ export function parseDaemonEvent(raw: EventMsg): DaemonEvent | null {
83
83
  }
84
84
 
85
85
  return match(parsed.data)
86
- .with({ ev: 'session.added' }, { ev: 'session.state' }, (d) => {
86
+ .with({ ev: 'SessionAdded' }, { ev: 'SessionState' }, (d) => {
87
87
  const session = toMirrorSession(d.session);
88
88
 
89
89
  return session === null ? null : { ev: d.ev, session };
90
90
  })
91
- .with({ ev: 'session.renamed' }, (d) => ({
91
+ .with({ ev: 'SessionRenamed' }, (d) => ({
92
92
  ev: d.ev,
93
93
  s: d.s,
94
94
  name: d.name,
95
95
  namedBy: d.namedBy,
96
96
  }))
97
- .with({ ev: 'session.removed' }, (d) => ({ ev: d.ev, s: d.s }))
98
- .with({ ev: 'session.resized' }, (d) => ({ ev: d.ev, s: d.s, cols: d.cols, rows: d.rows }))
99
- .with({ ev: 'session.output' }, (d) => ({ ev: d.ev, s: d.s, seq: d.seq, d: d.d }))
100
- .with({ ev: 'session.desync' }, (d) => ({ ev: d.ev, s: d.s, dropped: d.dropped }))
101
- .with({ ev: 'permission.requested' }, (d) => ({
97
+ .with({ ev: 'SessionRemoved' }, (d) => ({ ev: d.ev, s: d.s }))
98
+ .with({ ev: 'SessionResized' }, (d) => ({ ev: d.ev, s: d.s, cols: d.cols, rows: d.rows }))
99
+ .with({ ev: 'SessionOutput' }, (d) => ({ ev: d.ev, s: d.s, seq: d.seq, d: d.d }))
100
+ .with({ ev: 'SessionDesync' }, (d) => ({ ev: d.ev, s: d.s, dropped: d.dropped }))
101
+ .with({ ev: 'PermissionRequested' }, (d) => ({
102
102
  ev: d.ev,
103
103
  request: d.request,
104
104
  s: d.s,
105
105
  message: d.message,
106
106
  respondable: d.respondable,
107
107
  }))
108
- .with({ ev: 'permission.resolved' }, (d) => ({
108
+ .with({ ev: 'PermissionResolved' }, (d) => ({
109
109
  ev: d.ev,
110
110
  request: d.request,
111
111
  decision: d.decision,
@@ -21,14 +21,15 @@ export class AttachRegistry<TClient> {
21
21
  this.bySession.set(sessionID, clients);
22
22
  }
23
23
 
24
- detach(sessionID: SessionID, client: TClient): void {
24
+ detach(sessionID: SessionID, client: TClient): boolean {
25
25
  const clients = this.bySession.get(sessionID);
26
-
27
- clients?.delete(client);
26
+ const removed = clients?.delete(client) ?? false;
28
27
 
29
28
  if (clients !== undefined && clients.size === 0) {
30
29
  this.bySession.delete(sessionID);
31
30
  }
31
+
32
+ return removed;
32
33
  }
33
34
 
34
35
  detachAll(client: TClient): SessionID[] {
@@ -18,21 +18,21 @@ export function buildSessionEvent(
18
18
  added: () => {
19
19
  const session = findDescriptor(mgr, s.id);
20
20
 
21
- return session === null ? null : { v: PROTOCOL_V, ev: 'session.added', session };
21
+ return session === null ? null : { v: PROTOCOL_V, ev: 'SessionAdded', session };
22
22
  },
23
23
  state: () => {
24
24
  const session = findDescriptor(mgr, s.id);
25
25
 
26
- return session === null ? null : { v: PROTOCOL_V, ev: 'session.state', session };
26
+ return session === null ? null : { v: PROTOCOL_V, ev: 'SessionState', session };
27
27
  },
28
28
  renamed: () => ({
29
29
  v: PROTOCOL_V,
30
- ev: 'session.renamed',
30
+ ev: 'SessionRenamed',
31
31
  s: s.id,
32
32
  name: s.name,
33
33
  namedBy: s.namedBy,
34
34
  }),
35
- removed: () => ({ v: PROTOCOL_V, ev: 'session.removed', s: s.id }),
35
+ removed: () => ({ v: PROTOCOL_V, ev: 'SessionRemoved', s: s.id }),
36
36
  };
37
37
 
38
38
  return builders[kind]();
@@ -168,7 +168,7 @@ export class DaemonConnection {
168
168
 
169
169
  for (const [sessionID, dropped] of this.desynced) {
170
170
  this.desynced.delete(sessionID);
171
- this.sendEvent({ v: PROTOCOL_V, ev: 'session.desync', s: sessionID, dropped });
171
+ this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
172
172
  void this.ctx.resyncClient(sessionID, this);
173
173
  }
174
174
  }
@@ -2,6 +2,7 @@ import { unlinkSync, writeFileSync } from 'node:fs';
2
2
  import type { AgentAdapter } from '../agents/agent-adapter';
3
3
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
4
4
  import type { EventMsg } from '../protocol/protocol';
5
+ import type { HooksConfig } from '../shared/collect-hooks';
5
6
  import type { SessionID } from '../shared/session-id';
6
7
  import { StateStore } from '../store/state-store';
7
8
  import { AttachRegistry } from './attach-registry';
@@ -9,6 +10,8 @@ import { buildSessionEvent } from './build-session-event';
9
10
  import { DaemonConnection } from './daemon-connection';
10
11
  import type { DaemonContext, OutputClient } from './daemon-connection';
11
12
  import { startHookServer } from './hooks';
13
+ import { makeHookRunner } from './make-hook-runner';
14
+ import type { HookScope } from './make-hook-runner';
12
15
  import { PermissionRegistry } from './permission-registry';
13
16
  import { restoreFleet } from './restore-fleet';
14
17
  import { runEjectHandoff } from './run-eject-handoff';
@@ -16,6 +19,7 @@ import { ScreenModel } from './screen-model';
16
19
  import { SessionRuntime } from './session-runtime';
17
20
  import { SessionManager } from './sessions';
18
21
  import type { SessionDescriptor, SessionState } from './sessions';
22
+ import { startEventsServer } from './start-events-server';
19
23
  import { startHeadlessTurn } from './start-headless-turn';
20
24
 
21
25
  export interface DaemonOptions {
@@ -39,6 +43,14 @@ export interface DaemonOptions {
39
43
  // When set, the daemon's pid is written here and removed on stop.
40
44
  readonly pidPath?: string;
41
45
 
46
+ // When set, a read-only events socket listens here and streams every
47
+ // broadcast event as NDJSON to any subscriber, no handshake required.
48
+ readonly eventsSocketPath?: string;
49
+
50
+ // User-configured hooks, keyed by wire-event name; each broadcast event
51
+ // fires its matching commands, observational and fire-and-forget.
52
+ readonly hooks?: HooksConfig;
53
+
42
54
  // Where the statusline contract file is written; defaults to the real one.
43
55
  readonly statusPath?: string;
44
56
 
@@ -86,27 +98,55 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
86
98
  const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? []);
87
99
  const clients = new Set<DaemonConnection>();
88
100
 
89
- const emitEvent = (event: EventMsg) => {
101
+ const runHooks = makeHookRunner(opts.hooks ?? {});
102
+
103
+ const eventsServer =
104
+ opts.eventsSocketPath === undefined
105
+ ? null
106
+ : startEventsServer({
107
+ socketPath: opts.eventsSocketPath,
108
+ collectSnapshot: () =>
109
+ mgr.collectDescriptors().map((session) => ({
110
+ v: PROTOCOL_V,
111
+ ev: 'SessionAdded',
112
+ session,
113
+ })),
114
+ ...(opts.queueBytes === undefined ? {} : { queueBytes: opts.queueBytes }),
115
+ });
116
+
117
+ const emitEvent = (event: EventMsg, scope: HookScope | null = null) => {
90
118
  for (const client of clients) {
91
119
  client.sendEvent(event);
92
120
  }
121
+
122
+ eventsServer?.broadcast(event);
123
+ runHooks(event, scope);
124
+ };
125
+
126
+ const findHookScope = (sessionID: SessionID): HookScope | null => {
127
+ const s = mgr.sessions.find((x) => x.id === sessionID);
128
+
129
+ return s === undefined ? null : { cwd: s.cwd, repoRoot: s.repoRoot };
93
130
  };
94
131
 
95
132
  const registry = new PermissionRegistry();
96
133
 
97
134
  registry.onRequested = (req) => {
98
- emitEvent({
99
- v: PROTOCOL_V,
100
- ev: 'permission.requested',
101
- request: req.id,
102
- s: req.sessionID,
103
- message: req.message,
104
- respondable: req.respondable,
105
- });
135
+ emitEvent(
136
+ {
137
+ v: PROTOCOL_V,
138
+ ev: 'PermissionRequested',
139
+ request: req.id,
140
+ s: req.sessionID,
141
+ message: req.message,
142
+ respondable: req.respondable,
143
+ },
144
+ findHookScope(req.sessionID),
145
+ );
106
146
  };
107
147
 
108
148
  registry.onResolved = (id, decision) => {
109
- emitEvent({ v: PROTOCOL_V, ev: 'permission.resolved', request: id, decision });
149
+ emitEvent({ v: PROTOCOL_V, ev: 'PermissionResolved', request: id, decision });
110
150
  };
111
151
 
112
152
  // One runtime per live session: its screen model, output sequence
@@ -161,7 +201,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
161
201
 
162
202
  client.sendOutput(
163
203
  sessionID,
164
- { v: PROTOCOL_V, ev: 'session.output', s: sessionID, seq, d: chunk },
204
+ { v: PROTOCOL_V, ev: 'SessionOutput', s: sessionID, seq, d: chunk },
165
205
  chunk.length,
166
206
  );
167
207
  }
@@ -190,13 +230,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
190
230
  runtime.dims = dims;
191
231
  }
192
232
 
193
- emitEvent({
194
- v: PROTOCOL_V,
195
- ev: 'session.resized',
196
- s: sessionID,
197
- cols: dims.cols,
198
- rows: dims.rows,
199
- });
233
+ emitEvent(
234
+ {
235
+ v: PROTOCOL_V,
236
+ ev: 'SessionResized',
237
+ s: sessionID,
238
+ cols: dims.cols,
239
+ rows: dims.rows,
240
+ },
241
+ findHookScope(sessionID),
242
+ );
200
243
  };
201
244
 
202
245
  // Debounced so two clients resizing in opposite directions cannot produce
@@ -258,7 +301,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
258
301
 
259
302
  seq++;
260
303
 
261
- const event: EventMsg = { v: PROTOCOL_V, ev: 'session.output', s: s.id, seq, d: chunk };
304
+ const event: EventMsg = { v: PROTOCOL_V, ev: 'SessionOutput', s: s.id, seq, d: chunk };
262
305
 
263
306
  for (const conn of conns) {
264
307
  conn.sendOutput(s.id, event, chunk.length);
@@ -270,6 +313,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
270
313
  }
271
314
  };
272
315
 
316
+ // A detach can outlive its session — a kill removes the session before the
317
+ // client's connection closes — and a gone session already broadcast
318
+ // SessionRemoved, so a descriptor miss emits nothing.
319
+ const emitSessionDetached = (sessionID: SessionID) => {
320
+ const session = mgr.collectDescriptors().find((x) => x.id === sessionID);
321
+
322
+ if (session !== undefined) {
323
+ emitEvent(
324
+ { v: PROTOCOL_V, ev: 'SessionDetached', session },
325
+ { cwd: session.cwd, repoRoot: session.repoRoot },
326
+ );
327
+ }
328
+ };
329
+
273
330
  // Permission requests are synthesized from attention transitions: entering
274
331
  // needs_you opens one, and leaving it (answered directly in the terminal,
275
332
  // or the session dying) dismisses whatever is pending. Each session's
@@ -319,7 +376,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
319
376
  const event = buildSessionEvent(mgr, kind, s);
320
377
 
321
378
  if (event !== null) {
322
- emitEvent(event);
379
+ // The scope comes from the session object rather than a descriptor
380
+ // lookup, so dir-filtered hooks still fire for a removed session.
381
+ emitEvent(event, { cwd: s.cwd, repoRoot: s.repoRoot });
323
382
  }
324
383
  };
325
384
 
@@ -488,17 +547,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
488
547
  // only redraws its live region, never the rows above it.
489
548
  applyEffectiveDims(sessionID);
490
549
  void sendReplay(sessionID, client);
550
+ const attached = getDescriptor(mgr, sessionID);
551
+
552
+ emitEvent(
553
+ { v: PROTOCOL_V, ev: 'SessionAttached', session: attached },
554
+ { cwd: attached.cwd, repoRoot: attached.repoRoot },
555
+ );
491
556
 
492
557
  return 'ok';
493
558
  },
494
559
  detachSession: (client, sessionID) => {
495
- attachments.detach(sessionID, client);
560
+ if (!attachments.detach(sessionID, client)) {
561
+ return;
562
+ }
496
563
 
497
564
  scheduleResize(sessionID);
565
+ emitSessionDetached(sessionID);
498
566
  },
499
567
  detachClient: (client) => {
500
568
  for (const sessionID of attachments.detachAll(client)) {
501
569
  scheduleResize(sessionID);
570
+ emitSessionDetached(sessionID);
502
571
  }
503
572
  },
504
573
  writeSessionInput: (sessionID, data) => {
@@ -570,6 +639,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
570
639
 
571
640
  stopDaemon = async () => {
572
641
  server.stop(true);
642
+ eventsServer?.stop();
573
643
  reporter.stop(true);
574
644
  mgr.killAll();
575
645
 
@@ -0,0 +1,84 @@
1
+ import type { EventMsg } from '../protocol/protocol';
2
+ import type { HookEntry, HooksConfig } from '../shared/collect-hooks';
3
+
4
+ /**
5
+ * The session paths a hook's `dir` filter is matched against. Null when the
6
+ * event carries no session, in which case only unfiltered hooks fire.
7
+ */
8
+ export interface HookScope {
9
+ readonly cwd: string;
10
+ readonly repoRoot: string;
11
+ }
12
+
13
+ export type RunHooks = (event: EventMsg, scope: HookScope | null) => void;
14
+
15
+ /**
16
+ * Builds the daemon's hook runner. Each call fires every configured command
17
+ * for the event's name, with the wire-event JSON on stdin and the event name
18
+ * in `ATC_EVENT`. Hooks are observational and fire-and-forget: the daemon
19
+ * never waits on one, a run past its timeout is killed, and a nonzero exit
20
+ * or spawn failure is logged to stderr and otherwise ignored.
21
+ */
22
+ export function makeHookRunner(hooks: HooksConfig): RunHooks {
23
+ return (event, scope) => {
24
+ for (const entry of hooks[event.ev] ?? []) {
25
+ if (entry.dir === undefined || isInScope(entry.dir, scope)) {
26
+ runHook(entry, event);
27
+ }
28
+ }
29
+ };
30
+ }
31
+
32
+ function isInScope(dir: string, scope: HookScope | null): boolean {
33
+ if (scope === null) {
34
+ return false;
35
+ }
36
+
37
+ return isUnderDir(scope.repoRoot, dir) || isUnderDir(scope.cwd, dir);
38
+ }
39
+
40
+ // Path-segment containment: /a/b contains /a/b and /a/b/c, never /a/bc.
41
+ function isUnderDir(candidate: string, dir: string): boolean {
42
+ const prefix = dir === '/' ? '/' : `${dir}/`;
43
+
44
+ return candidate === dir || candidate.startsWith(prefix);
45
+ }
46
+
47
+ const DEFAULT_TIMEOUT_MS = 10_000;
48
+
49
+ function runHook(entry: HookEntry, event: EventMsg): void {
50
+ let proc: ReturnType<typeof Bun.spawn>;
51
+
52
+ try {
53
+ proc = Bun.spawn(['/bin/sh', '-c', entry.command], {
54
+ stdin: Buffer.from(`${JSON.stringify(event)}\n`),
55
+ stdout: 'ignore',
56
+ stderr: 'ignore',
57
+ env: { ...process.env, ATC_EVENT: event.ev },
58
+ });
59
+ } catch (error) {
60
+ console.error(`atc hook for ${event.ev} failed to spawn: ${String(error)}`);
61
+
62
+ return;
63
+ }
64
+
65
+ // Unref'd so an in-flight hook never keeps the daemon process alive; a
66
+ // hook orphaned by daemon exit is on its own.
67
+ const timer = setTimeout(() => {
68
+ proc.kill();
69
+ }, entry.timeout ?? DEFAULT_TIMEOUT_MS);
70
+
71
+ timer.unref();
72
+
73
+ void (async () => {
74
+ try {
75
+ const code = await proc.exited;
76
+
77
+ if (code !== 0) {
78
+ console.error(`atc hook for ${event.ev} exited ${code}`);
79
+ }
80
+ } finally {
81
+ clearTimeout(timer);
82
+ }
83
+ })();
84
+ }
@@ -0,0 +1,97 @@
1
+ import { unlinkSync } from 'node:fs';
2
+ import { OutboundQueue } from '../protocol/outbound-queue';
3
+ import { encodeMessage } from '../protocol/protocol';
4
+ import type { EventMsg } from '../protocol/protocol';
5
+
6
+ interface EventsServerOptions {
7
+ readonly socketPath: string;
8
+
9
+ // Events replayed to a subscriber the moment it connects, ahead of any
10
+ // live broadcast, so a subscriber learns the current fleet without
11
+ // speaking the client protocol.
12
+ readonly collectSnapshot: () => readonly EventMsg[];
13
+
14
+ // Outbound queue capacity per subscriber; small values force the overflow
15
+ // disconnect in tests.
16
+ readonly queueBytes?: number;
17
+ }
18
+
19
+ export interface EventsServer {
20
+ readonly broadcast: (event: EventMsg) => void;
21
+ readonly stop: () => void;
22
+ }
23
+
24
+ interface Subscriber {
25
+ readonly queue: OutboundQueue;
26
+ readonly end: () => void;
27
+ }
28
+
29
+ /**
30
+ * Starts the read-only events listener. A subscriber connects with no
31
+ * handshake and receives NDJSON wire events: first the snapshot, then every
32
+ * broadcast behind it. Input on the socket is ignored. A subscriber whose
33
+ * outbound queue overflows is disconnected rather than stalled — on
34
+ * reconnect it gets a fresh snapshot instead of the backlog it missed.
35
+ */
36
+ export function startEventsServer(opts: EventsServerOptions): EventsServer {
37
+ const subscribers = new Set<Subscriber>();
38
+
39
+ try {
40
+ unlinkSync(opts.socketPath);
41
+ } catch {}
42
+
43
+ const server = Bun.listen<Subscriber>({
44
+ unix: opts.socketPath,
45
+ socket: {
46
+ open(socket) {
47
+ const subscriber: Subscriber = {
48
+ queue: new OutboundQueue(socket, opts.queueBytes),
49
+ end: () => {
50
+ socket.end();
51
+ },
52
+ };
53
+
54
+ socket.data = subscriber;
55
+
56
+ subscribers.add(subscriber);
57
+
58
+ for (const event of opts.collectSnapshot()) {
59
+ if (!subscriber.queue.send(encodeMessage(event))) {
60
+ subscriber.end();
61
+
62
+ return;
63
+ }
64
+ }
65
+ },
66
+ data() {},
67
+ drain(socket) {
68
+ socket.data.queue.drain();
69
+ },
70
+ close(socket) {
71
+ subscribers.delete(socket.data);
72
+ },
73
+ error() {},
74
+ },
75
+ });
76
+
77
+ return {
78
+ broadcast(event) {
79
+ const line = encodeMessage(event);
80
+
81
+ for (const subscriber of subscribers) {
82
+ if (!subscriber.queue.send(line)) {
83
+ subscriber.end();
84
+ }
85
+ }
86
+ },
87
+ stop() {
88
+ server.stop(true);
89
+
90
+ for (const subscriber of subscribers) {
91
+ subscriber.end();
92
+ }
93
+
94
+ subscribers.clear();
95
+ },
96
+ };
97
+ }
package/src/events.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { eventsSocketPath } from './shared/config';
2
+
3
+ /**
4
+ * Streams the daemon's events socket to stdout, one NDJSON wire event per
5
+ * line, until the daemon closes the connection. When no daemon is
6
+ * listening, prints a hint to stderr and exits nonzero instead of booting
7
+ * one.
8
+ */
9
+ export async function runEvents(): Promise<void> {
10
+ const closed = Promise.withResolvers<void>();
11
+
12
+ try {
13
+ await Bun.connect({
14
+ unix: eventsSocketPath,
15
+ socket: {
16
+ data(_socket, buf) {
17
+ process.stdout.write(buf);
18
+ },
19
+ close() {
20
+ closed.resolve();
21
+ },
22
+ error() {
23
+ closed.resolve();
24
+ },
25
+ },
26
+ });
27
+ } catch {
28
+ console.error(`atc events: no daemon at ${eventsSocketPath} — start atc first`);
29
+ process.exit(1);
30
+ }
31
+
32
+ await closed.promise;
33
+ }
@@ -5,7 +5,7 @@ import { isRecord } from '../shared/report';
5
5
  * per line. Three message kinds, distinguished by which fields are present —
6
6
  * request (id + m), response (id + ok or err), event (ev).
7
7
  */
8
- export const PROTOCOL_V = 2;
8
+ export const PROTOCOL_V = 3;
9
9
 
10
10
  // Control lines are capped before buffering; PTY output is split into chunks
11
11
  // so a queued response is delayed by at most one chunk.
@@ -0,0 +1,90 @@
1
+ import { homedir } from 'node:os';
2
+ import { z } from 'zod';
3
+ import { isRecord } from './report';
4
+
5
+ /**
6
+ * One configured hook: a shell command the daemon runs when the named wire
7
+ * event broadcasts. `dir` narrows it to sessions whose repo root or working
8
+ * directory sits at or under that path; `timeout` caps the run in
9
+ * milliseconds before the process is killed.
10
+ */
11
+ export interface HookEntry {
12
+ readonly command: string;
13
+ readonly dir?: string;
14
+ readonly timeout?: number;
15
+ }
16
+
17
+ export type HooksConfig = Readonly<Record<string, readonly HookEntry[]>>;
18
+
19
+ // One hook entry's keys. A wrong-typed optional field parses to undefined
20
+ // rather than failing the entry, so a hook with one bad field still runs
21
+ // with the default for it.
22
+ const HOOK_ENTRY_SCHEMA = z.object({
23
+ command: buildOptionalNonEmptyString(),
24
+ dir: buildOptionalNonEmptyString(),
25
+ timeout: buildOptionalPositiveNumber(),
26
+ });
27
+
28
+ /**
29
+ * Reads the hooks map, keyed by wire-event name. An entry without a command
30
+ * is left out; an event name with no valid entries is dropped. Unknown event
31
+ * names are kept as written — they never fire, and a future daemon that
32
+ * emits them starts firing without a config change. A `dir` is stored with
33
+ * `~` expanded and trailing slashes trimmed, ready for path matching.
34
+ */
35
+ export function collectHooks(raw: unknown): HooksConfig {
36
+ if (!isRecord(raw)) {
37
+ return {};
38
+ }
39
+
40
+ const hooks: Record<string, readonly HookEntry[]> = {};
41
+
42
+ for (const [event, value] of Object.entries(raw)) {
43
+ if (event === '' || !Array.isArray(value)) {
44
+ continue;
45
+ }
46
+
47
+ const entries: HookEntry[] = [];
48
+
49
+ for (const item of value) {
50
+ const parsed = HOOK_ENTRY_SCHEMA.safeParse(item);
51
+
52
+ if (!parsed.success || parsed.data.command === undefined) {
53
+ continue;
54
+ }
55
+
56
+ entries.push({
57
+ command: parsed.data.command,
58
+ ...(parsed.data.dir === undefined ? {} : { dir: normalizeHookDir(parsed.data.dir) }),
59
+ ...(parsed.data.timeout === undefined ? {} : { timeout: parsed.data.timeout }),
60
+ });
61
+ }
62
+
63
+ if (entries.length > 0) {
64
+ hooks[event] = entries;
65
+ }
66
+ }
67
+
68
+ return hooks;
69
+ }
70
+
71
+ function normalizeHookDir(dir: string): string {
72
+ const expanded = dir === '~' ? homedir() : dir.replace(/^~\//u, `${homedir()}/`);
73
+ const trimmed = expanded.replace(/\/+$/u, '');
74
+
75
+ return trimmed === '' ? '/' : trimmed;
76
+ }
77
+
78
+ function buildOptionalNonEmptyString() {
79
+ return z.preprocess(
80
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
81
+ z.string().optional(),
82
+ );
83
+ }
84
+
85
+ function buildOptionalPositiveNumber() {
86
+ return z.preprocess(
87
+ (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined),
88
+ z.number().optional(),
89
+ );
90
+ }
@@ -6,6 +6,8 @@ import { buildOptionalString } from './build-optional-string';
6
6
  import { buildOptionalStringArray } from './build-optional-string-array';
7
7
  import { collectGateways } from './collect-gateways';
8
8
  import type { GatewayConfig } from './collect-gateways';
9
+ import { collectHooks } from './collect-hooks';
10
+ import type { HooksConfig } from './collect-hooks';
9
11
 
10
12
  export interface Config {
11
13
  claudeBin: string;
@@ -15,6 +17,7 @@ export interface Config {
15
17
  codexBin: string;
16
18
  codexArgs: string[];
17
19
  gateways: GatewayConfig[];
20
+ hooks: HooksConfig;
18
21
  leader: LeaderKey;
19
22
  }
20
23
 
@@ -31,6 +34,7 @@ const DEFAULTS: Config = {
31
34
  codexBin: 'codex',
32
35
  codexArgs: [],
33
36
  gateways: [],
37
+ hooks: {},
34
38
  leader: { code: 0, label: '^Space' },
35
39
  };
36
40
 
@@ -39,6 +43,7 @@ const configDir = join(homedir(), '.config', 'atc');
39
43
  export const stateDir = join(homedir(), '.local', 'state', 'atc');
40
44
  export const socketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc.sock');
41
45
  export const daemonSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.sock');
46
+ export const eventsSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-events.sock');
42
47
  export const statusFile = join(stateDir, 'status.json');
43
48
  export const dbFile = join(stateDir, 'atc.db');
44
49
  export const legacyFleetFile = join(stateDir, 'fleet.json');
@@ -55,6 +60,7 @@ const CONFIG_SCHEMA = z.object({
55
60
  codexBin: buildOptionalString(),
56
61
  codexArgs: buildOptionalStringArray(),
57
62
  gateways: z.unknown().optional(),
63
+ hooks: z.unknown().optional(),
58
64
  leader: buildOptionalString(),
59
65
  });
60
66
 
@@ -97,11 +103,12 @@ export function parseConfig(raw: unknown): Config {
97
103
  const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
98
104
  const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
99
105
  const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
106
+ const hooks = collectHooks(parsed.data.hooks);
100
107
 
101
108
  const leader =
102
109
  (parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
103
110
 
104
- return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, gateways, leader };
111
+ return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, gateways, hooks, leader };
105
112
  }
106
113
 
107
114
  // Control bytes the terminal needs for its own input: enter, tab, and esc