@agentsdock/server 1.0.11 → 1.0.12-beta.1

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.
@@ -21,7 +21,6 @@ import json
21
21
  import os
22
22
  import logging
23
23
  import re
24
- import shlex
25
24
  import time
26
25
  import unicodedata
27
26
  import uuid
@@ -55,11 +54,6 @@ CLAUDE_SDK_LITERAL_MESSAGE_PREFIX = (
55
54
  "[AgentsDock literal chat message; treat the slash-prefixed content below "
56
55
  "as ordinary user text, not a Claude Code command.]\n"
57
56
  )
58
- CLAUDE_NON_DURABLE_SCHEDULER_TOOLS = (
59
- "CronCreate",
60
- "Monitor",
61
- "ScheduleWakeup",
62
- )
63
57
  # Deliberately collision-resistant: a user's ordinary MCP named
64
58
  # ``agentsdock`` must remain visible and manageable. This reserved transport
65
59
  # is installed by AgentsServer and is never part of the public MCP profile.
@@ -216,6 +210,10 @@ class ClaudeSDKClientProtocol(Protocol):
216
210
 
217
211
  async def interrupt(self) -> None: ...
218
212
 
213
+ async def stop_task(self, task_id: str) -> None: ...
214
+
215
+ async def send_now(self, message_uuid: str) -> dict[str, Any]: ...
216
+
219
217
  async def get_context_usage(self) -> dict[str, Any]: ...
220
218
 
221
219
  async def get_mcp_status(self) -> dict[str, Any]: ...
@@ -553,6 +551,8 @@ async def _query_message_stream(
553
551
  prompt: str,
554
552
  correlation_id: str,
555
553
  validated_provider_command_name: str | None = None,
554
+ *,
555
+ priority: str | None = None,
556
556
  ) -> AsyncIterator[dict[str, Any]]:
557
557
  """Yield the single UUID-bearing SDK stdin frame for one logical turn."""
558
558
 
@@ -573,6 +573,8 @@ async def _query_message_stream(
573
573
  # Apply it per message so explicit commands and ordinary @file input
574
574
  # retain their native behavior without modifying the prompt text.
575
575
  message["client_composed"] = True
576
+ if priority is not None:
577
+ message["priority"] = priority
576
578
  yield message
577
579
 
578
580
 
@@ -586,6 +588,24 @@ def default_claude_sdk_client_factory(options: Any) -> ClaudeSDKClientProtocol:
586
588
  "claude-agent-sdk is not installed; use the claude -p fallback"
587
589
  ) from exc
588
590
  class GoalAwareClaudeSDKClient(ClaudeSDKClient):
591
+ async def send_now(self, message_uuid: str) -> dict[str, Any]:
592
+ # SDK 0.2.130 has no public wrapper for this newer native control.
593
+ # The supervisor must first see interrupt_send_now_v1 on init:
594
+ # older CLIs interpret these extra fields as an ordinary Stop.
595
+ if self._query is None:
596
+ raise ClaudeSDKSupervisorClosed("Claude SDK client is not connected")
597
+ return await self._query._send_control_request({
598
+ "subtype": "interrupt", "send_now": True,
599
+ "message_uuid": message_uuid,
600
+ })
601
+
602
+ async def cancel_queued_input(self, message_uuid: str) -> dict[str, Any]:
603
+ if self._query is None:
604
+ raise ClaudeSDKSupervisorClosed("Claude SDK client is not connected")
605
+ return await self._query._send_control_request({
606
+ "subtype": "cancel_async_message", "message_uuid": message_uuid,
607
+ })
608
+
589
609
  async def connect(self, prompt=None) -> None:
590
610
  from claude_model_catalog import native_catalog_key, remember_native_models
591
611
 
@@ -625,11 +645,11 @@ def default_claude_sdk_client_factory(options: Any) -> ClaudeSDKClientProtocol:
625
645
  def _parse_claude_sdk_message(data: dict[str, Any]) -> Any:
626
646
  from claude_agent_sdk._internal.message_parser import parse_message
627
647
 
628
- if data.get("type") == "active_goal":
648
+ if data.get("type") in {"active_goal", "command_lifecycle"}:
629
649
  return data
630
650
  message = parse_message(data)
631
651
  if message is not None:
632
- for field in ("local_command", "local_command_run"):
652
+ for field in ("local_command", "local_command_run", "user_message_uuid"):
633
653
  if field in data:
634
654
  setattr(message, field, data[field])
635
655
  return message
@@ -687,276 +707,6 @@ class ClaudeSDKHookMatcher:
687
707
  timeout: float | None = None
688
708
 
689
709
 
690
- _SHELL_CONTROL_TOKENS = {";", "&&", "||", "|", "&", "(", ")", "\n"}
691
- _SHELL_COMMAND_WRAPPERS = {"builtin", "command", "env"}
692
- _SHELL_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
693
- _UNTRACKED_BACKGROUND_REASON = (
694
- "AgentsDock cannot reliably keep background Bash attached or wake this chat when "
695
- "it finishes. Retry without run_in_background, nohup, disown, setsid, or shell "
696
- "'&'. Keep work needed for the current reply in the foreground. For asynchronous "
697
- "completion that must notify this chat, delegate a tracked Agent/workflow. If the "
698
- "user explicitly requested a durable service, use an observable service manager."
699
- )
700
- _NON_DURABLE_SCHEDULER_REASON = (
701
- "Claude's {tool_name} is not an AgentsDock durable job and cannot be relied on "
702
- "to survive this turn or deliver a later chat update. If the user explicitly "
703
- "requested scheduling, use only the run-bound AgentsDock provider tool's Jobs "
704
- "helper. Otherwise keep required "
705
- "work in the foreground or use a tracked Agent/workflow."
706
- )
707
-
708
-
709
- def _heredoc_delimiters(line: str) -> list[tuple[str, bool]]:
710
- """Find conventional shell heredocs without interpreting quoted text."""
711
-
712
- delimiters: list[tuple[str, bool]] = []
713
- quote: str | None = None
714
- escaped = False
715
- index = 0
716
- while index < len(line):
717
- char = line[index]
718
- if escaped:
719
- escaped = False
720
- index += 1
721
- continue
722
- if char == "\\" and quote != "'":
723
- escaped = True
724
- index += 1
725
- continue
726
- if quote:
727
- if char == quote:
728
- quote = None
729
- index += 1
730
- continue
731
- if char in {"'", '"'}:
732
- quote = char
733
- index += 1
734
- continue
735
- if char == "#" and (
736
- index == 0
737
- or line[index - 1].isspace()
738
- or line[index - 1] in ";|&()"
739
- ):
740
- break
741
- if not line.startswith("<<", index) or line.startswith("<<<", index):
742
- index += 1
743
- continue
744
- cursor = index + 2
745
- strip_tabs = cursor < len(line) and line[cursor] == "-"
746
- if strip_tabs:
747
- cursor += 1
748
- while cursor < len(line) and line[cursor] in " \t":
749
- cursor += 1
750
- delimiter_quote = line[cursor] if cursor < len(line) and line[cursor] in {"'", '"'} else None
751
- if delimiter_quote:
752
- cursor += 1
753
- end = line.find(delimiter_quote, cursor)
754
- if end < 0:
755
- break
756
- delimiter = line[cursor:end]
757
- cursor = end + 1
758
- else:
759
- start = cursor
760
- while cursor < len(line) and (
761
- line[cursor].isalnum() or line[cursor] in {"_", "-"}
762
- ):
763
- cursor += 1
764
- delimiter = line[start:cursor]
765
- if delimiter and (delimiter[0].isalpha() or delimiter[0] == "_"):
766
- delimiters.append((delimiter, strip_tabs))
767
- index = max(cursor, index + 2)
768
- return delimiters
769
-
770
-
771
- def _without_heredoc_bodies(command: str) -> str:
772
- """Blank data bodies so source-code ampersands are not treated as shell jobs."""
773
-
774
- pending: list[tuple[str, bool]] = []
775
- output: list[str] = []
776
- for line in command.splitlines(keepends=True):
777
- ending = "\n" if line.endswith(("\n", "\r")) else ""
778
- content = line.rstrip("\r\n")
779
- if pending:
780
- delimiter, strip_tabs = pending[0]
781
- candidate = content.lstrip("\t") if strip_tabs else content
782
- if candidate == delimiter:
783
- pending.pop(0)
784
- output.append(ending)
785
- continue
786
- pending.extend(_heredoc_delimiters(content))
787
- output.append(line)
788
- return "".join(output)
789
-
790
-
791
- def _has_unquoted_background_operator(command: str) -> bool:
792
- """Return true for a shell control ``&``, excluding data and redirections."""
793
-
794
- command = _without_heredoc_bodies(command)
795
- quote: str | None = None
796
- escaped = False
797
- index = 0
798
- while index < len(command):
799
- char = command[index]
800
- if escaped:
801
- escaped = False
802
- index += 1
803
- continue
804
- if char == "\\" and quote != "'":
805
- escaped = True
806
- index += 1
807
- continue
808
- if quote:
809
- if char == quote:
810
- quote = None
811
- index += 1
812
- continue
813
- if char in {"'", '"'}:
814
- quote = char
815
- index += 1
816
- continue
817
- if char == "#" and (
818
- index == 0
819
- or command[index - 1].isspace()
820
- or command[index - 1] in ";|&()"
821
- ):
822
- newline = command.find("\n", index)
823
- if newline < 0:
824
- return False
825
- index = newline + 1
826
- continue
827
- if char != "&":
828
- index += 1
829
- continue
830
- previous = command[index - 1] if index else ""
831
- following = command[index + 1] if index + 1 < len(command) else ""
832
- if previous in {"&", ">", "<"} or following in {"&", ">"}:
833
- index += 1
834
- continue
835
- return True
836
- return False
837
-
838
-
839
- def _shell_tokens(command: str) -> list[str]:
840
- try:
841
- lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>()")
842
- lexer.whitespace_split = True
843
- lexer.commenters = ""
844
- return list(lexer)
845
- except ValueError:
846
- # An incomplete command will fail in Bash itself. Do not turn a parser
847
- # disagreement into a misleading policy denial.
848
- return []
849
-
850
-
851
- def claude_untracked_background_reason(
852
- tool_name: str,
853
- tool_input: dict[str, Any] | None,
854
- ) -> str | None:
855
- """Identify common shell detachment that bypasses SDK task tracking."""
856
-
857
- if str(tool_name or "").strip().lower() != "bash":
858
- return None
859
- normalized_input = tool_input or {}
860
- if normalized_input.get("run_in_background") is True:
861
- return _UNTRACKED_BACKGROUND_REASON
862
- command = str(normalized_input.get("command") or "")
863
- if not command.strip():
864
- return None
865
- if _has_unquoted_background_operator(command):
866
- return _UNTRACKED_BACKGROUND_REASON
867
-
868
- tokens = _shell_tokens(command)
869
- expect_command = True
870
- wrapper_active = False
871
- for index, token in enumerate(tokens):
872
- if token in _SHELL_CONTROL_TOKENS:
873
- expect_command = True
874
- wrapper_active = False
875
- continue
876
- if not expect_command:
877
- continue
878
- if _SHELL_ASSIGNMENT_RE.match(token):
879
- continue
880
- executable = token.rsplit("/", 1)[-1]
881
- if executable in _SHELL_COMMAND_WRAPPERS:
882
- wrapper_active = True
883
- continue
884
- if wrapper_active and token.startswith("-"):
885
- continue
886
- if executable in {"nohup", "disown"}:
887
- return _UNTRACKED_BACKGROUND_REASON
888
- if executable in {"bash", "dash", "ksh", "sh", "zsh"}:
889
- arguments = tokens[index + 1:]
890
- for argument_index, argument in enumerate(arguments[:-1]):
891
- if (
892
- argument.startswith("-")
893
- and not argument.startswith("--")
894
- and "c" in argument[1:]
895
- and claude_untracked_background_reason(
896
- "Bash", {"command": arguments[argument_index + 1]}
897
- ) is not None
898
- ):
899
- return _UNTRACKED_BACKGROUND_REASON
900
- if executable == "setsid":
901
- # Even without --fork, a new session can escape process-group
902
- # cleanup if the provider/server disappears. Keep it out of the
903
- # untracked Bash path entirely.
904
- return _UNTRACKED_BACKGROUND_REASON
905
- expect_command = False
906
- return None
907
-
908
-
909
- async def reject_untracked_background_hook(
910
- hook_input: dict[str, Any],
911
- _tool_use_id: str | None,
912
- _context: dict[str, Any],
913
- ) -> dict[str, Any]:
914
- """Keep Claude background work attached to the SDK's task lifecycle."""
915
-
916
- reason = claude_untracked_background_reason(
917
- str(hook_input.get("tool_name") or ""),
918
- hook_input.get("tool_input")
919
- if isinstance(hook_input.get("tool_input"), dict)
920
- else {},
921
- )
922
- if reason is None:
923
- return {}
924
- return {
925
- "hookSpecificOutput": {
926
- "hookEventName": "PreToolUse",
927
- "permissionDecision": "deny",
928
- "permissionDecisionReason": reason,
929
- }
930
- }
931
-
932
-
933
- def claude_nondurable_scheduler_reason(tool_name: Any) -> str | None:
934
- """Reject only the exact Claude tools that cannot create durable Dock work."""
935
-
936
- if tool_name not in CLAUDE_NON_DURABLE_SCHEDULER_TOOLS:
937
- return None
938
- return _NON_DURABLE_SCHEDULER_REASON.format(tool_name=tool_name)
939
-
940
-
941
- async def reject_nondurable_scheduler_hook(
942
- hook_input: dict[str, Any],
943
- _tool_use_id: str | None,
944
- _context: dict[str, Any],
945
- ) -> dict[str, Any]:
946
- """Fail closed before Claude can arm a provider-local scheduler."""
947
-
948
- reason = claude_nondurable_scheduler_reason(hook_input.get("tool_name"))
949
- if reason is None:
950
- return {}
951
- return {
952
- "hookSpecificOutput": {
953
- "hookEventName": "PreToolUse",
954
- "permissionDecision": "deny",
955
- "permissionDecisionReason": reason,
956
- }
957
- }
958
-
959
-
960
710
  async def reject_subagent_provider_tool_hook(
961
711
  hook_input: dict[str, Any],
962
712
  _tool_use_id: str | None,
@@ -979,7 +729,7 @@ async def reject_subagent_provider_tool_hook(
979
729
 
980
730
 
981
731
  def claude_background_tracking_hooks() -> dict[str, list[ClaudeSDKHookMatcher]]:
982
- """Return SDK hooks for shell detachment and non-durable schedulers."""
732
+ """Keep native task receipts and exact AgentsDock tool ownership."""
983
733
 
984
734
  mail_hint = _PendingMailHintHook()
985
735
  return {
@@ -994,19 +744,6 @@ def claude_background_tracking_hooks() -> dict[str, list[ClaudeSDKHookMatcher]]:
994
744
  hooks=[reject_subagent_provider_tool_hook],
995
745
  timeout=5.0,
996
746
  ),
997
- ClaudeSDKHookMatcher(
998
- matcher="Bash",
999
- hooks=[reject_untracked_background_hook],
1000
- timeout=5.0,
1001
- ),
1002
- *[
1003
- ClaudeSDKHookMatcher(
1004
- matcher=tool_name,
1005
- hooks=[reject_nondurable_scheduler_hook],
1006
- timeout=5.0,
1007
- )
1008
- for tool_name in CLAUDE_NON_DURABLE_SCHEDULER_TOOLS
1009
- ],
1010
747
  ]
1011
748
  }
1012
749
 
@@ -1280,6 +1017,33 @@ class _Interrupt:
1280
1017
  response: asyncio.Future[bool]
1281
1018
 
1282
1019
 
1020
+ @dataclass
1021
+ class _SteerRun:
1022
+ prompt: str
1023
+ run_id: str
1024
+ ownership_token: str
1025
+ query_session_id: str | None
1026
+ on_before_send: SupervisorReadyCallback | None
1027
+ response: asyncio.Future[str]
1028
+ correlation_id: str
1029
+ cancelled: bool = False
1030
+ delivery_attempted: bool = False
1031
+ send_now_requested: bool = False
1032
+ goal_condition: str | None = None
1033
+ goal_generation: str | None = None
1034
+ goal_started: bool = False
1035
+ predecessor_result: Any | None = None
1036
+
1037
+
1038
+ @dataclass(frozen=True)
1039
+ class ClaudeSDKGoalReceipt:
1040
+ """Internal receipt for one provider-owned queued /goal command."""
1041
+
1042
+ command_id: str
1043
+ error: str | None = None
1044
+ cancelled_by_user: bool = False
1045
+
1046
+
1283
1047
  @dataclass
1284
1048
  class _ClearGoal:
1285
1049
  run_id: str
@@ -1378,6 +1142,7 @@ class ClaudeSDKSupervisor:
1378
1142
  raise ValueError("chat_id is required")
1379
1143
  self.chat_id = clean_chat_id
1380
1144
  self.options = options
1145
+ self._reconnect_options = options
1381
1146
  self.configuration_key = str(configuration_key)
1382
1147
  self.ownership_token = f"claudeowner_{uuid.uuid4().hex}"
1383
1148
  bind_permission_owner(self.options, self.ownership_token)
@@ -1409,8 +1174,19 @@ class ClaudeSDKSupervisor:
1409
1174
  self._receiver_task: asyncio.Task[None] | None = None
1410
1175
  self._ack_timeout_task: asyncio.Task[None] | None = None
1411
1176
  self._active_run: ClaudeSDKRunHandle | None = None
1177
+ self._pending_steer: _SteerRun | None = None
1178
+ self._superseded_query_ids: set[str] = set()
1179
+ self._current_query_id: str | None = None
1180
+ self._supports_send_now = False
1181
+ self._supports_command_lifecycle = False
1412
1182
  self._pending_goal_clear: _ClearGoal | None = None
1183
+ self._pending_goal: _SteerRun | None = None
1184
+ self._goal_stop_run_id: str | None = None
1185
+ self._goal_stop_tasks: set[str] = set()
1186
+ self._goal_stop_result: Any | None = None
1413
1187
  self._inflight_tasks: set[str] = set()
1188
+ self._live_background_tasks: set[str] = set()
1189
+ self._background_work_revision = 0
1414
1190
  self._background_reconciliation_hook: _BackgroundReconciliationHook | None = None
1415
1191
  self._pending_mail_hint_hook: _PendingMailHintHook | None = None
1416
1192
  self._generation = 0
@@ -1551,6 +1327,56 @@ class ClaudeSDKSupervisor:
1551
1327
  await self._commands.put(_Interrupt(run_id=run_id, response=response))
1552
1328
  return await asyncio.shield(response)
1553
1329
 
1330
+ async def steer_run(
1331
+ self,
1332
+ prompt: str,
1333
+ *,
1334
+ run_id: str,
1335
+ ownership_token: str,
1336
+ query_session_id: str | None = None,
1337
+ on_before_send: SupervisorReadyCallback | None = None,
1338
+ _goal_condition: str | None = None,
1339
+ _command_id: str | None = None,
1340
+ _goal_generation: str | None = None,
1341
+ ) -> str:
1342
+ """Send native follow-up input without stopping the owning run/tasks.
1343
+
1344
+ Return after exact replay, or native queue admission for /goal. Cancellation
1345
+ after a write attempt is delivery-uncertain and never retries or stops
1346
+ the live connection. Its ACK still settles the actor's pending lane.
1347
+ """
1348
+
1349
+ loop = self._ensure_actor()
1350
+ response: asyncio.Future[str] = loop.create_future()
1351
+ command = _SteerRun(
1352
+ str(prompt), str(run_id), str(ownership_token), query_session_id,
1353
+ on_before_send, response, _command_id or str(uuid.uuid4()),
1354
+ goal_condition=_goal_condition,
1355
+ goal_generation=_goal_generation,
1356
+ )
1357
+ # An HTTP waiter may leave after delivery. Still consume any eventual
1358
+ # failure while preserving it for callers that remain attached.
1359
+ response.add_done_callback(
1360
+ lambda future: None if future.cancelled() else future.exception()
1361
+ )
1362
+ assert self._commands is not None
1363
+ await self._commands.put(command)
1364
+ try:
1365
+ return await asyncio.shield(response)
1366
+ except asyncio.CancelledError as exc:
1367
+ if not command.delivery_attempted:
1368
+ command.cancelled = True
1369
+ response.cancel()
1370
+ # Preserve asyncio cancellation while letting the queue owner
1371
+ # distinguish proven non-delivery from an uncertain write.
1372
+ exc.delivery_uncertain = False
1373
+ exc.safe_to_requeue = True
1374
+ raise
1375
+ raise ClaudeSDKQueryError(
1376
+ f"Claude {'goal request' if _goal_condition is not None else 'Force Send'} was cancelled after delivery was attempted; "
1377
+ "the message must not be replayed"
1378
+ ) from exc
1379
+
1554
1380
  async def clear_goal(
1555
1381
  self, *, run_id: str, expected_generation: str | None = None,
1556
1382
  ) -> tuple[dict[str, Any], str]:
@@ -1581,7 +1407,11 @@ class ClaudeSDKSupervisor:
1581
1407
  response: asyncio.Future[dict[str, Any] | None] = loop.create_future()
1582
1408
  assert self._commands is not None
1583
1409
  await self._commands.put(_GetContextUsage(response=response))
1584
- return await asyncio.shield(response)
1410
+ try:
1411
+ return await asyncio.shield(response)
1412
+ except asyncio.CancelledError:
1413
+ response.cancel()
1414
+ raise
1585
1415
 
1586
1416
  async def get_mcp_status(self) -> tuple[dict[str, Any], str]:
1587
1417
  """Connect lazily and query MCP state on this chat-owned SDK actor."""
@@ -1828,6 +1658,11 @@ class ClaudeSDKSupervisor:
1828
1658
  async def _new_client(self) -> ClaudeSDKClientProtocol:
1829
1659
  client: ClaudeSDKClientProtocol | None = None
1830
1660
  try:
1661
+ # A fork's saved native ID changes after its first turn. Keep the
1662
+ # latest supplied binding for reconnects that happen inside the
1663
+ # actor, without replacing callbacks on the still-live client.
1664
+ self.options = self._reconnect_options
1665
+ bind_permission_owner(self.options, self.ownership_token)
1831
1666
  client_options, background_hook = _connection_background_hook(self.options)
1832
1667
  self._background_reconciliation_hook = background_hook
1833
1668
  client_options, self._pending_mail_hint_hook = _connection_mail_hint_hook(client_options)
@@ -1886,6 +1721,27 @@ class ClaudeSDKSupervisor:
1886
1721
  client = await self._new_client()
1887
1722
  return client
1888
1723
 
1724
+ def _query_provider_session_id(self, supplied: str | None) -> str | None:
1725
+ if supplied is not None and supplied != "default":
1726
+ return supplied
1727
+ options = self._reconnect_options
1728
+ value = options.get if isinstance(options, dict) else lambda key: getattr(options, key, None)
1729
+ # A first fork submission runs in a newly assigned child UUID, not
1730
+ # its resume source. The exact process/query/prompt still owns its hook.
1731
+ return None if value("fork_session") else value("resume")
1732
+
1733
+ def _observe_live_background_work(self, message: Any) -> None:
1734
+ subtype, task_id, task_type, status = _task_lifecycle_fields(message)
1735
+ if not task_id:
1736
+ return
1737
+ before = task_id in self._live_background_tasks
1738
+ if subtype == "task_started" and task_type in {*_DEFERRING_TASK_TYPES, "local_bash"}:
1739
+ self._live_background_tasks.add(task_id)
1740
+ elif subtype == "task_notification" or (subtype == "task_updated" and status in _TERMINAL_TASK_STATUSES):
1741
+ self._live_background_tasks.discard(task_id)
1742
+ if before != (task_id in self._live_background_tasks):
1743
+ self._background_work_revision += 1
1744
+
1889
1745
  async def _receive_loop(
1890
1746
  self,
1891
1747
  client: ClaudeSDKClientProtocol,
@@ -1900,6 +1756,11 @@ class ClaudeSDKSupervisor:
1900
1756
  # activity, including packets arriving after side cancellation.
1901
1757
  if is_native_side_question_progress(message):
1902
1758
  continue
1759
+ if self._client is client and self._generation == generation:
1760
+ # Native Bash may outlive the logical Result. Observe its
1761
+ # lifecycle on this exact connection, even with no run or
1762
+ # while an auxiliary actor control is awaiting a reply.
1763
+ self._observe_live_background_work(message)
1903
1764
  commands = self._commands
1904
1765
  if commands is None:
1905
1766
  return
@@ -1918,6 +1779,10 @@ class ClaudeSDKSupervisor:
1918
1779
  )
1919
1780
 
1920
1781
  async def _disconnect_current_client(self) -> None:
1782
+ self._supports_send_now = False
1783
+ self._supports_command_lifecycle = False
1784
+ self._fail_pending_steer("Claude disconnected before confirming Force Send")
1785
+ self._fail_pending_goal("Claude disconnected before applying the queued goal")
1921
1786
  pending = self._pending_goal_clear
1922
1787
  self._pending_goal_clear = None
1923
1788
  if pending is not None and not pending.response.done():
@@ -1938,17 +1803,26 @@ class ClaudeSDKSupervisor:
1938
1803
  self._client = None
1939
1804
  self._receiver_task = None
1940
1805
  self._connected = False
1806
+ self._live_background_tasks.clear()
1807
+ self._background_work_revision += 1
1941
1808
  if client is not None:
1942
1809
  await self._disconnect_client(client)
1943
1810
  if receiver is not None and receiver is not asyncio.current_task():
1944
1811
  await self._cancel_task_bounded(receiver)
1945
1812
 
1946
1813
  def _fail_active(self, error: BaseException) -> None:
1814
+ self._fail_pending_steer("Claude ended before confirming Force Send")
1815
+ self._fail_pending_goal("Claude ended before applying the queued goal")
1947
1816
  self._cancel_ack_timeout()
1948
1817
  active = self._active_run
1949
1818
  self._active_run = None
1950
1819
  bind_provider_tool_owner(self.options, "", "")
1951
1820
  self._inflight_tasks.clear()
1821
+ self._goal_stop_run_id = None
1822
+ self._goal_stop_tasks.clear()
1823
+ self._goal_stop_result = None
1824
+ self._superseded_query_ids.clear()
1825
+ self._current_query_id = None
1952
1826
  if self._background_reconciliation_hook is not None:
1953
1827
  self._background_reconciliation_hook.retire()
1954
1828
  if self._pending_mail_hint_hook is not None:
@@ -1956,6 +1830,23 @@ class ClaudeSDKSupervisor:
1956
1830
  if active is not None:
1957
1831
  active._fail(error)
1958
1832
 
1833
+ def _fail_pending_steer(self, message: str) -> None:
1834
+ pending = self._pending_steer
1835
+ self._pending_steer = None
1836
+ if pending is not None and not pending.response.done():
1837
+ pending.response.set_exception(ClaudeSDKQueryError(message))
1838
+
1839
+ def _fail_pending_goal(self, message: str, *, cancelled_by_user: bool = False) -> None:
1840
+ pending = self._pending_goal
1841
+ self._pending_goal = None
1842
+ if pending is not None:
1843
+ if self._active_run is not None:
1844
+ self._active_run._deliver(ClaudeSDKGoalReceipt(
1845
+ pending.correlation_id, message, cancelled_by_user=cancelled_by_user,
1846
+ ))
1847
+ if not pending.response.done():
1848
+ pending.response.set_exception(ClaudeSDKQueryError(message))
1849
+
1959
1850
  def _cancel_ack_timeout(self) -> None:
1960
1851
  task = self._ack_timeout_task
1961
1852
  self._ack_timeout_task = None
@@ -1986,10 +1877,10 @@ class ClaudeSDKSupervisor:
1986
1877
 
1987
1878
  async def _deliver_query_bounded(
1988
1879
  self,
1989
- query: Awaitable[None],
1880
+ query: Awaitable[Any],
1990
1881
  *,
1991
1882
  run_id: str,
1992
- ) -> None:
1883
+ ) -> Any:
1993
1884
  """Bound stdin delivery without awaiting cancellation-hostile SDK code."""
1994
1885
 
1995
1886
  task = asyncio.create_task(
@@ -2018,7 +1909,7 @@ class ClaudeSDKSupervisor:
2018
1909
  f"Claude SDK query delivery timed out for chat {self.chat_id}; "
2019
1910
  "delivery is uncertain"
2020
1911
  )
2021
- await task
1912
+ return await task
2022
1913
 
2023
1914
  async def _handle_start(self, command: _StartRun) -> None:
2024
1915
  if self._pending_goal_clear is not None:
@@ -2093,6 +1984,8 @@ class ClaudeSDKSupervisor:
2093
1984
  )
2094
1985
  handle._background_task_reconciliation = command.background_task_reconciliation
2095
1986
  self._active_run = handle
1987
+ self._superseded_query_ids.clear()
1988
+ self._current_query_id = correlation_id
2096
1989
  self._last_used_at = time.monotonic()
2097
1990
  if command.on_supervisor_ready is not None:
2098
1991
  try:
@@ -2127,9 +2020,7 @@ class ClaudeSDKSupervisor:
2127
2020
  if mail_hook is not None:
2128
2021
  mail_hook.retire()
2129
2022
  if callable(command.pending_mail_hint):
2130
- provider_id = command.query_session_id
2131
- if provider_id is None or provider_id == "default":
2132
- provider_id = self.options.get("resume") if isinstance(self.options, dict) else getattr(self.options, "resume", None)
2023
+ provider_id = self._query_provider_session_id(command.query_session_id)
2133
2024
  generation = self._generation
2134
2025
  mail_hook.bind(
2135
2026
  handle, command.pending_mail_hint, _receipt_field(provider_id),
@@ -2137,9 +2028,7 @@ class ClaudeSDKSupervisor:
2137
2028
  and self._generation == generation and self._pending_mail_hint_hook is mail_hook,
2138
2029
  )
2139
2030
  if background_hook is not None and command.background_task_reconciliation is not None:
2140
- provider_id = command.query_session_id
2141
- if provider_id is None or provider_id == "default":
2142
- provider_id = self.options.get("resume") if isinstance(self.options, dict) else getattr(self.options, "resume", None)
2031
+ provider_id = self._query_provider_session_id(command.query_session_id)
2143
2032
  background_hook.bind(
2144
2033
  handle,
2145
2034
  command.prompt,
@@ -2209,6 +2098,112 @@ class ClaudeSDKSupervisor:
2209
2098
  if not command.response.done():
2210
2099
  command.response.set_result(handle)
2211
2100
 
2101
+ async def _handle_steer(self, command: _SteerRun) -> None:
2102
+ if command.cancelled:
2103
+ return
2104
+ active = self._active_run
2105
+ client = self._client
2106
+ operation = "setting the goal" if command.goal_condition is not None else "Force Send"
2107
+ if (active is None or active.done or active.run_id != command.run_id
2108
+ or self.ownership_token != command.ownership_token
2109
+ or client is None or not self._connected):
2110
+ command.response.set_exception(ClaudeSDKGenerationChanged(
2111
+ f"The Claude run changed before {operation}"
2112
+ ))
2113
+ return
2114
+ if ((not active.acknowledged and command.goal_condition is None)
2115
+ or (self._pending_goal is not None if command.goal_condition is not None else self._pending_steer is not None)
2116
+ or self._pending_goal_clear is not None or self._goal_stop_run_id is not None):
2117
+ command.response.set_exception(ClaudeSDKRunActive(
2118
+ "Claude is still confirming its previous input"
2119
+ ))
2120
+ return
2121
+ send_now = getattr(client, "send_now", None)
2122
+ if command.goal_condition is not None and not self._supports_command_lifecycle:
2123
+ command.response.set_exception(ClaudeSDKConfigurationConflict(
2124
+ "Update Claude Code to set a goal while it is working"
2125
+ ))
2126
+ return
2127
+ if command.goal_generation is not None and command.goal_generation != self.control_generation:
2128
+ command.response.set_exception(ClaudeSDKGenerationChanged(
2129
+ "The Claude command inventory changed before setting the goal"
2130
+ ))
2131
+ return
2132
+ if command.goal_condition is None and (not self._supports_send_now or not callable(send_now)):
2133
+ command.response.set_exception(ClaudeSDKConfigurationConflict(
2134
+ "This Claude CLI cannot Force Send while preserving background work; "
2135
+ "update Claude Code or leave the message queued"
2136
+ ))
2137
+ return
2138
+ try:
2139
+ if command.on_before_send is not None:
2140
+ await command.on_before_send(self.ownership_token)
2141
+ if command.cancelled:
2142
+ return
2143
+ if self._closed:
2144
+ raise ClaudeSDKSupervisorClosed(f"Claude closed before {operation}")
2145
+ except Exception as exc:
2146
+ if not command.response.done():
2147
+ command.response.set_exception(exc)
2148
+ return
2149
+
2150
+ # Keep the same run and MCP owner. Rebinding would let a delayed call
2151
+ # from the preceding model response borrow a successor's authority.
2152
+ # Install the receipt lane before writing: the receiver can enqueue
2153
+ # an interrupted predecessor Result or a child completion immediately.
2154
+ if command.goal_condition is not None:
2155
+ self._pending_goal = command
2156
+ else:
2157
+ self._pending_steer = command
2158
+ command.delivery_attempted = True
2159
+ try:
2160
+ kwargs = ({"session_id": command.query_session_id}
2161
+ if command.query_session_id is not None else {})
2162
+ await self._deliver_query_bounded(
2163
+ client.query(_query_message_stream(
2164
+ command.prompt, command.correlation_id,
2165
+ "goal" if command.goal_condition is not None else None,
2166
+ priority="now" if command.goal_condition is not None else "next",
2167
+ ), **kwargs),
2168
+ run_id=command.run_id,
2169
+ )
2170
+ except Exception as exc:
2171
+ error = ClaudeSDKQueryError(
2172
+ f"Claude {'goal request' if command.goal_condition is not None else 'Force Send'} delivery is uncertain: {exc}"
2173
+ )
2174
+ if not command.response.done():
2175
+ command.response.set_exception(error)
2176
+ # The transport may have accepted the frame before reporting a
2177
+ # write failure. Keep its receipt lane and the existing tasks;
2178
+ # only an actual receiver failure or explicit Stop retires them.
2179
+ return
2180
+ self._last_used_at = time.monotonic()
2181
+
2182
+ async def _send_pending_input_now(self, command: _SteerRun) -> None:
2183
+ if command.send_now_requested:
2184
+ return
2185
+ command.send_now_requested = True
2186
+ try:
2187
+ # Wait for the exact queued receipt: a control sent while native
2188
+ # admission is pending intentionally does nothing. Once queued,
2189
+ # Send now releases the foreground tool wait while sparing native
2190
+ # background tasks. Plain interrupt remains the hard Stop path.
2191
+ assert self._client is not None
2192
+ receipt = await self._deliver_query_bounded(
2193
+ self._client.send_now(command.correlation_id), run_id=command.run_id,
2194
+ )
2195
+ if not isinstance(receipt, dict) or receipt.get("send_now") not in {
2196
+ "stopped", "delivering", "nothing_waiting",
2197
+ }:
2198
+ raise ClaudeSDKSupervisorError("Claude returned no Send now receipt")
2199
+ except Exception as exc:
2200
+ if not command.response.done():
2201
+ command.response.set_exception(ClaudeSDKQueryError(
2202
+ f"Claude Force Send delivery is uncertain: {exc}"
2203
+ ))
2204
+ # The user frame was already written. Preserve its ACK lane and
2205
+ # live tasks; retrying or disconnecting would compound uncertainty.
2206
+
2212
2207
  async def _handle_clear_goal(self, command: _ClearGoal) -> None:
2213
2208
  if command.cancelled or command.response.done():
2214
2209
  return
@@ -2224,6 +2219,11 @@ class ClaudeSDKSupervisor:
2224
2219
  "Claude is already clearing its goal"
2225
2220
  ))
2226
2221
  return
2222
+ if self._pending_steer is not None:
2223
+ command.response.set_exception(ClaudeSDKRunActive(
2224
+ "Claude is still applying a queued input"
2225
+ ))
2226
+ return
2227
2227
  generation = self.control_generation
2228
2228
  if command.expected_generation is not None and command.expected_generation != generation:
2229
2229
  command.response.set_exception(ClaudeSDKGenerationChanged(
@@ -2241,6 +2241,7 @@ class ClaudeSDKSupervisor:
2241
2241
  }
2242
2242
 
2243
2243
  try:
2244
+ await self._cancel_queued_goal(active.run_id)
2244
2245
  # Priority-now interrupts model streaming but can wait behind a
2245
2246
  # running tool. The SDK control channel cancels that tool first.
2246
2247
  # Keep the receipt lane installed before interrupting: Claude may
@@ -2260,6 +2261,23 @@ class ClaudeSDKSupervisor:
2260
2261
  f"Claude goal-clear delivery is uncertain: {exc}"
2261
2262
  ))
2262
2263
 
2264
+ async def _cancel_queued_goal(self, run_id: str) -> None:
2265
+ pending = self._pending_goal
2266
+ if pending is None:
2267
+ return
2268
+ # Plain interrupt deliberately keeps native queued inputs. Withdraw
2269
+ # this exact UUID first so explicit Stop/Clear cannot resurrect it.
2270
+ assert self._client is not None
2271
+ cancel = getattr(self._client, "cancel_queued_input", None)
2272
+ if not callable(cancel):
2273
+ raise ClaudeSDKSupervisorError("Claude cannot cancel its queued goal")
2274
+ receipt = await self._deliver_query_bounded(cancel(pending.correlation_id), run_id=run_id)
2275
+ if not isinstance(receipt, dict) or type(receipt.get("cancelled")) is not bool:
2276
+ raise ClaudeSDKSupervisorError("Claude did not confirm queued-goal cancellation")
2277
+ # False means already dequeued: the following explicit interrupt owns
2278
+ # that running command. No queued copy remains to start afterwards.
2279
+ self._fail_pending_goal("The queued goal was cancelled", cancelled_by_user=True)
2280
+
2263
2281
  async def _handle_interrupt(self, command: _Interrupt) -> None:
2264
2282
  active = self._active_run
2265
2283
  if (
@@ -2280,6 +2298,29 @@ class ClaudeSDKSupervisor:
2280
2298
  )
2281
2299
  return
2282
2300
  active._background_reconciliation_aborted = True
2301
+ stopping_queued_goal = self._pending_goal is not None or self._goal_stop_run_id == active.run_id
2302
+ if stopping_queued_goal:
2303
+ self._goal_stop_run_id = active.run_id
2304
+ self._goal_stop_tasks.update(
2305
+ item["task_id"] for item in active.background_task_receipts
2306
+ if item["status"] == "running"
2307
+ )
2308
+ withdrawal_error: Exception | None = None
2309
+ try:
2310
+ if stopping_queued_goal and self._pending_steer is not None:
2311
+ # Both inputs may be queued on this owner. Withdrawing only
2312
+ # the goal could let Force Send wake while Stop drains tasks.
2313
+ cancel = getattr(client, "cancel_queued_input", None)
2314
+ if callable(cancel):
2315
+ await self._deliver_query_bounded(
2316
+ cancel(self._pending_steer.correlation_id), run_id=active.run_id,
2317
+ )
2318
+ self._fail_pending_steer("Claude was stopped before confirming Force Send")
2319
+ await self._cancel_queued_goal(active.run_id)
2320
+ except Exception as exc:
2321
+ withdrawal_error = exc
2322
+ self._fail_pending_steer("Stop could not confirm withdrawal of the Force Send input")
2323
+ self._fail_pending_goal("Stop could not confirm withdrawal of the queued goal")
2283
2324
  try:
2284
2325
  await client.interrupt()
2285
2326
  except Exception as exc:
@@ -2290,6 +2331,21 @@ class ClaudeSDKSupervisor:
2290
2331
  )
2291
2332
  )
2292
2333
  return
2334
+ if stopping_queued_goal:
2335
+ # Priority-now can register a foreground Bash as a background
2336
+ # task *after* interrupt returns. Keep this exact receiver alive
2337
+ # until the native terminal receipt and stop those exact tasks as
2338
+ # they appear. Disconnect alone does not terminate such a Bash.
2339
+ for task_id in tuple(self._goal_stop_tasks):
2340
+ await self._stop_goal_task(task_id, active.run_id)
2341
+ if not command.response.done():
2342
+ if withdrawal_error is not None:
2343
+ command.response.set_exception(ClaudeSDKQueryError(
2344
+ f"Claude Stop was sent but queued-input withdrawal is uncertain: {withdrawal_error}"
2345
+ ))
2346
+ else:
2347
+ command.response.set_result(True)
2348
+ return
2293
2349
  if not active.acknowledged:
2294
2350
  error = ClaudeSDKQueryError(
2295
2351
  f"Claude SDK query {active.run_id} for chat {self.chat_id} was "
@@ -2301,6 +2357,48 @@ class ClaudeSDKSupervisor:
2301
2357
  if not command.response.done():
2302
2358
  command.response.set_result(True)
2303
2359
 
2360
+ async def _stop_goal_task(self, task_id: str, run_id: str) -> None:
2361
+ if self._goal_stop_run_id != run_id or self._client is None:
2362
+ return
2363
+ try:
2364
+ await self._deliver_query_bounded(self._client.stop_task(task_id), run_id=run_id)
2365
+ except Exception as exc:
2366
+ # Do not invent a stopped receipt. Retain ownership and let the
2367
+ # existing Stop confirmation deadline/retry report the failure.
2368
+ logger.warning("Claude task Stop was not confirmed chat=%s task=%s: %s", self.chat_id, task_id, exc)
2369
+
2370
+ async def _receive_goal_stop(self, message: Any, active: ClaudeSDKRunHandle) -> None:
2371
+ if (self._is_result_message(message)
2372
+ and str(_message_field(message, "user_message_uuid") or "") in self._superseded_query_ids):
2373
+ return
2374
+ subtype, task_id, _task_type, status = _task_lifecycle_fields(message)
2375
+ active._observe_background_task(message)
2376
+ if subtype == "task_started" and task_id:
2377
+ self._goal_stop_tasks.add(task_id)
2378
+ await self._stop_goal_task(task_id, active.run_id)
2379
+ elif task_id and (subtype == "task_notification" or status in _TERMINAL_TASK_STATUSES):
2380
+ self._goal_stop_tasks.discard(task_id)
2381
+ if self._is_result_message(message):
2382
+ self._goal_stop_result = message
2383
+ elif _message_type(message) != "command_lifecycle":
2384
+ active._deliver(message)
2385
+ if self._goal_stop_result is None or self._goal_stop_tasks:
2386
+ return
2387
+ terminal = self._goal_stop_result
2388
+ self._cancel_ack_timeout()
2389
+ # Retire before releasing the handle: no queued input or stopping
2390
+ # task observer can cross into a successor on this connection.
2391
+ await self._disconnect_current_client()
2392
+ self._closed = True
2393
+ self._active_run = None
2394
+ self._goal_stop_run_id = None
2395
+ self._goal_stop_result = None
2396
+ self._inflight_tasks.clear()
2397
+ self._superseded_query_ids.clear()
2398
+ self._current_query_id = None
2399
+ active._deliver(terminal)
2400
+ active._finish(terminal)
2401
+
2304
2402
  async def _handle_get_context_usage(
2305
2403
  self,
2306
2404
  command: _GetContextUsage,
@@ -2311,8 +2409,21 @@ class ClaudeSDKSupervisor:
2311
2409
  if not command.response.done():
2312
2410
  command.response.set_result(None)
2313
2411
  return
2412
+ if command.response.cancelled():
2413
+ return
2414
+ operation = asyncio.create_task(getter())
2314
2415
  try:
2315
- value = await getter()
2416
+ done, _ = await asyncio.wait(
2417
+ {operation, command.response}, timeout=self._control_timeout_seconds,
2418
+ return_when=asyncio.FIRST_COMPLETED,
2419
+ )
2420
+ if operation not in done:
2421
+ if not command.response.done():
2422
+ command.response.set_exception(ClaudeSDKControlTimeout(
2423
+ f"Claude SDK context usage timed out for {self.chat_id}"
2424
+ ))
2425
+ return
2426
+ value = operation.result()
2316
2427
  except Exception as exc:
2317
2428
  if not command.response.done():
2318
2429
  command.response.set_exception(
@@ -2321,6 +2432,9 @@ class ClaudeSDKSupervisor:
2321
2432
  )
2322
2433
  )
2323
2434
  return
2435
+ finally:
2436
+ if not operation.done():
2437
+ await self._cancel_task_bounded(operation)
2324
2438
  self._last_used_at = time.monotonic()
2325
2439
  if not command.response.done():
2326
2440
  command.response.set_result(dict(value) if isinstance(value, dict) else None)
@@ -2714,6 +2828,18 @@ class ClaudeSDKSupervisor:
2714
2828
  async def _handle_received(self, command: _ReceivedMessage) -> None:
2715
2829
  if command.generation != self._generation:
2716
2830
  return
2831
+ data = _message_field(command.message, "data", {})
2832
+ if _message_field(command.message, "subtype") == "init":
2833
+ capabilities = _message_field(command.message, "capabilities")
2834
+ if capabilities is None and isinstance(data, dict):
2835
+ capabilities = data.get("capabilities")
2836
+ self._supports_send_now = isinstance(capabilities, list) and all(
2837
+ capability in capabilities
2838
+ for capability in ("interrupt_send_now_v1", "msg_lifecycle_v1")
2839
+ )
2840
+ self._supports_command_lifecycle = (
2841
+ isinstance(capabilities, list) and "msg_lifecycle_v1" in capabilities
2842
+ )
2717
2843
  if _message_type(command.message) in {"ratelimitevent", "rate_limit_event"} and self._usage_observer is not None:
2718
2844
  generation = self.control_generation
2719
2845
  if generation is not None:
@@ -2742,7 +2868,58 @@ class ClaudeSDKSupervisor:
2742
2868
  active = self._active_run
2743
2869
  if active is None or active.done:
2744
2870
  return
2871
+ if self._goal_stop_run_id == active.run_id:
2872
+ await self._receive_goal_stop(command.message, active)
2873
+ return
2745
2874
  self._last_used_at = time.monotonic()
2875
+ steer = self._pending_steer
2876
+ goal = self._pending_goal
2877
+ if _message_type(command.message) == "command_lifecycle":
2878
+ goal_matches = goal is not None and _message_field(command.message, "command_uuid") == goal.correlation_id
2879
+ queued = goal if goal_matches else steer
2880
+ if (queued is not None
2881
+ and _message_field(command.message, "command_uuid") == queued.correlation_id):
2882
+ state = _message_field(command.message, "state")
2883
+ if state == "queued":
2884
+ if goal_matches:
2885
+ if not queued.response.done():
2886
+ queued.response.set_result(queued.correlation_id)
2887
+ else:
2888
+ await self._send_pending_input_now(queued)
2889
+ elif state == "started":
2890
+ queued.goal_started = True
2891
+ elif state in {"cancelled", "discarded", "refused"}:
2892
+ previous = queued.predecessor_result
2893
+ if goal_matches:
2894
+ self._fail_pending_goal(f"Claude {state} the queued goal before applying it")
2895
+ else:
2896
+ self._fail_pending_steer("Claude cancelled the Force Send input before confirming it")
2897
+ if previous is not None:
2898
+ await self._handle_received(_ReceivedMessage(command.generation, previous))
2899
+ return
2900
+ goal_matches = goal is not None and _is_matching_replay_ack(command.message, goal.correlation_id)
2901
+ accepted = goal if goal_matches else steer
2902
+ if accepted is not None and _is_matching_replay_ack(command.message, accepted.correlation_id):
2903
+ if self._current_query_id is not None:
2904
+ self._superseded_query_ids.add(self._current_query_id)
2905
+ self._current_query_id = accepted.correlation_id
2906
+ if goal_matches:
2907
+ self._pending_goal = None
2908
+ if not active.acknowledged:
2909
+ active._mark_acknowledged_without_replay()
2910
+ self._cancel_ack_timeout()
2911
+ active._deliver(ClaudeSDKGoalReceipt(accepted.correlation_id))
2912
+ else:
2913
+ self._pending_steer = None
2914
+ if not accepted.response.done():
2915
+ accepted.response.set_result(accepted.correlation_id)
2916
+ return
2917
+ message_uuid = str(_message_field(command.message, "uuid") or "")
2918
+ if (active.acknowledged and (message_uuid == self._current_query_id
2919
+ or message_uuid in self._superseded_query_ids)
2920
+ and _is_matching_replay_ack(command.message, message_uuid)):
2921
+ # Duplicate receipts are protocol metadata, never user bubbles.
2922
+ return
2746
2923
  if not active.acknowledged:
2747
2924
  if (pending is not None and self._is_result_message(command.message)
2748
2925
  and _result_forces_run_end(command.message)):
@@ -2760,18 +2937,36 @@ class ClaudeSDKSupervisor:
2760
2937
  # before this query. Nothing owns the new run until its exact UUID
2761
2938
  # is replayed by the CLI.
2762
2939
  return
2763
- elif _is_matching_replay_ack(
2764
- command.message,
2765
- active.correlation_id,
2766
- ):
2767
- # A duplicate acknowledgment is protocol metadata, never a second
2768
- # user bubble.
2769
- return
2770
2940
 
2771
2941
  active._observe_reconciliation_progress(command.message)
2772
2942
  if self._pending_mail_hint_hook is not None:
2773
2943
  self._pending_mail_hint_hook.observe(command.message)
2774
2944
  if self._is_result_message(command.message):
2945
+ result_query_id = str(_message_field(command.message, "user_message_uuid") or "")
2946
+ if goal is not None:
2947
+ if (result_query_id == goal.correlation_id
2948
+ or (_message_field(command.message, "local_command") == "goal"
2949
+ and goal.goal_started and not result_query_id)):
2950
+ # A successful /goal starts a model query and replays its
2951
+ # UUID. A text-only local result is a native refusal (even
2952
+ # when is_error=False), not the active parent's Result.
2953
+ previous = goal.predecessor_result
2954
+ self._fail_pending_goal(str(_message_field(command.message, "result")
2955
+ or _message_field(command.message, "errors")
2956
+ or "Claude did not confirm the queued goal"))
2957
+ if previous is not None:
2958
+ await self._handle_received(_ReceivedMessage(command.generation, previous))
2959
+ return
2960
+ if result_query_id not in self._superseded_query_ids:
2961
+ goal.predecessor_result = command.message
2962
+ if goal is not None or steer is not None or result_query_id in self._superseded_query_ids:
2963
+ # A Send now input preempts one model response while its
2964
+ # native Agent/workflow tasks remain alive. Before its exact
2965
+ # ACK, Results still belong to the preceding response. Newer
2966
+ # CLIs also identify that input explicitly, fencing delayed
2967
+ # predecessor Results after the ACK without discarding task
2968
+ # notification turns (which may have their own UUID).
2969
+ return
2775
2970
  # A Claude Result ends one model turn, not necessarily the logical
2776
2971
  # run. Delegated local agents/workflows can outlive that Result;
2777
2972
  # their completion wakes Claude for a follow-up model turn and a
@@ -2790,6 +2985,8 @@ class ClaudeSDKSupervisor:
2790
2985
  self._active_run = None
2791
2986
  bind_provider_tool_owner(self.options, "", "")
2792
2987
  self._inflight_tasks.clear()
2988
+ self._superseded_query_ids.clear()
2989
+ self._current_query_id = None
2793
2990
  if had_inflight_tasks and forced_run_end:
2794
2991
  # An interrupted/error response can strand provider-side task
2795
2992
  # frames after this logical run has ended. Retire only this
@@ -2883,6 +3080,8 @@ class ClaudeSDKSupervisor:
2883
3080
  )
2884
3081
  if isinstance(command, _StartRun):
2885
3082
  await self._handle_start(command)
3083
+ elif isinstance(command, _SteerRun):
3084
+ await self._handle_steer(command)
2886
3085
  elif isinstance(command, _Interrupt):
2887
3086
  await self._handle_interrupt(command)
2888
3087
  elif isinstance(command, _GetContextUsage):
@@ -2940,15 +3139,15 @@ class ClaudeSDKSupervisor:
2940
3139
 
2941
3140
 
2942
3141
  class ClaudeSDKSupervisorManager:
2943
- """Lazy, bounded registry of independent chat-scoped supervisors."""
3142
+ """Lazy registry with optional operator limits for chat-scoped supervisors."""
2944
3143
 
2945
3144
  def __init__(
2946
3145
  self,
2947
3146
  *,
2948
3147
  client_factory: ClientFactory = default_claude_sdk_client_factory,
2949
3148
  is_result_message: ResultPredicate = default_is_result_message,
2950
- max_clients: int = 12,
2951
- idle_ttl_seconds: float | None = 15 * 60,
3149
+ max_clients: int = 0,
3150
+ idle_ttl_seconds: float | None = None,
2952
3151
  connect_timeout_seconds: float = 30.0,
2953
3152
  disconnect_timeout_seconds: float = 2.0,
2954
3153
  ack_timeout_seconds: float = 60.0,
@@ -2956,15 +3155,15 @@ class ClaudeSDKSupervisorManager:
2956
3155
  control_timeout_seconds: float = 15.0,
2957
3156
  usage_observer: Callable[[str, str, Any], Awaitable[None]] | None = None,
2958
3157
  ) -> None:
2959
- if max_clients < 1:
2960
- raise ValueError("max_clients must be positive")
3158
+ if max_clients < 0:
3159
+ raise ValueError("max_clients must be non-negative")
2961
3160
  if idle_ttl_seconds is not None and idle_ttl_seconds < 0:
2962
3161
  raise ValueError("idle_ttl_seconds must be non-negative or None")
2963
3162
  self._client_factory = client_factory
2964
3163
  self._is_result_message = is_result_message
2965
3164
  self._usage_observer = usage_observer
2966
3165
  self._max_clients = int(max_clients)
2967
- self._idle_ttl_seconds = idle_ttl_seconds
3166
+ self._idle_ttl_seconds = idle_ttl_seconds or None
2968
3167
  if connect_timeout_seconds <= 0:
2969
3168
  raise ValueError("connect_timeout_seconds must be positive")
2970
3169
  self._connect_timeout_seconds = float(connect_timeout_seconds)
@@ -3079,6 +3278,10 @@ class ClaudeSDKSupervisorManager:
3079
3278
  )
3080
3279
  self._supervisors[chat_id] = supervisor
3081
3280
  else:
3281
+ # Preserve current callbacks and owned work, but never discard a
3282
+ # newer persisted resume/fork binding merely because this client
3283
+ # is connected. Its actor may need to reconnect after this lookup.
3284
+ supervisor._reconnect_options = options
3082
3285
  # A disconnected actor has no live provider state. Refresh its
3083
3286
  # lazy reconnect options so a newly persisted resume/fork binding
3084
3287
  # is honored after a stream failure without changing the stable
@@ -3215,6 +3418,62 @@ class ClaudeSDKSupervisorManager:
3215
3418
  return False
3216
3419
  return await supervisor.interrupt(run_id=run_id)
3217
3420
 
3421
+ async def steer_run(
3422
+ self,
3423
+ chat_id: str,
3424
+ prompt: str,
3425
+ *,
3426
+ run_id: str,
3427
+ ownership_token: str,
3428
+ query_session_id: str | None = None,
3429
+ on_before_send: SupervisorReadyCallback | None = None,
3430
+ ) -> str:
3431
+ """Steer only the exact existing live owner, without a new authority."""
3432
+
3433
+ self._bind_loop()
3434
+ assert self._lock is not None
3435
+ async with self._lock:
3436
+ supervisor = self._supervisors.get(str(chat_id))
3437
+ if (supervisor is None or supervisor.closed
3438
+ or supervisor.ownership_token != str(ownership_token)
3439
+ or supervisor.active_run_id != str(run_id)):
3440
+ raise ClaudeSDKGenerationChanged(
3441
+ "The Claude run changed before Force Send"
3442
+ )
3443
+ return await supervisor.steer_run(
3444
+ prompt, run_id=run_id, ownership_token=ownership_token,
3445
+ query_session_id=query_session_id, on_before_send=on_before_send,
3446
+ )
3447
+
3448
+ async def queue_goal(
3449
+ self, chat_id: str, condition: str, *, run_id: str,
3450
+ ownership_token: str, command_id: str,
3451
+ expected_generation: str | None = None,
3452
+ on_before_send: SupervisorReadyCallback | None = None,
3453
+ ) -> str:
3454
+ """Queue native /goal on its current owner, without interrupting work.
3455
+
3456
+ Return its exact queued UUID. Applied/refused receipts remain on the
3457
+ existing run stream, which stays alive across the predecessor Result.
3458
+ """
3459
+ condition = str(condition).strip()
3460
+ if (not condition or len(condition) > 4000
3461
+ or condition.lower() in {"clear", "stop", "off", "reset", "none", "cancel"}):
3462
+ raise ValueError("Enter a completion condition for Claude")
3463
+ self._bind_loop()
3464
+ assert self._lock is not None
3465
+ async with self._lock:
3466
+ supervisor = self._supervisors.get(str(chat_id))
3467
+ if (supervisor is None or supervisor.closed
3468
+ or supervisor.ownership_token != str(ownership_token)
3469
+ or supervisor.active_run_id != str(run_id)):
3470
+ raise ClaudeSDKGenerationChanged("The Claude run changed before setting its goal")
3471
+ return await asyncio.wait_for(supervisor.steer_run(
3472
+ "/goal " + condition, run_id=run_id, ownership_token=ownership_token,
3473
+ on_before_send=on_before_send, _goal_condition=condition,
3474
+ _command_id=command_id, _goal_generation=expected_generation,
3475
+ ), timeout=supervisor._control_timeout_seconds)
3476
+
3218
3477
  async def clear_goal(
3219
3478
  self, chat_id: str, *, run_id: str,
3220
3479
  expected_generation: str | None = None,
@@ -3255,6 +3514,15 @@ class ClaudeSDKSupervisorManager:
3255
3514
  )
3256
3515
  await self._unpin_mcp_supervisor(clean_chat_id, supervisor)
3257
3516
 
3517
+ def background_work_snapshot(self) -> tuple[tuple[str, str, int, int, bool], ...]:
3518
+ """Connection-bound live work; schedules without running tasks are idle."""
3519
+ return tuple(
3520
+ (chat_id, supervisor.ownership_token, supervisor._generation,
3521
+ supervisor._background_work_revision, bool(supervisor._live_background_tasks))
3522
+ for chat_id, supervisor in self._supervisors.items()
3523
+ if supervisor.connected and not supervisor.closed
3524
+ )
3525
+
3258
3526
  async def get_context_usage(
3259
3527
  self,
3260
3528
  chat_id: str,
@@ -3712,7 +3980,7 @@ class ClaudeSDKSupervisorManager:
3712
3980
  for chat_id, supervisor in idle_candidates:
3713
3981
  if now - supervisor.last_used_at >= self._idle_ttl_seconds:
3714
3982
  selected_ids.add(chat_id)
3715
- overflow = max(0, len(self._supervisors) - self._max_clients)
3983
+ overflow = max(0, len(self._supervisors) - self._max_clients) if self._max_clients else 0
3716
3984
  if overflow:
3717
3985
  for chat_id, _supervisor in idle_candidates:
3718
3986
  if len(selected_ids) >= overflow: