agent-manager-cli 0.1.9__tar.gz → 0.1.11__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.11}/PKG-INFO +1 -1
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/cli.py +152 -20
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/pyproject.toml +1 -1
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/tests/test_cli.py +5 -2
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/.gitignore +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/README.md +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/__main__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/scanners/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/scanners/_util.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/scanners/claude.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/scanners/codex.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/scanners/gemini.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/schemas/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/am/schemas/session.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/tests/__init__.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/tests/test_event_mapping.py +0 -0
- {agent_manager_cli-0.1.9 → agent_manager_cli-0.1.11}/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
|
|
@@ -121,6 +122,11 @@ def map_stream_event(line: str) -> list[dict]:
|
|
|
121
122
|
"output_tokens": usage.get("output_tokens", 0),
|
|
122
123
|
"cache_creation_input_tokens": usage.get("cache_creation_input_tokens", 0),
|
|
123
124
|
"cache_read_input_tokens": usage.get("cache_read_input_tokens", 0),
|
|
125
|
+
# Forward the model id so the UI can pick the
|
|
126
|
+
# right context window size (200k for regular,
|
|
127
|
+
# 1M for claude-*-[1m] Opus variants) when
|
|
128
|
+
# computing the ctx% indicator.
|
|
129
|
+
"model": message.get("model", ""),
|
|
124
130
|
},
|
|
125
131
|
}
|
|
126
132
|
)
|
|
@@ -318,6 +324,63 @@ def _log(prefix: str, msg: str) -> None:
|
|
|
318
324
|
print(f"[{prefix}] {msg}", file=sys.stderr)
|
|
319
325
|
|
|
320
326
|
|
|
327
|
+
def _read_claude_oauth_token() -> str | None:
|
|
328
|
+
"""Read Claude Code's OAuth access token from the system credential
|
|
329
|
+
store. Currently macOS-only (keychain)."""
|
|
330
|
+
if sys.platform != "darwin":
|
|
331
|
+
return None
|
|
332
|
+
try:
|
|
333
|
+
result = subprocess.run(
|
|
334
|
+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
timeout=3,
|
|
338
|
+
)
|
|
339
|
+
if result.returncode != 0:
|
|
340
|
+
return None
|
|
341
|
+
creds = json.loads(result.stdout)
|
|
342
|
+
return creds.get("claudeAiOauth", {}).get("accessToken") or None
|
|
343
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def fetch_claude_usage() -> dict | None:
|
|
348
|
+
"""Query Claude's `/api/oauth/usage` endpoint using the locally-stored
|
|
349
|
+
OAuth token. Returns the raw JSON payload (five_hour, seven_day,
|
|
350
|
+
seven_day_sonnet, extra_usage, ...) or `None` on any failure.
|
|
351
|
+
|
|
352
|
+
This is meant to be called periodically by the node agent so the
|
|
353
|
+
backend can push usage info to the UI for a quota bar."""
|
|
354
|
+
import urllib.error
|
|
355
|
+
import urllib.request
|
|
356
|
+
|
|
357
|
+
token = _read_claude_oauth_token()
|
|
358
|
+
if not token:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
req = urllib.request.Request(
|
|
362
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
363
|
+
headers={
|
|
364
|
+
"Authorization": f"Bearer {token}",
|
|
365
|
+
# Same header Claude CLI sends — without it the endpoint
|
|
366
|
+
# returns 401 with "oauth beta required" or similar.
|
|
367
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
368
|
+
"User-Agent": "agent-manager-cli",
|
|
369
|
+
},
|
|
370
|
+
method="GET",
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
with _safe_urlopen(req, timeout=5) as resp:
|
|
374
|
+
return json.loads(resp.read())
|
|
375
|
+
except urllib.error.HTTPError as e:
|
|
376
|
+
if e.code == 429:
|
|
377
|
+
# Rate limited — caller should back off.
|
|
378
|
+
return {"_rate_limited": True}
|
|
379
|
+
return None
|
|
380
|
+
except Exception:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
|
|
321
384
|
def _safe_urlopen(url, **kwargs):
|
|
322
385
|
"""urlopen wrapper that only allows http/https schemes."""
|
|
323
386
|
import urllib.request
|
|
@@ -348,11 +411,16 @@ CLAUDE_PERMISSION_MODES_ALL = (
|
|
|
348
411
|
|
|
349
412
|
|
|
350
413
|
def _build_claude_hook_settings() -> str:
|
|
351
|
-
"""Inline JSON payload for `claude --settings` that
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
414
|
+
"""Inline JSON payload for `claude --settings` that:
|
|
415
|
+
|
|
416
|
+
1. Registers our PreToolUse hook (`am permission-hook`) so every
|
|
417
|
+
tool call routes through the backend → UI dialog → back.
|
|
418
|
+
2. Disables sandbox mode (`sandbox.enabled: false`). Agents spawned
|
|
419
|
+
via AgentManager are usually run against trusted project dirs
|
|
420
|
+
and the sandbox friction (extra approval prompts for anything
|
|
421
|
+
non-obvious) is unwanted. Matches `/sandbox` "disabled" state
|
|
422
|
+
in the interactive TUI.
|
|
423
|
+
"""
|
|
356
424
|
import shlex
|
|
357
425
|
|
|
358
426
|
cmd_str = f"{shlex.quote(sys.executable)} -m am permission-hook"
|
|
@@ -370,7 +438,8 @@ def _build_claude_hook_settings() -> str:
|
|
|
370
438
|
],
|
|
371
439
|
}
|
|
372
440
|
]
|
|
373
|
-
}
|
|
441
|
+
},
|
|
442
|
+
"sandbox": {"enabled": False},
|
|
374
443
|
}
|
|
375
444
|
)
|
|
376
445
|
|
|
@@ -394,6 +463,11 @@ def _build_cli_command(
|
|
|
394
463
|
"--continue",
|
|
395
464
|
"--resume",
|
|
396
465
|
session_id,
|
|
466
|
+
"--model",
|
|
467
|
+
# "opus" resolves to the latest Opus at runtime — AgentManager's
|
|
468
|
+
# default model for agent work (most capable). UI-side
|
|
469
|
+
# ModelToggle can override this later via set_model control.
|
|
470
|
+
"opus",
|
|
397
471
|
"--output-format",
|
|
398
472
|
"stream-json",
|
|
399
473
|
"--input-format",
|
|
@@ -493,6 +567,13 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
493
567
|
# echo the input in `updatedInput`.
|
|
494
568
|
pending_tool_requests: dict[str, dict] = {}
|
|
495
569
|
|
|
570
|
+
# Flipped to True by `_read_ws` when it receives an explicit
|
|
571
|
+
# {"type": "stop"} from the backend. Used by the main loop below
|
|
572
|
+
# to pick the correct log message — otherwise an explicit Stop
|
|
573
|
+
# got reported as "WS closed while Claude is still running" which
|
|
574
|
+
# was misleading (the WS wasn't actually closed).
|
|
575
|
+
explicit_stop = False
|
|
576
|
+
|
|
496
577
|
async def _read_stdout():
|
|
497
578
|
nonlocal sent_count, session_id
|
|
498
579
|
line_num = 0
|
|
@@ -567,6 +648,7 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
567
648
|
_log("daemon", f"stdout closed. Sent {sent_count} events.")
|
|
568
649
|
|
|
569
650
|
async def _read_ws():
|
|
651
|
+
nonlocal explicit_stop
|
|
570
652
|
try:
|
|
571
653
|
async for raw_msg in ws:
|
|
572
654
|
try:
|
|
@@ -645,6 +727,7 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
645
727
|
proc.stdin.flush()
|
|
646
728
|
_log("stdin", f"set_permission_mode: {mode}")
|
|
647
729
|
elif msg_type == "stop":
|
|
730
|
+
explicit_stop = True
|
|
648
731
|
_log("daemon", "Stop received — terminating Claude CLI")
|
|
649
732
|
try:
|
|
650
733
|
proc.terminate()
|
|
@@ -669,7 +752,10 @@ async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str
|
|
|
669
752
|
return_when=asyncio.FIRST_COMPLETED,
|
|
670
753
|
)
|
|
671
754
|
|
|
672
|
-
if ws_task in done and proc.returncode is None:
|
|
755
|
+
if ws_task in done and proc.returncode is None and not explicit_stop:
|
|
756
|
+
# Unexpected WS closure (backend restart, proxy timeout, etc.)
|
|
757
|
+
# — kill Claude so our subprocess tree exits cleanly; the
|
|
758
|
+
# node agent will respawn it on the next attach command.
|
|
673
759
|
_log(
|
|
674
760
|
"daemon",
|
|
675
761
|
"WS closed while Claude is still running — terminating CLI "
|
|
@@ -864,6 +950,7 @@ async def _run_node_agent(
|
|
|
864
950
|
secure: bool,
|
|
865
951
|
access_token: str,
|
|
866
952
|
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
953
|
+
daemon_started_at: dict[str, float],
|
|
867
954
|
) -> None:
|
|
868
955
|
"""Run the node agent: connect, scan sessions, handle attach commands.
|
|
869
956
|
|
|
@@ -921,22 +1008,28 @@ async def _run_node_agent(
|
|
|
921
1008
|
await asyncio.sleep(25)
|
|
922
1009
|
# Reap any daemon subprocesses that have exited so we
|
|
923
1010
|
# 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
|
-
]
|
|
1011
|
+
dead = [aid for aid, p in daemon_procs.items() if p.returncode is not None]
|
|
928
1012
|
for aid in dead:
|
|
929
1013
|
daemon_procs.pop(aid, None)
|
|
930
1014
|
|
|
1015
|
+
# Drop started_at entries for dead daemons too.
|
|
1016
|
+
for aid in dead:
|
|
1017
|
+
daemon_started_at.pop(aid, None)
|
|
1018
|
+
|
|
931
1019
|
# Report live daemons to the backend so it can refresh
|
|
932
1020
|
# `daemon:live:{id}` heartbeats. This is the source of
|
|
933
1021
|
# truth for the UI's `is_daemon_alive` check — without
|
|
934
1022
|
# it the backend has no way to know when a daemon
|
|
935
1023
|
# subprocess is alive but its own ws to the backend is
|
|
936
1024
|
# dead (orphaned after backend restart, etc.).
|
|
937
|
-
alive_ids = [
|
|
938
|
-
|
|
939
|
-
|
|
1025
|
+
alive_ids = [aid for aid, p in daemon_procs.items() if p.returncode is None]
|
|
1026
|
+
daemons_info = [
|
|
1027
|
+
{
|
|
1028
|
+
"id": aid,
|
|
1029
|
+
"pid": daemon_procs[aid].pid,
|
|
1030
|
+
"started_at": daemon_started_at.get(aid),
|
|
1031
|
+
}
|
|
1032
|
+
for aid in alive_ids
|
|
940
1033
|
]
|
|
941
1034
|
try:
|
|
942
1035
|
await ws.send(
|
|
@@ -944,6 +1037,7 @@ async def _run_node_agent(
|
|
|
944
1037
|
{
|
|
945
1038
|
"type": "heartbeat",
|
|
946
1039
|
"daemons": alive_ids,
|
|
1040
|
+
"daemons_info": daemons_info,
|
|
947
1041
|
}
|
|
948
1042
|
)
|
|
949
1043
|
)
|
|
@@ -960,23 +1054,56 @@ async def _run_node_agent(
|
|
|
960
1054
|
continue
|
|
961
1055
|
|
|
962
1056
|
if cmd.get("type") == "attach":
|
|
963
|
-
await _handle_attach(cmd, host, secure, daemon_procs)
|
|
1057
|
+
await _handle_attach(cmd, host, secure, daemon_procs, daemon_started_at)
|
|
964
1058
|
except websockets.exceptions.ConnectionClosed as e:
|
|
965
1059
|
_log("node", f"listen: connection closed ({e.code} {e.reason or '-'})")
|
|
966
1060
|
|
|
1061
|
+
async def _report_usage():
|
|
1062
|
+
"""Periodically fetch Claude's plan-usage quota and forward it
|
|
1063
|
+
to the backend so the UI can render a live quota bar. Runs
|
|
1064
|
+
once on startup then every 3 minutes. If we hit a rate
|
|
1065
|
+
limit (429) we back off to 5 minutes to be a good citizen."""
|
|
1066
|
+
loop = asyncio.get_event_loop()
|
|
1067
|
+
normal_interval = 180
|
|
1068
|
+
interval = normal_interval
|
|
1069
|
+
while True:
|
|
1070
|
+
try:
|
|
1071
|
+
usage = await loop.run_in_executor(None, fetch_claude_usage)
|
|
1072
|
+
if usage is None:
|
|
1073
|
+
# Hard failure (no token, network down). Keep
|
|
1074
|
+
# normal interval — the next tick might succeed.
|
|
1075
|
+
interval = normal_interval
|
|
1076
|
+
elif usage.get("_rate_limited"):
|
|
1077
|
+
_log("node", "usage: rate limited, backing off to 5m")
|
|
1078
|
+
interval = 300
|
|
1079
|
+
else:
|
|
1080
|
+
await ws.send(json.dumps({"type": "usage", "data": usage}))
|
|
1081
|
+
five = usage.get("five_hour", {}).get("utilization")
|
|
1082
|
+
seven = usage.get("seven_day", {}).get("utilization")
|
|
1083
|
+
_log("node", f"usage: 5h={five}% 7d={seven}%")
|
|
1084
|
+
interval = normal_interval
|
|
1085
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
1086
|
+
_log("node", f"usage: connection closed ({e.code} {e.reason or '-'})")
|
|
1087
|
+
break
|
|
1088
|
+
except Exception as e:
|
|
1089
|
+
_log("node", f"usage error: {e}")
|
|
1090
|
+
await asyncio.sleep(interval)
|
|
1091
|
+
|
|
967
1092
|
scan_task = asyncio.create_task(_scan_and_send())
|
|
968
1093
|
hb_task = asyncio.create_task(_heartbeat())
|
|
969
1094
|
cmd_task = asyncio.create_task(_listen_commands())
|
|
1095
|
+
usage_task = asyncio.create_task(_report_usage())
|
|
970
1096
|
|
|
971
1097
|
try:
|
|
972
1098
|
await asyncio.wait(
|
|
973
|
-
[scan_task, hb_task, cmd_task],
|
|
1099
|
+
[scan_task, hb_task, cmd_task, usage_task],
|
|
974
1100
|
return_when=asyncio.FIRST_COMPLETED,
|
|
975
1101
|
)
|
|
976
1102
|
finally:
|
|
977
1103
|
scan_task.cancel()
|
|
978
1104
|
hb_task.cancel()
|
|
979
1105
|
cmd_task.cancel()
|
|
1106
|
+
usage_task.cancel()
|
|
980
1107
|
# NB: do NOT terminate daemon_procs here. Each daemon has its
|
|
981
1108
|
# own WS connection + daemon_token and can survive node agent
|
|
982
1109
|
# reconnects (e.g. when the backend reloads during dev).
|
|
@@ -992,6 +1119,7 @@ async def _handle_attach(
|
|
|
992
1119
|
host: str,
|
|
993
1120
|
secure: bool,
|
|
994
1121
|
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
1122
|
+
daemon_started_at: dict[str, float],
|
|
995
1123
|
) -> None:
|
|
996
1124
|
"""Handle an attach command from the server."""
|
|
997
1125
|
agent_id = cmd["agent_id"]
|
|
@@ -1050,6 +1178,7 @@ async def _handle_attach(
|
|
|
1050
1178
|
env=env,
|
|
1051
1179
|
)
|
|
1052
1180
|
daemon_procs[agent_id] = proc
|
|
1181
|
+
daemon_started_at[agent_id] = time.time()
|
|
1053
1182
|
_log("node", f"Spawned daemon PID {proc.pid} for agent {agent_id[:8]}...")
|
|
1054
1183
|
|
|
1055
1184
|
asyncio.create_task(_log_daemon_stderr(agent_id, proc, daemon_procs))
|
|
@@ -1107,14 +1236,17 @@ def cmd_connect(args: argparse.Namespace) -> None:
|
|
|
1107
1236
|
# daemon_procs is owned here so it persists across ws reconnects — we
|
|
1108
1237
|
# don't want to kill long-running Claude sessions just because the
|
|
1109
1238
|
# backend hiccuped or reloaded.
|
|
1110
|
-
import time as _time
|
|
1111
|
-
|
|
1112
1239
|
daemon_procs: dict[str, asyncio.subprocess.Process] = {}
|
|
1240
|
+
daemon_started_at: dict[str, float] = {}
|
|
1113
1241
|
backoff = 1
|
|
1114
1242
|
try:
|
|
1115
1243
|
while True:
|
|
1116
1244
|
try:
|
|
1117
|
-
asyncio.run(
|
|
1245
|
+
asyncio.run(
|
|
1246
|
+
_run_node_agent(
|
|
1247
|
+
host, secure, access_token, daemon_procs, daemon_started_at
|
|
1248
|
+
)
|
|
1249
|
+
)
|
|
1118
1250
|
# _run_node_agent returned normally → ws was closed by server
|
|
1119
1251
|
# or one of the tasks finished. Reconnect after a short delay.
|
|
1120
1252
|
_log("connect", f"Disconnected. Reconnecting in {backoff}s...")
|
|
@@ -1131,7 +1263,7 @@ def cmd_connect(args: argparse.Namespace) -> None:
|
|
|
1131
1263
|
except Exception as e:
|
|
1132
1264
|
_log("connect", f"Error: {e} ({type(e).__name__}), retrying in {backoff}s...")
|
|
1133
1265
|
|
|
1134
|
-
|
|
1266
|
+
time.sleep(backoff)
|
|
1135
1267
|
backoff = min(backoff * 2, 30) # cap at 30 seconds
|
|
1136
1268
|
|
|
1137
1269
|
# 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
|