pocketshell 0.4.44__tar.gz → 0.4.45__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.
- {pocketshell-0.4.44 → pocketshell-0.4.45}/PKG-INFO +140 -62
- {pocketshell-0.4.44 → pocketshell-0.4.45}/README.md +137 -59
- {pocketshell-0.4.44 → pocketshell-0.4.45}/pyproject.toml +38 -17
- pocketshell-0.4.45/scripts/sync-readme-exit-codes.py +169 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/__init__.py +1 -1
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agent_log.py +80 -6
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agents.py +332 -48
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agents_kind.py +10 -15
- pocketshell-0.4.45/src/pocketshell/aplexer.py +108 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/cgroup_agents.py +2 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/cli.py +6 -1
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/daemon.py +505 -44
- pocketshell-0.4.45/src/pocketshell/engines.py +644 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/jobs.py +8 -16
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/profiles.py +113 -2
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/repos.py +18 -21
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/send.py +165 -21
- pocketshell-0.4.45/src/pocketshell/serve.py +208 -0
- pocketshell-0.4.45/src/pocketshell/session_enum.py +267 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/sessions.py +98 -50
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/tree.py +255 -42
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/usage.py +126 -50
- pocketshell-0.4.45/src/pocketshell/usage_capture.py +648 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/usage_reset.py +26 -13
- pocketshell-0.4.45/tests/conftest.py +92 -0
- pocketshell-0.4.45/tests/data/quse-0.0.13-usage.json +170 -0
- pocketshell-0.4.45/tests/data/quse-0.0.14-usage.json +184 -0
- pocketshell-0.4.45/tests/data/quse-0.0.14-usage.provenance.txt +18 -0
- pocketshell-0.4.45/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +6 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agent_log.py +70 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agents.py +283 -44
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agents_kind.py +21 -0
- pocketshell-0.4.45/tests/test_agents_kind_target.py +280 -0
- pocketshell-0.4.45/tests/test_agents_source_target.py +426 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cgroup_agents.py +28 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_daemon.py +96 -10
- pocketshell-0.4.45/tests/test_daemon_fallback.py +326 -0
- pocketshell-0.4.45/tests/test_engines.py +231 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_profiles.py +256 -2
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_push.py +7 -8
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_send.py +622 -2
- pocketshell-0.4.45/tests/test_serve.py +268 -0
- pocketshell-0.4.45/tests/test_session_enum.py +142 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_sessions.py +82 -19
- pocketshell-0.4.45/tests/test_ssh_fixture_penalties.py +1009 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_tree.py +266 -1
- pocketshell-0.4.45/tests/test_usage.py +619 -0
- pocketshell-0.4.45/tests/test_usage_capture.py +526 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_usage_reset.py +8 -10
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_watch_ci_cancelled.py +178 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/uv.lock +10 -10
- pocketshell-0.4.44/src/pocketshell/usage_capture.py +0 -301
- pocketshell-0.4.44/tests/test_usage.py +0 -435
- pocketshell-0.4.44/tests/test_usage_capture.py +0 -223
- {pocketshell-0.4.44 → pocketshell-0.4.45}/.gitignore +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/scheduler/README.md +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agent_card_push.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/cards.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/__init__.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/data/quse-0.0.11-usage.json +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/data/quse-0.0.9-usage.json +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agent_card_push.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cards.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cards_push_notify.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cli.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_env.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_github.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_logs.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_repos.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_resume.py +0 -0
- {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_watch_ci.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.45
|
|
4
4
|
Summary: Unified server-side Python utility for the PocketShell Android client.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
|
|
6
6
|
Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
|
|
@@ -21,8 +21,8 @@ Requires-Python: >=3.11
|
|
|
21
21
|
Requires-Dist: click>=8.2.0
|
|
22
22
|
Requires-Dist: google-auth>=2.0.0
|
|
23
23
|
Requires-Dist: pyyaml>=6.0
|
|
24
|
-
Requires-Dist: quse==0.0.
|
|
25
|
-
Requires-Dist: tmuxctl>=0.3.
|
|
24
|
+
Requires-Dist: quse==0.0.14
|
|
25
|
+
Requires-Dist: tmuxctl>=0.3.5
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest>=8.4.0; extra == 'dev'
|
|
28
28
|
Requires-Dist: ruff>=0.15.0; extra == 'dev'
|
|
@@ -91,6 +91,7 @@ pocketshell env ... # .env / .envrc management
|
|
|
91
91
|
pocketshell hooks ... # Claude/Codex/OpenCode hooks
|
|
92
92
|
pocketshell logs ... # server-side trace sink
|
|
93
93
|
pocketshell daemon ... # IPC daemon lifecycle
|
|
94
|
+
pocketshell serve --dir PATH [--port N] # foreground static HTTP server
|
|
94
95
|
pocketshell qr-share ... # SSH host QR import payloads
|
|
95
96
|
```
|
|
96
97
|
|
|
@@ -118,42 +119,69 @@ framing twice put the inner markers into the receiving program as literal
|
|
|
118
119
|
text (issue #1854). Callers that want bracketed paste write the framed bytes
|
|
119
120
|
to stdin.
|
|
120
121
|
|
|
121
|
-
Exit codes are stable
|
|
122
|
-
|
|
122
|
+
Exit codes are stable. Both renderings below — this table and `--help` — are
|
|
123
|
+
generated from the one `EXIT_CODE_TABLE` the code exits with, so neither can
|
|
124
|
+
drift from the other (issue #2153; regenerate with
|
|
125
|
+
`tools/pocketshell/scripts/sync-readme-exit-codes.py`, pinned by a test in the
|
|
126
|
+
`Python utility tests (pocketshell)` check):
|
|
127
|
+
|
|
128
|
+
<!-- BEGIN GENERATED: send exit codes (source: EXIT_CODE_TABLE in pocketshell/send.py) -->
|
|
123
129
|
|
|
124
130
|
| Exit | stdout reason | Meaning |
|
|
125
131
|
| ---- | ------------- | ------- |
|
|
126
|
-
| 0 | `delivered` |
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
133
|
-
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
132
|
+
| 0 | `delivered` \| `already-delivered` \| `pruned` | Success. 'delivered' = injected by THIS call and journaled. 'already-delivered' = the token was already journaled, nothing was injected. 'pruned' = --prune-older-than removed N records. |
|
|
133
|
+
| 2 | `bad-usage` | Invalid or missing arguments. Nothing was injected or journaled. |
|
|
134
|
+
| 3 | `pane-not-found` | The pane id does not exist on the tmux server, or it is dead. Nothing was injected; the token is NOT journaled and stays retryable. |
|
|
135
|
+
| 4 | `tmux-failed` | tmux is missing, no server is running, or a tmux command returned a definitive failure. This call put NOTHING into the pane and recorded no delivery, and it left the journal exactly as it found it: a claim this call took is released, and a pre-existing unresolved record it overwrote under --resend-interrupted is restored byte-for-byte. A retry therefore cannot duplicate — but 'unchanged' is not 'absent': if the token was already journaled-unresolved it still is, and the next plain call answers 'send-interrupted' rather than injecting. |
|
|
136
|
+
| 5 | `send-interrupted` \| `journal-corrupt` | Delivery is genuinely UNKNOWN and the token is left journaled-unresolved, so no plain call will ever inject it again. Two ways in. Either a PREVIOUS attempt died without an answer (or left an unreadable record) and its owning process is gone, in which case this call injected nothing; or THIS call got past the point of no return — tmux accepted the paste and then the Enter failed, or the delivery could not be journaled — in which case the payload may ALREADY be in the pane. Never auto-retry either reading. Re-run with --resend-interrupted only to accept a possible duplicate. |
|
|
137
|
+
| 6 | `timeout` | A tmux invocation exceeded --timeout. If the timeout hit at or after the commit the token is left in the unknown state above and the payload may ALREADY be in the pane; a retry then reports 'send-interrupted' rather than injecting again. |
|
|
138
|
+
| 7 | `journal-failed` | The durable token journal could not be read or written (permissions, disk). Nothing was injected — the journal is written BEFORE the pane is touched precisely so this failure is safe. |
|
|
139
|
+
| 8 | `send-in-progress` | Another send for this token is STILL RUNNING (its process is alive on this host). Nothing was injected by this call and nothing is unknown: the outcome is owned by that call. Retry shortly to read the answer — it will be 'delivered'/'already-delivered' or, if that process dies, 'send-interrupted'. --resend-interrupted does not override this: there is no unknown to resolve while the owner is alive, and forcing one would duplicate the payload. |
|
|
140
|
+
|
|
141
|
+
<!-- END GENERATED: send exit codes -->
|
|
142
|
+
|
|
143
|
+
(`pruned` prints the record count after the reason word: `pruned <n>`.)
|
|
144
|
+
|
|
145
|
+
**The paste is the point of no return, and that is the boundary between exit 4
|
|
146
|
+
and exit 5** (issue #2136). A client that branches on this table to decide
|
|
147
|
+
whether to auto-retry (#2124) needs one property, and it is exactly what exit 4
|
|
148
|
+
now means:
|
|
149
|
+
|
|
150
|
+
> **Exit 4 ⇒ this call put nothing into the pane, and left the journal exactly
|
|
151
|
+
> as it found it.**
|
|
152
|
+
|
|
153
|
+
Every exit-4 site satisfies it. Failures at the pane lookup or while filling
|
|
154
|
+
the paste buffer happen before the journal is written at all — nothing injected,
|
|
155
|
+
token unclaimed, cleanly retryable; this is the ordinary case. A definitive
|
|
156
|
+
`paste-buffer` failure (tmux answered "no", or tmux became unexecutable before
|
|
157
|
+
the paste) rolls this call's claim back: for a plain call that returns the token
|
|
158
|
+
to absent, and under `--resend-interrupted` the *pre-existing* unresolved record
|
|
159
|
+
is restored byte-for-byte rather than erased.
|
|
160
|
+
|
|
161
|
+
Note what "unchanged" does **not** mean. It does not mean "absent": a token that
|
|
162
|
+
was already journaled-unresolved still is, so the next plain call answers exit 5
|
|
163
|
+
rather than injecting. A retry after exit 4 can therefore never duplicate, but
|
|
164
|
+
it is not guaranteed to inject.
|
|
165
|
+
|
|
166
|
+
**Exit 5 in detail** — it carries two different facts, and a client must treat
|
|
167
|
+
both the same way (do not auto-retry; surface the choice):
|
|
168
|
+
|
|
169
|
+
- A **previous** attempt for this token died without an answer, or left an
|
|
170
|
+
unreadable record, and its owning process is gone. This call injected nothing.
|
|
171
|
+
- **This** call got past the paste. tmux accepted the payload, so it **is** in
|
|
172
|
+
the pane, and then either the `send-keys Enter` failed or the delivery could
|
|
173
|
+
not be journaled. The claim is deliberately kept — rolling it back is what
|
|
174
|
+
would let a plain retry paste the payload a second time.
|
|
175
|
+
|
|
176
|
+
Both readings leave the token journaled-unresolved, so the invariant is the same
|
|
177
|
+
for both: no plain call will ever inject it again, and `--resend-interrupted` is
|
|
178
|
+
the explicit opt-in that accepts a possible duplicate.
|
|
179
|
+
|
|
180
|
+
The `Enter` step has two failure shapes — tmux answers non-zero, or tmux stops
|
|
181
|
+
being executable between the paste and the `send-keys` — and they leave
|
|
182
|
+
byte-identical state. They report one outcome (exit 5). Until #2136 the second
|
|
183
|
+
reported exit 4, which told a client the pane was untouched while the payload
|
|
184
|
+
was sitting in it.
|
|
157
185
|
|
|
158
186
|
stdout is machine-readable: the first whitespace-delimited token is one of
|
|
159
187
|
the reasons above; human detail goes to stderr. Every retry path drains stdin
|
|
@@ -213,8 +241,15 @@ pocketshell usage codex # filter to a single provider
|
|
|
213
241
|
|
|
214
242
|
The output shape is byte-identical to `quse [provider] [--json]`. When
|
|
215
243
|
the IPC daemon is running, `usage --json` dispatches `usage.fetch` over
|
|
216
|
-
the daemon socket and uses the daemon's short TTL cache; otherwise
|
|
217
|
-
|
|
244
|
+
the daemon socket and uses the daemon's short TTL cache; otherwise an
|
|
245
|
+
absent/unavailable daemon or explicitly supported method skew falls through
|
|
246
|
+
to the one-shot subprocess path. Timeout, malformed-response, and
|
|
247
|
+
daemon-internal failures are surfaced instead of being retried locally.
|
|
248
|
+
|
|
249
|
+
All daemon-backed wrappers (`usage`, `repos`, `tree`, `jobs`, `sessions`, and
|
|
250
|
+
`agents kind`) use one typed fallback boundary. It emits the safe
|
|
251
|
+
`pocketshell.daemon_call` event with `reason`, `method`, `phase`, RPC code, and
|
|
252
|
+
available CLI/daemon versions. It never logs RPC parameters or command output.
|
|
218
253
|
|
|
219
254
|
If `quse` is not installed, `pocketshell usage` exits with code 127 and
|
|
220
255
|
prints an install hint to stderr.
|
|
@@ -304,6 +339,34 @@ unauthenticated the `hint` tells them to run `gh auth login`. The only
|
|
|
304
339
|
network access is whatever `gh auth status` itself performs (a token-validity
|
|
305
340
|
check); the command does NOT call the GitHub API.
|
|
306
341
|
|
|
342
|
+
### `pocketshell serve`
|
|
343
|
+
|
|
344
|
+
Serve a folder over HTTP for a client-owned SSH port forward:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
pocketshell serve --dir /path/to/site
|
|
348
|
+
pocketshell serve --dir /path/to/site --port 8080 --bind 127.0.0.1
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The server binds `127.0.0.1` by default. Omitting `--port` (or passing
|
|
352
|
+
`--port 0`) lets the OS select a free port; after binding, stdout contains
|
|
353
|
+
exactly one stable JSON line with the selected port:
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
{"port":43123}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The process stays in the foreground so the caller owns its lifetime: keep the
|
|
360
|
+
SSH exec channel alive while the site is needed and terminate that process
|
|
361
|
+
when the view closes or the connection is lost. There is no detached server
|
|
362
|
+
registry or `--stop` command in this contract. HTTP access logs and errors go
|
|
363
|
+
to stderr, keeping stdout parseable.
|
|
364
|
+
|
|
365
|
+
Requests serve static files with stdlib MIME detection. A directory resolves
|
|
366
|
+
to its `index.html` when present; paths are resolved before the containment
|
|
367
|
+
check, so parent traversal and symlinks that leave the selected directory are
|
|
368
|
+
rejected rather than served.
|
|
369
|
+
|
|
307
370
|
### `pocketshell qr-share`
|
|
308
371
|
|
|
309
372
|
Builds a `pocketshell.ssh-import.v1` payload from an `~/.ssh/config`
|
|
@@ -443,39 +506,54 @@ maintainer cuts an Android release tag (`vX.Y.Z`), the
|
|
|
443
506
|
[`Build`](../../.github/workflows/build.yml) workflow assembles the APK
|
|
444
507
|
and **also** builds the Python sdist + wheel and publishes them to PyPI.
|
|
445
508
|
|
|
446
|
-
### Version coupling
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
509
|
+
### Version coupling (tag-derived, issue #2356)
|
|
510
|
+
|
|
511
|
+
Neither side is a hand-maintained literal any more. Both derive from the git
|
|
512
|
+
tag being built, via the single shared script
|
|
513
|
+
[`scripts/derive-version.sh`](../../scripts/derive-version.sh):
|
|
514
|
+
|
|
515
|
+
- `app/build.gradle.kts` computes `versionCode`/`versionName` at Gradle
|
|
516
|
+
configuration time by shelling out to `scripts/derive-version.sh`.
|
|
517
|
+
- `tools/pocketshell/pyproject.toml`'s committed `version` field is a
|
|
518
|
+
placeholder. The release workflow's "Stamp pyproject.toml version from
|
|
519
|
+
tag" step overwrites it (in the ephemeral CI checkout, never committed)
|
|
520
|
+
from `scripts/derive-version.sh version-name --ref <tag>` immediately
|
|
521
|
+
before building the sdist/wheel.
|
|
522
|
+
|
|
523
|
+
[`scripts/check-version-coupling.sh`](../../scripts/check-version-coupling.sh)
|
|
524
|
+
verifies the derivation script is the SOLE source of truth (its own
|
|
525
|
+
self-test, Gradle's resolved version matching a direct script invocation,
|
|
526
|
+
and both consumers referencing the script by path rather than an
|
|
527
|
+
independent reimplementation) — it runs per-push in `tests.yml`.
|
|
453
528
|
[`scripts/check-pypi-version.sh`](../../scripts/check-pypi-version.sh)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
Run it locally before tagging:
|
|
529
|
+
verifies, at tag-publish time, that the freshly-stamped
|
|
530
|
+
`pyproject.toml` version equals what `scripts/derive-version.sh` derives
|
|
531
|
+
for the tag being published:
|
|
459
532
|
|
|
460
533
|
```bash
|
|
461
|
-
scripts/check-pypi-version.sh # local match check
|
|
462
534
|
scripts/check-pypi-version.sh --check-tag vX.Y.Z
|
|
463
535
|
```
|
|
464
536
|
|
|
465
|
-
###
|
|
537
|
+
### Cutting a release
|
|
538
|
+
|
|
539
|
+
There is no version-bump commit or PR. The tag itself is the version
|
|
540
|
+
declaration:
|
|
466
541
|
|
|
467
542
|
1. Pick the next semantic version after the latest GitHub Release/tag.
|
|
468
|
-
2.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
3.
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
543
|
+
2. Run the emulator release validation gate
|
|
544
|
+
(`scripts/release-emulator-validation.sh`) against the current `main`
|
|
545
|
+
HEAD, as described in [`process.md`](../../process.md) -> "Release
|
|
546
|
+
Builds".
|
|
547
|
+
3. Push the tag with `scripts/push-release-tag.sh vX.Y.Z ...`. It creates
|
|
548
|
+
the tag locally FIRST and verifies `scripts/derive-version.sh` derives
|
|
549
|
+
the expected `versionName` (and a strictly-monotonic `versionCode`
|
|
550
|
+
versus the previous tag) from it before pushing — so a derivation bug
|
|
551
|
+
is caught before the tag ever reaches `origin`. The tag-triggered
|
|
477
552
|
`Build` workflow then:
|
|
478
|
-
- builds and uploads the APK
|
|
553
|
+
- builds and uploads the APK (its `versionCode`/`versionName` come from
|
|
554
|
+
the tag via `app/build.gradle.kts`'s own derivation) + creates the
|
|
555
|
+
GitHub Release
|
|
556
|
+
- stamps `tools/pocketshell/pyproject.toml`'s version from the tag
|
|
479
557
|
- runs `scripts/check-pypi-version.sh --check-tag vX.Y.Z`
|
|
480
558
|
- builds the Python sdist + wheel
|
|
481
559
|
- publishes them to PyPI via OIDC trusted publishing
|
|
@@ -59,6 +59,7 @@ pocketshell env ... # .env / .envrc management
|
|
|
59
59
|
pocketshell hooks ... # Claude/Codex/OpenCode hooks
|
|
60
60
|
pocketshell logs ... # server-side trace sink
|
|
61
61
|
pocketshell daemon ... # IPC daemon lifecycle
|
|
62
|
+
pocketshell serve --dir PATH [--port N] # foreground static HTTP server
|
|
62
63
|
pocketshell qr-share ... # SSH host QR import payloads
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -86,42 +87,69 @@ framing twice put the inner markers into the receiving program as literal
|
|
|
86
87
|
text (issue #1854). Callers that want bracketed paste write the framed bytes
|
|
87
88
|
to stdin.
|
|
88
89
|
|
|
89
|
-
Exit codes are stable
|
|
90
|
-
|
|
90
|
+
Exit codes are stable. Both renderings below — this table and `--help` — are
|
|
91
|
+
generated from the one `EXIT_CODE_TABLE` the code exits with, so neither can
|
|
92
|
+
drift from the other (issue #2153; regenerate with
|
|
93
|
+
`tools/pocketshell/scripts/sync-readme-exit-codes.py`, pinned by a test in the
|
|
94
|
+
`Python utility tests (pocketshell)` check):
|
|
95
|
+
|
|
96
|
+
<!-- BEGIN GENERATED: send exit codes (source: EXIT_CODE_TABLE in pocketshell/send.py) -->
|
|
91
97
|
|
|
92
98
|
| Exit | stdout reason | Meaning |
|
|
93
99
|
| ---- | ------------- | ------- |
|
|
94
|
-
| 0 | `delivered` |
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
100
|
+
| 0 | `delivered` \| `already-delivered` \| `pruned` | Success. 'delivered' = injected by THIS call and journaled. 'already-delivered' = the token was already journaled, nothing was injected. 'pruned' = --prune-older-than removed N records. |
|
|
101
|
+
| 2 | `bad-usage` | Invalid or missing arguments. Nothing was injected or journaled. |
|
|
102
|
+
| 3 | `pane-not-found` | The pane id does not exist on the tmux server, or it is dead. Nothing was injected; the token is NOT journaled and stays retryable. |
|
|
103
|
+
| 4 | `tmux-failed` | tmux is missing, no server is running, or a tmux command returned a definitive failure. This call put NOTHING into the pane and recorded no delivery, and it left the journal exactly as it found it: a claim this call took is released, and a pre-existing unresolved record it overwrote under --resend-interrupted is restored byte-for-byte. A retry therefore cannot duplicate — but 'unchanged' is not 'absent': if the token was already journaled-unresolved it still is, and the next plain call answers 'send-interrupted' rather than injecting. |
|
|
104
|
+
| 5 | `send-interrupted` \| `journal-corrupt` | Delivery is genuinely UNKNOWN and the token is left journaled-unresolved, so no plain call will ever inject it again. Two ways in. Either a PREVIOUS attempt died without an answer (or left an unreadable record) and its owning process is gone, in which case this call injected nothing; or THIS call got past the point of no return — tmux accepted the paste and then the Enter failed, or the delivery could not be journaled — in which case the payload may ALREADY be in the pane. Never auto-retry either reading. Re-run with --resend-interrupted only to accept a possible duplicate. |
|
|
105
|
+
| 6 | `timeout` | A tmux invocation exceeded --timeout. If the timeout hit at or after the commit the token is left in the unknown state above and the payload may ALREADY be in the pane; a retry then reports 'send-interrupted' rather than injecting again. |
|
|
106
|
+
| 7 | `journal-failed` | The durable token journal could not be read or written (permissions, disk). Nothing was injected — the journal is written BEFORE the pane is touched precisely so this failure is safe. |
|
|
107
|
+
| 8 | `send-in-progress` | Another send for this token is STILL RUNNING (its process is alive on this host). Nothing was injected by this call and nothing is unknown: the outcome is owned by that call. Retry shortly to read the answer — it will be 'delivered'/'already-delivered' or, if that process dies, 'send-interrupted'. --resend-interrupted does not override this: there is no unknown to resolve while the owner is alive, and forcing one would duplicate the payload. |
|
|
108
|
+
|
|
109
|
+
<!-- END GENERATED: send exit codes -->
|
|
110
|
+
|
|
111
|
+
(`pruned` prints the record count after the reason word: `pruned <n>`.)
|
|
112
|
+
|
|
113
|
+
**The paste is the point of no return, and that is the boundary between exit 4
|
|
114
|
+
and exit 5** (issue #2136). A client that branches on this table to decide
|
|
115
|
+
whether to auto-retry (#2124) needs one property, and it is exactly what exit 4
|
|
116
|
+
now means:
|
|
117
|
+
|
|
118
|
+
> **Exit 4 ⇒ this call put nothing into the pane, and left the journal exactly
|
|
119
|
+
> as it found it.**
|
|
120
|
+
|
|
121
|
+
Every exit-4 site satisfies it. Failures at the pane lookup or while filling
|
|
122
|
+
the paste buffer happen before the journal is written at all — nothing injected,
|
|
123
|
+
token unclaimed, cleanly retryable; this is the ordinary case. A definitive
|
|
124
|
+
`paste-buffer` failure (tmux answered "no", or tmux became unexecutable before
|
|
125
|
+
the paste) rolls this call's claim back: for a plain call that returns the token
|
|
126
|
+
to absent, and under `--resend-interrupted` the *pre-existing* unresolved record
|
|
127
|
+
is restored byte-for-byte rather than erased.
|
|
128
|
+
|
|
129
|
+
Note what "unchanged" does **not** mean. It does not mean "absent": a token that
|
|
130
|
+
was already journaled-unresolved still is, so the next plain call answers exit 5
|
|
131
|
+
rather than injecting. A retry after exit 4 can therefore never duplicate, but
|
|
132
|
+
it is not guaranteed to inject.
|
|
133
|
+
|
|
134
|
+
**Exit 5 in detail** — it carries two different facts, and a client must treat
|
|
135
|
+
both the same way (do not auto-retry; surface the choice):
|
|
136
|
+
|
|
137
|
+
- A **previous** attempt for this token died without an answer, or left an
|
|
138
|
+
unreadable record, and its owning process is gone. This call injected nothing.
|
|
139
|
+
- **This** call got past the paste. tmux accepted the payload, so it **is** in
|
|
140
|
+
the pane, and then either the `send-keys Enter` failed or the delivery could
|
|
141
|
+
not be journaled. The claim is deliberately kept — rolling it back is what
|
|
142
|
+
would let a plain retry paste the payload a second time.
|
|
143
|
+
|
|
144
|
+
Both readings leave the token journaled-unresolved, so the invariant is the same
|
|
145
|
+
for both: no plain call will ever inject it again, and `--resend-interrupted` is
|
|
146
|
+
the explicit opt-in that accepts a possible duplicate.
|
|
147
|
+
|
|
148
|
+
The `Enter` step has two failure shapes — tmux answers non-zero, or tmux stops
|
|
149
|
+
being executable between the paste and the `send-keys` — and they leave
|
|
150
|
+
byte-identical state. They report one outcome (exit 5). Until #2136 the second
|
|
151
|
+
reported exit 4, which told a client the pane was untouched while the payload
|
|
152
|
+
was sitting in it.
|
|
125
153
|
|
|
126
154
|
stdout is machine-readable: the first whitespace-delimited token is one of
|
|
127
155
|
the reasons above; human detail goes to stderr. Every retry path drains stdin
|
|
@@ -181,8 +209,15 @@ pocketshell usage codex # filter to a single provider
|
|
|
181
209
|
|
|
182
210
|
The output shape is byte-identical to `quse [provider] [--json]`. When
|
|
183
211
|
the IPC daemon is running, `usage --json` dispatches `usage.fetch` over
|
|
184
|
-
the daemon socket and uses the daemon's short TTL cache; otherwise
|
|
185
|
-
|
|
212
|
+
the daemon socket and uses the daemon's short TTL cache; otherwise an
|
|
213
|
+
absent/unavailable daemon or explicitly supported method skew falls through
|
|
214
|
+
to the one-shot subprocess path. Timeout, malformed-response, and
|
|
215
|
+
daemon-internal failures are surfaced instead of being retried locally.
|
|
216
|
+
|
|
217
|
+
All daemon-backed wrappers (`usage`, `repos`, `tree`, `jobs`, `sessions`, and
|
|
218
|
+
`agents kind`) use one typed fallback boundary. It emits the safe
|
|
219
|
+
`pocketshell.daemon_call` event with `reason`, `method`, `phase`, RPC code, and
|
|
220
|
+
available CLI/daemon versions. It never logs RPC parameters or command output.
|
|
186
221
|
|
|
187
222
|
If `quse` is not installed, `pocketshell usage` exits with code 127 and
|
|
188
223
|
prints an install hint to stderr.
|
|
@@ -272,6 +307,34 @@ unauthenticated the `hint` tells them to run `gh auth login`. The only
|
|
|
272
307
|
network access is whatever `gh auth status` itself performs (a token-validity
|
|
273
308
|
check); the command does NOT call the GitHub API.
|
|
274
309
|
|
|
310
|
+
### `pocketshell serve`
|
|
311
|
+
|
|
312
|
+
Serve a folder over HTTP for a client-owned SSH port forward:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
pocketshell serve --dir /path/to/site
|
|
316
|
+
pocketshell serve --dir /path/to/site --port 8080 --bind 127.0.0.1
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The server binds `127.0.0.1` by default. Omitting `--port` (or passing
|
|
320
|
+
`--port 0`) lets the OS select a free port; after binding, stdout contains
|
|
321
|
+
exactly one stable JSON line with the selected port:
|
|
322
|
+
|
|
323
|
+
```json
|
|
324
|
+
{"port":43123}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
The process stays in the foreground so the caller owns its lifetime: keep the
|
|
328
|
+
SSH exec channel alive while the site is needed and terminate that process
|
|
329
|
+
when the view closes or the connection is lost. There is no detached server
|
|
330
|
+
registry or `--stop` command in this contract. HTTP access logs and errors go
|
|
331
|
+
to stderr, keeping stdout parseable.
|
|
332
|
+
|
|
333
|
+
Requests serve static files with stdlib MIME detection. A directory resolves
|
|
334
|
+
to its `index.html` when present; paths are resolved before the containment
|
|
335
|
+
check, so parent traversal and symlinks that leave the selected directory are
|
|
336
|
+
rejected rather than served.
|
|
337
|
+
|
|
275
338
|
### `pocketshell qr-share`
|
|
276
339
|
|
|
277
340
|
Builds a `pocketshell.ssh-import.v1` payload from an `~/.ssh/config`
|
|
@@ -411,39 +474,54 @@ maintainer cuts an Android release tag (`vX.Y.Z`), the
|
|
|
411
474
|
[`Build`](../../.github/workflows/build.yml) workflow assembles the APK
|
|
412
475
|
and **also** builds the Python sdist + wheel and publishes them to PyPI.
|
|
413
476
|
|
|
414
|
-
### Version coupling
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
477
|
+
### Version coupling (tag-derived, issue #2356)
|
|
478
|
+
|
|
479
|
+
Neither side is a hand-maintained literal any more. Both derive from the git
|
|
480
|
+
tag being built, via the single shared script
|
|
481
|
+
[`scripts/derive-version.sh`](../../scripts/derive-version.sh):
|
|
482
|
+
|
|
483
|
+
- `app/build.gradle.kts` computes `versionCode`/`versionName` at Gradle
|
|
484
|
+
configuration time by shelling out to `scripts/derive-version.sh`.
|
|
485
|
+
- `tools/pocketshell/pyproject.toml`'s committed `version` field is a
|
|
486
|
+
placeholder. The release workflow's "Stamp pyproject.toml version from
|
|
487
|
+
tag" step overwrites it (in the ephemeral CI checkout, never committed)
|
|
488
|
+
from `scripts/derive-version.sh version-name --ref <tag>` immediately
|
|
489
|
+
before building the sdist/wheel.
|
|
490
|
+
|
|
491
|
+
[`scripts/check-version-coupling.sh`](../../scripts/check-version-coupling.sh)
|
|
492
|
+
verifies the derivation script is the SOLE source of truth (its own
|
|
493
|
+
self-test, Gradle's resolved version matching a direct script invocation,
|
|
494
|
+
and both consumers referencing the script by path rather than an
|
|
495
|
+
independent reimplementation) — it runs per-push in `tests.yml`.
|
|
421
496
|
[`scripts/check-pypi-version.sh`](../../scripts/check-pypi-version.sh)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
Run it locally before tagging:
|
|
497
|
+
verifies, at tag-publish time, that the freshly-stamped
|
|
498
|
+
`pyproject.toml` version equals what `scripts/derive-version.sh` derives
|
|
499
|
+
for the tag being published:
|
|
427
500
|
|
|
428
501
|
```bash
|
|
429
|
-
scripts/check-pypi-version.sh # local match check
|
|
430
502
|
scripts/check-pypi-version.sh --check-tag vX.Y.Z
|
|
431
503
|
```
|
|
432
504
|
|
|
433
|
-
###
|
|
505
|
+
### Cutting a release
|
|
506
|
+
|
|
507
|
+
There is no version-bump commit or PR. The tag itself is the version
|
|
508
|
+
declaration:
|
|
434
509
|
|
|
435
510
|
1. Pick the next semantic version after the latest GitHub Release/tag.
|
|
436
|
-
2.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
3.
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
511
|
+
2. Run the emulator release validation gate
|
|
512
|
+
(`scripts/release-emulator-validation.sh`) against the current `main`
|
|
513
|
+
HEAD, as described in [`process.md`](../../process.md) -> "Release
|
|
514
|
+
Builds".
|
|
515
|
+
3. Push the tag with `scripts/push-release-tag.sh vX.Y.Z ...`. It creates
|
|
516
|
+
the tag locally FIRST and verifies `scripts/derive-version.sh` derives
|
|
517
|
+
the expected `versionName` (and a strictly-monotonic `versionCode`
|
|
518
|
+
versus the previous tag) from it before pushing — so a derivation bug
|
|
519
|
+
is caught before the tag ever reaches `origin`. The tag-triggered
|
|
445
520
|
`Build` workflow then:
|
|
446
|
-
- builds and uploads the APK
|
|
521
|
+
- builds and uploads the APK (its `versionCode`/`versionName` come from
|
|
522
|
+
the tag via `app/build.gradle.kts`'s own derivation) + creates the
|
|
523
|
+
GitHub Release
|
|
524
|
+
- stamps `tools/pocketshell/pyproject.toml`'s version from the tag
|
|
447
525
|
- runs `scripts/check-pypi-version.sh --check-tag vX.Y.Z`
|
|
448
526
|
- builds the Python sdist + wheel
|
|
449
527
|
- publishes them to PyPI via OIDC trusted publishing
|
|
@@ -4,11 +4,17 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pocketshell"
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
7
|
+
# PLACEHOLDER (issue #2356, Phase 4 of epic #2350): this value is NEVER read
|
|
8
|
+
# for a real release. The publish-pypi job in .github/workflows/build.yml
|
|
9
|
+
# overwrites this field (in its ephemeral CI checkout only, never committed)
|
|
10
|
+
# from `scripts/derive-version.sh version-name --ref <tag>` — the SAME
|
|
11
|
+
# derivation app/build.gradle.kts uses for the Android versionName — right
|
|
12
|
+
# before building the sdist/wheel, so both sides derive from the git tag
|
|
13
|
+
# being released and can never independently drift.
|
|
14
|
+
# scripts/check-pypi-version.sh enforces the stamped value matches the tag;
|
|
15
|
+
# scripts/check-version-coupling.sh enforces the derivation itself is
|
|
16
|
+
# single-sourced. See tools/pocketshell/README.md ("Version coupling").
|
|
17
|
+
version = "0.4.45"
|
|
12
18
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
19
|
readme = "README.md"
|
|
14
20
|
requires-python = ">=3.11"
|
|
@@ -36,17 +42,17 @@ dependencies = [
|
|
|
36
42
|
"click>=8.2.0",
|
|
37
43
|
# Usage backend + single source of truth for the unified provider schema
|
|
38
44
|
# (issue #1318). Pinned exactly: `pocketshell usage --json` expects quse's
|
|
39
|
-
# provider-keyed `--json` document
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# `
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
"quse==0.0.
|
|
45
|
+
# provider-keyed `--json` document and fails loudly on schema drift, so the
|
|
46
|
+
# version is frozen, not a range. The published 0.0.14 wheel is the
|
|
47
|
+
# five-provider legacy shape with `short_term` / `long_term` records. The
|
|
48
|
+
# host boundary translates those records into canonical `windows`; it also
|
|
49
|
+
# accepts a separate top-level `windows` producer contract for newer or
|
|
50
|
+
# custom producers, without treating that shape as PyPI 0.0.14 provenance.
|
|
51
|
+
# Canonical-only providers such as OpenCode Go belong to that separate
|
|
52
|
+
# contract, not to the published-wheel provider list. 0.0.13 (#2195)
|
|
53
|
+
# historically added the `grok` provider; 0.0.11 (#1564) labeled each
|
|
54
|
+
# Codex window from its actual `limit_window_seconds`.
|
|
55
|
+
"quse==0.0.14",
|
|
50
56
|
# FCM HTTP v1 push delivery (#690): service-account OAuth2 bearer minting
|
|
51
57
|
# for `pocketshell push` / the `usage --capture` reset-push send. Imported
|
|
52
58
|
# lazily and fail-soft — a host without it (or without a Firebase
|
|
@@ -64,7 +70,15 @@ dependencies = [
|
|
|
64
70
|
# down every session at once; 0.3.3 starts the server in its own
|
|
65
71
|
# login-independent unit under robust.slice (tmuxctl#4). 0.3.0 first shipped
|
|
66
72
|
# the `create-detached` verb this CLI calls.
|
|
67
|
-
|
|
73
|
+
# >=0.3.5 is required as of 2026-08-26: 0.3.4 still resolves/creates
|
|
74
|
+
# sessions on the legacy shared socket only. A pinned-old pocketshell host
|
|
75
|
+
# CLI running alongside a newer interactive `tmuxctl`/`t` checkout (which
|
|
76
|
+
# does per-session servers) raced session creation for the same name onto
|
|
77
|
+
# two different sockets, so the app/desktop and an interactive `t` session
|
|
78
|
+
# silently disagreed about which sessions existed. 0.3.5 ships tmuxctl's
|
|
79
|
+
# per-session-server migration so every caller resolves sessions the same
|
|
80
|
+
# way.
|
|
81
|
+
"tmuxctl>=0.3.5",
|
|
68
82
|
]
|
|
69
83
|
|
|
70
84
|
[project.scripts]
|
|
@@ -111,6 +125,13 @@ exclude = [
|
|
|
111
125
|
"/dist",
|
|
112
126
|
]
|
|
113
127
|
|
|
128
|
+
[tool.uv]
|
|
129
|
+
# Keep the lockfile's reproducibility cutoff project-local. The host may have
|
|
130
|
+
# a rolling global `exclude-newer` policy that predates deliberately pinned
|
|
131
|
+
# releases (quse 0.0.14 and tmuxctl 0.3.5); this exact value is also the
|
|
132
|
+
# `uv.lock` options cutoff and must be used when checking this lock.
|
|
133
|
+
exclude-newer = "2026-08-27T22:00:00Z"
|
|
134
|
+
|
|
114
135
|
[tool.pytest.ini_options]
|
|
115
136
|
testpaths = ["tests"]
|
|
116
137
|
|