qbraid-cli 0.16.13__tar.gz → 0.16.15__tar.gz

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.
Files changed (75) hide show
  1. {qbraid_cli-0.16.13/qbraid_cli.egg-info → qbraid_cli-0.16.15}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/app.py +230 -76
  4. qbraid_cli-0.16.15/qbraid_cli/agents/keep_alive.py +357 -0
  5. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15/qbraid_cli.egg-info}/PKG-INFO +4 -4
  6. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/SOURCES.txt +1 -0
  7. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/requires.txt +3 -3
  8. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/tests/test_cli_e2e.py +30 -1
  9. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/LICENSE +0 -0
  10. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/README.md +0 -0
  11. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/__init__.py +0 -0
  12. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/_build_info.py +0 -0
  13. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/_version.py +0 -0
  14. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/account/__init__.py +0 -0
  15. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/account/app.py +0 -0
  16. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/__init__.py +0 -0
  17. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/app.py +0 -0
  18. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/headers.py +0 -0
  19. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/validation.py +0 -0
  20. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/__init__.py +0 -0
  21. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/permission_response.py +0 -0
  22. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/provider_children.py +0 -0
  23. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/queue.py +0 -0
  24. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/work.py +0 -0
  25. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/ai/__init__.py +0 -0
  26. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/ai/app.py +0 -0
  27. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/ai/token.py +0 -0
  28. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/comms/__init__.py +0 -0
  29. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/comms/app.py +0 -0
  30. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/__init__.py +0 -0
  31. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/app.py +0 -0
  32. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/instances.py +0 -0
  33. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/profiles.py +0 -0
  34. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/server.py +0 -0
  35. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/ssh.py +0 -0
  36. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/configure/__init__.py +0 -0
  37. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/configure/actions.py +0 -0
  38. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/configure/app.py +0 -0
  39. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/devices/__init__.py +0 -0
  40. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/devices/app.py +0 -0
  41. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/devices/validation.py +0 -0
  42. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/__init__.py +0 -0
  43. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/activate.py +0 -0
  44. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/app.py +0 -0
  45. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/create.py +0 -0
  46. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/data_handling.py +0 -0
  47. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/groups.py +0 -0
  48. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/publish.py +0 -0
  49. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/registry.py +0 -0
  50. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/share.py +0 -0
  51. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/exceptions.py +0 -0
  52. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/handlers.py +0 -0
  53. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/jobs/__init__.py +0 -0
  54. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/jobs/app.py +0 -0
  55. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/jobs/groups.py +0 -0
  56. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/kernels/__init__.py +0 -0
  57. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/kernels/app.py +0 -0
  58. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/lab/__init__.py +0 -0
  59. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/lab/app.py +0 -0
  60. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/main.py +0 -0
  61. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/pip/__init__.py +0 -0
  62. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/pip/app.py +0 -0
  63. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/pip/hooks.py +0 -0
  64. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/projects/__init__.py +0 -0
  65. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/projects/app.py +0 -0
  66. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/py.typed +0 -0
  67. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/skills/__init__.py +0 -0
  68. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/skills/app.py +0 -0
  69. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/troubleshoot/__init__.py +0 -0
  70. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/troubleshoot/app.py +0 -0
  71. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  72. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/entry_points.txt +0 -0
  73. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/top_level.txt +0 -0
  74. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/setup.cfg +0 -0
  75. {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-cli
3
- Version: 0.16.13
3
+ Version: 0.16.15
4
4
  Summary: Command Line Interface for interacting with all parts of the qBraid platform.
5
5
  Author-email: qBraid Development Team <contact@qbraid.com>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
30
30
  Requires-Dist: rich>=10.11.0
31
31
  Requires-Dist: click<=8.5.0
32
32
  Requires-Dist: questionary>=2.0.0
33
- Requires-Dist: qbraid-core>=0.6.32
33
+ Requires-Dist: qbraid-core>=0.6.34
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.32; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.34; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.32; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.34; extra == "envs"
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: isort; extra == "dev"
40
40
  Requires-Dist: black; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qbraid-cli"
7
- version = "0.16.13"
7
+ version = "0.16.15"
8
8
  description = "Command Line Interface for interacting with all parts of the qBraid platform."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "qBraid Development Team", email = "contact@qbraid.com" }]
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "rich>=10.11.0",
33
33
  "click<=8.5.0",
34
34
  "questionary>=2.0.0",
35
- "qbraid-core>=0.6.32",
35
+ "qbraid-core>=0.6.34",
36
36
  ]
37
37
  requires-python = ">= 3.10"
38
38
 
@@ -43,8 +43,8 @@ Documentation = "https://docs.qbraid.com/v2/cli/api-reference/qbraid"
43
43
  Discord = "https://discord.gg/KugF6Cnncm"
44
44
 
45
45
  [project.optional-dependencies]
46
- compute = ["qbraid-core[compute]>=0.6.32"]
47
- envs = ["qbraid-core[environments]>=0.6.32"]
46
+ compute = ["qbraid-core[compute]>=0.6.34"]
47
+ envs = ["qbraid-core[environments]>=0.6.34"]
48
48
  dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
49
49
 
50
50
  [project.scripts]
@@ -3,12 +3,14 @@
3
3
 
4
4
  """Agents CLI - Monitor and manage AI coding agent sessions."""
5
5
 
6
+ import errno
6
7
  import inspect
7
8
  import json
8
9
  import os
9
10
  import re
10
11
  import signal
11
12
  import time
13
+ from contextlib import nullcontext
12
14
  from datetime import datetime
13
15
  from enum import Enum
14
16
  from pathlib import Path
@@ -23,6 +25,7 @@ from rich.table import Table
23
25
 
24
26
  from qbraid_cli.handlers import handle_error, run_progress_task
25
27
 
28
+ from .keep_alive import KeepAliveCommandError, keep_alive_app, stop_plan
26
29
  from .queue import interrupt, queue_app
27
30
  from .work import register_work_commands
28
31
 
@@ -55,6 +58,7 @@ hooks_app = typer.Typer(
55
58
  )
56
59
 
57
60
  agents_app.add_typer(hooks_app, name="hooks")
61
+ agents_app.add_typer(keep_alive_app, name="keep-alive")
58
62
  agents_app.add_typer(queue_app, name="queue")
59
63
  agents_app.command("interrupt")(interrupt)
60
64
 
@@ -452,17 +456,23 @@ def _not_running_reason(session):
452
456
  return None
453
457
 
454
458
 
455
- def _signal_agent(pid, force):
459
+ def _signal_agent(pid, force, *, quiet=False):
456
460
  sig = signal.SIGKILL if force else signal.SIGTERM
457
461
  sig_name = "SIGKILL" if force else "SIGTERM"
458
462
  try:
459
463
  os.kill(pid, sig)
460
- console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
461
- except (OSError, ProcessLookupError):
462
- console.print(f"[dim]Process {pid} already stopped.[/dim]")
464
+ if not quiet:
465
+ console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
466
+ except OSError as error:
467
+ if not isinstance(error, ProcessLookupError) and error.errno != errno.ESRCH:
468
+ raise KeepAliveCommandError(
469
+ "STOP_FAILED", f"Stop was not confirmed for PID {pid}: {error}. Inspect its current status."
470
+ ) from error
471
+ if not quiet:
472
+ console.print(f"[dim]Process {pid} already stopped.[/dim]")
463
473
 
464
474
 
465
- def _kill_tmux_session(session):
475
+ def _kill_tmux_session(session, *, launcher=None, confirmation=None, as_json=False):
466
476
  """Stop a tmux-hosted agent only through a verified target.
467
477
 
468
478
  A saved window name is mutable: another session may hold it now. Core's
@@ -472,25 +482,150 @@ def _kill_tmux_session(session):
472
482
  """
473
483
  from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
474
484
 
475
- launcher = AgentLauncher()
485
+ launcher = launcher or AgentLauncher()
476
486
  if not callable(getattr(launcher, "kill_bound", None)):
477
487
  # Core has had kill_bound since 0.6.5; a saved window name alone is
478
488
  # never trusted, so without it nothing is closed.
479
- console.print(
480
- "[red]Not stopped:[/red] this qbraid-core cannot verify a tmux window; update qbraid-core.",
481
- highlight=False,
482
- )
483
- raise typer.Exit(code=1)
489
+ _stop_refused("This qbraid-core cannot verify a tmux window; update qbraid-core.", as_json)
484
490
  try:
485
- launcher.kill_bound(session.session_id)
491
+ launcher.kill_bound(session.session_id, **_stop_options(confirmation))
486
492
  except AgentLauncherError as error:
487
- console.print(
488
- "[red]Not stopped: Core could not verify this session's tmux window.[/red] "
489
- f"{escape(str(error))}",
490
- highlight=False,
493
+ if as_json:
494
+ from .keep_alive import _error
495
+
496
+ _error(error, True)
497
+ _stop_refused(f"Core could not verify this session's tmux window. {error}", False)
498
+ _stop_success(session, as_json, f"Stopped the agent in tmux window '{session.tmux_window}'.")
499
+
500
+
501
+ def _stop_options(confirmation):
502
+ return {"keep_alive_confirmation": confirmation} if confirmation is not None else {}
503
+
504
+
505
+ def _stop_refused(message, as_json, code="STOP_REFUSED"):
506
+ from .keep_alive import _error
507
+ from .keep_alive import _plain as safe_text
508
+
509
+ if as_json:
510
+ _error(KeepAliveCommandError(code, message), True)
511
+ console.print(f"[red]Not stopped:[/red] {escape(safe_text(message))}", highlight=False)
512
+ raise typer.Exit(code=1)
513
+
514
+
515
+ def _stop_success(session, as_json, message):
516
+ from .keep_alive import _plain as safe_text
517
+
518
+ if as_json:
519
+ typer.echo(json.dumps({"ok": True, "sessionId": session.session_id, "stopped": True}))
520
+ else:
521
+ console.print(f"[green]{escape(safe_text(message))}[/green]", highlight=False)
522
+
523
+
524
+ def _require_remote_stop_cli(compute):
525
+ """Inspect help before any Stop; a legacy CLI cannot provide consent receipts."""
526
+ import subprocess
527
+
528
+ try:
529
+ probe = subprocess.run(
530
+ [
531
+ "ssh",
532
+ "-T",
533
+ "-o",
534
+ "BatchMode=yes",
535
+ "-o",
536
+ "ConnectTimeout=10",
537
+ compute,
538
+ "qbraid agents kill --help",
539
+ ],
540
+ capture_output=True,
541
+ text=True,
542
+ check=False,
543
+ timeout=30,
544
+ stdin=subprocess.DEVNULL,
545
+ )
546
+ except (OSError, subprocess.TimeoutExpired) as error:
547
+ raise KeepAliveCommandError(
548
+ "DESTINATION_UNAVAILABLE",
549
+ "Could not inspect destination Stop capabilities. No Stop was attempted.",
550
+ {"attempted": False},
551
+ ) from error
552
+ if probe.returncode == 255:
553
+ raise KeepAliveCommandError(
554
+ "DESTINATION_UNAVAILABLE",
555
+ "Destination SSH connection failed during the capability check. No Stop was attempted.",
556
+ {"attempted": False},
557
+ )
558
+ help_text = re.sub(r"\x1b\[[0-9;]*m", "", probe.stdout)
559
+ if probe.returncode or not all(option in help_text for option in ("--json", "--yes")):
560
+ raise KeepAliveCommandError(
561
+ "UNSUPPORTED_OPERATION",
562
+ "Update the destination qbraid-cli for confirmed remote Stop. No Stop was attempted.",
563
+ {"attempted": False},
564
+ )
565
+
566
+
567
+ def _remote_stop(compute, target, force, yes, as_json):
568
+ """New destination CLI owns consent; unknown authority cannot run legacy kill."""
569
+ import shlex
570
+ import subprocess
571
+
572
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.@:-]{0,254}", compute):
573
+ raise KeepAliveCommandError("INVALID_ARGUMENT", "Use a configured SSH alias for --compute.")
574
+ _require_remote_stop_cli(compute)
575
+ command = "qbraid agents kill --json"
576
+ command += " --force" if force else ""
577
+ command += " --yes" if yes else ""
578
+ command += f" -- {shlex.quote(target)}"
579
+ try:
580
+ response = subprocess.run(
581
+ ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", compute, command],
582
+ capture_output=True,
583
+ text=True,
584
+ check=False,
585
+ timeout=30,
586
+ stdin=subprocess.DEVNULL,
587
+ )
588
+ except (OSError, subprocess.TimeoutExpired) as error:
589
+ raise KeepAliveCommandError(
590
+ "UNKNOWN_OUTCOME",
591
+ "Remote Stop was not confirmed. It was not retried; inspect the destination.",
592
+ ) from error
593
+ try:
594
+ result = json.loads(response.stdout)
595
+ except (ValueError, UnicodeError, RecursionError):
596
+ raise KeepAliveCommandError(
597
+ "UNKNOWN_OUTCOME",
598
+ "Destination Stop could not be verified. Update its CLI/Core for confirmation support. "
599
+ "No command was retried; inspect the destination before retrying.",
600
+ ) from None
601
+ if not isinstance(result, dict) or not isinstance(result.get("ok"), bool):
602
+ raise KeepAliveCommandError(
603
+ "UNKNOWN_OUTCOME", "Destination Stop receipt was invalid; no command was retried."
604
+ )
605
+ if not result["ok"]:
606
+ failure = result.get("error")
607
+ if not isinstance(failure, dict) or not isinstance(failure.get("message"), str):
608
+ raise KeepAliveCommandError(
609
+ "UNKNOWN_OUTCOME", "Destination Stop error was invalid; no command was retried."
610
+ )
611
+ raise KeepAliveCommandError(
612
+ failure.get("code") or "STOP_REFUSED", failure["message"], failure.get("details")
491
613
  )
492
- raise typer.Exit(code=1) from error
493
- console.print(f"[green]Stopped the agent in tmux window '{escape(session.tmux_window)}'.[/green]")
614
+ if (
615
+ response.returncode
616
+ or result.get("stopped") is not True
617
+ or not isinstance(result.get("sessionId"), str)
618
+ or not result["sessionId"]
619
+ ):
620
+ raise KeepAliveCommandError(
621
+ "UNKNOWN_OUTCOME", "Destination did not confirm an exact stopped session; no command was retried."
622
+ )
623
+ if as_json:
624
+ typer.echo(json.dumps(result, ensure_ascii=True))
625
+ else:
626
+ from .keep_alive import _plain as safe_text
627
+
628
+ console.print(f"[green]Stopped '{escape(safe_text(target))}' on {escape(compute)}.[/green]")
494
629
 
495
630
 
496
631
  _GONE = "gone"
@@ -896,12 +1031,19 @@ def agents_discover(
896
1031
  include_error_traceback=False,
897
1032
  )
898
1033
 
1034
+ registered_count = registry.merge_discovered_sessions(discovered) if discovered else 0
1035
+ # Refresh native provider children even when discovery found no new
1036
+ # process sessions. Their lifecycle is owned by the provider transcript
1037
+ # observer, not by process discovery; without this refresh a stopped
1038
+ # Claude child can remain "working" in the registry indefinitely.
1039
+ refresh = getattr(registry, "refresh_discovered_sessions", None)
1040
+ if callable(refresh):
1041
+ refresh()
1042
+
899
1043
  if not discovered:
900
1044
  console.print("[dim]No agent sessions found.[/dim]")
901
1045
  return
902
1046
 
903
- registered_count = registry.merge_discovered_sessions(discovered)
904
-
905
1047
  table = Table(title=f"Discovered Sessions ({len(discovered)})")
906
1048
  table.add_column("Session ID", style="cyan", no_wrap=True)
907
1049
  table.add_column("Tool")
@@ -1258,6 +1400,8 @@ def agents_kill(
1258
1400
  target: str = typer.Argument(..., help="Agent name, ID prefix, or PID"),
1259
1401
  force: bool = typer.Option(False, "--force", "-f", help="Use SIGKILL instead of SIGTERM"),
1260
1402
  compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
1403
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm disabling keep-alive before Stop."),
1404
+ as_json: bool = typer.Option(False, "--json", help="Print a JSON receipt; enrolled Stop requires --yes."),
1261
1405
  ):
1262
1406
  """Stop an agent by name, session ID, or PID.
1263
1407
 
@@ -1269,11 +1413,7 @@ def agents_kill(
1269
1413
  from qbraid_core.services.agents import AgentSessionRegistryManager
1270
1414
 
1271
1415
  if compute:
1272
- import shlex
1273
-
1274
- flags = " --force" if force else ""
1275
- _ssh_cmd(compute, f"qbraid agents kill {shlex.quote(target)}{flags}")
1276
- console.print(f"[green]Stopped '{target}' on {compute}.[/green]")
1416
+ _remote_stop(compute, target, force, yes, as_json)
1277
1417
  return
1278
1418
 
1279
1419
  registry = AgentSessionRegistryManager()
@@ -1303,11 +1443,14 @@ def agents_kill(
1303
1443
  ]
1304
1444
 
1305
1445
  if not matches:
1306
- console.print(f"[red]No session found matching '{target}'.[/red]")
1307
- raise typer.Exit(code=1)
1446
+ _stop_refused(f"No session found matching '{target}'.", as_json, "SESSION_NOT_FOUND")
1308
1447
 
1309
1448
  # If multiple matches, show filtered picker
1310
1449
  if len(matches) > 1:
1450
+ if as_json or not _can_ask():
1451
+ _stop_refused(
1452
+ "Multiple sessions match; use a complete, exact session ID.", as_json, "AMBIGUOUS_SESSION"
1453
+ )
1311
1454
  import questionary
1312
1455
 
1313
1456
  console.print(f"[yellow]Multiple sessions match '{target}':[/yellow]")
@@ -1338,92 +1481,103 @@ def agents_kill(
1338
1481
 
1339
1482
  session = matches[0]
1340
1483
  if getattr(session, "runtime_kind", None) == "provider_child":
1341
- console.print(
1342
- "[red]Not stopped:[/red] native provider children are read-only. "
1343
- "Manage this child from its parent agent."
1484
+ _stop_refused(
1485
+ "Native provider children are read-only. Manage this child from its parent agent.",
1486
+ as_json,
1487
+ "READ_ONLY_SESSION",
1344
1488
  )
1345
- raise typer.Exit(code=1)
1346
1489
  pid = session.pid
1347
1490
 
1348
1491
  # Shown as interrupted (another Lab's row this Lab cannot verify):
1349
1492
  # refused before anything, Core included, so nothing is written for
1350
1493
  # it. Other native rows go to Core, which asks their owning server.
1351
1494
  if (_lifecycle_view(registry, session).metadata or {}).get("status_overlay"):
1352
- console.print(
1353
- "[red]Not stopped:[/red] it belongs to another Lab that this Lab cannot verify, so it is "
1495
+ _stop_refused(
1496
+ "It belongs to another Lab that this Lab cannot verify, so it is "
1354
1497
  "listed as interrupted. Resume it with --confirm-owner-gone, or stop it from its own Lab. "
1355
1498
  "Nothing was signalled or closed.",
1356
- highlight=False,
1499
+ as_json,
1357
1500
  )
1358
- raise typer.Exit(code=1)
1359
1501
 
1360
- if session.metadata.get("terminal_backend") == "jupyter":
1502
+ native = session.metadata.get("terminal_backend") == "jupyter"
1503
+ if not native:
1504
+ refusal = _foreign_pid_reason(session) or _not_running_reason(session)
1505
+ if refusal:
1506
+ _stop_refused(f"{refusal} Nothing was signalled or closed.", as_json)
1507
+
1508
+ from qbraid_core.services.agents import AgentLauncher
1509
+
1510
+ launcher = AgentLauncher(registry=registry)
1511
+ recovery, confirmation = stop_plan(launcher, session, yes=yes, as_json=as_json, can_ask=_can_ask)
1512
+
1513
+ if native:
1361
1514
  # A native agent belongs to its Lab server, which Core reaches and
1362
1515
  # verifies; its PID may be another namespace's. Never signalled here.
1363
1516
  from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
1364
1517
  from qbraid_core.services.agents.exceptions import AgentRuntimeUnavailableError
1365
1518
 
1366
1519
  try:
1367
- AgentLauncher().kill_bound(session.session_id)
1520
+ launcher.kill_bound(session.session_id, **_stop_options(confirmation))
1368
1521
  except AgentRuntimeUnavailableError as error:
1369
1522
  if _not_running_reason(session):
1370
1523
  # Nothing to verify: Core says why (e.g. no longer active).
1371
- console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1524
+ _stop_refused(str(error), as_json)
1372
1525
  else:
1373
- console.print(
1374
- "[red]Could not verify the Lab server that owns this agent; "
1375
- "nothing was stopped.[/red] "
1376
- f"{escape(str(error))}",
1377
- highlight=False,
1526
+ _stop_refused(
1527
+ f"The owning Lab did not confirm Stop. Inspect its current status. {error}",
1528
+ as_json,
1378
1529
  )
1379
- raise typer.Exit(code=1)
1380
1530
  except AgentLauncherError as error:
1381
- console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1382
- raise typer.Exit(code=1)
1383
- console.print("[green]Stopped native Lab agent.[/green]")
1531
+ if as_json:
1532
+ from .keep_alive import _error
1533
+
1534
+ _error(error, True)
1535
+ _stop_refused(str(error), False)
1536
+ _stop_success(session, as_json, "Stopped native Lab agent.")
1384
1537
  return
1385
1538
 
1386
1539
  # Refused before anything is touched: a row of another PID namespace or
1387
1540
  # machine, or one no longer running, names a PID and a tmux window that
1388
1541
  # may now belong to someone else.
1389
- refusal = _foreign_pid_reason(session) or _not_running_reason(session)
1390
- if refusal:
1391
- console.print(
1392
- f"[red]Not stopped:[/red] {escape(refusal)} Nothing was signalled or closed.", highlight=False
1393
- )
1394
- raise typer.Exit(code=1)
1395
-
1396
1542
  if session.tmux_session and session.tmux_window:
1397
- _kill_tmux_session(session)
1543
+ _kill_tmux_session(session, launcher=launcher, confirmation=confirmation, as_json=as_json)
1398
1544
  return
1399
1545
 
1400
1546
  if not (pid and pid > 0):
1401
- console.print(
1402
- "[red]Not stopped:[/red] no agent process or terminal is recorded for this session."
1403
- )
1404
- raise typer.Exit(code=1)
1405
- identity = _agent_identity_refusal(session)
1406
- if identity == _GONE:
1407
- console.print(f"[dim]Process {pid} already stopped.[/dim]")
1408
- elif identity:
1409
- console.print(
1410
- f"[red]Not stopped. Did not signal PID {pid}:[/red] {escape(identity)}", highlight=False
1411
- )
1412
- raise typer.Exit(code=1)
1413
- else:
1414
- _signal_agent(pid, force)
1415
-
1416
- # Verified gone or signalled: mark stopped in registry
1417
- registry.deregister_session(session_id=session.session_id)
1418
- console.print(
1419
- f"[dim]Session {session.session_id[:8]} "
1420
- f"({session.terminal_name or session.tmux_window or '-'}) stopped.[/dim]"
1547
+ _stop_refused("No agent process or terminal is recorded for this session.", as_json)
1548
+ guard = recovery.keep_alive_stop_guard(launcher, None, suppress=False) if recovery else nullcontext()
1549
+ with guard:
1550
+ if recovery and recovery.recovery_bound_pids(registry, {pid}):
1551
+ _stop_refused(
1552
+ "This PID belongs to a recovery-bound runtime. Use its exact managed session Stop.",
1553
+ as_json,
1554
+ )
1555
+ identity = _agent_identity_refusal(session)
1556
+ if identity == _GONE:
1557
+ if not as_json:
1558
+ console.print(f"[dim]Process {pid} already stopped.[/dim]")
1559
+ elif identity:
1560
+ _stop_refused(f"Did not signal PID {pid}: {identity}", as_json)
1561
+ else:
1562
+ _signal_agent(pid, force, quiet=as_json)
1563
+ registry.deregister_session(session_id=session.session_id)
1564
+ _stop_success(
1565
+ session,
1566
+ as_json,
1567
+ f"Session {session.session_id[:8]} "
1568
+ f"({session.terminal_name or session.tmux_window or '-'}) stopped.",
1421
1569
  )
1422
1570
 
1423
1571
  except (typer.Exit, SystemExit):
1424
1572
  raise
1425
1573
  except Exception as e:
1426
- handle_error(message=str(e))
1574
+ from .keep_alive import _plain as safe_text
1575
+
1576
+ if as_json:
1577
+ from .keep_alive import _error
1578
+
1579
+ _error(e, True)
1580
+ console.print(f"[red]Stop was not confirmed:[/red] {escape(safe_text(e))}", highlight=False)
1427
1581
  raise typer.Exit(code=1)
1428
1582
 
1429
1583
 
@@ -0,0 +1,357 @@
1
+ # Copyright (c) 2026, qBraid Development Team
2
+ # All rights reserved.
3
+ """Explicit local keep-alive controls and an optional foreground host."""
4
+
5
+ import importlib
6
+ import inspect
7
+ import json
8
+ import math
9
+ import time
10
+ import unicodedata
11
+ from typing import Optional
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ keep_alive_app = typer.Typer(
19
+ help=(
20
+ "Opt-in local managed agent recovery.\n\n"
21
+ "Restore enrolled local conversations after an instance restart. "
22
+ "A process exit in the same instance pauses recovery for attention. "
23
+ "Off by default. Explicit Stop disables recovery. A compatible running host is required; "
24
+ "these commands do not install a daemon or support remote compute."
25
+ ),
26
+ no_args_is_help=True,
27
+ )
28
+ console = Console()
29
+
30
+
31
+ class KeepAliveCommandError(Exception):
32
+ """An actionable failure that also has a stable JSON representation."""
33
+
34
+ def __init__(self, code, message, details=None):
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.details = details
38
+
39
+
40
+ def _load():
41
+ """Keep ordinary CLI imports usable with an older installed Core."""
42
+ try:
43
+ module = importlib.import_module("qbraid_core.services.agents.keep_alive")
44
+ manager = module.KeepAliveManager
45
+ supervisor = module.RecoverySupervisor
46
+ if not all(
47
+ callable(getattr(manager, method, None)) for method in ("enable", "list", "disable", "forget")
48
+ ):
49
+ raise AttributeError("Incomplete keep-alive controls")
50
+ if not callable(getattr(supervisor, "tick", None)):
51
+ raise AttributeError("Missing keep-alive host")
52
+ return manager, supervisor
53
+ except (ImportError, AttributeError) as error:
54
+ raise KeepAliveCommandError(
55
+ "UNSUPPORTED_OPERATION",
56
+ "Keep-alive requires a compatible qbraid-core with KeepAliveManager and RecoverySupervisor. "
57
+ "Upgrade Core before using these commands. No policy was changed.",
58
+ ) from error
59
+
60
+
61
+ def _require_recovery_protocol(*, host=False):
62
+ """Check installed behavior; the recovery release has no assigned version yet."""
63
+ try:
64
+ module = importlib.import_module("qbraid_core.services.agents.keep_alive")
65
+ end = importlib.import_module("qbraid_core.services.agents.session_end_hook")
66
+ if not _protocol_is(module, "KEEP_ALIVE_STOP_PROTOCOL", 1) or not _protocol_is(
67
+ end, "BOUND_END_HOOK_PROTOCOL", 2
68
+ ):
69
+ raise AttributeError("Missing reviewed recovery protocols")
70
+ if host and "supported_backends" not in inspect.signature(module.RecoverySupervisor).parameters:
71
+ raise AttributeError("Missing recovery-host backend selection")
72
+ except (ImportError, AttributeError, TypeError, ValueError) as error:
73
+ raise KeepAliveCommandError(
74
+ "UNSUPPORTED_OPERATION",
75
+ "Upgrade Core to the reviewed keep-alive and bound-end adapters before enabling or hosting "
76
+ "recovery. No recovery release version has been assigned. No policy was changed.",
77
+ ) from error
78
+
79
+
80
+ def _protocol_is(module, name, expected):
81
+ value = getattr(module, name, None)
82
+ return isinstance(value, int) and not isinstance(value, bool) and value == expected
83
+
84
+
85
+ def stop_plan(launcher, session, *, yes, as_json, can_ask):
86
+ """Read consent without mutation; Core rechecks the token while stopping."""
87
+ name = "qbraid_core.services.agents.keep_alive"
88
+ try:
89
+ module = importlib.import_module(name)
90
+ except ModuleNotFoundError as error:
91
+ if error.name == name:
92
+ registry_path = getattr(getattr(launcher, "_registry", None), "registry_path", None)
93
+ from pathlib import Path
94
+
95
+ if session.metadata.get("keep_alive_provider") is not None or (
96
+ isinstance(registry_path, (str, Path))
97
+ and any((Path(registry_path).parent / "keep-alive").glob("*.json"))
98
+ ):
99
+ raise KeepAliveCommandError(
100
+ "UNSUPPORTED_OPERATION",
101
+ "Update Core to inspect saved recovery policies before stopping this agent. "
102
+ "Nothing was stopped or disabled.",
103
+ ) from error
104
+ return None, None # Released Core predating enrollment keeps its Stop behavior.
105
+ raise
106
+ try:
107
+ controls = importlib.import_module("qbraid_core.services.agents.runtime_controls")
108
+ guard = getattr(module, "keep_alive_stop_guard", None)
109
+ supported = (
110
+ _protocol_is(module, "KEEP_ALIVE_STOP_PROTOCOL", 1)
111
+ and callable(getattr(controls, "stop_preflight", None))
112
+ and callable(getattr(module, "recovery_bound_pids", None))
113
+ and callable(guard)
114
+ and "keep_alive_confirmation" in inspect.signature(guard).parameters
115
+ )
116
+ except (ImportError, TypeError, ValueError):
117
+ supported = False
118
+ if not supported:
119
+ raise KeepAliveCommandError(
120
+ "UNSUPPORTED_OPERATION",
121
+ "Update Core for atomic keep-alive Stop confirmation. Nothing was stopped or disabled.",
122
+ )
123
+ preflight = controls.stop_preflight(launcher, session.session_id)
124
+ if not isinstance(preflight, dict) or not isinstance(preflight.get("requiresConfirmation"), bool):
125
+ raise KeepAliveCommandError("INVALID_RESPONSE", "Core returned an invalid Stop preflight.")
126
+ confirmation = preflight.get("keepAliveConfirmation")
127
+ if preflight["requiresConfirmation"]:
128
+ if not isinstance(confirmation, dict):
129
+ raise KeepAliveCommandError("INVALID_RESPONSE", "Core did not bind its Stop confirmation.")
130
+ if not yes:
131
+ if as_json or not can_ask():
132
+ raise KeepAliveCommandError(
133
+ "KEEP_ALIVE_CONFIRMATION_REQUIRED",
134
+ "Keep-alive is enabled. Pass --yes to turn it off and stop the agent. "
135
+ "Nothing was stopped or disabled.",
136
+ preflight,
137
+ )
138
+ try:
139
+ confirmed = typer.confirm(
140
+ "Keep-alive is enabled. Turn it off and stop the agent?", default=False
141
+ )
142
+ except (typer.Abort, EOFError, KeyboardInterrupt):
143
+ confirmed = False
144
+ if not confirmed:
145
+ typer.echo("Not stopped. No policy was changed.")
146
+ raise typer.Exit(code=1)
147
+ elif confirmation is not None:
148
+ raise KeepAliveCommandError("INVALID_RESPONSE", "Core returned an unexpected Stop confirmation.")
149
+ return module, confirmation
150
+
151
+
152
+ def _manager(compute):
153
+ if compute is not None:
154
+ raise ValueError("Keep-alive is local only. Run this command on the session's original instance.")
155
+ manager, supervisor = _load()
156
+ from qbraid_core.services.agents import AgentLauncher
157
+
158
+ return manager(AgentLauncher()), supervisor
159
+
160
+
161
+ def _plain(value):
162
+ """Keep terminal controls and invisible direction changes out of human output."""
163
+ return "".join(
164
+ f"\\u{ord(char):04x}" if unicodedata.category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"} else char
165
+ for char in str(value)
166
+ )
167
+
168
+
169
+ def _error(error, as_json=False):
170
+ if as_json:
171
+ failure = {"code": getattr(error, "code", None) or "KEEP_ALIVE_ERROR", "message": str(error)}
172
+ details = getattr(error, "details", None)
173
+ if isinstance(details, dict):
174
+ failure["details"] = details
175
+ typer.echo(json.dumps({"ok": False, "error": failure}, ensure_ascii=True))
176
+ else:
177
+ typer.echo(f"Keep-alive: {_plain(error)}", err=True)
178
+ raise typer.Exit(code=1) from error
179
+
180
+
181
+ def _policy(manager, policy_id):
182
+ matches = [policy for policy in manager.list() if policy["policy_id"] == policy_id]
183
+ if len(matches) != 1:
184
+ raise ValueError("No policy has that exact ID. Use 'qbraid agents keep-alive list'.")
185
+ return matches[0]
186
+
187
+
188
+ def _show(policies, as_json):
189
+ if as_json:
190
+ typer.echo(json.dumps(policies))
191
+ return
192
+ if not policies:
193
+ typer.echo("No keep-alive policies. Recovery is off for unenrolled sessions.")
194
+ return
195
+ table = Table("Policy ID", "Session ID", "State", "Reason")
196
+ for policy in policies:
197
+ table.add_row(
198
+ *(
199
+ Text(_plain(policy.get(key) or ""))
200
+ for key in (
201
+ "policy_id",
202
+ "active_session_id",
203
+ "state",
204
+ "reason",
205
+ )
206
+ )
207
+ )
208
+ console.print(table)
209
+ typer.echo("Enabled policies require a compatible host to be running.")
210
+
211
+
212
+ @keep_alive_app.command("list")
213
+ def list_policies(
214
+ as_json: bool = typer.Option(False, "--json", help="Print policy data as JSON."),
215
+ compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
216
+ ):
217
+ """List local recovery policies.
218
+
219
+ Does not enroll or launch anything.
220
+ """
221
+ try:
222
+ manager, _ = _manager(compute)
223
+ _show(manager.list(), as_json)
224
+ except Exception as error:
225
+ _error(error, as_json)
226
+
227
+
228
+ @keep_alive_app.command("enable")
229
+ def enable_policy(
230
+ session_id: str = typer.Argument(
231
+ ..., help="Complete local managed session ID; names/prefixes are refused."
232
+ ),
233
+ as_json: bool = typer.Option(False, "--json", help="Print the resulting policy as JSON."),
234
+ compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
235
+ ):
236
+ """Enable recovery for one exact session.
237
+
238
+ Preserves the live conversation's recorded permissions.
239
+ """
240
+ try:
241
+ manager, _ = _manager(compute)
242
+ _require_recovery_protocol()
243
+ session = manager.registry.get_session(session_id)
244
+ if session.session_id != session_id:
245
+ raise ValueError("Enable requires the complete, exact session ID.")
246
+ expected = {
247
+ "pid": session.pid,
248
+ "startedAt": session.started_at,
249
+ "tool": session.tool.value,
250
+ "terminalId": session.metadata.get("jupyter_terminal_id"),
251
+ "serverId": session.metadata.get("jupyter_server_id"),
252
+ "transcriptPath": session.transcript_path,
253
+ }
254
+ policy = manager.enable(session_id, expected)
255
+ if as_json:
256
+ typer.echo(json.dumps(policy))
257
+ else:
258
+ _show([policy], False)
259
+ except Exception as error:
260
+ _error(error, as_json)
261
+
262
+
263
+ @keep_alive_app.command("disable")
264
+ def disable_policy(
265
+ policy_id: str = typer.Argument(..., help="Complete policy ID from keep-alive list."),
266
+ as_json: bool = typer.Option(False, "--json", help="Print the resulting policy as JSON."),
267
+ compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
268
+ ):
269
+ """Disable recovery; leave the agent running."""
270
+ try:
271
+ manager, _ = _manager(compute)
272
+ selected = _policy(manager, policy_id)
273
+ policy = manager.disable(policy_id, selected["revision"])
274
+ if as_json:
275
+ typer.echo(json.dumps(policy))
276
+ else:
277
+ typer.echo(f"Disabled keep-alive policy {_plain(policy_id)}. The running agent was not stopped.")
278
+ except Exception as error:
279
+ _error(error, as_json)
280
+
281
+
282
+ @keep_alive_app.command("forget")
283
+ def forget_policy(
284
+ policy_id: str = typer.Argument(..., help="Complete, already disabled policy ID."),
285
+ as_json: bool = typer.Option(False, "--json", help="Print a JSON acknowledgement."),
286
+ compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
287
+ ):
288
+ """Remove an already disabled policy.
289
+
290
+ Does not change any agent runtime.
291
+ """
292
+ try:
293
+ manager, _ = _manager(compute)
294
+ selected = _policy(manager, policy_id)
295
+ if selected["enabled"]:
296
+ raise ValueError("Disable this policy before removing it.")
297
+ manager.forget(policy_id, selected["revision"])
298
+ if as_json:
299
+ typer.echo(json.dumps({"policy_id": policy_id, "removed": True}))
300
+ else:
301
+ typer.echo(f"Removed disabled policy {_plain(policy_id)}.")
302
+ except Exception as error:
303
+ _error(error, as_json)
304
+
305
+
306
+ @keep_alive_app.command("run")
307
+ def run_host(
308
+ once: bool = typer.Option(False, "--once", help="Reconcile once and exit; installs no background host."),
309
+ interval: float = typer.Option(5.0, "--interval", min=1.0, max=300.0, help="Seconds between ticks."),
310
+ as_json: bool = typer.Option(
311
+ False, "--json", help="Print each tick's policies as newline-delimited JSON."
312
+ ),
313
+ compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
314
+ ):
315
+ """Run the local recovery host.
316
+
317
+ Ctrl-C exits without disabling policies.
318
+ Exiting leaves policies armed, but recovery requires another compatible host
319
+ to be running. Native policies pause here and require the owning Lab host.
320
+ This command never enrolls a session or installs a daemon.
321
+ """
322
+ try:
323
+ if not math.isfinite(interval):
324
+ raise ValueError("The polling interval must be a finite number of seconds.")
325
+ manager, supervisor = _manager(compute)
326
+ _require_recovery_protocol(host=True)
327
+ host = supervisor(manager, supported_backends={"tmux"})
328
+ if not once:
329
+ typer.echo(
330
+ "Foreground keep-alive host running. No sessions are enrolled by this command.", err=True
331
+ )
332
+ try:
333
+ while True:
334
+ try:
335
+ policies = host.tick()
336
+ except Exception as error:
337
+ raise KeepAliveCommandError(
338
+ "HOST_FAILED",
339
+ f"Recovery host failed and stopped: {error}. This tick was not retried. "
340
+ "Inspect policy status before starting another host.",
341
+ ) from error
342
+ if once or as_json:
343
+ # An overlapping host's nonblocking tick can return no
344
+ # results; it does not mean there are no saved policies.
345
+ _show(policies or manager.list(), as_json)
346
+ if once:
347
+ return
348
+ time.sleep(interval)
349
+ except KeyboardInterrupt:
350
+ return
351
+ finally:
352
+ typer.echo(
353
+ "Host stopped. Policies stay armed, but recovery requires a compatible host to be running.",
354
+ err=True,
355
+ )
356
+ except Exception as error:
357
+ _error(error, as_json)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-cli
3
- Version: 0.16.13
3
+ Version: 0.16.15
4
4
  Summary: Command Line Interface for interacting with all parts of the qBraid platform.
5
5
  Author-email: qBraid Development Team <contact@qbraid.com>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
30
30
  Requires-Dist: rich>=10.11.0
31
31
  Requires-Dist: click<=8.5.0
32
32
  Requires-Dist: questionary>=2.0.0
33
- Requires-Dist: qbraid-core>=0.6.32
33
+ Requires-Dist: qbraid-core>=0.6.34
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.32; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.34; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.32; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.34; extra == "envs"
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: isort; extra == "dev"
40
40
  Requires-Dist: black; extra == "dev"
@@ -22,6 +22,7 @@ qbraid_cli/admin/headers.py
22
22
  qbraid_cli/admin/validation.py
23
23
  qbraid_cli/agents/__init__.py
24
24
  qbraid_cli/agents/app.py
25
+ qbraid_cli/agents/keep_alive.py
25
26
  qbraid_cli/agents/permission_response.py
26
27
  qbraid_cli/agents/provider_children.py
27
28
  qbraid_cli/agents/queue.py
@@ -2,10 +2,10 @@ typer>=0.12.1
2
2
  rich>=10.11.0
3
3
  click<=8.5.0
4
4
  questionary>=2.0.0
5
- qbraid-core>=0.6.32
5
+ qbraid-core>=0.6.34
6
6
 
7
7
  [compute]
8
- qbraid-core[compute]>=0.6.32
8
+ qbraid-core[compute]>=0.6.34
9
9
 
10
10
  [dev]
11
11
  isort
@@ -15,4 +15,4 @@ pytest-cov
15
15
  pytest-asyncio
16
16
 
17
17
  [envs]
18
- qbraid-core[environments]>=0.6.32
18
+ qbraid-core[environments]>=0.6.34
@@ -937,7 +937,7 @@ class TestAgentsE2E:
937
937
 
938
938
  @patch("qbraid_core.services.agents.AgentLauncher")
939
939
  @patch("qbraid_core.services.agents.AgentSessionRegistryManager")
940
- def test_kill_session(self, mock_registry_cls, mock_launcher_cls):
940
+ def test_kill_session(self, mock_registry_cls, mock_launcher_cls, tmp_path):
941
941
  """Kill a session by ID: a tmux row goes through Core's verified kill."""
942
942
  mock_session = MagicMock()
943
943
  mock_session.session_id = "abc12345-6789"
@@ -952,10 +952,12 @@ class TestAgentsE2E:
952
952
  mock_session.tmux_session = "qbraid-agents"
953
953
 
954
954
  mock_registry = MagicMock()
955
+ mock_registry.registry_path = tmp_path / "sessions.json"
955
956
  all_sessions = MagicMock()
956
957
  all_sessions.sessions = [mock_session]
957
958
  mock_registry.load_registry.return_value = all_sessions
958
959
  mock_registry_cls.return_value = mock_registry
960
+ mock_launcher_cls.return_value._registry = mock_registry
959
961
 
960
962
  from qbraid_cli.agents.app import agents_app
961
963
 
@@ -964,6 +966,8 @@ class TestAgentsE2E:
964
966
 
965
967
  assert result.exit_code == 0, result.output
966
968
  mock_launcher_cls.return_value.kill_bound.assert_called_once_with("abc12345-6789")
969
+ assert not (tmp_path / "keep-alive").exists()
970
+ mock_registry.deregister_session.assert_not_called()
967
971
  # Nothing touches the window or the PID directly: Core verified the target.
968
972
  tmux.assert_not_called()
969
973
  kill.assert_not_called()
@@ -972,6 +976,7 @@ class TestAgentsE2E:
972
976
  def test_discover_empty(self, mock_registry_cls):
973
977
  """Discover with no new sessions found."""
974
978
  mock_registry = MagicMock()
979
+ mock_registry.refresh_discovered_sessions = MagicMock(return_value={"pruned": [], "reactivated": []})
975
980
  mock_registry_cls.return_value = mock_registry
976
981
 
977
982
  from qbraid_cli.agents.app import agents_app
@@ -980,6 +985,30 @@ class TestAgentsE2E:
980
985
  result = runner.invoke(agents_app, ["discover"])
981
986
 
982
987
  assert result.exit_code == 0
988
+ mock_registry.refresh_discovered_sessions.assert_called_once_with()
989
+
990
+ @patch("qbraid_core.services.agents.AgentSessionRegistryManager")
991
+ def test_discover_refreshes_children_after_merging(self, mock_registry_cls):
992
+ """Provider child reconciliation follows process discovery and merge."""
993
+ events = []
994
+ mock_session = MagicMock()
995
+ mock_session.session_id = "abc12345-6789"
996
+ mock_session.tool.value = "codex"
997
+ mock_session.status.value = "idle"
998
+ mock_session.model_name = "test-model"
999
+ mock_session.cwd = "/tmp/project"
1000
+ mock_registry = MagicMock()
1001
+ mock_registry.merge_discovered_sessions.side_effect = lambda rows: events.append("merge") or 1
1002
+ mock_registry.refresh_discovered_sessions.side_effect = lambda: events.append("refresh")
1003
+ mock_registry_cls.return_value = mock_registry
1004
+
1005
+ from qbraid_cli.agents.app import agents_app
1006
+
1007
+ with patch("qbraid_core.services.agents.detect_sessions", return_value=[mock_session]):
1008
+ result = runner.invoke(agents_app, ["discover"])
1009
+
1010
+ assert result.exit_code == 0, result.output
1011
+ assert events == ["merge", "refresh"]
983
1012
 
984
1013
  @patch("qbraid_core.services.agents.AgentSessionRegistryManager")
985
1014
  def test_update_session(self, mock_registry_cls):
File without changes
File without changes
File without changes