cdx-manager 0.5.7 → 0.6.0
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 +41 -2
- package/changelogs/CHANGELOGS_0_6_0.md +47 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +19 -4
- package/src/cli_commands.py +390 -17
- package/src/cli_render.py +21 -22
- package/src/provider_runtime.py +39 -3
- package/src/session_service.py +122 -0
- package/src/session_store.py +36 -0
- package/src/status_view.py +15 -3
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
|
|
|
@@ -34,10 +34,14 @@ One command to launch any session. Zero auth juggling.
|
|
|
34
34
|
|
|
35
35
|
- **Multiple accounts, one tool.** Register as many Codex or Claude sessions as you need. Each one gets its own isolated auth environment — no cross-contamination between accounts.
|
|
36
36
|
- **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
|
|
37
|
+
- **Quick relaunch.** `cdx last` reopens the most recently launched assistant profile.
|
|
37
38
|
- **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
39
|
- **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.
|
|
40
|
+
- **Next-ready notification.** `cdx ready` schedules a native system notification for the next assistant that comes back from cooldown, then returns immediately.
|
|
39
41
|
- **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
|
|
40
42
|
- **Persistent launch settings.** Pin per-session power, permission, and fast-mode preferences once; `cdx` reapplies them on every launch until you unset them.
|
|
43
|
+
- **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
|
|
44
|
+
- **Update prompts.** Periodic update checks surface `cdx update` directly in the `cdx`, `cdx status`, and launch output when a newer release is available.
|
|
41
45
|
- **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.
|
|
42
46
|
- **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.
|
|
43
47
|
- **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
|
|
@@ -127,7 +131,7 @@ For a specific version:
|
|
|
127
131
|
|
|
128
132
|
```bash
|
|
129
133
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
130
|
-
CDX_VERSION=v0.
|
|
134
|
+
CDX_VERSION=v0.6.0 sh install.sh
|
|
131
135
|
```
|
|
132
136
|
|
|
133
137
|
From source:
|
|
@@ -230,6 +234,18 @@ cdx work
|
|
|
230
234
|
|
|
231
235
|
# Check usage across all sessions
|
|
232
236
|
cdx status
|
|
237
|
+
|
|
238
|
+
# Notify when the next cooling-down assistant is ready
|
|
239
|
+
cdx ready
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Next-Ready Notifications
|
|
243
|
+
|
|
244
|
+
Use `cdx ready` when every useful assistant is cooling down and you want your terminal back. It is shorthand for `cdx notify --schedule --next-ready`: cdx picks the next known reset, registers a native OS notification, and exits immediately.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
cdx ready
|
|
248
|
+
cdx ready --refresh
|
|
233
249
|
```
|
|
234
250
|
|
|
235
251
|
### Persistent Launch Settings
|
|
@@ -251,6 +267,22 @@ cdx unset work --all
|
|
|
251
267
|
|
|
252
268
|
`--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
269
|
|
|
270
|
+
### Launch History
|
|
271
|
+
|
|
272
|
+
Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
cdx history
|
|
276
|
+
cdx history work
|
|
277
|
+
cdx history work --limit 5
|
|
278
|
+
cdx history work --json
|
|
279
|
+
cdx history --summary
|
|
280
|
+
cdx history --summary --since 7d
|
|
281
|
+
cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`cdx history --summary` aggregates total time per assistant. Add `--since`, `--from`, or `--to` to focus on a period.
|
|
285
|
+
|
|
254
286
|
---
|
|
255
287
|
|
|
256
288
|
## All Commands
|
|
@@ -271,6 +303,8 @@ cdx unset work --all
|
|
|
271
303
|
| `cdx config <name> [--json]` | Show persistent launch settings for a session |
|
|
272
304
|
| `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
305
|
| `cdx unset <name> (--power\|--permission\|--fast\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
306
|
+
| `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
307
|
+
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
274
308
|
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
275
309
|
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
276
310
|
| `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 |
|
|
@@ -281,6 +315,7 @@ cdx unset work --all
|
|
|
281
315
|
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
282
316
|
| `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
|
|
283
317
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
318
|
+
| `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
|
|
284
319
|
| `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
320
|
| `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
|
|
286
321
|
| `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
|
|
@@ -313,9 +348,12 @@ Commands with machine-readable output:
|
|
|
313
348
|
- `cdx enable ... --json`
|
|
314
349
|
- `cdx context ... --json`
|
|
315
350
|
- `cdx handoff ... --json`
|
|
351
|
+
- `cdx history ... --json`
|
|
352
|
+
- `cdx last --json`
|
|
316
353
|
- `cdx doctor --json`
|
|
317
354
|
- `cdx repair --json`
|
|
318
355
|
- `cdx update --json`
|
|
356
|
+
- `cdx ready --json`
|
|
319
357
|
- `cdx notify ... --json`
|
|
320
358
|
|
|
321
359
|
Success payloads follow a shared envelope:
|
|
@@ -452,6 +490,7 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
|
|
|
452
490
|
sessions.json # Session registry (versioned, all sessions)
|
|
453
491
|
state/
|
|
454
492
|
<encoded-name>.json # Per-session rehydration state
|
|
493
|
+
launch_history.jsonl # Append-only launch history
|
|
455
494
|
profiles/
|
|
456
495
|
<encoded-name>/ # Codex session: CODEX_HOME points here
|
|
457
496
|
log/
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog (`0.5.7 -> 0.6.0`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-28
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Added launch history workflows for reviewing, summarizing, and relaunching assistant sessions.
|
|
8
|
+
- Improved `cdx status` guidance with reset-week countdowns, active-session marking, cleaner blocking quota labels, and periodic update notices.
|
|
9
|
+
- Added shortcut commands that reduce routine assistant switching and waiting friction.
|
|
10
|
+
|
|
11
|
+
## Launch History and Relaunch
|
|
12
|
+
|
|
13
|
+
- Added `cdx history` to inspect recent assistant launches with provider, result, duration, working directory, launch settings, and transcript path.
|
|
14
|
+
- Added `cdx history --summary` to aggregate assistant time by session.
|
|
15
|
+
- Added period filters for history summaries with `--since`, `--from`, and `--to`.
|
|
16
|
+
- Added `cdx last` to relaunch the most recent existing session from launch history.
|
|
17
|
+
- Skips removed sessions when resolving the last launch target.
|
|
18
|
+
|
|
19
|
+
## Status and Availability
|
|
20
|
+
|
|
21
|
+
- Added reset-week countdowns to status output.
|
|
22
|
+
- Hid healthy blocking quota labels so status tables stay focused on actionable quota limits.
|
|
23
|
+
- Marked the active launched session in status and list output.
|
|
24
|
+
- Added `cdx ready` as a shortcut for scheduling the next assistant reset notification.
|
|
25
|
+
- Added update notices to `cdx status`, matching the existing periodic update check behavior.
|
|
26
|
+
- Changed visual update notices to show `Run: cdx update` instead of a release URL.
|
|
27
|
+
|
|
28
|
+
## CLI Ergonomics
|
|
29
|
+
|
|
30
|
+
- Curated the main screen next-actions list around the commands users are most likely to run next.
|
|
31
|
+
- Kept release URLs in JSON warning payloads for integrations while making text output directly actionable.
|
|
32
|
+
- Added `cdx last [--json]` to help output and README command documentation.
|
|
33
|
+
|
|
34
|
+
## Release Metadata and Documentation
|
|
35
|
+
|
|
36
|
+
- Updated package metadata, CLI version output, README badge, and pinned installer example to `v0.6.0`.
|
|
37
|
+
- Documented `cdx last` and the action-oriented update notices in the README.
|
|
38
|
+
|
|
39
|
+
## Validation and Regression Coverage
|
|
40
|
+
|
|
41
|
+
- Added regression coverage for launch history, assistant-time summaries, period filters, active session indicators, `cdx ready`, `cdx last`, and update notices in `cdx status`.
|
|
42
|
+
- Added coverage to ensure visual update notices show `Run: cdx update` without leaking the release URL.
|
|
43
|
+
|
|
44
|
+
## Validation and Regression Evidence
|
|
45
|
+
|
|
46
|
+
- `npm run lint`
|
|
47
|
+
- `npm test`
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -18,6 +18,8 @@ from .cli_commands import (
|
|
|
18
18
|
handle_export,
|
|
19
19
|
handle_import,
|
|
20
20
|
handle_handoff,
|
|
21
|
+
handle_history,
|
|
22
|
+
handle_last,
|
|
21
23
|
handle_launch,
|
|
22
24
|
handle_login,
|
|
23
25
|
handle_logout,
|
|
@@ -52,7 +54,7 @@ from .status_view import (
|
|
|
52
54
|
)
|
|
53
55
|
from .update_check import check_for_update
|
|
54
56
|
|
|
55
|
-
VERSION = "0.
|
|
57
|
+
VERSION = "0.6.0"
|
|
56
58
|
|
|
57
59
|
|
|
58
60
|
# ---------------------------------------------------------------------------
|
|
@@ -73,6 +75,8 @@ def _print_help(use_color=False):
|
|
|
73
75
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
74
76
|
f" {_style('cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]', '36', use_color)}",
|
|
75
77
|
f" {_style('cdx unset <name> (--power|--permission|--fast|--all) [--json]', '36', use_color)}",
|
|
78
|
+
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
79
|
+
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
76
80
|
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
77
81
|
f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
|
|
78
82
|
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
@@ -89,6 +93,7 @@ def _print_help(use_color=False):
|
|
|
89
93
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
90
94
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
91
95
|
f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
96
|
+
f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
|
|
92
97
|
f" {_style('cdx notify <name> --at-reset [--schedule] [--refresh] [--json]', '36', use_color)}",
|
|
93
98
|
f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
|
|
94
99
|
f" {_style('cdx <name> [--json]', '36', use_color)}",
|
|
@@ -156,8 +161,7 @@ def _update_warning_payload(notice):
|
|
|
156
161
|
def _update_warning_text(notice):
|
|
157
162
|
if not notice:
|
|
158
163
|
return None
|
|
159
|
-
|
|
160
|
-
return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}).{suffix}"
|
|
164
|
+
return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}). Run: cdx update"
|
|
161
165
|
|
|
162
166
|
|
|
163
167
|
# ---------------------------------------------------------------------------
|
|
@@ -227,7 +231,7 @@ def main(argv, options=None):
|
|
|
227
231
|
"version": VERSION,
|
|
228
232
|
"cwd": options.get("cwd") or os.getcwd(),
|
|
229
233
|
"update_notice": _get_update_notice(service, env, options) if command not in (
|
|
230
|
-
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "
|
|
234
|
+
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "ready", "notify", "context", "config", "set", "unset", "history", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
|
|
231
235
|
) else None,
|
|
232
236
|
"use_color": use_color,
|
|
233
237
|
}
|
|
@@ -268,6 +272,11 @@ def main(argv, options=None):
|
|
|
268
272
|
if command == "update":
|
|
269
273
|
return handle_update(rest, ctx)
|
|
270
274
|
|
|
275
|
+
if command == "ready":
|
|
276
|
+
if any(arg not in ("--refresh", "--json") for arg in rest):
|
|
277
|
+
raise CdxError("Usage: cdx ready [--refresh] [--json]")
|
|
278
|
+
return handle_notify(["--next-ready", "--schedule", *rest], ctx)
|
|
279
|
+
|
|
271
280
|
if command == "notify":
|
|
272
281
|
return handle_notify(rest, ctx)
|
|
273
282
|
|
|
@@ -283,6 +292,12 @@ def main(argv, options=None):
|
|
|
283
292
|
if command == "unset":
|
|
284
293
|
return handle_unset(rest, ctx)
|
|
285
294
|
|
|
295
|
+
if command == "history":
|
|
296
|
+
return handle_history(rest, ctx)
|
|
297
|
+
|
|
298
|
+
if command == "last":
|
|
299
|
+
return handle_last(rest, ctx)
|
|
300
|
+
|
|
286
301
|
if command == "handoff":
|
|
287
302
|
return handle_handoff(rest, ctx)
|
|
288
303
|
|
package/src/cli_commands.py
CHANGED
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
6
|
import time
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
8
|
|
|
9
9
|
from .claude_refresh import _refresh_claude_sessions
|
|
10
10
|
from .cli_render import _dim, _info, _success, _warn
|
|
@@ -52,6 +52,8 @@ HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <targ
|
|
|
52
52
|
SET_USAGE = "Usage: cdx set <name> [--power low|medium|high|xhigh|max] [--permission review|default|auto|full] [--fast on|off] [--json]"
|
|
53
53
|
UNSET_USAGE = "Usage: cdx unset <name> (--power|--permission|--fast|--all) [--json]"
|
|
54
54
|
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
55
|
+
HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
56
|
+
LAST_USAGE = "Usage: cdx last [--json]"
|
|
55
57
|
API_SCHEMA_VERSION = 1
|
|
56
58
|
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
57
59
|
|
|
@@ -76,6 +78,26 @@ def _write_json(ctx, payload):
|
|
|
76
78
|
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
77
79
|
|
|
78
80
|
|
|
81
|
+
def _update_notice_warning(ctx):
|
|
82
|
+
notice = ctx.get("update_notice")
|
|
83
|
+
if not notice:
|
|
84
|
+
return None
|
|
85
|
+
return {
|
|
86
|
+
"code": "update_available",
|
|
87
|
+
"message": f"Update available: cdx-manager {notice['latest_version']}",
|
|
88
|
+
"latest_version": notice["latest_version"],
|
|
89
|
+
"url": notice.get("url"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_update_notice(ctx):
|
|
94
|
+
notice = ctx.get("update_notice")
|
|
95
|
+
if not notice:
|
|
96
|
+
return
|
|
97
|
+
text = f"Update available: cdx-manager {notice['latest_version']} (current version installed may be older). Run: cdx update"
|
|
98
|
+
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
99
|
+
|
|
100
|
+
|
|
79
101
|
def _format_bytes(value):
|
|
80
102
|
if value is None:
|
|
81
103
|
return "n/a"
|
|
@@ -364,6 +386,154 @@ def _parse_config_args(args):
|
|
|
364
386
|
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
365
387
|
|
|
366
388
|
|
|
389
|
+
def _parse_positive_int(value):
|
|
390
|
+
try:
|
|
391
|
+
parsed = int(value)
|
|
392
|
+
except (TypeError, ValueError) as error:
|
|
393
|
+
raise CdxError(HISTORY_USAGE) from error
|
|
394
|
+
if parsed < 1:
|
|
395
|
+
raise CdxError(HISTORY_USAGE)
|
|
396
|
+
return parsed
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _local_timezone():
|
|
400
|
+
return datetime.now().astimezone().tzinfo
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_datetime_value(value, usage, end_of_day=False):
|
|
404
|
+
text = str(value or "").strip()
|
|
405
|
+
if not text:
|
|
406
|
+
raise CdxError(usage)
|
|
407
|
+
if text.endswith("Z"):
|
|
408
|
+
text = f"{text[:-1]}+00:00"
|
|
409
|
+
is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
|
|
410
|
+
try:
|
|
411
|
+
parsed = datetime.fromisoformat(text)
|
|
412
|
+
except ValueError as error:
|
|
413
|
+
raise CdxError(usage) from error
|
|
414
|
+
if is_date_only and end_of_day:
|
|
415
|
+
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
416
|
+
if parsed.tzinfo is None:
|
|
417
|
+
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
418
|
+
return parsed.astimezone()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parse_since_value(value, now, usage):
|
|
422
|
+
text = str(value or "").strip().lower()
|
|
423
|
+
if not text:
|
|
424
|
+
raise CdxError(usage)
|
|
425
|
+
if text == "today":
|
|
426
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
427
|
+
if text == "yesterday":
|
|
428
|
+
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
429
|
+
return today - timedelta(days=1)
|
|
430
|
+
unit = text[-1:]
|
|
431
|
+
amount_text = text[:-1]
|
|
432
|
+
if unit in ("m", "h", "d", "w") and amount_text.isdigit():
|
|
433
|
+
amount = int(amount_text)
|
|
434
|
+
if amount < 1:
|
|
435
|
+
raise CdxError(usage)
|
|
436
|
+
multipliers = {
|
|
437
|
+
"m": timedelta(minutes=amount),
|
|
438
|
+
"h": timedelta(hours=amount),
|
|
439
|
+
"d": timedelta(days=amount),
|
|
440
|
+
"w": timedelta(weeks=amount),
|
|
441
|
+
}
|
|
442
|
+
return now - multipliers[unit]
|
|
443
|
+
return _parse_datetime_value(value, usage, end_of_day=False)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _format_period_datetime(value):
|
|
447
|
+
if value is None:
|
|
448
|
+
return None
|
|
449
|
+
return value.isoformat(timespec="seconds")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _parse_history_period(parsed, now):
|
|
453
|
+
since = parsed.get("since")
|
|
454
|
+
from_value = parsed.get("from")
|
|
455
|
+
to_value = parsed.get("to")
|
|
456
|
+
if since and from_value:
|
|
457
|
+
raise CdxError("Usage: cdx history cannot combine --since and --from.")
|
|
458
|
+
start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
|
|
459
|
+
if from_value:
|
|
460
|
+
start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
|
|
461
|
+
end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
|
|
462
|
+
if start and end and start.timestamp() > end.timestamp():
|
|
463
|
+
raise CdxError("Usage: cdx history period start must be before period end.")
|
|
464
|
+
return {
|
|
465
|
+
"from": _format_period_datetime(start),
|
|
466
|
+
"to": _format_period_datetime(end),
|
|
467
|
+
"from_ts": start.timestamp() if start else None,
|
|
468
|
+
"to_ts": end.timestamp() if end else None,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _has_history_period(period):
|
|
473
|
+
return period.get("from_ts") is not None or period.get("to_ts") is not None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _parse_entry_timestamp(entry):
|
|
477
|
+
value = entry.get("started_at")
|
|
478
|
+
if not value:
|
|
479
|
+
return None
|
|
480
|
+
text = str(value)
|
|
481
|
+
if text.endswith("Z"):
|
|
482
|
+
text = f"{text[:-1]}+00:00"
|
|
483
|
+
try:
|
|
484
|
+
parsed = datetime.fromisoformat(text)
|
|
485
|
+
except ValueError:
|
|
486
|
+
return None
|
|
487
|
+
if parsed.tzinfo is None:
|
|
488
|
+
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
489
|
+
return parsed.timestamp()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _filter_history_period(entries, period):
|
|
493
|
+
if not _has_history_period(period):
|
|
494
|
+
return entries
|
|
495
|
+
filtered = []
|
|
496
|
+
start = period.get("from_ts")
|
|
497
|
+
end = period.get("to_ts")
|
|
498
|
+
for entry in entries:
|
|
499
|
+
timestamp = _parse_entry_timestamp(entry)
|
|
500
|
+
if timestamp is None:
|
|
501
|
+
continue
|
|
502
|
+
if start is not None and timestamp < start:
|
|
503
|
+
continue
|
|
504
|
+
if end is not None and timestamp > end:
|
|
505
|
+
continue
|
|
506
|
+
filtered.append(entry)
|
|
507
|
+
return filtered
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _public_history_period(period):
|
|
511
|
+
return {
|
|
512
|
+
"from": period.get("from"),
|
|
513
|
+
"to": period.get("to"),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _parse_history_args(args, now=None):
|
|
518
|
+
now = now or datetime.now().astimezone()
|
|
519
|
+
parsed = _parse_flag_args(args, {
|
|
520
|
+
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
521
|
+
"--summary": {"key": "summary", "type": "bool", "default": False},
|
|
522
|
+
"--since": {"key": "since", "type": "str", "default": None},
|
|
523
|
+
"--from": {"key": "from", "type": "str", "default": None},
|
|
524
|
+
"--to": {"key": "to", "type": "str", "default": None},
|
|
525
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
526
|
+
}, HISTORY_USAGE, positionals_key="names", max_positionals=1)
|
|
527
|
+
period = _parse_history_period(parsed, now)
|
|
528
|
+
return {
|
|
529
|
+
"name": parsed["names"][0] if parsed["names"] else None,
|
|
530
|
+
"limit": parsed["limit"],
|
|
531
|
+
"summary": parsed["summary"],
|
|
532
|
+
"period": period,
|
|
533
|
+
"json": parsed["json"],
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
367
537
|
def _read_option_value(args, index, usage):
|
|
368
538
|
if index + 1 >= len(args):
|
|
369
539
|
raise CdxError(usage)
|
|
@@ -590,6 +760,123 @@ def _format_launch_config(session):
|
|
|
590
760
|
])
|
|
591
761
|
|
|
592
762
|
|
|
763
|
+
def _format_duration_ms(value):
|
|
764
|
+
if value is None:
|
|
765
|
+
return "-"
|
|
766
|
+
try:
|
|
767
|
+
amount = int(value)
|
|
768
|
+
except (TypeError, ValueError):
|
|
769
|
+
return str(value)
|
|
770
|
+
if amount < 1000:
|
|
771
|
+
return f"{amount}ms"
|
|
772
|
+
seconds = amount / 1000
|
|
773
|
+
if seconds < 60:
|
|
774
|
+
return f"{seconds:.1f}s"
|
|
775
|
+
minutes = int(seconds // 60)
|
|
776
|
+
remaining = int(seconds % 60)
|
|
777
|
+
return f"{minutes}m{remaining:02d}s"
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _summarize_history(entries):
|
|
781
|
+
by_session = {}
|
|
782
|
+
for entry in entries:
|
|
783
|
+
name = entry.get("session_name") or "-"
|
|
784
|
+
row = by_session.setdefault(name, {
|
|
785
|
+
"session_name": name,
|
|
786
|
+
"provider": entry.get("provider") or "-",
|
|
787
|
+
"launches": 0,
|
|
788
|
+
"successes": 0,
|
|
789
|
+
"failures": 0,
|
|
790
|
+
"duration_ms": 0,
|
|
791
|
+
"last_started_at": None,
|
|
792
|
+
})
|
|
793
|
+
row["launches"] += 1
|
|
794
|
+
if entry.get("status") == "success":
|
|
795
|
+
row["successes"] += 1
|
|
796
|
+
elif entry.get("status") == "failed":
|
|
797
|
+
row["failures"] += 1
|
|
798
|
+
try:
|
|
799
|
+
row["duration_ms"] += int(entry.get("duration_ms") or 0)
|
|
800
|
+
except (TypeError, ValueError):
|
|
801
|
+
pass
|
|
802
|
+
started = entry.get("started_at")
|
|
803
|
+
if started and (not row["last_started_at"] or started > row["last_started_at"]):
|
|
804
|
+
row["last_started_at"] = started
|
|
805
|
+
row["provider"] = entry.get("provider") or row["provider"]
|
|
806
|
+
return sorted(
|
|
807
|
+
by_session.values(),
|
|
808
|
+
key=lambda item: (item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
|
|
809
|
+
reverse=True,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _format_history_period(period):
|
|
814
|
+
if not _has_history_period(period or {}):
|
|
815
|
+
return None
|
|
816
|
+
start = _format_period_display(period.get("from")) or "beginning"
|
|
817
|
+
end = _format_period_display(period.get("to")) or "now"
|
|
818
|
+
return f"Period: {start} -> {end}"
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _format_period_display(value):
|
|
822
|
+
if not value:
|
|
823
|
+
return None
|
|
824
|
+
try:
|
|
825
|
+
parsed = datetime.fromisoformat(str(value))
|
|
826
|
+
except ValueError:
|
|
827
|
+
return str(value)
|
|
828
|
+
return parsed.strftime("%Y-%m-%d %H:%M")
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _format_history_summary(entries, period=None, use_color=False):
|
|
832
|
+
from .cli_render import _format_relative_age, _pad_table
|
|
833
|
+
|
|
834
|
+
summary = _summarize_history(entries)
|
|
835
|
+
if not summary:
|
|
836
|
+
return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
|
|
837
|
+
rows = [["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]
|
|
838
|
+
for row in summary:
|
|
839
|
+
rows.append([
|
|
840
|
+
row["session_name"],
|
|
841
|
+
row["provider"],
|
|
842
|
+
str(row["launches"]),
|
|
843
|
+
str(row["successes"]),
|
|
844
|
+
str(row["failures"]),
|
|
845
|
+
_format_duration_ms(row["duration_ms"]),
|
|
846
|
+
_format_relative_age(row.get("last_started_at")),
|
|
847
|
+
])
|
|
848
|
+
lines = ["Assistant time:"]
|
|
849
|
+
period_line = _format_history_period(period or {})
|
|
850
|
+
if period_line:
|
|
851
|
+
lines.extend([period_line, ""])
|
|
852
|
+
lines.append(_pad_table(rows))
|
|
853
|
+
return "\n".join(lines)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _format_history(entries, use_color=False):
|
|
857
|
+
from .cli_render import _format_relative_age, _pad_table
|
|
858
|
+
|
|
859
|
+
if not entries:
|
|
860
|
+
return "No launch history."
|
|
861
|
+
rows = [["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]
|
|
862
|
+
for entry in entries:
|
|
863
|
+
transcript_path = entry.get("transcript_path")
|
|
864
|
+
rows.append([
|
|
865
|
+
entry.get("session_name") or "-",
|
|
866
|
+
entry.get("provider") or "-",
|
|
867
|
+
entry.get("status") or "-",
|
|
868
|
+
_format_duration_ms(entry.get("duration_ms")),
|
|
869
|
+
_format_relative_age(entry.get("started_at")),
|
|
870
|
+
os.path.basename(transcript_path) if transcript_path else "-",
|
|
871
|
+
])
|
|
872
|
+
return "\n".join([
|
|
873
|
+
"Recent launches:",
|
|
874
|
+
_pad_table(rows),
|
|
875
|
+
"",
|
|
876
|
+
_dim("Full transcript paths and cwd are available with --json.", use_color),
|
|
877
|
+
])
|
|
878
|
+
|
|
879
|
+
|
|
593
880
|
def handle_set(rest, ctx):
|
|
594
881
|
parsed = _parse_set_args(rest)
|
|
595
882
|
session = ctx["service"]["set_launch_settings"](parsed["name"], parsed["settings"])
|
|
@@ -627,6 +914,56 @@ def handle_config(rest, ctx):
|
|
|
627
914
|
return 0
|
|
628
915
|
|
|
629
916
|
|
|
917
|
+
def handle_history(rest, ctx):
|
|
918
|
+
now_fn = ctx["options"].get("now") or time.time
|
|
919
|
+
now = datetime.fromtimestamp(now_fn()).astimezone()
|
|
920
|
+
parsed = _parse_history_args(rest, now=now)
|
|
921
|
+
has_period = _has_history_period(parsed["period"])
|
|
922
|
+
limit = 0 if parsed["summary"] or has_period else parsed["limit"]
|
|
923
|
+
entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
|
|
924
|
+
entries = _filter_history_period(entries, parsed["period"])
|
|
925
|
+
if has_period and not parsed["summary"]:
|
|
926
|
+
entries = entries[:parsed["limit"]]
|
|
927
|
+
message = (
|
|
928
|
+
f"Listed launch history for {parsed['name']}"
|
|
929
|
+
if parsed["name"]
|
|
930
|
+
else "Listed launch history"
|
|
931
|
+
)
|
|
932
|
+
if parsed["json"]:
|
|
933
|
+
payload = {"history": entries, "period": _public_history_period(parsed["period"])}
|
|
934
|
+
if parsed["summary"]:
|
|
935
|
+
payload["summary"] = _summarize_history(entries)
|
|
936
|
+
_write_json(ctx, _json_success("history", message, **payload))
|
|
937
|
+
return 0
|
|
938
|
+
if parsed["summary"]:
|
|
939
|
+
ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'])}\n")
|
|
940
|
+
return 0
|
|
941
|
+
ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'])}\n")
|
|
942
|
+
return 0
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _resolve_last_launch_session(ctx):
|
|
946
|
+
for entry in ctx["service"]["get_launch_history"](limit=0):
|
|
947
|
+
name = entry.get("session_name")
|
|
948
|
+
if not name:
|
|
949
|
+
continue
|
|
950
|
+
session = ctx["service"]["get_session"](name)
|
|
951
|
+
if session:
|
|
952
|
+
return session
|
|
953
|
+
raise CdxError("No launch history yet. Run cdx <name> first.")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def handle_last(rest, ctx):
|
|
957
|
+
json_flag, args = _parse_json_flag(rest)
|
|
958
|
+
if args:
|
|
959
|
+
raise CdxError(LAST_USAGE)
|
|
960
|
+
session = _resolve_last_launch_session(ctx)
|
|
961
|
+
if not json_flag:
|
|
962
|
+
message = f"Launching last session: {session['name']}"
|
|
963
|
+
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
964
|
+
return handle_launch(session["name"], ctx)
|
|
965
|
+
|
|
966
|
+
|
|
630
967
|
def handle_clean(rest, ctx):
|
|
631
968
|
json_flag, args = _parse_json_flag(rest)
|
|
632
969
|
service = ctx["service"]
|
|
@@ -926,6 +1263,9 @@ def handle_status(rest, ctx):
|
|
|
926
1263
|
}
|
|
927
1264
|
for item in refresh_errors
|
|
928
1265
|
]
|
|
1266
|
+
update_warning = _update_notice_warning(ctx)
|
|
1267
|
+
if update_warning:
|
|
1268
|
+
warnings.append(update_warning)
|
|
929
1269
|
|
|
930
1270
|
status_progress = None if parsed["json"] else _make_status_progress(ctx)
|
|
931
1271
|
rows = ctx["service"]["get_status_rows"](
|
|
@@ -938,6 +1278,7 @@ def handle_status(rest, ctx):
|
|
|
938
1278
|
return 0
|
|
939
1279
|
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
|
|
940
1280
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
1281
|
+
_write_update_notice(ctx)
|
|
941
1282
|
return 0
|
|
942
1283
|
|
|
943
1284
|
row = next((r for r in rows if r["session_name"] == args[0]), None)
|
|
@@ -948,6 +1289,7 @@ def handle_status(rest, ctx):
|
|
|
948
1289
|
return 0
|
|
949
1290
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
950
1291
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
1292
|
+
_write_update_notice(ctx)
|
|
951
1293
|
return 0
|
|
952
1294
|
|
|
953
1295
|
|
|
@@ -1169,16 +1511,11 @@ def handle_update(rest, ctx):
|
|
|
1169
1511
|
|
|
1170
1512
|
|
|
1171
1513
|
def handle_launch(command, ctx, initial_prompt=None):
|
|
1172
|
-
json_flag = "--json" in ctx["options"].get("raw_args", [])
|
|
1514
|
+
json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
|
|
1173
1515
|
update_notice = ctx.get("update_notice")
|
|
1174
1516
|
warnings = []
|
|
1175
1517
|
if update_notice:
|
|
1176
|
-
warnings.append(
|
|
1177
|
-
"code": "update_available",
|
|
1178
|
-
"message": f"Update available: cdx-manager {update_notice['latest_version']}",
|
|
1179
|
-
"latest_version": update_notice["latest_version"],
|
|
1180
|
-
"url": update_notice.get("url"),
|
|
1181
|
-
})
|
|
1518
|
+
warnings.append(_update_notice_warning(ctx))
|
|
1182
1519
|
session = ctx["service"]["launch_session"](command)
|
|
1183
1520
|
_ensure_session_authentication(
|
|
1184
1521
|
session,
|
|
@@ -1193,15 +1530,51 @@ def handle_launch(command, ctx, initial_prompt=None):
|
|
|
1193
1530
|
message = f"Launching {session['provider']} session {session['name']}"
|
|
1194
1531
|
if not json_flag:
|
|
1195
1532
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1533
|
+
_write_update_notice(ctx)
|
|
1534
|
+
cwd = ctx.get("cwd") or os.getcwd()
|
|
1535
|
+
runtime_run_id = None
|
|
1536
|
+
|
|
1537
|
+
def runtime_lifecycle(event, info):
|
|
1538
|
+
nonlocal runtime_run_id
|
|
1539
|
+
if event == "started":
|
|
1540
|
+
runtime = ctx["service"]["start_session_runtime"](session["name"], info)
|
|
1541
|
+
runtime_run_id = runtime.get("runId")
|
|
1542
|
+
elif event == "finished" and runtime_run_id:
|
|
1543
|
+
ctx["service"]["finish_session_runtime"](
|
|
1544
|
+
session["name"],
|
|
1545
|
+
runtime_run_id,
|
|
1546
|
+
{"status": "stopped", "returncode": info.get("returncode")},
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
try:
|
|
1550
|
+
run_info = _run_interactive_provider_command(
|
|
1551
|
+
session, "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
|
|
1552
|
+
signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
|
|
1553
|
+
lifecycle_callback=runtime_lifecycle,
|
|
1554
|
+
)
|
|
1555
|
+
except CdxError as error:
|
|
1556
|
+
run_info = getattr(error, "run_info", {}) or {}
|
|
1557
|
+
if runtime_run_id:
|
|
1558
|
+
ctx["service"]["finish_session_runtime"](
|
|
1559
|
+
session["name"],
|
|
1560
|
+
runtime_run_id,
|
|
1561
|
+
{"status": "failed", "returncode": run_info.get("returncode")},
|
|
1562
|
+
)
|
|
1563
|
+
if run_info:
|
|
1564
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
1565
|
+
"status": "failed",
|
|
1566
|
+
"cwd": cwd,
|
|
1567
|
+
"error": str(error),
|
|
1568
|
+
"exit_code": error.exit_code,
|
|
1569
|
+
**run_info,
|
|
1570
|
+
})
|
|
1571
|
+
raise
|
|
1572
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
1573
|
+
"status": "success",
|
|
1574
|
+
"cwd": cwd,
|
|
1575
|
+
"exit_code": 0,
|
|
1576
|
+
**run_info,
|
|
1577
|
+
})
|
|
1205
1578
|
if json_flag:
|
|
1206
1579
|
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
1207
1580
|
return 0
|
package/src/cli_render.py
CHANGED
|
@@ -98,6 +98,19 @@ def _dim(text, use_color=False):
|
|
|
98
98
|
return _style(text, "2", use_color)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def _format_launch_text(launch):
|
|
102
|
+
if not launch:
|
|
103
|
+
return "default"
|
|
104
|
+
parts = []
|
|
105
|
+
if launch.get("power"):
|
|
106
|
+
parts.append(launch["power"])
|
|
107
|
+
if launch.get("permission"):
|
|
108
|
+
parts.append(launch["permission"])
|
|
109
|
+
if launch.get("fast") is True:
|
|
110
|
+
parts.append("fast-on")
|
|
111
|
+
return "/".join(parts) or "default"
|
|
112
|
+
|
|
113
|
+
|
|
101
114
|
def format_error(error, env=None, stderr=None):
|
|
102
115
|
return _style(str(error), "31", _should_use_color(env or os.environ, stderr or sys.stderr))
|
|
103
116
|
|
|
@@ -116,40 +129,26 @@ def _format_sessions(service, use_color=False):
|
|
|
116
129
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
117
130
|
table_rows = []
|
|
118
131
|
for r in rows:
|
|
119
|
-
|
|
132
|
+
name = f"{r['name']}*" if r.get("active") else r["name"]
|
|
133
|
+
parts = [name]
|
|
120
134
|
if has_provider:
|
|
121
135
|
parts.append(r.get("provider") or "n/a")
|
|
122
136
|
status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
123
137
|
parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
124
138
|
if has_launch:
|
|
125
139
|
launch = r.get("launch") or {}
|
|
126
|
-
|
|
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))
|
|
140
|
+
parts.append(_dim(_format_launch_text(launch), use_color))
|
|
135
141
|
parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
|
|
136
142
|
table_rows.append(parts)
|
|
137
143
|
lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
|
|
138
144
|
lines += [
|
|
139
145
|
_style("Next actions:", "1", use_color),
|
|
140
|
-
f" {_style('cdx
|
|
141
|
-
f" {_style('cdx
|
|
142
|
-
f" {_style('cdx login <name>', '36', use_color)}",
|
|
143
|
-
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
144
|
-
f" {_style('cdx config <name>', '36', use_color)}",
|
|
146
|
+
f" {_style('cdx status', '36', use_color)}",
|
|
147
|
+
f" {_style('cdx ready', '36', use_color)}",
|
|
145
148
|
f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
|
|
146
|
-
f" {_style('cdx context show', '36', use_color)}",
|
|
147
|
-
f" {_style('cdx handoff <name>', '36', use_color)}",
|
|
148
149
|
f" {_style('cdx handoff <source> <target>', '36', use_color)}",
|
|
149
|
-
f" {_style('cdx
|
|
150
|
-
f" {_style('cdx
|
|
151
|
-
f" {_style('cdx
|
|
152
|
-
f" {_style('cdx rmv <name>', '36', use_color)}",
|
|
153
|
-
f" {_style('cdx status', '36', use_color)}",
|
|
150
|
+
f" {_style('cdx history', '36', use_color)}",
|
|
151
|
+
f" {_style('cdx help', '36', use_color)}",
|
|
152
|
+
f" {_style('cdx update', '36', use_color)}",
|
|
154
153
|
]
|
|
155
154
|
return "\n".join(lines)
|
package/src/provider_runtime.py
CHANGED
|
@@ -277,7 +277,7 @@ def _signal_name(sig):
|
|
|
277
277
|
|
|
278
278
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
279
279
|
env_override=None, signal_emitter=None,
|
|
280
|
-
initial_prompt=None):
|
|
280
|
+
initial_prompt=None, lifecycle_callback=None):
|
|
281
281
|
spawn = spawn or subprocess.Popen
|
|
282
282
|
spec = (
|
|
283
283
|
_build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|
|
@@ -293,11 +293,32 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
293
293
|
**{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
|
|
294
294
|
)
|
|
295
295
|
|
|
296
|
+
start_time = datetime.now(timezone.utc)
|
|
297
|
+
|
|
298
|
+
child_pid = None
|
|
299
|
+
|
|
300
|
+
def run_info(current_spec, returncode=None):
|
|
301
|
+
end_time = datetime.now(timezone.utc)
|
|
302
|
+
return {
|
|
303
|
+
"started_at": start_time.isoformat().replace("+00:00", "Z"),
|
|
304
|
+
"ended_at": end_time.isoformat().replace("+00:00", "Z"),
|
|
305
|
+
"duration_ms": int((end_time - start_time).total_seconds() * 1000),
|
|
306
|
+
"command": current_spec.get("command"),
|
|
307
|
+
"args": list(current_spec.get("args") or []),
|
|
308
|
+
"label": current_spec.get("label"),
|
|
309
|
+
"transcript_path": current_spec.get("transcript_path"),
|
|
310
|
+
"pid": child_pid,
|
|
311
|
+
"returncode": returncode,
|
|
312
|
+
}
|
|
313
|
+
|
|
296
314
|
try:
|
|
297
315
|
child = start_child(spec)
|
|
298
316
|
except FileNotFoundError as error:
|
|
299
317
|
spec = _fallback_launch_spec_or_raise(spec, error)
|
|
300
318
|
child = start_child(spec)
|
|
319
|
+
child_pid = getattr(child, "pid", None) or os.getpid()
|
|
320
|
+
if lifecycle_callback:
|
|
321
|
+
lifecycle_callback("started", run_info(spec))
|
|
301
322
|
|
|
302
323
|
forwarded_signal = None
|
|
303
324
|
handlers = []
|
|
@@ -336,6 +357,9 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
336
357
|
if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
|
|
337
358
|
spec = _fallback_launch_spec_or_raise(spec)
|
|
338
359
|
child = start_child(spec)
|
|
360
|
+
child_pid = getattr(child, "pid", None) or os.getpid()
|
|
361
|
+
if lifecycle_callback:
|
|
362
|
+
lifecycle_callback("started", run_info(spec))
|
|
339
363
|
child.wait()
|
|
340
364
|
finally:
|
|
341
365
|
if use_emitter:
|
|
@@ -352,14 +376,26 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
352
376
|
pass
|
|
353
377
|
|
|
354
378
|
if forwarded_signal is not None:
|
|
355
|
-
|
|
379
|
+
error = CdxError(
|
|
356
380
|
f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
|
|
357
381
|
_signal_exit_code(forwarded_signal),
|
|
358
382
|
)
|
|
383
|
+
error.run_info = run_info(spec, returncode=error.exit_code)
|
|
384
|
+
if lifecycle_callback:
|
|
385
|
+
lifecycle_callback("finished", error.run_info)
|
|
386
|
+
raise error
|
|
359
387
|
if child.returncode != 0:
|
|
360
|
-
|
|
388
|
+
error = CdxError(
|
|
361
389
|
f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
|
|
362
390
|
)
|
|
391
|
+
error.run_info = run_info(spec, returncode=child.returncode)
|
|
392
|
+
if lifecycle_callback:
|
|
393
|
+
lifecycle_callback("finished", error.run_info)
|
|
394
|
+
raise error
|
|
395
|
+
info = run_info(spec, returncode=child.returncode)
|
|
396
|
+
if lifecycle_callback:
|
|
397
|
+
lifecycle_callback("finished", info)
|
|
398
|
+
return info
|
|
363
399
|
|
|
364
400
|
|
|
365
401
|
def _fallback_launch_spec_or_raise(spec, original_error=None):
|
package/src/session_service.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import base64
|
|
5
5
|
import sys
|
|
6
6
|
import tempfile
|
|
7
|
+
import uuid
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
from urllib.parse import quote
|
|
9
10
|
|
|
@@ -28,11 +29,13 @@ RESERVED_SESSION_NAMES = {
|
|
|
28
29
|
"export",
|
|
29
30
|
"help",
|
|
30
31
|
"handoff",
|
|
32
|
+
"history",
|
|
31
33
|
"import",
|
|
32
34
|
"login",
|
|
33
35
|
"logout",
|
|
34
36
|
"mv",
|
|
35
37
|
"notify",
|
|
38
|
+
"ready",
|
|
36
39
|
"repair",
|
|
37
40
|
"ren",
|
|
38
41
|
"rename",
|
|
@@ -116,6 +119,24 @@ def _local_now_iso():
|
|
|
116
119
|
return datetime.now().astimezone().isoformat()
|
|
117
120
|
|
|
118
121
|
|
|
122
|
+
def _process_is_running(pid):
|
|
123
|
+
try:
|
|
124
|
+
pid = int(pid)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
return False
|
|
127
|
+
if pid <= 0:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
os.kill(pid, 0)
|
|
131
|
+
except ProcessLookupError:
|
|
132
|
+
return False
|
|
133
|
+
except PermissionError:
|
|
134
|
+
return True
|
|
135
|
+
except OSError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
119
140
|
def _safe_relpath(path):
|
|
120
141
|
normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
|
|
121
142
|
if (
|
|
@@ -324,6 +345,32 @@ def create_session_service(options=None):
|
|
|
324
345
|
raise CdxError(f"Unsupported provider: {value}")
|
|
325
346
|
return value
|
|
326
347
|
|
|
348
|
+
def _runtime_is_active(runtime):
|
|
349
|
+
return (
|
|
350
|
+
isinstance(runtime, dict)
|
|
351
|
+
and runtime.get("status") == "running"
|
|
352
|
+
and _process_is_running(runtime.get("pid"))
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _session_runtime(name):
|
|
356
|
+
state = store["read_session_state"](name)
|
|
357
|
+
if not state:
|
|
358
|
+
return None
|
|
359
|
+
runtime = state.get("runtime")
|
|
360
|
+
if _runtime_is_active(runtime):
|
|
361
|
+
return runtime
|
|
362
|
+
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
363
|
+
store["write_session_state"](name, {
|
|
364
|
+
**state,
|
|
365
|
+
"status": "ready",
|
|
366
|
+
"runtime": {
|
|
367
|
+
**runtime,
|
|
368
|
+
"status": "stale",
|
|
369
|
+
"endedAt": _local_now_iso(),
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
return None
|
|
373
|
+
|
|
327
374
|
def _validate_new_session_name(name):
|
|
328
375
|
if not name:
|
|
329
376
|
raise CdxError("Session name is required")
|
|
@@ -585,6 +632,56 @@ def create_session_service(options=None):
|
|
|
585
632
|
**s, "updatedAt": now, "lastLaunchedAt": now
|
|
586
633
|
})
|
|
587
634
|
|
|
635
|
+
def start_session_runtime(name, payload=None):
|
|
636
|
+
session = store["get_session"](name)
|
|
637
|
+
if not session:
|
|
638
|
+
raise CdxError(f"Unknown session: {name}")
|
|
639
|
+
state = store["read_session_state"](name)
|
|
640
|
+
if not state:
|
|
641
|
+
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
642
|
+
payload = dict(payload or {})
|
|
643
|
+
now = _local_now_iso()
|
|
644
|
+
runtime = {
|
|
645
|
+
"status": "running",
|
|
646
|
+
"runId": payload.get("runId") or uuid.uuid4().hex,
|
|
647
|
+
"startedAt": payload.get("startedAt") or now,
|
|
648
|
+
"pid": payload.get("pid") or os.getpid(),
|
|
649
|
+
"command": payload.get("command"),
|
|
650
|
+
"label": payload.get("label"),
|
|
651
|
+
"transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
652
|
+
}
|
|
653
|
+
store["write_session_state"](name, {
|
|
654
|
+
**state,
|
|
655
|
+
"status": "running",
|
|
656
|
+
"runtime": runtime,
|
|
657
|
+
})
|
|
658
|
+
return runtime
|
|
659
|
+
|
|
660
|
+
def finish_session_runtime(name, run_id=None, payload=None):
|
|
661
|
+
session = store["get_session"](name)
|
|
662
|
+
if not session:
|
|
663
|
+
raise CdxError(f"Unknown session: {name}")
|
|
664
|
+
state = store["read_session_state"](name)
|
|
665
|
+
if not state:
|
|
666
|
+
return None
|
|
667
|
+
runtime = state.get("runtime") or {}
|
|
668
|
+
if run_id and runtime.get("runId") != run_id:
|
|
669
|
+
return runtime
|
|
670
|
+
payload = dict(payload or {})
|
|
671
|
+
updated_runtime = {
|
|
672
|
+
**runtime,
|
|
673
|
+
"status": payload.get("status") or "stopped",
|
|
674
|
+
"endedAt": payload.get("endedAt") or _local_now_iso(),
|
|
675
|
+
}
|
|
676
|
+
if "returncode" in payload:
|
|
677
|
+
updated_runtime["returncode"] = payload["returncode"]
|
|
678
|
+
store["write_session_state"](name, {
|
|
679
|
+
**state,
|
|
680
|
+
"status": "ready",
|
|
681
|
+
"runtime": updated_runtime,
|
|
682
|
+
})
|
|
683
|
+
return updated_runtime
|
|
684
|
+
|
|
588
685
|
def ensure_session_state(name):
|
|
589
686
|
session = store["get_session"](name)
|
|
590
687
|
if not session:
|
|
@@ -669,6 +766,25 @@ def create_session_service(options=None):
|
|
|
669
766
|
raise CdxError(f"Unknown session: {name}")
|
|
670
767
|
return updated
|
|
671
768
|
|
|
769
|
+
def record_launch_history(name, payload):
|
|
770
|
+
session = store["get_session"](name)
|
|
771
|
+
if not session:
|
|
772
|
+
raise CdxError(f"Unknown session: {name}")
|
|
773
|
+
entry = {
|
|
774
|
+
"schema_version": 1,
|
|
775
|
+
"session_name": session["name"],
|
|
776
|
+
"provider": session["provider"],
|
|
777
|
+
"launch": session.get("launch") or {},
|
|
778
|
+
**payload,
|
|
779
|
+
}
|
|
780
|
+
store["append_launch_history"](entry)
|
|
781
|
+
return entry
|
|
782
|
+
|
|
783
|
+
def get_launch_history(name=None, limit=20):
|
|
784
|
+
if name and not store["get_session"](name):
|
|
785
|
+
raise CdxError(f"Unknown session: {name}")
|
|
786
|
+
return store["list_launch_history"](session_name=name, limit=limit)
|
|
787
|
+
|
|
672
788
|
def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
|
|
673
789
|
current_status = session.get("lastStatus")
|
|
674
790
|
if session.get("enabled", True) is False:
|
|
@@ -806,6 +922,7 @@ def create_session_service(options=None):
|
|
|
806
922
|
"session_name": s["name"],
|
|
807
923
|
"provider": s["provider"],
|
|
808
924
|
"enabled": enabled,
|
|
925
|
+
"active": bool(_session_runtime(s["name"])) if enabled else False,
|
|
809
926
|
"status": "enabled" if enabled else "disabled",
|
|
810
927
|
"remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
|
|
811
928
|
"remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
|
|
@@ -839,6 +956,7 @@ def create_session_service(options=None):
|
|
|
839
956
|
"name": s["name"],
|
|
840
957
|
"provider": s["provider"] if has_multiple else None,
|
|
841
958
|
"enabled": s.get("enabled", True) is not False,
|
|
959
|
+
"active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
|
|
842
960
|
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
843
961
|
"status": s.get("lastStatus"),
|
|
844
962
|
"launch": s.get("launch") or {},
|
|
@@ -992,6 +1110,8 @@ def create_session_service(options=None):
|
|
|
992
1110
|
"copy_session": copy_session,
|
|
993
1111
|
"rename_session": rename_session,
|
|
994
1112
|
"launch_session": launch_session,
|
|
1113
|
+
"start_session_runtime": start_session_runtime,
|
|
1114
|
+
"finish_session_runtime": finish_session_runtime,
|
|
995
1115
|
"ensure_session_state": ensure_session_state,
|
|
996
1116
|
"list_sessions": list_sessions,
|
|
997
1117
|
"get_session": get_session,
|
|
@@ -999,6 +1119,8 @@ def create_session_service(options=None):
|
|
|
999
1119
|
"set_launch_settings": set_launch_settings,
|
|
1000
1120
|
"unset_launch_settings": unset_launch_settings,
|
|
1001
1121
|
"record_status": record_status,
|
|
1122
|
+
"record_launch_history": record_launch_history,
|
|
1123
|
+
"get_launch_history": get_launch_history,
|
|
1002
1124
|
"update_auth_state": update_auth_state,
|
|
1003
1125
|
"get_status_rows": get_status_rows,
|
|
1004
1126
|
"format_list_rows": format_list_rows,
|
package/src/session_store.py
CHANGED
|
@@ -105,6 +105,7 @@ def create_session_store(base_dir):
|
|
|
105
105
|
store_file = os.path.join(base_dir, "sessions.json")
|
|
106
106
|
lock_file = os.path.join(base_dir, ".sessions.lock")
|
|
107
107
|
state_dir = os.path.join(base_dir, "state")
|
|
108
|
+
launch_history_file = os.path.join(state_dir, "launch_history.jsonl")
|
|
108
109
|
|
|
109
110
|
def _state_file_path(name):
|
|
110
111
|
return os.path.join(state_dir, f"{_encode(name)}.json")
|
|
@@ -259,6 +260,39 @@ def create_session_store(base_dir):
|
|
|
259
260
|
with _file_lock(lock_file):
|
|
260
261
|
_write_session_state_unlocked(name, state)
|
|
261
262
|
|
|
263
|
+
def append_launch_history(entry):
|
|
264
|
+
with _file_lock(lock_file):
|
|
265
|
+
_ensure_dir(state_dir)
|
|
266
|
+
with open(launch_history_file, "a", encoding="utf-8") as handle:
|
|
267
|
+
handle.write(json.dumps(entry, separators=(",", ":")))
|
|
268
|
+
handle.write("\n")
|
|
269
|
+
handle.flush()
|
|
270
|
+
os.fsync(handle.fileno())
|
|
271
|
+
_fsync_directory(state_dir)
|
|
272
|
+
|
|
273
|
+
def list_launch_history(session_name=None, limit=20):
|
|
274
|
+
with _file_lock(lock_file):
|
|
275
|
+
try:
|
|
276
|
+
with open(launch_history_file, "r", encoding="utf-8") as handle:
|
|
277
|
+
lines = handle.readlines()
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return []
|
|
280
|
+
entries = []
|
|
281
|
+
for line in reversed(lines):
|
|
282
|
+
line = line.strip()
|
|
283
|
+
if not line:
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
entry = json.loads(line)
|
|
287
|
+
except json.JSONDecodeError as error:
|
|
288
|
+
raise CdxError(f"Corrupt JSONL file: {launch_history_file}") from error
|
|
289
|
+
if session_name and entry.get("session_name") != session_name:
|
|
290
|
+
continue
|
|
291
|
+
entries.append(entry)
|
|
292
|
+
if limit and len(entries) >= limit:
|
|
293
|
+
break
|
|
294
|
+
return entries
|
|
295
|
+
|
|
262
296
|
return {
|
|
263
297
|
"list_sessions": list_sessions,
|
|
264
298
|
"get_session": get_session,
|
|
@@ -269,4 +303,6 @@ def create_session_store(base_dir):
|
|
|
269
303
|
"replace_session": replace_session,
|
|
270
304
|
"read_session_state": read_session_state,
|
|
271
305
|
"write_session_state": write_session_state,
|
|
306
|
+
"append_launch_history": append_launch_history,
|
|
307
|
+
"list_launch_history": list_launch_history,
|
|
272
308
|
}
|
package/src/status_view.py
CHANGED
|
@@ -11,6 +11,7 @@ from .cli_render import (
|
|
|
11
11
|
|
|
12
12
|
RESET_COUNTDOWN_SAFETY_SECONDS = 60
|
|
13
13
|
PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
|
|
14
|
+
BLOCKING_QUOTA_WARNING_THRESHOLD = 10
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _format_reset_time(value):
|
|
@@ -42,7 +43,11 @@ def _format_reset_time(value):
|
|
|
42
43
|
if remaining_minutes == 0:
|
|
43
44
|
return f"in {hours}h"
|
|
44
45
|
return f"in {hours}h {remaining_minutes}m"
|
|
45
|
-
|
|
46
|
+
days = int(delta_s // (24 * 60 * 60))
|
|
47
|
+
hours = int((delta_s % (24 * 60 * 60)) // (60 * 60))
|
|
48
|
+
if hours == 0:
|
|
49
|
+
return f"in {days}d"
|
|
50
|
+
return f"in {days}d {hours}h"
|
|
46
51
|
|
|
47
52
|
|
|
48
53
|
def _style_reset_time(value, use_color=False):
|
|
@@ -77,7 +82,7 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
77
82
|
priority = _recommend_priority_sessions(active_rows)
|
|
78
83
|
table_rows = []
|
|
79
84
|
for r in priority + disabled_rows:
|
|
80
|
-
base = [r
|
|
85
|
+
base = [_format_session_name(r)]
|
|
81
86
|
if has_provider:
|
|
82
87
|
base.append(r.get("provider") or "n/a")
|
|
83
88
|
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
@@ -133,6 +138,10 @@ def _format_current_session_line(rows):
|
|
|
133
138
|
return f"Current: last launched {row['session_name']} ({label})."
|
|
134
139
|
|
|
135
140
|
|
|
141
|
+
def _format_session_name(row):
|
|
142
|
+
return f"{row['session_name']}*" if row.get("active") else row["session_name"]
|
|
143
|
+
|
|
144
|
+
|
|
136
145
|
def _recommend_priority_sessions(rows):
|
|
137
146
|
if not rows:
|
|
138
147
|
return []
|
|
@@ -166,6 +175,9 @@ def _format_blocking_quota(row):
|
|
|
166
175
|
remaining_week = row.get("remaining_week_pct")
|
|
167
176
|
if remaining_5h is None and remaining_week is None:
|
|
168
177
|
return "?"
|
|
178
|
+
known = [value for value in (remaining_5h, remaining_week) if value is not None]
|
|
179
|
+
if known and min(known) > BLOCKING_QUOTA_WARNING_THRESHOLD:
|
|
180
|
+
return "-"
|
|
169
181
|
if remaining_5h is None:
|
|
170
182
|
return "WEEK"
|
|
171
183
|
if remaining_week is None:
|
|
@@ -284,7 +296,7 @@ def _now_timestamp():
|
|
|
284
296
|
|
|
285
297
|
def _format_status_detail(row, use_color=False):
|
|
286
298
|
lines = [
|
|
287
|
-
f"{_style('Session:', '1', use_color)} {row
|
|
299
|
+
f"{_style('Session:', '1', use_color)} {_format_session_name(row)}",
|
|
288
300
|
f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
|
|
289
301
|
f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
|
|
290
302
|
f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
|