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 +27 -4
- package/changelogs/CHANGELOGS_0_5_6.md +42 -0
- package/changelogs/CHANGELOGS_0_5_7.md +45 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +19 -4
- package/src/cli_commands.py +165 -1
- package/src/cli_render.py +16 -0
- package/src/notify.py +273 -17
- package/src/provider_runtime.py +45 -2
- package/src/session_service.py +131 -13
- package/src/status_view.py +11 -8
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] [--json]` | Wait for a session reset time
|
|
262
|
-
| `cdx notify --next-ready [--poll seconds] [--once] [--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,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.
|
|
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
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 [--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
|
|
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
|
|
|
@@ -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"](
|
|
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
|
|
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(
|
|
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(
|
|
52
|
+
raise CdxError(NOTIFY_USAGE)
|
|
41
53
|
cleaned.append(arg)
|
|
42
54
|
i += 1
|
|
43
55
|
if at_reset == next_ready:
|
|
44
|
-
raise CdxError(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
+
return _event(False, "cdx", "No upcoming session reset available", None)
|
|
77
112
|
first = priority[0]
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
129
|
+
def _is_usable_now(row):
|
|
97
130
|
value = row.get("available_pct")
|
|
98
|
-
return value is not None and value >
|
|
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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
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)
|
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",
|
|
@@ -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
|
-
|
|
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(
|
|
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":
|
|
694
|
-
"status": "
|
|
695
|
-
"remaining_5h_pct":
|
|
696
|
-
"remaining_week_pct":
|
|
697
|
-
"credits":
|
|
698
|
-
"available_pct": _compute_available_pct(
|
|
699
|
-
"reset_5h_at":
|
|
700
|
-
"reset_week_at":
|
|
701
|
-
"reset_at":
|
|
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,
|
package/src/status_view.py
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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),
|