cdx-manager 0.5.6 → 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 +66 -4
- package/changelogs/CHANGELOGS_0_5_7.md +45 -0
- package/changelogs/CHANGELOGS_0_6_0.md +47 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +36 -6
- package/src/cli_commands.py +531 -17
- package/src/cli_render.py +27 -12
- package/src/notify.py +229 -6
- package/src/provider_runtime.py +84 -5
- package/src/session_service.py +202 -0
- package/src/session_store.py +36 -0
- package/src/status_view.py +15 -3
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
|
|
|
@@ -105,35 +118,37 @@ def format_error(error, env=None, stderr=None):
|
|
|
105
118
|
def _format_sessions(service, use_color=False):
|
|
106
119
|
rows = service["format_list_rows"]()
|
|
107
120
|
has_provider = any(r.get("provider") for r in rows)
|
|
121
|
+
has_launch = any(r.get("launch") for r in rows)
|
|
108
122
|
headers = ["SESSION"]
|
|
109
123
|
if has_provider:
|
|
110
124
|
headers.append("PROVIDER")
|
|
111
125
|
headers.append("STATUS")
|
|
126
|
+
if has_launch:
|
|
127
|
+
headers.append("LAUNCH")
|
|
112
128
|
headers.append("UPDATED")
|
|
113
129
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
114
130
|
table_rows = []
|
|
115
131
|
for r in rows:
|
|
116
|
-
|
|
132
|
+
name = f"{r['name']}*" if r.get("active") else r["name"]
|
|
133
|
+
parts = [name]
|
|
117
134
|
if has_provider:
|
|
118
135
|
parts.append(r.get("provider") or "n/a")
|
|
119
136
|
status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
120
137
|
parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
138
|
+
if has_launch:
|
|
139
|
+
launch = r.get("launch") or {}
|
|
140
|
+
parts.append(_dim(_format_launch_text(launch), use_color))
|
|
121
141
|
parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
|
|
122
142
|
table_rows.append(parts)
|
|
123
143
|
lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
|
|
124
144
|
lines += [
|
|
125
145
|
_style("Next actions:", "1", use_color),
|
|
126
|
-
f" {_style('cdx add <name>', '36', use_color)}",
|
|
127
|
-
f" {_style('cdx <name>', '36', use_color)}",
|
|
128
|
-
f" {_style('cdx login <name>', '36', use_color)}",
|
|
129
|
-
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
130
|
-
f" {_style('cdx context show', '36', use_color)}",
|
|
131
|
-
f" {_style('cdx handoff <name>', '36', use_color)}",
|
|
132
|
-
f" {_style('cdx handoff <source> <target>', '36', use_color)}",
|
|
133
|
-
f" {_style('cdx disable <name>', '36', use_color)}",
|
|
134
|
-
f" {_style('cdx enable <name>', '36', use_color)}",
|
|
135
|
-
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
136
|
-
f" {_style('cdx rmv <name>', '36', use_color)}",
|
|
137
146
|
f" {_style('cdx status', '36', use_color)}",
|
|
147
|
+
f" {_style('cdx ready', '36', use_color)}",
|
|
148
|
+
f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
|
|
149
|
+
f" {_style('cdx handoff <source> <target>', '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)}",
|
|
138
153
|
]
|
|
139
154
|
return "\n".join(lines)
|
package/src/notify.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import shlex
|
|
3
4
|
import subprocess
|
|
4
5
|
import time
|
|
6
|
+
from datetime import datetime, timedelta
|
|
5
7
|
|
|
6
8
|
from .errors import CdxError
|
|
7
9
|
from .status_view import (
|
|
@@ -12,23 +14,27 @@ from .status_view import (
|
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
|
|
18
|
+
|
|
19
|
+
|
|
15
20
|
def parse_notify_args(args):
|
|
16
21
|
json_flag = "--json" in args
|
|
17
22
|
once = "--once" in args
|
|
18
23
|
at_reset = "--at-reset" in args
|
|
19
24
|
next_ready = "--next-ready" in args
|
|
20
25
|
refresh = "--refresh" in args
|
|
26
|
+
schedule = "--schedule" in args
|
|
21
27
|
poll = 60
|
|
22
28
|
cleaned = []
|
|
23
29
|
i = 0
|
|
24
30
|
while i < len(args):
|
|
25
31
|
arg = args[i]
|
|
26
|
-
if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh"):
|
|
32
|
+
if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh", "--schedule"):
|
|
27
33
|
i += 1
|
|
28
34
|
continue
|
|
29
35
|
if arg == "--poll":
|
|
30
36
|
if i + 1 >= len(args):
|
|
31
|
-
raise CdxError(
|
|
37
|
+
raise CdxError(NOTIFY_USAGE)
|
|
32
38
|
try:
|
|
33
39
|
poll = max(1, int(args[i + 1]))
|
|
34
40
|
except ValueError as error:
|
|
@@ -43,15 +49,15 @@ def parse_notify_args(args):
|
|
|
43
49
|
i += 1
|
|
44
50
|
continue
|
|
45
51
|
if arg.startswith("-"):
|
|
46
|
-
raise CdxError(
|
|
52
|
+
raise CdxError(NOTIFY_USAGE)
|
|
47
53
|
cleaned.append(arg)
|
|
48
54
|
i += 1
|
|
49
55
|
if at_reset == next_ready:
|
|
50
|
-
raise CdxError(
|
|
56
|
+
raise CdxError(NOTIFY_USAGE)
|
|
51
57
|
if at_reset and len(cleaned) != 1:
|
|
52
|
-
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh]")
|
|
58
|
+
raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh]")
|
|
53
59
|
if next_ready and cleaned:
|
|
54
|
-
raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
|
|
60
|
+
raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]")
|
|
55
61
|
return {
|
|
56
62
|
"name": cleaned[0] if cleaned else None,
|
|
57
63
|
"mode": "at-reset" if at_reset else "next-ready",
|
|
@@ -59,6 +65,7 @@ def parse_notify_args(args):
|
|
|
59
65
|
"once": once,
|
|
60
66
|
"json": json_flag,
|
|
61
67
|
"refresh": refresh,
|
|
68
|
+
"schedule": schedule,
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
|
|
@@ -212,9 +219,225 @@ def _escape_applescript(value):
|
|
|
212
219
|
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
213
220
|
|
|
214
221
|
|
|
222
|
+
def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=None, now_fn=None):
|
|
223
|
+
import sys
|
|
224
|
+
spawn_sync = spawn_sync or subprocess.run
|
|
225
|
+
env = env or os.environ
|
|
226
|
+
now_fn = now_fn or time.time
|
|
227
|
+
target_timestamp = event.get("target_timestamp")
|
|
228
|
+
if target_timestamp is None:
|
|
229
|
+
raise CdxError(f"Cannot schedule notification: {event['message']}")
|
|
230
|
+
if target_timestamp <= now_fn():
|
|
231
|
+
return {
|
|
232
|
+
"scheduled": False,
|
|
233
|
+
"backend": "immediate",
|
|
234
|
+
"message": event["message"],
|
|
235
|
+
"target_timestamp": target_timestamp,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
argv = _scheduled_notify_argv(parsed, env)
|
|
239
|
+
if sys.platform == "darwin":
|
|
240
|
+
result = _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env)
|
|
241
|
+
elif sys.platform == "win32":
|
|
242
|
+
result = _schedule_windows_task(parsed, event, argv, spawn_sync, env)
|
|
243
|
+
else:
|
|
244
|
+
result = _schedule_linux(parsed, event, argv, spawn_sync, env)
|
|
245
|
+
result["target_timestamp"] = target_timestamp
|
|
246
|
+
result["target_iso"] = _timestamp_to_local_iso(target_timestamp)
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _scheduled_notify_argv(parsed, env):
|
|
251
|
+
executable = env.get("CDX_BIN") or shutil_which("cdx", env) or "cdx"
|
|
252
|
+
argv = [executable, "notify"]
|
|
253
|
+
if parsed["mode"] == "at-reset":
|
|
254
|
+
argv.append(parsed["name"])
|
|
255
|
+
argv.append("--at-reset")
|
|
256
|
+
else:
|
|
257
|
+
argv.append("--next-ready")
|
|
258
|
+
argv.append("--once")
|
|
259
|
+
argv.append("--refresh")
|
|
260
|
+
return argv
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
|
|
264
|
+
label = _schedule_id("com.cdx-manager.notify", parsed, event)
|
|
265
|
+
schedule_dir = os.path.join(base_dir, "state", "notifications")
|
|
266
|
+
os.makedirs(schedule_dir, mode=0o700, exist_ok=True)
|
|
267
|
+
script_path = os.path.join(schedule_dir, f"{label}.sh")
|
|
268
|
+
launch_agents = os.path.join(os.path.expanduser("~"), "Library", "LaunchAgents")
|
|
269
|
+
os.makedirs(launch_agents, exist_ok=True)
|
|
270
|
+
plist_path = os.path.join(launch_agents, f"{label}.plist")
|
|
271
|
+
target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
|
|
272
|
+
script = _macos_schedule_script(argv, env, label, plist_path, script_path)
|
|
273
|
+
with open(script_path, "w", encoding="utf-8") as f:
|
|
274
|
+
f.write(script)
|
|
275
|
+
try:
|
|
276
|
+
os.chmod(script_path, 0o700)
|
|
277
|
+
except OSError:
|
|
278
|
+
pass
|
|
279
|
+
with open(plist_path, "w", encoding="utf-8") as f:
|
|
280
|
+
f.write(_launchd_plist(label, script_path, target))
|
|
281
|
+
result = _run_scheduler_command(["launchctl", "bootstrap", f"gui/{os.getuid()}", plist_path], spawn_sync, env)
|
|
282
|
+
if not result["ok"]:
|
|
283
|
+
if _scheduler_error_means_exists(result["error"]):
|
|
284
|
+
return {"scheduled": True, "existing": True, "backend": "launchd", "id": label, "path": plist_path}
|
|
285
|
+
result = _run_scheduler_command(["launchctl", "load", plist_path], spawn_sync, env)
|
|
286
|
+
if not result["ok"]:
|
|
287
|
+
if _scheduler_error_means_exists(result["error"]):
|
|
288
|
+
return {"scheduled": True, "existing": True, "backend": "launchd", "id": label, "path": plist_path}
|
|
289
|
+
raise CdxError(f"Failed to schedule notification with launchd: {result['error']}")
|
|
290
|
+
return {"scheduled": True, "existing": False, "backend": "launchd", "id": label, "path": plist_path}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _macos_schedule_script(argv, env, label, plist_path, script_path):
|
|
294
|
+
lines = ["#!/bin/sh", f"export PATH={shlex.quote(env.get('PATH', '/usr/local/bin:/usr/bin:/bin'))}"]
|
|
295
|
+
if env.get("CDX_HOME"):
|
|
296
|
+
lines.append(f"export CDX_HOME={shlex.quote(env['CDX_HOME'])}")
|
|
297
|
+
lines.extend([
|
|
298
|
+
f"{' '.join(shlex.quote(str(part)) for part in argv)}",
|
|
299
|
+
"status=$?",
|
|
300
|
+
f"launchctl bootout {shlex.quote('gui/' + str(os.getuid()) + '/' + label)} >/dev/null 2>&1 || launchctl unload {shlex.quote(plist_path)} >/dev/null 2>&1 || true",
|
|
301
|
+
f"rm -f {shlex.quote(plist_path)} {shlex.quote(script_path)}",
|
|
302
|
+
"exit $status",
|
|
303
|
+
"",
|
|
304
|
+
])
|
|
305
|
+
return "\n".join(lines)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _launchd_plist(label, script_path, target):
|
|
309
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
310
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
311
|
+
<plist version="1.0">
|
|
312
|
+
<dict>
|
|
313
|
+
<key>Label</key>
|
|
314
|
+
<string>{_escape_xml(label)}</string>
|
|
315
|
+
<key>ProgramArguments</key>
|
|
316
|
+
<array>
|
|
317
|
+
<string>/bin/sh</string>
|
|
318
|
+
<string>{_escape_xml(script_path)}</string>
|
|
319
|
+
</array>
|
|
320
|
+
<key>StartCalendarInterval</key>
|
|
321
|
+
<dict>
|
|
322
|
+
<key>Month</key><integer>{target.month}</integer>
|
|
323
|
+
<key>Day</key><integer>{target.day}</integer>
|
|
324
|
+
<key>Hour</key><integer>{target.hour}</integer>
|
|
325
|
+
<key>Minute</key><integer>{target.minute}</integer>
|
|
326
|
+
</dict>
|
|
327
|
+
</dict>
|
|
328
|
+
</plist>
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
333
|
+
unit = _schedule_id("cdx-manager-notify", parsed, event)
|
|
334
|
+
target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
|
|
335
|
+
if shutil_which("systemd-run", env):
|
|
336
|
+
calendar = target.strftime("%Y-%m-%d %H:%M:%S")
|
|
337
|
+
result = _run_scheduler_command(
|
|
338
|
+
["systemd-run", "--user", f"--unit={unit}", f"--on-calendar={calendar}", *argv],
|
|
339
|
+
spawn_sync,
|
|
340
|
+
env,
|
|
341
|
+
)
|
|
342
|
+
if result["ok"]:
|
|
343
|
+
return {"scheduled": True, "existing": False, "backend": "systemd", "id": unit}
|
|
344
|
+
if _scheduler_error_means_exists(result["error"]):
|
|
345
|
+
return {"scheduled": True, "existing": True, "backend": "systemd", "id": unit}
|
|
346
|
+
raise CdxError(f"Failed to schedule notification with systemd-run: {result['error']}")
|
|
347
|
+
if shutil_which("at", env):
|
|
348
|
+
at_time = target.strftime("%Y%m%d%H%M.%S")
|
|
349
|
+
command = " ".join(shlex.quote(str(part)) for part in argv)
|
|
350
|
+
result = _run_scheduler_command(["at", "-t", at_time], spawn_sync, env, input_text=f"{command}\n")
|
|
351
|
+
if result["ok"]:
|
|
352
|
+
return {"scheduled": True, "existing": False, "backend": "at", "id": unit}
|
|
353
|
+
raise CdxError(f"Failed to schedule notification with at: {result['error']}")
|
|
354
|
+
raise CdxError("Cannot schedule notification: install systemd-run or at, or run cdx notify without --schedule")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _schedule_windows_task(parsed, event, argv, spawn_sync, env):
|
|
358
|
+
name = _schedule_id("cdx-manager-notify", parsed, event)
|
|
359
|
+
target = datetime.fromtimestamp(event["target_timestamp"]).astimezone()
|
|
360
|
+
command = subprocess.list2cmdline([str(part) for part in argv])
|
|
361
|
+
result = _run_scheduler_command([
|
|
362
|
+
"schtasks",
|
|
363
|
+
"/Create",
|
|
364
|
+
"/SC",
|
|
365
|
+
"ONCE",
|
|
366
|
+
"/TN",
|
|
367
|
+
name,
|
|
368
|
+
"/TR",
|
|
369
|
+
command,
|
|
370
|
+
"/ST",
|
|
371
|
+
target.strftime("%H:%M"),
|
|
372
|
+
"/SD",
|
|
373
|
+
target.strftime("%m/%d/%Y"),
|
|
374
|
+
"/F",
|
|
375
|
+
"/Z",
|
|
376
|
+
], spawn_sync, env)
|
|
377
|
+
if not result["ok"]:
|
|
378
|
+
raise CdxError(f"Failed to schedule notification with Task Scheduler: {result['error']}")
|
|
379
|
+
return {"scheduled": True, "existing": False, "backend": "schtasks", "id": name}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _run_scheduler_command(argv, spawn_sync, env, input_text=None):
|
|
383
|
+
try:
|
|
384
|
+
completed = spawn_sync(
|
|
385
|
+
argv,
|
|
386
|
+
env=env,
|
|
387
|
+
input=input_text,
|
|
388
|
+
capture_output=True,
|
|
389
|
+
text=True,
|
|
390
|
+
timeout=10,
|
|
391
|
+
)
|
|
392
|
+
except (FileNotFoundError, OSError) as error:
|
|
393
|
+
return {"ok": False, "error": str(error)}
|
|
394
|
+
return {
|
|
395
|
+
"ok": getattr(completed, "returncode", 0) == 0,
|
|
396
|
+
"error": (getattr(completed, "stderr", "") or getattr(completed, "stdout", "") or "").strip(),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _schedule_id(prefix, parsed, event):
|
|
401
|
+
session = parsed.get("name") or event.get("session") or "next-ready"
|
|
402
|
+
safe_session = "".join(ch if ch.isalnum() else "-" for ch in str(session).lower()).strip("-") or "session"
|
|
403
|
+
return f"{prefix}.{parsed['mode']}.{safe_session}.{int(event['target_timestamp'])}"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _scheduler_error_means_exists(error):
|
|
407
|
+
normalized = str(error or "").lower()
|
|
408
|
+
return any(fragment in normalized for fragment in (
|
|
409
|
+
"already exists",
|
|
410
|
+
"file exists",
|
|
411
|
+
"unit exists",
|
|
412
|
+
"unit already",
|
|
413
|
+
"service already",
|
|
414
|
+
"bootstrap failed: 5",
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _timestamp_to_local_iso(timestamp):
|
|
419
|
+
return datetime.fromtimestamp(timestamp).astimezone().isoformat()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _round_up_to_next_minute(value):
|
|
423
|
+
if value.second == 0 and value.microsecond == 0:
|
|
424
|
+
return value
|
|
425
|
+
return (value + timedelta(minutes=1)).replace(second=0, microsecond=0)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _escape_xml(value):
|
|
429
|
+
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
430
|
+
|
|
431
|
+
|
|
215
432
|
def format_notify_event(event):
|
|
216
433
|
return event["message"]
|
|
217
434
|
|
|
218
435
|
|
|
436
|
+
def format_scheduled_notification(schedule):
|
|
437
|
+
if schedule.get("scheduled"):
|
|
438
|
+
return f"Scheduled notification via {schedule['backend']} for {schedule['target_iso']}"
|
|
439
|
+
return schedule["message"]
|
|
440
|
+
|
|
441
|
+
|
|
219
442
|
def notify_json(event):
|
|
220
443
|
return json.dumps(event, indent=2)
|
package/src/provider_runtime.py
CHANGED
|
@@ -12,6 +12,20 @@ from .errors import CdxError
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
15
|
+
LAUNCH_PERMISSION_ARGS = {
|
|
16
|
+
PROVIDER_CLAUDE: {
|
|
17
|
+
"review": ["--permission-mode", "plan"],
|
|
18
|
+
"default": ["--permission-mode", "default"],
|
|
19
|
+
"auto": ["--permission-mode", "auto"],
|
|
20
|
+
"full": ["--permission-mode", "bypassPermissions"],
|
|
21
|
+
},
|
|
22
|
+
PROVIDER_CODEX: {
|
|
23
|
+
"review": ["-s", "read-only", "-a", "on-request"],
|
|
24
|
+
"default": ["-s", "workspace-write", "-a", "on-request"],
|
|
25
|
+
"auto": ["-s", "workspace-write", "-a", "never"],
|
|
26
|
+
"full": ["-s", "danger-full-access", "-a", "never"],
|
|
27
|
+
},
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
|
|
17
31
|
def _home_env_overrides(auth_home):
|
|
@@ -49,6 +63,35 @@ def _build_launch_transcript_path(session):
|
|
|
49
63
|
)
|
|
50
64
|
|
|
51
65
|
|
|
66
|
+
def _launch_power(session):
|
|
67
|
+
launch = session.get("launch") or {}
|
|
68
|
+
power = launch.get("power")
|
|
69
|
+
if power:
|
|
70
|
+
return power
|
|
71
|
+
if launch.get("fast") is True:
|
|
72
|
+
return "low"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _launch_config_args(session):
|
|
77
|
+
launch = session.get("launch") or {}
|
|
78
|
+
args = []
|
|
79
|
+
power = _launch_power(session)
|
|
80
|
+
permission = launch.get("permission")
|
|
81
|
+
provider = session["provider"]
|
|
82
|
+
if provider == PROVIDER_CLAUDE:
|
|
83
|
+
if power:
|
|
84
|
+
args += ["--effort", power]
|
|
85
|
+
if permission:
|
|
86
|
+
args += LAUNCH_PERMISSION_ARGS[PROVIDER_CLAUDE].get(permission, [])
|
|
87
|
+
return args
|
|
88
|
+
if power:
|
|
89
|
+
args += ["-c", f'model_reasoning_effort="{power}"']
|
|
90
|
+
if permission:
|
|
91
|
+
args += LAUNCH_PERMISSION_ARGS[PROVIDER_CODEX].get(permission, [])
|
|
92
|
+
return args
|
|
93
|
+
|
|
94
|
+
|
|
52
95
|
def _list_launch_transcript_paths(session, glob_fn=None):
|
|
53
96
|
import glob
|
|
54
97
|
|
|
@@ -109,7 +152,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
109
152
|
env_override = env_override or {}
|
|
110
153
|
env = {**os.environ, **env_override}
|
|
111
154
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
112
|
-
args = ["--name", session["name"]]
|
|
155
|
+
args = ["--name", session["name"]] + _launch_config_args(session)
|
|
113
156
|
if initial_prompt:
|
|
114
157
|
args.append(initial_prompt)
|
|
115
158
|
return _wrap_launch_with_transcript(session, {
|
|
@@ -121,7 +164,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
121
164
|
},
|
|
122
165
|
"label": "claude",
|
|
123
166
|
}, env=env)
|
|
124
|
-
args = ["--no-alt-screen", "--cd", cwd]
|
|
167
|
+
args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
|
|
125
168
|
if initial_prompt:
|
|
126
169
|
args.append(initial_prompt)
|
|
127
170
|
return _wrap_launch_with_transcript(session, {
|
|
@@ -234,7 +277,7 @@ def _signal_name(sig):
|
|
|
234
277
|
|
|
235
278
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
236
279
|
env_override=None, signal_emitter=None,
|
|
237
|
-
initial_prompt=None):
|
|
280
|
+
initial_prompt=None, lifecycle_callback=None):
|
|
238
281
|
spawn = spawn or subprocess.Popen
|
|
239
282
|
spec = (
|
|
240
283
|
_build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
|
|
@@ -250,11 +293,32 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
250
293
|
**{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
|
|
251
294
|
)
|
|
252
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
|
+
|
|
253
314
|
try:
|
|
254
315
|
child = start_child(spec)
|
|
255
316
|
except FileNotFoundError as error:
|
|
256
317
|
spec = _fallback_launch_spec_or_raise(spec, error)
|
|
257
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))
|
|
258
322
|
|
|
259
323
|
forwarded_signal = None
|
|
260
324
|
handlers = []
|
|
@@ -293,6 +357,9 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
293
357
|
if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
|
|
294
358
|
spec = _fallback_launch_spec_or_raise(spec)
|
|
295
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))
|
|
296
363
|
child.wait()
|
|
297
364
|
finally:
|
|
298
365
|
if use_emitter:
|
|
@@ -309,14 +376,26 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
309
376
|
pass
|
|
310
377
|
|
|
311
378
|
if forwarded_signal is not None:
|
|
312
|
-
|
|
379
|
+
error = CdxError(
|
|
313
380
|
f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
|
|
314
381
|
_signal_exit_code(forwarded_signal),
|
|
315
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
|
|
316
387
|
if child.returncode != 0:
|
|
317
|
-
|
|
388
|
+
error = CdxError(
|
|
318
389
|
f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
|
|
319
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
|
|
320
399
|
|
|
321
400
|
|
|
322
401
|
def _fallback_launch_spec_or_raise(spec, original_error=None):
|