@agentsdock/server 1.0.11-beta.2 → 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.
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +1608 -351
- package/server/agentsdock_notify.py +123 -0
- package/server/chat_mailbox.py +12 -7
- package/server/claude_sdk_client.py +585 -317
- package/server/codex_app_server.py +19 -10
- package/server/codex_side_question.py +85 -7
- package/server/cursor_agent_client.py +22 -2
- package/server/install.sh +20 -3
- package/server/interactive_chat_share_routes.py +1 -1
- package/server/interactive_chat_share_web.py +2 -2
- package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
- package/server/npm/agentsdock/pair.cjs +123 -0
- package/server/npm/agentsdock/qr-renderer.cjs +15 -0
- package/server/public_chat_share_routes.py +8 -6
- package/server/public_chat_shares.py +17 -7
- package/server/public_chat_transcript.py +11 -3
- package/server/session_unread.py +184 -0
- package/server/shared_chat_analytics_web.py +5 -0
- package/server/side_questions.py +46 -14
|
@@ -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")
|
|
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
|
-
"""
|
|
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
|
-
|
|
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[
|
|
1880
|
+
query: Awaitable[Any],
|
|
1990
1881
|
*,
|
|
1991
1882
|
run_id: str,
|
|
1992
|
-
) ->
|
|
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
|
-
|
|
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
|
|
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 =
|
|
2951
|
-
idle_ttl_seconds: float | None =
|
|
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 <
|
|
2960
|
-
raise ValueError("max_clients must be
|
|
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:
|