qbraid-cli 0.16.12__tar.gz → 0.16.14__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.12/qbraid_cli.egg-info → qbraid_cli-0.16.14}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/agents/app.py +248 -74
  4. qbraid_cli-0.16.14/qbraid_cli/agents/keep_alive.py +357 -0
  5. qbraid_cli-0.16.14/qbraid_cli/agents/provider_children.py +87 -0
  6. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14/qbraid_cli.egg-info}/PKG-INFO +4 -4
  7. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/SOURCES.txt +2 -0
  8. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/requires.txt +3 -3
  9. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/tests/test_cli_e2e.py +5 -1
  10. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/LICENSE +0 -0
  11. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/README.md +0 -0
  12. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/__init__.py +0 -0
  13. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/_build_info.py +0 -0
  14. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/_version.py +0 -0
  15. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/account/__init__.py +0 -0
  16. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/account/app.py +0 -0
  17. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/admin/__init__.py +0 -0
  18. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/admin/app.py +0 -0
  19. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/admin/headers.py +0 -0
  20. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/admin/validation.py +0 -0
  21. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/agents/__init__.py +0 -0
  22. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/agents/permission_response.py +0 -0
  23. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/agents/queue.py +0 -0
  24. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/agents/work.py +0 -0
  25. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/ai/__init__.py +0 -0
  26. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/ai/app.py +0 -0
  27. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/ai/token.py +0 -0
  28. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/comms/__init__.py +0 -0
  29. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/comms/app.py +0 -0
  30. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/compute/__init__.py +0 -0
  31. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/compute/app.py +0 -0
  32. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/compute/instances.py +0 -0
  33. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/compute/profiles.py +0 -0
  34. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/compute/server.py +0 -0
  35. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/compute/ssh.py +0 -0
  36. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/configure/__init__.py +0 -0
  37. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/configure/actions.py +0 -0
  38. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/configure/app.py +0 -0
  39. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/devices/__init__.py +0 -0
  40. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/devices/app.py +0 -0
  41. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/devices/validation.py +0 -0
  42. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/__init__.py +0 -0
  43. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/activate.py +0 -0
  44. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/app.py +0 -0
  45. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/create.py +0 -0
  46. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/data_handling.py +0 -0
  47. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/groups.py +0 -0
  48. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/publish.py +0 -0
  49. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/registry.py +0 -0
  50. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/envs/share.py +0 -0
  51. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/exceptions.py +0 -0
  52. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/handlers.py +0 -0
  53. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/jobs/__init__.py +0 -0
  54. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/jobs/app.py +0 -0
  55. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/jobs/groups.py +0 -0
  56. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/kernels/__init__.py +0 -0
  57. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/kernels/app.py +0 -0
  58. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/lab/__init__.py +0 -0
  59. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/lab/app.py +0 -0
  60. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/main.py +0 -0
  61. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/pip/__init__.py +0 -0
  62. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/pip/app.py +0 -0
  63. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/pip/hooks.py +0 -0
  64. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/projects/__init__.py +0 -0
  65. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/projects/app.py +0 -0
  66. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/py.typed +0 -0
  67. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/skills/__init__.py +0 -0
  68. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/skills/app.py +0 -0
  69. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/troubleshoot/__init__.py +0 -0
  70. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli/troubleshoot/app.py +0 -0
  71. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  72. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/entry_points.txt +0 -0
  73. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/top_level.txt +0 -0
  74. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/setup.cfg +0 -0
  75. {qbraid_cli-0.16.12 → qbraid_cli-0.16.14}/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.12
3
+ Version: 0.16.14
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.30
33
+ Requires-Dist: qbraid-core>=0.6.33
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.30; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.30; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.33; 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.12"
7
+ version = "0.16.14"
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.30",
35
+ "qbraid-core>=0.6.33",
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.30"]
47
- envs = ["qbraid-core[environments]>=0.6.30"]
46
+ compute = ["qbraid-core[compute]>=0.6.33"]
47
+ envs = ["qbraid-core[environments]>=0.6.33"]
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},
491
564
  )
492
- raise typer.Exit(code=1) from error
493
- console.print(f"[green]Stopped the agent in tmux window '{escape(session.tmux_window)}'.[/green]")
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")
613
+ )
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"
@@ -1241,6 +1376,9 @@ def agents_update(
1241
1376
  console.print("[dim]Use 'qbraid agents list' to see active sessions.[/dim]")
1242
1377
  raise typer.Exit(code=1)
1243
1378
  except Exception as e:
1379
+ if _is_registry_error(e):
1380
+ console.print(f"[red]Cannot update session: {_plain(e)}[/red]", highlight=False)
1381
+ raise typer.Exit(code=1) from e
1244
1382
  handle_error(message=str(e))
1245
1383
  raise typer.Exit(code=1)
1246
1384
 
@@ -1255,6 +1393,8 @@ def agents_kill(
1255
1393
  target: str = typer.Argument(..., help="Agent name, ID prefix, or PID"),
1256
1394
  force: bool = typer.Option(False, "--force", "-f", help="Use SIGKILL instead of SIGTERM"),
1257
1395
  compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
1396
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm disabling keep-alive before Stop."),
1397
+ as_json: bool = typer.Option(False, "--json", help="Print a JSON receipt; enrolled Stop requires --yes."),
1258
1398
  ):
1259
1399
  """Stop an agent by name, session ID, or PID.
1260
1400
 
@@ -1266,11 +1406,7 @@ def agents_kill(
1266
1406
  from qbraid_core.services.agents import AgentSessionRegistryManager
1267
1407
 
1268
1408
  if compute:
1269
- import shlex
1270
-
1271
- flags = " --force" if force else ""
1272
- _ssh_cmd(compute, f"qbraid agents kill {shlex.quote(target)}{flags}")
1273
- console.print(f"[green]Stopped '{target}' on {compute}.[/green]")
1409
+ _remote_stop(compute, target, force, yes, as_json)
1274
1410
  return
1275
1411
 
1276
1412
  registry = AgentSessionRegistryManager()
@@ -1300,11 +1436,14 @@ def agents_kill(
1300
1436
  ]
1301
1437
 
1302
1438
  if not matches:
1303
- console.print(f"[red]No session found matching '{target}'.[/red]")
1304
- raise typer.Exit(code=1)
1439
+ _stop_refused(f"No session found matching '{target}'.", as_json, "SESSION_NOT_FOUND")
1305
1440
 
1306
1441
  # If multiple matches, show filtered picker
1307
1442
  if len(matches) > 1:
1443
+ if as_json or not _can_ask():
1444
+ _stop_refused(
1445
+ "Multiple sessions match; use a complete, exact session ID.", as_json, "AMBIGUOUS_SESSION"
1446
+ )
1308
1447
  import questionary
1309
1448
 
1310
1449
  console.print(f"[yellow]Multiple sessions match '{target}':[/yellow]")
@@ -1334,87 +1473,104 @@ def agents_kill(
1334
1473
  matches = [s for s in matches if s.session_id == selected]
1335
1474
 
1336
1475
  session = matches[0]
1476
+ if getattr(session, "runtime_kind", None) == "provider_child":
1477
+ _stop_refused(
1478
+ "Native provider children are read-only. Manage this child from its parent agent.",
1479
+ as_json,
1480
+ "READ_ONLY_SESSION",
1481
+ )
1337
1482
  pid = session.pid
1338
1483
 
1339
1484
  # Shown as interrupted (another Lab's row this Lab cannot verify):
1340
1485
  # refused before anything, Core included, so nothing is written for
1341
1486
  # it. Other native rows go to Core, which asks their owning server.
1342
1487
  if (_lifecycle_view(registry, session).metadata or {}).get("status_overlay"):
1343
- console.print(
1344
- "[red]Not stopped:[/red] it belongs to another Lab that this Lab cannot verify, so it is "
1488
+ _stop_refused(
1489
+ "It belongs to another Lab that this Lab cannot verify, so it is "
1345
1490
  "listed as interrupted. Resume it with --confirm-owner-gone, or stop it from its own Lab. "
1346
1491
  "Nothing was signalled or closed.",
1347
- highlight=False,
1492
+ as_json,
1348
1493
  )
1349
- raise typer.Exit(code=1)
1350
1494
 
1351
- if session.metadata.get("terminal_backend") == "jupyter":
1495
+ native = session.metadata.get("terminal_backend") == "jupyter"
1496
+ if not native:
1497
+ refusal = _foreign_pid_reason(session) or _not_running_reason(session)
1498
+ if refusal:
1499
+ _stop_refused(f"{refusal} Nothing was signalled or closed.", as_json)
1500
+
1501
+ from qbraid_core.services.agents import AgentLauncher
1502
+
1503
+ launcher = AgentLauncher(registry=registry)
1504
+ recovery, confirmation = stop_plan(launcher, session, yes=yes, as_json=as_json, can_ask=_can_ask)
1505
+
1506
+ if native:
1352
1507
  # A native agent belongs to its Lab server, which Core reaches and
1353
1508
  # verifies; its PID may be another namespace's. Never signalled here.
1354
1509
  from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
1355
1510
  from qbraid_core.services.agents.exceptions import AgentRuntimeUnavailableError
1356
1511
 
1357
1512
  try:
1358
- AgentLauncher().kill_bound(session.session_id)
1513
+ launcher.kill_bound(session.session_id, **_stop_options(confirmation))
1359
1514
  except AgentRuntimeUnavailableError as error:
1360
1515
  if _not_running_reason(session):
1361
1516
  # Nothing to verify: Core says why (e.g. no longer active).
1362
- console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1517
+ _stop_refused(str(error), as_json)
1363
1518
  else:
1364
- console.print(
1365
- "[red]Could not verify the Lab server that owns this agent; "
1366
- "nothing was stopped.[/red] "
1367
- f"{escape(str(error))}",
1368
- highlight=False,
1519
+ _stop_refused(
1520
+ f"The owning Lab did not confirm Stop. Inspect its current status. {error}",
1521
+ as_json,
1369
1522
  )
1370
- raise typer.Exit(code=1)
1371
1523
  except AgentLauncherError as error:
1372
- console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1373
- raise typer.Exit(code=1)
1374
- console.print("[green]Stopped native Lab agent.[/green]")
1524
+ if as_json:
1525
+ from .keep_alive import _error
1526
+
1527
+ _error(error, True)
1528
+ _stop_refused(str(error), False)
1529
+ _stop_success(session, as_json, "Stopped native Lab agent.")
1375
1530
  return
1376
1531
 
1377
1532
  # Refused before anything is touched: a row of another PID namespace or
1378
1533
  # machine, or one no longer running, names a PID and a tmux window that
1379
1534
  # may now belong to someone else.
1380
- refusal = _foreign_pid_reason(session) or _not_running_reason(session)
1381
- if refusal:
1382
- console.print(
1383
- f"[red]Not stopped:[/red] {escape(refusal)} Nothing was signalled or closed.", highlight=False
1384
- )
1385
- raise typer.Exit(code=1)
1386
-
1387
1535
  if session.tmux_session and session.tmux_window:
1388
- _kill_tmux_session(session)
1536
+ _kill_tmux_session(session, launcher=launcher, confirmation=confirmation, as_json=as_json)
1389
1537
  return
1390
1538
 
1391
1539
  if not (pid and pid > 0):
1392
- console.print(
1393
- "[red]Not stopped:[/red] no agent process or terminal is recorded for this session."
1394
- )
1395
- raise typer.Exit(code=1)
1396
- identity = _agent_identity_refusal(session)
1397
- if identity == _GONE:
1398
- console.print(f"[dim]Process {pid} already stopped.[/dim]")
1399
- elif identity:
1400
- console.print(
1401
- f"[red]Not stopped. Did not signal PID {pid}:[/red] {escape(identity)}", highlight=False
1402
- )
1403
- raise typer.Exit(code=1)
1404
- else:
1405
- _signal_agent(pid, force)
1406
-
1407
- # Verified gone or signalled: mark stopped in registry
1408
- registry.deregister_session(session_id=session.session_id)
1409
- console.print(
1410
- f"[dim]Session {session.session_id[:8]} "
1411
- f"({session.terminal_name or session.tmux_window or '-'}) stopped.[/dim]"
1540
+ _stop_refused("No agent process or terminal is recorded for this session.", as_json)
1541
+ guard = recovery.keep_alive_stop_guard(launcher, None, suppress=False) if recovery else nullcontext()
1542
+ with guard:
1543
+ if recovery and recovery.recovery_bound_pids(registry, {pid}):
1544
+ _stop_refused(
1545
+ "This PID belongs to a recovery-bound runtime. Use its exact managed session Stop.",
1546
+ as_json,
1547
+ )
1548
+ identity = _agent_identity_refusal(session)
1549
+ if identity == _GONE:
1550
+ if not as_json:
1551
+ console.print(f"[dim]Process {pid} already stopped.[/dim]")
1552
+ elif identity:
1553
+ _stop_refused(f"Did not signal PID {pid}: {identity}", as_json)
1554
+ else:
1555
+ _signal_agent(pid, force, quiet=as_json)
1556
+ registry.deregister_session(session_id=session.session_id)
1557
+ _stop_success(
1558
+ session,
1559
+ as_json,
1560
+ f"Session {session.session_id[:8]} "
1561
+ f"({session.terminal_name or session.tmux_window or '-'}) stopped.",
1412
1562
  )
1413
1563
 
1414
1564
  except (typer.Exit, SystemExit):
1415
1565
  raise
1416
1566
  except Exception as e:
1417
- handle_error(message=str(e))
1567
+ from .keep_alive import _plain as safe_text
1568
+
1569
+ if as_json:
1570
+ from .keep_alive import _error
1571
+
1572
+ _error(e, True)
1573
+ console.print(f"[red]Stop was not confirmed:[/red] {escape(safe_text(e))}", highlight=False)
1418
1574
  raise typer.Exit(code=1)
1419
1575
 
1420
1576
 
@@ -2848,7 +3004,8 @@ def agents_watch(
2848
3004
  import subprocess as _sp
2849
3005
 
2850
3006
  remote_flags = f"qbraid agents watch {shlex.quote(session_id)} --stream --lines {lines}"
2851
- console.print(f"[dim]Watching {session_id} on {compute}... (Ctrl+C to stop)[/dim]")
3007
+ if not stream:
3008
+ console.print(f"[dim]Watching {session_id} on {compute}... (Ctrl+C to stop)[/dim]")
2852
3009
 
2853
3010
  proc = _sp.Popen( # pylint: disable=consider-using-with
2854
3011
  ["ssh", "-o", "ConnectTimeout=10", compute, remote_flags],
@@ -2871,6 +3028,11 @@ def agents_watch(
2871
3028
  try:
2872
3029
 
2873
3030
  event = json.loads(line)
3031
+ if event.get("runtime_kind") == "provider_child":
3032
+ from .provider_children import render_child_event
3033
+
3034
+ render_child_event(console, event)
3035
+ continue
2874
3036
  entry_type = event.get("type", "unknown")
2875
3037
  content = event.get("content", "")
2876
3038
  ts = event.get("timestamp")
@@ -2895,12 +3057,18 @@ def agents_watch(
2895
3057
  console.print(line_text, highlight=False)
2896
3058
  except Exception:
2897
3059
  console.print(line, highlight=False)
3060
+ remote_status = proc.wait()
3061
+ if remote_status:
3062
+ raise typer.Exit(code=remote_status if remote_status > 0 else 1)
2898
3063
  except KeyboardInterrupt:
2899
- proc.terminate()
2900
- console.print("\n[dim]Stopped watching.[/dim]")
2901
- finally:
2902
3064
  proc.terminate()
2903
3065
  proc.wait()
3066
+ if not stream:
3067
+ console.print("\n[dim]Stopped watching.[/dim]")
3068
+ finally:
3069
+ if proc.poll() is None:
3070
+ proc.terminate()
3071
+ proc.wait()
2904
3072
  return
2905
3073
 
2906
3074
  from qbraid_core.services.agents import get_parser_for_tool, watch_transcript
@@ -2913,6 +3081,12 @@ def agents_watch(
2913
3081
  console.print("[dim]Use 'qbraid agents list' to see active sessions.[/dim]")
2914
3082
  raise typer.Exit(code=1)
2915
3083
 
3084
+ if getattr(session, "runtime_kind", None) == "provider_child":
3085
+ from .provider_children import watch_child
3086
+
3087
+ watch_child(registry, session.session_id, lines=lines, stream=stream, console=console)
3088
+ return
3089
+
2916
3090
  tool_value = session.tool.value if session.tool else None
2917
3091
 
2918
3092
  # OpenCode uses SQLite DB instead of transcript files