agent-manager-cli 0.1.9__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.9 → agent_manager_cli-0.1.10}/PKG-INFO +1 -1
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/cli.py +147 -20
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/pyproject.toml +1 -1
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/tests/test_cli.py +5 -2
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/.gitignore +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/README.md +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/__main__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/scanners/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/scanners/_util.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/scanners/claude.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/scanners/codex.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/scanners/gemini.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/schemas/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/am/schemas/session.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/tests/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.10}/tests/test_event_mapping.py +0 -0
- {agent_manager_cli-0.1.9 → 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()
|
|
@@ -669,7 +747,10 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
669
747
|
return_when=asyncio.FIRST_COMPLETED,
|
|
670
748
|
)
|
|
671
749
|
|
|
672
|
-
if ws_task in done and proc.returncode is None:
|
|
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.
|
|
673
754
|
_log(
|
|
674
755
|
"daemon",
|
|
675
756
|
"WS closed while Claude is still running — terminating CLI "
|
|
@@ -864,6 +945,7 @@ async def _run_node_agent(
|
|
|
864
945
|
secure: bool,
|
|
865
946
|
access_token: str,
|
|
866
947
|
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
948
|
+
daemon_started_at: dict[str, float],
|
|
867
949
|
) -> None:
|
|
868
950
|
"""Run the node agent: connect, scan sessions, handle attach commands.
|
|
869
951
|
|
|
@@ -921,22 +1003,28 @@ async def _run_node_agent(
|
|
|
921
1003
|
await asyncio.sleep(25)
|
|
922
1004
|
# Reap any daemon subprocesses that have exited so we
|
|
923
1005
|
# don't report them as alive.
|
|
924
|
-
dead = [
|
|
925
|
-
aid for aid, p in daemon_procs.items()
|
|
926
|
-
if p.returncode is not None
|
|
927
|
-
]
|
|
1006
|
+
dead = [aid for aid, p in daemon_procs.items() if p.returncode is not None]
|
|
928
1007
|
for aid in dead:
|
|
929
1008
|
daemon_procs.pop(aid, None)
|
|
930
1009
|
|
|
1010
|
+
# Drop started_at entries for dead daemons too.
|
|
1011
|
+
for aid in dead:
|
|
1012
|
+
daemon_started_at.pop(aid, None)
|
|
1013
|
+
|
|
931
1014
|
# Report live daemons to the backend so it can refresh
|
|
932
1015
|
# `daemon:live:{id}` heartbeats. This is the source of
|
|
933
1016
|
# truth for the UI's `is_daemon_alive` check — without
|
|
934
1017
|
# it the backend has no way to know when a daemon
|
|
935
1018
|
# subprocess is alive but its own ws to the backend is
|
|
936
1019
|
# dead (orphaned after backend restart, etc.).
|
|
937
|
-
alive_ids = [
|
|
938
|
-
|
|
939
|
-
|
|
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
|
|
940
1028
|
]
|
|
941
1029
|
try:
|
|
942
1030
|
await ws.send(
|
|
@@ -944,6 +1032,7 @@ async def _run_node_agent(
|
|
|
944
1032
|
{
|
|
945
1033
|
"type": "heartbeat",
|
|
946
1034
|
"daemons": alive_ids,
|
|
1035
|
+
"daemons_info": daemons_info,
|
|
947
1036
|
}
|
|
948
1037
|
)
|
|
949
1038
|
)
|
|
@@ -960,23 +1049,56 @@ async def _run_node_agent(
|
|
|
960
1049
|
continue
|
|
961
1050
|
|
|
962
1051
|
if cmd.get("type") == "attach":
|
|
963
|
-
await _handle_attach(cmd, host, secure, daemon_procs)
|
|
1052
|
+
await _handle_attach(cmd, host, secure, daemon_procs, daemon_started_at)
|
|
964
1053
|
except websockets.exceptions.ConnectionClosed as e:
|
|
965
1054
|
_log("node", f"listen: connection closed ({e.code} {e.reason or '-'})")
|
|
966
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
|
+
|
|
967
1087
|
scan_task = asyncio.create_task(_scan_and_send())
|
|
968
1088
|
hb_task = asyncio.create_task(_heartbeat())
|
|
969
1089
|
cmd_task = asyncio.create_task(_listen_commands())
|
|
1090
|
+
usage_task = asyncio.create_task(_report_usage())
|
|
970
1091
|
|
|
971
1092
|
try:
|
|
972
1093
|
await asyncio.wait(
|
|
973
|
-
[scan_task, hb_task, cmd_task],
|
|
1094
|
+
[scan_task, hb_task, cmd_task, usage_task],
|
|
974
1095
|
return_when=asyncio.FIRST_COMPLETED,
|
|
975
1096
|
)
|
|
976
1097
|
finally:
|
|
977
1098
|
scan_task.cancel()
|
|
978
1099
|
hb_task.cancel()
|
|
979
1100
|
cmd_task.cancel()
|
|
1101
|
+
usage_task.cancel()
|
|
980
1102
|
# NB: do NOT terminate daemon_procs here. Each daemon has its
|
|
981
1103
|
# own WS connection + daemon_token and can survive node agent
|
|
982
1104
|
# reconnects (e.g. when the backend reloads during dev).
|
|
@@ -992,6 +1114,7 @@ async def _handle_attach(
|
|
|
992
1114
|
host: str,
|
|
993
1115
|
secure: bool,
|
|
994
1116
|
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
1117
|
+
daemon_started_at: dict[str, float],
|
|
995
1118
|
) -> None:
|
|
996
1119
|
"""Handle an attach command from the server."""
|
|
997
1120
|
agent_id = cmd["agent_id"]
|
|
@@ -1050,6 +1173,7 @@ async def _handle_attach(
|
|
|
1050
1173
|
env=env,
|
|
1051
1174
|
)
|
|
1052
1175
|
daemon_procs[agent_id] = proc
|
|
1176
|
+
daemon_started_at[agent_id] = time.time()
|
|
1053
1177
|
_log("node", f"Spawned daemon PID {proc.pid} for agent {agent_id[:8]}...")
|
|
1054
1178
|
|
|
1055
1179
|
asyncio.create_task(_log_daemon_stderr(agent_id, proc, daemon_procs))
|
|
@@ -1107,14 +1231,17 @@ def cmd_connect(args: argparse.Namespace) -> None:
|
|
|
1107
1231
|
# daemon_procs is owned here so it persists across ws reconnects — we
|
|
1108
1232
|
# don't want to kill long-running Claude sessions just because the
|
|
1109
1233
|
# backend hiccuped or reloaded.
|
|
1110
|
-
import time as _time
|
|
1111
|
-
|
|
1112
1234
|
daemon_procs: dict[str, asyncio.subprocess.Process] = {}
|
|
1235
|
+
daemon_started_at: dict[str, float] = {}
|
|
1113
1236
|
backoff = 1
|
|
1114
1237
|
try:
|
|
1115
1238
|
while True:
|
|
1116
1239
|
try:
|
|
1117
|
-
asyncio.run(
|
|
1240
|
+
asyncio.run(
|
|
1241
|
+
_run_node_agent(
|
|
1242
|
+
host, secure, access_token, daemon_procs, daemon_started_at
|
|
1243
|
+
)
|
|
1244
|
+
)
|
|
1118
1245
|
# _run_node_agent returned normally → ws was closed by server
|
|
1119
1246
|
# or one of the tasks finished. Reconnect after a short delay.
|
|
1120
1247
|
_log("connect", f"Disconnected. Reconnecting in {backoff}s...")
|
|
@@ -1131,7 +1258,7 @@ def cmd_connect(args: argparse.Namespace) -> None:
|
|
|
1131
1258
|
except Exception as e:
|
|
1132
1259
|
_log("connect", f"Error: {e} ({type(e).__name__}), retrying in {backoff}s...")
|
|
1133
1260
|
|
|
1134
|
-
|
|
1261
|
+
time.sleep(backoff)
|
|
1135
1262
|
backoff = min(backoff * 2, 30) # cap at 30 seconds
|
|
1136
1263
|
|
|
1137
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
|