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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-manager-cli
3
- Version: 0.1.8
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()
@@ -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
- await stdout_task
660
- ws_task.cancel()
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(json.dumps({"type": "heartbeat"}))
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(_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
+ )
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
- _time.sleep(backoff)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-manager-cli"
3
- version = "0.1.8"
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: