agent-manager-cli 0.1.0__py3-none-any.whl
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.0.dist-info/METADATA +183 -0
- agent_manager_cli-0.1.0.dist-info/RECORD +14 -0
- agent_manager_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_manager_cli-0.1.0.dist-info/entry_points.txt +2 -0
- am/__init__.py +3 -0
- am/__main__.py +5 -0
- am/cli.py +817 -0
- am/scanners/__init__.py +21 -0
- am/scanners/_util.py +17 -0
- am/scanners/claude.py +105 -0
- am/scanners/codex.py +88 -0
- am/scanners/gemini.py +99 -0
- am/schemas/__init__.py +0 -0
- am/schemas/session.py +12 -0
am/cli.py
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Manager CLI — daemon, login, and node agent.
|
|
3
|
+
|
|
4
|
+
Modes:
|
|
5
|
+
1. daemon — bridges Claude Code CLI to the backend via bidirectional WebSocket
|
|
6
|
+
2. login — authenticate with the server and save token locally
|
|
7
|
+
3. connect — run as a persistent node agent: scan sessions, receive attach commands
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
am login --host agent-manager.space --email user@mail.com
|
|
11
|
+
am connect
|
|
12
|
+
am daemon --token <TOKEN> -- claude -p "task" --output-format stream-json
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import getpass
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import websockets
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Config file management (~/.agentmanager/config.json)
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
CONFIG_DIR = Path.home() / ".agentmanager"
|
|
32
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config() -> dict:
|
|
36
|
+
"""Load config from ~/.agentmanager/config.json."""
|
|
37
|
+
if CONFIG_FILE.exists():
|
|
38
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_config(data: dict) -> None:
|
|
43
|
+
"""Save config to ~/.agentmanager/config.json with restricted permissions."""
|
|
44
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
CONFIG_DIR.chmod(0o700)
|
|
46
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2))
|
|
47
|
+
CONFIG_FILE.chmod(0o600)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Stream event mapping (daemon mode)
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def map_stream_event(line: str) -> list[dict]:
|
|
56
|
+
"""Map a Claude Code stream-json line to our StreamEvent format(s).
|
|
57
|
+
|
|
58
|
+
Returns a list of event dicts (may be empty).
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
msg = json.loads(line)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
msg_type = msg.get("type")
|
|
66
|
+
|
|
67
|
+
if msg_type == "system":
|
|
68
|
+
if msg.get("subtype") == "init":
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
"event_type": "status",
|
|
72
|
+
"data": {
|
|
73
|
+
"status": "running",
|
|
74
|
+
"message": "Session started",
|
|
75
|
+
"session_id": msg.get("session_id", ""),
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
if msg_type == "assistant":
|
|
82
|
+
content_blocks = msg.get("message", {}).get("content", [])
|
|
83
|
+
events = []
|
|
84
|
+
for block in content_blocks:
|
|
85
|
+
block_type = block.get("type")
|
|
86
|
+
if block_type == "thinking":
|
|
87
|
+
events.append(
|
|
88
|
+
{
|
|
89
|
+
"event_type": "thinking",
|
|
90
|
+
"data": {"text": block.get("thinking", "")},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
elif block_type == "text":
|
|
94
|
+
events.append(
|
|
95
|
+
{
|
|
96
|
+
"event_type": "text",
|
|
97
|
+
"data": {"text": block.get("text", "")},
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
elif block_type == "tool_use":
|
|
101
|
+
events.append(
|
|
102
|
+
{
|
|
103
|
+
"event_type": "tool_use",
|
|
104
|
+
"data": {
|
|
105
|
+
"tool": block.get("name", ""),
|
|
106
|
+
"input": block.get("input", {}),
|
|
107
|
+
"tool_use_id": block.get("id", ""),
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
return events
|
|
112
|
+
|
|
113
|
+
if msg_type == "user":
|
|
114
|
+
content_blocks = msg.get("message", {}).get("content", [])
|
|
115
|
+
for block in content_blocks:
|
|
116
|
+
if block.get("type") == "tool_result":
|
|
117
|
+
content = block.get("content", "")
|
|
118
|
+
if isinstance(content, list):
|
|
119
|
+
content = "\n".join(
|
|
120
|
+
b.get("text", "") for b in content if b.get("type") == "text"
|
|
121
|
+
)
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
"event_type": "tool_result",
|
|
125
|
+
"data": {
|
|
126
|
+
"tool_use_id": block.get("tool_use_id", ""),
|
|
127
|
+
"content": str(content)[:2000],
|
|
128
|
+
"is_error": block.get("is_error", False),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
if msg_type == "result":
|
|
135
|
+
subtype = msg.get("subtype", "")
|
|
136
|
+
if subtype == "success":
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
"event_type": "status",
|
|
140
|
+
"data": {
|
|
141
|
+
"status": "completed",
|
|
142
|
+
"message": "Task completed",
|
|
143
|
+
"cost_usd": msg.get("total_cost_usd", 0),
|
|
144
|
+
"duration_ms": msg.get("duration_ms", 0),
|
|
145
|
+
"num_turns": msg.get("num_turns", 0),
|
|
146
|
+
"session_id": msg.get("session_id", ""),
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
"event_type": "error",
|
|
153
|
+
"data": {
|
|
154
|
+
"message": f"Task ended: {subtype}",
|
|
155
|
+
"cost_usd": msg.get("total_cost_usd", 0),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
if msg_type == "stream_event":
|
|
161
|
+
delta = msg.get("event", {}).get("delta", {})
|
|
162
|
+
delta_type = delta.get("type")
|
|
163
|
+
if delta_type == "text_delta":
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
"event_type": "text",
|
|
167
|
+
"data": {"text": delta.get("text", ""), "partial": True},
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
if delta_type == "thinking_delta":
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
"event_type": "thinking",
|
|
174
|
+
"data": {"text": delta.get("thinking", ""), "partial": True},
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
if msg_type == "input_request":
|
|
179
|
+
request_type = msg.get("request_type", "text")
|
|
180
|
+
event_data: dict = {"request_type": request_type}
|
|
181
|
+
|
|
182
|
+
if request_type == "permission":
|
|
183
|
+
event_data["tool_name"] = msg.get("tool_name", "")
|
|
184
|
+
event_data["tool_input"] = msg.get("tool_input", {})
|
|
185
|
+
elif request_type == "question":
|
|
186
|
+
event_data["questions"] = msg.get("questions", [])
|
|
187
|
+
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
"event_type": "input_request",
|
|
191
|
+
"data": event_data,
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Helpers
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def read_lines(stream) -> asyncio.Queue:
|
|
204
|
+
"""Read lines from a sync stream in a thread, put them into an async queue."""
|
|
205
|
+
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
206
|
+
loop = asyncio.get_event_loop()
|
|
207
|
+
|
|
208
|
+
def _reader():
|
|
209
|
+
try:
|
|
210
|
+
for line in stream:
|
|
211
|
+
loop.call_soon_threadsafe(queue.put_nowait, line.strip())
|
|
212
|
+
finally:
|
|
213
|
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
214
|
+
|
|
215
|
+
loop.run_in_executor(None, _reader)
|
|
216
|
+
return queue
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def format_user_message_for_stdin(text: str) -> str:
|
|
220
|
+
"""Format a user message as stream-json for Claude CLI stdin."""
|
|
221
|
+
return json.dumps(
|
|
222
|
+
{
|
|
223
|
+
"type": "user",
|
|
224
|
+
"message": {
|
|
225
|
+
"role": "user",
|
|
226
|
+
"content": [{"type": "text", "text": text}],
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def format_permission_response_for_stdin(allow: bool) -> str:
|
|
233
|
+
"""Format a permission response as stream-json for Claude CLI stdin."""
|
|
234
|
+
return json.dumps(
|
|
235
|
+
{
|
|
236
|
+
"type": "permission_response",
|
|
237
|
+
"permission": "allow" if allow else "deny",
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _log(prefix: str, msg: str) -> None:
|
|
243
|
+
print(f"[{prefix}] {msg}", file=sys.stderr)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _safe_urlopen(url: str, **kwargs):
|
|
247
|
+
"""urlopen wrapper that only allows http/https schemes."""
|
|
248
|
+
import urllib.request
|
|
249
|
+
|
|
250
|
+
if not url.startswith(("http://", "https://")):
|
|
251
|
+
raise ValueError(f"Unsafe URL scheme: {url}")
|
|
252
|
+
return urllib.request.urlopen(url, **kwargs) # nosec B310 — scheme validated above
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Allowed CLI executables for subprocess calls
|
|
256
|
+
_ALLOWED_EXECUTABLES = frozenset({"claude", "codex", "gemini"})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _build_cli_command(cli: str, session_id: str) -> list[str]:
|
|
260
|
+
"""Build CLI-specific resume command.
|
|
261
|
+
|
|
262
|
+
Only allows known CLI executables to prevent arbitrary command execution.
|
|
263
|
+
"""
|
|
264
|
+
if cli not in _ALLOWED_EXECUTABLES:
|
|
265
|
+
allowed = ", ".join(sorted(_ALLOWED_EXECUTABLES))
|
|
266
|
+
raise ValueError(f"Unsupported CLI: {cli!r} (allowed: {allowed})")
|
|
267
|
+
if cli == "claude":
|
|
268
|
+
return [
|
|
269
|
+
"claude",
|
|
270
|
+
"--continue",
|
|
271
|
+
"--resume",
|
|
272
|
+
session_id,
|
|
273
|
+
"--output-format",
|
|
274
|
+
"stream-json",
|
|
275
|
+
"--input-format",
|
|
276
|
+
"stream-json",
|
|
277
|
+
"--verbose",
|
|
278
|
+
]
|
|
279
|
+
if cli == "codex":
|
|
280
|
+
return ["codex", "resume", session_id, "--json"]
|
|
281
|
+
# gemini
|
|
282
|
+
return ["gemini", "--resume"]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _build_continue_command(
|
|
286
|
+
original_cmd: list[str],
|
|
287
|
+
session_id: str,
|
|
288
|
+
prompt: str,
|
|
289
|
+
) -> list[str]:
|
|
290
|
+
"""Build a --continue --resume command from the original command."""
|
|
291
|
+
exe = original_cmd[0]
|
|
292
|
+
return [
|
|
293
|
+
exe,
|
|
294
|
+
"-p",
|
|
295
|
+
prompt,
|
|
296
|
+
"--continue",
|
|
297
|
+
"--resume",
|
|
298
|
+
session_id,
|
|
299
|
+
"--output-format",
|
|
300
|
+
"stream-json",
|
|
301
|
+
"--input-format",
|
|
302
|
+
"stream-json",
|
|
303
|
+
"--verbose",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Daemon mode
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _log_event(etype: str, data: dict) -> None:
|
|
313
|
+
"""Log a mapped event to stderr."""
|
|
314
|
+
if etype == "text":
|
|
315
|
+
_log(etype, data.get("text", "")[:80])
|
|
316
|
+
elif etype == "tool_use":
|
|
317
|
+
_log(etype, data.get("tool", ""))
|
|
318
|
+
elif etype == "tool_result":
|
|
319
|
+
_log(etype, data.get("content", "")[:60])
|
|
320
|
+
elif etype in ("status", "error"):
|
|
321
|
+
_log(etype, data.get("message", ""))
|
|
322
|
+
elif etype == "thinking":
|
|
323
|
+
_log(etype, "...")
|
|
324
|
+
elif etype == "input_request":
|
|
325
|
+
_log(etype, data.get("request_type", ""))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def run_daemon_bidirectional(ws_url: str, ws_token: str, command: list[str]):
|
|
329
|
+
"""Run Claude CLI with bidirectional pipes and forward via WebSocket."""
|
|
330
|
+
_log("daemon", f"Running: {' '.join(command)}")
|
|
331
|
+
proc = subprocess.Popen(
|
|
332
|
+
command,
|
|
333
|
+
stdin=subprocess.PIPE,
|
|
334
|
+
stdout=subprocess.PIPE,
|
|
335
|
+
stderr=sys.stderr,
|
|
336
|
+
text=True,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
session_id = None
|
|
340
|
+
|
|
341
|
+
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
|
|
342
|
+
await ws.send(json.dumps({"token": ws_token}))
|
|
343
|
+
_log("daemon", "Connected (bidirectional mode)")
|
|
344
|
+
|
|
345
|
+
stdout_queue = await read_lines(proc.stdout)
|
|
346
|
+
sent_count = 0
|
|
347
|
+
|
|
348
|
+
async def _read_stdout():
|
|
349
|
+
nonlocal sent_count, session_id
|
|
350
|
+
line_num = 0
|
|
351
|
+
while True:
|
|
352
|
+
line = await stdout_queue.get()
|
|
353
|
+
if line is None:
|
|
354
|
+
break
|
|
355
|
+
if not line:
|
|
356
|
+
continue
|
|
357
|
+
line_num += 1
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
raw = json.loads(line)
|
|
361
|
+
if raw.get("type") == "system" and raw.get("subtype") == "init":
|
|
362
|
+
session_id = raw.get("session_id")
|
|
363
|
+
elif raw.get("type") == "result":
|
|
364
|
+
sid = raw.get("session_id")
|
|
365
|
+
if sid:
|
|
366
|
+
session_id = sid
|
|
367
|
+
except (json.JSONDecodeError, KeyError):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
events = map_stream_event(line)
|
|
371
|
+
if not events:
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
for event in events:
|
|
375
|
+
try:
|
|
376
|
+
await ws.send(json.dumps(event))
|
|
377
|
+
sent_count += 1
|
|
378
|
+
except Exception as e:
|
|
379
|
+
_log("send error", str(e))
|
|
380
|
+
continue
|
|
381
|
+
_log_event(event["event_type"], event.get("data", {}))
|
|
382
|
+
|
|
383
|
+
_log("daemon", f"stdout closed. Sent {sent_count} events.")
|
|
384
|
+
|
|
385
|
+
async def _read_ws():
|
|
386
|
+
try:
|
|
387
|
+
async for raw_msg in ws:
|
|
388
|
+
try:
|
|
389
|
+
msg = json.loads(raw_msg)
|
|
390
|
+
except json.JSONDecodeError:
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
msg_type = msg.get("type", "")
|
|
394
|
+
if msg_type == "user_input":
|
|
395
|
+
text = msg.get("data", {}).get("text", "")
|
|
396
|
+
if text and proc.stdin and not proc.stdin.closed:
|
|
397
|
+
proc.stdin.write(format_user_message_for_stdin(text) + "\n")
|
|
398
|
+
proc.stdin.flush()
|
|
399
|
+
_log("stdin", f"user_input: {text[:60]}")
|
|
400
|
+
elif msg_type == "permission_response":
|
|
401
|
+
allow = msg.get("data", {}).get("allow", False)
|
|
402
|
+
if proc.stdin and not proc.stdin.closed:
|
|
403
|
+
proc.stdin.write(format_permission_response_for_stdin(allow) + "\n")
|
|
404
|
+
proc.stdin.flush()
|
|
405
|
+
_log("stdin", f"permission: {'allow' if allow else 'deny'}")
|
|
406
|
+
except websockets.exceptions.ConnectionClosed:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
stdout_task = asyncio.create_task(_read_stdout())
|
|
410
|
+
ws_task = asyncio.create_task(_read_ws())
|
|
411
|
+
await stdout_task
|
|
412
|
+
ws_task.cancel()
|
|
413
|
+
|
|
414
|
+
proc.wait()
|
|
415
|
+
_log("daemon", f"Process exited with code {proc.returncode}")
|
|
416
|
+
return proc.returncode, session_id
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def run_daemon_pipe(ws_url: str, ws_token: str, input_stream):
|
|
420
|
+
"""Forward mapped events from stdin (pipe mode, read-only)."""
|
|
421
|
+
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
|
|
422
|
+
await ws.send(json.dumps({"token": ws_token}))
|
|
423
|
+
_log("daemon", "Connected (pipe mode)")
|
|
424
|
+
|
|
425
|
+
queue = await read_lines(input_stream)
|
|
426
|
+
sent_count = 0
|
|
427
|
+
|
|
428
|
+
while True:
|
|
429
|
+
line = await queue.get()
|
|
430
|
+
if line is None:
|
|
431
|
+
break
|
|
432
|
+
if not line:
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
events = map_stream_event(line)
|
|
436
|
+
for event in events:
|
|
437
|
+
try:
|
|
438
|
+
await ws.send(json.dumps(event))
|
|
439
|
+
sent_count += 1
|
|
440
|
+
except Exception as e:
|
|
441
|
+
_log("send error", str(e))
|
|
442
|
+
continue
|
|
443
|
+
_log_event(event["event_type"], event.get("data", {}))
|
|
444
|
+
|
|
445
|
+
_log("daemon", f"Done. Sent {sent_count} events.")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
async def run_with_followup(ws_url: str, ws_token: str, command: list[str]):
|
|
449
|
+
"""Run Claude CLI, then listen for follow-up messages and restart."""
|
|
450
|
+
returncode, session_id = await run_daemon_bidirectional(ws_url, ws_token, command)
|
|
451
|
+
|
|
452
|
+
if returncode != 0 or not session_id:
|
|
453
|
+
return returncode
|
|
454
|
+
|
|
455
|
+
_log("daemon", f"Task completed (session: {session_id}). Listening for follow-ups...")
|
|
456
|
+
|
|
457
|
+
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
|
|
458
|
+
await ws.send(json.dumps({"token": ws_token}))
|
|
459
|
+
await ws.send(
|
|
460
|
+
json.dumps(
|
|
461
|
+
{
|
|
462
|
+
"event_type": "status",
|
|
463
|
+
"data": {
|
|
464
|
+
"status": "waiting_followup",
|
|
465
|
+
"message": "Ready for follow-up",
|
|
466
|
+
"session_id": session_id,
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
async for raw_msg in ws:
|
|
474
|
+
try:
|
|
475
|
+
msg = json.loads(raw_msg)
|
|
476
|
+
except json.JSONDecodeError:
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
if msg.get("type") == "user_input":
|
|
480
|
+
text = msg.get("data", {}).get("text", "")
|
|
481
|
+
if not text:
|
|
482
|
+
continue
|
|
483
|
+
_log("daemon", f"Follow-up: {text[:60]}")
|
|
484
|
+
continue_cmd = _build_continue_command(command, session_id, text)
|
|
485
|
+
break
|
|
486
|
+
elif msg.get("type") == "stop":
|
|
487
|
+
_log("daemon", "Stop received.")
|
|
488
|
+
return 0
|
|
489
|
+
else:
|
|
490
|
+
return 0
|
|
491
|
+
except websockets.exceptions.ConnectionClosed:
|
|
492
|
+
return 0
|
|
493
|
+
|
|
494
|
+
return await run_with_followup(ws_url, ws_token, continue_cmd)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
# Login command
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_login(args: argparse.Namespace) -> None:
|
|
503
|
+
"""Authenticate with the server and save tokens locally."""
|
|
504
|
+
import urllib.error
|
|
505
|
+
import urllib.request
|
|
506
|
+
|
|
507
|
+
host = args.host
|
|
508
|
+
email = args.email or input("Email: ")
|
|
509
|
+
password = args.password or getpass.getpass("Password: ")
|
|
510
|
+
|
|
511
|
+
proto = "https" if args.secure else "http"
|
|
512
|
+
url = f"{proto}://{host}/api/auth/login"
|
|
513
|
+
|
|
514
|
+
payload = json.dumps({"email": email, "password": password}).encode()
|
|
515
|
+
req = urllib.request.Request(
|
|
516
|
+
url,
|
|
517
|
+
data=payload,
|
|
518
|
+
headers={"Content-Type": "application/json"},
|
|
519
|
+
method="POST",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
with _safe_urlopen(req) as resp:
|
|
524
|
+
data = json.loads(resp.read())
|
|
525
|
+
except urllib.error.HTTPError as e:
|
|
526
|
+
body = e.read().decode()
|
|
527
|
+
_log("login", f"Error {e.code}: {body}")
|
|
528
|
+
sys.exit(1)
|
|
529
|
+
except urllib.error.URLError as e:
|
|
530
|
+
_log("login", f"Connection error: {e.reason}")
|
|
531
|
+
sys.exit(1)
|
|
532
|
+
|
|
533
|
+
config = load_config()
|
|
534
|
+
config["host"] = host
|
|
535
|
+
config["secure"] = args.secure
|
|
536
|
+
config["access_token"] = data["access_token"]
|
|
537
|
+
config["refresh_token"] = data["refresh_token"]
|
|
538
|
+
save_config(config)
|
|
539
|
+
|
|
540
|
+
_log("login", f"Logged in as {email}")
|
|
541
|
+
_log("login", f"Config saved to {CONFIG_FILE}")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# Connect command (Node Agent)
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _refresh_token(host: str, secure: bool, refresh_token: str) -> dict | None:
|
|
550
|
+
"""Try to refresh the access token. Returns new tokens or None."""
|
|
551
|
+
import urllib.request
|
|
552
|
+
|
|
553
|
+
proto = "https" if secure else "http"
|
|
554
|
+
url = f"{proto}://{host}/api/auth/refresh"
|
|
555
|
+
payload = json.dumps({"refresh_token": refresh_token}).encode()
|
|
556
|
+
req = urllib.request.Request(
|
|
557
|
+
url,
|
|
558
|
+
data=payload,
|
|
559
|
+
headers={"Content-Type": "application/json"},
|
|
560
|
+
method="POST",
|
|
561
|
+
)
|
|
562
|
+
try:
|
|
563
|
+
with _safe_urlopen(req) as resp:
|
|
564
|
+
return json.loads(resp.read())
|
|
565
|
+
except Exception:
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
async def _run_node_agent(host: str, secure: bool, access_token: str) -> None:
|
|
570
|
+
"""Run the node agent: connect, scan sessions, handle attach commands."""
|
|
571
|
+
from am.scanners import scan_all_sessions
|
|
572
|
+
|
|
573
|
+
ws_proto = "wss" if secure else "ws"
|
|
574
|
+
ws_url = f"{ws_proto}://{host}/ws/node"
|
|
575
|
+
|
|
576
|
+
_log("node", f"Connecting to {ws_url}...")
|
|
577
|
+
|
|
578
|
+
daemon_procs: dict[str, asyncio.subprocess.Process] = {}
|
|
579
|
+
|
|
580
|
+
async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
|
|
581
|
+
await ws.send(json.dumps({"token": access_token}))
|
|
582
|
+
|
|
583
|
+
raw = await ws.recv()
|
|
584
|
+
msg = json.loads(raw)
|
|
585
|
+
if msg.get("type") != "connected":
|
|
586
|
+
_log("node", f"Unexpected response: {msg}")
|
|
587
|
+
return
|
|
588
|
+
_log("node", f"Connected as user {msg.get('user_id', '?')[:8]}...")
|
|
589
|
+
|
|
590
|
+
async def _scan_and_send():
|
|
591
|
+
while True:
|
|
592
|
+
try:
|
|
593
|
+
loop = asyncio.get_event_loop()
|
|
594
|
+
sessions = await loop.run_in_executor(None, scan_all_sessions)
|
|
595
|
+
sessions_data = [s.model_dump(mode="json") for s in sessions]
|
|
596
|
+
await ws.send(
|
|
597
|
+
json.dumps(
|
|
598
|
+
{
|
|
599
|
+
"type": "sessions",
|
|
600
|
+
"data": sessions_data,
|
|
601
|
+
}
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
_log("node", f"Sent {len(sessions)} sessions")
|
|
605
|
+
except websockets.exceptions.ConnectionClosed:
|
|
606
|
+
break
|
|
607
|
+
except Exception as e:
|
|
608
|
+
_log("node", f"Scan error: {e}")
|
|
609
|
+
await asyncio.sleep(30)
|
|
610
|
+
|
|
611
|
+
async def _heartbeat():
|
|
612
|
+
while True:
|
|
613
|
+
await asyncio.sleep(25)
|
|
614
|
+
try:
|
|
615
|
+
await ws.send(json.dumps({"type": "heartbeat"}))
|
|
616
|
+
except websockets.exceptions.ConnectionClosed:
|
|
617
|
+
break
|
|
618
|
+
|
|
619
|
+
async def _listen_commands():
|
|
620
|
+
try:
|
|
621
|
+
async for raw_msg in ws:
|
|
622
|
+
try:
|
|
623
|
+
cmd = json.loads(raw_msg)
|
|
624
|
+
except json.JSONDecodeError:
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
if cmd.get("type") == "attach":
|
|
628
|
+
await _handle_attach(cmd, host, secure, daemon_procs)
|
|
629
|
+
except websockets.exceptions.ConnectionClosed:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
scan_task = asyncio.create_task(_scan_and_send())
|
|
633
|
+
hb_task = asyncio.create_task(_heartbeat())
|
|
634
|
+
cmd_task = asyncio.create_task(_listen_commands())
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
await asyncio.wait(
|
|
638
|
+
[scan_task, hb_task, cmd_task],
|
|
639
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
640
|
+
)
|
|
641
|
+
finally:
|
|
642
|
+
scan_task.cancel()
|
|
643
|
+
hb_task.cancel()
|
|
644
|
+
cmd_task.cancel()
|
|
645
|
+
for aid, proc in daemon_procs.items():
|
|
646
|
+
if proc.returncode is None:
|
|
647
|
+
proc.terminate()
|
|
648
|
+
_log("node", f"Terminated daemon for agent {aid[:8]}...")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
async def _handle_attach(
|
|
652
|
+
cmd: dict,
|
|
653
|
+
host: str,
|
|
654
|
+
secure: bool,
|
|
655
|
+
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
656
|
+
) -> None:
|
|
657
|
+
"""Handle an attach command from the server."""
|
|
658
|
+
agent_id = cmd["agent_id"]
|
|
659
|
+
session_id = cmd["session_id"]
|
|
660
|
+
cli = cmd.get("cli", "claude")
|
|
661
|
+
project_path = cmd.get("project_path", "")
|
|
662
|
+
daemon_token = cmd["daemon_token"]
|
|
663
|
+
|
|
664
|
+
_log("node", f"Attach: {cli} session {session_id[:8]}... agent {agent_id[:8]}...")
|
|
665
|
+
|
|
666
|
+
cli_cmd = _build_cli_command(cli, session_id)
|
|
667
|
+
daemon_cmd = [
|
|
668
|
+
sys.executable,
|
|
669
|
+
"-m",
|
|
670
|
+
"am",
|
|
671
|
+
"daemon",
|
|
672
|
+
"--token",
|
|
673
|
+
daemon_token,
|
|
674
|
+
"--host",
|
|
675
|
+
host,
|
|
676
|
+
*(["--secure"] if secure else []),
|
|
677
|
+
"--",
|
|
678
|
+
*cli_cmd,
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
proc = await asyncio.create_subprocess_exec(
|
|
682
|
+
*daemon_cmd,
|
|
683
|
+
cwd=project_path or None,
|
|
684
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
685
|
+
stderr=asyncio.subprocess.PIPE,
|
|
686
|
+
)
|
|
687
|
+
daemon_procs[agent_id] = proc
|
|
688
|
+
_log("node", f"Spawned daemon PID {proc.pid} for agent {agent_id[:8]}...")
|
|
689
|
+
|
|
690
|
+
asyncio.create_task(_log_daemon_stderr(agent_id, proc, daemon_procs))
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
async def _log_daemon_stderr(
|
|
694
|
+
agent_id: str,
|
|
695
|
+
proc: asyncio.subprocess.Process,
|
|
696
|
+
daemon_procs: dict[str, asyncio.subprocess.Process],
|
|
697
|
+
) -> None:
|
|
698
|
+
"""Read daemon stderr and log it."""
|
|
699
|
+
try:
|
|
700
|
+
while True:
|
|
701
|
+
line = await proc.stderr.readline()
|
|
702
|
+
if not line:
|
|
703
|
+
break
|
|
704
|
+
print(f" [daemon:{agent_id[:8]}] {line.decode().rstrip()}", file=sys.stderr)
|
|
705
|
+
except Exception:
|
|
706
|
+
pass
|
|
707
|
+
await proc.wait()
|
|
708
|
+
_log("node", f"Daemon {agent_id[:8]}... exited with code {proc.returncode}")
|
|
709
|
+
daemon_procs.pop(agent_id, None)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def cmd_connect(args: argparse.Namespace) -> None:
|
|
713
|
+
"""Run the node agent."""
|
|
714
|
+
config = load_config()
|
|
715
|
+
|
|
716
|
+
host = args.host or config.get("host")
|
|
717
|
+
secure = args.secure or config.get("secure", False)
|
|
718
|
+
access_token = config.get("access_token")
|
|
719
|
+
refresh_token = config.get("refresh_token")
|
|
720
|
+
|
|
721
|
+
if not host:
|
|
722
|
+
_log("connect", "No host configured. Run `am login` first.")
|
|
723
|
+
sys.exit(1)
|
|
724
|
+
if not access_token:
|
|
725
|
+
_log("connect", "No access token. Run `am login` first.")
|
|
726
|
+
sys.exit(1)
|
|
727
|
+
|
|
728
|
+
# Refresh token before connecting
|
|
729
|
+
if refresh_token:
|
|
730
|
+
tokens = _refresh_token(host, secure, refresh_token)
|
|
731
|
+
if tokens:
|
|
732
|
+
access_token = tokens["access_token"]
|
|
733
|
+
config["access_token"] = tokens["access_token"]
|
|
734
|
+
config["refresh_token"] = tokens["refresh_token"]
|
|
735
|
+
save_config(config)
|
|
736
|
+
_log("connect", "Token refreshed.")
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
asyncio.run(_run_node_agent(host, secure, access_token))
|
|
740
|
+
except KeyboardInterrupt:
|
|
741
|
+
print("\n[connect] Stopped.", file=sys.stderr)
|
|
742
|
+
except websockets.exceptions.InvalidStatusCode as e:
|
|
743
|
+
_log("connect", f"WebSocket error: {e}")
|
|
744
|
+
if e.status_code in (401, 403):
|
|
745
|
+
_log("connect", "Token may be expired. Run `am login` again.")
|
|
746
|
+
sys.exit(1)
|
|
747
|
+
except Exception as e:
|
|
748
|
+
_log("connect", f"Error: {e}")
|
|
749
|
+
sys.exit(1)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# ---------------------------------------------------------------------------
|
|
753
|
+
# Daemon subcommand
|
|
754
|
+
# ---------------------------------------------------------------------------
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def cmd_daemon(args: argparse.Namespace) -> None:
|
|
758
|
+
"""Run daemon for a CLI session."""
|
|
759
|
+
proto = "wss" if args.secure else "ws"
|
|
760
|
+
ws_url = f"{proto}://{args.host}/ws/daemon"
|
|
761
|
+
|
|
762
|
+
if args.cli_command:
|
|
763
|
+
if args.no_followup:
|
|
764
|
+
returncode, _ = asyncio.run(
|
|
765
|
+
run_daemon_bidirectional(ws_url, args.token, args.cli_command)
|
|
766
|
+
)
|
|
767
|
+
else:
|
|
768
|
+
returncode = asyncio.run(run_with_followup(ws_url, args.token, args.cli_command))
|
|
769
|
+
sys.exit(returncode or 0)
|
|
770
|
+
else:
|
|
771
|
+
_log("daemon", "Reading from stdin (pipe mode)...")
|
|
772
|
+
asyncio.run(run_daemon_pipe(ws_url, args.token, sys.stdin))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ---------------------------------------------------------------------------
|
|
776
|
+
# Main entry point
|
|
777
|
+
# ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def main() -> None:
|
|
781
|
+
parser = argparse.ArgumentParser(
|
|
782
|
+
prog="am",
|
|
783
|
+
description="Agent Manager — daemon, login, and node agent",
|
|
784
|
+
)
|
|
785
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
786
|
+
|
|
787
|
+
# --- login ---
|
|
788
|
+
login_p = subparsers.add_parser("login", help="Authenticate with the server")
|
|
789
|
+
login_p.add_argument("--host", required=True, help="Server host:port")
|
|
790
|
+
login_p.add_argument("--email", help="Email (will prompt if not provided)")
|
|
791
|
+
login_p.add_argument("--password", help="Password (will prompt if not provided)")
|
|
792
|
+
login_p.add_argument("--secure", action="store_true", help="Use HTTPS/WSS")
|
|
793
|
+
|
|
794
|
+
# --- connect ---
|
|
795
|
+
connect_p = subparsers.add_parser("connect", help="Run node agent")
|
|
796
|
+
connect_p.add_argument("--host", help="Override saved host")
|
|
797
|
+
connect_p.add_argument("--secure", action="store_true", help="Use HTTPS/WSS")
|
|
798
|
+
|
|
799
|
+
# --- daemon ---
|
|
800
|
+
daemon_p = subparsers.add_parser("daemon", help="Run daemon for a CLI session")
|
|
801
|
+
daemon_p.add_argument("--token", required=True, help="Daemon JWT token")
|
|
802
|
+
daemon_p.add_argument("--host", default="localhost:8000", help="Backend host:port")
|
|
803
|
+
daemon_p.add_argument("--secure", action="store_true", help="Use WSS")
|
|
804
|
+
daemon_p.add_argument("--no-followup", action="store_true", help="Disable follow-up mode")
|
|
805
|
+
daemon_p.add_argument("cli_command", nargs="*", help="CLI command to run")
|
|
806
|
+
|
|
807
|
+
args = parser.parse_args()
|
|
808
|
+
|
|
809
|
+
if args.command == "login":
|
|
810
|
+
cmd_login(args)
|
|
811
|
+
elif args.command == "connect":
|
|
812
|
+
cmd_connect(args)
|
|
813
|
+
elif args.command == "daemon":
|
|
814
|
+
cmd_daemon(args)
|
|
815
|
+
else:
|
|
816
|
+
parser.print_help()
|
|
817
|
+
sys.exit(1)
|