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.
- {pocketshell-0.4.18 → pocketshell-0.4.20}/PKG-INFO +1 -1
- {pocketshell-0.4.18 → pocketshell-0.4.20}/pyproject.toml +1 -1
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agents.py +217 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agents.py +188 -4
- {pocketshell-0.4.18 → pocketshell-0.4.20}/.gitignore +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/README.md +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/scheduler/README.md +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agent_card_push.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/agents_kind.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/cards.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/cgroup_agents.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/tree.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/__init__.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agent_card_push.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_agents_kind.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_cards.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_cgroup_agents.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_cli.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_daemon.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_env.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_github.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_logs.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_push.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_repos.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_resume.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_tree.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_usage.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_usage_reset.py +0 -0
- {pocketshell-0.4.18 → pocketshell-0.4.20}/tests/test_watch_ci.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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[:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|