Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ argus
```

Use the terminal cockpit to talk to the Manager, follow live work, inspect state, and resume projects.
Without an explicit `--port`, Argus reuses a compatible backend or selects the
first available port starting at `8799` when another program or stale backend
occupies it. On Windows, a plain `argus` launch also opens the Web UI; use
`argus --no-open` for the terminal cockpit only.

### Web UI

Expand All @@ -201,7 +205,8 @@ Start Argus and open the Web UI in your default browser:
argus --web
```

Default address: [http://127.0.0.1:8799](http://127.0.0.1:8799)
Preferred address: [http://127.0.0.1:8799](http://127.0.0.1:8799); Argus advances
to the next available port when needed.

The Web UI follows the browser language on first launch and supports English
and Simplified Chinese. Use the language button in the session sidebar to
Expand Down
5 changes: 4 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ argus
```

通过终端 Cockpit 与 Manager 对话、跟踪实时工作、检查状态并恢复项目。
未显式指定 `--port` 时,Argus 会复用兼容后端;若默认端口被其他程序或旧后端占用,
则从 `8799` 开始选择首个可用端口。在 Windows 上,普通 `argus` 启动会同时打开
Web UI;使用 `argus --no-open` 可只保留终端 Cockpit。

### Web UI

Expand All @@ -199,7 +202,7 @@ argus
argus --web
```

默认地址:[http://127.0.0.1:8799](http://127.0.0.1:8799)
首选地址:[http://127.0.0.1:8799](http://127.0.0.1:8799);被占用时会自动顺延。

```bash
argus --web --no-open # 只启动,不打开浏览器
Expand Down
5 changes: 2 additions & 3 deletions argus_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,12 @@ def run_bootstrap_doctor(root=None):
findings.append(_finding(
"ARGUS-DESKTOP-001",
"Desktop runtime",
electron_ready,
True,
(
"Electron runtime present" if electron_installed and electron_ready
else "Desktop dependencies not installed (optional for CLI/Web)" if not electron_installed
else "Electron runtime binary missing"
else "Electron runtime binary missing (optional for CLI/Web)"
),
"run `npm --prefix desktop ci`; Desktop postinstall downloads the declared Electron runtime",
))

web_host = os.environ.get("ARGUS_SKILL_WEB_HOST", "127.0.0.1")
Expand Down
10 changes: 9 additions & 1 deletion argus_skill/apps/tui_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"--gc",
"--watch",
"--follow",
"--web",
"--pair-plan",
"--notify",
"--init-identity",
Expand Down Expand Up @@ -145,6 +144,15 @@ def _run_python_admin(argv: list[str]) -> int:


def _uses_python_admin(argv: list[str]) -> bool:
# `argus --web` is a cockpit surface: it needs the TUI's automatic port
# selection and browser launch. Keep the legacy raw WebAPI spelling on the
# Python path only when its backend-specific options are present.
if "--web" in argv and any(
arg == option or arg.startswith(f"{option}=")
for arg in argv
for option in ("--web-host", "--web-port")
):
return True
i = 0
while i < len(argv):
arg = argv[i]
Expand Down
2 changes: 1 addition & 1 deletion argus_skill/core/knobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class BudgetCaps:
Knob("ARGUS_SKILL_REWRITE_MODEL", "auto", "interactive prompt rewrite model: gpt-5.5 on codex/copilot, Manager model otherwise; set an id to override", "models"),
Knob("ARGUS_SKILL_MANAGER_REPLY_MODEL", "inherit", "operator-facing Manager SELF model; inherit uses the configured Manager/shared route model", "models", cockpit=True),
Knob("ARGUS_SKILL_FRONTDOOR_MODEL", "auto", "cheap front-door classification model: gpt-5.4-mini on codex/copilot, Manager model otherwise", "models"),
Knob("ARGUS_SKILL_FRONTDOOR_CLASSIFY_EFFORT", "medium", "reasoning effort for the LLM-only front-door and STEER confirmation", "models"),
Knob("ARGUS_SKILL_FRONTDOOR_CLASSIFY_EFFORT", "low", "reasoning effort for the LLM-only front-door and STEER confirmation", "models"),
# --- reasoning effort ---
Knob("ARGUS_SKILL_MANAGER_REASONING_EFFORT", "high", "manager reasoning effort", "reasoning", cockpit=True),
Knob("ARGUS_SKILL_PLANNER_REASONING_EFFORT", "high", "planner reasoning effort", "reasoning", cockpit=True),
Expand Down
48 changes: 48 additions & 0 deletions argus_skill/core/portable_filename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations

import base64
import os

_WINDOWS_RESERVED = frozenset({
"con",
"prn",
"aux",
"nul",
*(f"com{i}" for i in range(1, 10)),
*(f"lpt{i}" for i in range(1, 10)),
})


def portable_filename_component(
value: str,
*,
windows: bool | None = None,
max_bytes: int = 120,
) -> str:
"""Encode a logical identifier as one bounded, portable path component."""
text = str(value)
raw = text.encode("utf-8")
if len(raw) > max_bytes:
raise ValueError(f"identifier exceeds {max_bytes} UTF-8 bytes")
on_windows = os.name == "nt" if windows is None else windows
stem = text.split(".", 1)[0].casefold()
unsafe = (
not text
or text.startswith("~")
or any(char in text for char in "/\\\0")
or (
on_windows
and (
any(ord(char) < 32 or char in '<>:"|?*' for char in text)
or text.endswith((" ", "."))
or stem in _WINDOWS_RESERVED
)
)
)
if not unsafe:
return text
encoded = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"~{encoded}"


__all__ = ["portable_filename_component"]
6 changes: 3 additions & 3 deletions argus_skill/manager/_front_door_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def classify_front_door(
Built FRESH on the raw backend (``self.runner``, NEVER
``self._session`` — no giant-session resume, no pollution),
``resume_thread_id=None``. Effort comes from
``ARGUS_SKILL_FRONTDOOR_CLASSIFY_EFFORT`` (default ``medium``). Biases
``ARGUS_SKILL_FRONTDOOR_CLASSIFY_EFFORT`` (default ``low``). Biases
each axis to its own safe default on any error."""
from ..life.router import classify_front_door

Expand All @@ -56,8 +56,8 @@ def classify_front_door(
_backend = self.runner
_effort = resolve_knob(
"ARGUS_SKILL_FRONTDOOR_CLASSIFY_EFFORT",
"medium",
).value.strip() or "medium"
"low",
).value.strip() or "low"

def run_exec(prompt: str) -> Any: # noqa: ANN401
return gateway_run_exec(
Expand Down
1 change: 1 addition & 0 deletions argus_skill/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def _source_files(root: Path) -> Iterable[Path]:
"plugins/argus/**/*.yaml",
"plugins/argus/bin/*",
"plugins/argus/install.sh",
"argus_doctor.py",
"pyproject.toml",
)
tracked = _git_tracked_files(root)
Expand Down
4 changes: 2 additions & 2 deletions argus_skill/release_manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"package_version": "0.1.1",
"release_id": "0.1.1+a4de81c666ef26a3",
"release_id": "0.1.1+87a2e4aae67e9583",
"schema_version": 1,
"source_digest": "a4de81c666ef26a3645b67f679e18132546e5ec92003000d736e503810fa9700"
"source_digest": "87a2e4aae67e95833d5b12a2140b54f90aae0eca54255b656f60d0d5d1826adb"
}
120 changes: 103 additions & 17 deletions argus_skill/team/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from __future__ import annotations

import contextlib
import ctypes
import logging
import os
import re
Expand All @@ -38,6 +39,8 @@
from . import completion, leaderboard, pool, registry, roster, task_board

log = logging.getLogger(__name__)
_SYNCHRONIZE = 0x00100000
_WAIT_TIMEOUT = 0x00000102


def _windows_process_command_line(pid: int) -> str:
Expand All @@ -62,14 +65,47 @@ def _windows_process_command_line(pid: int) -> str:
return result.stdout.strip() if result.returncode == 0 else ""


def _terminate_windows_tree(pid: int) -> None:
subprocess.run(
["taskkill.exe", "/PID", str(int(pid)), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
def _terminate_windows_tree(pid: int) -> bool:
try:
result = subprocess.run(
["taskkill.exe", "/PID", str(int(pid)), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError:
return False
return result.returncode == 0


def _open_windows_process_handle(pid: int) -> int:
windll = getattr(ctypes, "windll", None)
if windll is None:
return 0
open_process = windll.kernel32.OpenProcess
open_process.argtypes = (ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32)
open_process.restype = ctypes.c_void_p
return int(open_process(_SYNCHRONIZE, False, int(pid)) or 0)


def _close_windows_process_handle(handle: int) -> None:
windll = getattr(ctypes, "windll", None)
if handle > 0 and windll is not None:
close_handle = windll.kernel32.CloseHandle
close_handle.argtypes = (ctypes.c_void_p,)
close_handle.restype = ctypes.c_int
close_handle(ctypes.c_void_p(handle))


def _windows_process_handle_alive(handle: int) -> bool:
windll = getattr(ctypes, "windll", None)
if handle <= 0 or windll is None:
return False
wait = windll.kernel32.WaitForSingleObject
wait.argtypes = (ctypes.c_void_p, ctypes.c_uint32)
wait.restype = ctypes.c_uint32
return wait(ctypes.c_void_p(handle), 0) == _WAIT_TIMEOUT


def _pid_is_teammate(pid: int, member_id: str, root: Path | None = None) -> bool:
Expand Down Expand Up @@ -139,14 +175,41 @@ class _AdoptedProc:
``_terminate`` rely on, so adopted children flow through every owned-child
path unchanged."""

def __init__(self, pid: int, member_id: str, root: Path) -> None:
def __init__(
self,
pid: int,
member_id: str,
root: Path,
*,
windows_handle: int = 0,
) -> None:
self.pid = int(pid)
self._member_id = member_id
self._root = Path(root)
self._windows_handle = (
windows_handle or _open_windows_process_handle(self.pid)
if os.name == "nt"
else 0
)
if os.name == "nt" and self._windows_handle <= 0:
raise OSError(f"could not retain Windows process handle for pid {self.pid}")

def poll(self) -> int | None:
if os.name == "nt":
if _windows_process_handle_alive(self._windows_handle):
return None
self._close_windows_handle()
return 0
return None if _pid_is_teammate(self.pid, self._member_id, self._root) else 0

def _close_windows_handle(self) -> None:
handle = self._windows_handle
self._windows_handle = 0
_close_windows_process_handle(handle)

def __del__(self) -> None:
self._close_windows_handle()

def wait(self, timeout: float | None = None) -> int:
end = (time.time() + timeout) if timeout else None
while self.poll() is None:
Expand Down Expand Up @@ -311,10 +374,22 @@ def _adopt_orphans(self, root: Path, *, now: float | None = None) -> list[str]:
child_key = _child_key(root, str(mid or ""))
if not mid or child_key in self._children or not pid:
continue
if m.get("status") != "running" or not _pid_is_teammate(int(pid), mid, root):
if m.get("status") != "running":
continue
if os.name == "nt":
handle = _open_windows_process_handle(int(pid))
if handle <= 0:
continue
if not _pid_is_teammate(int(pid), mid, root):
_close_windows_process_handle(handle)
continue
proc = _AdoptedProc(int(pid), mid, root, windows_handle=handle)
else:
if not _pid_is_teammate(int(pid), mid, root):
continue
proc = _AdoptedProc(int(pid), mid, root)
self._children[child_key] = TrackedTeammate(
_AdoptedProc(int(pid), mid, root), member_id=mid,
proc, member_id=mid,
task_id=m.get("task_id", ""), root=root, started_at=now,
timeout_s=self.teammate_timeout_s, hard_grace_s=self.hard_grace_s)
adopted.append(mid)
Expand Down Expand Up @@ -381,27 +456,28 @@ def _refill(self, root: Path, *, width: int, cwd: Path,
"failed_dead_cwd": failed_dead_cwd}

# ---- reaping --------------------------------------------------------
def _terminate(self, tt: TrackedTeammate, *, grace: float = 2.0) -> None:
def _terminate(self, tt: TrackedTeammate, *, grace: float = 2.0) -> bool:
"""Kill one tracked child's process group (SIGTERM → grace → SIGKILL)."""
proc = tt.proc
if proc.poll() is not None:
return
return True
if os.name == "nt":
_terminate_windows_tree(proc.pid)
with contextlib.suppress(subprocess.TimeoutExpired):
proc.wait(timeout=max(grace, 5.0))
return
return proc.poll() is not None
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (OSError, ProcessLookupError):
return
return proc.poll() is not None
try:
proc.wait(timeout=grace)
except subprocess.TimeoutExpired:
with contextlib.suppress(OSError, ProcessLookupError):
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
with contextlib.suppress(subprocess.TimeoutExpired):
proc.wait(timeout=5)
return proc.poll() is not None

def _reap(self, now: float | None = None) -> dict[str, list[str]]:
"""Drop children that exited on their own; hard-kill+free those past the
Expand All @@ -425,7 +501,12 @@ def _reap(self, now: float | None = None) -> dict[str, list[str]]:
dropped.append(tt.member_id)
continue
if now >= tt.hard_deadline():
self._terminate(tt)
if not self._terminate(tt):
log.error(
"curator: timed-out teammate %s remained alive after termination",
tt.member_id,
)
continue
with contextlib.suppress(Exception):
task_board.fail(tt.root, tt.task_id, reason="curator hard-timeout")
roster.set_member_status(tt.root, tt.member_id, "failed")
Expand Down Expand Up @@ -607,7 +688,12 @@ def stop(self) -> None:
stopped_roots: set[Path] = set()
for tt in list(self._children.values()):
status = "stopped" if tt.alive() else "exited"
self._terminate(tt)
if not self._terminate(tt):
log.error(
"curator: teammate %s remained alive during shutdown",
tt.member_id,
)
continue
stopped_roots.add(tt.root)
with contextlib.suppress(Exception):
roster.set_member_status(tt.root, tt.member_id, status)
Expand Down
Loading
Loading