pocketshell 0.4.45__tar.gz → 0.4.47__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 (87) hide show
  1. {pocketshell-0.4.45 → pocketshell-0.4.47}/PKG-INFO +2 -2
  2. {pocketshell-0.4.45 → pocketshell-0.4.47}/pyproject.toml +22 -14
  3. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/cards.py +142 -49
  4. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/daemon.py +82 -43
  5. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/engines.py +339 -55
  6. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/tree.py +99 -65
  7. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/usage.py +46 -85
  8. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/usage_reset.py +11 -7
  9. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/conftest.py +6 -1
  10. pocketshell-0.4.47/tests/data/quse-0.0.15-usage.json +226 -0
  11. pocketshell-0.4.47/tests/data/quse-0.0.15-usage.ndjson +6 -0
  12. pocketshell-0.4.47/tests/data/quse-0.0.15-usage.provenance.txt +32 -0
  13. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents.py +48 -0
  14. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cards.py +199 -2
  15. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_daemon.py +106 -19
  16. pocketshell-0.4.47/tests/test_engines.py +759 -0
  17. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_tree.py +151 -23
  18. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_usage.py +321 -139
  19. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_usage_capture.py +26 -22
  20. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_usage_reset.py +26 -2
  21. {pocketshell-0.4.45 → pocketshell-0.4.47}/uv.lock +6 -6
  22. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.json +0 -184
  23. pocketshell-0.4.45/tests/data/quse-0.0.14-usage.provenance.txt +0 -18
  24. pocketshell-0.4.45/tests/test_engines.py +0 -231
  25. {pocketshell-0.4.45 → pocketshell-0.4.47}/.gitignore +0 -0
  26. {pocketshell-0.4.45 → pocketshell-0.4.47}/README.md +0 -0
  27. {pocketshell-0.4.45 → pocketshell-0.4.47}/scheduler/README.md +0 -0
  28. {pocketshell-0.4.45 → pocketshell-0.4.47}/scheduler/pocketshell-usage-capture.service +0 -0
  29. {pocketshell-0.4.45 → pocketshell-0.4.47}/scheduler/pocketshell-usage-capture.timer +0 -0
  30. {pocketshell-0.4.45 → pocketshell-0.4.47}/scripts/sync-readme-exit-codes.py +0 -0
  31. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/__init__.py +0 -0
  32. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/__main__.py +0 -0
  33. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agent_card_push.py +0 -0
  34. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agent_log.py +0 -0
  35. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agents.py +0 -0
  36. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agents_kind.py +0 -0
  37. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/aplexer.py +0 -0
  38. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/cgroup_agents.py +0 -0
  39. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/cli.py +0 -0
  40. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/env.py +0 -0
  41. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/github.py +0 -0
  42. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/hooks.py +0 -0
  43. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/jobs.py +0 -0
  44. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/logs.py +0 -0
  45. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/profiles.py +0 -0
  46. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/prune_attachments.py +0 -0
  47. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/push.py +0 -0
  48. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/qr_share.py +0 -0
  49. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/repos.py +0 -0
  50. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/resume.py +0 -0
  51. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/send.py +0 -0
  52. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/serve.py +0 -0
  53. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/session_enum.py +0 -0
  54. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/sessions.py +0 -0
  55. {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/usage_capture.py +0 -0
  56. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/__init__.py +0 -0
  57. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/quse-0.0.11-usage.json +0 -0
  58. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/quse-0.0.13-usage.json +0 -0
  59. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/quse-0.0.9-usage.json +0 -0
  60. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +0 -0
  61. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agent_card_push.py +0 -0
  62. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agent_log.py +0 -0
  63. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents_kind.py +0 -0
  64. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents_kind_target.py +0 -0
  65. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents_source_target.py +0 -0
  66. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cards_push_notify.py +0 -0
  67. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cgroup_agents.py +0 -0
  68. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cli.py +0 -0
  69. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_daemon_fallback.py +0 -0
  70. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_env.py +0 -0
  71. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_github.py +0 -0
  72. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_hooks.py +0 -0
  73. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_jobs.py +0 -0
  74. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_logs.py +0 -0
  75. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_profiles.py +0 -0
  76. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_prune_attachments.py +0 -0
  77. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_push.py +0 -0
  78. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_qr_share.py +0 -0
  79. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_repos.py +0 -0
  80. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_resume.py +0 -0
  81. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_send.py +0 -0
  82. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_serve.py +0 -0
  83. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_session_enum.py +0 -0
  84. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_sessions.py +0 -0
  85. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_ssh_fixture_penalties.py +0 -0
  86. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_watch_ci.py +0 -0
  87. {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_watch_ci_cancelled.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pocketshell
3
- Version: 0.4.45
3
+ Version: 0.4.47
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,7 +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.14
24
+ Requires-Dist: quse==0.0.15
25
25
  Requires-Dist: tmuxctl>=0.3.5
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.4.0; extra == 'dev'
@@ -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.45"
17
+ version = "0.4.47"
18
18
  description = "Unified server-side Python utility for the PocketShell Android client."
19
19
  readme = "README.md"
20
20
  requires-python = ">=3.11"
@@ -43,16 +43,19 @@ dependencies = [
43
43
  # Usage backend + single source of truth for the unified provider schema
44
44
  # (issue #1318). Pinned exactly: `pocketshell usage --json` expects quse's
45
45
  # provider-keyed `--json` document and fails loudly on schema drift, so the
46
- # version is frozen, not a range. The published 0.0.14 wheel is the
47
- # five-provider legacy shape with `short_term` / `long_term` records. The
48
- # host boundary translates those records into canonical `windows`; it also
49
- # accepts a separate top-level `windows` producer contract for newer or
50
- # custom producers, without treating that shape as PyPI 0.0.14 provenance.
51
- # Canonical-only providers such as OpenCode Go belong to that separate
52
- # contract, not to the published-wheel provider list. 0.0.13 (#2195)
53
- # historically added the `grok` provider; 0.0.11 (#1564) labeled each
54
- # Codex window from its actual `limit_window_seconds`.
55
- "quse==0.0.14",
46
+ # version is frozen, not a range. The published 0.0.15 wheel (upstream
47
+ # a86959e, issue #2293) is the six-provider CANONICAL producer: every
48
+ # record already carries the top-level `windows` map the Android parser
49
+ # reads, so the host boundary only injects the provider key and forwards
50
+ # the record unchanged. #2283's `short_term` / `long_term` translation for
51
+ # the older wheel is hard-cut (D22) — a legacy-shaped record now fails
52
+ # loudly. 0.0.15 is also the first published wheel that reports the `go`
53
+ # provider (OpenCode on the Go backend), so the OpenCode Go quota finally
54
+ # reaches the Usage panel; it is a provider of the pinned wheel, not a
55
+ # separate producer contract. 0.0.13 (#2195) historically added the `grok`
56
+ # provider; 0.0.11 (#1564) labeled each Codex window from its actual
57
+ # `limit_window_seconds`.
58
+ "quse==0.0.15",
56
59
  # FCM HTTP v1 push delivery (#690): service-account OAuth2 bearer minting
57
60
  # for `pocketshell push` / the `usage --capture` reset-push send. Imported
58
61
  # lazily and fail-soft — a host without it (or without a Firebase
@@ -126,11 +129,16 @@ exclude = [
126
129
  ]
127
130
 
128
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"
129
136
  # Keep the lockfile's reproducibility cutoff project-local. The host may have
130
137
  # a rolling global `exclude-newer` policy that predates deliberately pinned
131
- # releases (quse 0.0.14 and tmuxctl 0.3.5); this exact value is also the
132
- # `uv.lock` options cutoff and must be used when checking this lock.
133
- exclude-newer = "2026-08-27T22:00:00Z"
138
+ # releases (quse 0.0.15 and tmuxctl 0.3.5); this exact value is also the
139
+ # `uv.lock` options cutoff and must be used when checking this lock. Moved
140
+ # past quse 0.0.15's 2026-08-28T12:09:07Z PyPI upload for issue #2293.
141
+ exclude-newer = "2026-08-28T22:00:00Z"
134
142
 
135
143
  [tool.pytest.ini_options]
136
144
  testpaths = ["tests"]
@@ -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"{_sanitise_session(session)}.yaml"
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 _sanitise_session(session: str) -> str:
140
- """Sanitise a tmux session name into a safe single-path-segment filename.
141
-
142
- tmux session names cannot contain whitespace but can contain ``/``, ``.``
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
- """Write ``text`` to ``path`` atomically with mode 0600 (copied from tree.py)."""
206
+ """Publish ``text`` atomically with file and directory durability barriers."""
161
207
  _ensure_dir(path.parent)
162
- tmp = path.with_name(path.name + ".tmp")
163
- fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, NEW_FILE_MODE)
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, "w", encoding="utf-8") as handle:
166
- handle.write(text)
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
- os.unlink(tmp)
227
+ tmp.unlink()
170
228
  except FileNotFoundError:
171
229
  pass
230
+ except OSError:
231
+ pass
172
232
  raise
173
- os.replace(tmp, path)
174
- try:
175
- os.chmod(path, NEW_FILE_MODE)
176
- except FileNotFoundError:
177
- pass
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 read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
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
- return path
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 = read_cards(session, paths=paths)
517
- card_id = card["id"]
518
- cards = [c for c in cards if c.get("id") != card_id]
519
- cards.append(card)
520
- return write_cards(session, cards, paths=paths)
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 = read_cards(session, paths=paths)
536
- target: Optional[dict[str, Any]] = None
537
- for card in cards:
538
- if card.get("id") == card_id:
539
- target = card
540
- break
541
- if target is None:
542
- raise ValueError(f"no card with id {card_id!r} in session {session!r}")
543
- handler = get_card_type(target.get("type", ""))
544
- if handler is None:
545
- raise ValueError(f"card {card_id!r} has unknown type {target.get('type')!r}")
546
- body = target.get("body", {}) if isinstance(target.get("body"), Mapping) else {}
547
- state = target.get("state", {}) if isinstance(target.get("state"), Mapping) else {}
548
- new_state = handler.apply_interaction(dict(body), dict(state), interaction)
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
 
@@ -56,6 +56,7 @@ versions when available) without RPC parameters or command output.
56
56
  from __future__ import annotations
57
57
 
58
58
  import json
59
+ import fcntl
59
60
  import logging
60
61
  import os
61
62
  import re
@@ -356,6 +357,13 @@ def resolve_pid_path(socket_path: Optional[Path] = None) -> Path:
356
357
  return socket_path.with_suffix(".pid")
357
358
 
358
359
 
360
+ def resolve_lock_path(socket_path: Optional[Path] = None) -> Path:
361
+ """Return the lifetime-ownership lock beside the daemon socket."""
362
+ if socket_path is None:
363
+ socket_path = resolve_socket_path()
364
+ return socket_path.with_suffix(".lock")
365
+
366
+
359
367
  def _ensure_socket_dir(socket_path: Path) -> None:
360
368
  """Create the socket's parent dir with mode 0700 if missing."""
361
369
  parent = socket_path.parent
@@ -890,6 +898,10 @@ class _RpcError(Exception):
890
898
  self.data = data
891
899
 
892
900
 
901
+ class _DaemonAlreadyOwned(Exception):
902
+ """Another daemon holds the socket's lifetime lock."""
903
+
904
+
893
905
  class Daemon:
894
906
  """Unix-socket JSON-RPC 2.0 server for ``pocketshell`` subcommands.
895
907
 
@@ -922,6 +934,7 @@ class Daemon:
922
934
  self._cache = _Cache(clock=clock)
923
935
  self._clock = clock
924
936
  self._server_sock: Optional[socket.socket] = None
937
+ self._lifecycle_lock: Optional[Any] = None
925
938
  self._stop_event = threading.Event()
926
939
  self._last_activity = self._clock()
927
940
 
@@ -934,9 +947,17 @@ class Daemon:
934
947
 
935
948
  def _bind(self) -> socket.socket:
936
949
  _ensure_socket_dir(self.socket_path)
937
- # Always unlink first. If a previous daemon crashed without
938
- # cleanup the path is a dead file; bind() would fail with
939
- # EADDRINUSE. Recreating cleanly is the documented self-heal.
950
+ lock_path = resolve_lock_path(self.socket_path)
951
+ lock_fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o600)
952
+ lock = os.fdopen(lock_fd, "a+")
953
+ try:
954
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
955
+ except BlockingIOError:
956
+ lock.close()
957
+ raise _DaemonAlreadyOwned
958
+ self._lifecycle_lock = lock
959
+
960
+ # Only the lifetime-lock owner may replace a proven stale endpoint.
940
961
  try:
941
962
  os.unlink(self.socket_path)
942
963
  except FileNotFoundError:
@@ -948,9 +969,14 @@ class Daemon:
948
969
  # process umask (no fchmod hook on AF_UNIX at bind time).
949
970
  old_umask = os.umask(0o077)
950
971
  try:
951
- sock.bind(str(self.socket_path))
952
- finally:
953
- os.umask(old_umask)
972
+ try:
973
+ sock.bind(str(self.socket_path))
974
+ finally:
975
+ os.umask(old_umask)
976
+ except BaseException:
977
+ sock.close()
978
+ self._release_lifecycle_lock()
979
+ raise
954
980
  sock.listen(8)
955
981
  # Defence in depth: chmod the socket explicitly in case some
956
982
  # platform ignored the umask.
@@ -994,7 +1020,10 @@ class Daemon:
994
1020
  Idle timeout 0 disables the idle exit (used by future systemd
995
1021
  ``Type=simple`` always-on mode).
996
1022
  """
997
- self._server_sock = self._bind()
1023
+ try:
1024
+ self._server_sock = self._bind()
1025
+ except _DaemonAlreadyOwned:
1026
+ return
998
1027
  self._write_pid_file()
999
1028
  self._last_activity = self._clock()
1000
1029
  # Trap SIGTERM so `daemon stop` (and systemd Stop) shuts us
@@ -1057,6 +1086,17 @@ class Daemon:
1057
1086
  self._server_sock = None
1058
1087
  self._remove_socket_file()
1059
1088
  self._remove_pid_file()
1089
+ self._release_lifecycle_lock()
1090
+
1091
+ def _release_lifecycle_lock(self) -> None:
1092
+ lock = self._lifecycle_lock
1093
+ if lock is None:
1094
+ return
1095
+ self._lifecycle_lock = None
1096
+ try:
1097
+ fcntl.flock(lock, fcntl.LOCK_UN)
1098
+ finally:
1099
+ lock.close()
1060
1100
 
1061
1101
  # -- request handling ------------------------------------------------
1062
1102
 
@@ -1647,15 +1687,10 @@ def stop_daemon(
1647
1687
  ) -> bool:
1648
1688
  """Stop a running daemon and remove its socket.
1649
1689
 
1650
- Strategy:
1651
-
1652
- 1. If a PID file exists, ``os.kill(pid, SIGTERM)`` — clean shutdown
1653
- via the signal handler.
1654
- 2. Wait up to ``timeout`` seconds for the socket file to disappear.
1655
- 3. Fall through to the RPC ``daemon.shutdown`` method if the PID
1656
- file is missing (legacy or test-spawned daemons).
1657
- 4. Always remove a stale socket file at the end so the next start
1658
- has a clean slate.
1690
+ A responsive socket proves daemon identity, so shutdown always goes over
1691
+ RPC. A numeric PID file alone is never trusted: the OS may have reused a
1692
+ stale PID for an unrelated process. Stale paths are removed only while
1693
+ holding the same exclusive lifetime lock used by startup.
1659
1694
 
1660
1695
  Returns ``True`` if a daemon was running and is now stopped,
1661
1696
  ``False`` if no daemon was running.
@@ -1663,41 +1698,45 @@ def stop_daemon(
1663
1698
  socket_path = socket_path or resolve_socket_path()
1664
1699
  pid_path = resolve_pid_path(socket_path)
1665
1700
 
1666
- pid = read_pid(pid_path)
1667
1701
  was_running = is_daemon_running(socket_path)
1668
1702
 
1669
- if pid is not None:
1670
- try:
1671
- os.kill(pid, signal.SIGTERM)
1672
- except ProcessLookupError:
1673
- pid = None
1674
- except PermissionError:
1675
- pid = None
1676
-
1677
- if was_running and pid is None:
1678
- # No PID file or kill failed; try the RPC shutdown instead.
1703
+ if was_running:
1679
1704
  try:
1680
1705
  call("daemon.shutdown", socket_path=socket_path, timeout=timeout)
1681
1706
  except (DaemonClientError, RuntimeError, OSError):
1682
1707
  pass
1683
1708
 
1684
- # Wait for the socket file to disappear.
1685
- deadline = time.monotonic() + timeout
1686
- while time.monotonic() < deadline:
1687
- if not socket_path.exists():
1688
- break
1689
- time.sleep(0.05)
1690
-
1691
- # Final cleanup: remove the socket if the daemon did not clean up
1692
- # itself (SIGKILL recovery).
1693
- try:
1694
- os.unlink(socket_path)
1695
- except FileNotFoundError:
1696
- pass
1709
+ if was_running:
1710
+ # Give the proven daemon time to remove its owned paths. A stale,
1711
+ # unresponsive socket has nobody to wait for and proceeds directly to
1712
+ # ownership-locked cleanup below.
1713
+ deadline = time.monotonic() + timeout
1714
+ while time.monotonic() < deadline:
1715
+ if not socket_path.exists():
1716
+ break
1717
+ time.sleep(0.05)
1718
+
1719
+ # Clean stale metadata only if no daemon owns the lifecycle lock. Re-probe
1720
+ # after acquiring it so a concurrent startup cannot be mistaken for stale.
1721
+ _ensure_socket_dir(socket_path)
1722
+ lock_fd = os.open(str(resolve_lock_path(socket_path)), os.O_RDWR | os.O_CREAT, 0o600)
1723
+ lock = os.fdopen(lock_fd, "a+")
1697
1724
  try:
1698
- pid_path.unlink()
1699
- except FileNotFoundError:
1700
- pass
1725
+ try:
1726
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
1727
+ except BlockingIOError:
1728
+ return was_running
1729
+ if not is_daemon_running(socket_path):
1730
+ try:
1731
+ os.unlink(socket_path)
1732
+ except FileNotFoundError:
1733
+ pass
1734
+ try:
1735
+ pid_path.unlink()
1736
+ except FileNotFoundError:
1737
+ pass
1738
+ finally:
1739
+ lock.close()
1701
1740
 
1702
1741
  return was_running
1703
1742