agent-manager-cli 0.1.8__tar.gz → 0.1.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/PKG-INFO +1 -1
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/cli.py +199 -15
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/pyproject.toml +1 -1
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/tests/test_cli.py +5 -2
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/.gitignore +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/README.md +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/__init__.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/__main__.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/scanners/__init__.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/scanners/_util.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/scanners/claude.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/scanners/codex.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/scanners/gemini.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/schemas/__init__.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/am/schemas/session.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/tests/__init__.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/tests/test_event_mapping.py +0 -0
- {agent_manager_cli-0.1.8 → agent_manager_cli-0.1.10}/tests/test_scanners.py +0 -0
|
@@ -21,6 +21,7 @@ import json
|
|
|
21
21
|
import os
|
|
22
22
|
import subprocess
|
|
23
23
|
import sys
|
|
24
|
+
import time
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
|
|
26
27
|
import websockets
|
|
@@ -318,6 +319,63 @@ def _log(prefix: str, msg: str) -> None:
|
|
|
318
319
|
print(f"[{prefix}] {msg}", file=sys.stderr)
|
|
319
320
|
|
|
320
321
|
|
|
322
|
+
def _read_claude_oauth_token() -> str | None:
|
|
323
|
+
"""Read Claude Code's OAuth access token from the system credential
|
|
324
|
+
store. Currently macOS-only (keychain)."""
|
|
325
|
+
if sys.platform != "darwin":
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
result = subprocess.run(
|
|
329
|
+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
timeout=3,
|
|
333
|
+
)
|
|
334
|
+
if result.returncode != 0:
|
|
335
|
+
return None
|
|
336
|
+
creds = json.loads(result.stdout)
|
|
337
|
+
return creds.get("claudeAiOauth", {}).get("accessToken") or None
|
|
338
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def fetch_claude_usage() -> dict | None:
|
|
343
|
+
"""Query Claude's `/api/oauth/usage` endpoint using the locally-stored
|
|
344
|
+
OAuth token. Returns the raw JSON payload (five_hour, seven_day,
|
|
345
|
+
seven_day_sonnet, extra_usage, ...) or `None` on any failure.
|
|
346
|
+
|
|
347
|
+
This is meant to be called periodically by the node agent so the
|
|
348
|
+
backend can push usage info to the UI for a quota bar."""
|
|
349
|
+
import urllib.error
|
|
350
|
+
import urllib.request
|
|
351
|
+
|
|
352
|
+
token = _read_claude_oauth_token()
|
|
353
|
+
if not token:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
req = urllib.request.Request(
|
|
357
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
358
|
+
headers={
|
|
359
|
+
"Authorization": f"Bearer {token}",
|
|
360
|
+
# Same header Claude CLI sends — without it the endpoint
|
|
361
|
+
# returns 401 with "oauth beta required" or similar.
|
|
362
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
363
|
+
"User-Agent": "agent-manager-cli",
|
|
364
|
+
},
|
|
365
|
+
method="GET",
|
|
366
|
+
)
|
|
367
|
+
try:
|
|
368
|
+
with _safe_urlopen(req, timeout=5) as resp:
|
|
369
|
+
return json.loads(resp.read())
|
|
370
|
+
except urllib.error.HTTPError as e:
|
|
371
|
+
if e.code == 429:
|
|
372
|
+
# Rate limited — caller should back off.
|
|
373
|
+
return {"_rate_limited": True}
|
|
374
|
+
return None
|
|
375
|
+
except Exception:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
321
379
|
def _safe_urlopen(url, **kwargs):
|
|
322
380
|
"""urlopen wrapper that only allows http/https schemes."""
|
|
323
381
|
import urllib.request
|
|
@@ -348,11 +406,16 @@ CLAUDE_PERMISSION_MODES_ALL = (
|
|
|
348
406
|
|
|
349
407
|
|
|
350
408
|
def _build_claude_hook_settings() -> str:
|
|
351
|
-
"""Inline JSON payload for `claude --settings` that
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
409
|
+
"""Inline JSON payload for `claude --settings` that:
|
|
410
|
+
|
|
411
|
+
1. Registers our PreToolUse hook (`am permission-hook`) so every
|
|
412
|
+
tool call routes through the backend → UI dialog → back.
|
|
413
|
+
2. Disables sandbox mode (`sandbox.enabled: false`). Agents spawned
|
|
414
|
+
via AgentManager are usually run against trusted project dirs
|
|
415
|
+
and the sandbox friction (extra approval prompts for anything
|
|
416
|
+
non-obvious) is unwanted. Matches `/sandbox` "disabled" state
|
|
417
|
+
in the interactive TUI.
|
|
418
|
+
"""
|
|
356
419
|
import shlex
|
|
357
420
|
|
|
358
421
|
cmd_str = f"{shlex.quote(sys.executable)} -m am permission-hook"
|
|
@@ -370,7 +433,8 @@ def _build_claude_hook_settings() -> str:
|
|
|
370
433
|
],
|
|
371
434
|
}
|
|
372
435
|
]
|
|
373
|
-
}
|
|
436
|
+
},
|
|
437
|
+
"sandbox": {"enabled": False},
|
|
374
438
|
}
|
|
375
439
|
)
|
|
376
440
|
|
|
@@ -394,6 +458,11 @@ def _build_cli_command(
|
|
|
394
458
|
"--continue",
|
|
395
459
|
"--resume",
|
|
396
460
|
session_id,
|
|
461
|
+
"--model",
|
|
462
|
+
# "opus" resolves to the latest Opus at runtime — AgentManager's
|
|
463
|
+
# default model for agent work (most capable). UI-side
|
|
464
|
+
# ModelToggle can override this later via set_model control.
|
|
465
|
+
"opus",
|
|
397
466
|
"--output-format",
|
|
398
467
|
"stream-json",
|
|
399
468
|
"--input-format",
|
|
@@ -493,6 +562,13 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
493
562
|
# echo the input in `updatedInput`.
|
|
494
563
|
pending_tool_requests: dict[str, dict] = {}
|
|
495
564
|
|
|
565
|
+
# Flipped to True by `_read_ws` when it receives an explicit
|
|
566
|
+
# {"type": "stop"} from the backend. Used by the main loop below
|
|
567
|
+
# to pick the correct log message — otherwise an explicit Stop
|
|
568
|
+
# got reported as "WS closed while Claude is still running" which
|
|
569
|
+
# was misleading (the WS wasn't actually closed).
|
|
570
|
+
explicit_stop = False
|
|
571
|
+
|
|
496
572
|
async def _read_stdout():
|
|
497
573
|
nonlocal sent_count, session_id
|
|
498
574
|
line_num = 0
|
|
@@ -567,6 +643,7 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
567
643
|
_log("daemon", f"stdout closed. Sent {sent_count} events.")
|
|
568
644
|
|
|
569
645
|
async def _read_ws():
|
|
646
|
+
nonlocal explicit_stop
|
|
570
647
|
try:
|
|
571
648
|
async for raw_msg in ws:
|
|
572
649
|
try:
|
|
@@ -645,6 +722,7 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
645
722
|
proc.stdin.flush()
|
|
646
723
|
_log("stdin", f"set_permission_mode: {mode}")
|
|
647
724
|
elif msg_type == "stop":
|
|
725
|
+
explicit_stop = True
|
|
648
726
|
_log("daemon", "Stop received — terminating Claude CLI")
|
|
649
727
|
try:
|
|
650
728
|
proc.terminate()
|
|
@@ -656,8 +734,42 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
656
734
|
|
|
657
735
|
stdout_task = asyncio.create_task(_read_stdout())
|
|
658
736
|
ws_task = asyncio.create_task(_read_ws())
|
|
659
|
-
|
|
660
|
-
|
|
737
|
+
|
|
738
|
+
# Run both in parallel. Normally stdout_task finishes first
|
|
739
|
+
# (Claude exits naturally at end of task). If ws_task finishes
|
|
740
|
+
# first it means the backend connection died — in that case we
|
|
741
|
+
# have no way to recover the session, so we terminate Claude
|
|
742
|
+
# so the daemon subprocess exits cleanly. The node agent will
|
|
743
|
+
# detect the dead subprocess and spawn a fresh one on the next
|
|
744
|
+
# attach command (with --continue --resume so history is kept).
|
|
745
|
+
done, _pending = await asyncio.wait(
|
|
746
|
+
[stdout_task, ws_task],
|
|
747
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
if ws_task in done and proc.returncode is None and not explicit_stop:
|
|
751
|
+
# Unexpected WS closure (backend restart, proxy timeout, etc.)
|
|
752
|
+
# — kill Claude so our subprocess tree exits cleanly; the
|
|
753
|
+
# node agent will respawn it on the next attach command.
|
|
754
|
+
_log(
|
|
755
|
+
"daemon",
|
|
756
|
+
"WS closed while Claude is still running — terminating CLI "
|
|
757
|
+
"(node agent will respawn on next attach)",
|
|
758
|
+
)
|
|
759
|
+
try:
|
|
760
|
+
proc.terminate()
|
|
761
|
+
except ProcessLookupError:
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
# Let remaining tasks clean up (stdout_task should exit once
|
|
765
|
+
# claude's stdout closes after terminate).
|
|
766
|
+
for task in (stdout_task, ws_task):
|
|
767
|
+
if not task.done():
|
|
768
|
+
task.cancel()
|
|
769
|
+
try:
|
|
770
|
+
await task
|
|
771
|
+
except (asyncio.CancelledError, Exception):
|
|
772
|
+
pass
|
|
661
773
|
|
|
662
774
|
proc.wait()
|
|
663
775
|
_log("daemon", f"Process exited with code {proc.returncode}")
|
|
@@ -833,6 +945,7 @@ async def _run_node_agent(
|
|
|
833
945
|
secure: bool,
|
|
834
946
|
access_token: str,
|
|
835
947
|
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
948
|
+
daemon_started_at: dict[str, float],
|
|
836
949
|
) -> None:
|
|
837
950
|
"""Run the node agent: connect, scan sessions, handle attach commands.
|
|
838
951
|
|
|
@@ -888,8 +1001,41 @@ async def _run_node_agent(
|
|
|
888
1001
|
async def _heartbeat():
|
|
889
1002
|
while True:
|
|
890
1003
|
await asyncio.sleep(25)
|
|
1004
|
+
# Reap any daemon subprocesses that have exited so we
|
|
1005
|
+
# don't report them as alive.
|
|
1006
|
+
dead = [aid for aid, p in daemon_procs.items() if p.returncode is not None]
|
|
1007
|
+
for aid in dead:
|
|
1008
|
+
daemon_procs.pop(aid, None)
|
|
1009
|
+
|
|
1010
|
+
# Drop started_at entries for dead daemons too.
|
|
1011
|
+
for aid in dead:
|
|
1012
|
+
daemon_started_at.pop(aid, None)
|
|
1013
|
+
|
|
1014
|
+
# Report live daemons to the backend so it can refresh
|
|
1015
|
+
# `daemon:live:{id}` heartbeats. This is the source of
|
|
1016
|
+
# truth for the UI's `is_daemon_alive` check — without
|
|
1017
|
+
# it the backend has no way to know when a daemon
|
|
1018
|
+
# subprocess is alive but its own ws to the backend is
|
|
1019
|
+
# dead (orphaned after backend restart, etc.).
|
|
1020
|
+
alive_ids = [aid for aid, p in daemon_procs.items() if p.returncode is None]
|
|
1021
|
+
daemons_info = [
|
|
1022
|
+
{
|
|
1023
|
+
"id": aid,
|
|
1024
|
+
"pid": daemon_procs[aid].pid,
|
|
1025
|
+
"started_at": daemon_started_at.get(aid),
|
|
1026
|
+
}
|
|
1027
|
+
for aid in alive_ids
|
|
1028
|
+
]
|
|
891
1029
|
try:
|
|
892
|
-
await ws.send(
|
|
1030
|
+
await ws.send(
|
|
1031
|
+
json.dumps(
|
|
1032
|
+
{
|
|
1033
|
+
"type": "heartbeat",
|
|
1034
|
+
"daemons": alive_ids,
|
|
1035
|
+
"daemons_info": daemons_info,
|
|
1036
|
+
}
|
|
1037
|
+
)
|
|
1038
|
+
)
|
|
893
1039
|
except websockets.exceptions.ConnectionClosed as e:
|
|
894
1040
|
_log("node", f"heartbeat: connection closed ({e.code} {e.reason or '-'})")
|
|
895
1041
|
break
|
|
@@ -903,23 +1049,56 @@ async def _run_node_agent(
|
|
|
903
1049
|
continue
|
|
904
1050
|
|
|
905
1051
|
if cmd.get("type") == "attach":
|
|
906
|
-
await _handle_attach(cmd, host, secure, daemon_procs)
|
|
1052
|
+
await _handle_attach(cmd, host, secure, daemon_procs, daemon_started_at)
|
|
907
1053
|
except websockets.exceptions.ConnectionClosed as e:
|
|
908
1054
|
_log("node", f"listen: connection closed ({e.code} {e.reason or '-'})")
|
|
909
1055
|
|
|
1056
|
+
async def _report_usage():
|
|
1057
|
+
"""Periodically fetch Claude's plan-usage quota and forward it
|
|
1058
|
+
to the backend so the UI can render a live quota bar. Runs
|
|
1059
|
+
once on startup then every 3 minutes. If we hit a rate
|
|
1060
|
+
limit (429) we back off to 5 minutes to be a good citizen."""
|
|
1061
|
+
loop = asyncio.get_event_loop()
|
|
1062
|
+
normal_interval = 180
|
|
1063
|
+
interval = normal_interval
|
|
1064
|
+
while True:
|
|
1065
|
+
try:
|
|
1066
|
+
usage = await loop.run_in_executor(None, fetch_claude_usage)
|
|
1067
|
+
if usage is None:
|
|
1068
|
+
# Hard failure (no token, network down). Keep
|
|
1069
|
+
# normal interval — the next tick might succeed.
|
|
1070
|
+
interval = normal_interval
|
|
1071
|
+
elif usage.get("_rate_limited"):
|
|
1072
|
+
_log("node", "usage: rate limited, backing off to 5m")
|
|
1073
|
+
interval = 300
|
|
1074
|
+
else:
|
|
1075
|
+
await ws.send(json.dumps({"type": "usage", "data": usage}))
|
|
1076
|
+
five = usage.get("five_hour", {}).get("utilization")
|
|
1077
|
+
seven = usage.get("seven_day", {}).get("utilization")
|
|
1078
|
+
_log("node", f"usage: 5h={five}% 7d={seven}%")
|
|
1079
|
+
interval = normal_interval
|
|
1080
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
1081
|
+
_log("node", f"usage: connection closed ({e.code} {e.reason or '-'})")
|
|
1082
|
+
break
|
|
1083
|
+
except Exception as e:
|
|
1084
|
+
_log("node", f"usage error: {e}")
|
|
1085
|
+
await asyncio.sleep(interval)
|
|
1086
|
+
|
|
910
1087
|
scan_task = asyncio.create_task(_scan_and_send())
|
|
911
1088
|
hb_task = asyncio.create_task(_heartbeat())
|
|
912
1089
|
cmd_task = asyncio.create_task(_listen_commands())
|
|
1090
|
+
usage_task = asyncio.create_task(_report_usage())
|
|
913
1091
|
|
|
914
1092
|
try:
|
|
915
1093
|
await asyncio.wait(
|
|
916
|
-
[scan_task, hb_task, cmd_task],
|
|
1094
|
+
[scan_task, hb_task, cmd_task, usage_task],
|
|
917
1095
|
return_when=asyncio.FIRST_COMPLETED,
|
|
918
1096
|
)
|
|
919
1097
|
finally:
|
|
920
1098
|
scan_task.cancel()
|
|
921
1099
|
hb_task.cancel()
|
|
922
1100
|
cmd_task.cancel()
|
|
1101
|
+
usage_task.cancel()
|
|
923
1102
|
# NB: do NOT terminate daemon_procs here. Each daemon has its
|
|
924
1103
|
# own WS connection + daemon_token and can survive node agent
|
|
925
1104
|
# reconnects (e.g. when the backend reloads during dev).
|
|
@@ -935,6 +1114,7 @@ async def _handle_attach(
|
|
|
935
1114
|
host: str,
|
|
936
1115
|
secure: bool,
|
|
937
1116
|
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
1117
|
+
daemon_started_at: dict[str, float],
|
|
938
1118
|
) -> None:
|
|
939
1119
|
"""Handle an attach command from the server."""
|
|
940
1120
|
agent_id = cmd["agent_id"]
|
|
@@ -993,6 +1173,7 @@ async def _handle_attach(
|
|
|
993
1173
|
env=env,
|
|
994
1174
|
)
|
|
995
1175
|
daemon_procs[agent_id] = proc
|
|
1176
|
+
daemon_started_at[agent_id] = time.time()
|
|
996
1177
|
_log("node", f"Spawned daemon PID {proc.pid} for agent {agent_id[:8]}...")
|
|
997
1178
|
|
|
998
1179
|
asyncio.create_task(_log_daemon_stderr(agent_id, proc, daemon_procs))
|
|
@@ -1050,14 +1231,17 @@ def cmd_connect(args: argparse.Namespace) -> None:
|
|
|
1050
1231
|
# daemon_procs is owned here so it persists across ws reconnects — we
|
|
1051
1232
|
# don't want to kill long-running Claude sessions just because the
|
|
1052
1233
|
# backend hiccuped or reloaded.
|
|
1053
|
-
import time as _time
|
|
1054
|
-
|
|
1055
1234
|
daemon_procs: dict[str, asyncio.subprocess.Process] = {}
|
|
1235
|
+
daemon_started_at: dict[str, float] = {}
|
|
1056
1236
|
backoff = 1
|
|
1057
1237
|
try:
|
|
1058
1238
|
while True:
|
|
1059
1239
|
try:
|
|
1060
|
-
asyncio.run(
|
|
1240
|
+
asyncio.run(
|
|
1241
|
+
_run_node_agent(
|
|
1242
|
+
host, secure, access_token, daemon_procs, daemon_started_at
|
|
1243
|
+
)
|
|
1244
|
+
)
|
|
1061
1245
|
# _run_node_agent returned normally → ws was closed by server
|
|
1062
1246
|
# or one of the tasks finished. Reconnect after a short delay.
|
|
1063
1247
|
_log("connect", f"Disconnected. Reconnecting in {backoff}s...")
|
|
@@ -1074,7 +1258,7 @@ def cmd_connect(args: argparse.Namespace) -> None:
|
|
|
1074
1258
|
except Exception as e:
|
|
1075
1259
|
_log("connect", f"Error: {e} ({type(e).__name__}), retrying in {backoff}s...")
|
|
1076
1260
|
|
|
1077
|
-
|
|
1261
|
+
time.sleep(backoff)
|
|
1078
1262
|
backoff = min(backoff * 2, 30) # cap at 30 seconds
|
|
1079
1263
|
|
|
1080
1264
|
# After a reconnect attempt, try to refresh the token again in case
|
|
@@ -173,8 +173,8 @@ class TestHookSettings:
|
|
|
173
173
|
def test_build_claude_hook_settings(self):
|
|
174
174
|
raw = _build_claude_hook_settings()
|
|
175
175
|
settings = json.loads(raw)
|
|
176
|
-
# Registers
|
|
177
|
-
assert
|
|
176
|
+
# Registers a PreToolUse hook matching all tools + disables sandbox.
|
|
177
|
+
assert set(settings.keys()) == {"hooks", "sandbox"}
|
|
178
178
|
assert list(settings["hooks"].keys()) == ["PreToolUse"]
|
|
179
179
|
group = settings["hooks"]["PreToolUse"][0]
|
|
180
180
|
assert group["matcher"] == "*"
|
|
@@ -183,6 +183,9 @@ class TestHookSettings:
|
|
|
183
183
|
assert hooks[0]["type"] == "command"
|
|
184
184
|
# Points at `sys.executable -m am permission-hook`
|
|
185
185
|
assert "am permission-hook" in hooks[0]["command"]
|
|
186
|
+
# Sandbox is explicitly disabled so agents run with full tool
|
|
187
|
+
# access against the project dir without extra approvals.
|
|
188
|
+
assert settings["sandbox"] == {"enabled": False}
|
|
186
189
|
|
|
187
190
|
|
|
188
191
|
class TestCLIEntryPoint:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|