@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 +67 -184
- package/package.json +1 -1
- package/src/cli.ts +14 -0
- package/src/client/index.ts +9 -9
- package/src/client/parse-daemon-event.ts +26 -26
- package/src/daemon/attach-registry.ts +4 -3
- package/src/daemon/build-session-event.ts +4 -4
- package/src/daemon/daemon-connection.ts +1 -1
- package/src/daemon/daemon.ts +91 -21
- package/src/daemon/make-hook-runner.ts +84 -0
- package/src/daemon/start-events-server.ts +97 -0
- package/src/events.ts +33 -0
- package/src/protocol/protocol.ts +1 -1
- package/src/shared/collect-hooks.ts +90 -0
- package/src/shared/config.ts +8 -1
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
<h1>atc</h1>
|
|
3
3
|
|
|
4
4
|
<p>
|
|
5
|
-
Control tower for
|
|
6
|
-
<code>
|
|
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/
|
|
18
|
-
<a href="./
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
45
|
-
| `n` | home/overlay | spawn: pick agent → dir
|
|
46
|
-
| `r` | home/overlay | adopt: pick agent → dir → name
|
|
47
|
-
| `R` | home | restore last fleet after a daemon death
|
|
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
|
|
52
|
-
| `a` | overlay | ack notification without attaching
|
|
53
|
-
| `p` | overlay | pin or unpin
|
|
54
|
-
| `g` | overlay | toggle the grouped view: sessions cluster under repository headers
|
|
55
|
-
| `H` | overlay | eject to headless
|
|
56
|
-
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place
|
|
57
|
-
| `y` | overlay | yank the resume command
|
|
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
|
|
60
|
-
|
|
|
61
|
-
|
|
|
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.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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.
|
|
222
|
-
|
|
223
|
-
restarts the daemon and restores the fleet at a moment you choose.
|
|
224
|
-
|
|
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
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: {
|
package/src/client/index.ts
CHANGED
|
@@ -344,16 +344,16 @@ function applyDaemonEvent(raw: EventMsg) {
|
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
match(event)
|
|
347
|
-
.with({ ev: '
|
|
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: '
|
|
352
|
+
.with({ ev: 'SessionAdded' }, (e) => {
|
|
353
353
|
upsertMirror(e.session);
|
|
354
354
|
refreshScreens();
|
|
355
355
|
})
|
|
356
|
-
.with({ ev: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
397
|
-
{ ev: '
|
|
398
|
-
{ ev: '
|
|
399
|
-
{ ev: '
|
|
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: '
|
|
9
|
-
| { readonly ev: '
|
|
8
|
+
| { readonly ev: 'SessionAdded'; readonly session: MirrorSession }
|
|
9
|
+
| { readonly ev: 'SessionState'; readonly session: MirrorSession }
|
|
10
10
|
| {
|
|
11
|
-
readonly ev: '
|
|
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: '
|
|
16
|
+
| { readonly ev: 'SessionRemoved'; readonly s: string }
|
|
17
17
|
| {
|
|
18
|
-
readonly ev: '
|
|
18
|
+
readonly ev: 'SessionResized';
|
|
19
19
|
readonly s: string;
|
|
20
20
|
readonly cols: number;
|
|
21
21
|
readonly rows: number;
|
|
22
22
|
}
|
|
23
|
-
| { readonly ev: '
|
|
24
|
-
| { readonly ev: '
|
|
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: '
|
|
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: '
|
|
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('
|
|
38
|
-
z.looseObject({ ev: z.literal('
|
|
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('
|
|
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('
|
|
45
|
+
z.looseObject({ ev: z.literal('SessionRemoved'), s: z.string() }),
|
|
46
46
|
z.looseObject({
|
|
47
|
-
ev: z.literal('
|
|
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('
|
|
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('
|
|
58
|
+
z.looseObject({ ev: z.literal('SessionDesync'), s: z.string(), dropped: z.number() }),
|
|
59
59
|
z.looseObject({
|
|
60
|
-
ev: z.literal('
|
|
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('
|
|
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: '
|
|
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: '
|
|
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: '
|
|
98
|
-
.with({ ev: '
|
|
99
|
-
.with({ ev: '
|
|
100
|
-
.with({ ev: '
|
|
101
|
-
.with({ ev: '
|
|
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: '
|
|
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):
|
|
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: '
|
|
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: '
|
|
26
|
+
return session === null ? null : { v: PROTOCOL_V, ev: 'SessionState', session };
|
|
27
27
|
},
|
|
28
28
|
renamed: () => ({
|
|
29
29
|
v: PROTOCOL_V,
|
|
30
|
-
ev: '
|
|
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: '
|
|
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: '
|
|
171
|
+
this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
|
|
172
172
|
void this.ctx.resyncClient(sessionID, this);
|
|
173
173
|
}
|
|
174
174
|
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
+
}
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -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
|