codex-manager 8.0.0__tar.gz → 9.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {codex_manager-8.0.0 → codex_manager-9.0.0}/PKG-INFO +1 -1
  2. {codex_manager-8.0.0 → codex_manager-9.0.0}/pyproject.toml +1 -1
  3. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/__init__.py +1 -1
  4. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/account_status.py +13 -8
  5. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/cli.py +2 -3
  6. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/cooldown.py +22 -8
  7. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/status.py +12 -5
  8. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/PKG-INFO +1 -1
  9. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_account_status_coverage.py +22 -0
  10. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_more.py +26 -0
  11. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cooldown.py +19 -0
  12. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_status2.py +20 -0
  13. {codex_manager-8.0.0 → codex_manager-9.0.0}/README.md +0 -0
  14. {codex_manager-8.0.0 → codex_manager-9.0.0}/setup.cfg +0 -0
  15. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/args.py +0 -0
  16. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/backup.py +0 -0
  17. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/cloud.py +0 -0
  18. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/config.py +0 -0
  19. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/credentials.py +0 -0
  20. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/doctor.py +0 -0
  21. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/list_backups.py +0 -0
  22. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/profile.py +0 -0
  23. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/prune.py +0 -0
  24. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/prune_backups.py +0 -0
  25. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/purge.py +0 -0
  26. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/recommend.py +0 -0
  27. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/registry.py +0 -0
  28. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/remove.py +0 -0
  29. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/restore.py +0 -0
  30. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/sync.py +0 -0
  31. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/ui.py +0 -0
  32. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/use_account.py +0 -0
  33. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager/utils.py +0 -0
  34. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/SOURCES.txt +0 -0
  35. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/dependency_links.txt +0 -0
  36. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/entry_points.txt +0 -0
  37. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/requires.txt +0 -0
  38. {codex_manager-8.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/top_level.txt +0 -0
  39. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_account_status_more2.py +0 -0
  40. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_args_cli.py +0 -0
  41. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_args_cli_3.py +0 -0
  42. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_args_cli_extra.py +0 -0
  43. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_backup.py +0 -0
  44. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_backup2.py +0 -0
  45. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_backup_ux.py +0 -0
  46. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli2.py +0 -0
  47. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage.py +0 -0
  48. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_2.py +0 -0
  49. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_3.py +0 -0
  50. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_4.py +0 -0
  51. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_5.py +0 -0
  52. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_6.py +0 -0
  53. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_7.py +0 -0
  54. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_8.py +0 -0
  55. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_extra.py +0 -0
  56. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cloud.py +0 -0
  57. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_config.py +0 -0
  58. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_cooldown2.py +0 -0
  59. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_credentials2.py +0 -0
  60. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_doctor.py +0 -0
  61. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_doctor2.py +0 -0
  62. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_dry_run_coverage.py +0 -0
  63. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_list_backups.py +0 -0
  64. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_list_backups2.py +0 -0
  65. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_list_backups3.py +0 -0
  66. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_list_backups_coverage.py +0 -0
  67. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_next_gen_upgrade.py +0 -0
  68. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_profile.py +0 -0
  69. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_prune.py +0 -0
  70. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_prune_backups.py +0 -0
  71. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_prune_backups2.py +0 -0
  72. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_purge.py +0 -0
  73. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_recommend.py +0 -0
  74. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_recommend2.py +0 -0
  75. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_registry_coverage_more.py +0 -0
  76. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_registry_dry_run_coverage.py +0 -0
  77. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_remove.py +0 -0
  78. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_restore.py +0 -0
  79. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_restore2.py +0 -0
  80. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_status.py +0 -0
  81. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_status3.py +0 -0
  82. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_sync.py +0 -0
  83. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_sync2.py +0 -0
  84. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_ui.py +0 -0
  85. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_ui_2.py +0 -0
  86. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_use.py +0 -0
  87. {codex_manager-8.0.0 → codex_manager-9.0.0}/tests/test_use2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-manager
3
- Version: 8.0.0
3
+ Version: 9.0.0
4
4
  Summary: Codex account snapshot manager
5
5
  Author-email: Dhruv <dhruv13x@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-manager"
3
- version = "8.0.0"
3
+ version = "9.0.0"
4
4
  description = "Codex account snapshot manager"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "8.0.0"
3
+ __version__ = "9.0.0"
@@ -30,6 +30,10 @@ def patch_metadata(
30
30
  # We will compute the final reset_at and session_start_at to save to registry
31
31
  final_reset_at = reset_at
32
32
  final_session_start_at = session_start_at
33
+ if is_expired and final_reset_at is None:
34
+ final_reset_at = datetime.now().astimezone()
35
+ if is_expired and final_session_start_at is None and final_reset_at is not None:
36
+ final_session_start_at = final_reset_at - timedelta(days=7)
33
37
 
34
38
  if backup_dir.exists():
35
39
  # Find any metadata file containing this email
@@ -151,15 +155,16 @@ def patch_metadata(
151
155
  try:
152
156
  cp.download_file(metadata_name, local_metadata)
153
157
  data = json.loads(local_metadata.read_text(encoding="utf-8"))
154
- data["reset_at"] = (
155
- reset_at.isoformat() if hasattr(reset_at, "isoformat") else str(reset_at)
156
- )
157
- data["next_available_at"] = data["reset_at"]
158
- if session_start_at:
158
+ if final_reset_at is not None:
159
+ data["reset_at"] = (
160
+ final_reset_at.isoformat() if hasattr(final_reset_at, "isoformat") else str(final_reset_at)
161
+ )
162
+ data["next_available_at"] = data["reset_at"]
163
+ if final_session_start_at:
159
164
  data["session_start_at"] = (
160
- session_start_at.isoformat()
161
- if hasattr(session_start_at, "isoformat")
162
- else str(session_start_at)
165
+ final_session_start_at.isoformat()
166
+ if hasattr(final_session_start_at, "isoformat")
167
+ else str(final_session_start_at)
163
168
  )
164
169
  data["quota_text"] = quota_text
165
170
  if quota_percent_left is not None:
@@ -47,12 +47,11 @@ def list_entries_from_args(args: Any) -> list[BackupEntry]:
47
47
  )
48
48
  )
49
49
 
50
- if getattr(args, "cloud", False) or force_latest:
50
+ if getattr(args, "cloud", False):
51
51
  cp = get_cloud_provider(args)
52
52
  if cp:
53
53
  # Sync registry (cooldown.json) when cloud is enabled
54
- if getattr(args, "cloud", False):
55
- sync_registry_with_cloud(cp)
54
+ sync_registry_with_cloud(cp)
56
55
 
57
56
  all_entries.extend(
58
57
  list_cloud_backups(
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from datetime import datetime
4
+ from datetime import datetime, timedelta
5
5
 
6
6
  from .list_backups import BackupEntry
7
7
 
@@ -67,10 +67,12 @@ def evaluate_records(
67
67
  current = now.astimezone() if now is not None else datetime.now().astimezone()
68
68
 
69
69
  for email, reg_entry in registry_data.items():
70
- if "reset_at" not in reg_entry or "updated_at" not in reg_entry:
70
+ if "updated_at" not in reg_entry:
71
71
  continue
72
72
 
73
73
  reg_updated_at = parse_iso_datetime(reg_entry["updated_at"])
74
+ reg_reset_at = reg_entry.get("reset_at")
75
+ reg_is_expired = reg_entry.get("is_expired", False)
74
76
 
75
77
  # Check if we already have a status for this email from backups
76
78
  existing_idx = next((i for i, s in enumerate(statuses) if s.email == email), None)
@@ -79,8 +81,14 @@ def evaluate_records(
79
81
  existing_status = statuses[existing_idx]
80
82
  # If registry is newer, update the status
81
83
  if reg_updated_at > existing_status.quota_end_detected_at:
82
- next_available_at = parse_iso_datetime(reg_entry["reset_at"])
83
- session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_entry["reset_at"]))
84
+ if reg_reset_at is not None:
85
+ next_available_at = parse_iso_datetime(reg_reset_at)
86
+ session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_reset_at))
87
+ elif reg_is_expired:
88
+ next_available_at = existing_status.next_available_at
89
+ session_start_at = existing_status.session_start_at
90
+ else:
91
+ continue
84
92
  remaining_seconds = int((next_available_at - current).total_seconds())
85
93
  statuses[existing_idx] = CooldownStatus(
86
94
  email=email,
@@ -93,12 +101,18 @@ def evaluate_records(
93
101
  remaining_seconds=max(0, remaining_seconds),
94
102
  quota_text=reg_entry.get("quota_text"),
95
103
  quota_percent_left=reg_entry.get("quota_percent_left"),
96
- is_expired=reg_entry.get("is_expired", False)
104
+ is_expired=reg_is_expired
97
105
  )
98
106
  else:
99
107
  # Create a new status from registry
100
- next_available_at = parse_iso_datetime(reg_entry["reset_at"])
101
- session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_entry["reset_at"]))
108
+ if reg_reset_at is not None:
109
+ next_available_at = parse_iso_datetime(reg_reset_at)
110
+ session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_reset_at))
111
+ elif reg_is_expired:
112
+ next_available_at = reg_updated_at
113
+ session_start_at = reg_updated_at - timedelta(days=7)
114
+ else:
115
+ continue
102
116
  remaining_seconds = int((next_available_at - current).total_seconds())
103
117
  statuses.append(
104
118
  CooldownStatus(
@@ -112,7 +126,7 @@ def evaluate_records(
112
126
  remaining_seconds=max(0, remaining_seconds),
113
127
  quota_text=reg_entry.get("quota_text"),
114
128
  quota_percent_left=reg_entry.get("quota_percent_left"),
115
- is_expired=reg_entry.get("is_expired", False)
129
+ is_expired=reg_is_expired
116
130
  )
117
131
  )
118
132
 
@@ -109,11 +109,14 @@ def capture_tmux_status_text(
109
109
  if has_session:
110
110
  run_command(["tmux", "kill-session", "-t", session_name], check=False)
111
111
 
112
- run_command(
112
+ session = run_command(
113
113
  [
114
114
  "tmux",
115
115
  "new-session",
116
116
  "-d",
117
+ "-P",
118
+ "-F",
119
+ "#{pane_id}",
117
120
  "-s",
118
121
  session_name,
119
122
  "-x",
@@ -123,12 +126,16 @@ def capture_tmux_status_text(
123
126
  codex_command,
124
127
  ]
125
128
  )
129
+ pane_id = session.stdout.strip()
130
+ if not pane_id:
131
+ raise RuntimeError("tmux did not return a pane id for the temporary capture session.")
132
+ run_command(["tmux", "set-option", "-t", session_name, "remain-on-exit", "on"])
126
133
 
127
134
  try:
128
135
  start = time.time()
129
136
  with console.status("[cyan]Waiting for Codex prompt...[/cyan]", spinner="dots"):
130
137
  while True:
131
- output = run_command(["tmux", "capture-pane", "-t", session_name, "-p"]).stdout
138
+ output = run_command(["tmux", "capture-pane", "-t", pane_id, "-p"]).stdout
132
139
  if "›" in output:
133
140
  break
134
141
 
@@ -136,13 +143,13 @@ def capture_tmux_status_text(
136
143
  raise RuntimeError("Timed out waiting for Codex prompt.")
137
144
  time.sleep(0.5)
138
145
 
139
- run_command(["tmux", "send-keys", "-t", session_name, "/status", "Enter"])
146
+ run_command(["tmux", "send-keys", "-t", pane_id, "/status", "Enter"])
140
147
 
141
148
  start = time.time()
142
149
  last_retry = start
143
150
  with console.status("[cyan]Checking Codex status...[/cyan]", spinner="dots"):
144
151
  while True:
145
- output = run_command(["tmux", "capture-pane", "-t", session_name, "-p"]).stdout
152
+ output = run_command(["tmux", "capture-pane", "-t", pane_id, "-p"]).stdout
146
153
 
147
154
  # If we have the full panel, return it
148
155
  if "Account:" in output and "Weekly limit:" in output:
@@ -163,7 +170,7 @@ def capture_tmux_status_text(
163
170
 
164
171
  # Periodic retry of /status if it seems stuck or refreshing
165
172
  if time.time() - last_retry > 5.0:
166
- run_command(["tmux", "send-keys", "-t", session_name, "/status", "Enter"])
173
+ run_command(["tmux", "send-keys", "-t", pane_id, "/status", "Enter"])
167
174
  last_retry = time.time()
168
175
 
169
176
  time.sleep(0.5)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-manager
3
- Version: 8.0.0
3
+ Version: 9.0.0
4
4
  Summary: Codex account snapshot manager
5
5
  Author-email: Dhruv <dhruv13x@gmail.com>
6
6
  License: MIT
@@ -55,6 +55,28 @@ def test_patch_metadata_fallback_datetime_parsing(mocker, tmp_path) -> None:
55
55
  mock_update.assert_called_once()
56
56
 
57
57
 
58
+ def test_patch_metadata_expired_without_existing_metadata_sets_registry_times(mocker, tmp_path) -> None:
59
+ class Args:
60
+ pass
61
+
62
+ args = Args()
63
+ args.backup_dir = str(tmp_path / "missing-backups")
64
+
65
+ mock_update = mocker.patch("codex_manager.account_status.update_registry_entry")
66
+
67
+ patch_metadata(
68
+ "expired@example.com",
69
+ quota_text="TOKEN EXPIRED: Re-login required.",
70
+ args=args,
71
+ is_expired=True,
72
+ )
73
+
74
+ call = mock_update.call_args.kwargs
75
+ assert call["is_expired"] is True
76
+ assert call["reset_at"] is not None
77
+ assert call["session_start_at"] is not None
78
+
79
+
58
80
 
59
81
  def test_sync_current_account_status_no_email(tmp_path, capsys):
60
82
  class Args:
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import pytest
4
+ from unittest.mock import MagicMock
4
5
 
5
6
  from codex_manager.cli import (
6
7
  _ensure_cloud_archive,
7
8
  _read_status_command_input,
8
9
  handle_status,
10
+ handle_recommend,
9
11
  list_entries_from_args,
10
12
  )
11
13
  from codex_manager.status import TokenExpiredError
@@ -67,3 +69,27 @@ def test_handle_status_token_expired(mocker, capsys):
67
69
 
68
70
  with pytest.raises(SystemExit):
69
71
  handle_status(args)
72
+
73
+
74
+ def test_handle_recommend_without_cloud_does_not_fetch_cloud(mocker, tmp_path):
75
+ class Args:
76
+ command = "recommend"
77
+ backup_dir = str(tmp_path)
78
+ cloud = False
79
+ live = False
80
+
81
+ args = Args()
82
+ mocker.patch("codex_manager.cli.list_backups", return_value=[])
83
+ mock_cloud = mocker.patch("codex_manager.cli.list_cloud_backups")
84
+ recommendation = MagicMock()
85
+ recommendation.selected.email = "test@example.com"
86
+ recommendation.selected.status = "ready"
87
+ recommendation.selected.remaining_seconds = 0
88
+ recommendation.selected.next_available_at.strftime.return_value = "2026-04-29 00:00:00 +0000"
89
+ recommendation.selected.validation_status = "backup"
90
+ recommendation.reason = "ready now"
91
+ mocker.patch("codex_manager.cli.choose_best_account", return_value=recommendation)
92
+
93
+ handle_recommend(args)
94
+
95
+ mock_cloud.assert_not_called()
@@ -83,6 +83,25 @@ def test_evaluate_records_merges_live_status(mock_reg) -> None:
83
83
  assert statuses[0].next_available_at.day == 20
84
84
 
85
85
 
86
+ @patch(
87
+ "codex_manager.registry.load_registry",
88
+ return_value={
89
+ "expired@example.com": {
90
+ "updated_at": "2026-04-21T16:00:00+00:00",
91
+ "is_expired": True,
92
+ "quota_text": "TOKEN EXPIRED: Re-login required.",
93
+ }
94
+ },
95
+ )
96
+ def test_evaluate_records_includes_expired_registry_entries_without_reset_at(mock_reg) -> None:
97
+ statuses = evaluate_records([], now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
98
+ assert len(statuses) == 1
99
+ assert statuses[0].email == "expired@example.com"
100
+ assert statuses[0].validation_status == "registry"
101
+ assert statuses[0].is_expired is True
102
+ assert statuses[0].status == "ready"
103
+
104
+
86
105
  def test_format_remaining() -> None:
87
106
  assert format_remaining(0) == "now"
88
107
  assert format_remaining(5400) == "1h 30m"
@@ -56,6 +56,8 @@ def test_capture_tmux_status_text_timeout(mock_sleep, mock_run):
56
56
  @patch("codex_manager.status.time.sleep")
57
57
  def test_capture_tmux_status_text_success(mock_sleep, mock_run):
58
58
  def mock_run_side_effect(args, **kwargs):
59
+ if args[:3] == ["tmux", "new-session", "-d"]:
60
+ return MagicMock(stdout="%42")
59
61
  if "capture-pane" in args:
60
62
  # We need to simulate the prompt '›' appearing, then the status appearing.
61
63
  # Let's count how many times capture-pane is called
@@ -72,11 +74,16 @@ def test_capture_tmux_status_text_success(mock_sleep, mock_run):
72
74
  mock_run.side_effect = mock_run_side_effect
73
75
  res = capture_tmux_status_text(startup_timeout_seconds=1.0, status_timeout_seconds=1.0)
74
76
  assert "Account: a@b.com" in res
77
+ capture_targets = [call.args[0] for call in mock_run.call_args_list if "capture-pane" in call.args[0]]
78
+ assert capture_targets
79
+ assert all("%42" in args for args in capture_targets)
75
80
 
76
81
  @patch("codex_manager.status.run_command")
77
82
  @patch("codex_manager.status.time.sleep")
78
83
  def test_capture_tmux_status_text_retry_and_timeout(mock_sleep, mock_run):
79
84
  def mock_run_side_effect(args, **kwargs):
85
+ if args[:3] == ["tmux", "new-session", "-d"]:
86
+ return MagicMock(stdout="%42")
80
87
  if "capture-pane" in args:
81
88
  # First phase: '›'
82
89
  # Second phase: status timeout
@@ -98,6 +105,19 @@ def test_capture_tmux_status_text_retry_and_timeout(mock_sleep, mock_run):
98
105
  with pytest.raises(RuntimeError):
99
106
  capture_tmux_status_text(startup_timeout_seconds=10.0, status_timeout_seconds=5.0)
100
107
 
108
+
109
+ @patch("codex_manager.status.run_command")
110
+ @patch("codex_manager.status.time.sleep")
111
+ def test_capture_tmux_status_text_requires_pane_id(mock_sleep, mock_run):
112
+ def mock_run_side_effect(args, **kwargs):
113
+ if args[:3] == ["tmux", "new-session", "-d"]:
114
+ return MagicMock(stdout="")
115
+ return MagicMock()
116
+
117
+ mock_run.side_effect = mock_run_side_effect
118
+ with pytest.raises(RuntimeError, match="pane id"):
119
+ capture_tmux_status_text(startup_timeout_seconds=1.0, status_timeout_seconds=1.0)
120
+
101
121
  @patch("codex_manager.status.subprocess.run")
102
122
  def test_run_command_fail(mock_run):
103
123
  mock_run.return_value = MagicMock(returncode=1)
File without changes
File without changes