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.
- sh_agent-0.1.0/.gitignore +7 -0
- sh_agent-0.1.0/LICENSE +21 -0
- sh_agent-0.1.0/PKG-INFO +367 -0
- sh_agent-0.1.0/README.md +332 -0
- sh_agent-0.1.0/pyproject.toml +76 -0
- sh_agent-0.1.0/scripts/install.sh +163 -0
- sh_agent-0.1.0/scripts/serverctl.sh +67 -0
- sh_agent-0.1.0/scripts/verify_m1.py +177 -0
- sh_agent-0.1.0/scripts/verify_resume.py +150 -0
- sh_agent-0.1.0/scripts/verify_resume_eviction.py +133 -0
- sh_agent-0.1.0/scripts/verify_tmux.py +202 -0
- sh_agent-0.1.0/sh-palette.example.json +29 -0
- sh_agent-0.1.0/sh_agent/__init__.py +19 -0
- sh_agent-0.1.0/sh_agent/__main__.py +141 -0
- sh_agent-0.1.0/sh_agent/app.py +97 -0
- sh_agent-0.1.0/sh_agent/auth.py +147 -0
- sh_agent-0.1.0/sh_agent/config.py +60 -0
- sh_agent-0.1.0/sh_agent/env.py +35 -0
- sh_agent-0.1.0/sh_agent/onboarding.py +185 -0
- sh_agent-0.1.0/sh_agent/palette.py +207 -0
- sh_agent-0.1.0/sh_agent/protocol.py +77 -0
- sh_agent-0.1.0/sh_agent/pty_session.py +355 -0
- sh_agent-0.1.0/sh_agent/registry.py +350 -0
- sh_agent-0.1.0/sh_agent/routes/__init__.py +0 -0
- sh_agent-0.1.0/sh_agent/routes/commands.py +97 -0
- sh_agent-0.1.0/sh_agent/routes/deps.py +62 -0
- sh_agent-0.1.0/sh_agent/routes/health.py +63 -0
- sh_agent-0.1.0/sh_agent/routes/session.py +314 -0
- sh_agent-0.1.0/sh_agent/tmux.py +159 -0
- sh_agent-0.1.0/systemd/sh-agent.service +27 -0
- sh_agent-0.1.0/tests/test_api.py +425 -0
- sh_agent-0.1.0/tests/test_onboarding.py +135 -0
- sh_agent-0.1.0/tests/test_pty_session.py +130 -0
- sh_agent-0.1.0/tests/test_startup_output.py +101 -0
- sh_agent-0.1.0/tests/test_tailscale_identity.py +169 -0
- sh_agent-0.1.0/tests/test_tmux.py +214 -0
- sh_agent-0.1.0/tests/test_tmux_api.py +609 -0
- sh_agent-0.1.0/uv.lock +845 -0
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.
|
sh_agent-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|