pocketshell 0.4.18__tar.gz → 0.4.20__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.18 → pocketshell-0.4.20}/PKG-INFO +1 -1
  2. {pocketshell-0.4.18 → pocketshell-0.4.20}/pyproject.toml +1 -1
  3. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agents.py +217 -0
  4. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agents.py +188 -4
  5. {pocketshell-0.4.18 → pocketshell-0.4.20}/.gitignore +0 -0
  6. {pocketshell-0.4.18 → pocketshell-0.4.20}/README.md +0 -0
  7. {pocketshell-0.4.18 → pocketshell-0.4.20}/scheduler/README.md +0 -0
  8. {pocketshell-0.4.18 → pocketshell-0.4.20}/scheduler/pocketshell-usage-capture.service +0 -0
  9. {pocketshell-0.4.18 → pocketshell-0.4.20}/scheduler/pocketshell-usage-capture.timer +0 -0
  10. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/__init__.py +0 -0
  11. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/__main__.py +0 -0
  12. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agent_card_push.py +0 -0
  13. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agent_log.py +0 -0
  14. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agents_kind.py +0 -0
  15. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/cards.py +0 -0
  16. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/cgroup_agents.py +0 -0
  17. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/cli.py +0 -0
  18. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/daemon.py +0 -0
  19. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/env.py +0 -0
  20. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/github.py +0 -0
  21. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/hooks.py +0 -0
  22. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/jobs.py +0 -0
  23. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/logs.py +0 -0
  24. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/profiles.py +0 -0
  25. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/prune_attachments.py +0 -0
  26. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/push.py +0 -0
  27. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/qr_share.py +0 -0
  28. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/repos.py +0 -0
  29. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/resume.py +0 -0
  30. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/sessions.py +0 -0
  31. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/tree.py +0 -0
  32. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/usage.py +0 -0
  33. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/usage_capture.py +0 -0
  34. {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/usage_reset.py +0 -0
  35. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/__init__.py +0 -0
  36. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agent_card_push.py +0 -0
  37. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agent_log.py +0 -0
  38. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agents_kind.py +0 -0
  39. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_cards.py +0 -0
  40. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_cgroup_agents.py +0 -0
  41. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_cli.py +0 -0
  42. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_daemon.py +0 -0
  43. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_env.py +0 -0
  44. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_github.py +0 -0
  45. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_hooks.py +0 -0
  46. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_jobs.py +0 -0
  47. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_logs.py +0 -0
  48. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_profiles.py +0 -0
  49. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_prune_attachments.py +0 -0
  50. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_push.py +0 -0
  51. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_qr_share.py +0 -0
  52. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_repos.py +0 -0
  53. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_resume.py +0 -0
  54. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_sessions.py +0 -0
  55. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_tree.py +0 -0
  56. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_usage.py +0 -0
  57. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_usage_capture.py +0 -0
  58. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_usage_reset.py +0 -0
  59. {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_watch_ci.py +0 -0
  60. {pocketshell-0.4.18 → pocketshell-0.4.20}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.4.18
3
+ Version: 0.4.20
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.18"
11
+ version = "0.4.20"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -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)
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
 
18
18
  import json
19
19
  import os
20
+ import sys
20
21
 
21
22
  import click
22
23
  import pytest
@@ -58,6 +59,20 @@ def _restore_cwd():
58
59
  os.chdir(original)
59
60
 
60
61
 
62
+ class _FakePopen:
63
+ pid = 12345
64
+
65
+
66
+ @pytest.fixture(autouse=True)
67
+ def _no_source_recorder_child(monkeypatch):
68
+ """Keep launch tests from spawning the background source watcher."""
69
+ monkeypatch.setattr(
70
+ agents.subprocess,
71
+ "Popen",
72
+ lambda *args, **kwargs: _FakePopen(),
73
+ )
74
+
75
+
61
76
  # ---------------------------------------------------------------------------
62
77
  # Env-strip scope: ALL three agents strip the 71 provider vars (issue #703)
63
78
  # ---------------------------------------------------------------------------
@@ -512,6 +527,168 @@ def test_record_agent_kind_swallows_runner_failure():
512
527
  assert ok is False
513
528
 
514
529
 
530
+ def test_record_agent_source_noop_when_not_in_tmux(tmp_path):
531
+ calls = []
532
+ ok = agents.record_agent_source(
533
+ "claude",
534
+ str(tmp_path),
535
+ env={},
536
+ runner=lambda argv, **kw: calls.append(argv),
537
+ popen=lambda *a, **kw: calls.append(a),
538
+ )
539
+ assert ok is False
540
+ assert calls == []
541
+
542
+
543
+ def test_record_agent_source_clears_stale_source_and_starts_watcher(tmp_path):
544
+ run_calls = []
545
+ popen_calls = []
546
+
547
+ ok = agents.record_agent_source(
548
+ "codex",
549
+ str(tmp_path),
550
+ env={"TMUX": "/tmp/tmux-1000/default,1234,0", "PATH": "/usr/bin"},
551
+ runner=lambda argv, **kw: run_calls.append((argv, kw)),
552
+ popen=lambda *args, **kwargs: popen_calls.append((args, kwargs)) or _FakePopen(),
553
+ )
554
+
555
+ assert ok is True
556
+ generation = run_calls[0][0][-1]
557
+ assert run_calls[0][0] == ["tmux", "set-option", "@ps_agent_source_generation", generation]
558
+ assert len(generation) == 32
559
+ assert run_calls[0][1] == {"check": False}
560
+ assert run_calls[1] == (
561
+ ["tmux", "set-option", "-uq", "@ps_agent_source"],
562
+ {"check": False},
563
+ )
564
+ assert len(popen_calls) == 1
565
+ argv = popen_calls[0][0][0]
566
+ assert argv[:2] == [sys.executable, "-c"]
567
+ assert argv[3:5] == ["codex", str(tmp_path)]
568
+ assert argv[6] == generation
569
+ assert popen_calls[0][1]["env"]["TMUX"] == "/tmp/tmux-1000/default,1234,0"
570
+
571
+
572
+ def test_watch_agent_source_refuses_stale_generation(monkeypatch):
573
+ calls = []
574
+ monkeypatch.setattr(
575
+ agents,
576
+ "_latest_agent_source",
577
+ lambda kind, cwd, started_at: "/tmp/current.jsonl",
578
+ )
579
+
580
+ def fake_run(argv, **kwargs):
581
+ calls.append((argv, kwargs))
582
+ if argv[:3] == ["tmux", "show-options", "-v"]:
583
+ return agents.subprocess.CompletedProcess(argv, 0, stdout="newer-generation\n")
584
+ return agents.subprocess.CompletedProcess(argv, 0, stdout="")
585
+
586
+ monkeypatch.setattr(agents.subprocess, "run", fake_run)
587
+
588
+ assert agents._watch_and_record_agent_source(
589
+ "claude",
590
+ "/workspace/proj",
591
+ "100",
592
+ "older-generation",
593
+ timeout_seconds="0.01",
594
+ ) == 1
595
+ assert calls == [
596
+ (
597
+ ["tmux", "show-options", "-v", "@ps_agent_source_generation"],
598
+ {
599
+ "check": False,
600
+ "stdout": agents.subprocess.PIPE,
601
+ "stderr": agents.subprocess.DEVNULL,
602
+ "text": True,
603
+ },
604
+ )
605
+ ]
606
+
607
+
608
+ def test_watch_agent_source_records_generation_scoped_source(monkeypatch):
609
+ calls = []
610
+ monkeypatch.setattr(
611
+ agents,
612
+ "_latest_agent_source",
613
+ lambda kind, cwd, started_at: "/tmp/current.jsonl",
614
+ )
615
+
616
+ def fake_run(argv, **kwargs):
617
+ calls.append((argv, kwargs))
618
+ if argv[:3] == ["tmux", "show-options", "-v"]:
619
+ return agents.subprocess.CompletedProcess(argv, 0, stdout="same-generation\n")
620
+ return agents.subprocess.CompletedProcess(argv, 0, stdout="")
621
+
622
+ monkeypatch.setattr(agents.subprocess, "run", fake_run)
623
+
624
+ assert agents._watch_and_record_agent_source(
625
+ "claude",
626
+ "/workspace/proj",
627
+ "100",
628
+ "same-generation",
629
+ timeout_seconds="0.01",
630
+ ) == 0
631
+ assert calls[-1][0] == [
632
+ "tmux",
633
+ "set-option",
634
+ "@ps_agent_source",
635
+ "same-generation\t/tmp/current.jsonl",
636
+ ]
637
+
638
+
639
+ def test_latest_claude_source_encodes_dot_cwd_like_android_detector(tmp_path, monkeypatch):
640
+ monkeypatch.setenv("HOME", str(tmp_path))
641
+ cwd = "/workspace/app.1"
642
+ log_dir = tmp_path / ".claude" / "projects" / "-workspace-app-1"
643
+ log_dir.mkdir(parents=True)
644
+ transcript = log_dir / "session.jsonl"
645
+ transcript.write_text("new\n", encoding="utf-8")
646
+ os.utime(transcript, (300, 300))
647
+
648
+ assert agents._latest_agent_source("claude", cwd, started_at=200) == str(transcript)
649
+
650
+
651
+ def test_latest_claude_source_ignores_prelaunch_sibling(tmp_path, monkeypatch):
652
+ monkeypatch.setenv("HOME", str(tmp_path))
653
+ cwd = "/workspace/proj"
654
+ log_dir = tmp_path / ".claude" / "projects" / "-workspace-proj"
655
+ log_dir.mkdir(parents=True)
656
+ older = log_dir / "older.jsonl"
657
+ newer = log_dir / "newer.jsonl"
658
+ older.write_text("old\n", encoding="utf-8")
659
+ newer.write_text("new\n", encoding="utf-8")
660
+ os.utime(older, (100, 100))
661
+ os.utime(newer, (300, 300))
662
+
663
+ assert agents._latest_agent_source("claude", cwd, started_at=200) == str(newer)
664
+
665
+
666
+ def test_latest_codex_source_matches_cwd_and_launch_time(tmp_path, monkeypatch):
667
+ monkeypatch.setenv("HOME", str(tmp_path))
668
+ root = tmp_path / ".codex" / "sessions" / "2026" / "06" / "27"
669
+ root.mkdir(parents=True)
670
+ mine = root / "rollout-mine.jsonl"
671
+ sibling = root / "rollout-sibling.jsonl"
672
+ stale = root / "rollout-stale.jsonl"
673
+ mine.write_text(
674
+ """{"type":"session_meta","payload":{"cwd":"/workspace/proj"}}\n""",
675
+ encoding="utf-8",
676
+ )
677
+ sibling.write_text(
678
+ """{"type":"session_meta","payload":{"cwd":"/workspace/other"}}\n""",
679
+ encoding="utf-8",
680
+ )
681
+ stale.write_text(
682
+ """{"type":"session_meta","payload":{"cwd":"/workspace/proj"}}\n""",
683
+ encoding="utf-8",
684
+ )
685
+ os.utime(mine, (300, 300))
686
+ os.utime(sibling, (400, 400))
687
+ os.utime(stale, (100, 100))
688
+
689
+ assert agents._latest_agent_source("codex", "/workspace/proj", started_at=200) == str(mine)
690
+
691
+
515
692
  @pytest.mark.parametrize("kind", ["claude", "codex", "opencode"])
516
693
  def test_launch_agent_records_kind_before_exec(tmp_path, monkeypatch, kind):
517
694
  monkeypatch.setenv("HOME", str(tmp_path))
@@ -525,6 +702,10 @@ def test_launch_agent_records_kind_before_exec(tmp_path, monkeypatch, kind):
525
702
  def fake_execvpe(file, argv, env):
526
703
  order.append(("exec", file))
527
704
 
705
+ def fake_record_source(k, cwd, env=None, runner=None, popen=None):
706
+ order.append(("source", k, cwd, env.get("TMUX") if env else None))
707
+ return True
708
+
528
709
  agents.launch_agent(
529
710
  _FakeCtx(),
530
711
  kind,
@@ -533,11 +714,12 @@ def test_launch_agent_records_kind_before_exec(tmp_path, monkeypatch, kind):
533
714
  config_dir=None,
534
715
  execvpe=fake_execvpe,
535
716
  record_kind=fake_record,
717
+ record_source=fake_record_source,
536
718
  )
537
- # The kind is recorded with the wrapper's own $TMUX, and recorded BEFORE
538
- # the exec replaces the process.
719
+ # The kind/source are recorded with the wrapper's own $TMUX before exec.
539
720
  assert order == [
540
721
  ("record", kind, "/tmp/tmux-1000/default,1234,0"),
722
+ ("source", kind, str(tmp_path), "/tmp/tmux-1000/default,1234,0"),
541
723
  ("exec", kind),
542
724
  ]
543
725
 
@@ -684,6 +866,7 @@ def test_launch_agent_records_profile_before_exec(tmp_path, monkeypatch):
684
866
  profile="Claude (Z.AI)",
685
867
  execvpe=lambda f, a, e: None,
686
868
  record_kind=fake_record,
869
+ record_source=lambda *a, **kw: True,
687
870
  )
688
871
  assert recorded == {"kind": "claude", "profile": "Claude (Z.AI)"}
689
872
 
@@ -692,7 +875,7 @@ def test_cli_agent_named_profile_records_profile_option(tmp_path, monkeypatch):
692
875
  """End-to-end #858: launching claude via the z.ai profile inside tmux
693
876
  writes BOTH @ps_agent_kind=claude AND @ps_agent_profile=<name> before
694
877
  exec, via the real --profile resolution path."""
695
- home = _seed_zlaude_home(tmp_path, monkeypatch)
878
+ _seed_zlaude_home(tmp_path, monkeypatch)
696
879
  workdir = tmp_path / "work"
697
880
  workdir.mkdir()
698
881
  monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
@@ -793,7 +976,8 @@ def test_cli_agent_default_then_zai_relaunch_sets_profile(tmp_path, monkeypatch)
793
976
  )
794
977
  assert ["tmux", "set-option", "@ps_agent_profile", "Claude (Z.AI)"] in zai_calls
795
978
  assert not any(
796
- argv[:3] == ["tmux", "set-option", "-uq"] for argv in zai_calls
979
+ argv[:4] == ["tmux", "set-option", "-uq", "@ps_agent_profile"]
980
+ for argv in zai_calls
797
981
  )
798
982
 
799
983
 
File without changes
File without changes
File without changes