pocketshell 0.4.47__tar.gz → 0.5.0__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 (92) hide show
  1. {pocketshell-0.4.47 → pocketshell-0.5.0}/PKG-INFO +1 -1
  2. {pocketshell-0.4.47 → pocketshell-0.5.0}/pyproject.toml +1 -1
  3. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/agent_log.py +14 -8
  4. pocketshell-0.5.0/src/pocketshell/config.py +163 -0
  5. pocketshell-0.5.0/src/pocketshell/session_enum.py +686 -0
  6. pocketshell-0.5.0/src/pocketshell/sessions.py +1296 -0
  7. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/conftest.py +5 -0
  8. pocketshell-0.5.0/tests/fixtures/aplexer/snapshot-reported-state.json +226 -0
  9. pocketshell-0.5.0/tests/fixtures/aplexer/snapshot.json +402 -0
  10. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_send.py +3 -0
  11. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_session_enum.py +6 -4
  12. pocketshell-0.5.0/tests/test_session_enum_schema2.py +664 -0
  13. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_sessions.py +8 -1
  14. pocketshell-0.5.0/tests/test_sessions_attach.py +480 -0
  15. pocketshell-0.5.0/tests/test_sessions_create_routing.py +793 -0
  16. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_usage.py +2 -0
  17. pocketshell-0.4.47/src/pocketshell/session_enum.py +0 -267
  18. pocketshell-0.4.47/src/pocketshell/sessions.py +0 -497
  19. {pocketshell-0.4.47 → pocketshell-0.5.0}/.gitignore +0 -0
  20. {pocketshell-0.4.47 → pocketshell-0.5.0}/README.md +0 -0
  21. {pocketshell-0.4.47 → pocketshell-0.5.0}/scheduler/README.md +0 -0
  22. {pocketshell-0.4.47 → pocketshell-0.5.0}/scheduler/pocketshell-usage-capture.service +0 -0
  23. {pocketshell-0.4.47 → pocketshell-0.5.0}/scheduler/pocketshell-usage-capture.timer +0 -0
  24. {pocketshell-0.4.47 → pocketshell-0.5.0}/scripts/sync-readme-exit-codes.py +0 -0
  25. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/__init__.py +0 -0
  26. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/__main__.py +0 -0
  27. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/agent_card_push.py +0 -0
  28. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/agents.py +0 -0
  29. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/agents_kind.py +0 -0
  30. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/aplexer.py +0 -0
  31. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/cards.py +0 -0
  32. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/cgroup_agents.py +0 -0
  33. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/cli.py +0 -0
  34. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/daemon.py +0 -0
  35. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/engines.py +0 -0
  36. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/env.py +0 -0
  37. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/github.py +0 -0
  38. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/hooks.py +0 -0
  39. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/jobs.py +0 -0
  40. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/logs.py +0 -0
  41. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/profiles.py +0 -0
  42. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/prune_attachments.py +0 -0
  43. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/push.py +0 -0
  44. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/qr_share.py +0 -0
  45. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/repos.py +0 -0
  46. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/resume.py +0 -0
  47. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/send.py +0 -0
  48. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/serve.py +0 -0
  49. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/tree.py +0 -0
  50. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/usage.py +0 -0
  51. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/usage_capture.py +0 -0
  52. {pocketshell-0.4.47 → pocketshell-0.5.0}/src/pocketshell/usage_reset.py +0 -0
  53. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/__init__.py +0 -0
  54. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/quse-0.0.11-usage.json +0 -0
  55. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/quse-0.0.13-usage.json +0 -0
  56. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.json +0 -0
  57. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.ndjson +0 -0
  58. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.provenance.txt +0 -0
  59. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/quse-0.0.9-usage.json +0 -0
  60. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +0 -0
  61. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_agent_card_push.py +0 -0
  62. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_agent_log.py +0 -0
  63. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_agents.py +0 -0
  64. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_agents_kind.py +0 -0
  65. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_agents_kind_target.py +0 -0
  66. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_agents_source_target.py +0 -0
  67. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_cards.py +0 -0
  68. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_cards_push_notify.py +0 -0
  69. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_cgroup_agents.py +0 -0
  70. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_cli.py +0 -0
  71. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_daemon.py +0 -0
  72. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_daemon_fallback.py +0 -0
  73. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_engines.py +0 -0
  74. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_env.py +0 -0
  75. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_github.py +0 -0
  76. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_hooks.py +0 -0
  77. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_jobs.py +0 -0
  78. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_logs.py +0 -0
  79. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_profiles.py +0 -0
  80. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_prune_attachments.py +0 -0
  81. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_push.py +0 -0
  82. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_qr_share.py +0 -0
  83. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_repos.py +0 -0
  84. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_resume.py +0 -0
  85. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_serve.py +0 -0
  86. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_ssh_fixture_penalties.py +0 -0
  87. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_tree.py +0 -0
  88. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_usage_capture.py +0 -0
  89. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_usage_reset.py +0 -0
  90. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_watch_ci.py +0 -0
  91. {pocketshell-0.4.47 → pocketshell-0.5.0}/tests/test_watch_ci_cancelled.py +0 -0
  92. {pocketshell-0.4.47 → pocketshell-0.5.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pocketshell
3
- Version: 0.4.47
3
+ Version: 0.5.0
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
@@ -14,7 +14,7 @@ name = "pocketshell"
14
14
  # scripts/check-pypi-version.sh enforces the stamped value matches the tag;
15
15
  # scripts/check-version-coupling.sh enforces the derivation itself is
16
16
  # single-sourced. See tools/pocketshell/README.md ("Version coupling").
17
- version = "0.4.47"
17
+ version = "0.5.0"
18
18
  description = "Unified server-side Python utility for the PocketShell Android client."
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.11"
@@ -539,6 +539,19 @@ def _grok_messages_from_row(row: dict[str, Any]) -> List[HandoffMessage]:
539
539
  return []
540
540
 
541
541
 
542
+ def _handoff_messages_from_row(engine: str, row: dict[str, Any]) -> List[HandoffMessage]:
543
+ """Dispatch one parsed JSONL row to its per-engine message extractor."""
544
+ if engine == "claude":
545
+ return _claude_messages_from_row(row)
546
+ if engine == "codex":
547
+ return _codex_messages_from_row(row)
548
+ if engine == "opencode":
549
+ return _opencode_messages_from_row(row)
550
+ if engine == "grok":
551
+ return _grok_messages_from_row(row)
552
+ return []
553
+
554
+
542
555
  def _handoff_messages_from_lines(engine: str, lines: Iterable[str]) -> List[HandoffMessage]:
543
556
  """Extract user/assistant prose from raw agent JSONL rows."""
544
557
  messages: List[HandoffMessage] = []
@@ -546,14 +559,7 @@ def _handoff_messages_from_lines(engine: str, lines: Iterable[str]) -> List[Hand
546
559
  row = _parse_json_line(line)
547
560
  if row is None:
548
561
  continue
549
- if engine == "claude":
550
- messages.extend(_claude_messages_from_row(row))
551
- elif engine == "codex":
552
- messages.extend(_codex_messages_from_row(row))
553
- elif engine == "opencode":
554
- messages.extend(_opencode_messages_from_row(row))
555
- elif engine == "grok":
556
- messages.extend(_grok_messages_from_row(row))
562
+ messages.extend(_handoff_messages_from_row(engine, row))
557
563
  return messages
558
564
 
559
565
 
@@ -0,0 +1,163 @@
1
+ """Host-level PocketShell CLI configuration (``~/.config/pocketshell/config.toml``).
2
+
3
+ Today this file carries exactly one section — ``[backends]`` — the per-host
4
+ policy that decides which session backend a *new* session is created on:
5
+
6
+ .. code-block:: toml
7
+
8
+ [backends]
9
+ agent = "tmux" # sessions created with --engine
10
+ shell = "tmux" # plain shell sessions
11
+
12
+ Why a pocketshell-owned config file rather than a flag or a backend-side
13
+ setting: routing is deployment policy that spans BOTH backends, and neither
14
+ backend can answer "not me" (see the simplification plan, §"Backend routing
15
+ policy"). The phone never sends a backend; the host decides.
16
+
17
+ Defaults are ``tmux`` for both keys. Flipping ``agent = "aplexer"`` is a
18
+ deliberate host config change the maintainer makes — never a code change and
19
+ never an automatic upgrade.
20
+
21
+ Parsing rules (deliberate, see ``load_config``):
22
+
23
+ * missing file / empty file -> defaults;
24
+ * malformed TOML, a non-table ``[backends]``, or an unknown backend value ->
25
+ :class:`ConfigError`, i.e. **fail loud**. Silently falling back to the
26
+ defaults would route agent sessions to tmux while the host owner believes
27
+ aplexer is switched on — a wrong-path failure that shows up as "the feature
28
+ just doesn't work", which is exactly the class of silent misroute this
29
+ project rejects. A typo in a two-line file is cheap to fix once it is
30
+ reported; a silent misroute is not.
31
+
32
+ ``tomllib`` is used unconditionally: this package's ``requires-python`` is
33
+ ``>=3.11``, where ``tomllib`` is stdlib, so there is no ``tomli`` fallback to
34
+ maintain (D22: no compatibility shims).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ import tomllib
41
+ from pathlib import Path
42
+ from typing import Any, Mapping, Optional
43
+
44
+ #: Session backends this CLI knows how to create on.
45
+ BACKEND_TMUX = "tmux"
46
+ BACKEND_APLEXER = "aplexer"
47
+ BACKENDS = (BACKEND_TMUX, BACKEND_APLEXER)
48
+
49
+ #: Backend used when the config file says nothing.
50
+ DEFAULT_BACKEND = BACKEND_TMUX
51
+
52
+ #: ``[backends]`` keys: ``agent`` covers a session launched with an engine,
53
+ #: ``shell`` a plain shell session.
54
+ BACKEND_KEY_AGENT = "agent"
55
+ BACKEND_KEY_SHELL = "shell"
56
+ BACKEND_KEYS = (BACKEND_KEY_AGENT, BACKEND_KEY_SHELL)
57
+
58
+ DEFAULT_BACKENDS: dict[str, str] = {
59
+ BACKEND_KEY_AGENT: DEFAULT_BACKEND,
60
+ BACKEND_KEY_SHELL: DEFAULT_BACKEND,
61
+ }
62
+
63
+
64
+ class ConfigError(RuntimeError):
65
+ """``config.toml`` exists but cannot be trusted (malformed / bad value)."""
66
+
67
+
68
+ def _env_source(env: Optional[Mapping[str, str]] = None) -> Mapping[str, str]:
69
+ return env if env is not None else os.environ
70
+
71
+
72
+ def _home_dir(env: Optional[Mapping[str, str]] = None) -> Path:
73
+ """Resolve ``$HOME`` (honouring an injected env for tests)."""
74
+ home = _env_source(env).get("HOME")
75
+ if home:
76
+ return Path(home)
77
+ return Path(os.path.expanduser("~"))
78
+
79
+
80
+ def config_path(env: Optional[Mapping[str, str]] = None) -> Path:
81
+ """``$XDG_CONFIG_HOME``/``~/.config`` + ``pocketshell/config.toml``.
82
+
83
+ Same resolution order as ``profiles.py``'s ``profiles.yaml`` so a host
84
+ keeps all PocketShell config in one directory.
85
+ """
86
+ xdg = _env_source(env).get("XDG_CONFIG_HOME")
87
+ base = Path(xdg) if xdg else _home_dir(env) / ".config"
88
+ return base / "pocketshell" / "config.toml"
89
+
90
+
91
+ def default_config() -> dict[str, Any]:
92
+ """A fresh copy of the fully-defaulted config document."""
93
+ return {"backends": dict(DEFAULT_BACKENDS)}
94
+
95
+
96
+ def _normalize_backends(raw: Any, *, path: Path) -> dict[str, str]:
97
+ """Validate the ``[backends]`` table and merge it over the defaults."""
98
+ backends = dict(DEFAULT_BACKENDS)
99
+ if raw is None:
100
+ return backends
101
+ if not isinstance(raw, Mapping):
102
+ raise ConfigError(
103
+ f"{path}: [backends] must be a table, got {type(raw).__name__}"
104
+ )
105
+ for key, value in raw.items():
106
+ if key not in BACKEND_KEYS:
107
+ raise ConfigError(
108
+ f"{path}: unknown key [backends].{key} "
109
+ f"(known keys: {', '.join(BACKEND_KEYS)})"
110
+ )
111
+ if not isinstance(value, str) or value.strip() not in BACKENDS:
112
+ raise ConfigError(
113
+ f"{path}: [backends].{key} must be one of "
114
+ f"{', '.join(BACKENDS)}, got {value!r}"
115
+ )
116
+ backends[key] = value.strip()
117
+ return backends
118
+
119
+
120
+ def load_config(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
121
+ """Read ``~/.config/pocketshell/config.toml``.
122
+
123
+ Returns the parsed document with a fully-populated ``backends`` table
124
+ (every key present, defaults filled in) so callers never have to probe for
125
+ key presence. Any other top-level table the file happens to carry is
126
+ passed through untouched — this loader owns ``[backends]`` only.
127
+
128
+ A missing file returns the defaults. A file that exists but is malformed
129
+ (bad TOML, non-table ``[backends]``, unknown key, unknown backend value,
130
+ unreadable) raises :class:`ConfigError` — see the module docstring for why
131
+ this fails loud instead of degrading to defaults.
132
+ """
133
+ path = config_path(env)
134
+ if not path.is_file():
135
+ return default_config()
136
+ try:
137
+ raw_bytes = path.read_bytes()
138
+ except OSError as exc:
139
+ raise ConfigError(f"{path}: cannot be read: {exc}") from exc
140
+ try:
141
+ document = tomllib.loads(raw_bytes.decode("utf-8"))
142
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
143
+ raise ConfigError(f"{path}: is not valid TOML: {exc}") from exc
144
+ if not isinstance(document, dict): # pragma: no cover - tomllib invariant
145
+ raise ConfigError(f"{path}: top level must be a table")
146
+ normalized = dict(document)
147
+ normalized["backends"] = _normalize_backends(document.get("backends"), path=path)
148
+ return normalized
149
+
150
+
151
+ def backend_for(config: Mapping[str, Any], key: str) -> str:
152
+ """Read one ``[backends]`` key out of a (possibly partial) config dict.
153
+
154
+ Tolerant on purpose: :func:`load_config` is the strict validator, and this
155
+ accessor is also handed hand-built dicts (tests, callers that skipped the
156
+ loader). Anything missing or not a usable string resolves to
157
+ :data:`DEFAULT_BACKEND`.
158
+ """
159
+ backends = config.get("backends") if isinstance(config, Mapping) else None
160
+ value = backends.get(key) if isinstance(backends, Mapping) else None
161
+ if isinstance(value, str) and value.strip():
162
+ return value.strip()
163
+ return DEFAULT_BACKEND