@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 +77 -183
- package/package.json +5 -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 +26 -1
- package/src/daemon/daemon.ts +103 -21
- package/src/daemon/make-hook-runner.ts +84 -0
- package/src/daemon/screen-model.ts +42 -11
- package/src/daemon/start-events-server.ts +97 -0
- package/src/events.ts +33 -0
- package/src/mcp-server.ts +11 -0
- package/src/protocol/protocol.ts +1 -1
- package/src/protocol/request-param-schemas.ts +1 -0
- 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,104 @@ 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
|
-
```
|
|
89
|
+
## Configuration
|
|
155
90
|
|
|
156
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
indistinguishable in the overlay column.
|
|
103
|
+
## Integrations
|
|
201
104
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
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.
|
|
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
|
+
"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: {
|
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]();
|