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.
@@ -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.11
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
@@ -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 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.
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
- aid for aid, p in daemon_procs.items()
939
- if p.returncode is None
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(_run_node_agent(host, secure, access_token, daemon_procs))
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
- _time.sleep(backoff)
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-manager-cli"
3
- version = "0.1.9"
3
+ version = "0.1.11"
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: