qbraid-cli 0.16.9__tar.gz → 0.16.10__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 (71) hide show
  1. {qbraid_cli-0.16.9/qbraid_cli.egg-info → qbraid_cli-0.16.10}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/agents/app.py +486 -94
  4. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10/qbraid_cli.egg-info}/PKG-INFO +4 -4
  5. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/requires.txt +3 -3
  6. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/tests/test_cli_e2e.py +14 -4
  7. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/LICENSE +0 -0
  8. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/README.md +0 -0
  9. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/__init__.py +0 -0
  10. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/_build_info.py +0 -0
  11. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/_version.py +0 -0
  12. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/account/__init__.py +0 -0
  13. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/account/app.py +0 -0
  14. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/__init__.py +0 -0
  15. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/app.py +0 -0
  16. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/headers.py +0 -0
  17. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/validation.py +0 -0
  18. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/agents/__init__.py +0 -0
  19. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/agents/permission_response.py +0 -0
  20. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/ai/__init__.py +0 -0
  21. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/ai/app.py +0 -0
  22. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/ai/token.py +0 -0
  23. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/comms/__init__.py +0 -0
  24. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/comms/app.py +0 -0
  25. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/__init__.py +0 -0
  26. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/app.py +0 -0
  27. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/instances.py +0 -0
  28. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/profiles.py +0 -0
  29. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/server.py +0 -0
  30. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/ssh.py +0 -0
  31. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/configure/__init__.py +0 -0
  32. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/configure/actions.py +0 -0
  33. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/configure/app.py +0 -0
  34. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/devices/__init__.py +0 -0
  35. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/devices/app.py +0 -0
  36. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/devices/validation.py +0 -0
  37. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/__init__.py +0 -0
  38. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/activate.py +0 -0
  39. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/app.py +0 -0
  40. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/create.py +0 -0
  41. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/data_handling.py +0 -0
  42. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/groups.py +0 -0
  43. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/publish.py +0 -0
  44. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/registry.py +0 -0
  45. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/share.py +0 -0
  46. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/exceptions.py +0 -0
  47. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/handlers.py +0 -0
  48. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/jobs/__init__.py +0 -0
  49. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/jobs/app.py +0 -0
  50. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/jobs/groups.py +0 -0
  51. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/kernels/__init__.py +0 -0
  52. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/kernels/app.py +0 -0
  53. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/lab/__init__.py +0 -0
  54. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/lab/app.py +0 -0
  55. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/main.py +0 -0
  56. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/pip/__init__.py +0 -0
  57. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/pip/app.py +0 -0
  58. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/pip/hooks.py +0 -0
  59. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/projects/__init__.py +0 -0
  60. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/projects/app.py +0 -0
  61. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/py.typed +0 -0
  62. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/skills/__init__.py +0 -0
  63. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/skills/app.py +0 -0
  64. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/troubleshoot/__init__.py +0 -0
  65. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/troubleshoot/app.py +0 -0
  66. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/SOURCES.txt +0 -0
  67. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  68. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/entry_points.txt +0 -0
  69. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/top_level.txt +0 -0
  70. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/setup.cfg +0 -0
  71. {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/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.9
3
+ Version: 0.16.10
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.21
33
+ Requires-Dist: qbraid-core>=0.6.27
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.21; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.27; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.21; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.27; 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.9"
7
+ version = "0.16.10"
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.21",
35
+ "qbraid-core>=0.6.27",
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.21"]
47
- envs = ["qbraid-core[environments]>=0.6.21"]
46
+ compute = ["qbraid-core[compute]>=0.6.27"]
47
+ envs = ["qbraid-core[environments]>=0.6.27"]
48
48
  dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
49
49
 
50
50
  [project.scripts]
@@ -134,6 +134,7 @@ def _status_style(status_value: str) -> str:
134
134
  "idle": "dim",
135
135
  "error": "red",
136
136
  "stopped": "red",
137
+ "interrupted": "yellow",
137
138
  }
138
139
  return styles.get(status_value, "dim")
139
140
 
@@ -259,6 +260,38 @@ def _is_registry_error(err: Exception) -> bool:
259
260
  return isinstance(err, AgentRegistryError)
260
261
 
261
262
 
263
+ def _lifecycle_view(registry, session):
264
+ """The record as Core lists it (qbraid-core #419's read-time overlay).
265
+
266
+ Another Lab's live-or-unknown native row is stored as it was and listed
267
+ as interrupted (``status_overlay``). Displays and decisions that must
268
+ agree with the list classify through ``session_lifecycle_view``; an older
269
+ Core, or a view that fails on this row, gives the stored record.
270
+ """
271
+ view = _core_method(registry, "session_lifecycle_view")
272
+ if view is None:
273
+ return session
274
+ try:
275
+ return view(session)
276
+ except Exception: # pylint: disable=broad-exception-caught
277
+ return session
278
+
279
+
280
+ def _interrupted(session) -> bool:
281
+ """Stopped because its runtime vanished (or shown so), with nothing after."""
282
+ status = getattr(getattr(session, "status", None), "value", getattr(session, "status", None))
283
+ at = (session.metadata or {}).get("interrupted_at")
284
+ if status != "stopped" or isinstance(at, bool) or not isinstance(at, (int, float)):
285
+ return False
286
+ return (session.last_activity or at) <= at
287
+
288
+
289
+ def _status_label(session) -> str:
290
+ if _interrupted(session):
291
+ return "interrupted"
292
+ return session.status.value if session.status else "idle"
293
+
294
+
262
295
  def _session_status(registry, session_id: str) -> Optional[str]:
263
296
  """The registry status of a session ("stopped", "working", ...), or None
264
297
  when it is not registered or the registry cannot be read."""
@@ -340,6 +373,174 @@ def agents_register():
340
373
  raise typer.Exit(code=0)
341
374
 
342
375
 
376
+ def _own_pid_namespace():
377
+ """This process's PID namespace (``pid:[<inode>]``), or None without /proc."""
378
+ try:
379
+ return os.readlink("/proc/self/ns/pid")
380
+ except (OSError, AttributeError):
381
+ return None
382
+
383
+
384
+ def _foreign_pid_reason(session):
385
+ """Why this session's recorded PID must not be signalled from here, or None.
386
+
387
+ A PID number names a process only in the namespace that issued it: a
388
+ session recorded in another PID namespace (another Lab, container or
389
+ machine) could name an unrelated process of ours.
390
+ """
391
+ if (
392
+ getattr(session, "compute_id", None)
393
+ or getattr(session, "machine_id", None)
394
+ or getattr(session, "cluster_id", None)
395
+ ):
396
+ return "it belongs to another machine."
397
+ metadata = getattr(session, "metadata", None) or {}
398
+ namespace = metadata.get("jupyter_process_pid_ns")
399
+ # Missing, null and malformed are one case: the namespace is unknown.
400
+ known = isinstance(namespace, str) and _PID_NAMESPACE.fullmatch(namespace) is not None
401
+ if not known and (metadata.get("jupyter_server_id") or namespace is not None):
402
+ return "it was recorded by a Lab server whose PID namespace is unknown."
403
+ if known:
404
+ own = _own_pid_namespace()
405
+ if own is None or namespace != own:
406
+ return "it was recorded in another PID namespace, where that number may be a different process."
407
+ # The agent's own scope, recorded on tmux and hook rows (qbraid-core #419).
408
+ if "agent_pid_ns" in metadata:
409
+ agent_ns = metadata.get("agent_pid_ns")
410
+ own = _own_pid_namespace()
411
+ if (
412
+ not (isinstance(agent_ns, str) and _PID_NAMESPACE.fullmatch(agent_ns))
413
+ or own is None
414
+ or agent_ns != own
415
+ ):
416
+ return (
417
+ "its agent was recorded in another PID namespace, "
418
+ "where that number may be a different process."
419
+ )
420
+ if "agent_boot_id" in metadata:
421
+ boot = metadata.get("agent_boot_id")
422
+ own_boot = _own_boot_id()
423
+ if not isinstance(boot, str) or not boot or own_boot is None or boot != own_boot:
424
+ return "its agent was recorded under another boot, where that number may be a different process."
425
+ return None
426
+
427
+
428
+ def _own_boot_id():
429
+ """This kernel's boot id, or None without /proc."""
430
+ try:
431
+ with open("/proc/sys/kernel/random/boot_id", encoding="ascii") as handle:
432
+ return handle.read().strip() or None
433
+ except OSError:
434
+ return None
435
+
436
+
437
+ _PID_NAMESPACE = re.compile(r"pid:\[\d+\]")
438
+
439
+
440
+ def _not_running_reason(session):
441
+ """Why a row no longer names a running agent, or None."""
442
+ status = getattr(getattr(session, "status", None), "value", getattr(session, "status", None))
443
+ if status in ("stopped", "error"):
444
+ return "the session is not running, so its PID and window name may now be someone else's."
445
+ return None
446
+
447
+
448
+ def _signal_agent(pid, force):
449
+ sig = signal.SIGKILL if force else signal.SIGTERM
450
+ sig_name = "SIGKILL" if force else "SIGTERM"
451
+ try:
452
+ os.kill(pid, sig)
453
+ console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
454
+ except (OSError, ProcessLookupError):
455
+ console.print(f"[dim]Process {pid} already stopped.[/dim]")
456
+
457
+
458
+ def _kill_tmux_session(session):
459
+ """Stop a tmux-hosted agent only through a verified target.
460
+
461
+ A saved window name is mutable: another session may hold it now. Core's
462
+ kill_bound closes the window by the runtime's immutable window id and
463
+ records the stop only once the agent exited. Anything unverified exits
464
+ non-zero and leaves the registry alone.
465
+ """
466
+ from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
467
+
468
+ launcher = AgentLauncher()
469
+ if not callable(getattr(launcher, "kill_bound", None)):
470
+ # Core has had kill_bound since 0.6.5; a saved window name alone is
471
+ # never trusted, so without it nothing is closed.
472
+ console.print(
473
+ "[red]Not stopped:[/red] this qbraid-core cannot verify a tmux window; update qbraid-core.",
474
+ highlight=False,
475
+ )
476
+ raise typer.Exit(code=1)
477
+ try:
478
+ launcher.kill_bound(session.session_id)
479
+ except AgentLauncherError as error:
480
+ console.print(
481
+ "[red]Not stopped: Core could not verify this session's tmux window.[/red] "
482
+ f"{escape(str(error))}",
483
+ highlight=False,
484
+ )
485
+ raise typer.Exit(code=1) from error
486
+ console.print(f"[green]Stopped the agent in tmux window '{escape(session.tmux_window)}'.[/green]")
487
+
488
+
489
+ _GONE = "gone"
490
+ # create_time() and started_at come from different clocks reads; allow this much.
491
+ _START_SLACK_S = 5.0
492
+
493
+
494
+ def _agent_identity_refusal(session):
495
+ """Why the recorded PID must not be signalled as this session's agent, or None.
496
+
497
+ A registry outlives its processes (it is in $HOME across pod restarts),
498
+ so a PID number alone may now name an unrelated process. It is signalled
499
+ only when psutil shows this tool's agent, started no later than the
500
+ session. Anything that cannot be checked is refused. Returns ``_GONE``
501
+ when no process has that PID.
502
+ """
503
+ status = getattr(getattr(session, "status", None), "value", getattr(session, "status", None))
504
+ if status in ("stopped", "error"):
505
+ return "the session is not running, so that number may now be another process."
506
+ try:
507
+ import psutil # pylint: disable=import-outside-toplevel
508
+ from qbraid_core.services.agents.registry import ( # pylint: disable=import-outside-toplevel
509
+ process_runs_agent,
510
+ )
511
+ except ImportError:
512
+ return "this qbraid-core cannot check the process's identity; update it."
513
+ started = getattr(session, "started_at", None)
514
+ tool = getattr(getattr(session, "tool", None), "value", None)
515
+ try:
516
+ process = psutil.Process(session.pid)
517
+ # This tool's agent, and already running when the session started.
518
+ if (
519
+ not tool
520
+ or not isinstance(started, (int, float))
521
+ or not process_runs_agent(process, tool)
522
+ or process.create_time() > started + _START_SLACK_S
523
+ ):
524
+ return "it is not this session's agent."
525
+ except psutil.NoSuchProcess:
526
+ return _GONE
527
+ except (psutil.AccessDenied, psutil.Error):
528
+ return "its identity could not be checked (access denied)."
529
+ return None
530
+
531
+
532
+ def _end_reported_kwargs(method) -> dict:
533
+ """`agent_end_reported=True` when this Core's deregister takes it."""
534
+ try:
535
+ return (
536
+ {"agent_end_reported": True}
537
+ if "agent_end_reported" in inspect.signature(method).parameters
538
+ else {}
539
+ )
540
+ except (TypeError, ValueError):
541
+ return {}
542
+
543
+
343
544
  @agents_app.command("deregister", hidden=True)
344
545
  def agents_deregister():
345
546
  """Deregister an agent session.
@@ -358,6 +559,9 @@ def agents_deregister():
358
559
  from qbraid_core.services.agents import AgentSessionRegistryManager
359
560
 
360
561
  registry = AgentSessionRegistryManager()
562
+ # This is the agent's own end hook: say so, where Core can record it
563
+ # (it lets a cleared PID read as exited). Nothing else may claim it.
564
+ ended = _end_reported_kwargs(registry.deregister_session)
361
565
 
362
566
  session_id = data.get("session_id", data.get("id"))
363
567
  pid = data.get("pid")
@@ -365,15 +569,19 @@ def agents_deregister():
365
569
  # Try to find and deregister by session_id, then by pid
366
570
  if session_id:
367
571
  try:
368
- registry.deregister_session(session_id=session_id)
369
- raise typer.Exit(code=0)
370
- except Exception:
371
- pass
572
+ ended_id = registry.deregister_session(session_id=session_id, **ended)
573
+ except Exception: # pylint: disable=broad-exception-caught
574
+ ended_id = None # unreadable by id: try the PID below
575
+ if ended_id is not None:
576
+ return # ended by id; the PID path is only for an unknown id
372
577
 
373
578
  if pid:
579
+ # The hook's PID is in this process's scope: a row recorded in
580
+ # another namespace, boot or machine that holds the same number
581
+ # is someone else's agent, never this one's end.
374
582
  session = registry.find_by_pid(int(pid))
375
- if session:
376
- registry.deregister_session(session_id=session.session_id)
583
+ if session and not _foreign_pid_reason(session):
584
+ registry.deregister_session(session_id=session.session_id, **ended)
377
585
 
378
586
  except (typer.Exit, SystemExit):
379
587
  raise
@@ -578,7 +786,7 @@ def agents_list(
578
786
  if not name_val and s.cwd:
579
787
  name_val = Path(s.cwd).name
580
788
  tool_val = s.tool.value if s.tool else "-"
581
- status_val = s.status.value if s.status else "idle"
789
+ status_val = _status_label(s)
582
790
  style = _status_style(status_val)
583
791
 
584
792
  # Add time-ago to status
@@ -772,9 +980,9 @@ def agents_status(
772
980
  console.print("[dim]Use 'qbraid agents list' to see active sessions.[/dim]")
773
981
  raise typer.Exit(code=1)
774
982
 
775
- # Build detail lines
776
- s = match
777
- status_val = s.status.value if s.status else "idle"
983
+ # Build detail lines, as the list shows the record
984
+ s = _lifecycle_view(registry, match)
985
+ status_val = _status_label(s)
778
986
  style = _status_style(status_val)
779
987
 
780
988
  lines = []
@@ -1121,39 +1329,75 @@ def agents_kill(
1121
1329
  session = matches[0]
1122
1330
  pid = session.pid
1123
1331
 
1332
+ # Shown as interrupted (another Lab's row this Lab cannot verify):
1333
+ # refused before anything, Core included, so nothing is written for
1334
+ # it. Other native rows go to Core, which asks their owning server.
1335
+ if (_lifecycle_view(registry, session).metadata or {}).get("status_overlay"):
1336
+ console.print(
1337
+ "[red]Not stopped:[/red] it belongs to another Lab that this Lab cannot verify, so it is "
1338
+ "listed as interrupted. Resume it with --confirm-owner-gone, or stop it from its own Lab. "
1339
+ "Nothing was signalled or closed.",
1340
+ highlight=False,
1341
+ )
1342
+ raise typer.Exit(code=1)
1343
+
1124
1344
  if session.metadata.get("terminal_backend") == "jupyter":
1125
- from qbraid_core.services.agents import AgentLauncher
1345
+ # A native agent belongs to its Lab server, which Core reaches and
1346
+ # verifies; its PID may be another namespace's. Never signalled here.
1347
+ from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
1348
+ from qbraid_core.services.agents.exceptions import AgentRuntimeUnavailableError
1126
1349
 
1127
- AgentLauncher().kill_bound(session.session_id)
1350
+ try:
1351
+ AgentLauncher().kill_bound(session.session_id)
1352
+ except AgentRuntimeUnavailableError as error:
1353
+ if _not_running_reason(session):
1354
+ # Nothing to verify: Core says why (e.g. no longer active).
1355
+ console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1356
+ else:
1357
+ console.print(
1358
+ "[red]Could not verify the Lab server that owns this agent; "
1359
+ "nothing was stopped.[/red] "
1360
+ f"{escape(str(error))}",
1361
+ highlight=False,
1362
+ )
1363
+ raise typer.Exit(code=1)
1364
+ except AgentLauncherError as error:
1365
+ console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1366
+ raise typer.Exit(code=1)
1128
1367
  console.print("[green]Stopped native Lab agent.[/green]")
1129
1368
  return
1130
1369
 
1131
- # For tmux-managed sessions, kill the tmux window
1132
- if session.tmux_session and session.tmux_window:
1133
- try:
1134
- import subprocess
1370
+ # Refused before anything is touched: a row of another PID namespace or
1371
+ # machine, or one no longer running, names a PID and a tmux window that
1372
+ # may now belong to someone else.
1373
+ refusal = _foreign_pid_reason(session) or _not_running_reason(session)
1374
+ if refusal:
1375
+ console.print(
1376
+ f"[red]Not stopped:[/red] {escape(refusal)} Nothing was signalled or closed.", highlight=False
1377
+ )
1378
+ raise typer.Exit(code=1)
1135
1379
 
1136
- subprocess.run(
1137
- ["tmux", "kill-window", "-t", f"{session.tmux_session}:{session.tmux_window}"],
1138
- capture_output=True,
1139
- check=False,
1140
- )
1141
- console.print(f"[green]Killed tmux window '{session.tmux_window}'.[/green]")
1142
- except Exception:
1143
- pass
1380
+ if session.tmux_session and session.tmux_window:
1381
+ _kill_tmux_session(session)
1382
+ return
1144
1383
 
1145
- # Send signal to the process if we have a valid PID
1146
- if pid and pid > 0:
1147
- sig = signal.SIGKILL if force else signal.SIGTERM
1148
- sig_name = "SIGKILL" if force else "SIGTERM"
1149
- try:
1150
- os.kill(pid, 0) # check alive
1151
- os.kill(pid, sig)
1152
- console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
1153
- except (OSError, ProcessLookupError):
1154
- console.print(f"[dim]Process {pid} already stopped.[/dim]")
1384
+ if not (pid and pid > 0):
1385
+ console.print(
1386
+ "[red]Not stopped:[/red] no agent process or terminal is recorded for this session."
1387
+ )
1388
+ raise typer.Exit(code=1)
1389
+ identity = _agent_identity_refusal(session)
1390
+ if identity == _GONE:
1391
+ console.print(f"[dim]Process {pid} already stopped.[/dim]")
1392
+ elif identity:
1393
+ console.print(
1394
+ f"[red]Not stopped. Did not signal PID {pid}:[/red] {escape(identity)}", highlight=False
1395
+ )
1396
+ raise typer.Exit(code=1)
1397
+ else:
1398
+ _signal_agent(pid, force)
1155
1399
 
1156
- # Mark stopped in registry
1400
+ # Verified gone or signalled: mark stopped in registry
1157
1401
  registry.deregister_session(session_id=session.session_id)
1158
1402
  console.print(
1159
1403
  f"[dim]Session {session.session_id[:8]} "
@@ -1623,6 +1867,139 @@ def _hook_trust_kwargs(method, value):
1623
1867
  return {"trust_hooks": value}
1624
1868
 
1625
1869
 
1870
+ OWNER_GONE_PAUSED = "The old Lab may be paused; only confirm if you closed or restarted it."
1871
+ OWNER_GONE_WARNING = (
1872
+ "The old Lab may be paused; only confirm if you closed or restarted it. "
1873
+ "Confirming while it still runs starts a second copy of the agent."
1874
+ )
1875
+
1876
+
1877
+ def _owner_confirmation_supported(launcher) -> bool:
1878
+ """Whether this Core can take a confirmation that a session's old Lab is gone."""
1879
+ try:
1880
+ return all(
1881
+ "confirm_owner_gone" in inspect.signature(method).parameters
1882
+ for method in (launcher.resume, launcher.resume_preflight)
1883
+ )
1884
+ except (AttributeError, TypeError, ValueError):
1885
+ return False
1886
+
1887
+
1888
+ def _exact_session_id(target: str) -> str:
1889
+ """One session's full id for a full id, unique prefix or name."""
1890
+ from qbraid_core.services.agents import AgentLauncherError
1891
+
1892
+ sessions = _get_registry().search_sessions(include_stopped=True)
1893
+ exact = [s for s in sessions if s.session_id == target]
1894
+ if exact:
1895
+ return exact[0].session_id
1896
+ matches = [
1897
+ s
1898
+ for s in sessions
1899
+ if s.session_id.startswith(target)
1900
+ or (s.tmux_window and s.tmux_window == target)
1901
+ or (s.terminal_name and s.terminal_name == target)
1902
+ ]
1903
+ if len(matches) != 1:
1904
+ raise AgentLauncherError(
1905
+ f"No session matches '{target}'."
1906
+ if not matches
1907
+ else f"Multiple sessions match '{target}'; give the full session ID."
1908
+ )
1909
+ return matches[0].session_id
1910
+
1911
+
1912
+ def _can_ask() -> bool:
1913
+ """Whether a person is at a terminal to answer a prompt."""
1914
+ import sys
1915
+
1916
+ return sys.stdin.isatty()
1917
+
1918
+
1919
+ def _confirm_owner_gone(launcher, session_id: str, yes: bool):
1920
+ """Gate `resume --confirm-owner-gone`: Core's preflight first, then the person.
1921
+
1922
+ Returns what the resume should carry as ``confirm_owner_gone``: exactly
1923
+ the Labs the preflight disclosed (``confirmServers``), or True from a Core
1924
+ that lists none (it covers the source's own Lab). Returns None when Core
1925
+ says no confirmation is needed. Exits otherwise.
1926
+ """
1927
+ from qbraid_core.services.agents import AgentLauncherError
1928
+
1929
+ if not _owner_confirmation_supported(launcher):
1930
+ raise AgentLauncherError(
1931
+ "--confirm-owner-gone requires a newer qbraid-core (confirm_owner_gone) on this machine."
1932
+ )
1933
+ preflight = launcher.resume_preflight(session_id)
1934
+ if preflight.get("canResume"):
1935
+ console.print(
1936
+ "[dim]This session can be resumed without confirming anything; resuming normally.[/dim]"
1937
+ )
1938
+ return None
1939
+ reason = preflight.get("reason") or "This session cannot be resumed."
1940
+ if preflight.get("confirmable") is not True:
1941
+ console.print(f"[red]{escape(reason)}[/red]", highlight=False)
1942
+ wait = preflight.get("confirmableInS")
1943
+ if preflight.get("confirmable") is False and isinstance(wait, (int, float)) and wait > 0:
1944
+ minutes = max(1, -(-int(wait) // 60))
1945
+ console.print(
1946
+ f"You can confirm the old Lab is gone in about {minutes} min ({int(wait)} s). "
1947
+ "Run this again then."
1948
+ )
1949
+ elif "confirmable" not in preflight:
1950
+ console.print("Confirming the old Lab gone does not apply to this session.")
1951
+ raise typer.Exit(code=1)
1952
+ console.print(f"[yellow]{escape(reason)}[/yellow]", highlight=False)
1953
+ if preflight.get("confirmReason"):
1954
+ console.print(preflight["confirmReason"], markup=False, highlight=False)
1955
+ # Core's reason already says what confirming risks; only the rest is added.
1956
+ servers = preflight.get("confirmServers")
1957
+ servers = list(servers) if isinstance(servers, list) and servers else None
1958
+ if servers and len(servers) > 1:
1959
+ console.print(f"This confirms {len(servers)} Lab servers gone: {', '.join(servers)}", markup=False)
1960
+ console.print(
1961
+ f"[bold]{OWNER_GONE_PAUSED if preflight.get('confirmReason') else OWNER_GONE_WARNING}[/bold]",
1962
+ highlight=False,
1963
+ )
1964
+ confirmation = servers or True
1965
+ if yes:
1966
+ return confirmation
1967
+ if not _can_ask():
1968
+ console.print(
1969
+ "[red]No terminal to ask on.[/red] Pass --yes only after the person has "
1970
+ "confirmed that the old Lab is closed or restarted."
1971
+ )
1972
+ raise typer.Exit(code=2)
1973
+ if not typer.confirm("Resume this session anyway?", default=False):
1974
+ console.print("Not resumed.")
1975
+ raise typer.Exit(code=1)
1976
+ return confirmation
1977
+
1978
+
1979
+ #: A new runtime registers at once; one that started this long before the
1980
+ #: resume call was already running.
1981
+ _ALREADY_RUNNING_SLACK_S = 2.0
1982
+
1983
+
1984
+ def _already_running(session, resume_started: float) -> bool:
1985
+ """Whether Core returned a runtime that was running before this resume.
1986
+
1987
+ Core #419 says so itself (`AgentSession.resumed_existing`: True only when
1988
+ resume returned the running resumption). Anything else (an older Core
1989
+ without the property) falls back to the start time: a runtime that
1990
+ started before this call was already running.
1991
+ """
1992
+ said = getattr(session, "resumed_existing", None)
1993
+ if said is True or said is False:
1994
+ return said
1995
+ started = getattr(session, "started_at", None)
1996
+ return (
1997
+ isinstance(started, (int, float))
1998
+ and not isinstance(started, bool)
1999
+ and started < resume_started - _ALREADY_RUNNING_SLACK_S
2000
+ )
2001
+
2002
+
1626
2003
  def _startup_pending(session):
1627
2004
  return session.tool.value in ("codex", "claude") and not getattr(session, "startup_confirmed_at", None)
1628
2005
 
@@ -1967,6 +2344,24 @@ def agents_resume(
1967
2344
  "Default: keep the answer given at launch."
1968
2345
  ),
1969
2346
  ),
2347
+ confirm_owner_gone: bool = typer.Option(
2348
+ False,
2349
+ "--confirm-owner-gone",
2350
+ help=(
2351
+ "For a session from a restarted Lab that cannot be verified: confirm its old Lab is "
2352
+ "closed or restarted, and resume anyway. Asks first; confirming while that Lab still "
2353
+ "runs starts a second copy of the agent."
2354
+ ),
2355
+ ),
2356
+ yes: bool = typer.Option(
2357
+ False,
2358
+ "--yes",
2359
+ "-y",
2360
+ help=(
2361
+ "With --confirm-owner-gone: do not ask. Agents must not pass this unless the person "
2362
+ "has confirmed the old Lab is gone."
2363
+ ),
2364
+ ),
1970
2365
  compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
1971
2366
  ):
1972
2367
  """Resume a previous agent session.
@@ -1974,10 +2369,24 @@ def agents_resume(
1974
2369
  Resumes with the selected terminal backend. With ``--compute`` the session is resumed
1975
2370
  on that machine, and a session id is required -- the interactive picker
1976
2371
  reads the local registry and cannot see another host's sessions.
2372
+
2373
+ ``--confirm-owner-gone`` is for a session whose old Lab (in another PID
2374
+ namespace, after a restart) cannot be verified gone. Core's preflight runs
2375
+ first; when it allows a confirmation, its reason and the risk are shown and
2376
+ the person is asked (default No). Without a terminal it refuses unless
2377
+ ``--yes`` is given. An agent must never pass ``--yes`` on its own judgement:
2378
+ only after the person has confirmed that the old Lab is closed or restarted.
1977
2379
  """
1978
2380
  try:
1979
2381
  from qbraid_core.services.agents import AgentLauncherError
1980
2382
 
2383
+ if yes and not confirm_owner_gone:
2384
+ raise AgentLauncherError("--yes applies only to --confirm-owner-gone.")
2385
+ if confirm_owner_gone and compute:
2386
+ raise AgentLauncherError(
2387
+ "--confirm-owner-gone is not available with --compute; run it on that machine, "
2388
+ "where the person can be asked."
2389
+ )
1981
2390
  if compute and backend != TerminalBackend.AUTO:
1982
2391
  raise AgentLauncherError("Remote resumes select the backend on the destination; omit --backend.")
1983
2392
  if compute:
@@ -2014,11 +2423,24 @@ def agents_resume(
2014
2423
 
2015
2424
  launcher = _backend_launcher(backend)
2016
2425
 
2426
+ confirmation = {}
2427
+ if confirm_owner_gone:
2428
+ session_id = _exact_session_id(session_id)
2429
+ confirmed = _confirm_owner_gone(launcher, session_id, yes)
2430
+ if confirmed is not None:
2431
+ # Only an explicit yes (or --yes) reaches Core: the Labs it was shown.
2432
+ confirmation = {"confirm_owner_gone": confirmed}
2433
+
2434
+ # A runtime Core returns that started before this call is one already
2435
+ # running (Core hands back a live resumption instead of a second copy).
2436
+ resume_started = time.time()
2437
+
2017
2438
  # Try resume — handle active session and ambiguous matches
2018
2439
  try:
2019
2440
  session = launcher.resume(
2020
2441
  session_id=session_id,
2021
2442
  name=name,
2443
+ **confirmation,
2022
2444
  **(_approval_kwargs(launcher.resume, approval.value) if approval else {}),
2023
2445
  **_hook_trust_kwargs(launcher.resume, True if trust_hooks else None),
2024
2446
  **_prompt_answer_kwargs(launcher.resume, trust_folder),
@@ -2045,6 +2467,18 @@ def agents_resume(
2045
2467
  )
2046
2468
 
2047
2469
  if choice in ("a", "attach"):
2470
+ active = next(
2471
+ (s for s in _get_registry().load_registry().sessions if s.session_id == active_sid),
2472
+ None,
2473
+ )
2474
+ foreign = (
2475
+ _foreign_pid_reason(active)
2476
+ if active is not None
2477
+ else "the active session is not recorded."
2478
+ )
2479
+ if foreign:
2480
+ console.print(f"[red]Not attached:[/red] {escape(foreign)}", highlight=False)
2481
+ raise typer.Exit(code=1)
2048
2482
  target = f"{tmux_sess}:{tmux_win}"
2049
2483
  console.print(f"[dim]Attaching to {target}... (Ctrl+b d to detach)[/dim]")
2050
2484
  os.execvp("tmux", ["tmux", "attach", "-t", target])
@@ -2154,6 +2588,16 @@ def agents_resume(
2154
2588
  **_execution_kwargs(launcher.resume, execution_mode.value if execution_mode else None),
2155
2589
  )
2156
2590
 
2591
+ if _already_running(session, resume_started):
2592
+ name_val = session.terminal_name or session.tmux_window
2593
+ label = f"{session.session_id[:8]}" + (f" ({escape(name_val)})" if name_val else "")
2594
+ console.print(
2595
+ f"This conversation is already running in {label}: attach with "
2596
+ f"[bold]qbraid agents attach {session.session_id}[/bold]",
2597
+ highlight=False,
2598
+ )
2599
+ return
2600
+
2157
2601
  status_val = (
2158
2602
  "starting / check terminal"
2159
2603
  if _startup_pending(session)
@@ -2372,65 +2816,6 @@ def _entry_style(entry_type: str) -> str:
2372
2816
  return styles.get(entry_type, "")
2373
2817
 
2374
2818
 
2375
- def _check_permission_prompt(tmux_session: str, tmux_window: str) -> Optional[list[str]]:
2376
- """Read the tmux pane and check for a permission prompt.
2377
-
2378
- Returns a list of option strings if a prompt is detected, None otherwise.
2379
- """
2380
- import subprocess
2381
-
2382
- result = subprocess.run(
2383
- ["tmux", "capture-pane", "-t", f"{tmux_session}:{tmux_window}", "-p", "-S", "-60"],
2384
- capture_output=True,
2385
- text=True,
2386
- check=False,
2387
- )
2388
- if result.returncode != 0:
2389
- return None
2390
-
2391
- pane_text = result.stdout
2392
-
2393
- # Look for Claude's permission prompt markers
2394
- if "Do you want to" not in pane_text and "Allow" not in pane_text:
2395
- return None
2396
-
2397
- # Extract numbered options using regex — handles ANSI/unicode prefixes
2398
- # Claude's format: "❯ 1. Yes" / " 2. Yes, allow..." / " 3. No"
2399
- # Options may wrap across multiple lines in the terminal.
2400
- options: list[str] = []
2401
- pane_lines = pane_text.splitlines()
2402
- for i, line in enumerate(pane_lines):
2403
- match = re.search(r"(\d+)\.\s+(.+)", line)
2404
- if match:
2405
- num = int(match.group(1))
2406
- text = match.group(2).strip()
2407
- if num == len(options) + 1 and text:
2408
- # Collect continuation lines (lines until next numbered option,
2409
- # blank line, or "Esc to cancel" marker)
2410
- for j in range(i + 1, len(pane_lines)):
2411
- cont = pane_lines[j].strip()
2412
- if not cont or re.search(r"^\d+\.", cont) or "Esc to cancel" in cont:
2413
- break
2414
- text += " " + cont
2415
- options.append(f"{num}. {text}")
2416
-
2417
- return options if options else None
2418
-
2419
-
2420
- def _send_permission_choice(tmux_session: str, tmux_window: str, choice_idx: int) -> None:
2421
- """Send arrow key presses and Enter to select a permission option."""
2422
- import subprocess
2423
-
2424
- target = f"{tmux_session}:{tmux_window}"
2425
-
2426
- # Arrow down from position 1 (default) to the chosen option
2427
- for _ in range(choice_idx):
2428
- subprocess.run(["tmux", "send-keys", "-t", target, "Down"], check=False)
2429
- time.sleep(0.05)
2430
-
2431
- subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
2432
-
2433
-
2434
2819
  @agents_app.command("watch")
2435
2820
  def agents_watch(
2436
2821
  session_id: str = typer.Argument(..., help="Session ID (prefix match)"),
@@ -2899,6 +3284,13 @@ def agents_attach(
2899
3284
  JupyterRuntimeClient(session.metadata["jupyter_server_id"]).attach(session)
2900
3285
  return
2901
3286
 
3287
+ # A row recorded in another PID namespace, boot or Lab names a window
3288
+ # that may now be someone else's: never attached to by name.
3289
+ foreign = _foreign_pid_reason(session)
3290
+ if foreign:
3291
+ console.print(f"[red]Not attached:[/red] {escape(foreign)}", highlight=False)
3292
+ raise typer.Exit(code=1)
3293
+
2902
3294
  tmux_session = session.tmux_session
2903
3295
  tmux_window = session.tmux_window
2904
3296
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-cli
3
- Version: 0.16.9
3
+ Version: 0.16.10
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.21
33
+ Requires-Dist: qbraid-core>=0.6.27
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.21; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.27; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.21; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.27; extra == "envs"
38
38
  Provides-Extra: dev
39
39
  Requires-Dist: isort; extra == "dev"
40
40
  Requires-Dist: black; extra == "dev"
@@ -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.21
5
+ qbraid-core>=0.6.27
6
6
 
7
7
  [compute]
8
- qbraid-core[compute]>=0.6.21
8
+ qbraid-core[compute]>=0.6.27
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.21
18
+ qbraid-core[environments]>=0.6.27
@@ -935,15 +935,20 @@ class TestAgentsE2E:
935
935
 
936
936
  assert result.exit_code == 1
937
937
 
938
+ @patch("qbraid_core.services.agents.AgentLauncher")
938
939
  @patch("qbraid_core.services.agents.AgentSessionRegistryManager")
939
- def test_kill_session(self, mock_registry_cls):
940
- """Kill a session by ID."""
940
+ def test_kill_session(self, mock_registry_cls, mock_launcher_cls):
941
+ """Kill a session by ID: a tmux row goes through Core's verified kill."""
941
942
  mock_session = MagicMock()
942
943
  mock_session.session_id = "abc12345-6789"
943
944
  mock_session.tmux_window = "worker-0"
944
945
  mock_session.terminal_name = None
945
946
  mock_session.pid = 12345
946
947
  mock_session.compute_id = None
948
+ mock_session.machine_id = None
949
+ mock_session.cluster_id = None
950
+ mock_session.metadata = {}
951
+ mock_session.status.value = "idle"
947
952
  mock_session.tmux_session = "qbraid-agents"
948
953
 
949
954
  mock_registry = MagicMock()
@@ -954,9 +959,14 @@ class TestAgentsE2E:
954
959
 
955
960
  from qbraid_cli.agents.app import agents_app
956
961
 
957
- result = runner.invoke(agents_app, ["kill", "abc12345"])
962
+ with patch("subprocess.run") as tmux, patch("os.kill") as kill:
963
+ result = runner.invoke(agents_app, ["kill", "abc12345"])
958
964
 
959
- assert result.exit_code == 0
965
+ assert result.exit_code == 0, result.output
966
+ mock_launcher_cls.return_value.kill_bound.assert_called_once_with("abc12345-6789")
967
+ # Nothing touches the window or the PID directly: Core verified the target.
968
+ tmux.assert_not_called()
969
+ kill.assert_not_called()
960
970
 
961
971
  @patch("qbraid_core.services.agents.AgentSessionRegistryManager")
962
972
  def test_discover_empty(self, mock_registry_cls):
File without changes
File without changes
File without changes