pocketshell
Unified server-side Python utility for the PocketShell Android client. The app probes for this single helper on each remote host and uses its subcommands for usage, tmux session/job metadata, agent conversations, QR host setup, repository discovery, environment files, hooks, logs, and daemon lifecycle checks.
Install
The recommended path is uv tool install, which lands the binary on PATH
under ~/.local/bin/:
uv tool install pocketshell
For local development from a clone:
cd tools/pocketshell
uv venv
uv pip install -e .
pocketshell --help
pipx install pocketshell works the same way for users who prefer
pipx. Both install paths produce a pocketshell binary that the
PocketShell app's bootstrap probe detects.
Optional extras
pocketshell qr-share requires the qrcode[pil] package (Pillow) to
render QR images. Because Pillow is heavy and not needed by any other
subcommand, it ships behind an optional qr extra:
uv tool install pocketshell --with qrcode[pil]
# or
pip install pocketshell[qr]
Without the extra, every other subcommand keeps working; only
pocketshell qr-share exits 127 with a friendly install hint.
Usage
Top-level commands in the current helper:
pocketshell usage [provider] [--json] # provider quota / usage
pocketshell send --pane %3 --token <id> # acknowledged pane delivery
pocketshell sessions list [--by activity] # tmux session summaries
pocketshell jobs ... # tmux recurring jobs
pocketshell agent-log ... # agent conversation logs
pocketshell repos list ... # local / GitHub repositories
pocketshell github status [--json] # gh install / auth state
pocketshell env ... # .env / .envrc management
pocketshell hooks ... # Claude/Codex/OpenCode hooks
pocketshell logs ... # server-side trace sink
pocketshell daemon ... # IPC daemon lifecycle
pocketshell qr-share ... # SSH host QR import payloads
Run pocketshell --help or pocketshell <command> --help for the live
flag set. Some parity subcommands still proxy through the existing host
tools internally so their output remains byte-identical to what the app
already parses.
pocketshell send
Deliver a payload into an exact tmux pane, exactly once per token. The exit status IS the acknowledgement — the client no longer has to read the terminal screen and guess whether its prompt landed (issue #2122, epic #2121).
printf 'summarise the diff' | pocketshell send --pane %3 --token <row-id> --enter
pocketshell send --prune-older-than 30d
The payload is read from stdin as raw bytes and delivered byte-exact
(load-buffer - → paste-buffer -d -r, never argv). This command does not
add bracketed-paste markers: the client already frames its payload, and
framing twice put the inner markers into the receiving program as literal
text (issue #1854). Callers that want bracketed paste write the framed bytes
to stdin.
Exit codes are stable, and --help documents them (rendered from the same
table the code exits with, so they cannot drift):
| Exit | stdout reason | Meaning |
|---|---|---|
| 0 | delivered |
Injected by this call and journaled. |
| 0 | already-delivered |
The token was already journaled; nothing injected. |
| 0 | pruned <n> |
--prune-older-than removed n records. |
| 2 | bad-usage |
Invalid/missing arguments. Nothing injected or journaled. |
| 3 | pane-not-found |
Pane missing or dead. Not journaled; stays retryable. |
| 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. |
| 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. |
| 6 | timeout |
A tmux call exceeded --timeout. |
| 7 | journal-failed |
The journal could not be read/written. Nothing injected. |
| 8 | send-in-progress |
Another send for this token is STILL RUNNING. Nothing injected, nothing unknown — retry shortly to read that call's answer. |
Exit 4 in detail — a client that branches on this table to decide whether
to auto-retry (#2124) must not read tmux-failed as "clean slate". It
guarantees only that this call never recorded a delivery. It does not
guarantee the token is unjournaled, and in one narrow case it does not
guarantee the pane is untouched:
- Failure at the pane lookup, or while filling the paste buffer — both happen before the journal is written at all: nothing injected, token unclaimed, cleanly retryable. This is the ordinary case.
- A definitive
paste-bufferfailure rolls this call's claim back. For a plain call that returns the token to absent (cleanly retryable). Under--resend-interruptedthe pre-existing unresolved record is restored byte-for-byte rather than erased, so the token stays journaled-unresolved and the next plain call answers exit 5, not a fresh injection. - tmux disappearing between a successful paste and the
Enter: the payload is in the pane, the pending record is deliberately kept, and the next plain call answers exit 5.
A plain retry after exit 4 is therefore always safe — it re-reads the journal and answers exit 5 rather than duplicating — but it is not guaranteed to inject.
stdout is machine-readable: the first whitespace-delimited token is one of
the reasons above; human detail goes to stderr. Every retry path drains stdin
before exiting, so a caller piping a payload never takes SIGPIPE on a
successful acknowledgement. (Argument validation runs before stdin is read,
deliberately: a caller with an open-but-idle stdin gets bad-usage
immediately instead of blocking on a payload that will never arrive.)
Durability invariant: at-most-once, except on an explicit opt-in. A token
is never injected a second time unless the caller passes
--resend-interrupted on the injecting call itself; no sequence of failures,
kills, races or automatic housekeeping can turn an injected token back into a
state a plain call will inject. The journal under
${XDG_STATE_HOME:-~/.local/state}/pocketshell/sends/ is two-phase — a
pending record is written (atomically, fsync'd) immediately before the one
command that can put bytes into the pane, then promoted to delivered once
tmux answers. A definitive tmux failure rolls that claim back, so ordinary
errors stay cleanly retryable; rolling back means undoing this call, so a
record this call created is removed and a pre-existing unresolved record it
overwrote is restored byte-for-byte rather than erased.
An unresolved record is then read against its owner process: gone ⇒ a previous
attempt died and delivery is genuinely unknown (exit 5, resolvable with
--resend-interrupted, so the state is never absorbing); still running ⇒
nothing is unknown and the outcome belongs to that call (exit 8, retryable).
--resend-interrupted does not override a live owner — there is no unknown to
resolve, and forcing one would simply duplicate the payload.
The invariant's honest edges: a definitive non-zero from paste-buffer is
taken as proof nothing reached the pane; the journal directory must survive
(delete it and the memory is gone); --prune-older-than is an operator
action that can clear unresolved records; and exit 8 is bounded by the owner
process's liveness, not by the owner's --timeout. A suspended owner
(reproduced with SIGSTOP) holds its token in send-in-progress for as long
as it stays stopped, because the liveness probe asks whether the process still
exists, not whether it is making progress, and --timeout bounds the tmux
calls of the process that passed it rather than some other process's lifetime.
This fails safe — the payload is never duplicated and the token never becomes
absorbing once the owner dies — and a client cannot reach it through its own
use, since it would have to suspend its own in-flight send. Nothing reaps a
suspended owner.
Records carry a timestamp and are pruned two ways: explicitly with
--prune-older-than <30d|12h|90m|3600s>, and automatically on delivery at a
30-day default retention (throttled to at most once every 6 h), so the
directory cannot grow without bound even if pruning is never invoked. The
automatic sweep only removes resolved records — ageing an unknown out of
the journal would silently make the token injectable again.
pocketshell usage
pocketshell usage # human-readable lines, one per provider
pocketshell usage --json # machine-readable JSON (consumed by the app)
pocketshell usage codex # filter to a single provider
The output shape is byte-identical to quse [provider] [--json]. When
the IPC daemon is running, usage --json dispatches usage.fetch over
the daemon socket and uses the daemon's short TTL cache; otherwise it
falls through to the one-shot subprocess path.
If quse is not installed, pocketshell usage exits with code 127 and
prints an install hint to stderr.
pocketshell repos list
Enumerate git repositories — either cloned on this host (--local) or
owned by the authenticated GitHub user (--remote). The two modes
share one unified JSON schema so a future merged view can interleave
them transparently.
pocketshell repos list --local # scan ~/git for clones (human)
pocketshell repos list --local --json # same, JSON output
pocketshell repos list --remote --json # via owner-only `gh api user/repos`
pocketshell repos list --remote --limit 20
Schema (every entry):
{
"owner": "alexeygrigorev", // null when remote URL is non-GitHub
"name": "pocketshell", // local dir basename, or GH repo name
"full_name": "alexeygrigorev/pocketshell", // null when owner unknown
"local": { // populated by --local scans
"path": "/home/alexey/git/pocketshell",
"head": "main"
},
"remote": { // populated by --remote scans
"default_branch": "main",
"html_url": "https://github.com/alexeygrigorev/pocketshell",
"ssh_url": "git@github.com:alexeygrigorev/pocketshell.git",
"updated_at": "2026-05-27T12:00:00Z"
}
}
--local scans ~/git by default (override with one or more --root
flags or the colon-separated POCKETSHELL_REPOS_ROOTS env var) and
populates local for every entry. owner and full_name are
best-effort from the parsed remote.origin.url; non-GitHub remotes
leave them null.
--remote delegates to gh api 'user/repos?affiliation=owner&sort=updated' --paginate --slurp.
Requires gh on PATH (apt install gh on Debian/Ubuntu,
brew install gh on macOS) authenticated via
gh auth login -s repo:read. Sorted by updated_at descending so the
picker shows the most-recently-touched repos first. Missing gh exits
127 with an install hint; a non-zero gh exit (auth missing,
rate-limit, etc.) propagates the exit code and stderr verbatim.
With neither flag, defaults to --local and prints a one-line
discoverability hint mentioning --remote.
Daemon mode caches repos.list_local for 10 s and repos.list_remote
for 5 min. --no-daemon forces the in-process path; --no-cache
forces the daemon to re-run upstream on the next call.
pocketshell github status
Reports whether the GitHub CLI (gh) is installed and authenticated, as
structured JSON the app consumes to gate GitHub features and prompt the
user to configure gh when it is missing (epic #644, slice #645).
pocketshell github status # human-readable summary
pocketshell github status --json # machine-readable JSON (consumed by the app)
Schema:
{
"installed": true, // shutil.which("gh") found the binary
"authenticated": true, // `gh auth status` exited 0
"account": "alexeygrigorev", // logged-in username, or null
"hint": null // actionable hint when something is missing
}
The command always exits 0 — "gh missing" and "not authenticated" are
normal, reportable states (not probe failures), so the app can poll the
status without treating it as an error. When gh is absent the hint tells
the user to install it and run gh auth login; when present but
unauthenticated the hint tells them to run gh auth login. The only
network access is whatever gh auth status itself performs (a token-validity
check); the command does NOT call the GitHub API.
pocketshell qr-share
Builds a pocketshell.ssh-import.v1 payload from an ~/.ssh/config
alias (resolved via ssh -G) or from explicit flags, wraps it in one or
more pocketshell.qr.v1 chunked envelopes (matching the Kotlin
QrChunkCodec byte-for-byte), and emits QR codes for the phone-side
scanner to consume (issue #129).
pocketshell qr-share prod # ssh-config alias
pocketshell qr-share --host h --user u --key ~/.ssh/id_ed25519 --name h
pocketshell qr-share prod --png --out-dir /tmp/qr # write PNGs
pocketshell qr-share prod --print-only --id deadbeef # debug envelopes
When stdout is a TTY the QRs are drawn inline as Unicode blocks; between
multi-part transmissions the command pauses on "Press Enter for next
QR" so the user can scan each in turn. When stdout is not a TTY (or
--png is passed) a numbered PNG sequence (qr-share-01.png,
qr-share-02.png, ...) is written to --out-dir.
Requires the optional qr extra (see Optional extras).
Without it, the command exits 127 with the install hint and every other
subcommand keeps working.
Running from a repo clone (no install)
To run qr-share straight from a checkout without installing the tool,
use uv run from tools/pocketshell and include the qr extra:
cd tools/pocketshell
uv run --extra qr pocketshell qr-share prod
The first run creates .venv and installs the QR dependency; later runs
are instant. Run it in an interactive terminal so stdout is a TTY and the
QR renders inline — otherwise it falls back to writing PNGs (add
--png --out-dir ./qr to force PNGs). Omitting --extra qr makes the
command exit 127 with the install hint.
pocketshell hooks
Installs agent stop / idle-detection hooks across Claude Code,
Codex, and OpenCode and normalizes their events into a single
append-only JSONL bus the app can read back. Server-side only;
integration only — no "tell the agent to continue" action yet (deferred;
see issue #267 and locked decision D26 in docs/decisions.md).
pocketshell hooks install [--engine claude|codex|opencode|all] # default: all
pocketshell hooks status [--engine ...] [--json] [--last N]
pocketshell hooks events [--since ISO8601] [--limit N] [--json]
pocketshell hooks uninstall [--engine ...]
install is non-destructive — it merges, it never clobbers:
- Claude Code — adds a
{type: "command", command: "python3 <handler>"}entry under theStop,SubagentStop, andNotificationhook events in~/.claude/settings.json, only when absent. All other top-level keys and any pre-existing user hooks are preserved. - Codex — sets the top-level
notifyprogram in~/.codex/config.tomlto our handler (Codex hooks do not fire undercodex exec, sonotifyis the headless-safe signal). Ifnotifyis already set to something else, it warns and skips rather than overwriting. The rest of the TOML is preserved. - OpenCode — drops a
pocketshell-idle-signal.jsplugin into~/.config/opencode/plugin/without disturbing other plugins.
install is idempotent (running twice adds nothing new). Generated handler
scripts and .installed ownership metadata are durable data under
$XDG_DATA_HOME/pocketshell/hooks/ (default
~/.local/share/pocketshell/hooks/). The volatile event bus stays at
$XDG_CACHE_HOME/pocketshell/hooks/events.jsonl (default
~/.cache/pocketshell/hooks/events.jsonl). A routine cache cleanup therefore
starts a fresh bus without breaking the absolute commands retained by Claude or
Codex; the next event recreates the cache directory and bus.
Path overrides are intentionally separate:
$POCKETSHELL_HOOKS_HANDLER_DIRoverrides the durable generated-handler dir.$POCKETSHELL_HOOKS_EVENTS_FILEoverrides the event bus file.- The historical
$POCKETSHELL_HOOKS_DIRremains an alias for the handler directory only when the new handler variable is unset. It no longer moves the bus. Use both new variables and rerunhooks installwhen both paths need customization.
Each generated handler embeds the resolved bus path and appends a normalized
record {ts, engine, state, source, session_id, cwd, ...} there. install
also migrates PocketShell-owned Claude/Codex commands from the old cache path to
the durable path even when cache cleanup already removed the old scripts;
foreign hooks and foreign Codex notify programs remain untouched.
Per-engine uninstall (pocketshell hooks uninstall) removes only what
we added and is idempotent:
- Claude Code — drops our command group from each hook event; an
event key (and the top-level
hooksobject) is deleted only if we created it and it ends up empty. A user's pre-existing hooks always survive, so a pre-populatedsettings.jsoncomes back byte-equivalent for the unrelated parts. - Codex — removes the top-level
notifyline only when it still points at our handler. Anotifythe user pointed elsewhere is left alone. - OpenCode — deletes our plugin file; other plugins and the dir itself are left in place.
The event bus (events.jsonl) is preserved on uninstall so already-emitted
records stay readable; only PocketShell-owned current/legacy config entries,
generated executables, and durable ownership metadata are cleaned up.
Development
cd tools/pocketshell
uv venv
uv pip install -e ".[dev]"
uv run pytest
Or via the dependency-group:
uv sync --group dev
uv run pytest
The tests stub pocketshell.usage.subprocess.run (and the quse/tmuxctl
binary resolvers) so they run in seconds without invoking any real binary or
hitting a provider API.
Release flow
pocketshell ships in lockstep with the Android app. Every time the
maintainer cuts an Android release tag (vX.Y.Z), the
Build workflow assembles the APK
and also builds the Python sdist + wheel and publishes them to PyPI.
Version coupling
Two files must agree on the release version:
app/build.gradle.kts->versionName = "X.Y.Z"tools/pocketshell/pyproject.toml->version = "X.Y.Z"
scripts/check-pypi-version.sh
enforces this. The release workflow runs it with --check-tag vX.Y.Z
before publishing, so a tag pushed with mismatched versions fails the
job loudly before anything reaches PyPI.
Run it locally before tagging:
scripts/check-pypi-version.sh # local match check
scripts/check-pypi-version.sh --check-tag vX.Y.Z
Bumping a release
- Pick the next semantic version after the latest GitHub Release/tag.
- Update both version sources in the same commit:
app/build.gradle.kts-> bumpversionName(andversionCode).tools/pocketshell/pyproject.toml-> bumpversionto the same value asversionName.
- Run
scripts/check-pypi-version.shto confirm they match. - Commit the bump on
main, push, and run the emulator release validation gate (scripts/release-emulator-validation.sh) as described inprocess.md-> "Release Builds". - Push the tag with
scripts/push-release-tag.sh. The tag-triggeredBuildworkflow then:- builds and uploads the APK + creates the GitHub Release
- runs
scripts/check-pypi-version.sh --check-tag vX.Y.Z - builds the Python sdist + wheel
- publishes them to PyPI via OIDC trusted publishing
The PyPI publish job depends on the APK build job, so a broken APK
build also aborts the PyPI publish. If only the PyPI publish fails the
maintainer can re-trigger the workflow at the same tag from the
Actions tab; the APK build is idempotent against an existing release
(softprops/action-gh-release updates the existing release rather
than failing).
PyPI trusted publishing setup (one-time)
The publish-pypi job uses GitHub's OIDC token instead of a long-lived
API token. This avoids storing a PYPI_API_TOKEN secret in the repo
and means there is nothing to rotate. The trade-off is that the
project owner must complete one configuration step on pypi.org before
the first automated tag publish:
- Sign in to https://pypi.org/ with the project owner account.
- Open the
pocketshellproject page -> Manage -> Publishing. - Under Trusted publishers, click Add a new pending publisher
(if the project is empty) or Add a new publisher, then fill in:
- PyPI Project Name:
pocketshell - Owner:
alexeygrigorev - Repository name:
pocketshell - Workflow name:
build.yml - Environment name:
pypi
- PyPI Project Name:
- Save the publisher.
- In this repository on GitHub, open
Settings -> Environments -> New environment -> name it
pypi. No secrets or reviewers are required; the environment exists purely to scope the OIDC token. (If the environment already exists, confirm it has no protection rules that would block the workflow from running.) - Push the next release tag. The
Publish to PyPI via trusted publishingstep should succeed without any token configuration.
Why trusted publishing (and not PYPI_API_TOKEN)?
- No long-lived secret to rotate, leak, or accidentally print in logs.
- The OIDC subject is scoped to
repo=alexeygrigorev/pocketshell,workflow=build.yml,environment=pypi, so a compromised fork or a different workflow file in this repo cannot reuse it. - D22 (no backwards-compat): we do not also maintain a token-fallback path. If trusted publishing breaks, fix it; do not add a token branch alongside.
If trusted publishing is ever unavailable for a tag (e.g. PyPI outage on the OIDC verifier), the recommended manual escape hatch is:
cd tools/pocketshell
python -m build
python -m twine upload dist/*
with the maintainer's account. Do not re-add a PYPI_API_TOKEN secret
as a permanent fallback.
Why a unified CLI?
The PocketShell app previously depended on multiple host-side tools.
That meant separate installs to keep up to date, separate probes to
surface failures from, and multiple PATH-discovery edge cases. A single
pocketshell binary collapses that app-facing contract into one install,
one probe, and one bootstrap row. The Android bootstrap probe now derives
PATH from the user's shell rc and prepends $HOME/.local/bin,
$HOME/bin, and $HOME/.cargo/bin before probing, so cloned-repo or
venv installs can be discovered without a manual app-side PATH field.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pocketshell-0.4.44.tar.gz.
File metadata
- Download URL: pocketshell-0.4.44.tar.gz
- Upload date:
- Size: 348.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eedcf5f85297fe96d25d2c5507f57774d320b164c00ed43ae1551d73f835a85a
|
|
| MD5 |
cdda37038e962d6a7d2355df79119d93
|
|
| BLAKE2b-256 |
f74da1f8421d26d6a3504e82b117daaaa4e65dabf154245e73f3be2c7357546e
|
File details
Details for the file pocketshell-0.4.44-py3-none-any.whl.
File metadata
- Download URL: pocketshell-0.4.44-py3-none-any.whl
- Upload date:
- Size: 199.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d361486dbbcebb6541b634f0005e50021c8eada4e1744e6fb7fc5dc2d2796059
|
|
| MD5 |
e18ca63fb1d54756f1091dfdd3d0ca1d
|
|
| BLAKE2b-256 |
5a0396a73f019a5b88f298d4a3084dc252b7a9d446477b42360afbd16298368b
|