pocketshell 0.4.17__tar.gz → 0.4.19__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 (60) hide show
  1. {pocketshell-0.4.17 → pocketshell-0.4.19}/PKG-INFO +1 -1
  2. {pocketshell-0.4.17 → pocketshell-0.4.19}/pyproject.toml +1 -1
  3. pocketshell-0.4.19/src/pocketshell/agent_card_push.py +263 -0
  4. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/agents.py +217 -0
  5. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/cards.py +11 -1
  6. pocketshell-0.4.19/tests/test_agent_card_push.py +279 -0
  7. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_agents.py +188 -4
  8. pocketshell-0.4.19/tests/test_watch_ci.py +600 -0
  9. {pocketshell-0.4.17 → pocketshell-0.4.19}/.gitignore +0 -0
  10. {pocketshell-0.4.17 → pocketshell-0.4.19}/README.md +0 -0
  11. {pocketshell-0.4.17 → pocketshell-0.4.19}/scheduler/README.md +0 -0
  12. {pocketshell-0.4.17 → pocketshell-0.4.19}/scheduler/pocketshell-usage-capture.service +0 -0
  13. {pocketshell-0.4.17 → pocketshell-0.4.19}/scheduler/pocketshell-usage-capture.timer +0 -0
  14. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/__init__.py +0 -0
  15. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/__main__.py +0 -0
  16. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/agent_log.py +0 -0
  17. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/agents_kind.py +0 -0
  18. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/cgroup_agents.py +0 -0
  19. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/cli.py +0 -0
  20. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/daemon.py +0 -0
  21. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/env.py +0 -0
  22. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/github.py +0 -0
  23. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/hooks.py +0 -0
  24. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/jobs.py +0 -0
  25. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/logs.py +0 -0
  26. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/profiles.py +0 -0
  27. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/prune_attachments.py +0 -0
  28. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/push.py +0 -0
  29. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/qr_share.py +0 -0
  30. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/repos.py +0 -0
  31. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/resume.py +0 -0
  32. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/sessions.py +0 -0
  33. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/tree.py +0 -0
  34. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/usage.py +0 -0
  35. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/usage_capture.py +0 -0
  36. {pocketshell-0.4.17 → pocketshell-0.4.19}/src/pocketshell/usage_reset.py +0 -0
  37. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/__init__.py +0 -0
  38. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_agent_log.py +0 -0
  39. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_agents_kind.py +0 -0
  40. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_cards.py +0 -0
  41. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_cgroup_agents.py +0 -0
  42. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_cli.py +0 -0
  43. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_daemon.py +0 -0
  44. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_env.py +0 -0
  45. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_github.py +0 -0
  46. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_hooks.py +0 -0
  47. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_jobs.py +0 -0
  48. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_logs.py +0 -0
  49. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_profiles.py +0 -0
  50. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_prune_attachments.py +0 -0
  51. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_push.py +0 -0
  52. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_qr_share.py +0 -0
  53. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_repos.py +0 -0
  54. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_resume.py +0 -0
  55. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_sessions.py +0 -0
  56. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_tree.py +0 -0
  57. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_usage.py +0 -0
  58. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_usage_capture.py +0 -0
  59. {pocketshell-0.4.17 → pocketshell-0.4.19}/tests/test_usage_reset.py +0 -0
  60. {pocketshell-0.4.17 → pocketshell-0.4.19}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.17
3
+ Version: 0.4.19
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.4.17"
11
+ version = "0.4.19"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -0,0 +1,263 @@
1
+ """Server-side FCM push when an agent posts a typed card (epic #859, Slice D).
2
+
3
+ This is the card sibling of :mod:`pocketshell.push` (which owns the usage-reset
4
+ FCM path, #690). When a running agent calls ``pocketshell push checklist`` the
5
+ host writes/updates a per-session card (:mod:`pocketshell.cards`); this module
6
+ fires a best-effort FCM **data** push so the phone surfaces a heads-up
7
+ notification that opens the session's card feed.
8
+
9
+ Why a separate module (not inside :mod:`pocketshell.cards`)
10
+ ----------------------------------------------------------
11
+
12
+ The card store and the FCM device token live in **two different state homes**
13
+ (the #859 research spike's risk #1): cards under ``~/.pocketshell/cards/`` and
14
+ the registered device token under the usage state dir
15
+ (``$XDG_STATE_HOME/pocketshell/usage/push-token.json``). Rather than have
16
+ :mod:`pocketshell.cards` import the token storage directly, the bridge lives
17
+ here: it reads the token via :func:`pocketshell.push.read_token` and sends via
18
+ :class:`pocketshell.push.FcmSender` — the exact same sender/credential the
19
+ usage-reset path uses. Adding a second card type later (``note``) reuses this
20
+ verbatim.
21
+
22
+ Hard-cut (D22): the app receive path adds a NEW ``type=agent_card`` payload
23
+ sibling to ``usage_reset`` — this module never emits ``usage_reset``.
24
+
25
+ Fail-soft (matches :func:`pocketshell.push.push_reset_events`): no registered
26
+ token, no service-account credential, or no :mod:`google.auth` installed → the
27
+ trigger no-ops and returns ``None`` WITHOUT raising. ``pocketshell push
28
+ checklist`` must never break because Firebase isn't set up.
29
+
30
+ De-dup
31
+ ------
32
+
33
+ A successful send records a deterministic **content key** under the cards dir
34
+ (``push-sent.jsonl``) so re-pushing the *same* card (an agent re-runs the same
35
+ ``push checklist``) does not re-POST, while an *updated* card (different items /
36
+ title) produces a new key and DOES push. The app's ``PushDedupStore`` is a
37
+ second line of defence on the device; this is the server-side primary guard.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import json
44
+ import socket
45
+ from pathlib import Path
46
+ from typing import Any, Optional
47
+
48
+ from pocketshell import push as push_mod
49
+ from pocketshell.cards import CardPaths
50
+ from pocketshell.usage_capture import _append_history, resolve_paths as resolve_usage_paths
51
+
52
+ # The app contract: an agent-card push is a `type=agent_card` FCM data message.
53
+ AGENT_CARD_PUSH_TYPE = "agent_card"
54
+
55
+ # Per-session content-key log so a re-push of an unchanged card never re-POSTs.
56
+ # Lives under the cards dir (NOT the usage dir) — co-located with the cards it
57
+ # de-dups. One small file is plenty: cards are rare and bounded.
58
+ SENT_LOG_FILENAME = "push-sent.jsonl"
59
+ DEFAULT_SENT_LOG_MAX_LINES = 1000
60
+
61
+
62
+ def sent_log_file(paths: CardPaths) -> Path:
63
+ """Return the per-content-key already-pushed log path for ``paths``."""
64
+ return paths.cards_dir / SENT_LOG_FILENAME
65
+
66
+
67
+ def sent_card_keys(paths: CardPaths) -> set[str]:
68
+ """Return the set of card content keys already successfully pushed."""
69
+ path = sent_log_file(paths)
70
+ if not path.exists():
71
+ return set()
72
+ keys: set[str] = set()
73
+ try:
74
+ text = path.read_text(encoding="utf-8")
75
+ except OSError:
76
+ return set()
77
+ for line in text.splitlines():
78
+ if not line.strip():
79
+ continue
80
+ try:
81
+ parsed = json.loads(line)
82
+ except json.JSONDecodeError:
83
+ continue
84
+ if isinstance(parsed, dict):
85
+ key = parsed.get("card_key")
86
+ if isinstance(key, str) and key:
87
+ keys.add(key)
88
+ return keys
89
+
90
+
91
+ def _mark_sent(
92
+ card_key: str,
93
+ *,
94
+ paths: CardPaths,
95
+ sent_log_max_lines: int = DEFAULT_SENT_LOG_MAX_LINES,
96
+ ) -> None:
97
+ """Record ``card_key`` as successfully pushed so it never re-POSTs."""
98
+ _append_history(
99
+ sent_log_file(paths),
100
+ {"card_key": card_key},
101
+ history_max_lines=sent_log_max_lines,
102
+ )
103
+
104
+
105
+ def _summarise(card: dict[str, Any]) -> str:
106
+ """One-line human summary of a card via its registered type, fail-soft.
107
+
108
+ Reuses the same per-type ``summarise`` the CLI's ``push status`` uses so the
109
+ notification body matches what the agent sees. Falls back to a generic line
110
+ for an unknown type (forward-compatible).
111
+ """
112
+ from pocketshell.cards import get_card_type
113
+
114
+ card_type = card.get("type", "")
115
+ handler = get_card_type(card_type) if isinstance(card_type, str) else None
116
+ body = card.get("body", {})
117
+ state = card.get("state", {})
118
+ if handler is not None and isinstance(body, dict) and isinstance(state, dict):
119
+ try:
120
+ return handler.summarise(dict(body), dict(state))
121
+ except Exception:
122
+ pass
123
+ return f"{card_type or 'card'} updated"
124
+
125
+
126
+ def _host_alias() -> str:
127
+ """Best-effort host identifier for the deep-link, never raising.
128
+
129
+ The host CLI does NOT know the app's host alias (the #859 research spike's
130
+ risk #2). The most reliable thing it can emit is its own resolvable
131
+ hostname, which the app matches against ``HostEntity.hostname`` /
132
+ ``HostEntity.name`` to route the tap to the right host's session feed. When
133
+ the match is ambiguous or absent the app falls back to its home screen — it
134
+ never opens the WRONG host.
135
+ """
136
+ try:
137
+ fqdn = socket.getfqdn().strip()
138
+ if fqdn and fqdn.lower() != "localhost":
139
+ return fqdn
140
+ except Exception:
141
+ pass
142
+ try:
143
+ return socket.gethostname().strip()
144
+ except Exception:
145
+ return ""
146
+
147
+
148
+ def card_to_data(
149
+ session: str,
150
+ card: dict[str, Any],
151
+ *,
152
+ host: Optional[str] = None,
153
+ ) -> dict[str, str]:
154
+ """Map a card + its session to the app's ``agent_card`` data-message keys.
155
+
156
+ Keys (mirrors the app's ``AgentCardPushPayload`` contract):
157
+
158
+ - ``type`` = ``agent_card``
159
+ - ``session`` = the tmux session the card belongs to (deep-link target)
160
+ - ``host`` = best-effort host hostname for host resolution (may be empty)
161
+ - ``card_id`` = the card id
162
+ - ``card_type`` = e.g. ``checklist``
163
+ - ``title`` = the card title (falls back to a type-derived label)
164
+ - ``summary`` = the per-type one-line summary (e.g. ``checklist 1/3 checked``)
165
+ - ``card_key`` = the content-hash de-dup identity (also the app dedup key)
166
+ """
167
+ card_type = str(card.get("type", "") or "")
168
+ card_id = str(card.get("id", "") or "")
169
+ title = card.get("title")
170
+ title_str = title.strip() if isinstance(title, str) and title.strip() else _default_title(card_type)
171
+ summary = _summarise(card)
172
+ resolved_host = host if host is not None else _host_alias()
173
+ card_key = content_key(session, card)
174
+ return {
175
+ "type": AGENT_CARD_PUSH_TYPE,
176
+ "session": session,
177
+ "host": resolved_host or "",
178
+ "card_id": card_id,
179
+ "card_type": card_type,
180
+ "title": title_str,
181
+ "summary": summary,
182
+ "card_key": card_key,
183
+ }
184
+
185
+
186
+ def _default_title(card_type: str) -> str:
187
+ if card_type == "checklist":
188
+ return "Checklist"
189
+ if card_type:
190
+ return card_type[:1].upper() + card_type[1:]
191
+ return "Card"
192
+
193
+
194
+ def content_key(session: str, card: dict[str, Any]) -> str:
195
+ """Deterministic content key for a card (drives server-side de-dup).
196
+
197
+ Hashes the session + card id + type + title + body + state — NOT
198
+ ``updated_at`` (which changes on every push) — so an identical re-push
199
+ yields the SAME key (suppressed) while any content/state change yields a NEW
200
+ key (pushed). Stable across processes (``sort_keys``).
201
+ """
202
+ payload = {
203
+ "session": session,
204
+ "id": card.get("id"),
205
+ "type": card.get("type"),
206
+ "title": card.get("title"),
207
+ "body": card.get("body"),
208
+ "state": card.get("state"),
209
+ }
210
+ blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
211
+ digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
212
+ return f"{session}|{card.get('id')}|{digest}"
213
+
214
+
215
+ def notify_card_pushed(
216
+ session: str,
217
+ card: dict[str, Any],
218
+ *,
219
+ card_paths: CardPaths,
220
+ host: Optional[str] = None,
221
+ sender: Optional["push_mod.FcmSender"] = None,
222
+ env: Optional[dict[str, str]] = None,
223
+ sent_log_max_lines: int = DEFAULT_SENT_LOG_MAX_LINES,
224
+ ) -> Optional[str]:
225
+ """Best-effort FCM push for a freshly upserted ``card``. Fail-soft.
226
+
227
+ Returns the ``card_key`` actually pushed this call, or ``None`` when push
228
+ isn't configured / nothing new was sent. NEVER raises — the ``push
229
+ checklist`` CLI call must succeed even when Firebase isn't set up.
230
+
231
+ Guards, in order (mirroring :func:`pocketshell.push.push_reset_events`):
232
+
233
+ 1. No registered device token -> ``None``.
234
+ 2. No service-account credential / no ``google.auth`` -> ``None`` (pre-S0).
235
+ 3. Already pushed this exact content (de-dup) -> ``None``.
236
+ 4. On a successful send: record the content key so it never re-POSTs.
237
+ On a failed send: leave it un-recorded so the next push RETRIES.
238
+ """
239
+ try:
240
+ usage_paths = resolve_usage_paths(env=env)
241
+ token = push_mod.read_token(usage_paths)
242
+ if token is None:
243
+ return None
244
+
245
+ if sender is None:
246
+ sa_path = push_mod._resolve_service_account_path(usage_paths, env=env)
247
+ if sa_path is None:
248
+ return None
249
+ sender = push_mod.FcmSender.from_service_account(sa_path)
250
+ if sender is None:
251
+ return None
252
+
253
+ data = card_to_data(session, card, host=host)
254
+ card_key = data["card_key"]
255
+ if card_key in sent_card_keys(card_paths):
256
+ return None
257
+ if sender.send_data_message(token=token, data=data):
258
+ _mark_sent(card_key, paths=card_paths, sent_log_max_lines=sent_log_max_lines)
259
+ return card_key
260
+ return None
261
+ except Exception:
262
+ # Absolute fail-soft backstop: a card push must never wedge the CLI.
263
+ return None
@@ -74,7 +74,11 @@ from __future__ import annotations
74
74
  import json
75
75
  import os
76
76
  import shutil
77
+ import sqlite3
77
78
  import subprocess
79
+ import sys
80
+ import time
81
+ import uuid
78
82
  from pathlib import Path
79
83
  from typing import Optional
80
84
 
@@ -442,6 +446,215 @@ def record_agent_kind(
442
446
  return True
443
447
 
444
448
 
449
+ def _encode_agent_cwd(cwd: str) -> str:
450
+ trimmed = cwd.strip()
451
+ return trimmed.replace("/", "-").replace(".", "-") if trimmed else "-"
452
+
453
+
454
+ def _codex_file_cwd(path: Path) -> Optional[str]:
455
+ try:
456
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
457
+ for line in handle:
458
+ if '"session_meta"' not in line or '"cwd"' not in line:
459
+ continue
460
+ row = json.loads(line)
461
+ payload = row.get("payload")
462
+ if row.get("type") == "session_meta" and isinstance(payload, dict):
463
+ cwd = payload.get("cwd")
464
+ return cwd if isinstance(cwd, str) else None
465
+ except Exception:
466
+ return None
467
+ return None
468
+
469
+
470
+ def _path_mtime(path: Path) -> float:
471
+ try:
472
+ return path.stat().st_mtime
473
+ except OSError:
474
+ return 0.0
475
+
476
+
477
+ def _latest_claude_source(cwd: str, started_at: float) -> Optional[str]:
478
+ root = Path.home() / ".claude" / "projects" / _encode_agent_cwd(cwd)
479
+ if not root.is_dir():
480
+ return None
481
+ candidates = [
482
+ path
483
+ for path in root.glob("*.jsonl")
484
+ if path.is_file() and _path_mtime(path) >= started_at
485
+ ]
486
+ if not candidates:
487
+ return None
488
+ return str(max(candidates, key=_path_mtime))
489
+
490
+
491
+ def _latest_codex_source(cwd: str, started_at: float) -> Optional[str]:
492
+ root = Path.home() / ".codex" / "sessions"
493
+ if not root.is_dir():
494
+ return None
495
+ candidates = [
496
+ path
497
+ for path in root.rglob("*.jsonl")
498
+ if path.is_file()
499
+ and _path_mtime(path) >= started_at
500
+ and _codex_file_cwd(path) == cwd
501
+ ]
502
+ if not candidates:
503
+ return None
504
+ return str(max(candidates, key=_path_mtime))
505
+
506
+
507
+ def _latest_opencode_source(cwd: str, started_at: float) -> Optional[str]:
508
+ db = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
509
+ if not db.is_file():
510
+ return None
511
+ normalized_cwd = cwd.rstrip("/") or "/"
512
+ started_ms = int(started_at * 1000)
513
+ query = """
514
+ SELECT s.id, COALESCE(s.time_updated, s.time_created, 0),
515
+ COALESCE(p.worktree, ''), COALESCE(s.directory, '')
516
+ FROM session s
517
+ LEFT JOIN project p ON p.id = s.project_id
518
+ WHERE COALESCE(s.time_updated, s.time_created, 0) >= ?
519
+ ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC
520
+ """
521
+ try:
522
+ with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as conn:
523
+ rows = conn.execute(query, (started_ms,)).fetchall()
524
+ except Exception:
525
+ return None
526
+ for session_id, _updated, worktree, directory in rows:
527
+ for candidate_cwd in (worktree, directory):
528
+ if not candidate_cwd:
529
+ continue
530
+ root = str(candidate_cwd).rstrip("/") or "/"
531
+ if normalized_cwd == root or normalized_cwd.startswith(root + "/"):
532
+ return f"{db}#{session_id}"
533
+ return None
534
+
535
+
536
+ def _latest_agent_source(
537
+ kind: str,
538
+ cwd: str,
539
+ started_at: float,
540
+ ) -> Optional[str]:
541
+ if kind == "claude":
542
+ return _latest_claude_source(cwd, started_at)
543
+ if kind == "codex":
544
+ return _latest_codex_source(cwd, started_at)
545
+ if kind == "opencode":
546
+ return _latest_opencode_source(cwd, started_at)
547
+ return None
548
+
549
+
550
+ def _watch_and_record_agent_source(
551
+ kind: str,
552
+ cwd: str,
553
+ started_at: str,
554
+ generation: str,
555
+ timeout_seconds: str = "20",
556
+ ) -> int:
557
+ """Watch for this launch's transcript and record it on the tmux session.
558
+
559
+ The wrapper starts this helper immediately before ``execvpe``. The agent
560
+ process has not minted its transcript id yet, so this runs in a detached
561
+ host-side child and records ``@ps_agent_source`` once the source appears.
562
+ """
563
+ try:
564
+ started = float(started_at)
565
+ timeout = float(timeout_seconds)
566
+ except ValueError:
567
+ return 2
568
+ deadline = time.monotonic() + max(timeout, 0.0)
569
+ while time.monotonic() <= deadline:
570
+ source = _latest_agent_source(kind, cwd, started)
571
+ if source:
572
+ try:
573
+ current_generation = subprocess.run(
574
+ ["tmux", "show-options", "-v", "@ps_agent_source_generation"],
575
+ check=False,
576
+ stdout=subprocess.PIPE,
577
+ stderr=subprocess.DEVNULL,
578
+ text=True,
579
+ ).stdout.strip()
580
+ if current_generation != generation:
581
+ return 1
582
+ subprocess.run(
583
+ ["tmux", "set-option", "@ps_agent_source", f"{generation}\t{source}"],
584
+ check=False,
585
+ stdout=subprocess.DEVNULL,
586
+ stderr=subprocess.DEVNULL,
587
+ )
588
+ return 0
589
+ except Exception:
590
+ return 1
591
+ time.sleep(0.25)
592
+ return 1
593
+
594
+
595
+ def record_agent_source(
596
+ kind: str,
597
+ cwd: str,
598
+ env: Optional[dict[str, str]] = None,
599
+ runner=None,
600
+ popen=None,
601
+ ) -> bool:
602
+ """Start a best-effort recorder for this launch's transcript source.
603
+
604
+ ``@ps_agent_kind`` records the engine at launch. This sibling option records
605
+ the exact transcript identity once the launched CLI has created it, so the
606
+ Android conversation opener can prefer an exact source over same-kind mtime
607
+ selection. The old source option is cleared first; if the watcher never
608
+ finds a transcript, the client falls back to its current selector instead of
609
+ trusting a stale path from a previous relaunch in the same tmux session.
610
+ """
611
+ if not kind or not cwd:
612
+ return False
613
+ source_env = os.environ if env is None else env
614
+ if not source_env.get("TMUX"):
615
+ return False
616
+ if runner is None:
617
+ runner = subprocess.run
618
+ if popen is None:
619
+ popen = subprocess.Popen
620
+ try:
621
+ generation = uuid.uuid4().hex
622
+ runner(
623
+ ["tmux", "set-option", "@ps_agent_source_generation", generation],
624
+ check=False,
625
+ )
626
+ runner(
627
+ ["tmux", "set-option", "-uq", "@ps_agent_source"],
628
+ check=False,
629
+ )
630
+ started_at = str(time.time() - 1.0)
631
+ popen(
632
+ [
633
+ sys.executable,
634
+ "-c",
635
+ (
636
+ "from pocketshell.agents import "
637
+ "_watch_and_record_agent_source; import sys; "
638
+ "raise SystemExit(_watch_and_record_agent_source("
639
+ "sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]))"
640
+ ),
641
+ kind,
642
+ cwd,
643
+ started_at,
644
+ generation,
645
+ ],
646
+ env=dict(source_env),
647
+ stdin=subprocess.DEVNULL,
648
+ stdout=subprocess.DEVNULL,
649
+ stderr=subprocess.DEVNULL,
650
+ close_fds=True,
651
+ start_new_session=True,
652
+ )
653
+ except Exception:
654
+ return False
655
+ return True
656
+
657
+
445
658
  def launch_agent(
446
659
  ctx: click.Context,
447
660
  kind: str,
@@ -453,6 +666,7 @@ def launch_agent(
453
666
  profile: Optional[str] = None,
454
667
  execvpe=None,
455
668
  record_kind=None,
669
+ record_source=None,
456
670
  ) -> None:
457
671
  """Resolve the dir, build env+argv, suppress prompts, exec the agent.
458
672
 
@@ -482,6 +696,8 @@ def launch_agent(
482
696
  execvpe = os.execvpe
483
697
  if record_kind is None:
484
698
  record_kind = record_agent_kind
699
+ if record_source is None:
700
+ record_source = record_agent_source
485
701
 
486
702
  path = _resolve_dir(ctx, directory)
487
703
  resolved_dir = str(path)
@@ -517,6 +733,7 @@ def launch_agent(
517
733
  # environment (os.environ) for the TMUX detection — `env` is the
518
734
  # provider-stripped launch env that does not necessarily carry $TMUX.
519
735
  record_kind(kind, dict(os.environ), profile=profile)
736
+ record_source(kind, resolved_dir, dict(os.environ))
520
737
 
521
738
  # Replace this process with the agent so it owns the pty cleanly.
522
739
  execvpe(argv[0], argv, env)
@@ -632,7 +632,17 @@ def register_push_card_commands(push_group: click.Group) -> None:
632
632
  build_kwargs={"items": parsed},
633
633
  preset_checked=preset_checked,
634
634
  )
635
- path = upsert_card(target, card, paths=resolve_paths())
635
+ card_paths = resolve_paths()
636
+ path = upsert_card(target, card, paths=card_paths)
637
+ # 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
636
646
  click.echo(
637
647
  f"checklist {card_id!r} ({len(parsed)} items) -> session {target!r} ({path})"
638
648
  )