cdx-manager 0.5.6 → 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.6-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.6 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] [--refresh] [--json]` | Wait for a session reset time and send a desktop notification when due |
262
- | `cdx notify --next-ready [--poll seconds] [--once] [--refresh] [--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,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.6",
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",
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.6"
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.6"
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 [--refresh] [--json]', '36', use_color)}",
87
- f" {_style('cdx notify --next-ready [--refresh] [--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
 
@@ -302,6 +309,61 @@ def _parse_toggle_args(args, usage):
302
309
  return {"name": parsed["names"][0], "json": parsed["json"]}
303
310
 
304
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
+
305
367
  def _read_option_value(args, index, usage):
306
368
  if index + 1 >= len(args):
307
369
  raise CdxError(usage)
@@ -518,6 +580,53 @@ def handle_enable(rest, ctx):
518
580
  return 0
519
581
 
520
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
+
521
630
  def handle_clean(rest, ctx):
522
631
  json_flag, args = _parse_json_flag(rest)
523
632
  service = ctx["service"]
@@ -639,6 +748,38 @@ def handle_notify(rest, ctx):
639
748
  env=ctx.get("env"),
640
749
  )
641
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
+
642
783
  event = wait_for_notification_event(
643
784
  ctx["service"],
644
785
  parsed,
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,7 +1,9 @@
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
9
  from .status_view import (
@@ -12,23 +14,27 @@ from .status_view import (
12
14
  )
13
15
 
14
16
 
17
+ NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
18
+
19
+
15
20
  def parse_notify_args(args):
16
21
  json_flag = "--json" in args
17
22
  once = "--once" in args
18
23
  at_reset = "--at-reset" in args
19
24
  next_ready = "--next-ready" in args
20
25
  refresh = "--refresh" in args
26
+ schedule = "--schedule" in args
21
27
  poll = 60
22
28
  cleaned = []
23
29
  i = 0
24
30
  while i < len(args):
25
31
  arg = args[i]
26
- if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh"):
32
+ if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh", "--schedule"):
27
33
  i += 1
28
34
  continue
29
35
  if arg == "--poll":
30
36
  if i + 1 >= len(args):
31
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
37
+ raise CdxError(NOTIFY_USAGE)
32
38
  try:
33
39
  poll = max(1, int(args[i + 1]))
34
40
  except ValueError as error:
@@ -43,15 +49,15 @@ def parse_notify_args(args):
43
49
  i += 1
44
50
  continue
45
51
  if arg.startswith("-"):
46
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
52
+ raise CdxError(NOTIFY_USAGE)
47
53
  cleaned.append(arg)
48
54
  i += 1
49
55
  if at_reset == next_ready:
50
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
56
+ raise CdxError(NOTIFY_USAGE)
51
57
  if at_reset and len(cleaned) != 1:
52
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh]")
58
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh]")
53
59
  if next_ready and cleaned:
54
- raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
60
+ raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]")
55
61
  return {
56
62
  "name": cleaned[0] if cleaned else None,
57
63
  "mode": "at-reset" if at_reset else "next-ready",
@@ -59,6 +65,7 @@ def parse_notify_args(args):
59
65
  "once": once,
60
66
  "json": json_flag,
61
67
  "refresh": refresh,
68
+ "schedule": schedule,
62
69
  }
63
70
 
64
71
 
@@ -212,9 +219,225 @@ def _escape_applescript(value):
212
219
  return str(value).replace("\\", "\\\\").replace('"', '\\"')
213
220
 
214
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
+
215
432
  def format_notify_event(event):
216
433
  return event["message"]
217
434
 
218
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
+
219
442
  def notify_json(event):
220
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",
@@ -47,6 +50,8 @@ RESERVED_SESSION_NAMES = {
47
50
  }
48
51
  STATUS_CACHE_TTL_SECONDS = 60
49
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"}
50
55
 
51
56
 
52
57
  def _encode(name):
@@ -78,6 +83,35 @@ def _seed_codex_auth_from_global(auth_home, env=None):
78
83
  return True
79
84
 
80
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
+
81
115
  def _local_now_iso():
82
116
  return datetime.now().astimezone().isoformat()
83
117
 
@@ -312,6 +346,7 @@ def create_session_service(options=None):
312
346
  "lastLaunchedAt": session.get("lastLaunchedAt"),
313
347
  "lastStatusAt": session.get("lastStatusAt"),
314
348
  "lastStatus": session.get("lastStatus"),
349
+ "launch": session.get("launch"),
315
350
  "auth": session.get("auth"),
316
351
  }
317
352
 
@@ -459,6 +494,7 @@ def create_session_service(options=None):
459
494
  "lastLaunchedAt": None,
460
495
  "lastStatusAt": None,
461
496
  "lastStatus": None,
497
+ **({"launch": source.get("launch")} if source.get("launch") else {}),
462
498
  "auth": {
463
499
  "status": "unknown",
464
500
  "lastCheckedAt": None,
@@ -581,6 +617,47 @@ def create_session_service(options=None):
581
617
  "updatedAt": now,
582
618
  })
583
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
+
584
661
  def record_status(name, payload):
585
662
  normalized = _normalize_status_payload(payload)
586
663
  updated = store["update_session"](name, lambda s: {
@@ -764,6 +841,7 @@ def create_session_service(options=None):
764
841
  "enabled": s.get("enabled", True) is not False,
765
842
  "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
766
843
  "status": s.get("lastStatus"),
844
+ "launch": s.get("launch") or {},
767
845
  "updated_at": _to_local_iso(s.get("updatedAt")),
768
846
  } for s in sessions]
769
847
 
@@ -918,6 +996,8 @@ def create_session_service(options=None):
918
996
  "list_sessions": list_sessions,
919
997
  "get_session": get_session,
920
998
  "set_session_enabled": set_session_enabled,
999
+ "set_launch_settings": set_launch_settings,
1000
+ "unset_launch_settings": unset_launch_settings,
921
1001
  "record_status": record_status,
922
1002
  "update_auth_state": update_auth_state,
923
1003
  "get_status_rows": get_status_rows,