cdx-manager 0.5.5 → 0.5.7

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.5.7-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -37,6 +37,7 @@ One command to launch any session. Zero auth juggling.
37
37
  - **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
38
38
  - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, last-updated timestamps, priority guidance, and the last launched session in one aligned table.
39
39
  - **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
40
+ - **Persistent launch settings.** Pin per-session power, permission, and fast-mode preferences once; `cdx` reapplies them on every launch until you unset them.
40
41
  - **Shared handoff context.** Keep a per-workspace Markdown context, or build one from a source session transcript, and install it into another assistant session before switching providers or accounts.
41
42
  - **Passive status resolution.** Codex status is read from the local Codex app-server rate-limit API when available, with legacy transcript/history parsing kept as a fallback.
42
43
  - **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
@@ -126,7 +127,7 @@ For a specific version:
126
127
 
127
128
  ```bash
128
129
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
129
- CDX_VERSION=v0.5.5 sh install.sh
130
+ CDX_VERSION=v0.5.7 sh install.sh
130
131
  ```
131
132
 
132
133
  From source:
@@ -231,6 +232,25 @@ cdx work
231
232
  cdx status
232
233
  ```
233
234
 
235
+ ### Persistent Launch Settings
236
+
237
+ By default, `cdx` launches provider CLIs without forcing model effort, permission mode, or fast behavior. Set only the values you want to pin:
238
+
239
+ ```bash
240
+ cdx set work --power medium --permission full --fast off
241
+ cdx set personal --power low --permission review
242
+ cdx config work
243
+ ```
244
+
245
+ Those values are stored on the session and reapplied every time you run `cdx work`. Remove overrides to return to provider defaults:
246
+
247
+ ```bash
248
+ cdx unset work --power
249
+ cdx unset work --all
250
+ ```
251
+
252
+ `--power` maps to Codex `model_reasoning_effort` and Claude `--effort`. `--permission` maps to provider-native permission flags. `--fast on` uses low effort when no explicit power is set.
253
+
234
254
  ---
235
255
 
236
256
  ## All Commands
@@ -248,6 +268,9 @@ cdx status
248
268
  | `cdx logout <name> [--json]` | Log out of a session |
249
269
  | `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
250
270
  | `cdx enable <name> [--json]` | Re-enable a disabled session |
271
+ | `cdx config <name> [--json]` | Show persistent launch settings for a session |
272
+ | `cdx set <name> [--power low\|medium\|high\|xhigh\|max] [--permission review\|default\|auto\|full] [--fast on\|off] [--json]` | Persist launch settings for a session |
273
+ | `cdx unset <name> (--power\|--permission\|--fast\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
251
274
  | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
252
275
  | `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
253
276
  | `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports Codex and Claude targets, including cross-provider handoff |
@@ -258,8 +281,8 @@ cdx status
258
281
  | `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
259
282
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
260
283
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
261
- | `cdx notify <name> --at-reset [--poll seconds] [--once] [--json]` | Wait for a session reset time and send a desktop notification when due |
262
- | `cdx notify --next-ready [--poll seconds] [--once] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
284
+ | `cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait for a session reset time or schedule an OS wake-up notification when due |
285
+ | `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
263
286
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
264
287
  | `cdx status --small [--refresh]` / `cdx status -s [--refresh]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
265
288
  | `cdx status <name> [--json] [--refresh]` | Show detailed usage breakdown for one session |
@@ -0,0 +1,42 @@
1
+ # Changelog (`0.5.5 -> 0.5.6`)
2
+
3
+ Release date: 2026-05-24
4
+
5
+ ## Major Highlights
6
+
7
+ - Reduced noisy `cdx status` progress output by respecting fresh cached status rows.
8
+ - Stopped probing disabled sessions during status resolution and hid stale quota metrics for disabled rows.
9
+ - Refined `cdx notify --next-ready` so it waits for the next blocked account reset instead of notifying for an account that is already usable.
10
+
11
+ ## Status Output
12
+
13
+ - Added status-row cache handling so recently resolved rows do not print redundant `Checking ...` progress lines.
14
+ - Kept Claude status cache behavior aligned with the Claude refresh TTL, avoiding unnecessary Claude checks while cached usage is still fresh.
15
+ - Treated disabled sessions as display-only in `cdx status`; disabled sessions remain visible but are not refreshed or re-read from status artifacts.
16
+ - Rendered disabled-session quota columns as `-` for `OK`, `5H`, `WEEK`, `BLOCK`, `CR`, `RESET 5H`, and `RESET WEEK`.
17
+ - Preserved the stored last status internally so re-enabled sessions can resume normal status behavior.
18
+
19
+ ## Notifications
20
+
21
+ - Added `--refresh` support to `cdx notify` so notification checks can force status refreshes when requested.
22
+ - Added text-mode progress output for notification checks while keeping `--json` output clean.
23
+ - Made `cdx notify --next-ready` ignore disabled sessions.
24
+ - Made `cdx notify --next-ready` ignore sessions that are already usable and instead target the next currently blocked session with a known reset.
25
+ - Returned `No upcoming session reset available` when there is no blocked session with a reset to wait for.
26
+
27
+ ## Documentation
28
+
29
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.6`.
30
+ - Documented `--refresh` for `cdx notify` command examples.
31
+
32
+ ## Validation and Regression Coverage
33
+
34
+ - Added regression coverage for cached Codex status rows suppressing redundant checking progress.
35
+ - Added regression coverage for Claude cache TTL behavior.
36
+ - Added regression coverage for disabled sessions skipping refresh and hiding public status metrics.
37
+ - Added regression coverage for `notify --next-ready` ignoring already-available and disabled sessions.
38
+
39
+ ## Validation and Regression Evidence
40
+
41
+ - `npm run test:py`
42
+ - `./bin/cdx status`
@@ -0,0 +1,45 @@
1
+ # Changelog (`0.5.6 -> 0.5.7`)
2
+
3
+ Release date: 2026-05-27
4
+
5
+ ## Major Highlights
6
+
7
+ - Added persistent per-session launch settings for Codex and Claude assistants.
8
+ - Let users pin power, permission, and fast-mode preferences once and have `cdx` reapply them on every launch.
9
+ - Preserved native provider defaults when no launch settings are configured.
10
+
11
+ ## Launch Settings
12
+
13
+ - Added `cdx set <name>` with `--power`, `--permission`, and `--fast` options.
14
+ - Added `cdx config <name>` to inspect the launch settings stored on a session.
15
+ - Added `cdx unset <name>` to clear individual launch settings or all overrides with `--all`.
16
+ - Stored launch settings directly on each CDX session record.
17
+ - Copied launch settings when copying a session.
18
+ - Exported and imported launch settings with session bundles.
19
+
20
+ ## Provider Runtime
21
+
22
+ - Mapped Codex power settings to `model_reasoning_effort`.
23
+ - Mapped Claude power settings to `--effort`.
24
+ - Mapped Codex permission presets to sandbox and approval flags.
25
+ - Mapped Claude permission presets to `--permission-mode`.
26
+ - Treated `--fast on` as low effort when no explicit power setting is configured.
27
+
28
+ ## CLI Output and Documentation
29
+
30
+ - Added launch settings to `cdx --help`.
31
+ - Added a compact launch-settings column to `cdx` session listings when any session has overrides.
32
+ - Documented the persistent launch-settings workflow in the README.
33
+ - Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.5.7`.
34
+
35
+ ## Validation and Regression Coverage
36
+
37
+ - Added regression coverage for persistent Codex launch settings and `unset --all`.
38
+ - Added regression coverage for persistent Claude launch settings and `cdx config`.
39
+ - Added runtime coverage for `fast on` resolving to low effort when no power is configured.
40
+
41
+ ## Validation and Regression Evidence
42
+
43
+ - `python -m py_compile bin/cdx src/*.py test/test_*_py.py`
44
+ - `python -m unittest discover -s test -p 'test_*_py.py'`
45
+ - `npm pack --dry-run`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -25,6 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "bin",
28
+ "!bin/__pycache__",
29
+ "!bin/**/*.pyc",
28
30
  "checksums",
29
31
  "changelogs",
30
32
  "src",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.5.5"
7
+ version = "0.5.7"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -9,6 +9,7 @@ from .cli_commands import (
9
9
  STATUS_USAGE,
10
10
  handle_add,
11
11
  handle_clean,
12
+ handle_config,
12
13
  handle_context,
13
14
  handle_copy,
14
15
  handle_doctor,
@@ -25,6 +26,8 @@ from .cli_commands import (
25
26
  handle_repair,
26
27
  handle_rename,
27
28
  handle_status,
29
+ handle_set,
30
+ handle_unset,
28
31
  handle_update,
29
32
  )
30
33
  from .cli_render import (
@@ -49,7 +52,7 @@ from .status_view import (
49
52
  )
50
53
  from .update_check import check_for_update
51
54
 
52
- VERSION = "0.5.5"
55
+ VERSION = "0.5.7"
53
56
 
54
57
 
55
58
  # ---------------------------------------------------------------------------
@@ -67,6 +70,9 @@ def _print_help(use_color=False):
67
70
  f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
68
71
  f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
69
72
  f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
73
+ f" {_style('cdx config <name> [--json]', '36', use_color)}",
74
+ f" {_style('cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]', '36', use_color)}",
75
+ f" {_style('cdx unset <name> (--power|--permission|--fast|--all) [--json]', '36', use_color)}",
70
76
  f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
71
77
  f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
72
78
  f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
@@ -83,8 +89,8 @@ def _print_help(use_color=False):
83
89
  f" {_style('cdx doctor [--json]', '36', use_color)}",
84
90
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
85
91
  f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
86
- f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
87
- f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
92
+ f" {_style('cdx notify <name> --at-reset [--schedule] [--refresh] [--json]', '36', use_color)}",
93
+ f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
88
94
  f" {_style('cdx <name> [--json]', '36', use_color)}",
89
95
  f" {_style('cdx --help', '36', use_color)}",
90
96
  f" {_style('cdx --version', '36', use_color)}",
@@ -221,7 +227,7 @@ def main(argv, options=None):
221
227
  "version": VERSION,
222
228
  "cwd": options.get("cwd") or os.getcwd(),
223
229
  "update_notice": _get_update_notice(service, env, options) if command not in (
224
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
230
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "config", "set", "unset", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
225
231
  ) else None,
226
232
  "use_color": use_color,
227
233
  }
@@ -268,6 +274,15 @@ def main(argv, options=None):
268
274
  if command == "context":
269
275
  return handle_context(rest, ctx)
270
276
 
277
+ if command == "config":
278
+ return handle_config(rest, ctx)
279
+
280
+ if command == "set":
281
+ return handle_set(rest, ctx)
282
+
283
+ if command == "unset":
284
+ return handle_unset(rest, ctx)
285
+
271
286
  if command == "handoff":
272
287
  return handle_handoff(rest, ctx)
273
288
 
@@ -3,6 +3,7 @@ import getpass
3
3
  import json
4
4
  import os
5
5
  import sys
6
+ import time
6
7
  from datetime import datetime
7
8
 
8
9
  from .claude_refresh import _refresh_claude_sessions
@@ -21,7 +22,10 @@ from .errors import CdxError
21
22
  from .health import collect_health_report, format_health_report
22
23
  from .notify import (
23
24
  format_notify_event,
25
+ format_scheduled_notification,
24
26
  parse_notify_args,
27
+ resolve_notify_event,
28
+ schedule_notification_event,
25
29
  send_desktop_notification,
26
30
  wait_for_notification_event,
27
31
  )
@@ -45,6 +49,9 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
45
49
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
46
50
  CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
47
51
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
52
+ SET_USAGE = "Usage: cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]"
53
+ UNSET_USAGE = "Usage: cdx unset <name> (--power|--permission|--fast|--all) [--json]"
54
+ CONFIG_USAGE = "Usage: cdx config <name> [--json]"
48
55
  API_SCHEMA_VERSION = 1
49
56
  HANDOFF_TRANSCRIPT_CHARS = 120000
50
57
 
@@ -139,6 +146,25 @@ def _make_status_progress(ctx):
139
146
  return progress
140
147
 
141
148
 
149
+ def _make_notify_progress(ctx):
150
+ def progress(event):
151
+ kind = event.get("event")
152
+ if kind == "notify_check_started":
153
+ target = event.get("session_name") or "next ready session"
154
+ ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
155
+ elif kind == "status_started":
156
+ message = f"Loading status for {event.get('session_count', 0)} session(s)..."
157
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
158
+ elif kind == "session_started":
159
+ provider = event.get("provider") or "session"
160
+ message = f"Checking {event.get('session_name')} ({provider})..."
161
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
162
+ elif kind == "notify_waiting":
163
+ message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
164
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
165
+ return progress
166
+
167
+
142
168
  def _latest_launch_transcript_path(session):
143
169
  paths = _list_launch_transcript_paths(session)
144
170
  if not paths:
@@ -283,6 +309,61 @@ def _parse_toggle_args(args, usage):
283
309
  return {"name": parsed["names"][0], "json": parsed["json"]}
284
310
 
285
311
 
312
+ def _parse_fast_value(value):
313
+ text = str(value).strip().lower()
314
+ if text in ("on", "true", "1", "yes"):
315
+ return True
316
+ if text in ("off", "false", "0", "no"):
317
+ return False
318
+ raise CdxError(SET_USAGE)
319
+
320
+
321
+ def _parse_set_args(args):
322
+ parsed = _parse_flag_args(args, {
323
+ "--power": {"key": "power", "type": "str", "default": None},
324
+ "--permission": {"key": "permission", "type": "str", "default": None},
325
+ "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
326
+ "--json": {"key": "json", "type": "bool", "default": False},
327
+ }, SET_USAGE, positionals_key="names", max_positionals=1)
328
+ if len(parsed["names"]) != 1:
329
+ raise CdxError(SET_USAGE)
330
+ settings = {
331
+ key: parsed[key]
332
+ for key in ("power", "permission", "fast")
333
+ if parsed[key] is not None
334
+ }
335
+ if not settings:
336
+ raise CdxError(SET_USAGE)
337
+ return {"name": parsed["names"][0], "settings": settings, "json": parsed["json"]}
338
+
339
+
340
+ def _parse_unset_args(args):
341
+ parsed = _parse_flag_args(args, {
342
+ "--power": {"key": "power", "type": "bool", "default": False},
343
+ "--permission": {"key": "permission", "type": "bool", "default": False},
344
+ "--fast": {"key": "fast", "type": "bool", "default": False},
345
+ "--all": {"key": "all", "type": "bool", "default": False},
346
+ "--json": {"key": "json", "type": "bool", "default": False},
347
+ }, UNSET_USAGE, positionals_key="names", max_positionals=1)
348
+ if len(parsed["names"]) != 1:
349
+ raise CdxError(UNSET_USAGE)
350
+ keys = ["power", "permission", "fast"] if parsed["all"] else [
351
+ key for key in ("power", "permission", "fast") if parsed[key]
352
+ ]
353
+ if not keys:
354
+ raise CdxError(UNSET_USAGE)
355
+ return {"name": parsed["names"][0], "keys": keys, "json": parsed["json"]}
356
+
357
+
358
+ def _parse_config_args(args):
359
+ parsed = _parse_flag_args(args, {
360
+ "--json": {"key": "json", "type": "bool", "default": False},
361
+ }, CONFIG_USAGE, positionals_key="names", max_positionals=1)
362
+ if len(parsed["names"]) != 1:
363
+ raise CdxError(CONFIG_USAGE)
364
+ return {"name": parsed["names"][0], "json": parsed["json"]}
365
+
366
+
286
367
  def _read_option_value(args, index, usage):
287
368
  if index + 1 >= len(args):
288
369
  raise CdxError(usage)
@@ -499,6 +580,53 @@ def handle_enable(rest, ctx):
499
580
  return 0
500
581
 
501
582
 
583
+ def _format_launch_config(session):
584
+ launch = session.get("launch") or {}
585
+ return "\n".join([
586
+ f"{session['name']} ({session['provider']})",
587
+ f"power: {launch.get('power') or 'default'}",
588
+ f"permission: {launch.get('permission') or 'default'}",
589
+ f"fast: {'on' if launch.get('fast') is True else 'off' if launch.get('fast') is False else 'default'}",
590
+ ])
591
+
592
+
593
+ def handle_set(rest, ctx):
594
+ parsed = _parse_set_args(rest)
595
+ session = ctx["service"]["set_launch_settings"](parsed["name"], parsed["settings"])
596
+ message = f"Updated launch settings for {parsed['name']}"
597
+ if parsed["json"]:
598
+ _write_json(ctx, _json_success("set", message, session=session, launch=session.get("launch") or {}))
599
+ return 0
600
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
601
+ ctx["out"](f"{_format_launch_config(session)}\n")
602
+ return 0
603
+
604
+
605
+ def handle_unset(rest, ctx):
606
+ parsed = _parse_unset_args(rest)
607
+ session = ctx["service"]["unset_launch_settings"](parsed["name"], parsed["keys"])
608
+ message = f"Cleared launch settings for {parsed['name']}"
609
+ if parsed["json"]:
610
+ _write_json(ctx, _json_success("unset", message, session=session, launch=session.get("launch") or {}))
611
+ return 0
612
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
613
+ ctx["out"](f"{_format_launch_config(session)}\n")
614
+ return 0
615
+
616
+
617
+ def handle_config(rest, ctx):
618
+ parsed = _parse_config_args(rest)
619
+ session = ctx["service"]["get_session"](parsed["name"])
620
+ if not session:
621
+ raise CdxError(f"Unknown session: {parsed['name']}")
622
+ message = f"Launch settings for {parsed['name']}"
623
+ if parsed["json"]:
624
+ _write_json(ctx, _json_success("config", message, session=session, launch=session.get("launch") or {}))
625
+ return 0
626
+ ctx["out"](f"{_format_launch_config(session)}\n")
627
+ return 0
628
+
629
+
502
630
  def handle_clean(rest, ctx):
503
631
  json_flag, args = _parse_json_flag(rest)
504
632
  service = ctx["service"]
@@ -620,12 +748,45 @@ def handle_notify(rest, ctx):
620
748
  env=ctx.get("env"),
621
749
  )
622
750
 
751
+ if parsed["schedule"]:
752
+ event = resolve_notify_event(
753
+ ctx["service"]["get_status_rows"](
754
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
755
+ force_refresh=parsed.get("refresh", False),
756
+ ),
757
+ parsed,
758
+ (ctx["options"].get("now") or time.time)(),
759
+ )
760
+ if event["ready"]:
761
+ notifier(event["title"], event["message"])
762
+ schedule = {
763
+ "scheduled": False,
764
+ "backend": "immediate",
765
+ "message": event["message"],
766
+ "target_timestamp": event.get("target_timestamp"),
767
+ }
768
+ else:
769
+ schedule = schedule_notification_event(
770
+ ctx["service"]["base_dir"],
771
+ parsed,
772
+ event,
773
+ spawn_sync=ctx.get("spawn_sync"),
774
+ env=ctx.get("env"),
775
+ now_fn=ctx["options"].get("now"),
776
+ )
777
+ if parsed["json"]:
778
+ _write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
779
+ else:
780
+ ctx["out"](f"{format_scheduled_notification(schedule)}\n")
781
+ return 0
782
+
623
783
  event = wait_for_notification_event(
624
784
  ctx["service"],
625
785
  parsed,
626
786
  notifier=notifier,
627
787
  sleep_fn=ctx["options"].get("sleep"),
628
788
  now_fn=ctx["options"].get("now"),
789
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
629
790
  )
630
791
  if parsed["json"]:
631
792
  _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
@@ -767,7 +928,10 @@ def handle_status(rest, ctx):
767
928
  ]
768
929
 
769
930
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
770
- rows = ctx["service"]["get_status_rows"](progress_callback=status_progress)
931
+ rows = ctx["service"]["get_status_rows"](
932
+ progress_callback=status_progress,
933
+ force_refresh=parsed["refresh"],
934
+ )
771
935
  if len(args) == 0:
772
936
  if parsed["json"]:
773
937
  _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
package/src/cli_render.py CHANGED
@@ -105,10 +105,13 @@ def format_error(error, env=None, stderr=None):
105
105
  def _format_sessions(service, use_color=False):
106
106
  rows = service["format_list_rows"]()
107
107
  has_provider = any(r.get("provider") for r in rows)
108
+ has_launch = any(r.get("launch") for r in rows)
108
109
  headers = ["SESSION"]
109
110
  if has_provider:
110
111
  headers.append("PROVIDER")
111
112
  headers.append("STATUS")
113
+ if has_launch:
114
+ headers.append("LAUNCH")
112
115
  headers.append("UPDATED")
113
116
  headers = [_style(header, "1", use_color) for header in headers]
114
117
  table_rows = []
@@ -118,6 +121,17 @@ def _format_sessions(service, use_color=False):
118
121
  parts.append(r.get("provider") or "n/a")
119
122
  status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
120
123
  parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
124
+ if has_launch:
125
+ launch = r.get("launch") or {}
126
+ if launch:
127
+ launch_text = "/".join([
128
+ launch.get("power") or "-",
129
+ launch.get("permission") or "-",
130
+ "fast-on" if launch.get("fast") is True else "fast-off" if launch.get("fast") is False else "-",
131
+ ])
132
+ else:
133
+ launch_text = "default"
134
+ parts.append(_dim(launch_text, use_color))
121
135
  parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
122
136
  table_rows.append(parts)
123
137
  lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
@@ -127,6 +141,8 @@ def _format_sessions(service, use_color=False):
127
141
  f" {_style('cdx <name>', '36', use_color)}",
128
142
  f" {_style('cdx login <name>', '36', use_color)}",
129
143
  f" {_style('cdx logout <name>', '36', use_color)}",
144
+ f" {_style('cdx config <name>', '36', use_color)}",
145
+ f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
130
146
  f" {_style('cdx context show', '36', use_color)}",
131
147
  f" {_style('cdx handoff <name>', '36', use_color)}",
132
148
  f" {_style('cdx handoff <source> <target>', '36', use_color)}",
package/src/notify.py CHANGED
@@ -1,10 +1,20 @@
1
1
  import json
2
2
  import os
3
+ import shlex
3
4
  import subprocess
4
5
  import time
6
+ from datetime import datetime, timedelta
5
7
 
6
8
  from .errors import CdxError
7
- from .status_view import _parse_reset_timestamp, _recommend_priority_sessions
9
+ from .status_view import (
10
+ PRIORITY_EMPTY_AVAILABLE_THRESHOLD,
11
+ _parse_reset_timestamp,
12
+ _priority_reset_timestamp,
13
+ _recommend_priority_sessions,
14
+ )
15
+
16
+
17
+ NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
8
18
 
9
19
 
10
20
  def parse_notify_args(args):
@@ -12,17 +22,19 @@ def parse_notify_args(args):
12
22
  once = "--once" in args
13
23
  at_reset = "--at-reset" in args
14
24
  next_ready = "--next-ready" in args
25
+ refresh = "--refresh" in args
26
+ schedule = "--schedule" in args
15
27
  poll = 60
16
28
  cleaned = []
17
29
  i = 0
18
30
  while i < len(args):
19
31
  arg = args[i]
20
- if arg in ("--json", "--once", "--at-reset", "--next-ready"):
32
+ if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh", "--schedule"):
21
33
  i += 1
22
34
  continue
23
35
  if arg == "--poll":
24
36
  if i + 1 >= len(args):
25
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
37
+ raise CdxError(NOTIFY_USAGE)
26
38
  try:
27
39
  poll = max(1, int(args[i + 1]))
28
40
  except ValueError as error:
@@ -37,48 +49,69 @@ def parse_notify_args(args):
37
49
  i += 1
38
50
  continue
39
51
  if arg.startswith("-"):
40
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
52
+ raise CdxError(NOTIFY_USAGE)
41
53
  cleaned.append(arg)
42
54
  i += 1
43
55
  if at_reset == next_ready:
44
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
56
+ raise CdxError(NOTIFY_USAGE)
45
57
  if at_reset and len(cleaned) != 1:
46
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once]")
58
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh]")
47
59
  if next_ready and cleaned:
48
- raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once]")
60
+ raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]")
49
61
  return {
50
62
  "name": cleaned[0] if cleaned else None,
51
63
  "mode": "at-reset" if at_reset else "next-ready",
52
64
  "poll": poll,
53
65
  "once": once,
54
66
  "json": json_flag,
67
+ "refresh": refresh,
68
+ "schedule": schedule,
55
69
  }
56
70
 
57
71
 
58
- def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None):
72
+ def wait_for_notification_event(service, parsed, notifier=None, sleep_fn=None, now_fn=None, progress_callback=None):
59
73
  notifier = notifier or send_desktop_notification
60
74
  sleep_fn = sleep_fn or time.sleep
61
75
  now_fn = now_fn or time.time
62
76
  while True:
63
- event = resolve_notify_event(service["get_status_rows"](), parsed, now_fn())
77
+ if progress_callback:
78
+ progress_callback({"event": "notify_check_started", "mode": parsed["mode"], "session_name": parsed["name"]})
79
+ event = resolve_notify_event(
80
+ service["get_status_rows"](
81
+ progress_callback=progress_callback,
82
+ force_refresh=parsed.get("refresh", False),
83
+ ),
84
+ parsed,
85
+ now_fn(),
86
+ )
64
87
  if event["ready"] or parsed["once"]:
65
88
  if event["ready"]:
66
89
  notifier(event["title"], event["message"])
67
90
  return event
91
+ if progress_callback:
92
+ progress_callback({
93
+ "event": "notify_waiting",
94
+ "message": event["message"],
95
+ "poll": parsed["poll"],
96
+ "session_name": event.get("session"),
97
+ "target_timestamp": event.get("target_timestamp"),
98
+ })
68
99
  sleep_fn(parsed["poll"])
69
100
 
70
101
 
71
102
  def resolve_notify_event(rows, parsed, now_ts=None):
72
103
  now_ts = time.time() if now_ts is None else now_ts
73
104
  if parsed["mode"] == "next-ready":
74
- priority = _recommend_priority_sessions(rows)
105
+ active_rows = [row for row in rows if row.get("enabled", True) is not False]
106
+ priority = _recommend_priority_sessions([
107
+ row for row in active_rows
108
+ if not _is_usable_now(row) and _priority_reset_timestamp(row) is not None
109
+ ])
75
110
  if not priority:
76
- return _event(False, "cdx", "No session status available", None)
111
+ return _event(False, "cdx", "No upcoming session reset available", None)
77
112
  first = priority[0]
78
- if _is_available(first):
79
- return _event(True, "cdx", f"{first['session_name']} is ready", first["session_name"])
80
- timestamp = _next_reset_timestamp(first)
81
- if timestamp is not None and timestamp <= now_ts:
113
+ timestamp = _priority_reset_timestamp(first)
114
+ if _needs_refresh(first, timestamp, now_ts):
82
115
  return _event(True, "cdx", f"{first['session_name']} reset is due; refresh status", first["session_name"])
83
116
  return _event(False, "cdx", f"Waiting for {first['session_name']}", first["session_name"], timestamp)
84
117
 
@@ -93,9 +126,16 @@ def resolve_notify_event(rows, parsed, now_ts=None):
93
126
  return _event(False, "cdx", f"Waiting for {row['session_name']} reset", row["session_name"], timestamp)
94
127
 
95
128
 
96
- def _is_available(row):
129
+ def _is_usable_now(row):
97
130
  value = row.get("available_pct")
98
- return value is not None and value > 0
131
+ return value is not None and value > PRIORITY_EMPTY_AVAILABLE_THRESHOLD
132
+
133
+
134
+ def _needs_refresh(row, reset_timestamp, now_ts):
135
+ value = row.get("available_pct")
136
+ if value is None or value > PRIORITY_EMPTY_AVAILABLE_THRESHOLD:
137
+ return False
138
+ return reset_timestamp is not None and reset_timestamp < now_ts
99
139
 
100
140
 
101
141
  def _next_reset_timestamp(row):
@@ -179,9 +219,225 @@ def _escape_applescript(value):
179
219
  return str(value).replace("\\", "\\\\").replace('"', '\\"')
180
220
 
181
221
 
222
+ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=None, now_fn=None):
223
+ import sys
224
+ spawn_sync = spawn_sync or subprocess.run
225
+ env = env or os.environ
226
+ now_fn = now_fn or time.time
227
+ target_timestamp = event.get("target_timestamp")
228
+ if target_timestamp is None:
229
+ raise CdxError(f"Cannot schedule notification: {event['message']}")
230
+ if target_timestamp <= now_fn():
231
+ return {
232
+ "scheduled": False,
233
+ "backend": "immediate",
234
+ "message": event["message"],
235
+ "target_timestamp": target_timestamp,
236
+ }
237
+
238
+ argv = _scheduled_notify_argv(parsed, env)
239
+ if sys.platform == "darwin":
240
+ result = _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env)
241
+ elif sys.platform == "win32":
242
+ result = _schedule_windows_task(parsed, event, argv, spawn_sync, env)
243
+ else:
244
+ result = _schedule_linux(parsed, event, argv, spawn_sync, env)
245
+ result["target_timestamp"] = target_timestamp
246
+ result["target_iso"] = _timestamp_to_local_iso(target_timestamp)
247
+ return result
248
+
249
+
250
+ def _scheduled_notify_argv(parsed, env):
251
+ executable = env.get("CDX_BIN") or shutil_which("cdx", env) or "cdx"
252
+ argv = [executable, "notify"]
253
+ if parsed["mode"] == "at-reset":
254
+ argv.append(parsed["name"])
255
+ argv.append("--at-reset")
256
+ else:
257
+ argv.append("--next-ready")
258
+ argv.append("--once")
259
+ argv.append("--refresh")
260
+ return argv
261
+
262
+
263
+ def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
264
+ label = _schedule_id("com.cdx-manager.notify", parsed, event)
265
+ schedule_dir = os.path.join(base_dir, "state", "notifications")
266
+ os.makedirs(schedule_dir, mode=0o700, exist_ok=True)
267
+ script_path = os.path.join(schedule_dir, f"{label}.sh")
268
+ launch_agents = os.path.join(os.path.expanduser("~"), "Library", "LaunchAgents")
269
+ os.makedirs(launch_agents, exist_ok=True)
270
+ plist_path = os.path.join(launch_agents, f"{label}.plist")
271
+ target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
272
+ script = _macos_schedule_script(argv, env, label, plist_path, script_path)
273
+ with open(script_path, "w", encoding="utf-8") as f:
274
+ f.write(script)
275
+ try:
276
+ os.chmod(script_path, 0o700)
277
+ except OSError:
278
+ pass
279
+ with open(plist_path, "w", encoding="utf-8") as f:
280
+ f.write(_launchd_plist(label, script_path, target))
281
+ result = _run_scheduler_command(["launchctl", "bootstrap", f"gui/{os.getuid()}", plist_path], spawn_sync, env)
282
+ if not result["ok"]:
283
+ if _scheduler_error_means_exists(result["error"]):
284
+ return {"scheduled": True, "existing": True, "backend": "launchd", "id": label, "path": plist_path}
285
+ result = _run_scheduler_command(["launchctl", "load", plist_path], spawn_sync, env)
286
+ if not result["ok"]:
287
+ if _scheduler_error_means_exists(result["error"]):
288
+ return {"scheduled": True, "existing": True, "backend": "launchd", "id": label, "path": plist_path}
289
+ raise CdxError(f"Failed to schedule notification with launchd: {result['error']}")
290
+ return {"scheduled": True, "existing": False, "backend": "launchd", "id": label, "path": plist_path}
291
+
292
+
293
+ def _macos_schedule_script(argv, env, label, plist_path, script_path):
294
+ lines = ["#!/bin/sh", f"export PATH={shlex.quote(env.get('PATH', '/usr/local/bin:/usr/bin:/bin'))}"]
295
+ if env.get("CDX_HOME"):
296
+ lines.append(f"export CDX_HOME={shlex.quote(env['CDX_HOME'])}")
297
+ lines.extend([
298
+ f"{' '.join(shlex.quote(str(part)) for part in argv)}",
299
+ "status=$?",
300
+ f"launchctl bootout {shlex.quote('gui/' + str(os.getuid()) + '/' + label)} >/dev/null 2>&1 || launchctl unload {shlex.quote(plist_path)} >/dev/null 2>&1 || true",
301
+ f"rm -f {shlex.quote(plist_path)} {shlex.quote(script_path)}",
302
+ "exit $status",
303
+ "",
304
+ ])
305
+ return "\n".join(lines)
306
+
307
+
308
+ def _launchd_plist(label, script_path, target):
309
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
310
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
311
+ <plist version="1.0">
312
+ <dict>
313
+ <key>Label</key>
314
+ <string>{_escape_xml(label)}</string>
315
+ <key>ProgramArguments</key>
316
+ <array>
317
+ <string>/bin/sh</string>
318
+ <string>{_escape_xml(script_path)}</string>
319
+ </array>
320
+ <key>StartCalendarInterval</key>
321
+ <dict>
322
+ <key>Month</key><integer>{target.month}</integer>
323
+ <key>Day</key><integer>{target.day}</integer>
324
+ <key>Hour</key><integer>{target.hour}</integer>
325
+ <key>Minute</key><integer>{target.minute}</integer>
326
+ </dict>
327
+ </dict>
328
+ </plist>
329
+ """
330
+
331
+
332
+ def _schedule_linux(parsed, event, argv, spawn_sync, env):
333
+ unit = _schedule_id("cdx-manager-notify", parsed, event)
334
+ target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
335
+ if shutil_which("systemd-run", env):
336
+ calendar = target.strftime("%Y-%m-%d %H:%M:%S")
337
+ result = _run_scheduler_command(
338
+ ["systemd-run", "--user", f"--unit={unit}", f"--on-calendar={calendar}", *argv],
339
+ spawn_sync,
340
+ env,
341
+ )
342
+ if result["ok"]:
343
+ return {"scheduled": True, "existing": False, "backend": "systemd", "id": unit}
344
+ if _scheduler_error_means_exists(result["error"]):
345
+ return {"scheduled": True, "existing": True, "backend": "systemd", "id": unit}
346
+ raise CdxError(f"Failed to schedule notification with systemd-run: {result['error']}")
347
+ if shutil_which("at", env):
348
+ at_time = target.strftime("%Y%m%d%H%M.%S")
349
+ command = " ".join(shlex.quote(str(part)) for part in argv)
350
+ result = _run_scheduler_command(["at", "-t", at_time], spawn_sync, env, input_text=f"{command}\n")
351
+ if result["ok"]:
352
+ return {"scheduled": True, "existing": False, "backend": "at", "id": unit}
353
+ raise CdxError(f"Failed to schedule notification with at: {result['error']}")
354
+ raise CdxError("Cannot schedule notification: install systemd-run or at, or run cdx notify without --schedule")
355
+
356
+
357
+ def _schedule_windows_task(parsed, event, argv, spawn_sync, env):
358
+ name = _schedule_id("cdx-manager-notify", parsed, event)
359
+ target = datetime.fromtimestamp(event["target_timestamp"]).astimezone()
360
+ command = subprocess.list2cmdline([str(part) for part in argv])
361
+ result = _run_scheduler_command([
362
+ "schtasks",
363
+ "/Create",
364
+ "/SC",
365
+ "ONCE",
366
+ "/TN",
367
+ name,
368
+ "/TR",
369
+ command,
370
+ "/ST",
371
+ target.strftime("%H:%M"),
372
+ "/SD",
373
+ target.strftime("%m/%d/%Y"),
374
+ "/F",
375
+ "/Z",
376
+ ], spawn_sync, env)
377
+ if not result["ok"]:
378
+ raise CdxError(f"Failed to schedule notification with Task Scheduler: {result['error']}")
379
+ return {"scheduled": True, "existing": False, "backend": "schtasks", "id": name}
380
+
381
+
382
+ def _run_scheduler_command(argv, spawn_sync, env, input_text=None):
383
+ try:
384
+ completed = spawn_sync(
385
+ argv,
386
+ env=env,
387
+ input=input_text,
388
+ capture_output=True,
389
+ text=True,
390
+ timeout=10,
391
+ )
392
+ except (FileNotFoundError, OSError) as error:
393
+ return {"ok": False, "error": str(error)}
394
+ return {
395
+ "ok": getattr(completed, "returncode", 0) == 0,
396
+ "error": (getattr(completed, "stderr", "") or getattr(completed, "stdout", "") or "").strip(),
397
+ }
398
+
399
+
400
+ def _schedule_id(prefix, parsed, event):
401
+ session = parsed.get("name") or event.get("session") or "next-ready"
402
+ safe_session = "".join(ch if ch.isalnum() else "-" for ch in str(session).lower()).strip("-") or "session"
403
+ return f"{prefix}.{parsed['mode']}.{safe_session}.{int(event['target_timestamp'])}"
404
+
405
+
406
+ def _scheduler_error_means_exists(error):
407
+ normalized = str(error or "").lower()
408
+ return any(fragment in normalized for fragment in (
409
+ "already exists",
410
+ "file exists",
411
+ "unit exists",
412
+ "unit already",
413
+ "service already",
414
+ "bootstrap failed: 5",
415
+ ))
416
+
417
+
418
+ def _timestamp_to_local_iso(timestamp):
419
+ return datetime.fromtimestamp(timestamp).astimezone().isoformat()
420
+
421
+
422
+ def _round_up_to_next_minute(value):
423
+ if value.second == 0 and value.microsecond == 0:
424
+ return value
425
+ return (value + timedelta(minutes=1)).replace(second=0, microsecond=0)
426
+
427
+
428
+ def _escape_xml(value):
429
+ return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
430
+
431
+
182
432
  def format_notify_event(event):
183
433
  return event["message"]
184
434
 
185
435
 
436
+ def format_scheduled_notification(schedule):
437
+ if schedule.get("scheduled"):
438
+ return f"Scheduled notification via {schedule['backend']} for {schedule['target_iso']}"
439
+ return schedule["message"]
440
+
441
+
186
442
  def notify_json(event):
187
443
  return json.dumps(event, indent=2)
@@ -12,6 +12,20 @@ from .errors import CdxError
12
12
 
13
13
 
14
14
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
15
+ LAUNCH_PERMISSION_ARGS = {
16
+ PROVIDER_CLAUDE: {
17
+ "review": ["--permission-mode", "plan"],
18
+ "default": ["--permission-mode", "default"],
19
+ "auto": ["--permission-mode", "auto"],
20
+ "full": ["--permission-mode", "bypassPermissions"],
21
+ },
22
+ PROVIDER_CODEX: {
23
+ "review": ["-s", "read-only", "-a", "on-request"],
24
+ "default": ["-s", "workspace-write", "-a", "on-request"],
25
+ "auto": ["-s", "workspace-write", "-a", "never"],
26
+ "full": ["-s", "danger-full-access", "-a", "never"],
27
+ },
28
+ }
15
29
 
16
30
 
17
31
  def _home_env_overrides(auth_home):
@@ -49,6 +63,35 @@ def _build_launch_transcript_path(session):
49
63
  )
50
64
 
51
65
 
66
+ def _launch_power(session):
67
+ launch = session.get("launch") or {}
68
+ power = launch.get("power")
69
+ if power:
70
+ return power
71
+ if launch.get("fast") is True:
72
+ return "low"
73
+ return None
74
+
75
+
76
+ def _launch_config_args(session):
77
+ launch = session.get("launch") or {}
78
+ args = []
79
+ power = _launch_power(session)
80
+ permission = launch.get("permission")
81
+ provider = session["provider"]
82
+ if provider == PROVIDER_CLAUDE:
83
+ if power:
84
+ args += ["--effort", power]
85
+ if permission:
86
+ args += LAUNCH_PERMISSION_ARGS[PROVIDER_CLAUDE].get(permission, [])
87
+ return args
88
+ if power:
89
+ args += ["-c", f'model_reasoning_effort="{power}"']
90
+ if permission:
91
+ args += LAUNCH_PERMISSION_ARGS[PROVIDER_CODEX].get(permission, [])
92
+ return args
93
+
94
+
52
95
  def _list_launch_transcript_paths(session, glob_fn=None):
53
96
  import glob
54
97
 
@@ -109,7 +152,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
109
152
  env_override = env_override or {}
110
153
  env = {**os.environ, **env_override}
111
154
  if session["provider"] == PROVIDER_CLAUDE:
112
- args = ["--name", session["name"]]
155
+ args = ["--name", session["name"]] + _launch_config_args(session)
113
156
  if initial_prompt:
114
157
  args.append(initial_prompt)
115
158
  return _wrap_launch_with_transcript(session, {
@@ -121,7 +164,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
121
164
  },
122
165
  "label": "claude",
123
166
  }, env=env)
124
- args = ["--no-alt-screen", "--cd", cwd]
167
+ args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
125
168
  if initial_prompt:
126
169
  args.append(initial_prompt)
127
170
  return _wrap_launch_with_transcript(session, {
@@ -37,7 +37,10 @@ RESERVED_SESSION_NAMES = {
37
37
  "ren",
38
38
  "rename",
39
39
  "rmv",
40
+ "config",
41
+ "set",
40
42
  "status",
43
+ "unset",
41
44
  "update",
42
45
  "version",
43
46
  "--help",
@@ -45,6 +48,10 @@ RESERVED_SESSION_NAMES = {
45
48
  "--version",
46
49
  "-v",
47
50
  }
51
+ STATUS_CACHE_TTL_SECONDS = 60
52
+ CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
53
+ LAUNCH_POWER_VALUES = {"low", "medium", "high", "xhigh", "max"}
54
+ LAUNCH_PERMISSION_VALUES = {"review", "default", "auto", "full"}
48
55
 
49
56
 
50
57
  def _encode(name):
@@ -76,6 +83,35 @@ def _seed_codex_auth_from_global(auth_home, env=None):
76
83
  return True
77
84
 
78
85
 
86
+ def _normalize_launch_settings(settings):
87
+ normalized = {}
88
+ if not settings:
89
+ return normalized
90
+ if "power" in settings and settings["power"] is not None:
91
+ power = str(settings["power"]).strip().lower()
92
+ if power not in LAUNCH_POWER_VALUES:
93
+ raise CdxError(f"Unsupported power: {settings['power']}")
94
+ normalized["power"] = power
95
+ if "permission" in settings and settings["permission"] is not None:
96
+ permission = str(settings["permission"]).strip().lower()
97
+ if permission not in LAUNCH_PERMISSION_VALUES:
98
+ raise CdxError(f"Unsupported permission: {settings['permission']}")
99
+ normalized["permission"] = permission
100
+ if "fast" in settings and settings["fast"] is not None:
101
+ value = settings["fast"]
102
+ if isinstance(value, bool):
103
+ normalized["fast"] = value
104
+ else:
105
+ text = str(value).strip().lower()
106
+ if text in ("on", "true", "1", "yes"):
107
+ normalized["fast"] = True
108
+ elif text in ("off", "false", "0", "no"):
109
+ normalized["fast"] = False
110
+ else:
111
+ raise CdxError(f"Unsupported fast value: {settings['fast']}")
112
+ return normalized
113
+
114
+
79
115
  def _local_now_iso():
80
116
  return datetime.now().astimezone().isoformat()
81
117
 
@@ -133,6 +169,24 @@ def _parse_status_timestamp(value):
133
169
  return None
134
170
 
135
171
 
172
+ def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
173
+ if session.get("provider") == PROVIDER_CLAUDE and ttl_seconds == STATUS_CACHE_TTL_SECONDS:
174
+ return CLAUDE_STATUS_CACHE_TTL_SECONDS
175
+ return ttl_seconds
176
+
177
+
178
+ def _is_status_cache_fresh(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
179
+ status = session.get("lastStatus") or {}
180
+ if _is_low_confidence_status_source(status):
181
+ return False
182
+ updated_at = _parse_status_timestamp(status.get("updated_at") or session.get("lastStatusAt"))
183
+ if not updated_at:
184
+ return False
185
+ now = datetime.now(timezone.utc).astimezone()
186
+ ttl_seconds = _status_cache_ttl_seconds(session, ttl_seconds)
187
+ return (now - updated_at.astimezone(now.tzinfo)).total_seconds() < ttl_seconds
188
+
189
+
136
190
  def _is_status_newer(candidate, current):
137
191
  if not candidate:
138
192
  return False
@@ -292,6 +346,7 @@ def create_session_service(options=None):
292
346
  "lastLaunchedAt": session.get("lastLaunchedAt"),
293
347
  "lastStatusAt": session.get("lastStatusAt"),
294
348
  "lastStatus": session.get("lastStatus"),
349
+ "launch": session.get("launch"),
295
350
  "auth": session.get("auth"),
296
351
  }
297
352
 
@@ -439,6 +494,7 @@ def create_session_service(options=None):
439
494
  "lastLaunchedAt": None,
440
495
  "lastStatusAt": None,
441
496
  "lastStatus": None,
497
+ **({"launch": source.get("launch")} if source.get("launch") else {}),
442
498
  "auth": {
443
499
  "status": "unknown",
444
500
  "lastCheckedAt": None,
@@ -561,6 +617,47 @@ def create_session_service(options=None):
561
617
  "updatedAt": now,
562
618
  })
563
619
 
620
+ def set_launch_settings(name, settings):
621
+ session = store["get_session"](name)
622
+ if not session:
623
+ raise CdxError(f"Unknown session: {name}")
624
+ updates = _normalize_launch_settings(settings)
625
+ if not updates:
626
+ raise CdxError("At least one launch setting is required.")
627
+ current = _normalize_launch_settings(session.get("launch") or {})
628
+ launch = {**current, **updates}
629
+ now = _local_now_iso()
630
+ return store["update_session"](name, lambda s: {
631
+ **s,
632
+ "launch": launch,
633
+ "updatedAt": now,
634
+ })
635
+
636
+ def unset_launch_settings(name, keys):
637
+ session = store["get_session"](name)
638
+ if not session:
639
+ raise CdxError(f"Unknown session: {name}")
640
+ if not keys:
641
+ raise CdxError("At least one launch setting is required.")
642
+ allowed = {"power", "permission", "fast"}
643
+ unknown = [key for key in keys if key not in allowed]
644
+ if unknown:
645
+ raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
646
+ current = dict(session.get("launch") or {})
647
+ for key in keys:
648
+ current.pop(key, None)
649
+ now = _local_now_iso()
650
+
651
+ def updater(s):
652
+ updated = {**s, "updatedAt": now}
653
+ if current:
654
+ updated["launch"] = current
655
+ else:
656
+ updated.pop("launch", None)
657
+ return updated
658
+
659
+ return store["update_session"](name, updater)
660
+
564
661
  def record_status(name, payload):
565
662
  normalized = _normalize_status_payload(payload)
566
663
  updated = store["update_session"](name, lambda s: {
@@ -572,8 +669,12 @@ def create_session_service(options=None):
572
669
  raise CdxError(f"Unknown session: {name}")
573
670
  return updated
574
671
 
575
- def _resolve_session_status(session):
672
+ def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
576
673
  current_status = session.get("lastStatus")
674
+ if session.get("enabled", True) is False:
675
+ return current_status
676
+ if current_status and not force_refresh and _is_status_cache_fresh(session, ttl_seconds=cache_ttl_seconds):
677
+ return current_status
577
678
  source_root = session.get("authHome") or _get_session_auth_home(
578
679
  session["name"], session["provider"]
579
680
  )
@@ -644,7 +745,7 @@ def create_session_service(options=None):
644
745
  raise CdxError(f"Unknown session: {name}")
645
746
  return updated
646
747
 
647
- def get_status_rows(progress_callback=None):
748
+ def get_status_rows(progress_callback=None, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
648
749
  sessions = list_sessions()
649
750
  if progress_callback:
650
751
  progress_callback({
@@ -653,13 +754,25 @@ def create_session_service(options=None):
653
754
  })
654
755
  resolved = []
655
756
  for s in sessions:
656
- if progress_callback:
757
+ cache_hit = (
758
+ s.get("enabled", True) is False
759
+ or (
760
+ s.get("lastStatus")
761
+ and not force_refresh
762
+ and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
763
+ )
764
+ )
765
+ if progress_callback and not cache_hit:
657
766
  progress_callback({
658
767
  "event": "session_started",
659
768
  "session_name": s["name"],
660
769
  "provider": s["provider"],
661
770
  })
662
- status = _resolve_session_status(s)
771
+ status = _resolve_session_status(
772
+ s,
773
+ force_refresh=force_refresh,
774
+ cache_ttl_seconds=cache_ttl_seconds,
775
+ )
663
776
  if progress_callback:
664
777
  progress_callback({
665
778
  "event": "session_finished",
@@ -687,18 +800,20 @@ def create_session_service(options=None):
687
800
  rows = []
688
801
  for s in resolved:
689
802
  status = s.get("lastStatus")
803
+ enabled = s.get("enabled", True) is not False
804
+ row_status = status if enabled else None
690
805
  rows.append({
691
806
  "session_name": s["name"],
692
807
  "provider": s["provider"],
693
- "enabled": s.get("enabled", True) is not False,
694
- "status": "disabled" if s.get("enabled", True) is False else "enabled",
695
- "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
696
- "remaining_week_pct": status.get("remaining_week_pct") if status else None,
697
- "credits": status.get("credits") if status else None,
698
- "available_pct": _compute_available_pct(status),
699
- "reset_5h_at": status.get("reset_5h_at") if status else None,
700
- "reset_week_at": status.get("reset_week_at") if status else None,
701
- "reset_at": status.get("reset_at") if status else None,
808
+ "enabled": enabled,
809
+ "status": "enabled" if enabled else "disabled",
810
+ "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
811
+ "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
812
+ "credits": row_status.get("credits") if row_status else None,
813
+ "available_pct": _compute_available_pct(row_status),
814
+ "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
815
+ "reset_week_at": row_status.get("reset_week_at") if row_status else None,
816
+ "reset_at": row_status.get("reset_at") if row_status else None,
702
817
  "updated_at": _to_local_iso(s.get("lastStatusAt")),
703
818
  "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
704
819
  })
@@ -726,6 +841,7 @@ def create_session_service(options=None):
726
841
  "enabled": s.get("enabled", True) is not False,
727
842
  "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
728
843
  "status": s.get("lastStatus"),
844
+ "launch": s.get("launch") or {},
729
845
  "updated_at": _to_local_iso(s.get("updatedAt")),
730
846
  } for s in sessions]
731
847
 
@@ -880,6 +996,8 @@ def create_session_service(options=None):
880
996
  "list_sessions": list_sessions,
881
997
  "get_session": get_session,
882
998
  "set_session_enabled": set_session_enabled,
999
+ "set_launch_settings": set_launch_settings,
1000
+ "unset_launch_settings": unset_launch_settings,
883
1001
  "record_status": record_status,
884
1002
  "update_auth_state": update_auth_state,
885
1003
  "get_status_rows": get_status_rows,
@@ -82,17 +82,20 @@ def _format_status_rows(rows, use_color=False, small=False):
82
82
  base.append(r.get("provider") or "n/a")
83
83
  status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
84
84
  base.append(_style(status, "2" if status == "disabled" else "32", use_color))
85
- usage_columns = [
86
- _style_pct(r.get("available_pct"), use_color),
87
- _style_pct(r.get("remaining_5h_pct"), use_color),
88
- _style_pct(r.get("remaining_week_pct"), use_color),
89
- _style_reset_time(r.get("reset_5h_at"), use_color),
90
- _style_reset_time(r.get("reset_week_at"), use_color),
91
- ]
85
+ if r.get("enabled", True) is False:
86
+ usage_columns = [_style("-", "2", use_color)] * 5
87
+ else:
88
+ usage_columns = [
89
+ _style_pct(r.get("available_pct"), use_color),
90
+ _style_pct(r.get("remaining_5h_pct"), use_color),
91
+ _style_pct(r.get("remaining_week_pct"), use_color),
92
+ _style_reset_time(r.get("reset_5h_at"), use_color),
93
+ _style_reset_time(r.get("reset_week_at"), use_color),
94
+ ]
92
95
  if small:
93
96
  base += usage_columns
94
97
  else:
95
- block = _format_blocking_quota(r)
98
+ block = "-" if r.get("enabled", True) is False else _format_blocking_quota(r)
96
99
  credits = str(r["credits"]) if r.get("credits") is not None else "-"
97
100
  base += usage_columns[:3] + [
98
101
  _style(block, "33" if block not in ("?", "-") else "2", use_color),