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 +27 -4
- package/changelogs/CHANGELOGS_0_5_7.md +45 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +19 -4
- package/src/cli_commands.py +141 -0
- package/src/cli_render.py +16 -0
- package/src/notify.py +229 -6
- package/src/provider_runtime.py +45 -2
- package/src/session_service.py +80 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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
|
|
262
|
-
| `cdx notify --next-ready [--poll seconds] [--once] [--refresh] [--json]` | Wait until the recommended session is usable or
|
|
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
package/pyproject.toml
CHANGED
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.
|
|
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
|
|
package/src/cli_commands.py
CHANGED
|
@@ -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(
|
|
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(
|
|
52
|
+
raise CdxError(NOTIFY_USAGE)
|
|
47
53
|
cleaned.append(arg)
|
|
48
54
|
i += 1
|
|
49
55
|
if at_reset == next_ready:
|
|
50
|
-
raise CdxError(
|
|
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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
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)
|
package/src/provider_runtime.py
CHANGED
|
@@ -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, {
|
package/src/session_service.py
CHANGED
|
@@ -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,
|