@zgeoff/atc 1.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,104 @@ 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
- ```
89
+ ## Configuration
155
90
 
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
- ```
91
+ `~/.config/atc/config.json` is created with defaults on first run. The
92
+ [configuration guide](./docs/guides/configuration.md) covers every field:
189
93
 
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. |
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
+ - [Attention hooks](./docs/guides/configuration.md#attention-hooks-grok-and-codex) — the Grok and
101
+ Codex self-install in detail.
198
102
 
199
- Two backends may be given the same `mark`. atc does not check, and a clash makes them
200
- indistinguishable in the overlay column.
103
+ ## Integrations
201
104
 
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.
105
+ Everything the fleet does broadcasts as a wire event — sessions added, state changes, attaches,
106
+ renames, permission requests. The [events guide](./docs/guides/events.md) covers the three ways to
107
+ consume the stream:
205
108
 
206
- `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.
109
+ - [Daemon hooks](./docs/guides/events.md#daemon-hooks) — run your own commands on fleet events,
110
+ straight from `config.json`.
111
+ - [`atc events`](./docs/guides/events.md#atc-events) — the stream on stdout, one NDJSON line per
112
+ event; pipe it into `jq` or your own tooling.
113
+ - [The events socket](./docs/guides/events.md#the-events-socket) — a read-only unix socket any
114
+ program can subscribe to, stable across atc upgrades.
115
+
116
+ `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, read the screen, organise) to any MCP
117
+ client, wrangled sessions included:
209
118
 
210
119
  ```sh
211
120
  claude mcp add --scope user atc -- atc mcp
212
121
  ```
213
122
 
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
123
  ## Crash safety
219
124
 
220
125
  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.
126
+ `atc` reconnects. If the daemon itself dies, every session's transcript is already on disk — press
127
+ `R` and the whole fleet respawns with the matching CLI. After an update, the status bar shows
128
+ `⟳ update ready`, and `u` restarts the daemon and restores the fleet at a moment you choose. The
129
+ [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.1.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",
@@ -59,5 +59,9 @@
59
59
  "type-fest": "5.8.0",
60
60
  "typescript": "7.0.2"
61
61
  },
62
+ "overrides": {
63
+ "fast-uri": "3.1.6",
64
+ "qs": "6.16.0"
65
+ },
62
66
  "packageManager": "bun@1.3.10"
63
67
  }
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]();