pocketshell 0.4.43__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 (87) hide show
  1. {pocketshell-0.4.43 → pocketshell-0.4.45}/PKG-INFO +239 -38
  2. pocketshell-0.4.45/README.md +595 -0
  3. {pocketshell-0.4.43 → 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.43 → pocketshell-0.4.45}/src/pocketshell/__init__.py +1 -1
  6. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/agent_log.py +80 -6
  7. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/agents.py +332 -48
  8. {pocketshell-0.4.43 → 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.43 → pocketshell-0.4.45}/src/pocketshell/cgroup_agents.py +2 -0
  11. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/cli.py +11 -1
  12. {pocketshell-0.4.43 → 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.43 → pocketshell-0.4.45}/src/pocketshell/hooks.py +353 -95
  15. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/jobs.py +8 -16
  16. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/logs.py +17 -8
  17. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/profiles.py +113 -2
  18. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/repos.py +18 -21
  19. pocketshell-0.4.45/src/pocketshell/send.py +1495 -0
  20. pocketshell-0.4.45/src/pocketshell/serve.py +208 -0
  21. pocketshell-0.4.45/src/pocketshell/session_enum.py +267 -0
  22. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/sessions.py +98 -50
  23. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/tree.py +255 -42
  24. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/usage.py +126 -50
  25. pocketshell-0.4.45/src/pocketshell/usage_capture.py +648 -0
  26. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/usage_reset.py +26 -13
  27. pocketshell-0.4.45/tests/conftest.py +92 -0
  28. pocketshell-0.4.45/tests/data/quse-0.0.13-usage.json +170 -0
  29. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.json +184 -0
  30. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.provenance.txt +18 -0
  31. pocketshell-0.4.45/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +6 -0
  32. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_agent_log.py +70 -0
  33. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_agents.py +283 -44
  34. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_agents_kind.py +21 -0
  35. pocketshell-0.4.45/tests/test_agents_kind_target.py +280 -0
  36. pocketshell-0.4.45/tests/test_agents_source_target.py +426 -0
  37. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_cgroup_agents.py +28 -0
  38. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_daemon.py +96 -10
  39. pocketshell-0.4.45/tests/test_daemon_fallback.py +326 -0
  40. pocketshell-0.4.45/tests/test_engines.py +231 -0
  41. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_hooks.py +224 -2
  42. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_logs.py +20 -0
  43. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_profiles.py +256 -2
  44. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_push.py +7 -8
  45. pocketshell-0.4.45/tests/test_send.py +2712 -0
  46. pocketshell-0.4.45/tests/test_serve.py +268 -0
  47. pocketshell-0.4.45/tests/test_session_enum.py +142 -0
  48. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_sessions.py +82 -19
  49. pocketshell-0.4.45/tests/test_ssh_fixture_penalties.py +1009 -0
  50. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_tree.py +266 -1
  51. pocketshell-0.4.45/tests/test_usage.py +619 -0
  52. pocketshell-0.4.45/tests/test_usage_capture.py +526 -0
  53. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_usage_reset.py +8 -10
  54. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_watch_ci.py +120 -1
  55. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_watch_ci_cancelled.py +178 -0
  56. {pocketshell-0.4.43 → pocketshell-0.4.45}/uv.lock +10 -10
  57. pocketshell-0.4.43/README.md +0 -394
  58. pocketshell-0.4.43/src/pocketshell/usage_capture.py +0 -301
  59. pocketshell-0.4.43/tests/test_usage.py +0 -435
  60. pocketshell-0.4.43/tests/test_usage_capture.py +0 -223
  61. {pocketshell-0.4.43 → pocketshell-0.4.45}/.gitignore +0 -0
  62. {pocketshell-0.4.43 → pocketshell-0.4.45}/scheduler/README.md +0 -0
  63. {pocketshell-0.4.43 → pocketshell-0.4.45}/scheduler/pocketshell-usage-capture.service +0 -0
  64. {pocketshell-0.4.43 → pocketshell-0.4.45}/scheduler/pocketshell-usage-capture.timer +0 -0
  65. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/__main__.py +0 -0
  66. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/agent_card_push.py +0 -0
  67. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/cards.py +0 -0
  68. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/env.py +0 -0
  69. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/github.py +0 -0
  70. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/prune_attachments.py +0 -0
  71. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/push.py +0 -0
  72. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/qr_share.py +0 -0
  73. {pocketshell-0.4.43 → pocketshell-0.4.45}/src/pocketshell/resume.py +0 -0
  74. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/__init__.py +0 -0
  75. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/data/quse-0.0.11-usage.json +0 -0
  76. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/data/quse-0.0.9-usage.json +0 -0
  77. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_agent_card_push.py +0 -0
  78. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_cards.py +0 -0
  79. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_cards_push_notify.py +0 -0
  80. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_cli.py +0 -0
  81. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_env.py +0 -0
  82. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_github.py +0 -0
  83. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_jobs.py +0 -0
  84. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_prune_attachments.py +0 -0
  85. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_qr_share.py +0 -0
  86. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_repos.py +0 -0
  87. {pocketshell-0.4.43 → pocketshell-0.4.45}/tests/test_resume.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pocketshell
3
- Version: 0.4.43
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'
@@ -81,6 +81,7 @@ Top-level commands in the current helper:
81
81
 
82
82
  ```text
83
83
  pocketshell usage [provider] [--json] # provider quota / usage
84
+ pocketshell send --pane %3 --token <id> # acknowledged pane delivery
84
85
  pocketshell sessions list [--by activity] # tmux session summaries
85
86
  pocketshell jobs ... # tmux recurring jobs
86
87
  pocketshell agent-log ... # agent conversation logs
@@ -90,6 +91,7 @@ pocketshell env ... # .env / .envrc management
90
91
  pocketshell hooks ... # Claude/Codex/OpenCode hooks
91
92
  pocketshell logs ... # server-side trace sink
92
93
  pocketshell daemon ... # IPC daemon lifecycle
94
+ pocketshell serve --dir PATH [--port N] # foreground static HTTP server
93
95
  pocketshell qr-share ... # SSH host QR import payloads
94
96
  ```
95
97
 
@@ -98,6 +100,137 @@ flag set. Some parity subcommands still proxy through the existing host
98
100
  tools internally so their output remains byte-identical to what the app
99
101
  already parses.
100
102
 
103
+ ### `pocketshell send`
104
+
105
+ Deliver a payload into an exact tmux pane, **exactly once per token**. The
106
+ exit status IS the acknowledgement — the client no longer has to read the
107
+ terminal screen and guess whether its prompt landed (issue #2122, epic
108
+ #2121).
109
+
110
+ ```bash
111
+ printf 'summarise the diff' | pocketshell send --pane %3 --token <row-id> --enter
112
+ pocketshell send --prune-older-than 30d
113
+ ```
114
+
115
+ The payload is read from **stdin as raw bytes** and delivered byte-exact
116
+ (`load-buffer -` → `paste-buffer -d -r`, never argv). This command does not
117
+ add bracketed-paste markers: the client already frames its payload, and
118
+ framing twice put the inner markers into the receiving program as literal
119
+ text (issue #1854). Callers that want bracketed paste write the framed bytes
120
+ to stdin.
121
+
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) -->
129
+
130
+ | Exit | stdout reason | Meaning |
131
+ | ---- | ------------- | ------- |
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.
185
+
186
+ stdout is machine-readable: the first whitespace-delimited token is one of
187
+ the reasons above; human detail goes to stderr. Every retry path drains stdin
188
+ before exiting, so a caller piping a payload never takes SIGPIPE on a
189
+ successful acknowledgement. (Argument validation runs *before* stdin is read,
190
+ deliberately: a caller with an open-but-idle stdin gets `bad-usage`
191
+ immediately instead of blocking on a payload that will never arrive.)
192
+
193
+ **Durability invariant: at-most-once, except on an explicit opt-in.** A token
194
+ is never injected a second time unless the caller passes
195
+ `--resend-interrupted` on the injecting call itself; no sequence of failures,
196
+ kills, races or automatic housekeeping can turn an injected token back into a
197
+ state a plain call will inject. The journal under
198
+ `${XDG_STATE_HOME:-~/.local/state}/pocketshell/sends/` is two-phase — a
199
+ `pending` record is written (atomically, fsync'd) immediately before the one
200
+ command that can put bytes into the pane, then promoted to `delivered` once
201
+ tmux answers. A definitive tmux failure rolls that claim back, so ordinary
202
+ errors stay cleanly retryable; rolling back means undoing *this* call, so a
203
+ record this call created is removed and a pre-existing unresolved record it
204
+ overwrote is restored byte-for-byte rather than erased.
205
+
206
+ An unresolved record is then read against its owner process: gone ⇒ a previous
207
+ attempt died and delivery is genuinely unknown (exit 5, resolvable with
208
+ `--resend-interrupted`, so the state is never absorbing); still running ⇒
209
+ nothing is unknown and the outcome belongs to that call (exit 8, retryable).
210
+ `--resend-interrupted` does not override a live owner — there is no unknown to
211
+ resolve, and forcing one would simply duplicate the payload.
212
+
213
+ The invariant's honest edges: a definitive non-zero from `paste-buffer` is
214
+ taken as proof nothing reached the pane; the journal directory must survive
215
+ (delete it and the memory is gone); `--prune-older-than` is an operator
216
+ action that *can* clear unresolved records; and exit 8 is bounded by the owner
217
+ process's **liveness**, not by the owner's `--timeout`. A suspended owner
218
+ (reproduced with `SIGSTOP`) holds its token in `send-in-progress` for as long
219
+ as it stays stopped, because the liveness probe asks whether the process still
220
+ exists, not whether it is making progress, and `--timeout` bounds the tmux
221
+ calls of the process that passed it rather than some other process's lifetime.
222
+ This fails safe — the payload is never duplicated and the token never becomes
223
+ absorbing once the owner dies — and a client cannot reach it through its own
224
+ use, since it would have to suspend its own in-flight send. Nothing reaps a
225
+ suspended owner.
226
+
227
+ Records carry a timestamp and are pruned two ways: explicitly with
228
+ `--prune-older-than <30d|12h|90m|3600s>`, and automatically on delivery at a
229
+ **30-day default retention** (throttled to at most once every 6 h), so the
230
+ directory cannot grow without bound even if pruning is never invoked. The
231
+ automatic sweep only removes **resolved** records — ageing an unknown out of
232
+ the journal would silently make the token injectable again.
233
+
101
234
  ### `pocketshell usage`
102
235
 
103
236
  ```text
@@ -108,8 +241,15 @@ pocketshell usage codex # filter to a single provider
108
241
 
109
242
  The output shape is byte-identical to `quse [provider] [--json]`. When
110
243
  the IPC daemon is running, `usage --json` dispatches `usage.fetch` over
111
- the daemon socket and uses the daemon's short TTL cache; otherwise it
112
- 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.
113
253
 
114
254
  If `quse` is not installed, `pocketshell usage` exits with code 127 and
115
255
  prints an install hint to stderr.
@@ -199,6 +339,34 @@ unauthenticated the `hint` tells them to run `gh auth login`. The only
199
339
  network access is whatever `gh auth status` itself performs (a token-validity
200
340
  check); the command does NOT call the GitHub API.
201
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
+
202
370
  ### `pocketshell qr-share`
203
371
 
204
372
  Builds a `pocketshell.ssh-import.v1` payload from an `~/.ssh/config`
@@ -269,11 +437,29 @@ pocketshell hooks uninstall [--engine ...]
269
437
  - **OpenCode** — drops a `pocketshell-idle-signal.js` plugin into
270
438
  `~/.config/opencode/plugin/` without disturbing other plugins.
271
439
 
272
- `install` is idempotent (running twice adds nothing new). Handler scripts
273
- and the event bus live under `~/.cache/pocketshell/hooks/` (override with
274
- `$POCKETSHELL_HOOKS_DIR`); each handler appends a normalized record
275
- `{ts, engine, state, source, session_id, cwd, ...}` to
276
- `events.jsonl`.
440
+ `install` is idempotent (running twice adds nothing new). Generated handler
441
+ scripts and `.installed` ownership metadata are durable data under
442
+ `$XDG_DATA_HOME/pocketshell/hooks/` (default
443
+ `~/.local/share/pocketshell/hooks/`). The volatile event bus stays at
444
+ `$XDG_CACHE_HOME/pocketshell/hooks/events.jsonl` (default
445
+ `~/.cache/pocketshell/hooks/events.jsonl`). A routine cache cleanup therefore
446
+ starts a fresh bus without breaking the absolute commands retained by Claude or
447
+ Codex; the next event recreates the cache directory and bus.
448
+
449
+ Path overrides are intentionally separate:
450
+
451
+ - `$POCKETSHELL_HOOKS_HANDLER_DIR` overrides the durable generated-handler dir.
452
+ - `$POCKETSHELL_HOOKS_EVENTS_FILE` overrides the event bus file.
453
+ - The historical `$POCKETSHELL_HOOKS_DIR` remains an alias for the **handler
454
+ directory only** when the new handler variable is unset. It no longer moves
455
+ the bus. Use both new variables and rerun `hooks install` when both paths need
456
+ customization.
457
+
458
+ Each generated handler embeds the resolved bus path and appends a normalized
459
+ record `{ts, engine, state, source, session_id, cwd, ...}` there. `install`
460
+ also migrates PocketShell-owned Claude/Codex commands from the old cache path to
461
+ the durable path even when cache cleanup already removed the old scripts;
462
+ foreign hooks and foreign Codex `notify` programs remain untouched.
277
463
 
278
464
  **Per-engine uninstall** (`pocketshell hooks uninstall`) removes only what
279
465
  we added and is idempotent:
@@ -289,9 +475,9 @@ we added and is idempotent:
289
475
  - **OpenCode** — deletes our plugin file; other plugins and the dir
290
476
  itself are left in place.
291
477
 
292
- The event bus (`events.jsonl`) is preserved on uninstall so
293
- already-emitted records stay readable; only the generated handler
294
- scripts are cleaned up.
478
+ The event bus (`events.jsonl`) is preserved on uninstall so already-emitted
479
+ records stay readable; only PocketShell-owned current/legacy config entries,
480
+ generated executables, and durable ownership metadata are cleaned up.
295
481
 
296
482
  ## Development
297
483
 
@@ -320,39 +506,54 @@ maintainer cuts an Android release tag (`vX.Y.Z`), the
320
506
  [`Build`](../../.github/workflows/build.yml) workflow assembles the APK
321
507
  and **also** builds the Python sdist + wheel and publishes them to PyPI.
322
508
 
323
- ### Version coupling
324
-
325
- Two files must agree on the release version:
326
-
327
- - `app/build.gradle.kts` -> `versionName = "X.Y.Z"`
328
- - `tools/pocketshell/pyproject.toml` -> `version = "X.Y.Z"`
329
-
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`.
330
528
  [`scripts/check-pypi-version.sh`](../../scripts/check-pypi-version.sh)
331
- enforces this. The release workflow runs it with `--check-tag vX.Y.Z`
332
- before publishing, so a tag pushed with mismatched versions fails the
333
- job loudly before anything reaches PyPI.
334
-
335
- 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:
336
532
 
337
533
  ```bash
338
- scripts/check-pypi-version.sh # local match check
339
534
  scripts/check-pypi-version.sh --check-tag vX.Y.Z
340
535
  ```
341
536
 
342
- ### 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:
343
541
 
344
542
  1. Pick the next semantic version after the latest GitHub Release/tag.
345
- 2. Update **both** version sources in the same commit:
346
- - `app/build.gradle.kts` -> bump `versionName` (and `versionCode`).
347
- - `tools/pocketshell/pyproject.toml` -> bump `version` to the
348
- same value as `versionName`.
349
- 3. Run `scripts/check-pypi-version.sh` to confirm they match.
350
- 4. Commit the bump on `main`, push, and run the emulator release
351
- validation gate (`scripts/release-emulator-validation.sh`) as
352
- described in [`process.md`](../../process.md) -> "Release Builds".
353
- 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
354
552
  `Build` workflow then:
355
- - 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
356
557
  - runs `scripts/check-pypi-version.sh --check-tag vX.Y.Z`
357
558
  - builds the Python sdist + wheel
358
559
  - publishes them to PyPI via OIDC trusted publishing