qbraid-cli 0.16.14__tar.gz → 0.16.16__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.14/qbraid_cli.egg-info → qbraid_cli-0.16.16}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/app.py +9 -2
  4. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/queue.py +180 -12
  5. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16/qbraid_cli.egg-info}/PKG-INFO +4 -4
  6. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli.egg-info/requires.txt +3 -3
  7. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/tests/test_cli_e2e.py +25 -0
  8. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/LICENSE +0 -0
  9. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/README.md +0 -0
  10. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/__init__.py +0 -0
  11. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/_build_info.py +0 -0
  12. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/_version.py +0 -0
  13. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/account/__init__.py +0 -0
  14. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/account/app.py +0 -0
  15. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/admin/__init__.py +0 -0
  16. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/admin/app.py +0 -0
  17. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/admin/headers.py +0 -0
  18. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/admin/validation.py +0 -0
  19. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/__init__.py +0 -0
  20. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/keep_alive.py +0 -0
  21. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/permission_response.py +0 -0
  22. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/provider_children.py +0 -0
  23. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/agents/work.py +0 -0
  24. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/ai/__init__.py +0 -0
  25. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/ai/app.py +0 -0
  26. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/ai/token.py +0 -0
  27. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/comms/__init__.py +0 -0
  28. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/comms/app.py +0 -0
  29. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/compute/__init__.py +0 -0
  30. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/compute/app.py +0 -0
  31. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/compute/instances.py +0 -0
  32. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/compute/profiles.py +0 -0
  33. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/compute/server.py +0 -0
  34. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/compute/ssh.py +0 -0
  35. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/configure/__init__.py +0 -0
  36. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/configure/actions.py +0 -0
  37. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/configure/app.py +0 -0
  38. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/devices/__init__.py +0 -0
  39. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/devices/app.py +0 -0
  40. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/devices/validation.py +0 -0
  41. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/__init__.py +0 -0
  42. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/activate.py +0 -0
  43. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/app.py +0 -0
  44. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/create.py +0 -0
  45. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/data_handling.py +0 -0
  46. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/groups.py +0 -0
  47. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/publish.py +0 -0
  48. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/registry.py +0 -0
  49. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/envs/share.py +0 -0
  50. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/exceptions.py +0 -0
  51. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/handlers.py +0 -0
  52. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/jobs/__init__.py +0 -0
  53. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/jobs/app.py +0 -0
  54. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/jobs/groups.py +0 -0
  55. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/kernels/__init__.py +0 -0
  56. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/kernels/app.py +0 -0
  57. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/lab/__init__.py +0 -0
  58. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/lab/app.py +0 -0
  59. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/main.py +0 -0
  60. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/pip/__init__.py +0 -0
  61. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/pip/app.py +0 -0
  62. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/pip/hooks.py +0 -0
  63. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/projects/__init__.py +0 -0
  64. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/projects/app.py +0 -0
  65. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/py.typed +0 -0
  66. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/skills/__init__.py +0 -0
  67. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/skills/app.py +0 -0
  68. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/troubleshoot/__init__.py +0 -0
  69. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli/troubleshoot/app.py +0 -0
  70. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli.egg-info/SOURCES.txt +0 -0
  71. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  72. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli.egg-info/entry_points.txt +0 -0
  73. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/qbraid_cli.egg-info/top_level.txt +0 -0
  74. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/setup.cfg +0 -0
  75. {qbraid_cli-0.16.14 → qbraid_cli-0.16.16}/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.14
3
+ Version: 0.16.16
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.33
33
+ Requires-Dist: qbraid-core>=0.6.35
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.35; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.33; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.35; 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.14"
7
+ version = "0.16.16"
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.33",
35
+ "qbraid-core>=0.6.35",
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.33"]
47
- envs = ["qbraid-core[environments]>=0.6.33"]
46
+ compute = ["qbraid-core[compute]>=0.6.35"]
47
+ envs = ["qbraid-core[environments]>=0.6.35"]
48
48
  dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
49
49
 
50
50
  [project.scripts]
@@ -1031,12 +1031,19 @@ def agents_discover(
1031
1031
  include_error_traceback=False,
1032
1032
  )
1033
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
+
1034
1043
  if not discovered:
1035
1044
  console.print("[dim]No agent sessions found.[/dim]")
1036
1045
  return
1037
1046
 
1038
- registered_count = registry.merge_discovered_sessions(discovered)
1039
-
1040
1047
  table = Table(title=f"Discovered Sessions ({len(discovered)})")
1041
1048
  table.add_column("Session ID", style="cyan", no_wrap=True)
1042
1049
  table.add_column("Tool")
@@ -25,15 +25,17 @@ _MAX_RESPONSE = 2 * 1024 * 1024
25
25
  _SSH_TIMEOUT = 120
26
26
  _STATES = {"queued", "sending", "sent", "uncertain", "cancelled", "stale", "expired", "resolved"}
27
27
  _ACTION_STATES = {"queue_edit": "queued", "queue_cancel": "cancelled", "queue_resolve": "resolved"}
28
+ _HOLD_CAPABILITY = "message-queue-hold-v1"
28
29
 
29
30
 
30
31
  class QueueCommandError(Exception):
31
32
  """A safe failure with a request identifier for reconciliation."""
32
33
 
33
- def __init__(self, code, message, request_id=None):
34
+ def __init__(self, code, message, request_id=None, *, retryable=False):
34
35
  super().__init__(message)
35
36
  self.code = code
36
37
  self.request_id = request_id
38
+ self.retryable = retryable
37
39
 
38
40
 
39
41
  def _plain(value, multiline=False):
@@ -112,6 +114,59 @@ def _valid_receipt(value, session_id, machine_id):
112
114
  )
113
115
 
114
116
 
117
+ def _valid_hold(value, session_id, binding, *, state="held"):
118
+ """Validate the Core hold schema and exact destination binding."""
119
+ keys = {
120
+ "id",
121
+ "sessionId",
122
+ "expectedRuntime",
123
+ "receiver",
124
+ "machineId",
125
+ "bootId",
126
+ "turn",
127
+ "revision",
128
+ "state",
129
+ "reason",
130
+ "createdAt",
131
+ "updatedAt",
132
+ }
133
+ if not isinstance(value, dict) or set(value) != keys or not _valid_runtime(value["expectedRuntime"]):
134
+ return False
135
+ runtime = value["expectedRuntime"]
136
+ if set(runtime) != {"pid", "startedAt", "tool", "terminalId", "serverId", "transcriptPath"}:
137
+ return False
138
+ receiver = value["receiver"]
139
+ native = receiver is None and all(
140
+ isinstance(runtime[key], str) and runtime[key] for key in ("terminalId", "serverId")
141
+ )
142
+ tmux = (
143
+ runtime["terminalId"] is None
144
+ and runtime["serverId"] is None
145
+ and isinstance(receiver, dict)
146
+ and bool(receiver)
147
+ and len(json.dumps(receiver)) <= 4096
148
+ )
149
+ return all(
150
+ (
151
+ _canonical_uuid(value["id"]),
152
+ value["sessionId"] == session_id,
153
+ value["machineId"] == binding["expectedMachineId"],
154
+ value["bootId"] == binding["expectedBootId"],
155
+ _stamp(runtime["startedAt"]),
156
+ isinstance(runtime["transcriptPath"], str) and bool(runtime["transcriptPath"]),
157
+ native or tmux,
158
+ isinstance(value["turn"], str) and re.fullmatch(r"[0-9a-f]{64}", value["turn"]),
159
+ isinstance(value["revision"], int)
160
+ and not isinstance(value["revision"], bool)
161
+ and value["revision"] > 0,
162
+ value["state"] == state,
163
+ isinstance(value["reason"], str),
164
+ _stamp(value["createdAt"]),
165
+ _stamp(value["updatedAt"]),
166
+ )
167
+ )
168
+
169
+
115
170
  def _new_protocol():
116
171
  try:
117
172
  from qbraid_core.services.agents.machine_protocol import MachineProtocol
@@ -281,7 +336,10 @@ class Destination:
281
336
  "UNKNOWN_OUTCOME", "Unconfirmed destination error; inspect request status.", rid
282
337
  )
283
338
  raise QueueCommandError(
284
- error.get("code", "OPERATION_FAILED"), error.get("message", "Request failed."), rid
339
+ error.get("code", "OPERATION_FAILED"),
340
+ error.get("message", "Request failed."),
341
+ rid,
342
+ retryable=error.get("retryable") is True,
285
343
  )
286
344
  result = response.get("result")
287
345
  if not isinstance(result, dict):
@@ -381,6 +439,10 @@ def _emit(result, json_output, request_id=None, *, ok=True):
381
439
  envelope["requestId"] = request_id
382
440
  typer.echo(json.dumps(envelope, ensure_ascii=True))
383
441
  elif "messages" in result:
442
+ if result.get("hold"):
443
+ hold = result["hold"]
444
+ typer.echo(f"Queue paused. Hold {_plain(hold['id'])}, revision {_plain(hold['revision'])}.")
445
+ typer.echo("Use queue continue with this hold ID and revision to allow automatic delivery.")
384
446
  for row in result["messages"]:
385
447
  typer.echo(f"{_plain(row['id'])} {_plain(row['state'])} revision {_plain(row['revision'])}")
386
448
  prose = _plain(row["text"], multiline=True).replace("\n", "\n ")
@@ -396,18 +458,64 @@ def _emit(result, json_output, request_id=None, *, ok=True):
396
458
  typer.echo(status)
397
459
  if result.get("reason"):
398
460
  typer.echo(_plain(result["reason"]))
461
+ elif result.get("interruptRequested") is True:
462
+ typer.echo("Interrupt requested; queued input is paused.")
463
+ elif isinstance(result.get("hold"), dict) and result["hold"].get("state") == "released":
464
+ typer.echo("Queue continued. A running watcher will deliver when the agent is ready.")
465
+ elif result.get("waiting") == "BUSY":
466
+ typer.echo("Queue is busy; the watcher will retry on its next tick.")
467
+ elif "delivered" in result and isinstance(result["delivered"], list):
468
+ typer.echo(f"Delivered {len(result['delivered'])} queued message(s).")
469
+ elif isinstance(result.get("state"), str):
470
+ typer.echo(f"Request status: {_plain(result['state'])}")
471
+ elif type(result.get("canInterrupt")) is bool: # pylint: disable=unidiomatic-typecheck
472
+ if result["canInterrupt"]:
473
+ typer.echo("Interrupt is available for the verified active turn.")
474
+ else:
475
+ reason = result.get("reason")
476
+ typer.echo(
477
+ f"Interrupt unavailable: {_plain(reason)}"
478
+ if isinstance(reason, str)
479
+ else "Interrupt unavailable."
480
+ )
399
481
  else:
400
- typer.echo(_plain(json.dumps(result, ensure_ascii=True)))
482
+ # Never serialize arbitrary protocol results to a human terminal. Some
483
+ # operations include receiver identities and transcript paths.
484
+ typer.echo("Operation completed.")
401
485
 
402
486
 
403
487
  def _mutation(destination, operation, json_output, request_id, **fields):
404
488
  rid = _uuid(request_id)
405
489
  # Written before crossing the transport, so even Ctrl+C leaves the ID visible.
406
490
  typer.echo(f"Request ID: {rid}", err=True)
407
- result = destination.call(operation, rid, **fields)
491
+ result = None
492
+ for attempt in range(4):
493
+ try:
494
+ result = destination.call(operation, rid, **fields)
495
+ break
496
+ except QueueCommandError as error:
497
+ # Core marks only queue-lock refusals that happened before any
498
+ # durable state or terminal write as retryable. A BUSY code alone
499
+ # is insufficient: transport-queue BUSY may not carry that proof.
500
+ if not error.retryable or error.code != "BUSY" or attempt == 3:
501
+ raise
502
+ time.sleep(0.1 * (2**attempt))
503
+ assert result is not None # every successful destination call returns a JSON object
408
504
  if operation == "interrupt":
409
505
  confirmed = (
410
- result.get("interruptRequested") is True and result.get("sessionId") == fields["sessionId"]
506
+ result.get("interruptRequested") is True
507
+ and result.get("sessionId") == fields["sessionId"]
508
+ and _valid_hold(result.get("hold"), fields["sessionId"], destination.binding)
509
+ and result["hold"]["expectedRuntime"] == fields["expectedRuntime"]
510
+ and result["hold"]["turn"] == fields["expectedTurn"]
511
+ )
512
+ elif operation == "queue_resume":
513
+ hold = result.get("hold")
514
+ confirmed = (
515
+ _valid_hold(hold, fields["sessionId"], destination.binding, state="released")
516
+ and hold["id"] == fields["holdId"]
517
+ and hold["revision"] == fields["revision"] + 1
518
+ and hold["expectedRuntime"] == fields["expectedRuntime"]
411
519
  )
412
520
  else:
413
521
  expected_id = rid if operation == "queue_enqueue" else fields["messageId"]
@@ -463,8 +571,8 @@ def enqueue(
463
571
  )
464
572
  if not json_output:
465
573
  typer.echo(
466
- "Queued input needs a running queue watcher on the destination. "
467
- "Sent means terminal submission, not a model response."
574
+ "Queued input needs a running queue watcher. Check queue list for its status; "
575
+ "if paused, use queue continue with the displayed hold ID and revision."
468
576
  )
469
577
 
470
578
 
@@ -482,12 +590,29 @@ def list_messages(
482
590
  messages: list[dict] = []
483
591
  seen, seen_ids = set(), set()
484
592
  cursor = None
593
+ hold = None
594
+ hold_supported = _HOLD_CAPABILITY in destination.snapshot["capabilities"]
485
595
  for _ in range(257):
486
596
  page = destination.call(
487
597
  "queue_list", sessionId=session_id, **({"cursor": cursor} if cursor else {})
488
598
  )
489
599
  if not isinstance(page.get("messages"), list) or "nextCursor" not in page:
490
600
  raise QueueCommandError("INVALID_RESPONSE", "Invalid queue listing.")
601
+ if hold_supported:
602
+ current_hold = page.get("hold")
603
+ if (
604
+ "hold" not in page
605
+ or current_hold is not None
606
+ and not _valid_hold(current_hold, session_id, destination.binding)
607
+ ):
608
+ raise QueueCommandError("INVALID_RESPONSE", "Invalid queue hold receipt.")
609
+ if current_hold is not None and current_hold["expectedRuntime"] != destination.runtime(
610
+ session_id
611
+ ):
612
+ raise QueueCommandError("QUEUE_CHANGED", "The paused runtime changed; list it again.")
613
+ if cursor is not None and current_hold != hold:
614
+ raise QueueCommandError("QUEUE_CHANGED", "The queue hold changed; list it again.")
615
+ hold = current_hold
491
616
  for row in page["messages"]:
492
617
  if (
493
618
  not _valid_receipt(row, session_id, destination.snapshot["machineId"])
@@ -499,7 +624,10 @@ def list_messages(
499
624
  messages.append(row)
500
625
  cursor = page["nextCursor"]
501
626
  if cursor is None:
502
- _emit({"messages": messages, "nextCursor": None}, json_output)
627
+ _emit(
628
+ {"messages": messages, "nextCursor": None, **({"hold": hold} if hold_supported else {})},
629
+ json_output,
630
+ )
503
631
  return
504
632
  if (
505
633
  not _canonical_uuid(cursor)
@@ -512,6 +640,33 @@ def list_messages(
512
640
  raise QueueCommandError("QUEUE_CHANGED", "Queue pagination changed; start the list again.")
513
641
 
514
642
 
643
+ @queue_app.command("continue")
644
+ def continue_queue(
645
+ session_id: str = typer.Argument(..., help="Exact destination session ID."),
646
+ hold_id: str = typer.Argument(..., help="Hold UUID shown by queue list."),
647
+ revision: int = typer.Option(..., "--revision", min=1, help="Hold revision shown by queue list."),
648
+ compute: Optional[str] = typer.Option(None, "--compute"),
649
+ request_id: Optional[str] = typer.Option(None, "--request-id"),
650
+ json_output: bool = typer.Option(False, "--json"),
651
+ ):
652
+ """Continue this paused queue.
653
+
654
+ Releases only the displayed hold. The watcher still checks turn completion,
655
+ pending approvals and uncertain messages; no input is sent by this command.
656
+ """
657
+ with _action(json_output, compute, _HOLD_CAPABILITY) as destination:
658
+ _mutation(
659
+ destination,
660
+ "queue_resume",
661
+ json_output,
662
+ request_id,
663
+ sessionId=session_id,
664
+ expectedRuntime=destination.runtime(session_id),
665
+ holdId=_uuid(hold_id),
666
+ revision=revision,
667
+ )
668
+
669
+
515
670
  @queue_app.command("edit")
516
671
  def edit(
517
672
  session_id: str = typer.Argument(...),
@@ -634,7 +789,12 @@ def watch(
634
789
  raise QueueCommandError(
635
790
  "UNSUPPORTED_OPERATION", "Update qbraid-core to host queued delivery."
636
791
  ) from None
637
- watcher = MessageQueue(destination.protocol)
792
+ # Opt in only when this CLI and destination share the hold contract.
793
+ # Older Core versions retain their original constructor signature.
794
+ watcher_options = (
795
+ {"track_native_holds": True} if _HOLD_CAPABILITY in destination.snapshot["capabilities"] else {}
796
+ )
797
+ watcher = MessageQueue(destination.protocol, **watcher_options)
638
798
  if not once and not json_output:
639
799
  typer.echo(f"Watching this machine's queue every {interval:g}s. Ctrl+C pauses delivery.")
640
800
  try:
@@ -670,11 +830,18 @@ def interrupt(
670
830
  ):
671
831
  """Interrupt one verified active turn.
672
832
 
673
- Request one Escape, without ending the agent process.
833
+ Request one Escape and pause queued input, without ending the agent process.
674
834
  """
675
835
  with _action(json_output, compute, "interrupt-v1") as destination:
836
+ if _HOLD_CAPABILITY not in destination.snapshot["capabilities"]:
837
+ raise QueueCommandError(
838
+ "UNSUPPORTED_OPERATION",
839
+ "Update qbraid-core on this destination to pause queued input safely when interrupting.",
840
+ )
676
841
  runtime = destination.runtime(session_id)
677
- offer = destination.call("interrupt_preflight", sessionId=session_id, expectedRuntime=runtime)
842
+ offer = destination.call(
843
+ "interrupt_preflight", sessionId=session_id, expectedRuntime=runtime, requireQueueHold=True
844
+ )
678
845
  if preflight:
679
846
  available = offer.get("canInterrupt") is True
680
847
  _emit(
@@ -697,6 +864,7 @@ def interrupt(
697
864
  sessionId=session_id,
698
865
  expectedRuntime=runtime,
699
866
  expectedTurn=offer["turn"],
867
+ requireQueueHold=True,
700
868
  )
701
869
  if not json_output:
702
- typer.echo("Interrupt requested. This acknowledges Escape input, not that the agent has stopped.")
870
+ typer.echo("This acknowledges Escape input, not that the agent has stopped.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-cli
3
- Version: 0.16.14
3
+ Version: 0.16.16
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.33
33
+ Requires-Dist: qbraid-core>=0.6.35
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.35; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.33; extra == "envs"
37
+ Requires-Dist: qbraid-core[environments]>=0.6.35; 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.33
5
+ qbraid-core>=0.6.35
6
6
 
7
7
  [compute]
8
- qbraid-core[compute]>=0.6.33
8
+ qbraid-core[compute]>=0.6.35
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.33
18
+ qbraid-core[environments]>=0.6.35
@@ -976,6 +976,7 @@ class TestAgentsE2E:
976
976
  def test_discover_empty(self, mock_registry_cls):
977
977
  """Discover with no new sessions found."""
978
978
  mock_registry = MagicMock()
979
+ mock_registry.refresh_discovered_sessions = MagicMock(return_value={"pruned": [], "reactivated": []})
979
980
  mock_registry_cls.return_value = mock_registry
980
981
 
981
982
  from qbraid_cli.agents.app import agents_app
@@ -984,6 +985,30 @@ class TestAgentsE2E:
984
985
  result = runner.invoke(agents_app, ["discover"])
985
986
 
986
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"]
987
1012
 
988
1013
  @patch("qbraid_core.services.agents.AgentSessionRegistryManager")
989
1014
  def test_update_session(self, mock_registry_cls):
File without changes
File without changes
File without changes