pocketshell 0.4.46__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.
- {pocketshell-0.4.46 → pocketshell-0.5.0}/PKG-INFO +1 -1
- {pocketshell-0.4.46 → pocketshell-0.5.0}/pyproject.toml +5 -1
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agent_log.py +14 -8
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/cards.py +142 -49
- pocketshell-0.5.0/src/pocketshell/config.py +163 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/daemon.py +82 -43
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/engines.py +333 -54
- pocketshell-0.5.0/src/pocketshell/session_enum.py +686 -0
- pocketshell-0.5.0/src/pocketshell/sessions.py +1296 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/tree.py +99 -65
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/usage_reset.py +11 -7
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/conftest.py +11 -1
- pocketshell-0.5.0/tests/fixtures/aplexer/snapshot-reported-state.json +226 -0
- pocketshell-0.5.0/tests/fixtures/aplexer/snapshot.json +402 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents.py +48 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cards.py +199 -2
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_daemon.py +76 -1
- pocketshell-0.5.0/tests/test_engines.py +759 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_send.py +3 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_session_enum.py +6 -4
- pocketshell-0.5.0/tests/test_session_enum_schema2.py +664 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_sessions.py +8 -1
- pocketshell-0.5.0/tests/test_sessions_attach.py +480 -0
- pocketshell-0.5.0/tests/test_sessions_create_routing.py +793 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_tree.py +151 -23
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_usage.py +2 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_usage_reset.py +25 -1
- pocketshell-0.4.46/src/pocketshell/session_enum.py +0 -267
- pocketshell-0.4.46/src/pocketshell/sessions.py +0 -497
- pocketshell-0.4.46/tests/test_engines.py +0 -263
- {pocketshell-0.4.46 → pocketshell-0.5.0}/.gitignore +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/README.md +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/scheduler/README.md +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/scripts/sync-readme-exit-codes.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agent_card_push.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agents.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/agents_kind.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/aplexer.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/cgroup_agents.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/send.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/serve.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/__init__.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.11-usage.json +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.13-usage.json +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.json +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.ndjson +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.15-usage.provenance.txt +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/quse-0.0.9-usage.json +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agent_card_push.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents_kind.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents_kind_target.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_agents_source_target.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cards_push_notify.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cgroup_agents.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_cli.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_daemon_fallback.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_env.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_github.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_logs.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_push.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_repos.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_resume.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_serve.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_ssh_fixture_penalties.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_watch_ci.py +0 -0
- {pocketshell-0.4.46 → pocketshell-0.5.0}/tests/test_watch_ci_cancelled.py +0 -0
- {pocketshell-0.4.46 → 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.
|
|
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.
|
|
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"
|
|
@@ -129,6 +129,10 @@ exclude = [
|
|
|
129
129
|
]
|
|
130
130
|
|
|
131
131
|
[tool.uv]
|
|
132
|
+
# CI and local frozen-sync checks use the same installer semantics. Bump this
|
|
133
|
+
# deliberately together with .github/workflows/tests.yml and validate the
|
|
134
|
+
# committed lock before merging.
|
|
135
|
+
required-version = "==0.10.11"
|
|
132
136
|
# Keep the lockfile's reproducibility cutoff project-local. The host may have
|
|
133
137
|
# a rolling global `exclude-newer` policy that predates deliberately pinned
|
|
134
138
|
# releases (quse 0.0.15 and tmuxctl 0.3.5); this exact value is also the
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -58,6 +58,12 @@ from __future__ import annotations
|
|
|
58
58
|
import os
|
|
59
59
|
import re
|
|
60
60
|
import sys
|
|
61
|
+
import base64
|
|
62
|
+
import errno
|
|
63
|
+
import fcntl
|
|
64
|
+
import logging
|
|
65
|
+
import tempfile
|
|
66
|
+
from contextlib import contextmanager
|
|
61
67
|
from dataclasses import dataclass
|
|
62
68
|
from datetime import datetime, timezone
|
|
63
69
|
from pathlib import Path
|
|
@@ -67,6 +73,10 @@ import click
|
|
|
67
73
|
|
|
68
74
|
# Permissions for the per-session card file (owner-only, matches tree.py).
|
|
69
75
|
NEW_FILE_MODE = 0o600
|
|
76
|
+
_LOGGER = logging.getLogger(__name__)
|
|
77
|
+
_UNSUPPORTED_SYNC_ERRNOS = {errno.EINVAL, errno.ENOTSUP}
|
|
78
|
+
if hasattr(errno, "EOPNOTSUPP"):
|
|
79
|
+
_UNSUPPORTED_SYNC_ERRNOS.add(errno.EOPNOTSUPP)
|
|
70
80
|
|
|
71
81
|
# Default checklist card id used when ``--id`` is omitted: a session has exactly
|
|
72
82
|
# one "default" checklist unless the agent names them, which keeps the common
|
|
@@ -107,7 +117,7 @@ class CardPaths:
|
|
|
107
117
|
cards_dir: Path
|
|
108
118
|
|
|
109
119
|
def session_file(self, session: str) -> Path:
|
|
110
|
-
return self.cards_dir / f"{
|
|
120
|
+
return self.cards_dir / f"{_encode_session(session)}.yaml"
|
|
111
121
|
|
|
112
122
|
|
|
113
123
|
# Env override for the cards root, so tests (and a non-default deployment) can
|
|
@@ -136,16 +146,10 @@ def resolve_paths(
|
|
|
136
146
|
return CardPaths(cards_dir=base_home / ".pocketshell" / "cards")
|
|
137
147
|
|
|
138
148
|
|
|
139
|
-
def
|
|
140
|
-
"""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
etc.; replace anything outside ``[A-Za-z0-9._-]`` so the name maps to one
|
|
144
|
-
flat file (no traversal, no nested dirs).
|
|
145
|
-
"""
|
|
146
|
-
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", session.strip())
|
|
147
|
-
safe = safe.strip(".") or "session"
|
|
148
|
-
return safe
|
|
149
|
+
def _encode_session(session: str) -> str:
|
|
150
|
+
"""Reversibly encode a tmux session as one safe path segment."""
|
|
151
|
+
encoded = base64.urlsafe_b64encode(session.encode("utf-8")).decode("ascii")
|
|
152
|
+
return "s-" + encoded.rstrip("=")
|
|
149
153
|
|
|
150
154
|
|
|
151
155
|
def _ensure_dir(path: Path) -> None:
|
|
@@ -156,25 +160,90 @@ def _ensure_dir(path: Path) -> None:
|
|
|
156
160
|
pass
|
|
157
161
|
|
|
158
162
|
|
|
163
|
+
def _durability_barrier(fd: int, *, path: Path, kind: str) -> bool:
|
|
164
|
+
"""Fsync one file or directory, tolerating only unsupported barriers."""
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
os.fsync(fd)
|
|
168
|
+
return True
|
|
169
|
+
except OSError as error:
|
|
170
|
+
if error.errno == errno.EINTR:
|
|
171
|
+
continue
|
|
172
|
+
if error.errno in _UNSUPPORTED_SYNC_ERRNOS:
|
|
173
|
+
_LOGGER.warning(
|
|
174
|
+
"durability barrier unavailable for %s %s: %s; "
|
|
175
|
+
"continuing with atomic publication",
|
|
176
|
+
kind,
|
|
177
|
+
path,
|
|
178
|
+
error,
|
|
179
|
+
)
|
|
180
|
+
return False
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fsync_directory(path: Path) -> bool:
|
|
185
|
+
"""Fsync a directory after rename when the filesystem supports it."""
|
|
186
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
187
|
+
try:
|
|
188
|
+
fd = os.open(str(path), flags)
|
|
189
|
+
except OSError as error:
|
|
190
|
+
if error.errno in _UNSUPPORTED_SYNC_ERRNOS:
|
|
191
|
+
_LOGGER.warning(
|
|
192
|
+
"durability barrier unavailable for directory %s: %s; "
|
|
193
|
+
"continuing with atomic publication",
|
|
194
|
+
path,
|
|
195
|
+
error,
|
|
196
|
+
)
|
|
197
|
+
return False
|
|
198
|
+
raise
|
|
199
|
+
try:
|
|
200
|
+
return _durability_barrier(fd, path=path, kind="directory")
|
|
201
|
+
finally:
|
|
202
|
+
os.close(fd)
|
|
203
|
+
|
|
204
|
+
|
|
159
205
|
def _write_private(path: Path, text: str) -> None:
|
|
160
|
-
"""
|
|
206
|
+
"""Publish ``text`` atomically with file and directory durability barriers."""
|
|
161
207
|
_ensure_dir(path.parent)
|
|
162
|
-
|
|
163
|
-
|
|
208
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
209
|
+
tmp = Path(tmp_name)
|
|
210
|
+
open_fd = fd
|
|
164
211
|
try:
|
|
165
|
-
with os.fdopen(fd, "
|
|
166
|
-
|
|
212
|
+
with os.fdopen(fd, "wb") as handle:
|
|
213
|
+
open_fd = -1
|
|
214
|
+
handle.write(text.encode("utf-8"))
|
|
215
|
+
handle.flush()
|
|
216
|
+
os.chmod(tmp, NEW_FILE_MODE)
|
|
217
|
+
_durability_barrier(handle.fileno(), path=tmp, kind="file")
|
|
218
|
+
os.replace(tmp, path)
|
|
219
|
+
_fsync_directory(path.parent)
|
|
167
220
|
except BaseException:
|
|
221
|
+
if open_fd >= 0:
|
|
222
|
+
try:
|
|
223
|
+
os.close(open_fd)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
168
226
|
try:
|
|
169
|
-
|
|
227
|
+
tmp.unlink()
|
|
170
228
|
except FileNotFoundError:
|
|
171
229
|
pass
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
172
232
|
raise
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@contextmanager
|
|
236
|
+
def _session_lock(path: Path):
|
|
237
|
+
"""Hold the cross-process advisory lock for one session document."""
|
|
238
|
+
_ensure_dir(path.parent)
|
|
239
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
240
|
+
fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, NEW_FILE_MODE)
|
|
241
|
+
with os.fdopen(fd, "a+") as lock:
|
|
242
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
243
|
+
try:
|
|
244
|
+
yield
|
|
245
|
+
finally:
|
|
246
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
178
247
|
|
|
179
248
|
|
|
180
249
|
# ---------------------------------------------------------------------------
|
|
@@ -427,9 +496,7 @@ def _import_yaml() -> Any:
|
|
|
427
496
|
return yaml
|
|
428
497
|
|
|
429
498
|
|
|
430
|
-
def
|
|
431
|
-
"""Return the session's list of cards (empty when none / unreadable)."""
|
|
432
|
-
path = paths.session_file(session)
|
|
499
|
+
def _read_cards_file(path: Path, session: str) -> list[dict[str, Any]]:
|
|
433
500
|
try:
|
|
434
501
|
raw = path.read_text(encoding="utf-8")
|
|
435
502
|
except (FileNotFoundError, IsADirectoryError, PermissionError):
|
|
@@ -443,14 +510,28 @@ def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
|
|
|
443
510
|
return []
|
|
444
511
|
if not isinstance(doc, Mapping):
|
|
445
512
|
return []
|
|
513
|
+
if doc.get("session") != session:
|
|
514
|
+
return []
|
|
446
515
|
cards = doc.get("cards")
|
|
447
516
|
if not isinstance(cards, list):
|
|
448
517
|
return []
|
|
449
518
|
return [dict(c) for c in cards if isinstance(c, Mapping)]
|
|
450
519
|
|
|
451
520
|
|
|
521
|
+
def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
|
|
522
|
+
"""Return the session's list of cards (empty when none / unreadable)."""
|
|
523
|
+
return _read_cards_file(paths.session_file(session), session)
|
|
524
|
+
|
|
525
|
+
|
|
452
526
|
def write_cards(session: str, cards: list[dict[str, Any]], *, paths: CardPaths) -> Path:
|
|
453
527
|
"""Atomically persist the session's card list as YAML. Returns the path."""
|
|
528
|
+
path = paths.session_file(session)
|
|
529
|
+
with _session_lock(path):
|
|
530
|
+
_write_cards_file(path, session, cards)
|
|
531
|
+
return path
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _write_cards_file(path: Path, session: str, cards: list[dict[str, Any]]) -> None:
|
|
454
535
|
yaml = _import_yaml()
|
|
455
536
|
doc = {
|
|
456
537
|
"schema": 1,
|
|
@@ -459,9 +540,22 @@ def write_cards(session: str, cards: list[dict[str, Any]], *, paths: CardPaths)
|
|
|
459
540
|
"cards": cards,
|
|
460
541
|
}
|
|
461
542
|
text = yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
|
|
462
|
-
path = paths.session_file(session)
|
|
463
543
|
_write_private(path, text)
|
|
464
|
-
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _mutate_cards(
|
|
547
|
+
session: str,
|
|
548
|
+
*,
|
|
549
|
+
paths: CardPaths,
|
|
550
|
+
mutation: Callable[[list[dict[str, Any]]], Any],
|
|
551
|
+
) -> tuple[Path, Any]:
|
|
552
|
+
"""Apply one read-modify-write transaction under the session lock."""
|
|
553
|
+
path = paths.session_file(session)
|
|
554
|
+
with _session_lock(path):
|
|
555
|
+
cards = _read_cards_file(path, session)
|
|
556
|
+
result = mutation(cards)
|
|
557
|
+
_write_cards_file(path, session, cards)
|
|
558
|
+
return path, result
|
|
465
559
|
|
|
466
560
|
|
|
467
561
|
# ---------------------------------------------------------------------------
|
|
@@ -513,11 +607,13 @@ def upsert_card(session: str, card: dict[str, Any], *, paths: CardPaths) -> Path
|
|
|
513
607
|
Replace preserves nothing of the old card — a re-push of a checklist by the
|
|
514
608
|
agent is a full replace (hard-cut semantics; the new list is authoritative).
|
|
515
609
|
"""
|
|
516
|
-
cards
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
610
|
+
def mutate(cards: list[dict[str, Any]]) -> None:
|
|
611
|
+
card_id = card["id"]
|
|
612
|
+
cards[:] = [c for c in cards if c.get("id") != card_id]
|
|
613
|
+
cards.append(card)
|
|
614
|
+
|
|
615
|
+
path, _ = _mutate_cards(session, paths=paths, mutation=mutate)
|
|
616
|
+
return path
|
|
521
617
|
|
|
522
618
|
|
|
523
619
|
def apply_interaction(
|
|
@@ -532,23 +628,20 @@ def apply_interaction(
|
|
|
532
628
|
Raises :class:`ValueError` when the card id is not found in the session, or
|
|
533
629
|
when the type's :meth:`apply_interaction` rejects the interaction.
|
|
534
630
|
"""
|
|
535
|
-
cards
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
target["state"] = new_state
|
|
550
|
-
target["updated_at"] = _now_iso()
|
|
551
|
-
write_cards(session, cards, paths=paths)
|
|
631
|
+
def mutate(cards: list[dict[str, Any]]) -> dict[str, Any]:
|
|
632
|
+
target = next((card for card in cards if card.get("id") == card_id), None)
|
|
633
|
+
if target is None:
|
|
634
|
+
raise ValueError(f"no card with id {card_id!r} in session {session!r}")
|
|
635
|
+
handler = get_card_type(target.get("type", ""))
|
|
636
|
+
if handler is None:
|
|
637
|
+
raise ValueError(f"card {card_id!r} has unknown type {target.get('type')!r}")
|
|
638
|
+
body = target.get("body", {}) if isinstance(target.get("body"), Mapping) else {}
|
|
639
|
+
state = target.get("state", {}) if isinstance(target.get("state"), Mapping) else {}
|
|
640
|
+
target["state"] = handler.apply_interaction(dict(body), dict(state), interaction)
|
|
641
|
+
target["updated_at"] = _now_iso()
|
|
642
|
+
return target
|
|
643
|
+
|
|
644
|
+
_, target = _mutate_cards(session, paths=paths, mutation=mutate)
|
|
552
645
|
return target
|
|
553
646
|
|
|
554
647
|
|
|
@@ -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
|