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.
Files changed (86) hide show
  1. {pocketshell-0.4.44 → pocketshell-0.4.45}/PKG-INFO +140 -62
  2. {pocketshell-0.4.44 → pocketshell-0.4.45}/README.md +137 -59
  3. {pocketshell-0.4.44 → pocketshell-0.4.45}/pyproject.toml +38 -17
  4. pocketshell-0.4.45/scripts/sync-readme-exit-codes.py +169 -0
  5. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/__init__.py +1 -1
  6. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agent_log.py +80 -6
  7. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agents.py +332 -48
  8. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agents_kind.py +10 -15
  9. pocketshell-0.4.45/src/pocketshell/aplexer.py +108 -0
  10. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/cgroup_agents.py +2 -0
  11. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/cli.py +6 -1
  12. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/daemon.py +505 -44
  13. pocketshell-0.4.45/src/pocketshell/engines.py +644 -0
  14. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/jobs.py +8 -16
  15. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/profiles.py +113 -2
  16. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/repos.py +18 -21
  17. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/send.py +165 -21
  18. pocketshell-0.4.45/src/pocketshell/serve.py +208 -0
  19. pocketshell-0.4.45/src/pocketshell/session_enum.py +267 -0
  20. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/sessions.py +98 -50
  21. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/tree.py +255 -42
  22. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/usage.py +126 -50
  23. pocketshell-0.4.45/src/pocketshell/usage_capture.py +648 -0
  24. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/usage_reset.py +26 -13
  25. pocketshell-0.4.45/tests/conftest.py +92 -0
  26. pocketshell-0.4.45/tests/data/quse-0.0.13-usage.json +170 -0
  27. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.json +184 -0
  28. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.provenance.txt +18 -0
  29. pocketshell-0.4.45/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +6 -0
  30. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agent_log.py +70 -0
  31. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agents.py +283 -44
  32. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agents_kind.py +21 -0
  33. pocketshell-0.4.45/tests/test_agents_kind_target.py +280 -0
  34. pocketshell-0.4.45/tests/test_agents_source_target.py +426 -0
  35. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cgroup_agents.py +28 -0
  36. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_daemon.py +96 -10
  37. pocketshell-0.4.45/tests/test_daemon_fallback.py +326 -0
  38. pocketshell-0.4.45/tests/test_engines.py +231 -0
  39. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_profiles.py +256 -2
  40. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_push.py +7 -8
  41. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_send.py +622 -2
  42. pocketshell-0.4.45/tests/test_serve.py +268 -0
  43. pocketshell-0.4.45/tests/test_session_enum.py +142 -0
  44. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_sessions.py +82 -19
  45. pocketshell-0.4.45/tests/test_ssh_fixture_penalties.py +1009 -0
  46. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_tree.py +266 -1
  47. pocketshell-0.4.45/tests/test_usage.py +619 -0
  48. pocketshell-0.4.45/tests/test_usage_capture.py +526 -0
  49. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_usage_reset.py +8 -10
  50. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_watch_ci_cancelled.py +178 -0
  51. {pocketshell-0.4.44 → pocketshell-0.4.45}/uv.lock +10 -10
  52. pocketshell-0.4.44/src/pocketshell/usage_capture.py +0 -301
  53. pocketshell-0.4.44/tests/test_usage.py +0 -435
  54. pocketshell-0.4.44/tests/test_usage_capture.py +0 -223
  55. {pocketshell-0.4.44 → pocketshell-0.4.45}/.gitignore +0 -0
  56. {pocketshell-0.4.44 → pocketshell-0.4.45}/scheduler/README.md +0 -0
  57. {pocketshell-0.4.44 → pocketshell-0.4.45}/scheduler/pocketshell-usage-capture.service +0 -0
  58. {pocketshell-0.4.44 → pocketshell-0.4.45}/scheduler/pocketshell-usage-capture.timer +0 -0
  59. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/__main__.py +0 -0
  60. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/agent_card_push.py +0 -0
  61. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/cards.py +0 -0
  62. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/env.py +0 -0
  63. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/github.py +0 -0
  64. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/hooks.py +0 -0
  65. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/logs.py +0 -0
  66. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/prune_attachments.py +0 -0
  67. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/push.py +0 -0
  68. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/qr_share.py +0 -0
  69. {pocketshell-0.4.44 → pocketshell-0.4.45}/src/pocketshell/resume.py +0 -0
  70. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/__init__.py +0 -0
  71. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/data/quse-0.0.11-usage.json +0 -0
  72. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/data/quse-0.0.9-usage.json +0 -0
  73. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_agent_card_push.py +0 -0
  74. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cards.py +0 -0
  75. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cards_push_notify.py +0 -0
  76. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_cli.py +0 -0
  77. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_env.py +0 -0
  78. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_github.py +0 -0
  79. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_hooks.py +0 -0
  80. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_jobs.py +0 -0
  81. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_logs.py +0 -0
  82. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_prune_attachments.py +0 -0
  83. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_qr_share.py +0 -0
  84. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_repos.py +0 -0
  85. {pocketshell-0.4.44 → pocketshell-0.4.45}/tests/test_resume.py +0 -0
  86. {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.44
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.11
25
- Requires-Dist: tmuxctl>=0.3.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, and `--help` documents them (rendered from the same
122
- table the code exits with, so they cannot drift):
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` | Injected by this call and journaled. |
127
- | 0 | `already-delivered` | The token was already journaled; nothing injected. |
128
- | 0 | `pruned <n>` | `--prune-older-than` removed `n` records. |
129
- | 2 | `bad-usage` | Invalid/missing arguments. Nothing injected or journaled. |
130
- | 3 | `pane-not-found` | Pane missing or dead. Not journaled; stays retryable. |
131
- | 4 | `tmux-failed` | tmux missing / no server / a definitive tmux failure. This call never recorded a delivery. Usually the pane was never touched and the token is left unclaimed, but not always see the note below before auto-retrying. |
132
- | 5 | `send-interrupted` or `journal-corrupt` | A previous attempt for this token died without an answer (its process is gone). Delivery is genuinely UNKNOWN and nothing was injected now. |
133
- | 6 | `timeout` | A tmux call exceeded `--timeout`. |
134
- | 7 | `journal-failed` | The journal could not be read/written. Nothing injected. |
135
- | 8 | `send-in-progress` | Another send for this token is STILL RUNNING. Nothing injected, nothing unknown — retry shortly to read that call's answer. |
136
-
137
- **Exit 4 in detail** a client that branches on this table to decide whether
138
- to auto-retry (#2124) must not read `tmux-failed` as "clean slate". It
139
- guarantees only that this call never recorded a *delivery*. It does **not**
140
- guarantee the token is unjournaled, and in one narrow case it does not
141
- guarantee the pane is untouched:
142
-
143
- - Failure at the pane lookup, or while filling the paste buffer — both happen
144
- before the journal is written at all: nothing injected, token unclaimed,
145
- cleanly retryable. This is the ordinary case.
146
- - A definitive `paste-buffer` failure rolls this call's claim back. For a plain
147
- call that returns the token to absent (cleanly retryable). Under
148
- `--resend-interrupted` the *pre-existing* unresolved record is restored
149
- byte-for-byte rather than erased, so the token stays journaled-unresolved and
150
- the next plain call answers exit 5, not a fresh injection.
151
- - tmux disappearing between a successful paste and the `Enter`: the payload
152
- **is** in the pane, the pending record is deliberately kept, and the next
153
- plain call answers exit 5.
154
-
155
- A plain retry after exit 4 is therefore always *safe* it re-reads the journal
156
- and answers exit 5 rather than duplicating but it is not guaranteed to inject.
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 it
217
- falls through to the one-shot subprocess path.
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
- Two files must agree on the release version:
449
-
450
- - `app/build.gradle.kts` -> `versionName = "X.Y.Z"`
451
- - `tools/pocketshell/pyproject.toml` -> `version = "X.Y.Z"`
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
- enforces this. The release workflow runs it with `--check-tag vX.Y.Z`
455
- before publishing, so a tag pushed with mismatched versions fails the
456
- job loudly before anything reaches PyPI.
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
- ### Bumping a release
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. Update **both** version sources in the same commit:
469
- - `app/build.gradle.kts` -> bump `versionName` (and `versionCode`).
470
- - `tools/pocketshell/pyproject.toml` -> bump `version` to the
471
- same value as `versionName`.
472
- 3. Run `scripts/check-pypi-version.sh` to confirm they match.
473
- 4. Commit the bump on `main`, push, and run the emulator release
474
- validation gate (`scripts/release-emulator-validation.sh`) as
475
- described in [`process.md`](../../process.md) -> "Release Builds".
476
- 5. Push the tag with `scripts/push-release-tag.sh`. The tag-triggered
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 + creates the GitHub Release
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, and `--help` documents them (rendered from the same
90
- table the code exits with, so they cannot drift):
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` | Injected by this call and journaled. |
95
- | 0 | `already-delivered` | The token was already journaled; nothing injected. |
96
- | 0 | `pruned <n>` | `--prune-older-than` removed `n` records. |
97
- | 2 | `bad-usage` | Invalid/missing arguments. Nothing injected or journaled. |
98
- | 3 | `pane-not-found` | Pane missing or dead. Not journaled; stays retryable. |
99
- | 4 | `tmux-failed` | tmux missing / no server / a definitive tmux failure. This call never recorded a delivery. Usually the pane was never touched and the token is left unclaimed, but not always see the note below before auto-retrying. |
100
- | 5 | `send-interrupted` or `journal-corrupt` | A previous attempt for this token died without an answer (its process is gone). Delivery is genuinely UNKNOWN and nothing was injected now. |
101
- | 6 | `timeout` | A tmux call exceeded `--timeout`. |
102
- | 7 | `journal-failed` | The journal could not be read/written. Nothing injected. |
103
- | 8 | `send-in-progress` | Another send for this token is STILL RUNNING. Nothing injected, nothing unknown — retry shortly to read that call's answer. |
104
-
105
- **Exit 4 in detail** a client that branches on this table to decide whether
106
- to auto-retry (#2124) must not read `tmux-failed` as "clean slate". It
107
- guarantees only that this call never recorded a *delivery*. It does **not**
108
- guarantee the token is unjournaled, and in one narrow case it does not
109
- guarantee the pane is untouched:
110
-
111
- - Failure at the pane lookup, or while filling the paste buffer — both happen
112
- before the journal is written at all: nothing injected, token unclaimed,
113
- cleanly retryable. This is the ordinary case.
114
- - A definitive `paste-buffer` failure rolls this call's claim back. For a plain
115
- call that returns the token to absent (cleanly retryable). Under
116
- `--resend-interrupted` the *pre-existing* unresolved record is restored
117
- byte-for-byte rather than erased, so the token stays journaled-unresolved and
118
- the next plain call answers exit 5, not a fresh injection.
119
- - tmux disappearing between a successful paste and the `Enter`: the payload
120
- **is** in the pane, the pending record is deliberately kept, and the next
121
- plain call answers exit 5.
122
-
123
- A plain retry after exit 4 is therefore always *safe* it re-reads the journal
124
- and answers exit 5 rather than duplicating but it is not guaranteed to inject.
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 it
185
- falls through to the one-shot subprocess path.
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
- Two files must agree on the release version:
417
-
418
- - `app/build.gradle.kts` -> `versionName = "X.Y.Z"`
419
- - `tools/pocketshell/pyproject.toml` -> `version = "X.Y.Z"`
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
- enforces this. The release workflow runs it with `--check-tag vX.Y.Z`
423
- before publishing, so a tag pushed with mismatched versions fails the
424
- job loudly before anything reaches PyPI.
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
- ### Bumping a release
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. Update **both** version sources in the same commit:
437
- - `app/build.gradle.kts` -> bump `versionName` (and `versionCode`).
438
- - `tools/pocketshell/pyproject.toml` -> bump `version` to the
439
- same value as `versionName`.
440
- 3. Run `scripts/check-pypi-version.sh` to confirm they match.
441
- 4. Commit the bump on `main`, push, and run the emulator release
442
- validation gate (`scripts/release-emulator-validation.sh`) as
443
- described in [`process.md`](../../process.md) -> "Release Builds".
444
- 5. Push the tag with `scripts/push-release-tag.sh`. The tag-triggered
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 + creates the GitHub Release
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
- # Must equal `versionName` in app/build.gradle.kts on every release tag.
8
- # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
- # runs that check before publishing to PyPI. See
10
- # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.4.44"
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 (per provider: status +
40
- # short_term/long_term {percent_remaining, reset_at, window} + error) and
41
- # fails loudly on any schema drift, so the version is frozen, not a range.
42
- # 0.0.11 (#1564): quse labels each Codex window from its actual
43
- # `limit_window_seconds` instead of assuming primary==5h / secondary==7d,
44
- # and omits a window Codex drops (`present: false`) as a null placeholder
45
- # rather than a phantom "0% / unavailable" ghost row. Codex temporarily
46
- # removed the 5h window, so its `primary_window` now carries the WEEKLY
47
- # (604800s) span the old positional labels mislabeled weekly data as a
48
- # "5h window" with a 5-day reset and left a "7d" ghost.
49
- "quse==0.0.11",
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
- "tmuxctl>=0.3.3",
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