pocketshell 0.4.24__tar.gz → 0.4.26__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 (64) hide show
  1. {pocketshell-0.4.24 → pocketshell-0.4.26}/PKG-INFO +2 -1
  2. {pocketshell-0.4.24 → pocketshell-0.4.26}/pyproject.toml +12 -5
  3. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/cards.py +40 -9
  4. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/daemon.py +19 -15
  5. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/hooks.py +79 -0
  6. pocketshell-0.4.26/src/pocketshell/usage.py +517 -0
  7. pocketshell-0.4.26/tests/data/quse-0.0.9-usage.json +132 -0
  8. pocketshell-0.4.26/tests/test_cards_push_notify.py +152 -0
  9. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_daemon.py +84 -41
  10. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_hooks.py +115 -2
  11. pocketshell-0.4.26/tests/test_usage.py +366 -0
  12. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_usage_capture.py +25 -1
  13. pocketshell-0.4.24/src/pocketshell/usage.py +0 -792
  14. pocketshell-0.4.24/tests/test_usage.py +0 -507
  15. {pocketshell-0.4.24 → pocketshell-0.4.26}/.gitignore +0 -0
  16. {pocketshell-0.4.24 → pocketshell-0.4.26}/README.md +0 -0
  17. {pocketshell-0.4.24 → pocketshell-0.4.26}/scheduler/README.md +0 -0
  18. {pocketshell-0.4.24 → pocketshell-0.4.26}/scheduler/pocketshell-usage-capture.service +0 -0
  19. {pocketshell-0.4.24 → pocketshell-0.4.26}/scheduler/pocketshell-usage-capture.timer +0 -0
  20. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/__init__.py +0 -0
  21. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/__main__.py +0 -0
  22. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/agent_card_push.py +0 -0
  23. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/agent_log.py +0 -0
  24. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/agents.py +0 -0
  25. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/agents_kind.py +0 -0
  26. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/cgroup_agents.py +0 -0
  27. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/cli.py +0 -0
  28. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/env.py +0 -0
  29. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/github.py +0 -0
  30. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/jobs.py +0 -0
  31. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/logs.py +0 -0
  32. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/profiles.py +0 -0
  33. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/prune_attachments.py +0 -0
  34. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/push.py +0 -0
  35. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/qr_share.py +0 -0
  36. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/repos.py +0 -0
  37. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/resume.py +0 -0
  38. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/sessions.py +0 -0
  39. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/tree.py +0 -0
  40. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/usage_capture.py +0 -0
  41. {pocketshell-0.4.24 → pocketshell-0.4.26}/src/pocketshell/usage_reset.py +0 -0
  42. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/__init__.py +0 -0
  43. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_agent_card_push.py +0 -0
  44. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_agent_log.py +0 -0
  45. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_agents.py +0 -0
  46. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_agents_kind.py +0 -0
  47. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_cards.py +0 -0
  48. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_cgroup_agents.py +0 -0
  49. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_cli.py +0 -0
  50. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_env.py +0 -0
  51. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_github.py +0 -0
  52. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_jobs.py +0 -0
  53. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_logs.py +0 -0
  54. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_profiles.py +0 -0
  55. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_prune_attachments.py +0 -0
  56. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_push.py +0 -0
  57. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_qr_share.py +0 -0
  58. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_repos.py +0 -0
  59. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_resume.py +0 -0
  60. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_sessions.py +0 -0
  61. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_tree.py +0 -0
  62. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_usage_reset.py +0 -0
  63. {pocketshell-0.4.24 → pocketshell-0.4.26}/tests/test_watch_ci.py +0 -0
  64. {pocketshell-0.4.24 → pocketshell-0.4.26}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.24
3
+ Version: 0.4.26
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,6 +21,7 @@ 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.9
24
25
  Requires-Dist: tmuxctl>=0.3.3
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=8.4.0; extra == 'dev'
@@ -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.4.24"
11
+ version = "0.4.26"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -27,12 +27,19 @@ classifiers = [
27
27
  "Topic :: Software Development",
28
28
  "Topic :: System :: Monitoring",
29
29
  ]
30
- # `pocketshell usage` delegates to the existing `quse` CLI via subprocess
31
- # (see `pocketshell/usage.py`) so this utility does not need `quse`
32
- # published on PyPI; the provider-detection implementation may be folded
33
- # in directly later. Pin lower-bound only.
30
+ # `pocketshell usage` delegates to the `quse` CLI, which is now a PINNED
31
+ # hard dependency (issue #1318): quse owns the unified usage schema, so we
32
+ # ship a known-good quse WITH pocketshell and invoke that pinned console-
33
+ # script (next to `sys.executable`, never PATH — see `pocketshell/usage.py`
34
+ # `_resolve_quse_binary`). A host-level quse upgrade must not reach us.
34
35
  dependencies = [
35
36
  "click>=8.2.0",
37
+ # Usage backend + single source of truth for the unified provider schema
38
+ # (issue #1318). Pinned exactly: `pocketshell usage --json` expects quse
39
+ # 0.0.9's 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
+ "quse==0.0.9",
36
43
  # FCM HTTP v1 push delivery (#690): service-account OAuth2 bearer minting
37
44
  # for `pocketshell push` / the `usage --capture` reset-push send. Imported
38
45
  # lazily and fail-soft — a host without it (or without a Firebase
@@ -572,6 +572,29 @@ def _require_session(explicit: Optional[str]) -> str:
572
572
  return session
573
573
 
574
574
 
575
+ def _notify_card_pushed_best_effort(
576
+ session: str, card: dict[str, Any], *, card_paths: CardPaths
577
+ ) -> None:
578
+ """Fire a best-effort FCM push for a freshly upserted card (#859 Slice D/2).
579
+
580
+ The single notification path shared by EVERY card-creating ``push`` verb
581
+ (``checklist``, ``note``, and any future card type). Centralising it here is
582
+ the durable class fix for #1446: a new ``push <cardtype>`` verb that upserts
583
+ a card just calls this one helper, so it can't silently forget to notify the
584
+ phone the way ``push note`` originally did.
585
+
586
+ Fail-soft — the push deps are imported lazily so a host without them still
587
+ pushes the card, and any failure is swallowed so a card write never wedges
588
+ the CLI. De-dup + configuration guards live in :func:`notify_card_pushed`.
589
+ """
590
+ try:
591
+ from pocketshell.agent_card_push import notify_card_pushed
592
+
593
+ notify_card_pushed(session, card, card_paths=card_paths)
594
+ except Exception:
595
+ pass
596
+
597
+
575
598
  def register_push_card_commands(push_group: click.Group) -> None:
576
599
  """Register the typed-card verbs onto the existing ``push`` click group.
577
600
 
@@ -635,14 +658,8 @@ def register_push_card_commands(push_group: click.Group) -> None:
635
658
  card_paths = resolve_paths()
636
659
  path = upsert_card(target, card, paths=card_paths)
637
660
  # Slice D (#859): fire a best-effort FCM push so the phone surfaces a
638
- # heads-up notification opening this session's card feed. Fail-soft —
639
- # imported lazily so a host without the push deps still pushes the card.
640
- try:
641
- from pocketshell.agent_card_push import notify_card_pushed
642
-
643
- notify_card_pushed(target, card, card_paths=card_paths)
644
- except Exception:
645
- pass
661
+ # heads-up notification opening this session's card feed.
662
+ _notify_card_pushed_best_effort(target, card, card_paths=card_paths)
646
663
  click.echo(
647
664
  f"checklist {card_id!r} ({len(parsed)} items) -> session {target!r} ({path})"
648
665
  )
@@ -652,6 +669,8 @@ def register_push_card_commands(push_group: click.Group) -> None:
652
669
  @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
653
670
  def push_get(as_json: bool, session: Optional[str]) -> None:
654
671
  """Return the session's cards (human/YAML by default, --json for the app)."""
672
+ # No notify (#1446 audit): read-only query, upserts no card. The app is
673
+ # the caller here; there is nothing new to surface.
655
674
  target = _require_session(session)
656
675
  cards = read_cards(target, paths=resolve_paths())
657
676
  if as_json:
@@ -677,6 +696,7 @@ def register_push_card_commands(push_group: click.Group) -> None:
677
696
  @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
678
697
  def push_status(card_id: Optional[str], as_json: bool, session: Optional[str]) -> None:
679
698
  """Report interaction state — which checklist items the human has ticked."""
699
+ # No notify (#1446 audit): read-only query, upserts no card.
680
700
  target = _require_session(session)
681
701
  cards = read_cards(target, paths=resolve_paths())
682
702
  if card_id is not None:
@@ -712,6 +732,9 @@ def register_push_card_commands(push_group: click.Group) -> None:
712
732
  @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
713
733
  def push_check(card_id: str, item_id: str, done: bool, session: Optional[str]) -> None:
714
734
  """Set a checklist item's checked state (this is what the app's tick calls)."""
735
+ # No notify (#1446 audit): this is the APP writing back the human's tick,
736
+ # not an agent pushing a new card. Notifying would push the phone about
737
+ # its own action (and could loop). It mutates state, never upserts a card.
715
738
  target = _require_session(session)
716
739
  try:
717
740
  card = apply_interaction(
@@ -771,7 +794,13 @@ def register_push_card_commands(push_group: click.Group) -> None:
771
794
  title=title,
772
795
  build_kwargs={"text": body_text},
773
796
  )
774
- path = upsert_card(target, card, paths=resolve_paths())
797
+ card_paths = resolve_paths()
798
+ path = upsert_card(target, card, paths=card_paths)
799
+ # #1446: a pushed note is a card the human must see, just like a
800
+ # checklist — fire the same best-effort FCM push so the phone surfaces a
801
+ # heads-up notification opening this session's card feed. (Slice D/#859
802
+ # shipped this only on `checklist`; `push note` was silently omitted.)
803
+ _notify_card_pushed_best_effort(target, card, card_paths=card_paths)
775
804
  click.echo(f"note {card_id!r} -> session {target!r} ({path})")
776
805
 
777
806
  @push_group.command("read")
@@ -780,6 +809,8 @@ def register_push_card_commands(push_group: click.Group) -> None:
780
809
  @click.option("--session", "session", default=None, help="Override the auto-detected tmux session.")
781
810
  def push_read(card_id: str, read: bool, session: Optional[str]) -> None:
782
811
  """Set a note's read state (this is what the app's "mark read" calls)."""
812
+ # No notify (#1446 audit): this is the APP writing back "human read it",
813
+ # not an agent pushing a new card. Same rationale as `push check`.
783
814
  target = _require_session(session)
784
815
  try:
785
816
  card = apply_interaction(
@@ -358,11 +358,11 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
358
358
  }
359
359
 
360
360
  Returning stdout in the envelope preserves the CLI/daemon contract.
361
- The stdout is normalized by :mod:`pocketshell.usage` before it is cached,
362
- because ``quse --json`` can expose provider quirks that are not the
363
- PocketShell app-facing schema. ``quse --json`` emits **NDJSON** (one
364
- provider per line, not a single document), so normalization keeps that
365
- line-oriented wire format.
361
+ ``quse --json`` emits a **provider-keyed JSON object**; the daemon
362
+ FLATTENS it into per-provider NDJSON via
363
+ :func:`pocketshell.usage.normalize_usage_stdout` before caching, so the
364
+ cached envelope stdout is already the app-facing NDJSON wire format (the
365
+ CLI proxies it verbatim, without re-flattening).
366
366
  """
367
367
  # Lazy import to avoid a circular module load at startup: the
368
368
  # daemon module is imported from ``cli.py`` which also imports
@@ -378,17 +378,13 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
378
378
 
379
379
  quse_path = _usage._resolve_quse_binary()
380
380
  if quse_path is None:
381
- # Mirror the CLI's exit-127 behaviour. The daemon does NOT
382
- # cache this; a `quse` install during the daemon's lifetime
383
- # should be picked up on the next call.
381
+ # quse is bundled WITH pocketshell (issue #1318): a missing pinned
382
+ # copy is a packaging-integrity error, not a "install quse" nag. The
383
+ # daemon does NOT cache this failure.
384
384
  return {
385
385
  "stdout": "",
386
- "stderr": (
387
- "pocketshell: `quse` is not installed on this host. "
388
- "Install it via `uv tool install quse` or `pipx install quse` "
389
- "and re-run.\n"
390
- ),
391
- "returncode": 127,
386
+ "stderr": _usage._QUSE_MISSING_MESSAGE + "\n",
387
+ "returncode": _usage._QUSE_MISSING_EXIT_CODE,
392
388
  "provider": provider,
393
389
  }
394
390
 
@@ -402,8 +398,16 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
402
398
  capture_output=True,
403
399
  text=True,
404
400
  )
401
+ # Only a successful (exit 0) quse run is flattened into per-provider
402
+ # NDJSON. A failed fetch is proxied raw (it is not a valid provider-keyed
403
+ # document, and the daemon does not cache failures anyway).
404
+ stdout = (
405
+ _usage.normalize_usage_stdout(completed.stdout)
406
+ if completed.returncode == 0
407
+ else completed.stdout
408
+ )
405
409
  return {
406
- "stdout": _usage.normalize_usage_stdout(completed.stdout),
410
+ "stdout": stdout,
407
411
  "stderr": completed.stderr,
408
412
  "returncode": completed.returncode,
409
413
  "provider": provider,
@@ -195,6 +195,7 @@ stop and never injects a follow-up (integration only; see D26).
195
195
  """
196
196
  import json
197
197
  import os
198
+ import subprocess
198
199
  import sys
199
200
  from datetime import datetime, timezone
200
201
 
@@ -202,6 +203,32 @@ HERE = os.path.dirname(os.path.abspath(__file__))
202
203
  EVENTS_FILE = os.path.join(HERE, "events.jsonl")
203
204
 
204
205
 
206
+ def record_tmux_agent_state(state):
207
+ if "TMUX" not in os.environ:
208
+ return
209
+ option_value = {
210
+ "FINISHED": "idle",
211
+ "WAITING_FOR_INPUT": "waiting_for_input",
212
+ }.get(state)
213
+ if option_value is None:
214
+ return
215
+ try:
216
+ subprocess.run(
217
+ ["tmux", "set-option", "@ps_agent_state", option_value],
218
+ stdout=subprocess.DEVNULL,
219
+ stderr=subprocess.DEVNULL,
220
+ check=False,
221
+ )
222
+ subprocess.run(
223
+ ["tmux", "set-option", "@ps_agent_state_updated_at", datetime.now(timezone.utc).isoformat()],
224
+ stdout=subprocess.DEVNULL,
225
+ stderr=subprocess.DEVNULL,
226
+ check=False,
227
+ )
228
+ except (OSError, subprocess.SubprocessError):
229
+ pass
230
+
231
+
205
232
  def main():
206
233
  raw = sys.stdin.read()
207
234
  try:
@@ -232,6 +259,7 @@ def main():
232
259
  os.makedirs(os.path.dirname(EVENTS_FILE), exist_ok=True)
233
260
  with open(EVENTS_FILE, "a") as handle:
234
261
  handle.write(json.dumps(record) + "\\n")
262
+ record_tmux_agent_state(state)
235
263
  # Exit clean with no stdout so Claude proceeds with the stop.
236
264
  sys.exit(0)
237
265
 
@@ -252,6 +280,7 @@ event bus next to this script.
252
280
  """
253
281
  import json
254
282
  import os
283
+ import subprocess
255
284
  import sys
256
285
  from datetime import datetime, timezone
257
286
 
@@ -259,6 +288,32 @@ HERE = os.path.dirname(os.path.abspath(__file__))
259
288
  EVENTS_FILE = os.path.join(HERE, "events.jsonl")
260
289
 
261
290
 
291
+ def record_tmux_agent_state(state):
292
+ if "TMUX" not in os.environ:
293
+ return
294
+ option_value = {
295
+ "FINISHED": "idle",
296
+ "WAITING_FOR_INPUT": "waiting_for_input",
297
+ }.get(state)
298
+ if option_value is None:
299
+ return
300
+ try:
301
+ subprocess.run(
302
+ ["tmux", "set-option", "@ps_agent_state", option_value],
303
+ stdout=subprocess.DEVNULL,
304
+ stderr=subprocess.DEVNULL,
305
+ check=False,
306
+ )
307
+ subprocess.run(
308
+ ["tmux", "set-option", "@ps_agent_state_updated_at", datetime.now(timezone.utc).isoformat()],
309
+ stdout=subprocess.DEVNULL,
310
+ stderr=subprocess.DEVNULL,
311
+ check=False,
312
+ )
313
+ except (OSError, subprocess.SubprocessError):
314
+ pass
315
+
316
+
262
317
  def main():
263
318
  raw = sys.argv[1] if len(sys.argv) > 1 else "{}"
264
319
  try:
@@ -283,6 +338,7 @@ def main():
283
338
  os.makedirs(os.path.dirname(EVENTS_FILE), exist_ok=True)
284
339
  with open(EVENTS_FILE, "a") as handle:
285
340
  handle.write(json.dumps(record) + "\\n")
341
+ record_tmux_agent_state(state)
286
342
  sys.exit(0)
287
343
 
288
344
 
@@ -300,6 +356,7 @@ _OPENCODE_PLUGIN_SOURCE = '''\
300
356
  // (WAITING_FOR_INPUT), or errors (ERROR). Integration only — no
301
357
  // continue/stop decision (see D26).
302
358
  import fs from "node:fs";
359
+ import child_process from "node:child_process";
303
360
  import os from "node:os";
304
361
  import path from "node:path";
305
362
 
@@ -311,6 +368,27 @@ function busPath() {
311
368
  return path.join(dir, "events.jsonl");
312
369
  }
313
370
 
371
+ function recordTmuxAgentState(state) {
372
+ if (!process.env.TMUX) return;
373
+ const value = {
374
+ FINISHED: "idle",
375
+ WAITING_FOR_INPUT: "waiting_for_input",
376
+ }[state];
377
+ if (!value) return;
378
+ try {
379
+ child_process.spawnSync("tmux", ["set-option", "@ps_agent_state", value], {
380
+ stdio: "ignore",
381
+ });
382
+ child_process.spawnSync(
383
+ "tmux",
384
+ ["set-option", "@ps_agent_state_updated_at", new Date().toISOString()],
385
+ { stdio: "ignore" },
386
+ );
387
+ } catch (e) {
388
+ // best-effort; never throw out of a plugin hook
389
+ }
390
+ }
391
+
314
392
  export const PocketShellIdleSignal = async () => {
315
393
  const EVENTS_FILE = busPath();
316
394
  function emit(state, event) {
@@ -325,6 +403,7 @@ export const PocketShellIdleSignal = async () => {
325
403
  try {
326
404
  fs.mkdirSync(path.dirname(EVENTS_FILE), { recursive: true });
327
405
  fs.appendFileSync(EVENTS_FILE, JSON.stringify(rec) + "\\n");
406
+ recordTmuxAgentState(state);
328
407
  } catch (e) {
329
408
  // best-effort; never throw out of a plugin hook
330
409
  }