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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-manager-cli
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: CLI для удалённого управления AI-агентами — Node Agent, daemon, сканер сессий
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -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 registers our
352
- PreToolUse hook. The hook is `am permission-hook` — a subcommand of
353
- this very CLI that forwards each permission request to the backend
354
- and routes the user's answer back to Claude."""
355
- # Quote sys.executable properly in case of spaces in path.
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
- aid for aid, p in daemon_procs.items()
939
- if p.returncode is None
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(_run_node_agent(host, secure, access_token, daemon_procs))
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
- _time.sleep(backoff)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-manager-cli"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "CLI для удалённого управления AI-агентами — Node Agent, daemon, сканер сессий"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 exactly one PreToolUse hook matching all tools.
177
- assert list(settings.keys()) == ["hooks"]
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: