pocketshell 0.3.31__tar.gz → 0.3.33__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 (41) hide show
  1. {pocketshell-0.3.31 → pocketshell-0.3.33}/PKG-INFO +1 -1
  2. {pocketshell-0.3.31 → pocketshell-0.3.33}/pyproject.toml +1 -1
  3. pocketshell-0.3.33/scheduler/README.md +74 -0
  4. pocketshell-0.3.33/scheduler/pocketshell-usage-capture.service +14 -0
  5. pocketshell-0.3.33/scheduler/pocketshell-usage-capture.timer +15 -0
  6. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/cli.py +2 -0
  7. pocketshell-0.3.33/src/pocketshell/prune_attachments.py +372 -0
  8. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/usage.py +153 -0
  9. pocketshell-0.3.33/src/pocketshell/usage_capture.py +294 -0
  10. pocketshell-0.3.33/src/pocketshell/usage_reset.py +352 -0
  11. pocketshell-0.3.33/tests/test_prune_attachments.py +296 -0
  12. pocketshell-0.3.33/tests/test_usage_capture.py +199 -0
  13. pocketshell-0.3.33/tests/test_usage_reset.py +265 -0
  14. {pocketshell-0.3.31 → pocketshell-0.3.33}/uv.lock +2 -2
  15. {pocketshell-0.3.31 → pocketshell-0.3.33}/.gitignore +0 -0
  16. {pocketshell-0.3.31 → pocketshell-0.3.33}/README.md +0 -0
  17. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/__init__.py +0 -0
  18. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/__main__.py +0 -0
  19. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/agent_log.py +0 -0
  20. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/daemon.py +0 -0
  21. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/env.py +0 -0
  22. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/github.py +0 -0
  23. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/hooks.py +0 -0
  24. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/jobs.py +0 -0
  25. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/logs.py +0 -0
  26. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/qr_share.py +0 -0
  27. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/repos.py +0 -0
  28. {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/sessions.py +0 -0
  29. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/__init__.py +0 -0
  30. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_agent_log.py +0 -0
  31. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_cli.py +0 -0
  32. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_daemon.py +0 -0
  33. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_env.py +0 -0
  34. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_github.py +0 -0
  35. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_hooks.py +0 -0
  36. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_jobs.py +0 -0
  37. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_logs.py +0 -0
  38. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_qr_share.py +0 -0
  39. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_repos.py +0 -0
  40. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_sessions.py +0 -0
  41. {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.31
3
+ Version: 0.3.33
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
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.3.31"
11
+ version = "0.3.33"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -0,0 +1,74 @@
1
+ # Scheduled usage capture
2
+
3
+ PocketShell's usage screen is stale-while-revalidate (issue #689): the host
4
+ captures provider usage on a schedule and the app renders the last captured
5
+ reading **instantly** before its own live foreground refresh swaps in fresh
6
+ data.
7
+
8
+ This directory holds the host-side scheduler units that drive the capture.
9
+ Server-side scheduling is fine — the foreground-only rule (D21) applies to the
10
+ Android app, not the host CLI.
11
+
12
+ ## What `--capture` does
13
+
14
+ ```bash
15
+ pocketshell usage --capture
16
+ ```
17
+
18
+ Fetches usage live (via the daemon when one is running, else a one-shot
19
+ subprocess), then writes two artifacts under
20
+ `${XDG_STATE_HOME:-~/.local/state}/pocketshell/usage/`:
21
+
22
+ - `usage-latest.json` — the cached latest reading
23
+ (`{"captured_at": "...Z", "records": [ … ]}`), mode `0600`.
24
+ - `usage-history.jsonl` — an append-only history log, one capture per line,
25
+ trimmed to the most recent 2000 lines (~83 days at hourly capture; the file
26
+ stays well under ~1 MB). No external logrotate dependency.
27
+
28
+ A failed live fetch is **not** cached, so a transient provider hiccup never
29
+ pins a bad reading.
30
+
31
+ The app reads the cache with:
32
+
33
+ ```bash
34
+ pocketshell usage --cached
35
+ ```
36
+
37
+ which prints `usage-latest.json` instantly (exit 3 with a friendly note when no
38
+ capture has run yet, so the app falls back to a pure live fetch).
39
+
40
+ ## Install (systemd user timer — recommended)
41
+
42
+ ```bash
43
+ mkdir -p ~/.config/systemd/user
44
+ cp pocketshell-usage-capture.service ~/.config/systemd/user/
45
+ cp pocketshell-usage-capture.timer ~/.config/systemd/user/
46
+ systemctl --user daemon-reload
47
+ systemctl --user enable --now pocketshell-usage-capture.timer
48
+
49
+ # Keep the timer running after you log out (so the laptop/server captures
50
+ # on schedule without an active session):
51
+ loginctl enable-linger "$USER"
52
+ ```
53
+
54
+ Check it:
55
+
56
+ ```bash
57
+ systemctl --user list-timers pocketshell-usage-capture.timer
58
+ journalctl --user -u pocketshell-usage-capture.service --no-pager
59
+ ```
60
+
61
+ The `.service` uses `%h/.local/bin/pocketshell` (the `uv tool install` /
62
+ `pipx install` location). Adjust `ExecStart` if `pocketshell` lives elsewhere
63
+ on your `PATH`.
64
+
65
+ ## Install (cron alternative)
66
+
67
+ For hosts without systemd, an hourly cron line works the same way:
68
+
69
+ ```cron
70
+ # m h dom mon dow command
71
+ 17 * * * * $HOME/.local/bin/pocketshell usage --capture >/dev/null 2>&1
72
+ ```
73
+
74
+ (The minute offset spreads the call off the top of the hour.)
@@ -0,0 +1,14 @@
1
+ [Unit]
2
+ Description=PocketShell usage capture (writes the cached latest reading + history log)
3
+ Documentation=https://github.com/alexeygrigorev/pocketshell/blob/main/docs/usage-panel.md
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ # Capture usage live, write usage-latest.json + append usage-history.jsonl
8
+ # under $XDG_STATE_HOME/pocketshell/usage/. %h expands to the user's home,
9
+ # so this works with `systemctl --user` (the recommended install).
10
+ ExecStart=%h/.local/bin/pocketshell usage --capture
11
+ # Keep capture failures (a transient provider hiccup) non-fatal for the
12
+ # timer: a single missed capture just means the app shows a slightly older
13
+ # cached reading until the next run.
14
+ SuccessExitStatus=0 1
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=Run PocketShell usage capture hourly
3
+ Documentation=https://github.com/alexeygrigorev/pocketshell/blob/main/docs/usage-panel.md
4
+
5
+ [Timer]
6
+ # Hourly capture. RandomizedDelaySec spreads the provider API calls so a
7
+ # fleet of hosts does not hit the endpoints all at once on the hour.
8
+ OnCalendar=hourly
9
+ RandomizedDelaySec=300
10
+ # Catch up after the machine was asleep/off so a laptop that missed the
11
+ # hourly tick still refreshes the cache on next boot.
12
+ Persistent=true
13
+
14
+ [Install]
15
+ WantedBy=timers.target
@@ -28,6 +28,7 @@ from pocketshell.github import github_group
28
28
  from pocketshell.hooks import hooks_group
29
29
  from pocketshell.jobs import jobs_group
30
30
  from pocketshell.logs import logs_group
31
+ from pocketshell.prune_attachments import prune_attachments_command
31
32
  from pocketshell.qr_share import qr_share_command
32
33
  from pocketshell.repos import repos_group
33
34
  from pocketshell.sessions import sessions_group
@@ -58,6 +59,7 @@ cli.add_command(github_group, name="github")
58
59
  cli.add_command(env_group, name="env")
59
60
  cli.add_command(hooks_group, name="hooks")
60
61
  cli.add_command(logs_group, name="logs")
62
+ cli.add_command(prune_attachments_command, name="prune-attachments")
61
63
  cli.add_command(qr_share_command, name="qr-share")
62
64
 
63
65
 
@@ -0,0 +1,372 @@
1
+ """`pocketshell prune-attachments` — server-side attachment retention backstop.
2
+
3
+ The PocketShell Android composer uploads each prompt attachment to the
4
+ remote host under ``~/.pocketshell/attachments/<host-scope>/`` (see
5
+ ``PromptAttachmentStager.REMOTE_DIRECTORY`` on the client). Nothing on the
6
+ host ever removes those files, so the directory grows unbounded on every
7
+ host the user attaches to (issue #547).
8
+
9
+ The client already prunes the *active* scope dir on a fresh upload
10
+ (``RemoteAttachmentPruner`` — option 1 in #547). This command is the
11
+ **server-side backstop** (option 2): it runs ON the host, over the whole
12
+ ``~/.pocketshell/attachments/`` tree, so even hosts the user stopped
13
+ attaching to are eventually trimmed. It is meant to be invoked by the
14
+ normal ``pocketshell`` plumbing (e.g. on connect / during usage probes) or
15
+ from a maintainer's cron.
16
+
17
+ Safety bounds (deny-by-default — this deletes files):
18
+
19
+ - **Scoped to one directory.** Only files directly under
20
+ ``~/.pocketshell/attachments/<scope>/`` (depth-2 from the root) are ever
21
+ considered. The root and the per-scope directories themselves are never
22
+ deleted; the user's own files outside the attachments tree are never
23
+ touched. The resolved root must stay inside ``$HOME`` or the command
24
+ refuses to run.
25
+ - **Regular files only.** Symlinks, directories, sockets, and anything
26
+ that is not a plain regular file is skipped — a symlink inside the
27
+ attachments dir can never be used to delete a file elsewhere.
28
+ - **Age bound (TTL).** A file is a deletion candidate only when its mtime
29
+ is strictly older than ``DEFAULT_TTL_DAYS`` (14 days).
30
+ - **Size cap.** After the TTL pass, if the *surviving* attachments still
31
+ exceed ``DEFAULT_MAX_TOTAL_BYTES`` (256 MiB), the oldest survivors are
32
+ deleted (oldest-first) until the tree is back under the cap. Files
33
+ younger than ``PROTECT_NEWEST_HOURS`` (24h) are never deleted by the
34
+ size cap so an active session's just-uploaded files are spared even
35
+ during a big backlog clear.
36
+ - **Dry-run default off, but ``--dry-run`` reports without deleting.**
37
+
38
+ The command is best-effort and never raises on a per-file delete error
39
+ (permissions, races) — it logs the failure into the result summary and
40
+ continues, so a single bad file can't wedge the whole prune.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+ from typing import Optional
49
+
50
+ import click
51
+
52
+ # Retention tuning knobs. Kept as module constants so both the CLI and the
53
+ # unit suite reference one source of truth.
54
+ DEFAULT_TTL_DAYS: int = 14
55
+ DEFAULT_MAX_TOTAL_BYTES: int = 256 * 1024 * 1024 # 256 MiB
56
+ PROTECT_NEWEST_HOURS: int = 24
57
+
58
+ # The attachments root, relative to ``$HOME``. Mirrors the client's
59
+ # ``PromptAttachmentStager.REMOTE_DIRECTORY``.
60
+ ATTACHMENTS_RELATIVE_ROOT = Path(".pocketshell") / "attachments"
61
+
62
+ _SECONDS_PER_DAY = 24 * 60 * 60
63
+ _SECONDS_PER_HOUR = 60 * 60
64
+
65
+
66
+ @dataclass
67
+ class DeletedFile:
68
+ """One file the prune removed (or would remove in dry-run)."""
69
+
70
+ path: str
71
+ size: int
72
+ age_days: float
73
+ reason: str # "ttl" or "size-cap"
74
+
75
+
76
+ @dataclass
77
+ class PruneResult:
78
+ """Structured summary of a prune pass (CLI emits this as JSON)."""
79
+
80
+ root: str
81
+ scanned_files: int = 0
82
+ scanned_bytes: int = 0
83
+ deleted: list[DeletedFile] = field(default_factory=list)
84
+ errors: list[str] = field(default_factory=list)
85
+ dry_run: bool = False
86
+ skipped_root_missing: bool = False
87
+
88
+ @property
89
+ def deleted_count(self) -> int:
90
+ return len(self.deleted)
91
+
92
+ @property
93
+ def deleted_bytes(self) -> int:
94
+ return sum(d.size for d in self.deleted)
95
+
96
+ def to_dict(self) -> dict:
97
+ return {
98
+ "root": self.root,
99
+ "dry_run": self.dry_run,
100
+ "skipped_root_missing": self.skipped_root_missing,
101
+ "scanned_files": self.scanned_files,
102
+ "scanned_bytes": self.scanned_bytes,
103
+ "deleted_count": self.deleted_count,
104
+ "deleted_bytes": self.deleted_bytes,
105
+ "deleted": [
106
+ {
107
+ "path": d.path,
108
+ "size": d.size,
109
+ "age_days": round(d.age_days, 3),
110
+ "reason": d.reason,
111
+ }
112
+ for d in self.deleted
113
+ ],
114
+ "errors": self.errors,
115
+ }
116
+
117
+
118
+ @dataclass
119
+ class _Candidate:
120
+ path: Path
121
+ size: int
122
+ mtime: float
123
+
124
+
125
+ def resolve_attachments_root(home: Optional[Path] = None) -> Path:
126
+ """Resolve ``~/.pocketshell/attachments`` from ``$HOME``.
127
+
128
+ Pulled out so the unit suite can point it at a tmp dir. Uses the
129
+ ``home`` argument when given, otherwise ``Path.home()``.
130
+ """
131
+ base = home if home is not None else Path.home()
132
+ return (base / ATTACHMENTS_RELATIVE_ROOT).resolve()
133
+
134
+
135
+ def _iter_attachment_files(root: Path) -> list[_Candidate]:
136
+ """Collect regular files exactly two levels under ``root``.
137
+
138
+ Layout is ``root/<scope>/<file>``. We deliberately do NOT recurse
139
+ deeper and we never follow symlinks: a candidate must be a real
140
+ regular file (``Path.is_file()`` follows symlinks, so we additionally
141
+ reject symlinks with ``is_symlink()``).
142
+ """
143
+ candidates: list[_Candidate] = []
144
+ for scope_dir in _safe_iterdir(root):
145
+ # Only descend into real directories that are direct children of
146
+ # the root — never symlinked dirs (which could point outside the
147
+ # attachments tree).
148
+ if scope_dir.is_symlink() or not scope_dir.is_dir():
149
+ continue
150
+ for entry in _safe_iterdir(scope_dir):
151
+ if entry.is_symlink():
152
+ continue
153
+ if not entry.is_file():
154
+ continue
155
+ try:
156
+ stat = entry.stat()
157
+ except OSError:
158
+ continue
159
+ candidates.append(
160
+ _Candidate(path=entry, size=stat.st_size, mtime=stat.st_mtime)
161
+ )
162
+ return candidates
163
+
164
+
165
+ def _safe_iterdir(directory: Path) -> list[Path]:
166
+ try:
167
+ return sorted(directory.iterdir())
168
+ except OSError:
169
+ return []
170
+
171
+
172
+ def prune_attachments(
173
+ root: Path,
174
+ *,
175
+ now: float,
176
+ ttl_days: int = DEFAULT_TTL_DAYS,
177
+ max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES,
178
+ protect_newest_hours: int = PROTECT_NEWEST_HOURS,
179
+ dry_run: bool = False,
180
+ ) -> PruneResult:
181
+ """Prune attachments under ``root`` by TTL then by size cap.
182
+
183
+ ``root`` MUST be the resolved ``~/.pocketshell/attachments`` directory;
184
+ the caller is responsible for the ``$HOME`` containment check (the CLI
185
+ does it). ``now`` is the current epoch-seconds (injected so the unit
186
+ suite is deterministic).
187
+ """
188
+ result = PruneResult(root=str(root), dry_run=dry_run)
189
+
190
+ if not root.exists() or not root.is_dir():
191
+ result.skipped_root_missing = True
192
+ return result
193
+
194
+ candidates = _iter_attachment_files(root)
195
+ result.scanned_files = len(candidates)
196
+ result.scanned_bytes = sum(c.size for c in candidates)
197
+
198
+ ttl_seconds = ttl_days * _SECONDS_PER_DAY
199
+ protect_seconds = protect_newest_hours * _SECONDS_PER_HOUR
200
+
201
+ survivors: list[_Candidate] = []
202
+
203
+ # Pass 1 — TTL. A file strictly older than the TTL is deleted.
204
+ for c in candidates:
205
+ age = now - c.mtime
206
+ if age > ttl_seconds:
207
+ _delete(result, c, now=now, reason="ttl", dry_run=dry_run)
208
+ else:
209
+ survivors.append(c)
210
+
211
+ # Pass 2 — size cap. If survivors still exceed the cap, delete the
212
+ # oldest (lowest mtime first) until back under, but never delete a file
213
+ # younger than the protect window.
214
+ surviving_bytes = sum(c.size for c in survivors)
215
+ if surviving_bytes > max_total_bytes:
216
+ # Oldest first.
217
+ for c in sorted(survivors, key=lambda x: x.mtime):
218
+ if surviving_bytes <= max_total_bytes:
219
+ break
220
+ age = now - c.mtime
221
+ if age < protect_seconds:
222
+ continue
223
+ _delete(result, c, now=now, reason="size-cap", dry_run=dry_run)
224
+ surviving_bytes -= c.size
225
+
226
+ return result
227
+
228
+
229
+ def _delete(
230
+ result: PruneResult,
231
+ candidate: _Candidate,
232
+ *,
233
+ now: float,
234
+ reason: str,
235
+ dry_run: bool,
236
+ ) -> None:
237
+ """Record (and, unless dry-run, perform) one deletion. Best-effort."""
238
+ age_days = (now - candidate.mtime) / _SECONDS_PER_DAY
239
+ if not dry_run:
240
+ try:
241
+ candidate.path.unlink()
242
+ except OSError as exc:
243
+ result.errors.append(f"{candidate.path}: {exc}")
244
+ return
245
+ result.deleted.append(
246
+ DeletedFile(
247
+ path=str(candidate.path),
248
+ size=candidate.size,
249
+ age_days=age_days,
250
+ reason=reason,
251
+ )
252
+ )
253
+
254
+
255
+ @click.command(
256
+ name="prune-attachments",
257
+ context_settings={"help_option_names": ["-h", "--help"]},
258
+ help=(
259
+ "Prune the ~/.pocketshell/attachments/ tree on this host.\n\n"
260
+ "Server-side retention backstop for prompt attachments uploaded by "
261
+ "the PocketShell composer (issue #547). Deletes regular files older "
262
+ "than the TTL, then trims the oldest survivors if the tree still "
263
+ "exceeds the size cap. Only touches files inside the attachments "
264
+ "directory; never the user's own files. Best-effort: per-file "
265
+ "errors are reported, not raised."
266
+ ),
267
+ )
268
+ @click.option(
269
+ "--ttl-days",
270
+ type=click.IntRange(min=0),
271
+ default=DEFAULT_TTL_DAYS,
272
+ show_default=True,
273
+ help="Delete attachments strictly older than this many days.",
274
+ )
275
+ @click.option(
276
+ "--max-total-mib",
277
+ type=click.IntRange(min=0),
278
+ default=DEFAULT_MAX_TOTAL_BYTES // (1024 * 1024),
279
+ show_default=True,
280
+ help=(
281
+ "After the TTL pass, trim oldest survivors until the tree is under "
282
+ "this size (MiB). Files younger than the protect window are spared."
283
+ ),
284
+ )
285
+ @click.option(
286
+ "--protect-newest-hours",
287
+ type=click.IntRange(min=0),
288
+ default=PROTECT_NEWEST_HOURS,
289
+ show_default=True,
290
+ help="Never let the size cap delete files younger than this.",
291
+ )
292
+ @click.option(
293
+ "--dry-run",
294
+ is_flag=True,
295
+ help="Report what would be deleted without deleting anything.",
296
+ )
297
+ @click.option(
298
+ "--json",
299
+ "as_json",
300
+ is_flag=True,
301
+ help="Emit the prune summary as JSON.",
302
+ )
303
+ def prune_attachments_command(
304
+ ttl_days: int,
305
+ max_total_mib: int,
306
+ protect_newest_hours: int,
307
+ dry_run: bool,
308
+ as_json: bool,
309
+ ) -> None:
310
+ """CLI entrypoint for ``pocketshell prune-attachments``."""
311
+ import time
312
+
313
+ home = Path.home().resolve()
314
+ root = resolve_attachments_root(home)
315
+
316
+ # Containment guard: the resolved root must stay inside $HOME. This is a
317
+ # belt-and-braces check against a tampered $HOME / ATTACHMENTS root.
318
+ if not _is_within(root, home):
319
+ raise click.ClickException(
320
+ f"refusing to prune {root}: not inside $HOME ({home})"
321
+ )
322
+
323
+ result = prune_attachments(
324
+ root,
325
+ now=time.time(),
326
+ ttl_days=ttl_days,
327
+ max_total_bytes=max_total_mib * 1024 * 1024,
328
+ protect_newest_hours=protect_newest_hours,
329
+ dry_run=dry_run,
330
+ )
331
+
332
+ if as_json:
333
+ click.echo(json.dumps(result.to_dict(), indent=2))
334
+ return
335
+
336
+ if result.skipped_root_missing:
337
+ click.echo(f"no attachments directory at {root}; nothing to prune.")
338
+ return
339
+
340
+ verb = "would delete" if dry_run else "deleted"
341
+ mib = result.deleted_bytes / (1024 * 1024)
342
+ click.echo(
343
+ f"scanned {result.scanned_files} files "
344
+ f"({result.scanned_bytes / (1024 * 1024):.1f} MiB); "
345
+ f"{verb} {result.deleted_count} ({mib:.1f} MiB)."
346
+ )
347
+ for d in result.deleted:
348
+ click.echo(f" {verb}: {d.path} ({d.reason}, {d.age_days:.1f}d)")
349
+ for err in result.errors:
350
+ click.echo(f" error: {err}", err=True)
351
+
352
+
353
+ def _is_within(path: Path, parent: Path) -> bool:
354
+ """True when ``path`` is ``parent`` or a descendant of it."""
355
+ try:
356
+ path.relative_to(parent)
357
+ return True
358
+ except ValueError:
359
+ return False
360
+
361
+
362
+ __all__ = [
363
+ "DEFAULT_TTL_DAYS",
364
+ "DEFAULT_MAX_TOTAL_BYTES",
365
+ "PROTECT_NEWEST_HOURS",
366
+ "ATTACHMENTS_RELATIVE_ROOT",
367
+ "DeletedFile",
368
+ "PruneResult",
369
+ "resolve_attachments_root",
370
+ "prune_attachments",
371
+ "prune_attachments_command",
372
+ ]
@@ -536,6 +536,37 @@ def _try_daemon_usage_fetch(
536
536
  "No effect on the one-shot subprocess path, which always runs fresh."
537
537
  ),
538
538
  )
539
+ @click.option(
540
+ "--capture",
541
+ "capture",
542
+ is_flag=True,
543
+ help=(
544
+ "Fetch usage live, write the cached latest reading + append to the "
545
+ "history log under $XDG_STATE_HOME/pocketshell/usage/, then exit. "
546
+ "Run this on a schedule (cron / systemd timer). Implies --json."
547
+ ),
548
+ )
549
+ @click.option(
550
+ "--cached",
551
+ "cached",
552
+ is_flag=True,
553
+ help=(
554
+ "Emit the last captured reading instantly (no live fetch) as a JSON "
555
+ "document {captured_at, records} for the app's stale-while-revalidate "
556
+ "render. Exits non-zero if no capture exists yet. Implies --json."
557
+ ),
558
+ )
559
+ @click.option(
560
+ "--reset-events",
561
+ "reset_events",
562
+ is_flag=True,
563
+ help=(
564
+ "Emit the recorded limit/session reset events (#690) as a JSON "
565
+ "document {reset_events: [...]} from the history log. The app reads "
566
+ "this to surface 'limits reset at <time>' on next open. Emits an "
567
+ "empty list when no resets have been detected yet. Implies --json."
568
+ ),
569
+ )
539
570
  @click.pass_context
540
571
  def usage_command(
541
572
  ctx: click.Context,
@@ -543,6 +574,9 @@ def usage_command(
543
574
  json_output: bool = False,
544
575
  no_daemon: bool = False,
545
576
  no_cache: bool = False,
577
+ capture: bool = False,
578
+ cached: bool = False,
579
+ reset_events: bool = False,
546
580
  ) -> None:
547
581
  """Report quota / usage for coding-agent providers on this host.
548
582
 
@@ -552,7 +586,32 @@ def usage_command(
552
586
  it falls through to ``quse [provider] [--json]`` as a one-shot
553
587
  subprocess. JSON output is normalized into PocketShell's schema so
554
588
  the app is not pinned to provider-specific `quse` quirks.
589
+
590
+ ``--capture`` and ``--cached`` implement the stale-while-revalidate
591
+ cache (#689): a scheduled ``--capture`` writes the latest reading +
592
+ history log on the host, and the app reads ``--cached`` for an instant
593
+ populated render before its own live foreground refresh swaps in fresh
594
+ data.
555
595
  """
596
+ # `--cached` emits the last captured reading instantly; `--capture`
597
+ # fetches live and persists the cache + history. Both bypass the
598
+ # normal stdout-proxy path below. They imply JSON output.
599
+ if reset_events:
600
+ exit_code = _emit_reset_events()
601
+ if exit_code != 0:
602
+ ctx.exit(exit_code)
603
+ return
604
+ if cached:
605
+ exit_code = _emit_cached_usage()
606
+ if exit_code != 0:
607
+ ctx.exit(exit_code)
608
+ return
609
+ if capture:
610
+ exit_code = _capture_usage(provider, no_daemon=no_daemon)
611
+ if exit_code != 0:
612
+ ctx.exit(exit_code)
613
+ return
614
+
556
615
  # JSON output is the format the daemon caches against (NDJSON
557
616
  # straight from `quse --json`). Human-readable output is rare and
558
617
  # not on the Android hot path, so it does not get the daemon
@@ -586,6 +645,100 @@ def usage_command(
586
645
  ctx.exit(exit_code)
587
646
 
588
647
 
648
+ def _fetch_usage_ndjson(
649
+ provider: Optional[str],
650
+ *,
651
+ no_daemon: bool,
652
+ ) -> tuple[Optional[str], str, int]:
653
+ """Fetch live usage NDJSON for the cache capture.
654
+
655
+ Returns ``(stdout, stderr, returncode)`` where ``stdout`` is the
656
+ normalized NDJSON (or ``None`` when ``quse`` is missing). Mirrors the
657
+ command's own daemon-then-subprocess fall-through so a scheduled
658
+ ``--capture`` benefits from the daemon cache when one is live.
659
+ """
660
+ if not no_daemon:
661
+ envelope = _try_daemon_usage_fetch(provider, no_cache=False)
662
+ if envelope is not None:
663
+ stdout = normalize_usage_stdout(str(envelope.get("stdout") or ""))
664
+ stderr = str(envelope.get("stderr") or "")
665
+ return stdout, stderr, int(envelope.get("returncode", 0))
666
+
667
+ quse_path = _resolve_quse_binary()
668
+ if quse_path is None:
669
+ return (
670
+ None,
671
+ (
672
+ "pocketshell: `quse` is not installed on this host. "
673
+ "Install it via `uv tool install quse` or `pipx install quse` "
674
+ "and re-run.\n"
675
+ ),
676
+ 127,
677
+ )
678
+ args: list[str] = [quse_path]
679
+ if provider:
680
+ args.append(provider)
681
+ args.append("--json")
682
+ completed = subprocess.run(args, check=False, capture_output=True, text=True)
683
+ return normalize_usage_stdout(completed.stdout), completed.stderr, completed.returncode
684
+
685
+
686
+ def _capture_usage(provider: Optional[str], *, no_daemon: bool) -> int:
687
+ """Fetch usage live and persist the cache + history log, then report.
688
+
689
+ Designed to run on a schedule. On a successful fetch it writes
690
+ ``usage-latest.json`` and appends to ``usage-history.jsonl`` under
691
+ ``$XDG_STATE_HOME/pocketshell/usage/`` and echoes the cache object so a
692
+ cron/systemd log shows what landed. A failed fetch is NOT cached so a
693
+ transient provider hiccup never pins a bad reading.
694
+ """
695
+ from pocketshell import usage_capture as _capture
696
+
697
+ stdout, stderr, returncode = _fetch_usage_ndjson(provider, no_daemon=no_daemon)
698
+ if returncode != 0 or stdout is None:
699
+ if stderr:
700
+ sys.stderr.write(stderr)
701
+ return returncode if returncode != 0 else 1
702
+
703
+ cache_obj = _capture.write_capture(stdout)
704
+ sys.stdout.write(json.dumps(cache_obj, sort_keys=True) + "\n")
705
+ return 0
706
+
707
+
708
+ def _emit_cached_usage() -> int:
709
+ """Emit the last captured reading as a JSON document, or exit non-zero.
710
+
711
+ Returns exit code 0 when a cache exists (its JSON document is written to
712
+ stdout), or 3 with a friendly stderr note when no capture has run yet so
713
+ the app can fall back to a pure live fetch.
714
+ """
715
+ from pocketshell import usage_capture as _capture
716
+
717
+ document = _capture.cached_document()
718
+ if document is None:
719
+ sys.stderr.write(
720
+ "pocketshell: no captured usage yet. "
721
+ "Run `pocketshell usage --capture` (or wait for the scheduled "
722
+ "capture) to populate the cache.\n"
723
+ )
724
+ return 3
725
+ sys.stdout.write(document)
726
+ return 0
727
+
728
+
729
+ def _emit_reset_events() -> int:
730
+ """Emit recorded reset events (#690) as a JSON document.
731
+
732
+ Always exits 0: the document is ``{"reset_events": [...]}`` (empty list
733
+ when no resets have been detected/logged yet), so the app can read it
734
+ unconditionally and surface "limits reset at <time>" when present.
735
+ """
736
+ from pocketshell import usage_reset as _reset
737
+
738
+ sys.stdout.write(_reset.reset_events_document())
739
+ return 0
740
+
741
+
589
742
  def _run_quse_json(args: Sequence[str]) -> int:
590
743
  """Invoke ``quse`` and normalize JSON stdout before proxying it."""
591
744
  quse_path = _resolve_quse_binary()