sh-agent 0.1.0__tar.gz

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.
Files changed (38) hide show
  1. sh_agent-0.1.0/.gitignore +7 -0
  2. sh_agent-0.1.0/LICENSE +21 -0
  3. sh_agent-0.1.0/PKG-INFO +367 -0
  4. sh_agent-0.1.0/README.md +332 -0
  5. sh_agent-0.1.0/pyproject.toml +76 -0
  6. sh_agent-0.1.0/scripts/install.sh +163 -0
  7. sh_agent-0.1.0/scripts/serverctl.sh +67 -0
  8. sh_agent-0.1.0/scripts/verify_m1.py +177 -0
  9. sh_agent-0.1.0/scripts/verify_resume.py +150 -0
  10. sh_agent-0.1.0/scripts/verify_resume_eviction.py +133 -0
  11. sh_agent-0.1.0/scripts/verify_tmux.py +202 -0
  12. sh_agent-0.1.0/sh-palette.example.json +29 -0
  13. sh_agent-0.1.0/sh_agent/__init__.py +19 -0
  14. sh_agent-0.1.0/sh_agent/__main__.py +141 -0
  15. sh_agent-0.1.0/sh_agent/app.py +97 -0
  16. sh_agent-0.1.0/sh_agent/auth.py +147 -0
  17. sh_agent-0.1.0/sh_agent/config.py +60 -0
  18. sh_agent-0.1.0/sh_agent/env.py +35 -0
  19. sh_agent-0.1.0/sh_agent/onboarding.py +185 -0
  20. sh_agent-0.1.0/sh_agent/palette.py +207 -0
  21. sh_agent-0.1.0/sh_agent/protocol.py +77 -0
  22. sh_agent-0.1.0/sh_agent/pty_session.py +355 -0
  23. sh_agent-0.1.0/sh_agent/registry.py +350 -0
  24. sh_agent-0.1.0/sh_agent/routes/__init__.py +0 -0
  25. sh_agent-0.1.0/sh_agent/routes/commands.py +97 -0
  26. sh_agent-0.1.0/sh_agent/routes/deps.py +62 -0
  27. sh_agent-0.1.0/sh_agent/routes/health.py +63 -0
  28. sh_agent-0.1.0/sh_agent/routes/session.py +314 -0
  29. sh_agent-0.1.0/sh_agent/tmux.py +159 -0
  30. sh_agent-0.1.0/systemd/sh-agent.service +27 -0
  31. sh_agent-0.1.0/tests/test_api.py +425 -0
  32. sh_agent-0.1.0/tests/test_onboarding.py +135 -0
  33. sh_agent-0.1.0/tests/test_pty_session.py +130 -0
  34. sh_agent-0.1.0/tests/test_startup_output.py +101 -0
  35. sh_agent-0.1.0/tests/test_tailscale_identity.py +169 -0
  36. sh_agent-0.1.0/tests/test_tmux.py +214 -0
  37. sh_agent-0.1.0/tests/test_tmux_api.py +609 -0
  38. sh_agent-0.1.0/uv.lock +845 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.egg-info/
7
+ dist/
sh_agent-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faisal Fayaz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,367 @@
1
+ Metadata-Version: 2.5
2
+ Name: sh-agent
3
+ Version: 0.1.0
4
+ Summary: Gesture-friendly terminal agent. Gives a phone a real interactive shell on your machine over Tailscale.
5
+ Project-URL: Homepage, https://github.com/Faisal-Fayaz/sh-agent
6
+ Project-URL: Repository, https://github.com/Faisal-Fayaz/sh-agent
7
+ Project-URL: Issues, https://github.com/Faisal-Fayaz/sh-agent/issues
8
+ Project-URL: Client, https://github.com/Faisal-Fayaz/sh-app
9
+ Author: Faisal Fayaz
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: mobile,pty,ssh,tailscale,terminal,tmux
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Environment :: No Input/Output (Daemon)
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: System :: Shells
23
+ Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.11
26
+ Requires-Dist: fastapi>=0.110
27
+ Requires-Dist: segno>=1.6
28
+ Requires-Dist: uvicorn[standard]>=0.27
29
+ Provides-Extra: dev
30
+ Requires-Dist: httpx>=0.27; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
32
+ Requires-Dist: pytest>=8; extra == 'dev'
33
+ Requires-Dist: websockets>=12; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # sh-agent
37
+
38
+ A real interactive shell on your own machine, driven from your phone. Run the agent on the
39
+ machine you want a shell into, install the app on your phone, scan a code, and you have a
40
+ terminal that keeps its scrollback across a dropped connection.
41
+
42
+ The client is [`sh-app`](https://github.com/Faisal-Fayaz/sh-app), an Expo app for Android and iOS.
43
+
44
+ ## Who this is for
45
+
46
+ Someone already uses a terminal, wants their phone to be a genuinely usable one, and is
47
+ comfortable installing a small program on their own machine and a VPN on two devices.
48
+
49
+ ## What you get
50
+
51
+ - **Two-finger gestures** for the keys a phone keyboard hides: arrows, `Ctrl-C`, `Ctrl-D`,
52
+ `Esc`, `Ctrl-Z`, plus a sticky modifier.
53
+ - **Pinch to resize the text**, so a narrow phone screen is not a cramped one.
54
+ - **Named tmux sessions** that survive the app being closed, so a long build keeps running.
55
+ - **Reconnect that waits.** A host that is asleep or out of cell range is retried
56
+ indefinitely rather than written off after 40 seconds.
57
+ - **A swipe-to-run command palette** for the commands you actually type often.
58
+
59
+ ## What this is not, yet
60
+
61
+ - **No Windows.** The agent is POSIX-only (`openpty`, `TIOCSCTTY`, `killpg`). Windows needs a
62
+ ConPTY layer and does not have one.
63
+ - **The host needs Python 3.11+**, macOS or Linux.
64
+ - **It does not wake your machine.** A sleeping host serves nothing. Tailscale gets you
65
+ reachability from anywhere; it does not get you a powered-on laptop.
66
+ - **iOS builds need an Apple account.** Expo Go works for trying it; a standalone build needs
67
+ the Apple Developer Program.
68
+
69
+ Everything above is a deliberate boundary, not a bug in disguise.
70
+
71
+ ## Try it in five minutes
72
+
73
+ You need Python 3.11+ on the host, the app on your phone, and either Tailscale (works from
74
+ anywhere) or being on the same Wi-Fi (works at home).
75
+
76
+ Install the app first. [Expo Go](https://expo.dev/go) is quickest on both platforms — run
77
+ `npx expo start` in the `sh-app` repo and scan its QR with the app. If you would rather not run a
78
+ dev server, the client's README has a standalone Android APK and says what an iOS build needs.
79
+
80
+ ```bash
81
+ curl -fsSL https://raw.githubusercontent.com/Faisal-Fayaz/sh-agent/main/scripts/install.sh | bash
82
+ sh-agent $SH_HOST=0.0.0.0 $SH_ALLOW_LAN=1
83
+ ```
84
+
85
+ Or from a clone, `./scripts/install.sh`. The installer picks `uv` or `pipx` if you have
86
+ either and falls back to a virtualenv if you do not.
87
+
88
+ There is no token to generate and nothing to copy into your shell. The agent creates one on
89
+ first run, stores it in `~/.config/sh/agent.env` (mode 0600), and prints a QR code. Open the app,
90
+ tap **Scan to pair**, and point your phone at it:
91
+
92
+ ```
93
+ sh-agent listening on 0.0.0.0:8765
94
+ pairing over the local network (172.20.10.3); run Tailscale to pair from anywhere
95
+
96
+ Pair your phone by scanning this, or open the link:
97
+
98
+ █████████████████████████████████████████████
99
+ ██ ▄▄▄▄▄ █ ▀ ▄▄▄▀▄ ▀█▀▀▄▀ ▀██▀▀▀█ ▄▄▄▄▄ ██
100
+ ...
101
+
102
+ sh://connect?host=172.20.10.3&port=8765&token=...
103
+ ```
104
+
105
+ The token persists, so restarting the agent never unpairs a phone. To get the code again later --
106
+ to add a second phone, or after deleting `agent.env` -- without restarting anything:
107
+
108
+ ```bash
109
+ sh-agent --pairing
110
+ ```
111
+
112
+ If you start the agent through `scripts/serverctl.sh` the code is echoed to your terminal after
113
+ the health check, since that script backgrounds the agent with its output redirected to a log.
114
+
115
+ With Tailscale running the same command needs no `SH_HOST` at all: the agent binds your `100.x`
116
+ tailnet address and the pairing code carries that instead, which is the only version that also
117
+ works once you leave the house.
118
+
119
+ Then scan the code with the app — tap **Scan to pair** and point the camera at the terminal. If
120
+ the camera is unavailable, **Enter manually** takes the same host, port and token by hand.
121
+
122
+ Verify it worked:
123
+
124
+ ```bash
125
+ curl -s http://<host>:8765/health | python3 -m json.tool
126
+ ```
127
+
128
+ ## Running from a clone
129
+
130
+ For development, where you want the tests and a local checkout:
131
+
132
+ ```bash
133
+ # uv (lockfile committed, reproducible)
134
+ uv venv && uv pip install -e ".[dev]"
135
+
136
+ # or plain pip
137
+ python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
138
+
139
+ ./.venv/bin/python -m sh_agent
140
+ ```
141
+
142
+ There is no `SH_TOKEN` to set. To pin one for a single run instead, export `SH_TOKEN`; it
143
+ overrides the stored value without replacing it.
144
+
145
+ With Tailscale running and no `SH_HOST` set, the agent binds to your tailnet address (`100.x.y.z`)
146
+ rather than `0.0.0.0`, so it is never exposed off-tailnet. Set `SH_ALLOW_LAN=1` to override (dev only).
147
+
148
+ ## Running it always
149
+
150
+ A phone client is only useful if the agent is up when you reach for it, and a shell
151
+ started by hand dies with the terminal. A **user** unit needs no root and survives
152
+ reboots once you log in or enable lingering:
153
+
154
+ ```bash
155
+ uv tool install --force .
156
+ install -Dm755 systemd/sh-agent.service ~/.config/systemd/user/sh-agent.service
157
+ systemctl --user daemon-reload && systemctl --user enable --now sh-agent
158
+ loginctl enable-linger "$USER" # optional: keep it running without a login session
159
+ ```
160
+
161
+ `uv tool install` puts the `sh-agent` binary on `PATH` at `~/.local/bin`, which is what the unit
162
+ runs. The token is not in the unit at all: run the agent once and it writes
163
+ `~/.config/sh/agent.env` (mode 0600), which the unit then loads. That is what stops restarting
164
+ the agent from invalidating a phone that is already paired.
165
+
166
+ **One ordering caveat.** `tailscaled` is a system unit, so a user unit cannot order
167
+ itself against it. If the agent wins that race on boot it binds `127.0.0.1` instead of
168
+ the tailnet address, which is silently unreachable from the phone. The agent prints
169
+ the address it bound to, so check that line after a reboot; if it says `127.0.0.1`
170
+ while you are on a tailnet, run `systemctl --user restart sh-agent`.
171
+
172
+ ## Configuration
173
+
174
+ | Variable | Default | Meaning |
175
+ |---|---|---|
176
+ | `SH_TOKEN` | — | **Required.** Bearer token for all access |
177
+ | `SH_WRITE_TOKEN` | unset | Enables palette write endpoints. Unset ⇒ 403 |
178
+ | `SH_HOST` | `127.0.0.1` | Bind address when not on a tailnet |
179
+ | `SH_PORT` | `8765` | Listen port |
180
+ | `SH_ALLOW_LAN` | unset | Bind `0.0.0.0`. Dev only |
181
+ | `SH_SHELL` | `/bin/bash -l` | Shell to spawn |
182
+ | `SH_CWD` | — | Default working directory |
183
+ | `SH_PALETTE` | `~/.config/sh/palette.json` | Palette file |
184
+ | `SH_AUDIT` | `~/.local/state/sh/audit.log` | Audit log (`SH_AUDIT=""` disables) |
185
+ | `SH_IDLE_TTL` | `1800` | Seconds before an idle session is reaped |
186
+ | `SH_DETACH_GRACE` | `120` | Seconds a disconnected session survives for reconnect/resume |
187
+ | `SH_REAP_INTERVAL` | `15` | How often the reaper runs |
188
+ | `SH_MAX_SESSIONS` | `8` | Concurrent session cap |
189
+ | `SH_RING_CAPACITY` | `262144` | Replay buffer bytes per session |
190
+ | `SH_TAILSCALE_REQUIRE` | unset | Reject peers whose identity can't be verified |
191
+ | `SH_TAILSCALE_ALLOW` | unset | Comma-separated allowlist of tailnet logins |
192
+ | `SH_TMUX_ENABLED` | `1` | Offer tmux-backed named sessions |
193
+ | `SH_TMUX_SECRET` | derived from `SH_TOKEN` | Secret for namespacing tmux session names |
194
+ | `SH_TMUX_MAX_SESSIONS` | `12` | Cap on persisted tmux sessions |
195
+ | `SH_TMUX_IDLE_TTL` | `3600` | Seconds an unattached tmux session may linger (see below) |
196
+
197
+ ## Protocol
198
+
199
+ WebSocket at `/ws/{session_id}`. Token via `Authorization: Bearer <token>` header or `?token=`.
200
+ All PTY bytes are **base64-encoded** — a UTF-8 sequence can straddle a frame boundary, and raw text
201
+ frames corrupt output.
202
+
203
+ ### Client → server
204
+
205
+ ```json
206
+ {"type": "stdin", "data": "<base64>"}
207
+ {"type": "resize", "cols": 132, "rows": 43}
208
+ {"type": "kill"}
209
+ {"type": "ping"}
210
+ ```
211
+
212
+ ### Server → client
213
+
214
+ ```json
215
+ {"type": "hello", "session_id": "…", "shell": "/bin/bash -l", "cols": 80, "rows": 24}
216
+ {"type": "stdout", "seq": 4096, "data": "<base64>"}
217
+ {"type": "exit", "code": 0}
218
+ {"type": "error", "message": "…"}
219
+ {"type": "pong"}
220
+ ```
221
+
222
+ `seq` is a monotonic **byte offset** into the session's output stream, not a frame counter.
223
+
224
+ ### Reconnect
225
+
226
+ Disconnecting the WebSocket does not kill the shell. Reconnect with `?resume=<seq>` where `seq` is
227
+ the last byte offset you received; the agent replays exactly the missed range. If the requested
228
+ offset has already been evicted from the ring buffer, replay starts from the oldest retained byte
229
+ and the client must treat the terminal as resynced. This is the mechanism for surviving mobile
230
+ network flaps.
231
+
232
+ ### Close codes
233
+
234
+ `4401` auth failed · `4404` unknown session
235
+
236
+ ## HTTP API
237
+
238
+ | Method | Path | Auth |
239
+ |---|---|---|
240
+ | `GET` | `/health` | open |
241
+ | `GET` | `/whoami` | token |
242
+ | `GET` | `/audit/recent?limit=` | token |
243
+ | `POST` | `/api/session` | token |
244
+ | `GET` | `/api/session/{id}` | token |
245
+ | `POST` | `/api/session/{id}/kill` | token |
246
+ | `GET` | `/api/commands` | token |
247
+ | `POST` | `/api/commands` | **write token** |
248
+ | `PATCH` | `/api/commands/{id}` | **write token** |
249
+ | `DELETE` | `/api/commands/{id}` | **write token** |
250
+
251
+ ## Security model
252
+
253
+ 1. Reachable only over the tailnet unless `SH_ALLOW_LAN` is set.
254
+ 2. Bearer token compared with `hmac.compare_digest`; never logged.
255
+ 3. Tailscale identity resolution via the `tailscaled.sock` localapi `whois` endpoint, so the agent
256
+ can allowlist by login even if the token leaks to another node on the tailnet.
257
+ 4. Palette writes require a distinct token and are off by default.
258
+ 5. Built-in palette entries are immutable and cannot be shadowed in place — only overridden by name.
259
+ 6. Every auth event, session lifecycle event, and palette mutation is appended to the audit log.
260
+ 7. Sessions are capped and reaped; children are killed via `killpg` so no shell outlives its
261
+ session.
262
+
263
+ ## Two session modes
264
+
265
+ | | `pty` (default) | `tmux` |
266
+ |---|---|---|
267
+ | Persistence | Survives a disconnect for `SH_DETACH_GRACE` | Survives indefinitely |
268
+ | Identity | Anonymous, per connection | Named, stable |
269
+ | Reconnect | Byte-offset replay into a ring buffer | `tmux new-session -A` reattaches |
270
+ | Scrollback | Replayed since the last disconnect | tmux's own, including across app restarts |
271
+ | Best for | Ad-hoc commands | Long-running work you come back to |
272
+
273
+ tmux mode is the same `PtySession` with its command set to `tmux new-session -A -s <name>`, so it
274
+ reuses the entire streaming path rather than introducing a second one.
275
+
276
+ **Namespacing.** `tmux new-session -A` attaches to *any* session with a matching name, so an
277
+ un-namespaced slug would let anyone holding a token attach to another holder's session. Slugs are
278
+ therefore mapped to `sh-<hmac8(secret, slug)>-<slug>`: readable in `tmux ls`, unguessable to a
279
+ client that does not hold the secret. The default secret derives from `SH_TOKEN`, so everyone
280
+ sharing a token shares a namespace — set `SH_TMUX_SECRET` for isolation.
281
+
282
+ **Lifecycle.** tmux sessions outlive everything, which is the point and the hazard. The sweeper caps
283
+ them at `SH_TMUX_MAX_SESSIONS` and only ever kills sessions with **no attached client** — an
284
+ explicit `kill` removes the tmux session itself, while `detach` deliberately leaves it running.
285
+
286
+ **Attaching is a switch, not a clone.** tmux will happily attach several clients to one session, so
287
+ tapping a session repeatedly would otherwise add a PTY and a registry row every time. Creating a
288
+ tmux session now *replaces* any session the agent already tracks under that name: the old socket is
289
+ closed, its tmux clients are detached, and the new one attaches. The tmux session and its scrollback
290
+ are untouched, so reconnecting mid-task is seamless. `/api/sessions` also omits any slug that is
291
+ already listed as live, so one session can never read as two rows.
292
+
293
+ ### Socket lifetime
294
+
295
+ Killing or reaping a session **closes its WebSocket** with code `4404`. Without that, the client
296
+ would sit on a socket to a dead shell and never learn; the close code is what lets it start a
297
+ replacement shell and tell the user it happened.
298
+
299
+ ### Session lifetime
300
+
301
+ Two independent reapers, because "idle" means different things in the two states:
302
+
303
+ - **Attached but idle** (`SH_IDLE_TTL`, 30 min) — a user walked away from a live terminal.
304
+ - **Detached** (`SH_DETACH_GRACE`, 2 min) — the WebSocket dropped. This is deliberately short but
305
+ long enough to survive a mobile network flap, because `?resume=` can only replay a session that
306
+ still exists.
307
+
308
+ Without the detach grace, every app reload leaks a session until the 30-minute idle reaper runs, and
309
+ a hot-reload dev loop hits `SH_MAX_SESSIONS` within minutes.
310
+
311
+ ## Development
312
+
313
+ ```bash
314
+ .venv/bin/python -m pytest -q # 99 unit + API tests, incl. a fake tailscaled socket
315
+ .venv/bin/python -m ruff check .
316
+ .venv/bin/python -m ruff format .
317
+
318
+ ./scripts/serverctl.sh start # pidfile-managed dev server
319
+ ./scripts/serverctl.sh stop
320
+
321
+ # live end-to-end checks (need a running agent)
322
+ export SH_TOKEN=$(.venv/bin/python -m sh_agent --generate-token)
323
+ ./scripts/serverctl.sh start
324
+ .venv/bin/python scripts/verify_m1.py # 23 checks: shell, resize, auth, resume
325
+ .venv/bin/python scripts/verify_resume.py # 10 checks: drop mid-stream, zero loss
326
+ .venv/bin/python scripts/verify_resume_eviction.py # 11 checks: resume from an evicted offset
327
+ .venv/bin/python scripts/verify_tmux.py # 27 checks: named sessions end to end
328
+ ```
329
+
330
+ New API:
331
+
332
+ | Method | Path | Purpose |
333
+ |---|---|---|
334
+ | `GET` | `/api/sessions` | List live PTY sessions and persisted tmux sessions |
335
+ | `POST` | `/api/session/{id}/detach` | Drop the socket but keep a tmux session running |
336
+ | `DELETE` | `/api/persisted/{slug}` | Remove a detached tmux session (it has no session id left to kill by) |
337
+
338
+ ## Terminal size
339
+
340
+ Sessions are created at 80x24 and resized as the client measures itself. That resize only
341
+ reaches full-screen programs if the spawned shell owns its controlling terminal: the agent
342
+ uses `os.openpty()` plus `setsid()`, and without `TIOCSCTTY` there is no foreground process
343
+ group, so `TIOCSWINSZ` updates the stored size but never delivers `SIGWINCH`. A plain shell
344
+ reads the size when asked and so never notices; tmux, vim and other full-screen programs only
345
+ re-read the terminal when signalled, and would keep rendering for their startup size. With
346
+ the controlling terminal in place, tmux follows the client and its `window-size latest`
347
+ option does the right thing on its own.
348
+
349
+ `preexec_fn` targets the slave fd rather than stdin because CPython runs it before it dup2's
350
+ the standard streams, so fd 0 is not yet the pty. The agent is single-threaded, which is the
351
+ one thing that would make `preexec_fn` unsafe.
352
+
353
+ ## Roadmap
354
+
355
+ The features are built and verified on hardware. What remains is reach.
356
+
357
+ - **Windows.** The one real gap. The agent is POSIX-only -- `os.openpty`, `TIOCSCTTY` and
358
+ `killpg` have no Windows equivalent and there is no ConPTY layer, so this is an
359
+ abstraction to write rather than a branch to patch. macOS and Linux are supported today.
360
+ - **PyPI.** The package builds and passes `twine check`; it has not been uploaded.
361
+ - **Wake on demand.** Not implemented, and not a small thing. Wake-on-LAN is a Layer 2
362
+ magic packet and Tailscale is Layer 3, so it cannot be sent over the tunnel at all --
363
+ it needs a second always-on machine whose only job is sending the packet. That machine
364
+ can just as well run the agent.
365
+
366
+ Earlier milestones -- the PTY agent, the Expo client, the gesture layer, the command palette,
367
+ Tailscale identity, reconnect hardening, and the pairing bootstrap -- are all done.