diff --git a/.github/workflows/shell-windows-pr.yml b/.github/workflows/shell-windows-pr.yml new file mode 100644 index 000000000..a8d8e5875 --- /dev/null +++ b/.github/workflows/shell-windows-pr.yml @@ -0,0 +1,52 @@ +name: Shell PowerShell PR contract + +on: + pull_request: + paths: + - ".github/workflows/shell-windows-pr.yml" + - "src/lingtai/adapters/shell*.py" + - "src/lingtai/adapters/windows/**" + - "src/lingtai/tools/bash/**" + - "src/lingtai/tools/registry.py" + - "tests/test_shell_pr1_contract.py" + - "tests/test_shell_windows_native.py" + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-windows-shell: + name: Native Windows / PowerShell 7 + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify PowerShell 7 + shell: pwsh + run: | + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "PowerShell 7 is required" + } + pwsh -NoLogo -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.ToString()' + + - name: Install focused test dependencies + shell: pwsh + run: | + python -m pip install --disable-pip-version-check pytest + python -m pip install --disable-pip-version-check --no-deps -e . + + - name: Run shell PR1 contract + shell: pwsh + env: + PYTHONUTF8: "1" + run: | + python -m pytest -q tests/test_shell_pr1_contract.py tests/test_shell_windows_native.py diff --git a/docs/references/claude-code-guide.md b/docs/references/claude-code-guide.md index 60007e062..e1e071d09 100644 --- a/docs/references/claude-code-guide.md +++ b/docs/references/claude-code-guide.md @@ -131,7 +131,7 @@ CustomAgent(Agent) — host's wrapper (subclass with domain logic) | Tier | What | How added | |------|------|-----------| | **Intrinsics** | Kernel services (mail, system, psyche, soul). Mail provides a disk-backed mailbox: send, check, read, search, delete. Self-send (to own address) creates persistent notes that survive context compaction. System provides runtime inspection (`show`), synchronization (`nap` — timed pause), lifecycle (`refresh` — reload MCP, reset session), self-sleep (`sleep`), and karma/nirvana-gated actions (`lull`, `interrupt`, `suspend`, `cpr`, `nirvana`). Psyche provides pad (`edit`/`load` on `system/pad.md`), context management (`molt` for self-compaction with briefing), and naming (`name`/`set` — set true name once, `nickname` — mutable). `context_forget` is internal only (system-forced wipe/recovery). Covenant is a protected prompt section (no tool access). Capabilities can upgrade intrinsics via `override_intrinsic()`. | Built-in, always present | -| **Capabilities** | Composable capabilities (file [read/write/edit/glob/grep], knowledge, skills, bash, avatar, daemon, mcp, vision, web_search) | Declared at construction via `capabilities=` on Agent | +| **Capabilities** | Composable capabilities (file [read/write/edit/glob/grep], knowledge, skills, shell, avatar, daemon, mcp, vision, web_search) | Declared at construction via `capabilities=` on Agent | | **MCP tools** | Domain tools from external MCP servers | Connected via `Agent.connect_mcp()` using `MCPClient` from `services/mcp.py`, or `add_tool()` in subclass constructors | ### Key Modules @@ -149,7 +149,7 @@ CustomAgent(Agent) — host's wrapper (subclass with domain logic) - **`lingtai.kernel.llm.service`** — `LLMService`. Adapter registry + factory, session registry, one-shot generation gateway, context compaction orchestration. Adapters register via `LLMService.register_adapter()`. Decoupled from config files — uses injected `key_resolver` and `provider_defaults`. - **`src/lingtai/llm/interface_converters.py`** — Bidirectional converters between `ChatInterface` and provider-specific formats (Anthropic, OpenAI, Gemini). - **`tools` (the five mandatory intrinsics)** — Each file exports `get_schema(lang)`, `get_description(lang)`, and `handle(agent, args)`. All five mandatory intrinsics (email, system, psyche, soul, notification) — now consolidated into the top-level `tools/` package and injected via `BaseAgent(intrinsics=lingtai.tools.registry.INTRINSICS)` — have self-contained handler logic — they receive the agent as an explicit parameter. Mail intrinsic provides a disk-backed mailbox with 5 actions: `send` (fire-and-forget with optional `delay` in seconds — all sends go through outbox → mailman thread → sent pipeline), `check` (list inbox with unread flags), `read` (by ID, non-destructive), `search` (regex), `delete`. Every send writes to `mailbox/outbox/`, spawns a daemon `_mailman` thread that sleeps for the delay, dispatches (filesystem write or self-send), then moves to `mailbox/sent/` with `sent_at` and `status`. Returns `{"status": "sent", "to": addr, "delay": N}` — the agent doesn't know dispatch outcome. `outbox/` (transient) and `sent/` (audit trail) are not exposed to the agent. Messages persist in `mailbox/inbox/{uuid}/message.json` — delivery is a filesystem write to the recipient's inbox directory. System intrinsic provides runtime inspection (`show`), synchronization (`nap` — timed pause, wakes on mail), lifecycle (`refresh` — reload MCP, reset session), self-sleep (`sleep`), and karma/nirvana-gated actions: `lull` (put other to sleep), `interrupt` (cancel other's turn), `suspend` (force other to SUSPENDED), `cpr` (resuscitate SUSPENDED agent), `nirvana` (permanently destroy). Psyche intrinsic provides pad (`edit`/`load` on `system/pad.md`), context management (`molt` for self-compaction with briefing), and naming (`name`/`set` — set true name once, `nickname` — mutable). `context_forget` is internal only (called by system-forced recovery). Covenant is injected at construction as a protected prompt section (no tool access). -- **`src/lingtai/tools/`** — Built-in capability modules export `setup(agent, **kwargs)`. The core default floor is read, write, edit, glob, grep (file I/O — also available as the `"file"` group), knowledge, skills, bash, avatar, daemon, and mcp; optional registry entries include vision and web_search. Durable private knowledge lives in the `knowledge` capability; the skill catalog lives in `skills`. Avatar (分身) spawns `Agent` peers with `name` and `type`; `type="deep"` copies character, pad, and knowledge, while the default shallow spawn starts from `init.json` only. Reasoning is sent as the first message. +- **`src/lingtai/tools/`** — Built-in capability modules export `setup(agent, **kwargs)`. The core default floor is read, write, edit, glob, grep (file I/O — also available as the `"file"` group), knowledge, skills, shell, avatar, daemon, and mcp; optional registry entries include vision and web_search. Durable private knowledge lives in the `knowledge` capability; the skill catalog lives in `skills`. Avatar (分身) spawns `Agent` peers with `name` and `type`; `type="deep"` copies character, pad, and knowledge, while the default shallow spawn starts from `init.json` only. Reasoning is sent as the first message. - **`src/lingtai/tools/daemon/__init__.py`** — Daemon (神識) subagent capability. `DaemonManager` dispatches ephemeral `ChatSession` tool loops in threads via `ThreadPoolExecutor`. Each emanation gets a curated tool surface (parent capability handlers plus task-scoped MCP registrations, minus blacklist). Results persist in per-run daemon folders and terminal completion/failure is surfaced as compact system notifications. Actions: emanate (分), list (观), ask (问), check (察), reclaim (收). Configurable: `max_emanations`, `max_turns`, `timeout`. - **`src/lingtai/cli.py`** — CLI entrypoint (`lingtai-agent run `). Reads `init.json` manifest, creates LLMService + Agent, starts the agent loop. Also `lingtai cpr ` for resuscitating suspended agents. - **`src/lingtai/network.py`** — `AgentNetwork` class. Three-layer topology discovery: avatar edges (from `delegates/ledger.jsonl`), contact edges (from `mailbox/contacts.json`), mail edges (from `mailbox/inbox/` + `mailbox/sent/`). Returns `AgentNode` and edge objects. @@ -176,7 +176,7 @@ CustomAgent(Agent) — host's wrapper (subclass with domain logic) | `grep` | `capabilities=["grep"]` | Search file contents by regex via FileIOService | | `psyche` | intrinsic | Identity, pad, context molt, and naming. | | `knowledge` | `capabilities=["knowledge"]` | Private durable knowledge across molts. Former durable-memory names `library` and `codex` are removed. | -| `bash` | `capabilities={"bash": {"policy_file": "p.json"}}` or `{"bash": {"yolo": True}}` | Shell command execution with policy | +| `shell` | `capabilities={"shell": {"policy_file": "p.json"}}` or `{"shell": {"yolo": True}}` | Shell command execution with the active dialect | | `avatar` | `capabilities=["avatar"]` | Spawn avatar (分身) as fully independent detached process. Two params: `name` (required, true name) and `type` ('shallow' default — copies init.json only, 投胎; 'deep' — copies character/pad/knowledge too, 二重身). Each avatar gets its own working dir + `lingtai-agent run` process. Survives parent death. Reasoning = starting prompt in init.json. | | `email` | `capabilities=["email"]` | Upgrades mail intrinsic with reply/reply_all, CC/BCC, contacts, sent/archive folders, archive (inbox→archive), delete (inbox/archive), delayed send (`delay`), private mode, and scheduled recurring sends (`schedule` sub-object with create/cancel/list). Routes dispatch through outbox → `_mailman(skip_sent=True)`. Sets `_mailbox_name="email box"`, `_mailbox_tool="email"`. Delegates inbox ops to mail intrinsic helpers. | | `vision` | `capabilities=["vision"]` or `{"vision": {"vision_service": svc}}` | Image understanding (LLM multimodal or dedicated VisionService) | @@ -195,11 +195,11 @@ CustomAgent(Agent) — host's wrapper (subclass with domain logic) # Layer 2: Agent with capabilities agent = Agent( service=svc, agent_name="alice", working_dir="/agents/alice", - capabilities=["file", "vision", "web_search", "bash"], # "file" expands to read/write/edit/glob/grep + capabilities=["file", "vision", "web_search", "shell"], # "file" expands to read/write/edit/glob/grep ) agent = Agent( service=svc, agent_name="bob", working_dir="/agents/bob", - capabilities={"bash": {"policy_file": "p.json"}}, # dict form (with kwargs) + capabilities={"shell": {"policy_file": "p.json"}}, # dict form (with kwargs) ) # Layer 3: Custom agent subclass diff --git a/src/lingtai/ANATOMY.md b/src/lingtai/ANATOMY.md index 1f0f621c4..66ebff1f6 100644 --- a/src/lingtai/ANATOMY.md +++ b/src/lingtai/ANATOMY.md @@ -5,7 +5,9 @@ related_files: - src/lingtai/__main__.py - src/lingtai/agent.py - src/lingtai/adapters/posix/ANATOMY.md - - src/lingtai/adapters/bash.py + - src/lingtai/adapters/shell.py + - src/lingtai/adapters/shell_process.py + - src/lingtai/adapters/shell_state_lock.py - src/lingtai/adapters/refresh_watcher.py - src/lingtai/adapters/lifecycle_clock.py - src/lingtai/auth/ANATOMY.md diff --git a/src/lingtai/__init__.py b/src/lingtai/__init__.py index ba4f18c9c..668765a31 100644 --- a/src/lingtai/__init__.py +++ b/src/lingtai/__init__.py @@ -27,6 +27,9 @@ "UnknownToolError": ("lingtai.kernel.types", "UnknownToolError"), # Tools "setup_capability": ("lingtai.tools.registry", "setup_capability"), + "ShellManager": ("lingtai.tools.bash", "ShellManager"), + # Retained Python import compatibility; the registered capability/tool is + # only ``shell``. "BashManager": ("lingtai.tools.bash", "BashManager"), "AvatarManager": ("lingtai.tools.avatar", "AvatarManager"), "EmailManager": ("lingtai.tools.email", "EmailManager"), @@ -74,7 +77,7 @@ from lingtai.kernel.state import AgentState from lingtai.kernel.types import UnknownToolError from lingtai.tools.avatar import AvatarManager - from lingtai.tools.bash import BashManager + from lingtai.tools.bash import BashManager, ShellManager from lingtai.tools.email import EmailManager from lingtai.tools.registry import setup_capability @@ -138,6 +141,7 @@ def __dir__() -> list[str]: "UnknownToolError", # Capabilities "setup_capability", + "ShellManager", "BashManager", "AvatarManager", "EmailManager", diff --git a/src/lingtai/adapters/bash.py b/src/lingtai/adapters/bash.py index 64f2e5544..09205ebc0 100644 --- a/src/lingtai/adapters/bash.py +++ b/src/lingtai/adapters/bash.py @@ -1,4 +1,4 @@ -"""Composition selector for Bash shell-language adapters.""" +"""Compatibility selector for the retained internal Bash implementation.""" from __future__ import annotations import os diff --git a/src/lingtai/adapters/bash_process.py b/src/lingtai/adapters/bash_process.py index 145818c70..497d39829 100644 --- a/src/lingtai/adapters/bash_process.py +++ b/src/lingtai/adapters/bash_process.py @@ -1,13 +1,9 @@ -"""Fail-loud Bash asynchronous process composition selector.""" +"""Compatibility selector for the retained internal Bash package.""" from __future__ import annotations -import os - from lingtai.tools.bash._async_process import BashAsyncProcessPort -from .posix.bash_process import PosixBashAsyncProcessAdapter +from .shell_process import select_shell_async_process def select_bash_async_process() -> BashAsyncProcessPort: - if os.name == "posix": - return PosixBashAsyncProcessAdapter() - raise NotImplementedError(f"Bash async process supervision is unsupported on {os.name!r}") + return select_shell_async_process() diff --git a/src/lingtai/adapters/bash_state_lock.py b/src/lingtai/adapters/bash_state_lock.py index 72ffe3b1e..394006bd4 100644 --- a/src/lingtai/adapters/bash_state_lock.py +++ b/src/lingtai/adapters/bash_state_lock.py @@ -1,13 +1,9 @@ -"""Fail-loud Bash async state-lock composition selector.""" +"""Compatibility selector for the retained internal Bash package.""" from __future__ import annotations -import os - from lingtai.tools.bash._state_lock import BashStateLockPort -from .posix.bash_state_lock import PosixBashStateLockAdapter +from .shell_state_lock import select_shell_state_lock def select_bash_state_lock() -> BashStateLockPort: - if os.name == "posix": - return PosixBashStateLockAdapter() - raise NotImplementedError(f"Bash async state locking is unsupported on {os.name!r}") + return select_shell_state_lock() diff --git a/src/lingtai/adapters/shell.py b/src/lingtai/adapters/shell.py new file mode 100644 index 000000000..64d54ec30 --- /dev/null +++ b/src/lingtai/adapters/shell.py @@ -0,0 +1,59 @@ +"""Composition selector for the canonical ``shell`` capability. + +The implementation package remains ``lingtai.tools.bash`` for PR1 durable and +packaging compatibility. Platform identity belongs at this outer selector. +""" +from __future__ import annotations + +import os +import platform + +from lingtai.tools.bash._shell_dialect import ShellDialect +from .posix.bash import PosixBashDialect + + +def _one_line(value: object) -> str: + """Normalize host metadata for one-line agent-facing tool prose.""" + return " ".join(str(value or "").split()) + + +def describe_host_os() -> str: + """Return a truthful human-readable host OS name and version.""" + system = _one_line(platform.system()) + if system == "Darwin": + version = _one_line(platform.mac_ver()[0]) + if version: + return f"macOS {version}" + kernel = _one_line(platform.release()) + return f"macOS (Darwin kernel {kernel})" if kernel else "macOS" + + if system == "Linux": + try: + os_release = platform.freedesktop_os_release() + except OSError: + os_release = {} + pretty_name = _one_line(os_release.get("PRETTY_NAME")) + if pretty_name: + return pretty_name + name = _one_line(os_release.get("NAME")) or "Linux" + version = _one_line(os_release.get("VERSION_ID") or os_release.get("VERSION")) + return f"{name} {version}".strip() + + if system == "Windows": + release = _one_line(platform.release()) + version = _one_line(platform.version()) + label = f"Windows {release}".strip() + return f"{label} ({version})" if version and version != release else label + + system = system or _one_line(os.name) or "unknown" + release = _one_line(platform.release()) + return f"{system} {release}".strip() + + +def select_shell_dialect() -> ShellDialect: + if os.name == "posix": + return PosixBashDialect() + if os.name == "nt": + from .windows.powershell import PowerShellDialect + return PowerShellDialect() + raise NotImplementedError(f"shell dialect is unsupported on platform {os.name!r}") diff --git a/src/lingtai/adapters/shell_process.py b/src/lingtai/adapters/shell_process.py new file mode 100644 index 000000000..7e2844735 --- /dev/null +++ b/src/lingtai/adapters/shell_process.py @@ -0,0 +1,18 @@ +"""Composition selector for the canonical shell async process Port.""" +from __future__ import annotations + +import os + +from lingtai.tools.bash._async_process import BashAsyncProcessPort + + +def select_shell_async_process() -> BashAsyncProcessPort: + if os.name == "posix": + # Keep the POSIX-only process implementation behind platform selection; + # its transitive imports include selectors that are not Windows-safe. + from .posix.bash_process import PosixBashAsyncProcessAdapter + return PosixBashAsyncProcessAdapter() + if os.name == "nt": + from .windows.powershell_process import WindowsShellAsyncProcessAdapter + return WindowsShellAsyncProcessAdapter() + raise NotImplementedError(f"shell async process supervision is unsupported on {os.name!r}") diff --git a/src/lingtai/adapters/shell_state_lock.py b/src/lingtai/adapters/shell_state_lock.py new file mode 100644 index 000000000..c76c63e48 --- /dev/null +++ b/src/lingtai/adapters/shell_state_lock.py @@ -0,0 +1,18 @@ +"""Composition selector for the canonical shell state-lock Port.""" +from __future__ import annotations + +import os + +from lingtai.tools.bash._state_lock import BashStateLockPort + + +def select_shell_state_lock() -> BashStateLockPort: + if os.name == "posix": + # Keep the POSIX-only fcntl import behind platform selection so loading + # the portable selector on Windows reaches its native adapter instead. + from .posix.bash_state_lock import PosixBashStateLockAdapter + return PosixBashStateLockAdapter() + if os.name == "nt": + from .windows.powershell_state_lock import WindowsShellStateLockAdapter + return WindowsShellStateLockAdapter() + raise NotImplementedError(f"shell async state locking is unsupported on {os.name!r}") diff --git a/src/lingtai/adapters/windows/__init__.py b/src/lingtai/adapters/windows/__init__.py new file mode 100644 index 000000000..acadd88e7 --- /dev/null +++ b/src/lingtai/adapters/windows/__init__.py @@ -0,0 +1 @@ +"""Native Windows adapters for the canonical shell capability.""" diff --git a/src/lingtai/adapters/windows/powershell.py b/src/lingtai/adapters/windows/powershell.py new file mode 100644 index 000000000..312be3088 --- /dev/null +++ b/src/lingtai/adapters/windows/powershell.py @@ -0,0 +1,324 @@ +"""PowerShell 7 dialect adapter for the shell language Port. + +This adapter intentionally does not reuse the POSIX extractor. It recognizes +PowerShell statement/pipeline boundaries and recursively inspects command +substitutions and script blocks. Unsupported dynamic syntax is represented by +a sentinel command so a configured allowlist/denylist fails closed; trusted +(yolo) execution can still pass the original script to pwsh. +""" +from __future__ import annotations + +import re +import shutil + +from lingtai.tools.bash._shell_dialect import ShellDialect, ShellInvocation + +_UNSUPPORTED = "__powershell_unsupported__" +_CONTROL_WORDS = { + "begin", "break", "catch", "class", "continue", "data", "do", "else", + "end", "finally", "for", "foreach", "function", "if", "param", "process", + "return", "switch", "throw", "trap", "try", "until", "using", "while", +} +_ASSIGNMENT_RE = re.compile(r"^(?:\$[A-Za-z_][\w:]*|[A-Za-z_][\w-]*)$") +_TOKEN_RE = re.compile( + r"(?:'[^']*(?:''[^']*)*'|\"(?:`.|[^\"])*\"|&(?=\s|$)|\.(?=\s|$)|[^\s|;&(){}]+)" +) + + +def _balanced_inner(script: str, start: int, opener: str, closer: str) -> tuple[str, int] | None: + """Return a balanced region, respecting PowerShell quote/backtick rules.""" + depth = 1 + quote: str | None = None + escaped = False + i = start + 1 + while i < len(script): + char = script[i] + if quote == "'": + if char == "'": + if i + 1 < len(script) and script[i + 1] == "'": + i += 2 + continue + quote = None + i += 1 + continue + if quote == '"': + if escaped: + escaped = False + elif char == "`": + escaped = True + elif char == '"': + quote = None + i += 1 + continue + if char in {"'", '"'}: + quote = char + elif char == opener: + depth += 1 + elif char == closer: + depth -= 1 + if depth == 0: + return script[start + 1 : i], i + 1 + i += 1 + return None + + +def _split_statements(script: str) -> tuple[list[str], bool]: + """Split top-level PowerShell statements and report malformed quoting.""" + pieces: list[str] = [] + begin = 0 + i = 0 + quote: str | None = None + escaped = False + paren_depth = 0 + while i < len(script): + char = script[i] + if quote == "'": + if char == "'": + if i + 1 < len(script) and script[i + 1] == "'": + i += 2 + continue + quote = None + i += 1 + continue + if quote == '"': + if escaped: + escaped = False + elif char == "`": + escaped = True + elif char == '"': + quote = None + i += 1 + continue + if char in {"'", '"'}: + quote = char + i += 1 + continue + if char == "(": + paren_depth += 1 + elif char == ")": + if paren_depth == 0: + return pieces, False + paren_depth -= 1 + if char in "|;\r\n" and paren_depth == 0: + pieces.append(script[begin:i]) + if char == "|" and i + 1 < len(script) and script[i + 1] in "|&": + i += 1 + elif char == "&" and i + 1 < len(script) and script[i + 1] == "&": + i += 1 + begin = i + 1 + elif char == "&" and i + 1 < len(script) and script[i + 1] == "&" and paren_depth == 0: + pieces.append(script[begin:i]) + i += 1 + begin = i + 1 + i += 1 + pieces.append(script[begin:]) + return pieces, quote is None and paren_depth == 0 + + +def _is_quoted_at(script: str, index: int) -> bool: + """Return whether ``index`` is inside a PowerShell quoted string.""" + quote: str | None = None + escaped = False + i = 0 + while i < index: + char = script[i] + if quote == "'": + if char == "'": + if i + 1 < index and script[i + 1] == "'": + i += 2 + continue + quote = None + i += 1 + continue + if quote == '"': + if escaped: + escaped = False + elif char == "`": + escaped = True + elif char == '"': + quote = None + i += 1 + continue + if char in {"'", '"'}: + quote = char + i += 1 + return quote is not None + + +def _commands(script: str) -> tuple[str, ...]: + pieces, well_formed = _split_statements(script) + if not well_formed: + return (_UNSUPPORTED,) + result: list[str] = [] + for piece in pieces: + text = piece.strip() + if not text: + continue + # Recursively inspect substitutions and script blocks before removing + # them from the outer statement. Dynamic invocation cannot be proved. + remainder: list[str] = [] + nested: list[str] = [] + i = 0 + while i < len(text): + if text.startswith("$(", i): + region = _balanced_inner(text, i + 1, "(", ")") + if region is None: + result.append(_UNSUPPORTED) + break + nested.extend(_commands(region[0])) + i = region[1] + continue + if text[i] == "{" or (text[i] == "@" and i + 1 < len(text) and text[i + 1] == "{"): + opener_at = i if text[i] == "{" else i + 1 + region = _balanced_inner(text, opener_at, "{", "}") + if region is None: + result.append(_UNSUPPORTED) + break + nested.extend(_commands(region[0])) + i = region[1] + continue + if text[i] == "(" and not _is_quoted_at(text, i): + region = _balanced_inner(text, i, "(", ")") + if region is None or not region[0].strip(): + result.append(_UNSUPPORTED) + break + nested.extend(_commands(region[0])) + i = region[1] + continue + if text[i] == ")" and not _is_quoted_at(text, i): + result.append(_UNSUPPORTED) + break + remainder.append(text[i]) + i += 1 + else: + outer = "".join(remainder).strip() + tokens = _TOKEN_RE.findall(outer) + if not tokens: + result.extend(nested) + continue + # A call/dot-source operator is syntax, not the command being + # invoked. Only an unquoted literal or a single-quoted literal is + # statically knowable; variables, expandable strings, and array or + # subexpression targets must fail closed under policy enforcement. + index = 0 + if tokens[0] in {"&", "."}: + if len(tokens) < 2: + result.append(_UNSUPPORTED) + result.extend(nested) + continue + target = tokens[1] + if target.startswith(("$", "@", '"', "`")): + result.append(_UNSUPPORTED) + result.extend(nested) + continue + if target.startswith("'") and not target.endswith("'"): + result.append(_UNSUPPORTED) + result.extend(nested) + continue + index = 2 + first = target[1:-1].replace("''", "'") if target.startswith("'") else target + else: + first = tokens[0].strip("'\"") + # Skip assignments and PowerShell control syntax. A bare control + # statement without a block is unsupported rather than guessed. + while index + 2 < len(tokens) and _ASSIGNMENT_RE.fullmatch(tokens[index]) and tokens[index + 1] == "=": + index += 2 + if index == 0: + if index >= len(tokens): + result.extend(nested) + continue + first = tokens[index].strip("'\"") + if "`" in first: + # PowerShell accepts backticks inside unquoted command names as + # lexical escapes (for example ``Rem`ove-Item``). Decoding every + # valid form requires a real PowerShell lexer; configured policy + # must instead reject the ambiguous identity conservatively. + result.append(_UNSUPPORTED) + result.extend(nested) + continue + if first.casefold() in _CONTROL_WORDS: + result.extend(nested) + continue + if first.startswith("$") or first.startswith("@"): + # A variable/array expression in a script block is data, not a + # command. Dynamic invocation was already rejected at ``& $x``. + result.extend(nested) + continue + result.append(first) + result.extend(nested) + return tuple(result) + + +class PowerShellDialect(ShellDialect): + """PowerShell 7 (``pwsh``) invocation and policy extraction.""" + + def __init__(self, executable: str | None = None) -> None: + self._executable = executable or shutil.which("pwsh") + if not self._executable: + raise FileNotFoundError( + "PowerShell 7 executable 'pwsh' was not found; Windows shell requires pwsh and never falls back to Windows PowerShell 5.1" + ) + + def extract_commands(self, script: str) -> tuple[str, ...]: + return _commands(script) + + def make_invocation(self, script: str) -> ShellInvocation: + # ``pwsh -Command`` otherwise collapses an external program's native + # exit status to PowerShell's generic 0/1 process status. PowerShell + # 7.3+ can expose non-zero native results as a typed ErrorRecord without + # changing command flow. Capture that final-operation type together + # with ``$?`` and ``$LASTEXITCODE`` inside the user's script scope. + # Crucially, the wrapper never resets or rewrites ``$LASTEXITCODE`` + # between user statements, so ordinary PowerShell status checks retain + # their native semantics. + wrapped = ( + "$global:__lingtai_success = $false\n" + "$global:__lingtai_native_exit = 0\n" + "$global:__lingtai_final_native_failure = $false\n" + "$__lingtai_old_native_pref = $PSNativeCommandUseErrorActionPreference\n" + "try {\n" + " $PSNativeCommandUseErrorActionPreference = $true\n" + " & {\n" + f"{script}\n" + # These assignments run in the same runtime scope as the user's + # final pipeline, before the wrapper performs any later command. + " $global:__lingtai_success = $?\n" + " $global:__lingtai_native_exit = [int]$global:LASTEXITCODE\n" + " $global:__lingtai_final_native_failure = (\n" + " (-not $global:__lingtai_success) -and\n" + " ($Error.Count -gt 0) -and\n" + " ($Error[0].FullyQualifiedErrorId -eq 'ProgramExitedWithNonZeroCode')\n" + " )\n" + " }\n" + "} catch {\n" + " $global:__lingtai_success = $false\n" + " $global:__lingtai_native_exit = [int]$global:LASTEXITCODE\n" + " $global:__lingtai_final_native_failure = (\n" + " $_.FullyQualifiedErrorId -eq 'ProgramExitedWithNonZeroCode'\n" + " )\n" + " if (-not $global:__lingtai_final_native_failure) {\n" + " [Console]::Error.WriteLine($_.ToString())\n" + " }\n" + "} finally {\n" + " $PSNativeCommandUseErrorActionPreference = $__lingtai_old_native_pref\n" + "}\n" + "if ($global:__lingtai_success) { exit 0 }\n" + "if ($global:__lingtai_final_native_failure -and " + "$global:__lingtai_native_exit -ne 0) {\n" + " exit $global:__lingtai_native_exit\n" + "}\n" + "exit 1\n" + ) + return ShellInvocation( + script=wrapped, + executable=self._executable, + argv=("-NoLogo", "-NoProfile", "-NonInteractive", "-Command"), + encoding="utf-8", + errors="replace", + ) + + def state_key(self) -> str: + return "powershell" + + +__all__ = ["PowerShellDialect"] diff --git a/src/lingtai/adapters/windows/powershell_process.py b/src/lingtai/adapters/windows/powershell_process.py new file mode 100644 index 000000000..001cafbb5 --- /dev/null +++ b/src/lingtai/adapters/windows/powershell_process.py @@ -0,0 +1,406 @@ +"""Windows process adapter using Job Objects for owned process trees. + +This module is imported only by the Windows composition selector. It does not +use ``killpg``, ``/proc`` or ``ps``. A command process is assigned to a native +Job Object immediately after spawn; cancellation terminates that job and waits +for its active-process count to reach zero before reporting ``group_cancelled``. +""" +from __future__ import annotations + +import functools +import os +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from lingtai.tools.bash._async_process import ( + BashAsyncProcessPort, + ProcessCompletion, + ProcessObservation, + ProcessRef, +) +from lingtai.tools.bash._shell_dialect import ShellInvocation + +_CREATE_NEW_PROCESS_GROUP = 0x00000200 +_CREATE_SUSPENDED = 0x00000004 +_CREATE_NO_WINDOW = 0x08000000 +_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +_PROCESS_SET_QUOTA = 0x0100 +_PROCESS_TERMINATE = 0x0001 +_JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1 +_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + + +@functools.lru_cache(maxsize=1) +def _kernel32(): + """Return kernel32 with explicit 64-bit-safe ctypes signatures.""" + if os.name != "nt": + raise OSError("Windows process adapter requires Windows") + import ctypes + from ctypes import wintypes + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.GetProcessTimes.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ] + kernel.GetProcessTimes.restype = wintypes.BOOL + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.CloseHandle.restype = wintypes.BOOL + kernel.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel.CreateJobObjectW.restype = wintypes.HANDLE + kernel.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD + ] + kernel.SetInformationJobObject.restype = wintypes.BOOL + kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + kernel.AssignProcessToJobObject.restype = wintypes.BOOL + kernel.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel.TerminateJobObject.restype = wintypes.BOOL + kernel.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + kernel.QueryInformationJobObject.restype = wintypes.BOOL + return kernel + + +@functools.lru_cache(maxsize=1) +def _ntdll(): + """Return ntdll with the process-resume signature used after Job assignment.""" + if os.name != "nt": + raise OSError("Windows process adapter requires Windows") + import ctypes + from ctypes import wintypes + + ntdll = ctypes.WinDLL("ntdll", use_last_error=True) + ntdll.NtResumeProcess.argtypes = [wintypes.HANDLE] + ntdll.NtResumeProcess.restype = wintypes.LONG + return ntdll + + +def _last_win_error(message: str) -> OSError: + import ctypes + + error = ctypes.get_last_error() + return OSError(error, f"{message} (WinError {error})") + + +def _set_kill_on_close(job_handle) -> None: + """Make supervisor death close the owned process tree instead of leaking it.""" + import ctypes + from ctypes import wintypes + + class _BasicLimitInformation(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class _IoCounters(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + class _ExtendedLimitInformation(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _BasicLimitInformation), + ("IoInfo", _IoCounters), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + info = _ExtendedLimitInformation() + info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not _kernel32().SetInformationJobObject( + job_handle, + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(info), + ctypes.sizeof(info), + ): + raise _last_win_error("SetInformationJobObject(KILL_ON_JOB_CLOSE) failed") + + +def _filetime_value(filetime) -> int: + return (int(filetime.dwHighDateTime) << 32) | int(filetime.dwLowDateTime) + + +def _identity(pid: int) -> str | None: + """Return a creation-time identity, never a PID-only authority.""" + if os.name != "nt": + return None + import ctypes + from ctypes import wintypes + + kernel = _kernel32() + handle = kernel.OpenProcess( + _PROCESS_QUERY_LIMITED_INFORMATION, False, wintypes.DWORD(pid) + ) + if not handle: + return None + created = wintypes.FILETIME() + exited = wintypes.FILETIME() + kernel_time = wintypes.FILETIME() + user_time = wintypes.FILETIME() + try: + if not kernel.GetProcessTimes( + handle, + ctypes.byref(created), ctypes.byref(exited), + ctypes.byref(kernel_time), ctypes.byref(user_time), + ): + return None + return f"windows:{_filetime_value(created)}" + finally: + kernel.CloseHandle(handle) + + +def _ref(pid: int) -> ProcessRef: + return ProcessRef(pid, _identity(pid)) + + +@dataclass +class _Owned: + process: subprocess.Popen + ref: ProcessRef + job_handle: object + stdout: object | None = None + stderr: object | None = None + + def close(self) -> None: + kernel = _kernel32() + for stream in (self.stdout, self.stderr): + if stream is not None: + stream.close() + if self.job_handle: + kernel.CloseHandle(self.job_handle) + self.job_handle = None + + +def _new_job_for_process(process: subprocess.Popen): + """Create and assign a kill-on-close Job before exposing the owned token.""" + kernel = _kernel32() + job = kernel.CreateJobObjectW(None, None) + if not job: + raise _last_win_error("CreateJobObjectW failed") + try: + _set_kill_on_close(job) + handle = kernel.OpenProcess( + _PROCESS_SET_QUOTA | _PROCESS_TERMINATE | _PROCESS_QUERY_LIMITED_INFORMATION, + False, + process.pid, + ) + if not handle: + raise _last_win_error("OpenProcess failed while assigning the shell Job Object") + try: + if not kernel.AssignProcessToJobObject(job, handle): + raise _last_win_error("AssignProcessToJobObject failed") + finally: + kernel.CloseHandle(handle) + return job + except Exception: + kernel.CloseHandle(job) + raise + + +def _resume_suspended_process(process: subprocess.Popen) -> None: + """Resume a CREATE_SUSPENDED Popen through its retained process handle. + + CPython closes the primary thread handle inside ``Popen`` and therefore has + no ``_thread`` attribute to pass to ``ResumeThread``. ``NtResumeProcess`` + operates on the retained process handle after Job assignment and closes the + spawn-to-assignment child-tree race without depending on that missing handle. + """ + handle = getattr(process, "_handle", None) + if handle is None: + raise OSError("Popen did not retain the suspended process handle") + status = int(_ntdll().NtResumeProcess(handle)) + if status < 0: + raise OSError( + f"NtResumeProcess failed after assigning the shell Job Object " + f"(NTSTATUS 0x{status & 0xFFFFFFFF:08x})" + ) + + +def _active_job_processes(job_handle) -> int: + """Return the Job's exact active-process count. + + Natural Job completion does not reliably signal the Job handle on every + supported Windows runtime. Basic accounting is the documented ownership + source of truth for both ordinary completion and cancellation. + """ + import ctypes + from ctypes import wintypes + + class _BasicAccountingInformation(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_longlong), + ("TotalKernelTime", ctypes.c_longlong), + ("ThisPeriodTotalUserTime", ctypes.c_longlong), + ("ThisPeriodTotalKernelTime", ctypes.c_longlong), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + info = _BasicAccountingInformation() + if not _kernel32().QueryInformationJobObject( + job_handle, + _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, + ctypes.byref(info), + ctypes.sizeof(info), + None, + ): + raise _last_win_error("QueryInformationJobObject(accounting) failed") + return int(info.ActiveProcesses) + + +def _wait_job(job_handle, timeout_seconds: float) -> bool: + deadline = time.monotonic() + max(0.0, timeout_seconds) + while True: + if _active_job_processes(job_handle) == 0: + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(0.05, remaining)) + + +class WindowsShellAsyncProcessAdapter(BashAsyncProcessPort): + """Native Windows implementation of the shared async process Port.""" + + def launch_supervisor(self, job_dir: Path, start_token: str): + if os.name != "nt": + raise OSError("Windows shell process adapter requires Windows") + import sys + process = subprocess.Popen( + [sys.executable, "-m", "lingtai.tools.bash._async_supervisor", str(job_dir), start_token], + creationflags=_CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + close_fds=True, + ) + ref = _ref(process.pid) + return ref, process + + def identify_current_process(self): + return _ref(os.getpid()) + + def spawn(self, invocation: ShellInvocation, cwd: str, stdout_path: Path, stderr_path: Path): + if os.name != "nt": + raise OSError("Windows shell process adapter requires Windows") + stdout = stdout_path.open("xb") + stderr = stderr_path.open("xb") + process = None + job = None + try: + args, kwargs = invocation.process_args() + kwargs.update({ + "stdin": subprocess.DEVNULL, + "stdout": stdout, + "stderr": stderr, + "cwd": cwd, + # Keep the child suspended until it is assigned to the Job + # Object; this closes the spawn-to-assignment tree-ownership race. + "creationflags": _CREATE_NEW_PROCESS_GROUP | _CREATE_SUSPENDED | _CREATE_NO_WINDOW, + "close_fds": True, + }) + process = subprocess.Popen(args, **kwargs) + job = _new_job_for_process(process) + _resume_suspended_process(process) + ref = _ref(process.pid) + return ref, _Owned(process, ref, job, stdout, stderr) + except Exception: + if job is not None: + try: + _kernel32().TerminateJobObject(job, 1) + finally: + _kernel32().CloseHandle(job) + if process is not None: + try: + process.kill() + process.wait(timeout=2) + except (OSError, subprocess.SubprocessError): + pass + stdout.close() + stderr.close() + raise + + def observe(self, process: ProcessRef): + if os.name != "nt": + return ProcessObservation("gone") + observed = _identity(process.public_id) + if observed is None: + return ProcessObservation("gone") + if process.incarnation is None: + return ProcessObservation("unknown") + return ProcessObservation("same" if observed == process.incarnation else "changed") + + def wait_supervisor(self, owned) -> int: + return owned.wait() if hasattr(owned, "wait") else owned.returncode + + def wait(self, owned: _Owned, cancellation_requested: Callable[[], bool]): + process = owned.process + + def cancel_owned_tree() -> ProcessCompletion | None: + if not cancellation_requested(): + return None + terminated = bool(_kernel32().TerminateJobObject(owned.job_handle, 1)) + if not terminated: + return ProcessCompletion(process.wait(), "unconfirmed") + # ActiveProcesses reaches zero only after every assigned child exits, + # which is the full-tree ownership proof. + if not _wait_job(owned.job_handle, 5.0): + return ProcessCompletion(process.wait(), "unconfirmed") + return ProcessCompletion(process.wait(), "group_cancelled") + + try: + while True: + cancellation = cancel_owned_tree() + if cancellation is not None: + return cancellation + if process.poll() is None: + time.sleep(0.05) + continue + + # The root Popen may have exited while a descendant is still in + # the Job. Keep the Job handle owned and poll it in short + # intervals so a later durable cancel request still gets the + # confirmed TerminateJobObject/group_cancelled path. Basic Job + # accounting is used because natural completion did not reliably + # signal the Job handle in native CI. + code = process.wait() + while not _wait_job(owned.job_handle, 0.05): + cancellation = cancel_owned_tree() + if cancellation is not None: + return cancellation + return ProcessCompletion(code) + finally: + owned.close() + + +__all__ = ["WindowsShellAsyncProcessAdapter"] diff --git a/src/lingtai/adapters/windows/powershell_state_lock.py b/src/lingtai/adapters/windows/powershell_state_lock.py new file mode 100644 index 000000000..9cc736716 --- /dev/null +++ b/src/lingtai/adapters/windows/powershell_state_lock.py @@ -0,0 +1,48 @@ +"""Native cross-process state lock for Windows shell jobs.""" +from __future__ import annotations + +import contextlib +import time +from pathlib import Path + + +class WindowsShellStateLockAdapter: + """Byte-range lock backed by Windows ``msvcrt.locking``. + + The lock file is shared by the manager and detached supervisor processes; + unlike ``threading.Lock`` this serializes state transitions across process + boundaries. Atomic state replacement remains owned by the shared policy. + """ + + @contextlib.contextmanager + def exclusive(self, job_dir: Path): + if __import__("os").name != "nt": + raise OSError("Windows shell state lock requires Windows") + import msvcrt + + lock_path = job_dir / ".state.lock" + handle = open(lock_path, "a+b") + try: + handle.seek(0) + if handle.tell() == 0 and lock_path.stat().st_size == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + # LK_NBLCK is explicit and retryable, avoiding a hidden indefinite + # CRT timeout while preserving cross-process exclusion. + while True: + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + break + except OSError: + time.sleep(0.01) + yield + finally: + try: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + finally: + handle.close() + + +__all__ = ["WindowsShellStateLockAdapter"] diff --git a/src/lingtai/agent.py b/src/lingtai/agent.py index f2996eed2..a1734a415 100644 --- a/src/lingtai/agent.py +++ b/src/lingtai/agent.py @@ -397,7 +397,10 @@ def install_from(pkg, subdir: str) -> None: continue src = entry / "manual" if src.is_dir(): - shutil.copytree(src, intrinsic_dir / subdir / entry.name) + # The retained implementation directory is ``bash``; its + # agent-facing manual is installed under canonical ``shell``. + destination_name = "shell" if entry.name == "bash" else entry.name + shutil.copytree(src, intrinsic_dir / subdir / destination_name) def install_skills_from(pkg, subdir: str) -> None: """Install standalone skill bundles (no companion code, no manual/ wrapper). @@ -1160,11 +1163,13 @@ def stop(self, timeout: float = 5.0) -> None: def has_capability(self, name: str) -> bool: """Check if a capability is registered.""" - return name in self._capability_managers + from lingtai.tools.registry import canonical_capability_name + return canonical_capability_name(name) in self._capability_managers def get_capability(self, name: str) -> Any: - """Return the manager instance for a registered capability, or None.""" - return self._capability_managers.get(name) + """Return a capability manager, accepting the retained ``bash`` alias.""" + from lingtai.tools.registry import canonical_capability_name + return self._capability_managers.get(canonical_capability_name(name)) # ------------------------------------------------------------------ # Deep refresh — full reconstruct from init.json diff --git a/src/lingtai/intrinsic_skills/psyche-manual/SKILL.md b/src/lingtai/intrinsic_skills/psyche-manual/SKILL.md index 91c14e6dd..258695256 100644 --- a/src/lingtai/intrinsic_skills/psyche-manual/SKILL.md +++ b/src/lingtai/intrinsic_skills/psyche-manual/SKILL.md @@ -221,7 +221,7 @@ If you wake up after a *system-performed* molt (triggered by karma, `.clear`, or 2. `email(check)` — see what arrived while you were down 3. Check `knowledge/session-journal/KNOWLEDGE.md` — your session history index 4. `skills(action="info")` — confirm which skills you have -5. `bash({"command": "tail -n 200 logs/events.jsonl | grep ..."})` — surgical reads if needed +5. `shell({"command": "tail -n 200 logs/events.jsonl | grep ..."})` — surgical reads if needed Reconstruct your situation from these sources. diff --git a/src/lingtai/intrinsic_skills/soul-manual/SKILL.md b/src/lingtai/intrinsic_skills/soul-manual/SKILL.md index d0783630e..8b3f0680f 100644 --- a/src/lingtai/intrinsic_skills/soul-manual/SKILL.md +++ b/src/lingtai/intrinsic_skills/soul-manual/SKILL.md @@ -125,7 +125,7 @@ switch. is not set. You can also run `soul(action='config', ...)` and read `soul_flow_enabled` in the result. - **Check the env from a shell:** - `bash({"command": "printenv LINGTAI_SOUL_FLOW_ENABLED"})` — empty output + `shell({"command": "printenv LINGTAI_SOUL_FLOW_ENABLED"})` — empty output means unset (disabled). - **Enabled but no fires?** Fires only happen while you are IDLE and only after `delay_seconds` elapses. Confirm `delay_seconds` is a small, sane value and diff --git a/src/lingtai/intrinsic_skills/system-manual/SKILL.md b/src/lingtai/intrinsic_skills/system-manual/SKILL.md index 7b72e7b44..4846dc491 100644 --- a/src/lingtai/intrinsic_skills/system-manual/SKILL.md +++ b/src/lingtai/intrinsic_skills/system-manual/SKILL.md @@ -124,7 +124,7 @@ selects that topic. | Authoring/publishing skills or changing skill catalog behavior | `skills-manual` | | Knowledge-entry layout and private durable memory | `knowledge-manual` | | MCP registration/activation/addon ownership | `mcp-manual` | -| Bash/cron/host scheduling details | `bash-manual` | +| Bash/cron/host scheduling details | `shell-manual` | | Daemon lifecycle/inspection/debugging | `daemon-manual` | | Avatar spawning/management/escalation | `avatar-manual` | | Kernel architecture/code truth | `lingtai-kernel-anatomy`, then cited code | diff --git a/src/lingtai/intrinsic_skills/system-manual/reference/procedures-manual/SKILL.md b/src/lingtai/intrinsic_skills/system-manual/reference/procedures-manual/SKILL.md index acf597656..74f77b41b 100644 --- a/src/lingtai/intrinsic_skills/system-manual/reference/procedures-manual/SKILL.md +++ b/src/lingtai/intrinsic_skills/system-manual/reference/procedures-manual/SKILL.md @@ -152,7 +152,7 @@ standing exceptions. Choose the smallest durable body: -- Use bash for deterministic local work. +- Use shell for deterministic local work. - Use daemon for noisy, isolated, disposable exploration, batch analysis, and long-context branches that would otherwise burden the parent. - Use avatar for persistent specialization or recurring collaboration. @@ -218,7 +218,7 @@ Tool results may carry `_advisory.type == "duplicate_tool_call"` when the same semantic tool call repeats more than the free-pass threshold. This is advisory-only: the tool already ran and the kernel did not block it. Treat it as a pause point. If the repeat is intentional, continue; otherwise switch to the -relevant manual (`bash-manual`, `daemon-manual`, `email-manual`) and use the +relevant manual (`shell-manual`, `daemon-manual`, `email-manual`) and use the recommended pattern: wait for completion notifications, back off, set one future reminder, centralize polling, or yield/idle rather than immediately repeating the same call. @@ -244,7 +244,7 @@ a default wait loop. If waiting for a human or peer, ensure the current state is in pad/knowledge and then sleep or stop the turn. **Idle care for unverified long-running work.** Before entering idle, if you have -launched any async/long-running child — a backgrounded `bash(async=true)` agent +launched any async/long-running child — a backgrounded `shell(async=true)` agent CLI, a daemon emanation, a scheduled job, a PR/CI run — whose health you have not just verified, do **not** hand yourself entirely to its completion/IDLE notification. Arm at least one self-wake (a `.notification/cron.json` reminder or @@ -253,7 +253,7 @@ fixed interval. On wake, health-check before assuming progress: log growing, PID/child/daemon events alive, output file/worktree advancing, not stuck on an interactive prompt or a provider/model error. If there is no progress, act — cancel/downgrade/switch path and report to the human — rather than waiting -indefinitely. Mechanics live in `bash-manual` (async + reminders) and +indefinitely. Mechanics live in `shell-manual` (async + reminders) and `daemon-manual` → `reference/inspection/SKILL.md` (daemon health checks). Use `reference/substrate-manual/SKILL.md` for lifecycle semantics. Use forceful karma @@ -286,7 +286,7 @@ mechanics here. For the checklist, templates, and summary rules, go to | Daemon inspection/debugging | `daemon-manual` | | Skill authoring/publishing | `skills-manual` | | Knowledge entries | `knowledge-manual` | -| Shell commands, cron, host scheduling | `bash-manual` | +| Shell commands, cron, host scheduling | `shell-manual` | | Querying LingTai runtime logs / SQLite log sidecar | `system-manual` → `reference/sqlite-log-query/SKILL.md` | | Kernel architecture / breaking changes | `lingtai-kernel-anatomy` | | TUI / portal code navigation | `lingtai-tui-anatomy` | diff --git a/src/lingtai/kernel/base_agent/tools.py b/src/lingtai/kernel/base_agent/tools.py index 0c4c73708..1d4146902 100644 --- a/src/lingtai/kernel/base_agent/tools.py +++ b/src/lingtai/kernel/base_agent/tools.py @@ -35,6 +35,10 @@ def _dispatch_tool(agent, tc) -> dict: return agent._intrinsics[tc.name](args) elif tc.name in agent._tool_handlers: return agent._tool_handlers[tc.name](tc.args or {}) + elif tc.name == "bash" and "shell" in agent._tool_handlers: + # One-way rolling compatibility for historical/pending calls. Do not + # register a second schema or expose ``bash`` in provider tools. + return agent._tool_handlers["shell"](tc.args or {}) else: raise UnknownToolError(tc.name) diff --git a/src/lingtai/kernel/llm/interface.py b/src/lingtai/kernel/llm/interface.py index 406691637..947ac753d 100644 --- a/src/lingtai/kernel/llm/interface.py +++ b/src/lingtai/kernel/llm/interface.py @@ -82,23 +82,23 @@ def _tool_call_context(tool_call: "ToolCallBlock | None") -> str: return "" lines = [f"Tool call id: {tool_call.id}"] args = tool_call.args if isinstance(tool_call.args, dict) else {} - if tool_call.name == "bash": + if tool_call.name in {"shell", "bash"}: action = args.get("action", "run") - lines.append(f"bash action: {action}") + lines.append(f"shell action: {action}") if "working_dir" in args: - lines.append(f"bash working_dir: {args.get('working_dir')}") + lines.append(f"shell working_dir: {args.get('working_dir')}") if "timeout" in args: - lines.append(f"bash timeout: {args.get('timeout')}") + lines.append(f"shell timeout: {args.get('timeout')}") if "async" in args: - lines.append(f"bash async: {args.get('async')}") + lines.append(f"shell async: {args.get('async')}") if "job_id" in args: - lines.append(f"bash job_id: {args.get('job_id')}") + lines.append(f"shell job_id: {args.get('job_id')}") command = args.get("command") if isinstance(command, str) and command: preview = command.replace("\n", "\\n") if len(preview) > 240: preview = preview[:240] + "..." - lines.append(f"bash command preview: {preview}") + lines.append(f"shell command preview: {preview}") elif args: keys = ", ".join(sorted(str(k) for k in args.keys())[:12]) if keys: diff --git a/src/lingtai/kernel/loop_guard.py b/src/lingtai/kernel/loop_guard.py index b02bb21d5..e929ca1a9 100644 --- a/src/lingtai/kernel/loop_guard.py +++ b/src/lingtai/kernel/loop_guard.py @@ -281,7 +281,7 @@ def advisory_metadata(self, verdict: DupVerdict) -> dict | None: "message": verdict.warning, "skill_refs": [ "system-manual", - "bash-manual", + "shell-manual", "daemon-manual", "email-manual", ], diff --git a/src/lingtai/kernel/presets.py b/src/lingtai/kernel/presets.py index 170439cf9..a2b60dc41 100644 --- a/src/lingtai/kernel/presets.py +++ b/src/lingtai/kernel/presets.py @@ -423,7 +423,7 @@ def materialize_active_preset( with a different ceiling. - **Core-default carry-forward** (capabilities the preset *omits*): for capability names in ``core_defaults`` — the always-on floor - (``knowledge``/``skills``/``bash``/``avatar``/``daemon``/``mcp``/ + (``knowledge``/``skills``/``shell``/``avatar``/``daemon``/``mcp``/ ``read``/``write``/``edit``/``glob``/``grep``) — init.json's kwargs are carried into the materialized manifest even when the preset never mentions the capability. Without this, a preset that omits ``daemon`` diff --git a/src/lingtai/mcp_servers/telegram/SKILL.md b/src/lingtai/mcp_servers/telegram/SKILL.md index 01113db46..77ad368e7 100644 --- a/src/lingtai/mcp_servers/telegram/SKILL.md +++ b/src/lingtai/mcp_servers/telegram/SKILL.md @@ -6,7 +6,7 @@ description: | vs 'photo' for charts/reports/generated artifacts, placeholder/live-status messages, reply vs send, read/check/search, parse_mode/entities, chat_action, dynamic slash commands, the programmable Task Card (task_card tool) — including the default to add a - human-visible watcher for long-running bash-async/daemon work — and error + human-visible watcher for long-running shell-async/daemon work — and error surfacing. Pulled on demand via action='manual'; you do not need to call it before every send. version: 1.5.2 @@ -246,7 +246,7 @@ structure lives in the separate ## PROGRAMMABLE TASK CARD (`task_card` tool) - **Default during Telegram-originated turns: when you launch a meaningful - long-running `bash(async=true)` job or daemon task and then go idle to await it, + long-running `shell(async=true)` job or daemon task and then go idle to await it, create a human-visible programmable Task Card watcher for it** so the person watching Telegram sees the latest reported snapshot instead of a silent gap (you refresh that snapshot after launch, after meaningful polls/checks, and at @@ -273,7 +273,7 @@ structure lives in the separate failures keep the last valid frame and raise a deduped fail-loud system wake. - `/taskcard off` hides delivery of **both** slots (see **TASKCARD STATE**) while the renderer, watches, and last-valid bookkeeping keep running. -- **Full manual (renderer contract, the two ready bash-async/daemon renderer +- **Full manual (renderer contract, the two ready shell-async/daemon renderer templates, the snapshot truthfulness model, and the full start|inspect|retry|stop walkthrough):** follow the relative path `task_card/SKILL.md` from this manual's directory (the co-located Programmable Task Card manual). Starting a watch drives diff --git a/src/lingtai/mcp_servers/telegram/task_card/ANATOMY.md b/src/lingtai/mcp_servers/telegram/task_card/ANATOMY.md index 7742c0fa0..6660243d5 100644 --- a/src/lingtai/mcp_servers/telegram/task_card/ANATOMY.md +++ b/src/lingtai/mcp_servers/telegram/task_card/ANATOMY.md @@ -176,7 +176,7 @@ Normative promises live in the paired [`CONTRACT.md`](CONTRACT.md). - **Manual:** [`SKILL.md`](SKILL.md). - **Manual assets:** [`assets/render_bash_async.py`](assets/render_bash_async.py) and [`assets/render_daemon.py`](assets/render_daemon.py) — the two co-located, - stdlib-only renderer templates the manual routes agents to (bash-async job and + stdlib-only renderer templates the manual routes agents to (shell-async job and daemon task). They read an orchestrator-owned working-dir state snapshot and print one bounded Task Card object; they are packaged skill assets, not runtime code (the controller never imports them — it runs the agent's own working-dir diff --git a/src/lingtai/mcp_servers/telegram/task_card/SKILL.md b/src/lingtai/mcp_servers/telegram/task_card/SKILL.md index 2aea1a2e2..06ab0f43f 100644 --- a/src/lingtai/mcp_servers/telegram/task_card/SKILL.md +++ b/src/lingtai/mcp_servers/telegram/task_card/SKILL.md @@ -2,11 +2,11 @@ name: telegram-task-card-manual description: | Manual for the programmable Telegram Task Card (`task_card` tool). Read this to - surface your latest reported state snapshot — a bash async job, a daemon task, + surface your latest reported state snapshot — a shell async job, a daemon task, a build — on the resident Telegram Task Card by supplying a small Python renderer whose stdout is one Task Card JSON object. Covers the renderer contract, the snapshot truthfulness model, the two co-located renderer - templates (bash async, daemon) shipped as skill assets, the + templates (shell async, daemon) shipped as skill assets, the start | inspect | retry | stop lifecycle, path/timeout/validation rules, fail-loud error wakes, and how the /taskcard toggle interacts. last_changed_at: "2026-07-14T19:22:00-07:00" @@ -32,7 +32,7 @@ autonomous progress feed. ## When to reach for this During a **Telegram-originated turn**, when you launch a meaningful -**long-running `bash(async=true)` job or daemon task** and then go idle to await +**long-running `shell(async=true)` job or daemon task** and then go idle to await its result, add a human-visible programmable Task Card watcher so the human watching Telegram sees the latest reported snapshot instead of a silent gap. Two ready templates cover exactly these two cases — see **Two ready templates** below. @@ -96,8 +96,8 @@ its cwd, so it locates state by a fixed relative path. Point it at a small **state snapshot file that you (the orchestrator) keep truthful** — not at a tool's private internals: -- A `bash(async=true)` job's own state under `system/jobs//` is **private - to the Bash capability**, and `bash(action="poll")` is a **one-shot, consuming** +- A `shell(async=true)` job's own state under `system/jobs//` is **private + to the shell capability**, and `shell(action="poll")` is a **one-shot, consuming** read (the first terminal poll marks the job consumed). A passive renderer must not touch either. - A daemon run's `daemons//daemon.json` is a **versioned forensic** artifact, @@ -113,7 +113,7 @@ job progress. It never invents or introspects tool state. **Truthfulness contract (both templates enforce this):** a live/terminal card is shown **only** when the snapshot carries both a nonempty **identity** (`job_id` -for bash, `id` for daemon) **and** an exact allow-listed **state** string. A +for shell, `id` for daemon) **and** an exact allow-listed **state** string. A missing file, non-JSON, non-object, missing identity/state, an unknown state, or a wrong-typed field renders an explicit `awaiting orchestrator update` frame — never a fabricated `starting`/`running`. So an empty or half-written snapshot can @@ -134,38 +134,38 @@ Each prints exactly one bounded Task Card object, enforces the truthfulness contract above, and stays valid even when the snapshot is missing, partial, or malformed: -- **`render_bash_async.py`** — for a `bash(async=true)` job. Reads +- **`render_bash_async.py`** — for a `shell(async=true)` job. Reads `task_card_state.json`. Requires `job_id` (str) + `status` (str, one of `starting|running|done|failed|cancelled|unknown`); also surfaces optional `title`, `exit_code` (int), `stage`, `updated_at`, `note`. Here `status` is a **display state you derive from the sanctioned poll/cancel result**, not the raw - top-level bash `status` (which is always `done` on completion — see the mapping - under **Deriving the bash display state** below). + top-level shell `status` (which is always `done` on completion — see the mapping + under **Deriving the shell display state** below). - **`render_daemon.py`** — for a daemon task (emanation). Reads `daemon_card_state.json`. Requires `id` (str) + `state` (str, one of `running|done|failed|cancelled|timeout`); also surfaces optional `title`, `current`, `elapsed_s` (finite number), `last_activity`, `health` (`alive|stalled|unknown`), `updated_at`, `note`. -**Deriving the bash display state.** The bash `status` you record is a **display -state derived from the sanctioned action result**, not the raw top-level bash -`status`. Bash's terminal `bash(action="poll")` is **always** top-level +**Deriving the shell display state.** The shell `status` you record is a **display +state derived from the sanctioned action result**, not the raw top-level shell +`status`. Shell's terminal `shell(action="poll")` is **always** top-level `status: "done"` — a nonzero inner command does **not** make it a top-level `"failed"`; the pass/fail signal is in the additive fidelity fields (`exit_status_known`, `exit_code`, `ok`, `command_status`). Map the result: -| `bash` result | record `status` | +| `shell` result | record `status` | |---|---| | `{"status": "running", ...}` | `running` (resident) | | `{"status": "done", "exit_status_known": true, "exit_code": 0, "ok": true, "command_status": "success"}` | `done` (terminal) | | `{"status": "done", "exit_status_known": true, "exit_code": , "ok": false, "command_status": "failed"}` | `failed` (terminal) | | `{"status": "done", "exit_status_known": false, "exit_code": null, ...}` | `unknown` (terminal) | -| `bash(action="cancel")` → `{"status": "cancelled", ...}` | `cancelled` (terminal) | +| `shell(action="cancel")` → `{"status": "cancelled", ...}` | `cancelled` (terminal) | So a nonzero completion is recorded `failed` (never `done` by copying the raw top-level `status`), and an exit-status-unavailable terminal completion is recorded `unknown` — a distinct **terminal** state that reports the exit status is -unavailable and claims **neither** success **nor** failure (Bash never invents +unavailable and claims **neither** success **nor** failure (Shell never invents `-1` or a false `command_status: "failed"` for it, so neither does the card). Copy `exit_code` only when `exit_status_known` is true; omit it for `unknown`. All four terminal display states (`done`, `failed`, `cancelled`, `unknown`) render the @@ -241,14 +241,14 @@ driver that forwards validated frames. A watcher does not end itself — the renderer is passive and has no `watch_id`, so **you** must stop it when the work finishes. When your job reaches a terminal -display state — bash `status` `done`/`failed`/`cancelled`/`unknown`, or daemon -`state` `done`/`failed`/`cancelled`/`timeout` (learned from the terminal bash -`bash(action="poll")`/`bash(action="cancel")` or the terminal `daemon` +display state — shell `status` `done`/`failed`/`cancelled`/`unknown`, or daemon +`state` `done`/`failed`/`cancelled`/`timeout` (learned from the terminal shell +`shell(action="poll")`/`shell(action="cancel")` or the terminal `daemon` notification/`daemon(action="check")`) — record that terminal snapshot, then **immediately call `task_card(action="stop", watch_id="")`** (the `watch_id` that `task_card(action="start", ...)` returned) to quiesce the watcher and clear the programmable slot so the completed card does not stay resident. The -bash `unknown` display state (a terminal poll whose `exit_status_known` is +shell `unknown` display state (a terminal poll whose `exit_status_known` is `false`) is terminal too — it reports the exit status is unavailable, claims neither success nor failure, and must still be stopped/cleared, not left resident. The two shipped templates surface this by rendering the footer diff --git a/src/lingtai/mcp_servers/telegram/task_card/assets/render_bash_async.py b/src/lingtai/mcp_servers/telegram/task_card/assets/render_bash_async.py index d41b824e9..692ea4766 100644 --- a/src/lingtai/mcp_servers/telegram/task_card/assets/render_bash_async.py +++ b/src/lingtai/mcp_servers/telegram/task_card/assets/render_bash_async.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Task Card renderer TEMPLATE for a long-running `bash(async=true)` job. +"""Task Card renderer TEMPLATE for a long-running `shell(async=true)` job. Locate this asset relative to the ABSOLUTE manual path that Telegram's `manual` action returns (its directory has `task_card/assets/`), then COPY it @@ -14,13 +14,13 @@ command-line arguments and runs with your working directory as the process cwd, so it locates its state by the fixed relative path `STATE_FILE` below. -WHY it reads a snapshot file, not bash internals +WHY it reads a snapshot file, not shell internals ------------------------------------------------- -The bash async job's own on-disk state under `system/jobs//` is PRIVATE -to the Bash capability, and `bash(action="poll")` is a one-shot, consuming read +The shell async job's own on-disk state under `system/jobs//` is PRIVATE +to the shell capability, and `shell(action="poll")` is a one-shot, consuming read (the first terminal poll marks the job consumed). A passive renderer must not touch either. Instead, YOU — the orchestrator — own a tiny snapshot file and -keep it truthful: write it right after `bash(async=true)` returns your `job_id`, +keep it truthful: write it right after `shell(async=true)` returns your `job_id`, and rewrite it after each meaningful poll and at the terminal result. The renderer shows only the LATEST REPORTED SNAPSHOT; it never invents job state and never claims progress you have not recorded. @@ -34,7 +34,7 @@ `starting`/`running`. { - "job_id": "job-a1b2...", # REQUIRED str: the id bash(async=true) returned + "job_id": "job-a1b2...", # REQUIRED str: the id shell(async=true) returned "status": "running", # REQUIRED str: starting|running|done|failed|cancelled|unknown "title": "Refactor auth module", # optional str headline "exit_code": 0, # optional int, once a terminal poll reports a known exit code @@ -44,11 +44,11 @@ } `status` is a DISPLAY STATE you derive from the sanctioned action result — it is -NOT the raw top-level `status` of the poll. Bash's terminal poll is ALWAYS +NOT the raw top-level `status` of the poll. Shell's terminal poll is ALWAYS top-level `status: "done"`: a nonzero inner command does not change it to a top-level `"failed"`. Read the additive fidelity fields and map them: - bash(action="poll") result -> record status= + shell(action="poll") result -> record status= ---------------------------------------------------------- --------------- {"status": "running", ...} "running" {"status": "done", "exit_status_known": true, "done" @@ -58,12 +58,12 @@ "command_status": "failed"} {"status": "done", "exit_status_known": false, "unknown" "exit_code": null, ...} - bash(action="cancel") -> {"status": "cancelled", ...} "cancelled" + shell(action="cancel") -> {"status": "cancelled", ...} "cancelled" So a nonzero completion is recorded `failed` (never `done`), and an exit-status-unknown terminal completion is recorded `unknown` — a distinct TERMINAL state that reports the exit status is unavailable and claims NEITHER -success NOR failure (Bash itself never invents `-1` or a false +success NOR failure (Shell itself never invents `-1` or a false `command_status: "failed"` for it, so neither may this card). Only copy the exit code into `exit_code` when `exit_status_known` is true; for `unknown` leave it out. Update the snapshot from your turn on each meaningful poll and at the @@ -82,7 +82,7 @@ `watch_id` and no tool access, so it CANNOT stop the watch or clear the card by itself. When the job reaches a terminal status (`done`, `failed`, `cancelled`, or `unknown` — the exit-status-unavailable terminal) — which you learn from the -terminal `bash(action="poll")` or `bash(action="cancel")` result — record that +terminal `shell(action="poll")` or `shell(action="cancel")` result — record that terminal snapshot, then IMMEDIATELY call `task_card(action="stop", watch_id="")` (the `watch_id` that `task_card(action="start", ...)` returned) to quiesce the watcher and clear the @@ -118,7 +118,7 @@ # The only accepted display states, mapped to a small status glyph. These are # DISPLAY states the orchestrator derives from the sanctioned poll/cancel result -# (see the module docstring), not the raw top-level bash ``status``. ``unknown`` +# (see the module docstring), not the raw top-level shell ``status``. ``unknown`` # is the exit-status-unavailable terminal: it claims neither success nor failure. _STATUS_GLYPH = { "starting": "…", diff --git a/src/lingtai/prompts/procedures/procedures.md b/src/lingtai/prompts/procedures/procedures.md index c328cf855..6d450be8f 100644 --- a/src/lingtai/prompts/procedures/procedures.md +++ b/src/lingtai/prompts/procedures/procedures.md @@ -117,7 +117,7 @@ CLI daemon backends skip LingTai preset resolution entirely. See ### Use the Right Body -Use bash for one-off deterministic host work, daemons for disposable parallel +Use shell for one-off deterministic host work, daemons for disposable parallel exploration and cheap deterministic work that would otherwise consume the main agent's context, avatars for persistent specialists, MCPs for durable external integrations, knowledge for private facts, and skills for reusable procedures. @@ -130,7 +130,7 @@ or model by exercising judgment about the task; when the human gives an explicit instruction, follow that instruction. When the same coding harness is available as a daemon backend option, prefer the -daemon backend over launching that harness through Bash async, using the +daemon backend over launching that harness through shell async, using the first-class daemon context-isolation and supervision path. Read `daemon-manual` for backend details. @@ -190,7 +190,7 @@ discipline keeps multiple same-day molts chronologically stable. | Daemon inspection/debugging | `daemon-manual` | | Skill authoring/publishing | `skills-manual` | | Knowledge entries | `knowledge-manual` | -| Shell commands, cron, host scheduling | `bash-manual` | +| Shell commands, cron, host scheduling | `shell-manual` | | SQLite / log.sqlite / LingTai runtime logs / `lingtai-agent log doctor|query|rebuild` / trace inspection | `system-manual` → `reference/sqlite-log-query/SKILL.md` | | Kernel architecture / breaking changes | `lingtai-kernel-anatomy` | | TUI / portal code navigation | `lingtai-tui-anatomy` | diff --git a/src/lingtai/prompts/substrate/substrate.md b/src/lingtai/prompts/substrate/substrate.md index 1e82eb79b..6b39546f9 100644 --- a/src/lingtai/prompts/substrate/substrate.md +++ b/src/lingtai/prompts/substrate/substrate.md @@ -38,14 +38,14 @@ You have one active mind and several extensions: | Extension | Use for | |---|---| -| **Bash** | One-off deterministic host work: git, tests, scripts, curl | +| **Shell** | One-off deterministic host work: git, tests, scripts, curl | | **Daemon** | Disposable, context-isolated exploration where only the conclusion matters | | **Avatar** | Persistent specialists or collaborators that should learn over time | | **MCP** | Durable external services and integrations | | **Knowledge** | Private durable facts, decisions, journals, local paths | | **Skills** | Reusable procedures, checklists, scripts, and templates | -Choose the smallest durable form that fits: bash for commands, daemon for +Choose the smallest durable form that fits: shell for commands, daemon for throwaway parallel work, avatar for persistent ownership, MCP for external services, knowledge for private facts, skills for reusable know-how. diff --git a/src/lingtai/tools/ANATOMY.md b/src/lingtai/tools/ANATOMY.md index 52f119f52..4ea4ac0f8 100644 --- a/src/lingtai/tools/ANATOMY.md +++ b/src/lingtai/tools/ANATOMY.md @@ -38,7 +38,7 @@ composes them. | `_media_host.py`, `_zhipu_mode.py` | Provider-host / z.ai-mode helpers for `vision` + `web_search` | **Tool sub-packages:** `email/`, `system/`, `psyche/`, `soul/`, `notification/` -(the five mandatory intrinsics); `knowledge/`, `skills/`, `bash/`, `avatar/`, +(the five mandatory intrinsics); canonical `shell` (retained `bash/` implementation), `knowledge/`, `skills/`, `avatar/`, `daemon/`, `mcp/`, `read/`, `write/`, `edit/`, `glob/`, `grep/` (always-on floor); `vision/`, `web_search/` (opt-in). `avatar/` registers two tools (`avatar_spawn`, `avatar_rules`). diff --git a/src/lingtai/tools/bash/ANATOMY.md b/src/lingtai/tools/bash/ANATOMY.md index 622267c9c..7d2335ff0 100644 --- a/src/lingtai/tools/bash/ANATOMY.md +++ b/src/lingtai/tools/bash/ANATOMY.md @@ -10,6 +10,12 @@ related_files: - src/lingtai/adapters/bash.py - src/lingtai/adapters/bash_process.py - src/lingtai/adapters/bash_state_lock.py + - src/lingtai/adapters/shell.py + - src/lingtai/adapters/shell_process.py + - src/lingtai/adapters/shell_state_lock.py + - src/lingtai/adapters/windows/powershell.py + - src/lingtai/adapters/windows/powershell_process.py + - src/lingtai/adapters/windows/powershell_state_lock.py - src/lingtai/adapters/posix/bash.py - src/lingtai/adapters/posix/bash_process.py - src/lingtai/adapters/posix/bash_state_lock.py @@ -28,9 +34,9 @@ maintenance: | maintenance field. If you notice drift between this anatomy and the code, report it. See lingtai-dev-guide for details. --- -# core/bash +# core/shell (retained implementation: bash) -Bash capability — shell command execution with file-based policy. Adds the +Canonical `shell` capability — shell command execution with file-based policy; PR1 retains the internal Bash package and durable namespace. Adds the ability to run shell commands. This is a capability (not intrinsic) because not every agent should have shell access — it's a powerful ability that should be explicitly opted into. @@ -40,16 +46,17 @@ be explicitly opted into. - `bash/__init__.py` — public schema/setup plus policy, sync execution, and durable async manager orchestration. `get_description` (`__init__.py:195`), `get_schema` (`__init__.py:199`), and `setup` (`__init__.py:1416`) define the public capability surface. `BashManager` owns validation, manager semantics, durable-state rehydration, notification publication, and poll/cancel consumption (`__init__.py:338`). `_augment_command_result` adds `ok`/`command_status`/`warning` fidelity fields (`__init__.py:141`). - `bash/_shell_dialect.py` — the Bash-local `ShellDialect` port and serializable `ShellInvocation`; the POSIX extraction helper preserves the existing policy grammar. - `adapters/posix/bash.py` — `PosixBashDialect`, the first production adapter; it provides POSIX policy extraction and script-form shell invocation. -- `adapters/bash.py` — `select_bash_shell_dialect`, the fail-loud setup selector. -- `bash/_async_process.py` and `bash/_state_lock.py` — Bash-local Ports for neutral process refs/observations/owned lifecycle handles and cross-process state serialization. -- `adapters/bash_process.py` and `adapters/bash_state_lock.py` — fail-loud selectors for the process and lock capabilities. +- `adapters/shell.py` — `select_shell_dialect`, the outer selector for POSIX and PowerShell dialects; `adapters/bash.py` remains a private compatibility selector. +- `bash/_async_process.py` and `bash/_state_lock.py` — retained implementation Ports (also exported as `ShellAsyncProcessPort`/`ShellStateLockPort`) for neutral process refs/observations/owned lifecycle handles and cross-process state serialization. +- `adapters/shell_process.py` and `adapters/shell_state_lock.py` — canonical outer selectors; the old Bash-named selectors remain compatibility-only. - `adapters/posix/bash_process.py` and `adapters/posix/bash_state_lock.py` — production POSIX implementations owning `Popen`, identity/liveness, process groups/signals/quiescence, exact waits, and `flock`. +- `adapters/windows/powershell.py`, `powershell_process.py`, and `powershell_state_lock.py` — PowerShell 7 argv dialect, Job Object process-tree ownership, and native cross-process byte-range locking. - `bash/_async_supervisor.py` — private detached policy runner that selects the same Ports, claims leases, delegates spawn/wait, and atomically persists terminal truth. - `bash/bash_policy.json` — default denylist policy shipped with the kernel. Denies destructive (`rm`, `rmdir`, `shred`, `dd`), privilege escalation (`sudo`, `su`, `doas`), permission changes (`chmod`, `chown`, `chgrp`), disk management (`mount`, `umount`, `mkfs`, `fdisk`), package managers (`apt`, `apt-get`, `yum`, `dnf`, `brew`), process control (`kill`, `killall`, `pkill`, `shutdown`, `reboot`, `systemctl`), network (`nc`, `ncat`), and code execution (`eval`, `exec`). ## Public API -The `bash` tool supports synchronous and asynchronous execution: +The canonical `shell` tool supports synchronous and asynchronous execution: | Parameter | Type | Description | |----------------|----------|-------------| @@ -141,4 +148,4 @@ bash/__init__.py - **Parent:** `src/lingtai/tools/` (tool package). - **Siblings:** `daemon/`, `avatar/`, `mcp/`, `knowledge/` (private durable memory), `skills/` (skill catalog). - **Manual:** `bash/manual/SKILL.md` — operational guide for agents covering async/poll/reminder durability plus scheduled / cron-driven work, wake-by-mailbox-drop, hygiene rules, OS-specific scheduler recipes, and debugging walkthroughs. -- **Kernel hooks:** `setup()` is called during capability initialization; `BashManager.handle()` is registered as the `bash` tool handler. +- **Kernel hooks:** `setup()` is called during capability initialization; `ShellManager.handle()` is registered as the canonical `shell` tool handler. diff --git a/src/lingtai/tools/bash/CONTRACT.md b/src/lingtai/tools/bash/CONTRACT.md index ded138155..5ffc38779 100644 --- a/src/lingtai/tools/bash/CONTRACT.md +++ b/src/lingtai/tools/bash/CONTRACT.md @@ -1,6 +1,6 @@ --- name: bash-contract -tool: bash +tool: shell contract_version: 3 related_files: - src/lingtai/tools/bash/__init__.py @@ -11,6 +11,12 @@ related_files: - src/lingtai/adapters/bash.py - src/lingtai/adapters/bash_process.py - src/lingtai/adapters/bash_state_lock.py + - src/lingtai/adapters/shell.py + - src/lingtai/adapters/shell_process.py + - src/lingtai/adapters/shell_state_lock.py + - src/lingtai/adapters/windows/powershell.py + - src/lingtai/adapters/windows/powershell_process.py + - src/lingtai/adapters/windows/powershell_state_lock.py - src/lingtai/adapters/posix/bash.py - src/lingtai/adapters/posix/bash_process.py - src/lingtai/adapters/posix/bash_state_lock.py @@ -22,9 +28,9 @@ maintenance: | same change and bump contract_version on breaking contract edits. --- -# Bash capability contract +# Shell capability contract -`bash` runs shell commands for an agent that has explicitly opted into shell +The canonical `shell` tool runs shell commands for an agent that has explicitly opted into shell access. It is a capability, not an intrinsic, because shell access is powerful and should be granted deliberately. The implementation lives in `src/lingtai/tools/bash/`; the code is the source of truth. @@ -41,7 +47,7 @@ and should be granted deliberately. The implementation lives in **Do not use this for:** - Long-running peer/subagent work: use `daemon` (see - `src/lingtai/tools/daemon/CONTRACT.md`) — `bash` async jobs are plain background + `src/lingtai/tools/daemon/CONTRACT.md`) — `shell` async jobs are plain background processes, not reasoning agents. - Code navigation only: read `src/lingtai/tools/bash/ANATOMY.md`. @@ -51,15 +57,15 @@ invariants. ## Scope -- Canonical tool name: `bash`. -- One tool exposes three actions: `run` (default), `poll`, `cancel`. -- Policy is file-based (`bash_policy.json` is the default). `yolo=True` at setup +- Canonical tool name: `shell` (the retained implementation package is `lingtai.tools.bash`). +- One public tool exposes three actions: `run` (default), `poll`, `cancel`. +- Policy is file-based (`bash_policy.json` is the POSIX default; Windows selects the reviewed `powershell_policy.json`). `yolo=True` at setup installs an allow-everything policy (unsandboxed command set) and is the documented default for trusted agents. Two policy modes exist: **allowlist** (only listed commands, active whenever an `allow` key is present) and **denylist** (everything except listed commands). The mode is implicit. -**Non-goals:** `bash` does not sandbox the command's own filesystem writes +**Non-goals:** `shell` does not sandbox the command's own filesystem writes beyond the `working_dir` scope check; it does not manage agent lifecycle; it does not stream output incrementally (async jobs are polled, not streamed). @@ -76,7 +82,7 @@ for `command` and `job_id`. `action` defaults to `run`. | Action | Required inputs | Optional inputs | Success output | Error shapes | |---|---|---|---|---| | `run` (sync) | Provider schema: `command`, `reminder`; runtime consumes `command` only | `working_dir`, `timeout` (default 30), `summary` | `{status: "ok", exit_code, stdout, stderr, ok, command_status, warning?}` | `{status: "error", message}` — empty command, policy-denied, cwd outside sandbox, timeout (with broad-scan hint), or spawn failure | -| `run` (async) | Provider/runtime: `command`, `async: true`, `reminder` | `working_dir`, `summary` | `{status: "ok", job_id, pid, message, handoff}`; `handoff` tells the model it may go idle or call `system(action='sleep')` while waiting for the terminal notification; read `bash-manual` and `notification-manual` for details | `{status: "error", message}` — same validation errors, invalid boolean/non-numeric/non-finite/negative/too-large `reminder`, plus `Failed to start async job: ...` | +| `run` (async) | Provider/runtime: `command`, `async: true`, `reminder` | `working_dir`, `summary` | `{status: "ok", job_id, pid, message, handoff}`; `handoff` tells the model it may go idle or call `system(action='sleep')` while waiting for the terminal notification; read `shell-manual` and `notification-manual` for details | `{status: "error", message}` — same validation errors, invalid boolean/non-numeric/non-finite/negative/too-large `reminder`, plus `Failed to start async job: ...` | | `poll` | Provider schema: `job_id`, `reminder`; runtime consumes `job_id` only | — | running: `{status: "running", job_id, pid?}` while the recorded supervisor may still commit; known finished: `{status: "done", exit_status_known: true, exit_code, stdout, stderr, ok, command_status, warning?}`; unrecoverable/legacy terminal: `{status: "done", exit_status_known: false, exit_code: null, stdout, stderr}` | `{status: "error", message}` — missing/invalid `job_id`, `Job not found`, or an already terminal-consumed job | | `cancel` | Provider schema: `job_id`, `reminder`; runtime consumes `job_id` only | — | `{status: "cancelled", job_id}` only after the supervisor has committed the held child's exact terminal status and cancellation atomically consumes/suppresses the job | `{status: "error", message}` — missing/invalid `job_id`, `Job not found`, terminal job, legacy job, or a durable cancellation request still awaiting a terminal commit (which remains pollable/remindable) | @@ -207,10 +213,24 @@ invocation and does not use the key for adapter dispatch. This is fail-loud schema/semantic validation, not cryptographic integrity for user-rewritable durable state. +The registered description is setup-time metadata and always includes +`Active shell dialect: posix` or `Active shell dialect: powershell`, plus a +human-readable `Host OS: ` derived from the host platform +(macOS product version, Linux `os-release`, Windows release/version, or an +explicit system/kernel fallback). The call schema has no writable dialect or +host-OS argument, so a call cannot claim a different environment from the +injected adapter and host. On Windows the selector requires PowerShell +7 `pwsh`, uses argv form with `-NoProfile` and `-NonInteractive`, and selects +native Job Object and cross-process state-lock adapters. A legacy durable record +with neither dialect nor invocation remains readable evidence but is explicitly +unrecoverable on a non-POSIX host rather than being reinterpreted as PowerShell. + ## Cross-platform invariants -The current production adapters are POSIX. New adapters must preserve the policy -invariants below without copying POSIX mechanisms into the Bash-local Ports. +POSIX and PowerShell 7 are the production dialect adapters. Platform-specific +process and lock mechanisms stay outside the retained Bash-local Ports; adapters +must preserve the policy invariants below without copying POSIX mechanisms into +those Ports. - POSIX sync execution consumes a script-form `ShellInvocation`, equivalent to `subprocess.run(command, shell=True, ...)` — POSIX shell string semantics. @@ -264,7 +284,8 @@ for cancellation correctness. | Reminder validation rejects non-finite and backend-unsafe delays | `src/lingtai/tools/bash/__init__.py` | `tests/test_bash_async.py::test_async_reminder_rejects_invalid_values` | | Deadline claim, bounded cancellation suppression/recovery, and terminal handling have deterministic lock-owned ordering | `src/lingtai/tools/bash/__init__.py` | `tests/test_bash_async.py::test_terminal_pop_before_deadline_claim_suppresses_reminder`, `::test_deadline_claim_before_terminal_pop_publishes_once`, `::test_expired_suppressing_reminder_recovers_after_manager_crash` | | Direct-manager fallback appends remain multi-event safe across managers | `src/lingtai/tools/bash/__init__.py` | `tests/test_bash_async.py::test_direct_manager_fallback_is_serialized_by_shared_store` | -| `yolo=True` allows all commands | `src/lingtai/tools/bash/__init__.py` | `tests/test_layers_bash.py::test_add_capability_bash_yolo` | +| `yolo=True` allows all commands and the registered public identity is `shell` | `src/lingtai/tools/bash/__init__.py` | `tests/test_layers_bash.py::test_add_capability_bash_yolo`, `tests/test_shell_pr1_contract.py::test_setup_registers_shell_and_advertises_selected_dialect` | +| PowerShell argv/dialect policy and Windows selector composition stay behind shared Ports | `src/lingtai/adapters/windows/` and `src/lingtai/adapters/shell*.py` | `tests/test_shell_pr1_contract.py` (native Windows execution remains a separate acceptance gate) | ## Verification matrix diff --git a/src/lingtai/tools/bash/__init__.py b/src/lingtai/tools/bash/__init__.py index cbd5fa189..0c5e5fae9 100644 --- a/src/lingtai/tools/bash/__init__.py +++ b/src/lingtai/tools/bash/__init__.py @@ -1,12 +1,12 @@ -"""Bash capability — shell command execution with file-based policy. +"""Canonical shell capability — shell command execution with file-based policy. Adds the ability to run shell commands. This is a capability (not intrinsic) because not every agent should have shell access — it's a powerful capability that should be explicitly opted into. Usage: - agent.add_capability("bash", policy_file="path/to/policy.json") - agent.add_capability("bash", yolo=True) # no restrictions + agent.add_capability("shell", policy_file="path/to/policy.json") + agent.add_capability("shell", yolo=True) # no restrictions """ from __future__ import annotations @@ -43,6 +43,7 @@ PROVIDERS = {"providers": [], "default": "builtin"} _DEFAULT_POLICY_FILE = Path(__file__).parent / "bash_policy.json" +_POWERSHELL_POLICY_FILE = Path(__file__).parent / "powershell_policy.json" _DEFAULT_ASYNC_REMINDER_SECONDS = 1800.0 _SUPERVISOR_START_LEASE_SECONDS = 3.0 # The parent may spend one start lease launching the supervisor and another @@ -56,17 +57,30 @@ _JOB_ID_RE = re.compile(r"job-(?:[0-9a-f]{32}|[0-9a-f]{8})\Z") -def _select_bash_shell_dialect() -> ShellDialect: - """Load the outer selector lazily to keep adapter → Port imports acyclic.""" - from lingtai.adapters.bash import select_bash_shell_dialect +def _select_shell_dialect() -> ShellDialect: + """Load the canonical outer selector lazily to keep imports acyclic.""" + from lingtai.adapters.shell import select_shell_dialect - return select_bash_shell_dialect() + return select_shell_dialect() -def _select_bash_async_process() -> BashAsyncProcessPort: - """Load the process adapter lazily so the capability remains composition-led.""" - from lingtai.adapters.bash_process import select_bash_async_process - return select_bash_async_process() +def _describe_host_os() -> str: + """Load setup-time host metadata from the outer composition layer.""" + from lingtai.adapters.shell import describe_host_os + + return describe_host_os() + + +def _select_shell_async_process() -> BashAsyncProcessPort: + """Load the canonical process selector lazily.""" + from lingtai.adapters.shell_process import select_shell_async_process + return select_shell_async_process() + + +# Retained private names keep old implementation-only callers readable during +# the PR1 rollout; they do not create another registered tool. +_select_bash_shell_dialect = _select_shell_dialect +_select_bash_async_process = _select_shell_async_process # Length of the stderr tail surfaced in the failure warning. Short on purpose: # the full stderr is already present in the result; the tail just makes the @@ -209,8 +223,18 @@ def _augment_command_result(result: dict) -> dict: result["warning"] = "; ".join(parts) return result -def get_description(lang: str = "en") -> str: - return "Execute a shell command and return stdout/stderr. Any system program — scripts, git, curl, pip, data pipelines. Returns exit_code, stdout, stderr, plus ok (bool) and command_status ('success'/'failed'). IMPORTANT: top-level status stays 'ok' even when the command FAILS — it only means the shell ran. Always check exit_code/ok and read the warning field (it names nonzero exits, Python tracebacks, and missing modules); never assume success from status alone. Avoid broad recursive scans (find … -name, rglob, os.walk, glob('**')) — they time out; prefer `rg --files`. Parse JSONL line-by-line, not as one JSON blob. Supports async mode (async=true → job_id, then poll/cancel). Before using this tool, read the `bash-manual` skill — it covers cron setup, async hygiene, and advanced usage; no exceptions." +def get_description( + lang: str = "en", + dialect: str = "posix", + host_os: str | None = None, +) -> str: + host = f" Host OS: {host_os}." if host_os else "" + return (f"Execute a shell command and return stdout/stderr. Active shell dialect: {dialect}.{host} " + "The dialect and host OS are detected at setup time; calls cannot choose them. Any system program — scripts, git, curl, pip, data pipelines. " + "Returns exit_code, stdout, stderr, plus ok (bool) and command_status ('success'/'failed'). IMPORTANT: top-level status stays 'ok' even when the command FAILS — it only means the shell ran. " + "Always check exit_code/ok and read the warning field (it names nonzero exits, Python tracebacks, and missing modules); never assume success from status alone. " + "Avoid broad recursive scans (find … -name, rglob, os.walk, glob('**')) — they time out; prefer `rg --files`. Parse JSONL line-by-line, not as one JSON blob. " + "Supports async mode (async=true → job_id, then poll/cancel). Before using this tool, read the `shell-manual` skill — it covers async hygiene and advanced usage; no exceptions.") def get_schema(lang: str = "en") -> dict: @@ -243,7 +267,7 @@ def get_schema(lang: str = "en") -> dict: }, "reminder": { "type": "number", - "description": "Last-resort async wake delay in seconds (default: 1800). For async run only: if the job is still non-terminal when the durable deadline expires, publish a system notification reminding you to poll it; exact completion suppresses this stale watchdog and publishes the Bash completion wake instead.", + "description": "Last-resort async wake delay in seconds (default: 1800). For async run only: if the job is still non-terminal when the durable deadline expires, publish a system notification reminding you to poll it; exact completion suppresses this stale watchdog and publishes the shell completion wake instead.", "default": _DEFAULT_ASYNC_REMINDER_SECONDS, }, "job_id": { @@ -261,7 +285,7 @@ def get_schema(lang: str = "en") -> dict: -class BashPolicy: +class ShellPolicy: """Command execution policy — allow/deny lists with pipe awareness. Two modes, determined by the policy file content: @@ -278,7 +302,7 @@ def __init__(self, allow: list[str] | None = None, deny: list[str] | None = None self._deny = set(deny) if deny and not allow else None @classmethod - def from_file(cls, path: str) -> "BashPolicy": + def from_file(cls, path: str) -> "ShellPolicy": """Load policy from a JSON file with allow/deny lists.""" p = Path(path) if not p.is_file(): @@ -287,7 +311,7 @@ def from_file(cls, path: str) -> "BashPolicy": return cls(allow=data.get("allow"), deny=data.get("deny")) @classmethod - def yolo(cls) -> "BashPolicy": + def yolo(cls) -> "ShellPolicy": """Create a policy that allows everything.""" return cls() @@ -315,16 +339,23 @@ def is_allowed(self, command: str) -> bool: commands = self._extract_commands(command) return all(self._check_single(cmd) for cmd in commands) - def _check_single(self, cmd: str) -> bool: - """Check a single command name against policy. + def _check_single(self, cmd: str, *, case_insensitive: bool = False) -> bool: + """Check one command name against policy. - Allowlist mode: command must be in allow set. - Denylist mode: command must not be in deny set. + PowerShell command names are case-insensitive; POSIX retains its + historical case-sensitive matching. The manager supplies the dialect + fact rather than making this policy object inspect the host. """ - if self._allow is not None: - return cmd in self._allow - if self._deny is not None: - return cmd not in self._deny + if case_insensitive: + cmd = cmd.casefold() + allow = {item.casefold() for item in self._allow} if self._allow is not None else None + deny = {item.casefold() for item in self._deny} if self._deny is not None else None + else: + allow, deny = self._allow, self._deny + if allow is not None: + return cmd in allow + if deny is not None: + return cmd not in deny return True @staticmethod @@ -337,12 +368,12 @@ def _extract_commands(command: str) -> list[str]: return list(extract_posix_commands(command)) -class BashManager: +class ShellManager: """Manages shell commands; async terminal truth belongs to a durable child.""" def __init__( self, - policy: BashPolicy, + policy: ShellPolicy, working_dir: str, agent: "BaseAgent", max_output: int = 50_000, @@ -353,8 +384,8 @@ def __init__( self._working_dir = working_dir self._max_output = max_output self._agent = agent - self._dialect = dialect or _select_bash_shell_dialect() - self._async_process = async_process or _select_bash_async_process() + self._dialect = dialect or _select_shell_dialect() + self._async_process = async_process or _select_shell_async_process() self._jobs_dir: Path | None = None self._reminder_lock = threading.Lock() self._reminder_cancel_events: dict[str, threading.Event] = {} @@ -395,8 +426,19 @@ def _validate_command(self, command: str) -> dict | None: """Validate command is non-empty and allowed by policy. Returns error dict or None.""" if not command.strip(): return {"status": "error", "message": "command is required"} - commands = self._dialect.extract_commands(command) - if not all(self._policy._check_single(cmd) for cmd in commands): + try: + commands = self._dialect.extract_commands(command) + except (NotImplementedError, ValueError) as exc: + return {"status": "error", "message": f"Shell dialect cannot validate command safely: {exc}"} + powershell = self._dialect.state_key() == "powershell" + if powershell and "__powershell_unsupported__" in commands and ( + self._policy._allow is not None or self._policy._deny is not None + ): + return { + "status": "error", + "message": "PowerShell policy validation does not support this syntax; refusing to run it", + } + if not all(self._policy._check_single(cmd, case_insensitive=powershell) for cmd in commands): denied = commands return { "status": "error", @@ -758,8 +800,8 @@ def arm_from_return(current: dict) -> dict: } return { "status": "ok", "job_id": job_id, "pid": pid, - "message": f'Job started. Use bash(action="poll", job_id="{job_id}") to check.', - "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read bash-manual and notification-manual for details.", + "message": f'Job started. Use shell(action="poll", job_id="{job_id}") to check.', + "handoff": "While waiting, go idle or call system(action='sleep'); the terminal result will arrive and wake you as a notification; read shell-manual and notification-manual for details.", } if state and self._terminal(state.get("status")): break @@ -951,8 +993,8 @@ def _cancel_reminder_timer(self, job_id: str) -> None: def _publish_async_reminder(self, job_id: str) -> bool: body = ( - f"Bash async job {job_id} may still be running. " - f"Poll it with bash(action=\"poll\", job_id=\"{job_id}\")." + f"Shell async job {job_id} may still be running. " + f"Poll it with shell(action=\"poll\", job_id=\"{job_id}\")." ) agent = self._agent if hasattr(agent, "_enqueue_system_notification"): @@ -1445,12 +1487,18 @@ def _close_handles(self, job_id: str) -> None: """Compatibility no-op: durable supervisors own and close their logs.""" return None +# Compatibility names for direct imports from the retained implementation +# package. The canonical public symbols are ShellPolicy and ShellManager. +BashPolicy = ShellPolicy +BashManager = ShellManager + + def setup( agent: "BaseAgent", policy_file: str | None = None, yolo: bool = False, -) -> BashManager: - """Set up the bash capability on an agent. +) -> ShellManager: + """Set up the canonical shell capability on an agent. Args: agent: The agent to extend. @@ -1460,29 +1508,29 @@ def setup( Returns: The BashManager instance for programmatic access. """ - # Resolve policy: explicit arg or default + # Resolve the dialect before the default policy so PowerShell does not + # silently reuse a POSIX denylist. An explicit policy remains authoritative. + dialect = _select_shell_dialect() resolved_policy_file = policy_file - if yolo: - policy = BashPolicy.yolo() + policy = ShellPolicy.yolo() elif resolved_policy_file is not None: - policy = BashPolicy.from_file(resolved_policy_file) + policy = ShellPolicy.from_file(resolved_policy_file) else: - policy = BashPolicy.from_file(str(_DEFAULT_POLICY_FILE)) - + default_policy = _POWERSHELL_POLICY_FILE if dialect.state_key() == "powershell" else _DEFAULT_POLICY_FILE + policy = ShellPolicy.from_file(str(default_policy)) - dialect = _select_bash_shell_dialect() - mgr = BashManager( + mgr = ShellManager( policy=policy, working_dir=str(agent._working_dir), agent=agent, dialect=dialect, ) - # Build description with policy rules - desc = get_description() + # Description is setup-time metadata derived from the injected adapter and host. + desc = get_description(dialect=dialect.state_key(), host_os=_describe_host_os()) policy_summary = policy.describe() if policy_summary: desc = f"{desc}\n\n{policy_summary}" - agent.add_tool("bash", schema=get_schema(), handler=mgr.handle, description=desc, glossary_package=__package__) + agent.add_tool("shell", schema=get_schema(), handler=mgr.handle, description=desc, glossary_package=__package__) return mgr diff --git a/src/lingtai/tools/bash/_async_process.py b/src/lingtai/tools/bash/_async_process.py index 07f1711f9..f2dabae8b 100644 --- a/src/lingtai/tools/bash/_async_process.py +++ b/src/lingtai/tools/bash/_async_process.py @@ -98,6 +98,11 @@ def wait( ) -> ProcessCompletion: ... +# Canonical Port name; the Bash spelling remains an internal compatibility name +# for retained PR1 imports and durable supervisor code. +ShellAsyncProcessPort = BashAsyncProcessPort + + def process_ref_from_state(state: dict[str, object], prefix: str = "command") -> ProcessRef | None: value = state.get(f"{prefix}_process") return ProcessRef.from_dict(value) diff --git a/src/lingtai/tools/bash/_async_supervisor.py b/src/lingtai/tools/bash/_async_supervisor.py index c3d0753f8..b45f79543 100644 --- a/src/lingtai/tools/bash/_async_supervisor.py +++ b/src/lingtai/tools/bash/_async_supervisor.py @@ -39,8 +39,8 @@ def load_state(job_dir: Path) -> dict[str, Any] | None: @contextlib.contextmanager def _state_lock(job_dir: Path): """Serialize read-modify-write state transitions across managers/runner.""" - from lingtai.adapters.bash_state_lock import select_bash_state_lock - with select_bash_state_lock().exclusive(job_dir): + from lingtai.adapters.shell_state_lock import select_shell_state_lock + with select_shell_state_lock().exclusive(job_dir): yield @@ -52,11 +52,15 @@ def _write_state_atomic(job_dir: Path, value: dict[str, Any]) -> None: handle.flush() os.fsync(handle.fileno()) os.replace(temporary, state_path(job_dir)) - directory_fd = os.open(job_dir, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) + # POSIX directory descriptors provide the durability barrier for the + # replacement. Windows does not support opening a directory with os.open; + # the file flush plus atomic replace above is the portable boundary there. + if os.name == "posix": + directory_fd = os.open(job_dir, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) def write_initial_state(job_dir: Path, value: dict[str, Any]) -> None: @@ -197,6 +201,10 @@ def _invocation_from_state(state: dict[str, Any], command: str) -> ShellInvocati has_dialect = "shell_dialect" in state has_invocation = "invocation" in state if not has_dialect and not has_invocation: + if os.name != "posix": + raise ValueError( + "legacy durable shell state has no dialect/invocation; refusing to reinterpret it on a non-POSIX host" + ) return ShellInvocation(script=command) dialect = state.get("shell_dialect") if not isinstance(dialect, str) or not dialect.strip(): @@ -238,8 +246,8 @@ def supervise(job_dir: Path, start_token: str) -> int: if isinstance(deadline, (int, float)) and time.time() >= float(deadline): _mark_launch_failure(job_dir, "supervisor start lease expired before claim") return 2 - from lingtai.adapters.bash_process import select_bash_async_process - process_port = select_bash_async_process() + from lingtai.adapters.shell_process import select_shell_async_process + process_port = select_shell_async_process() supervisor_ref = process_port.identify_current_process() supervisor_pid = supervisor_ref.public_id if supervisor_ref else os.getpid() supervisor_identity = supervisor_ref.incarnation if supervisor_ref else None diff --git a/src/lingtai/tools/bash/_state_lock.py b/src/lingtai/tools/bash/_state_lock.py index 9d73b3ce4..4df481c84 100644 --- a/src/lingtai/tools/bash/_state_lock.py +++ b/src/lingtai/tools/bash/_state_lock.py @@ -8,3 +8,7 @@ class BashStateLockPort(Protocol): def exclusive(self, job_dir: Path) -> AbstractContextManager[None]: ... + + +# Canonical Port name; retained for the PR1 internal package compatibility seam. +ShellStateLockPort = BashStateLockPort diff --git a/src/lingtai/tools/bash/glossary-en.md b/src/lingtai/tools/bash/glossary-en.md index a36a7c63f..c7eef887e 100644 --- a/src/lingtai/tools/bash/glossary-en.md +++ b/src/lingtai/tools/bash/glossary-en.md @@ -10,5 +10,5 @@ related_files: - src/lingtai/tools/bash/glossary-zh.md - src/lingtai/tools/bash/glossary-wen.md maintenance: | - English glossary for the `bash` tool package (lingtai.tools.bash); the English body must stay empty per tool_glossary.py's language contract — update only the identity/schema fields here, and update the zh/wen bodies in lockstep when bash's public tool schema changes. + English glossary for the canonical `shell` tool (retained implementation package lingtai.tools.bash); the English body must stay empty per tool_glossary.py's language contract — update only the identity/schema fields here, and update the zh/wen bodies in lockstep when shell's public tool schema changes. --- diff --git a/src/lingtai/tools/bash/glossary-wen.md b/src/lingtai/tools/bash/glossary-wen.md index c30bba853..275d969e1 100644 --- a/src/lingtai/tools/bash/glossary-wen.md +++ b/src/lingtai/tools/bash/glossary-wen.md @@ -10,11 +10,11 @@ related_files: - src/lingtai/tools/bash/glossary-en.md - src/lingtai/tools/bash/glossary-zh.md maintenance: | - Classical-Chinese (wen) glossary for the `bash` tool package (lingtai.tools.bash); body must stay non-empty and distinct from glossary-zh.md. Update in lockstep with glossary-en.md/glossary-zh.md whenever bash's public tool schema changes. + Classical-Chinese (wen) glossary for the canonical `shell` tool (retained implementation package lingtai.tools.bash); body must stay non-empty and distinct from glossary-zh.md. Update in lockstep with glossary-en.md/glossary-zh.md whenever shell's public tool schema changes. --- **名相对照** -- `bash`:执行指令,返 stdout/stderr。可运行系统上一切可用之程——脚本、git、curl、pip、数据管道等。返 exit_code、stdout、stderr,兼附 ok(真伪)与 command_status('success'/'failed')。须知:命令虽败,顶层 status 仍作 'ok'——此仅言 shell 已运,非言命令已成。必察 exit_code/ok,且阅 warning 一字(标非零之退、Python 之回溯、缺失之模块);勿独凭 status 而断其成。忌大范围递归之扫(find … -name、rglob、os.walk、glob('**'))——易致超时;宜先用 `rg --files`。JSONL 当逐行而解,勿混作一 JSON。支持异步:设 async=true 取 job_id,后以 poll/cancel 查之。用此器前,必先读 `bash-manual` 一技(含定时之设、异步之规、进阶之用),无所例外。 +- `shell`:执行指令,返 stdout/stderr。可运行系统上一切可用之程——脚本、git、curl、pip、数据管道等。返 exit_code、stdout、stderr,兼附 ok(真伪)与 command_status('success'/'failed')。须知:命令虽败,顶层 status 仍作 'ok'——此仅言 shell 已运,非言命令已成。必察 exit_code/ok,且阅 warning 一字(标非零之退、Python 之回溯、缺失之模块);勿独凭 status 而断其成。忌大范围递归之扫(find … -name、rglob、os.walk、glob('**'))——易致超时;宜先用 `rg --files`。JSONL 当逐行而解,勿混作一 JSON。支持异步:设 async=true 取 job_id,后以 poll/cancel 查之。用此器前,必先读 `shell-manual` 一技(含定时之设、异步之规、进阶之用),无所例外。 - `action`:所行之事:'run'(默认)执行指令,'poll' 查异步任务之状,'cancel' 斩异步任务 - `command`:欲执行之指令 - `timeout`:超时秒数(默认:30,唯同步执行时生效) diff --git a/src/lingtai/tools/bash/glossary-zh.md b/src/lingtai/tools/bash/glossary-zh.md index 2eb0a28a9..0b70b1475 100644 --- a/src/lingtai/tools/bash/glossary-zh.md +++ b/src/lingtai/tools/bash/glossary-zh.md @@ -10,11 +10,11 @@ related_files: - src/lingtai/tools/bash/glossary-en.md - src/lingtai/tools/bash/glossary-wen.md maintenance: | - Simplified-Chinese (zh) glossary for the `bash` tool package (lingtai.tools.bash); body must stay non-empty. Update in lockstep with glossary-en.md/glossary-wen.md whenever bash's public tool schema changes. + Simplified-Chinese (zh) glossary for the canonical `shell` tool (retained implementation package lingtai.tools.bash); body must stay non-empty. Update in lockstep with glossary-en.md/glossary-wen.md whenever shell's public tool schema changes. --- **术语对照** -- `bash`:执行指令,返 stdout/stderr。可运行系统上一切可用之程——脚本、git、curl、pip、数据管道等。返回 exit_code、stdout、stderr,并附 ok(布尔)与 command_status('success'/'failed')。要点:即便命令失败,顶层 status 仍为 'ok'——它仅表示 shell 已执行。务必检查 exit_code/ok 并阅读 warning 字段(标明非零退出、Python 回溯、缺失模块);切勿仅凭 status 断定成功。避免大范围递归扫描(find … -name、rglob、os.walk、glob('**'))——易超时;优先用 `rg --files`。JSONL 须逐行解析,勿当作单个 JSON。支持异步:async=true 获取 job_id,再用 poll/cancel 查之。用此工具前,必先读 `bash-manual` 技能(涵盖定时任务、异步规范与进阶用法),无例外。 +- `shell`:执行指令,返 stdout/stderr。可运行系统上一切可用之程——脚本、git、curl、pip、数据管道等。返回 exit_code、stdout、stderr,并附 ok(布尔)与 command_status('success'/'failed')。要点:即便命令失败,顶层 status 仍为 'ok'——它仅表示 shell 已执行。务必检查 exit_code/ok 并阅读 warning 字段(标明非零退出、Python 回溯、缺失模块);切勿仅凭 status 断定成功。避免大范围递归扫描(find … -name、rglob、os.walk、glob('**'))——易超时;优先用 `rg --files`。JSONL 须逐行解析,勿当作单个 JSON。支持异步:async=true 获取 job_id,再用 poll/cancel 查之。用此工具前,必先读 `shell-manual` 技能(涵盖定时任务、异步规范与进阶用法),无例外。 - `action`:执行动作:'run'(默认)执行命令,'poll' 查询异步任务状态,'cancel' 终止异步任务 - `command`:要执行的 shell 命令 - `timeout`:超时秒数(默认:30,仅同步执行时生效) diff --git a/src/lingtai/tools/bash/manual/SKILL.md b/src/lingtai/tools/bash/manual/SKILL.md index 7758761ee..eda3563c0 100644 --- a/src/lingtai/tools/bash/manual/SKILL.md +++ b/src/lingtai/tools/bash/manual/SKILL.md @@ -1,11 +1,11 @@ --- -name: bash-manual +name: shell-manual description: > **Read this before running long-lived agent/coding CLIs (`claude -p`, `codex exec`, `opencode run`, Cursor Agent, Gemini CLI, Aider, Goose, OpenHands, Crush, or similar harnesses), or before setting up cron, launchd, systemd timers, crontab jobs, or scheduled reminders.** Router for - Bash-related operational depth beyond the bash tool schema: async + poll + Shell-related operational depth beyond the shell tool schema: async + poll discipline for long-running child agents, host-scheduler setup, LingTai wake-by-mailbox-drop, built-in async last-resort reminders, script hygiene, one-shot `.notification/cron.json` reminders, debugging silent jobs, and safe cleanup. Start here for any @@ -22,9 +22,9 @@ maintenance: | Tracks the routed source/resources it summarizes; update when the underlying capability or its sub-references change. --- -# Bash Manual — Router +# Shell Manual — Router -The `bash` tool schema covers one-off command execution. This manual routes to +The `shell` tool schema covers one-off command execution. This manual routes to operational depth that is too long for the schema: host scheduling, mailbox-drop wakeups, async last-resort reminders, reminder files, debugging, and cleanup. @@ -35,7 +35,7 @@ rule below), start here. ## Nested reference catalog -`bash-manual` owns these nested references. They are parent-owned drill-down +`shell-manual` owns these nested references. They are parent-owned drill-down files, not standalone top-level skills. ```yaml @@ -140,12 +140,12 @@ files, not standalone top-level skills. ## Quick decision tree 1. **Short deterministic host work** (finishes in seconds: `ls`, `git status`, - `grep`, a quick build)? Use `bash` synchronously; this manual is not needed + `grep`, a quick build)? Use `shell` synchronously; this manual is not needed unless the command is risky, scheduled, or failing mysteriously. 2. **Long-running agent/coding CLI** (`claude -p`, `codex exec`, `opencode run`, Cursor Agent, MiMo Code, Qwen Code, Oh-My-Pi, Kimi Code, Gemini CLI, Aider, Goose, OpenHands, Crush, or any sub-agent that may think/run tools for minutes)? - **Never run it synchronously.** Use `bash(async=true)` and poll — see the + **Never run it synchronously.** Use `shell(async=true)` and poll — see the resident rule below. 3. **Time itself is the trigger?** Read `reference/scheduled-work/SKILL.md`. 4. **You only need a single future nudge?** Read @@ -155,7 +155,7 @@ files, not standalone top-level skills. ## Reading command results — never trust top-level `status` alone -The top-level `status` of a `bash` result (`ok`/`done`) means only that the +The top-level `status` of a `shell` result (`ok`/`done`) means only that the **shell spawned** the command — **not** that the command succeeded. A failed build, a missing file, a Python traceback, or a missing import all come back under `status: "ok"`. Proceeding on that false success is the single most @@ -181,7 +181,7 @@ common way agents corrupt their own downstream work. ## Avoid broad recursive scans Unbounded recursive walks over large roots (`work/projects/.lingtai`) are the -top cause of `bash` timeouts. On a timeout the tool appends an `rg` recipe when +top cause of `shell` timeouts. On a timeout the tool appends an `rg` recipe when it detects this shape, but prefer it from the start: - Replace `find -name …`, `Path(...).rglob(...)`, `os.walk(...)`, and @@ -204,10 +204,10 @@ reading the whole file when you only need recent events. ## Core rules to keep resident -- **Synchronous `bash` is only for short, deterministic commands.** A long-running +- **Synchronous `shell` is only for short, deterministic commands.** A long-running agent/coding CLI session — `claude -p`, `codex exec`, `opencode run`, the Cursor agent CLI, or any sub-agent that may think and run tools for minutes — must - **never** be a synchronous `bash` call. Run it with `bash(async=true)` and poll + **never** be a synchronous `shell` call. Run it with `shell(async=true)` and poll the returned `job_id`. A synchronous call blocks the whole turn until the child exits: you stay `ACTIVE` and stop seeing channel notifications (mail, refresh, interrupts) for the entire duration. Async + poll keeps you responsive and @@ -215,21 +215,21 @@ reading the whole file when you only need recent events. ```text # Start the child agent in the background — returns immediately with a job_id: - bash(async=true, reminder=1800, command="claude -p 'refactor the auth module' --output-format json") + shell(async=true, reminder=1800, command="claude -p 'refactor the auth module' --output-format json") # → {"status": "ok", "job_id": "job-a1b2c3d4e5f678901234567890abcdef", "pid": 4321} # Later turns: poll until done (handle mail/other work between polls): - bash(action="poll", job_id="job-a1b2c3d4e5f678901234567890abcdef", reminder=1800) + shell(action="poll", job_id="job-a1b2c3d4e5f678901234567890abcdef", reminder=1800) # → {"status": "running", …} then eventually # → {"status": "done", "exit_code": 0, "ok": true, "command_status": "success", "stdout": "…", "stderr": "…"} # On failure: {"status": "done", "exit_code": 1, "ok": false, # "command_status": "failed", "warning": "command exited with code 1; …"} # Abandon it if needed: - bash(action="cancel", job_id="job-a1b2c3d4e5f678901234567890abcdef", reminder=1800) + shell(action="cancel", job_id="job-a1b2c3d4e5f678901234567890abcdef", reminder=1800) ``` -- **If repeated-call `_advisory` appears on `bash(action="poll")`, stop +- **If repeated-call `_advisory` appears on `shell(action="poll")`, stop tight polling.** The poll already executed; the advisory is not a block. If the job is still running and nothing meaningful changed, handle any human messages, do other work, or set one future reminder (`bash` notification @@ -238,7 +238,7 @@ reading the whole file when you only need recent events. reason to expect new state. - **Idle care: set an async `reminder` that matches the expected duration.** - Every `bash(async=true)` call has a last-resort `reminder` delay, required in + Every `shell(async=true)` call has a last-resort `reminder` delay, required in the top-level provider schema and defaulted by the runtime to 1800 seconds when omitted by older direct callers. Provider-facing sync commands, `poll`, and `cancel` also carry `reminder` because of that schema shape, but the field @@ -250,17 +250,17 @@ reading the whole file when you only need recent events. `Popen`, or after the command is durably `running` but before async `run` has completed its return transition. Successful `run` atomically resets the deadline to `returned_at + reminder` and arms the guard, so startup latency does - not consume the interval you requested. Bash reports `status: ok` only when this + not consume the interval you requested. Shell reports `status: ok` only when this still-valid transition wins (or an exact completed/failed result already won under the valid guard). If the owner resumes after expiry, it returns `status: error` with the durable `job_id`/`pid` and an explicit "remains pollable" recovery message rather than claiming false success. A live job keeps the expired fallback; an expired launch or definitively dead supervisor becomes explicit unrecoverable state instead. - If the job is still non-terminal when its final deadline expires, Bash publishes + If the job is still non-terminal when its final deadline expires, Shell publishes a `bash.reminder` event into `.notification/system.json`. The deadline and stable `bash.reminder:` claim survive agent - stop/relaunch: Bash re-arms a future deadline or retries an overdue/stale claim. + stop/relaunch: Shell re-arms a future deadline or retries an overdue/stale claim. Cancellation temporarily uses a bounded durable `suppressing` state; if the manager crashes or the supervisor does not commit before it expires, reminder publication becomes recoverable again. Exact supervisor terminal commit @@ -282,7 +282,7 @@ reading the whole file when you only need recent events. interactive prompt or a provider/model error. If there is no progress, do not keep waiting — cancel, downgrade, or switch path, and report to the human. Use a separate `.notification/cron.json` reminder or delayed self-email only - for a broader workflow wake that is not tied to one Bash async job. Do not + for a broader workflow wake that is not tied to one Shell async job. Do not conflate them: a Bash reminder belongs to one persisted `job_id`, while `.notification/cron.json` is a separately scheduled workflow wake. diff --git a/src/lingtai/tools/bash/manual/reference/bash-aider/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-aider/SKILL.md index d71cd4563..7292daf3a 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-aider/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-aider/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-aider description: > - Nested bash-manual reference for Aider CLI. Read this when you need to run, + Nested shell-manual reference for Aider CLI. Read this when you need to run, validate, or document `aider` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Aider CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `aider`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ aider --message "" ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-claude-code/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-claude-code/SKILL.md index efffd4af7..b0c7bacd1 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-claude-code/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-claude-code/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-claude-code description: > - Nested bash-manual reference for Claude Code CLI. Delegate code implementation, patch writing, documentation, and refactoring to + Nested shell-manual reference for Claude Code CLI. Delegate code implementation, patch writing, documentation, and refactoring to Claude Code CLI (Anthropic's coding agent). Runs non-interactively from bash, uses the human's Claude Max subscription (no additional API costs), and supports quality/effort/budget controls. Use this when you need to write code, generate @@ -19,7 +19,7 @@ maintenance: | # Claude Code CLI — Code Delegation -> Ownership: this CLI-agent reference now lives under `bash-manual` +> Ownership: this CLI-agent reference now lives under `shell-manual` > because the workflow is executed as a long-running shell subprocess. > It was moved from `swiss-knife` during the bash harness migration. @@ -88,7 +88,7 @@ If the variable is hard-coded in a shell startup file, comment out only that exp LingTai exposes Claude Code in two forms. They are **not interchangeable** — pick the one whose shape matches the work. -### CLI (`claude -p ...` via bash) +### CLI (`claude -p ...` via shell) A single synchronous subprocess. You wait for it to finish, you get one transcript, the conversation ends when the bash call returns. diff --git a/src/lingtai/tools/bash/manual/reference/bash-crush/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-crush/SKILL.md index f9fa75866..d29e53531 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-crush/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-crush/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-crush description: > - Nested bash-manual reference for Charm Crush CLI. Read this when you need to run, + Nested shell-manual reference for Charm Crush CLI. Read this when you need to run, validate, or document `crush` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Charm Crush CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `crush`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ crush run "" ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-cursor-agent/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-cursor-agent/SKILL.md index 8099ad413..9d2adbc1f 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-cursor-agent/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-cursor-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-cursor-agent description: > - Nested bash-manual reference for Cursor Agent CLI. Read this when you need to run, + Nested shell-manual reference for Cursor Agent CLI. Read this when you need to run, validate, or document `cursor-agent` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Cursor Agent CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `cursor-agent`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ cursor-agent --print ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-gemini-cli/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-gemini-cli/SKILL.md index 9b5f44363..8bebcf9a3 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-gemini-cli/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-gemini-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-gemini-cli description: > - Nested bash-manual reference for Gemini CLI. Read this when you need to run, + Nested shell-manual reference for Gemini CLI. Read this when you need to run, validate, or document `gemini` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Gemini CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `gemini`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ gemini -p "" / gemini --prompt "" ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-goose/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-goose/SKILL.md index 02952ae9f..32fb74e6c 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-goose/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-goose/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-goose description: > - Nested bash-manual reference for Goose CLI. Read this when you need to run, + Nested shell-manual reference for Goose CLI. Read this when you need to run, validate, or document `goose` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Goose CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `goose`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ goose run/session commands (verify current `goose --help`) ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-kimicode/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-kimicode/SKILL.md index 00583eca9..11e88cc51 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-kimicode/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-kimicode/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-kimicode description: > - Nested bash-manual reference for Kimi Code CLI. Read this when you need to run, + Nested shell-manual reference for Kimi Code CLI. Read this when you need to run, validate, or document `kimicode / kimi` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Kimi Code CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `kimicode / kimi`: command shape, async/poll discipline, one-shot mode, and the session/resume caveats that keep `ask` unsupported in the daemon backend. @@ -40,7 +40,7 @@ kimi --prompt '' --output-format text free-form `backend_options` are inserted before those owned flags. Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## Environment diff --git a/src/lingtai/tools/bash/manual/reference/bash-mimocode/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-mimocode/SKILL.md index de4b29c54..12520cd23 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-mimocode/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-mimocode/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-mimocode description: > - Nested bash-manual reference for MiMo Code CLI. Read this when you need to run, + Nested shell-manual reference for MiMo Code CLI. Read this when you need to run, validate, or document `mimocode / mimo` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # MiMo Code CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `mimocode / mimo`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ mimocode (daemon backend uses the tested command shape in daemon docs) ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-oh-my-pi/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-oh-my-pi/SKILL.md index a5f1412f5..dbb34c3b1 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-oh-my-pi/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-oh-my-pi/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-oh-my-pi description: > - Nested bash-manual reference for Oh-My-Pi CLI. Read this when you need to run, + Nested shell-manual reference for Oh-My-Pi CLI. Read this when you need to run, validate, or document `omp` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Oh-My-Pi CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `omp`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ omp --mode json --approval-mode yolo ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-openai-codex/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-openai-codex/SKILL.md index 7acb88c41..250cdbe26 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-openai-codex/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-openai-codex/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-openai-codex description: > - Nested bash-manual reference for OpenAI Codex CLI. Manual (not a tool) for OpenAI Codex CLI — OpenAI's coding agent that runs + Nested shell-manual reference for OpenAI Codex CLI. Manual (not a tool) for OpenAI Codex CLI — OpenAI's coding agent that runs locally from your terminal. Built in Rust for speed and efficiency. Supports headless remote control, Vim editing, plugin management, hooks, and Chrome browser integration. Read this when the human asks to use OpenAI Codex CLI, @@ -19,7 +19,7 @@ maintenance: | # OpenAI Codex CLI -> Ownership: this CLI-agent reference now lives under `bash-manual` +> Ownership: this CLI-agent reference now lives under `shell-manual` > because the workflow is executed as a long-running shell subprocess. > It was moved from `swiss-knife` during the bash harness migration. @@ -30,7 +30,7 @@ maintenance: | LingTai exposes Codex in two forms. They are **not interchangeable** — pick the one whose shape matches the work. -### CLI (`codex exec ...` via bash) +### CLI (`codex exec ...` via shell) A single synchronous subprocess. You wait for it to finish, you get one transcript back, the conversation ends when the bash call returns. diff --git a/src/lingtai/tools/bash/manual/reference/bash-opencode/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-opencode/SKILL.md index 3475db5c2..0b5ddd84f 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-opencode/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-opencode/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-opencode description: > - Nested bash-manual reference for OpenCode CLI. Manual (not a tool) for OpenCode CLI — an open-source terminal coding agent + Nested shell-manual reference for OpenCode CLI. Manual (not a tool) for OpenCode CLI — an open-source terminal coding agent that runs locally, supports 75+ LLM providers through Models.dev, and can be scripted with `opencode run` or served through a reusable headless backend. Read this when the human asks to use OpenCode as a CLI tool, compare it with @@ -19,7 +19,7 @@ maintenance: | # OpenCode CLI — Local Coding Agent -> Ownership: this CLI-agent reference now lives under `bash-manual` +> Ownership: this CLI-agent reference now lives under `shell-manual` > because the workflow is executed as a long-running shell subprocess. > It was moved from `swiss-knife` during the bash harness migration. @@ -54,7 +54,7 @@ OpenCode stores provider credentials in `~/.local/share/opencode/auth.json`. It This sub-skill is about using OpenCode directly from the shell. For longer work, first check whether your active LingTai daemon schema explicitly supports an OpenCode backend; otherwise supervise OpenCode through bash/worktrees. -### CLI (`opencode run ...` via bash) +### CLI (`opencode run ...` via shell) A single synchronous subprocess. You wait for it to finish, review its transcript/diff, and decide the next step in your own context. diff --git a/src/lingtai/tools/bash/manual/reference/bash-openhands/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-openhands/SKILL.md index f2249db88..3ebd96575 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-openhands/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-openhands/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-openhands description: > - Nested bash-manual reference for OpenHands CLI. Read this when you need to run, + Nested shell-manual reference for OpenHands CLI. Read this when you need to run, validate, or document `openhands` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # OpenHands CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `openhands`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ openhands --task "" --json ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-qwen-code/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-qwen-code/SKILL.md index 6143dadce..22591c22a 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-qwen-code/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-qwen-code/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-qwen-code description: > - Nested bash-manual reference for Qwen Code CLI. Read this when you need to run, + Nested shell-manual reference for Qwen Code CLI. Read this when you need to run, validate, or document `qwen-code / qwen` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Qwen Code CLI -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `qwen-code / qwen`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ qwen-code (daemon backend uses the tested command shape in daemon docs) ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/bash-zed-acp/SKILL.md b/src/lingtai/tools/bash/manual/reference/bash-zed-acp/SKILL.md index 7ce696d03..63a22a754 100644 --- a/src/lingtai/tools/bash/manual/reference/bash-zed-acp/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/bash-zed-acp/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-zed-acp description: > - Nested bash-manual reference for Zed / ACP agent bridge. Read this when you need to run, + Nested shell-manual reference for Zed / ACP agent bridge. Read this when you need to run, validate, or document `ACP servers` as a long-running shell subprocess or LingTai daemon harness candidate. version: 0.1.0 @@ -15,7 +15,7 @@ maintenance: | # Zed / ACP agent bridge -Nested bash-manual reference. This page owns **shell execution hygiene** for +Nested shell-manual reference. This page owns **shell execution hygiene** for `ACP servers`: command shape, async/poll discipline, approval flags, session/resume caveats, and whether the CLI is ready for a LingTai daemon backend. @@ -30,7 +30,7 @@ agent-specific ACP command ``` Before relying on the command in production, run the current CLI's `--help` and -prefer `bash(async=true)` for work that can think, edit files, or run tools for +prefer `shell(async=true)` for work that can think, edit files, or run tools for minutes. Do not run long coding CLIs synchronously from the parent turn. ## LingTai daemon notes diff --git a/src/lingtai/tools/bash/manual/reference/debugging-cleanup/SKILL.md b/src/lingtai/tools/bash/manual/reference/debugging-cleanup/SKILL.md index 0ee41a132..39c8fedd4 100644 --- a/src/lingtai/tools/bash/manual/reference/debugging-cleanup/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/debugging-cleanup/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-debugging-cleanup description: > - Nested bash-manual reference for debugging silent scheduled jobs and retiring + Nested shell-manual reference for debugging silent scheduled jobs and retiring cron jobs safely: scheduler fired, script ran, work landed, agent saw mail, worked launchd diagnosis, cleanup, and bash work footprint hygiene. version: 1.0.0 @@ -15,7 +15,7 @@ maintenance: | # Debugging and Cleanup Reference -Nested bash-manual reference. Open this when a scheduled job goes silent, fires +Nested shell-manual reference. Open this when a scheduled job goes silent, fires incorrectly, or needs to be retired or cleaned up. ## Debugging cron — when things go silent diff --git a/src/lingtai/tools/bash/manual/reference/notification-reminders/SKILL.md b/src/lingtai/tools/bash/manual/reference/notification-reminders/SKILL.md index 28f5ac9b8..a3d868978 100644 --- a/src/lingtai/tools/bash/manual/reference/notification-reminders/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/notification-reminders/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-notification-reminders description: > - Nested bash-manual reference for one-shot wakeup reminders using + Nested shell-manual reference for one-shot wakeup reminders using `.notification/cron.json`: payload shape, atomic writer, shell example, and the rest checklist for agents leaving work pending. version: 1.0.0 @@ -16,7 +16,7 @@ maintenance: | # Notification Reminder Reference -Nested bash-manual reference. Open this when you need a one-shot reminder or a +Nested shell-manual reference. Open this when you need a one-shot reminder or a lightweight wakeup nudge rather than a full recurring host scheduler. ## One-shot wakeup reminders via `.notification/cron.json` diff --git a/src/lingtai/tools/bash/manual/reference/scheduled-work/SKILL.md b/src/lingtai/tools/bash/manual/reference/scheduled-work/SKILL.md index f9358d131..d95a403fc 100644 --- a/src/lingtai/tools/bash/manual/reference/scheduled-work/SKILL.md +++ b/src/lingtai/tools/bash/manual/reference/scheduled-work/SKILL.md @@ -1,7 +1,7 @@ --- name: bash-scheduled-work description: > - Nested bash-manual reference for cron-driven scheduled work: when to use host + Nested shell-manual reference for cron-driven scheduled work: when to use host schedulers, the LingTai wake-by-mailbox-drop contract, prompt boundaries, script hygiene, macOS launchd, Linux systemd timers, crontab fallback, and the launchd process-tree reaping gotcha. @@ -16,7 +16,7 @@ maintenance: | # Scheduled Work Reference -Nested bash-manual reference. Open this when the top-level `bash-manual` router +Nested shell-manual reference. Open this when the top-level `shell-manual` router selects host-scheduler setup for recurring or time-driven work. ## Scheduled / cron-driven work diff --git a/src/lingtai/tools/bash/powershell_policy.json b/src/lingtai/tools/bash/powershell_policy.json new file mode 100644 index 000000000..e0b0eb79c --- /dev/null +++ b/src/lingtai/tools/bash/powershell_policy.json @@ -0,0 +1,12 @@ +{ + "deny": [ + "Remove-Item", "ri", "rm", "rmdir", "del", "erase", "rd", + "Format-Volume", "Clear-Disk", "Initialize-Disk", + "Stop-Computer", "Restart-Computer", "Stop-Process", "kill", + "Invoke-Expression", "iex", "Start-Process", + "Set-Acl", "icacls", "takeown", + "Invoke-WebRequest", "iwr", "curl", "wget", + "Set-ExecutionPolicy", "Unregister-ScheduledTask", + "reg", "sc", "diskpart" + ] +} diff --git a/src/lingtai/tools/daemon/ANATOMY.md b/src/lingtai/tools/daemon/ANATOMY.md index e3c13120a..6c506f743 100644 --- a/src/lingtai/tools/daemon/ANATOMY.md +++ b/src/lingtai/tools/daemon/ANATOMY.md @@ -90,7 +90,7 @@ polling. ## Components -- `daemon/__init__.py` — public capability surface. `get_description`, `get_schema`, and `setup`; the core class is `DaemonManager`, which manages the full emanation lifecycle and parent-stop cleanup. Key internals: `_ToolCollector` (`daemon/__init__.py:743`) intercepts `add_tool` calls during preset-driven capability setup to build a sandboxed tool surface without mutating the parent's registry. `EMANATION_BLACKLIST` (`daemon/__init__.py:65`) prevents recursion by blocking `daemon`, `avatar`, `psyche`, `skills`, and `knowledge`; `_parent_host_tool_floor()` (`daemon/__init__.py:76`) derives the NARROW set of always-on host tools a preset emanation may borrow from the parent — exactly `bash` + the file group (read/write/edit/glob/grep), derived from `CORE_DEFAULTS − EMANATION_BLACKLIST − {mcp}`; optional/provider parent tools (`vision`, `web_search`) are deliberately excluded so a preset that omits/fails a provider cap does not silently fall back to the parent; `_reconcile_terminal_notifications()` (`daemon/__init__.py:1116-1151`) retries terminal run dirs whose published receipt is missing, after stale-parent reaping; `_write_kimicode_mcp_config()` (`daemon/__init__.py:309-337`) writes the run-private Kimi `mcp.json` for stdio and HTTP registrations; `_daemon_intrinsic_surface()` (`daemon/__init__.py:1207`) is the narrow daemon intrinsic bridge for explicitly requested `email` only; `_task_mcp_registrations()` (`daemon/__init__.py:1296`) validates full per-task MCP registrations and renders prompt-safe YAML; `_daemon_common_mcp_registration()` / `_read_daemon_completion()` (`daemon/__init__.py:1390` / `daemon/__init__.py:1460`) add and validate the built-in completion MCP; `_connect_task_mcp_registrations()` (`daemon/__init__.py:1550`) starts task-scoped MCP clients for the LingTai backend; `_daemon_provider_defaults()` (`daemon/__init__.py:1695`) preserves the parent/preset provider defaults for every provider and adds the per-run `daemon.json` cache anchor only for Codex; `_build_tool_surface()` (`daemon/__init__.py:1786`) includes only task-scoped MCP tools rather than auto-inheriting parent MCP tools, and — for preset-driven emanations — preserves the parent's narrow host tool floor (`_parent_host_tool_floor()`: bash + file primitives) that saved presets omit from `manifest.capabilities`, so requested host tools valid in the parent set are not rejected as unknown (resolution is preset-first, then intrinsics/MCP, then parent host fill-in); `_run_emanation()` (`daemon/__init__.py:2074`) is the LingTai-backend worker loop that builds a fresh daemon-scoped service for every no-preset run instead of reusing the parent service and enforces `finish(done)` before `mark_done`. +- `daemon/__init__.py` — public capability surface. `get_description`, `get_schema`, and `setup`; the core class is `DaemonManager`, which manages the full emanation lifecycle and parent-stop cleanup. Key internals: `_ToolCollector` (`daemon/__init__.py:743`) intercepts `add_tool` calls during preset-driven capability setup to build a sandboxed tool surface without mutating the parent's registry. `EMANATION_BLACKLIST` (`daemon/__init__.py:65`) prevents recursion by blocking `daemon`, `avatar`, `psyche`, `skills`, and `knowledge`; `_parent_host_tool_floor()` (`daemon/__init__.py:76`) derives the NARROW set of always-on host tools a preset emanation may borrow from the parent — exactly `shell` + the file group (read/write/edit/glob/grep), derived from `CORE_DEFAULTS − EMANATION_BLACKLIST − {mcp}`; optional/provider parent tools (`vision`, `web_search`) are deliberately excluded so a preset that omits/fails a provider cap does not silently fall back to the parent; `_reconcile_terminal_notifications()` (`daemon/__init__.py:1116-1151`) retries terminal run dirs whose published receipt is missing, after stale-parent reaping; `_write_kimicode_mcp_config()` (`daemon/__init__.py:309-337`) writes the run-private Kimi `mcp.json` for stdio and HTTP registrations; `_daemon_intrinsic_surface()` (`daemon/__init__.py:1207`) is the narrow daemon intrinsic bridge for explicitly requested `email` only; `_task_mcp_registrations()` (`daemon/__init__.py:1296`) validates full per-task MCP registrations and renders prompt-safe YAML; `_daemon_common_mcp_registration()` / `_read_daemon_completion()` (`daemon/__init__.py:1390` / `daemon/__init__.py:1460`) add and validate the built-in completion MCP; `_connect_task_mcp_registrations()` (`daemon/__init__.py:1550`) starts task-scoped MCP clients for the LingTai backend; `_daemon_provider_defaults()` (`daemon/__init__.py:1695`) preserves the parent/preset provider defaults for every provider and adds the per-run `daemon.json` cache anchor only for Codex; `_build_tool_surface()` (`daemon/__init__.py:1786`) includes only task-scoped MCP tools rather than auto-inheriting parent MCP tools, and — for preset-driven emanations — preserves the parent's narrow host tool floor (`_parent_host_tool_floor()`: shell + file primitives) that saved presets omit from `manifest.capabilities`, so requested host tools valid in the parent set are not rejected as unknown (resolution is preset-first, then intrinsics/MCP, then parent host fill-in); `_run_emanation()` (`daemon/__init__.py:2074`) is the LingTai-backend worker loop that builds a fresh daemon-scoped service for every no-preset run instead of reusing the parent service and enforces `finish(done)` before `mark_done`. - `daemon/claude_interactive.py` — interactive Claude Code daemon backend. **Hidden / not user-selectable:** `claude` / `claude-interactive` were removed from the `get_schema()` `backend` enum and description (`daemon/__init__.py:889-904`); the code path stays only so older callers and stored daemon entries with `backend="claude"` still resolve through `_normalize_backend`/dispatch. New work uses print-mode `claude-p`. This retained PTY bridge is POSIX-only; Windows headless composition never selects it, and native interactive support @@ -253,7 +253,7 @@ results without requiring handler wrappers. ## Composition - **Parent:** `src/lingtai/tools/` (tool package). -- **Siblings:** `avatar/`, `mcp/`, `knowledge/` (private durable memory), `skills/` (skill catalog), `bash/`. +- **Siblings:** `avatar/`, `mcp/`, `knowledge/` (private durable memory), `skills/` (skill catalog), canonical `shell` (retained `bash/` implementation). - **Manual:** `daemon/manual/SKILL.md` — skill documentation for the LLM. - **Contract:** `daemon/DAEMON_CONTRACT.md` — cross-backend architecture capability contract for selected skills, one-run MCP registrations, completion, artifacts, backend support status, review triggers, and acceptance gates. - **Kernel hooks:** `setup()` is called during capability initialization; `DaemonManager.handle()` is registered as the `daemon` tool handler. diff --git a/src/lingtai/tools/daemon/__init__.py b/src/lingtai/tools/daemon/__init__.py index 863350073..233b23300 100644 --- a/src/lingtai/tools/daemon/__init__.py +++ b/src/lingtai/tools/daemon/__init__.py @@ -145,7 +145,7 @@ def _parent_host_tool_floor() -> frozenset[str]: the TUI preset wizard only writes overrides/opt-ins into ``manifest.capabilities``. So those floor tools must still resolve from the parent surface under a preset. But the floor is exactly the host - primitives — ``bash`` and the ``file`` group (read/write/edit/glob/grep) — + primitives — ``shell`` and the ``file`` group (read/write/edit/glob/grep) — and nothing more: optional/provider parent tools (e.g. ``vision``, ``web_search``) must NOT silently fall back to the parent when a preset omits or fails them. @@ -156,7 +156,7 @@ def _parent_host_tool_floor() -> frozenset[str]: register no emanation-usable tool surface; * ``mcp`` — the MCP host registers no regular tool of its own; parent MCP tools are inherited only via task ``mcp`` registrations, never the floor. - The result is exactly {bash, read, write, edit, glob, grep}. + The result is exactly {shell, read, write, edit, glob, grep}. """ from lingtai.tools.registry import CORE_DEFAULTS # noqa: PLC0415 return frozenset(set(CORE_DEFAULTS) - EMANATION_BLACKLIST - {"mcp"}) @@ -925,7 +925,7 @@ def get_schema(lang: str = "en") -> dict: }, "required": ["task", "tools"], }, - "description": "List of task objects for 'emanate'. Each: {task: str (required — instructions including where to save work), tools: list[str] (required — capability names, e.g. ['file', 'bash']), skills: list[str] (optional — skill directory or SKILL.md paths to render into the daemon prompt), mcp: list[object] (optional — full one-run MCP registrations to serialize into daemon context; LingTai backend also loads them as task-scoped MCP tools), preset: str (optional — preset file path, use name from system(action='presets') output). Omit preset to inherit the parent's regular tool surface. Parent MCP tools are not auto-inherited; provide complete task mcp registrations when needed. See daemon-manual for preset inheritance and capability resolution details.", + "description": "List of task objects for 'emanate'. Each: {task: str (required — instructions including where to save work), tools: list[str] (required — capability names, e.g. ['file', 'shell']), skills: list[str] (optional — skill directory or SKILL.md paths to render into the daemon prompt), mcp: list[object] (optional — full one-run MCP registrations to serialize into daemon context; LingTai backend also loads them as task-scoped MCP tools), preset: str (optional — preset file path, use name from system(action='presets') output). Omit preset to inherit the parent's regular tool surface. Parent MCP tools are not auto-inherited; provide complete task mcp registrations when needed. See daemon-manual for preset inheritance and capability resolution details.", }, "id": { "type": "string", @@ -2023,7 +2023,7 @@ def _build_tool_surface( preset's pre-instantiated sandbox supplies the child LLM's provider-specific capabilities (``preset_surface = (schemas_by_name, handlers_by_name)``), but it does NOT replace the - parent's always-on host tool floor. Only that narrow floor — ``bash`` + parent's always-on host tool floor. Only that narrow floor — ``shell`` and the file primitives (read/write/edit/glob/grep) the preset wizard omits from ``manifest.capabilities`` — stays available from the parent, so requested host tools are not rejected as unknown just because a @@ -2060,7 +2060,7 @@ def _build_tool_surface( preset_schemas, preset_handlers = preset_surface # A preset selects the child LLM + provider-specific capabilities; # it does NOT re-declare the parent's always-on CORE_DEFAULTS host - # floor (bash / read / write / edit / glob / grep), because the + # floor (shell / read / write / edit / glob / grep), because the # preset wizard only writes overrides/opt-ins into # manifest.capabilities. So those floor tools must remain available # — they must not become "unknown" just because a preset was @@ -2155,10 +2155,11 @@ def _parent_mcp_tool_names(self) -> set[str]: def _expand_requested_tools(self, requested: list[str]) -> set[str]: """Expand requested daemon tools after group aliases and blacklist.""" - from lingtai.tools.registry import _GROUPS + from lingtai.tools.registry import _GROUPS, canonical_capability_name tool_names: set[str] = set() for name in requested: + name = canonical_capability_name(name) if name in EMANATION_BLACKLIST: continue if name in _GROUPS: diff --git a/src/lingtai/tools/daemon/execution_host.py b/src/lingtai/tools/daemon/execution_host.py index 794d33bac..735f496f4 100644 --- a/src/lingtai/tools/daemon/execution_host.py +++ b/src/lingtai/tools/daemon/execution_host.py @@ -113,10 +113,11 @@ def __init__( # setup used by the parent manager. No full Agent/workdir lease is # constructed in this process. from lingtai.tools.daemon import EMANATION_BLACKLIST, _ToolCollector - from lingtai.tools.registry import BUILTIN_TOOLS, setup_capability + from lingtai.tools.registry import BUILTIN_TOOLS, canonical_capability_name, setup_capability collector = _ToolCollector(self._agent) names = set() for name in manifest.get("tools", []): + name = canonical_capability_name(name) if name in EMANATION_BLACKLIST: continue names.add(name) diff --git a/src/lingtai/tools/daemon/manual/SKILL.md b/src/lingtai/tools/daemon/manual/SKILL.md index 76b488517..28af54203 100644 --- a/src/lingtai/tools/daemon/manual/SKILL.md +++ b/src/lingtai/tools/daemon/manual/SKILL.md @@ -110,7 +110,7 @@ files, not standalone top-level skills. report path, or ask a named peer before guessing). `email` is daemon-eligible communication, but it is not granted by default; include `tools: ["email"]` only when the daemon should be able to use internal mail. - Other tool names still matter for file/bash/web/etc. access. + Other tool names still matter for file/shell/web/etc. access. - `skills` answers **which workflows the daemon should know about**. It is an optional list of strings. Each string may be either a skill directory containing `SKILL.md` or a direct `SKILL.md` path; relative paths resolve @@ -154,7 +154,7 @@ files, not standalone top-level skills. - `backend_options`: raw CLI flags for CLI backends only. - `context_token_limit`: optional context-token compaction threshold (rendered/provider-context tokens, not cumulative spend). Effective only for `backend="lingtai"` tasks whose resolved provider is Codex (`codex`/`codex-pool`) or the native `mimo` LLM provider (`manifest.llm.provider="mimo"` — NOT the `backend` enum's `mimo`/`mimocode` alias, which drives the external `mimo` CLI as a subprocess and never consults this field at all); every other provider and every external CLI backend ignores it. When the session's provider-visible input-token count reaches the limit, the runtime compacts provider context via that provider's standalone compaction (`POST /responses/compact`) and continues the same tool loop — the daemon keeps running; nothing restarts or drops history. Native `mimo` defaults to the stateless OpenAI Responses wire (full-history/raw-output-item replay; never `store`/`previous_response_id`/`conversation`/generic `context_management` — MiMo's Responses API marks those incompatible); an explicit `wire_api="chat_completions"` on the preset still selects the Chat Completions escape hatch instead. **Failure policy differs by provider:** a standalone-compaction failure is non-fatal for Codex (that turn's compaction is skipped; the loop continues on full history) but a HARD failure for native `mimo` (it propagates to the caller — never silently continuing on full history and never falling back to a different wire), because MiMo has no generic `context_management` fallback and no server-side state to lean on. Omit to inherit the parent service's resolved context window as the threshold. Must be a positive integer; a boolean is rejected. - Treat `system_prompt` as the parent's behavioral contract for **all** tools - and selected skills/MCP context, not only for communication. If a daemon receives `bash`, + and selected skills/MCP context, not only for communication. If a daemon receives `shell`, say whether it may run mutating commands; if it receives file access, say what it may read/write; if it receives web/MCP tools, say what external calls are allowed; if it can communicate, say who it may contact and what context it may @@ -240,7 +240,7 @@ contract: { "task": "Audit the daemon manual changes and write a concise review to reports/daemon-manual-review.md.", "system_prompt": "Act as a documentation reviewer. Stay read-only except for the requested report file. Use the selected daemon-manual skills only when you need exact daemon semantics. Use the local-docs MCP only for daemon documentation lookup, not for unrelated search. You may use email only to ask dev-2 for missing daemon context; do not contact the human. If you email dev-2, state the exact question, include only the relevant snippet, and summarize the exchange in your final report. Do not use web tools unless the local docs are insufficient.", - "tools": ["file", "bash"], + "tools": ["file", "shell"], "mcp": [ {"name": "local-docs", "transport": "stdio", "command": "python", "args": ["-m", "local_docs_mcp"]} ], diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/SKILL.md index a3fb89e3b..ecf519bd8 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/SKILL.md @@ -39,7 +39,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the Codex daemon backend's flag surface. Read this only when a daemon task needs Codex-specific CLI flags (model selection, reasoning effort, config overrides): it routes to the - installed CLI's live help via bash and shows how to translate that help + installed CLI's live help via shell and shows how to translate that help into generic `backend_options` (e.g. repeated `--config` overrides). - name: daemon-backend-opencode location: reference/backends/opencode/SKILL.md @@ -47,7 +47,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the OpenCode daemon backend's flag surface. Read this only when a daemon task needs OpenCode-specific CLI flags (model selection, reasoning variants, agent choice): it routes - to the installed CLI's live help via bash and shows how to translate that + to the installed CLI's live help via shell and shows how to translate that help into generic `backend_options` (e.g. `--model provider/model`). - name: daemon-backend-claude-p location: reference/backends/claude-p/SKILL.md @@ -55,7 +55,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the claude-p (alias claude-code) daemon backend's flag surface. Read this only when a daemon task needs Claude Code-specific CLI flags (model selection, fallback model, tool - restrictions): it routes to the installed CLI's live help via bash and + restrictions): it routes to the installed CLI's live help via shell and shows how to translate that help into generic `backend_options` (e.g. underscore keys becoming dashed long flags like `--fallback-model`). - name: daemon-backend-mimocode @@ -64,7 +64,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the MiMo Code (`mimocode` / `mimo`) daemon backend's flag surface. Read this only when a daemon task needs MiMo-specific CLI flags (model selection, provider switches): it - routes to the installed CLI's live help via bash (`mimo run --help`) and + routes to the installed CLI's live help via shell (`mimo run --help`) and shows how to translate that help into generic `backend_options`. - name: daemon-backend-qwen-code location: reference/backends/qwen-code/SKILL.md @@ -72,7 +72,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the Qwen Code (`qwen-code` / `qwen`) daemon backend's flag surface. Read this only when a daemon task needs Qwen-specific CLI flags (model selection, provider tunables): it - routes to the installed CLI's live help via bash and shows how to + routes to the installed CLI's live help via shell and shows how to translate that help into generic `backend_options`, plus the backend's reserved-flag and no-resume boundaries. - name: daemon-backend-kimicode @@ -81,7 +81,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the Kimi Code daemon backend's flag surface. Read this only when a daemon task needs Kimi-specific CLI flags (model selection, skills/workspace directories): it routes to the - installed CLI's live help via bash and shows how to translate that help + installed CLI's live help via shell and shows how to translate that help into generic `backend_options`, plus the exact reserved harness flags, the run-private `mcp.json` loader, and the current ask/resume limitation. - name: daemon-backend-cursor @@ -90,7 +90,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the Cursor daemon backend's flag surface. Read this only when a daemon task needs Cursor-specific CLI flags (model selection, output/tooling switches): it routes to the - installed `agent` CLI's live help via bash and shows how to translate + installed `agent` CLI's live help via shell and shows how to translate that help into generic `backend_options`. - name: daemon-backend-oh-my-pi location: reference/backends/oh-my-pi/SKILL.md @@ -98,7 +98,7 @@ catalog. Only backends with proven demand get a page. Nested daemon-cli-backends reference for the Oh-My-Pi (`omp`) daemon backend's flag surface. Read this only when a daemon task needs Oh-My-Pi-specific CLI flags (model selection, tool or provider - switches): it routes to the installed CLI's live help via bash, lists + switches): it routes to the installed CLI's live help via shell, lists the exact harness-reserved flags, and shows how to translate that help into generic `backend_options`. - name: daemon-backend-lingtai @@ -152,21 +152,21 @@ ownership: - This page owns the daemon API contract: backend names, `daemon(...)` behavior, `backend_options`, result/session capture, `ask`/resume, and backend-specific parser caveats. -- `bash-manual` owns the shell subprocess recipes for the underlying CLIs. Before +- `shell-manual` owns the shell subprocess recipes for the underlying CLIs. Before launching or troubleshooting a long-running coding CLI directly from bash, read the matching nested bash reference: - - Claude Code: `bash-manual` → `reference/bash-claude-code/SKILL.md` - - OpenAI Codex: `bash-manual` → `reference/bash-openai-codex/SKILL.md` - - OpenCode: `bash-manual` → `reference/bash-opencode/SKILL.md` - - Cursor Agent: `bash-manual` → `reference/bash-cursor-agent/SKILL.md` - - MiMo Code: `bash-manual` → `reference/bash-mimocode/SKILL.md` - - Qwen Code: `bash-manual` → `reference/bash-qwen-code/SKILL.md` - - Oh-My-Pi / Pi Coding Agent: `bash-manual` → + - Claude Code: `shell-manual` → `reference/bash-claude-code/SKILL.md` + - OpenAI Codex: `shell-manual` → `reference/bash-openai-codex/SKILL.md` + - OpenCode: `shell-manual` → `reference/bash-opencode/SKILL.md` + - Cursor Agent: `shell-manual` → `reference/bash-cursor-agent/SKILL.md` + - MiMo Code: `shell-manual` → `reference/bash-mimocode/SKILL.md` + - Qwen Code: `shell-manual` → `reference/bash-qwen-code/SKILL.md` + - Oh-My-Pi / Pi Coding Agent: `shell-manual` → `reference/bash-oh-my-pi/SKILL.md` - - Kimi Code: `bash-manual` → `reference/bash-kimicode/SKILL.md` + - Kimi Code: `shell-manual` → `reference/bash-kimicode/SKILL.md` Candidate harnesses that are not daemon backends yet (Gemini CLI, Aider, Goose, -OpenHands, Crush, and Zed/ACP bridges) are tracked under `bash-manual` as +OpenHands, Crush, and Zed/ACP bridges) are tracked under `shell-manual` as `reference/bash-*/SKILL.md` pages until their command/session contracts are stable enough for backend promotion. @@ -243,7 +243,7 @@ intrinsic is available only when explicitly requested in the task `tools` list, so result-only/no-tool emanations cannot communicate in the local agent network unless the parent opted in. Other intrinsics remain unavailable to keep daemon lightweight and -non-recursive. As with file/bash/web/MCP tools, technical availability is not a +non-recursive. As with file/shell/web/MCP tools, technical availability is not a policy by itself: the parent should use `system_prompt` to say when and how the daemon may use any available tool, including who it may contact and what context it may share if email is involved. @@ -308,7 +308,7 @@ loader), read `reference/backends/kimicode/SKILL.md`. For Cursor (binary `agent` This is intentionally a passthrough, not a fixed table. Claude Code, Codex, OpenCode, MiMo Code, Qwen Code, Oh-My-Pi, Kimi Code, and Cursor rev their flag lists between releases. Before adding new options, run the installed CLI's -`--help` in `bash` to discover what it supports today (`claude --help`, +`--help` in `shell` to discover what it supports today (`claude --help`, `codex exec --help`, `opencode run --help`, `mimo run --help`, `qwen --help`, `omp --help`, `kimi --help`, or `agent --help`). Anything here is illustrative, not authoritative. Note that each backend reserves its own harness-owned flags diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/claude-p/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/claude-p/SKILL.md index 0a152f29b..791728683 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/claude-p/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/claude-p/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the claude-p (alias claude-code) daemon backend's flag surface. Read this only when a daemon task needs Claude Code-specific CLI flags (model selection, fallback model, tool - restrictions): it routes you to the installed CLI's live help via bash and + restrictions): it routes you to the installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 @@ -25,7 +25,7 @@ compatibility alias with the same runner and reserved-flag set. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-claude-code/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-claude-code/SKILL.md` has broader Claude Code CLI context). 2. Run, in bash: `claude --version` and `claude --help`. The daemon backend wraps `claude --print`, so the print-mode flags in `claude --help` are the diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/codex/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/codex/SKILL.md index 0203368dd..f187e573c 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/codex/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/codex/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the Codex daemon backend's flag surface. Read this only when a daemon task needs Codex-specific CLI flags (model selection, reasoning effort, config overrides): it routes you to the - installed CLI's live help via bash and shows how to translate that help into + installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 last_changed_at: "2026-07-09T18:55:23-07:00" @@ -24,7 +24,7 @@ flag list LingTai could ship. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-openai-codex/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-openai-codex/SKILL.md` has broader Codex CLI context). 2. Run, in bash: `codex --version`, `codex --help`, and `codex exec --help`. The daemon backend wraps `codex exec`, so `codex exec --help` is the diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/cursor/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/cursor/SKILL.md index ee6214ce5..57494625e 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/cursor/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/cursor/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the Cursor daemon backend's flag surface. Read this only when a daemon task needs Cursor-specific CLI flags (model selection, output/tooling switches): it routes you to the installed - CLI's live help via bash and shows how to translate that help into the + CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 last_changed_at: "2026-07-09T19:23:56-07:00" @@ -23,7 +23,7 @@ is the authority — not this page, and not any flag list LingTai could ship. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-cursor-agent/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-cursor-agent/SKILL.md` has broader Cursor Agent CLI context). 2. Run `agent --version` and `agent --help` in bash. The daemon spawns root `agent` directly — `agent -p --force --output-format stream-json ` — diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/kimicode/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/kimicode/SKILL.md index af361b028..d00427b4a 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/kimicode/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/kimicode/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the Kimi Code daemon backend's flag surface. Read this only when a daemon task needs Kimi-specific CLI flags (model selection, skills/workspace directories): it routes you to the - installed CLI's live help via bash and shows how to translate that help into + installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 last_changed_at: "2026-07-09T19:22:52-07:00" @@ -25,7 +25,7 @@ daemon entries use the canonical backend name `kimicode`. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-kimicode/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-kimicode/SKILL.md` has broader Kimi Code CLI context: per-run environment contract, MCP config evidence, validation checklist). 2. Run, in bash: `kimi --version` and `kimi --help`. The daemon backend wraps diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/mimocode/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/mimocode/SKILL.md index 5bb25003c..c8aac9eb9 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/mimocode/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/mimocode/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the MiMo Code (`mimocode` / `mimo`) daemon backend's flag surface. Read this only when a daemon task needs MiMo-specific CLI flags (model selection, provider switches): it routes you - to the installed CLI's live help via bash and shows how to translate that + to the installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.2.0 last_changed_at: "2026-07-11T00:00:00-07:00" @@ -31,7 +31,7 @@ between the harness flags and the trailing prompt positional. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-mimocode/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-mimocode/SKILL.md` has broader MiMo Code CLI context). 2. Run, in bash: `mimo --version`, `mimo --help`, and `mimo run --help`. The daemon backend wraps `mimo run`, so `mimo run --help` is the relevant diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/oh-my-pi/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/oh-my-pi/SKILL.md index afe08fcf0..4cf73c4c2 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/oh-my-pi/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/oh-my-pi/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the Oh-My-Pi (`omp`) daemon backend's flag surface. Read this only when a daemon task needs Oh-My-Pi-specific CLI flags (model selection, tool or provider switches): - it routes you to the installed CLI's live help via bash and shows how to + it routes you to the installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 @@ -27,7 +27,7 @@ could ship. `omp` is an accepted backend alias that canonicalizes to ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-oh-my-pi/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-oh-my-pi/SKILL.md` has broader Oh-My-Pi CLI context). 2. Run, in bash: `omp --version` and `omp --help`. The daemon backend wraps the root `omp` invocation (no subcommand), so the root help is the diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/opencode/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/opencode/SKILL.md index ec36c1f29..5728a6396 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/opencode/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/opencode/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the OpenCode daemon backend's flag surface. Read this only when a daemon task needs OpenCode-specific CLI flags (model selection, provider-specific reasoning variants, agent choice): it - routes you to the installed CLI's live help via bash and shows how to + routes you to the installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 @@ -25,7 +25,7 @@ flag list LingTai could ship. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-opencode/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-opencode/SKILL.md` has broader OpenCode CLI context: providers, agents, config files). 2. Run, in bash: `opencode --version`, `opencode --help`, and `opencode run --help`. The daemon backend wraps `opencode run`, so diff --git a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/qwen-code/SKILL.md b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/qwen-code/SKILL.md index 83436cd24..fba39a2d9 100644 --- a/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/qwen-code/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/cli-backends/reference/backends/qwen-code/SKILL.md @@ -4,7 +4,7 @@ description: > Nested daemon-cli-backends reference for the Qwen Code (`qwen-code` / `qwen`) daemon backend's flag surface. Read this only when a daemon task needs Qwen-specific CLI flags (model selection, provider tunables): it - routes you to the installed CLI's live help via bash and shows how to + routes you to the installed CLI's live help via shell and shows how to translate that help into the generic `backend_options` mechanism. It is not a flag catalog. version: 0.1.0 @@ -26,7 +26,7 @@ ship. The alias `qwen` canonicalizes to the `qwen-code` backend id. ## Discover flags from the installed CLI -1. Load `bash-manual` (its nested `reference/bash-qwen-code/SKILL.md` has +1. Load `shell-manual` (its nested `reference/bash-qwen-code/SKILL.md` has broader Qwen Code CLI context). 2. Run, in bash: `qwen --version` and `qwen --help`. The daemon backend wraps the top-level `qwen` binary directly — it spawns diff --git a/src/lingtai/tools/daemon/manual/reference/forensics/SKILL.md b/src/lingtai/tools/daemon/manual/reference/forensics/SKILL.md index ec5d5809c..063ef755f 100644 --- a/src/lingtai/tools/daemon/manual/reference/forensics/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/forensics/SKILL.md @@ -95,7 +95,7 @@ Read `daemon.json` once. The fields you want: - `result_preview` / `result_path` — bounded terminal preview and full `result.txt` path after completion - `elapsed_s` — wall clock since start -If `current_tool` is null AND `tool_call_count` hasn't changed for a while, the LLM is thinking — wait. If `current_tool` is set and stays set, that tool is slow (e.g., a big file read or a long bash command). +If `current_tool` is null AND `tool_call_count` hasn't changed for a while, the LLM is thinking — wait. If `current_tool` is set and stays set, that tool is slow (e.g., a big file read or a long shell command). ### "What has it figured out so far?" diff --git a/src/lingtai/tools/daemon/manual/reference/inspection/SKILL.md b/src/lingtai/tools/daemon/manual/reference/inspection/SKILL.md index 96cc52e99..62ac027ce 100644 --- a/src/lingtai/tools/daemon/manual/reference/inspection/SKILL.md +++ b/src/lingtai/tools/daemon/manual/reference/inspection/SKILL.md @@ -70,7 +70,7 @@ If any of those is false, the emanation is making progress — wait. Reclaim is - `current_tool` — tracks the CLI's own tool calls (set on `tool_use` / cleared on `tool_result`). - `logs/events.jsonl` `cli_output` entries — stdout/stderr stream. -Because the only progress signal is `last_output_at`, the right cadence on CLI backends is **"compare `last_output_at` to `now()`"** rather than a fixed interval: if it advanced since your last look, the emanation is alive; if it hasn't advanced in 5+ minutes AND `current_tool` is unchanged, apply the stall heuristic. Don't confuse a slow tool (large bash, big file read) with a stall — check `current_tool` first. +Because the only progress signal is `last_output_at`, the right cadence on CLI backends is **"compare `last_output_at` to `now()`"** rather than a fixed interval: if it advanced since your last look, the emanation is alive; if it hasn't advanced in 5+ minutes AND `current_tool` is unchanged, apply the stall heuristic. Don't confuse a slow tool (large shell, big file read) with a stall — check `current_tool` first. ### Anti-patterns @@ -82,7 +82,7 @@ Because the only progress signal is `last_output_at`, the right cadence on CLI b ### Resting while daemon work is pending: set a cron notification reminder -If the daemon is healthy but unfinished and you are about to rest, do **not** keep polling and do **not** rely on memory. Set a lightweight wakeup reminder through the `bash-manual` section **"One-shot wakeup reminders via `.notification/cron.json`"**. +If the daemon is healthy but unfinished and you are about to rest, do **not** keep polling and do **not** rely on memory. Set a lightweight wakeup reminder through the `shell-manual` section **"One-shot wakeup reminders via `.notification/cron.json`"**. **This is mandatory idle care, not an optimization.** Completion is push-notified, but a daemon can also die silently, stall, exit immediately, or get stuck on a provider/model error *without* producing a terminal-state notification — and a CLI-backend emanation gives you no live `tokens`/`turn` signal to fall back on. So before going IDLE with daemon work pending and **unverified-healthy**, arm at least one self-wake. Choose the delay from the task's *expected* duration, not a fixed value: a focused scan might warrant a 2-minute check; a long multi-file synthesis, 10–15 minutes. @@ -123,11 +123,11 @@ read("daemons/em-3-20260427-094215-abc123/daemon.json") # → state=running, turn=8, current_tool=null, tool_call_count=15, tokens.input=22000 # Last few lines of the transcript -bash("tail -n 20 daemons/em-3-20260427-094215-abc123/history/chat_history.jsonl") +shell("tail -n 20 daemons/em-3-20260427-094215-abc123/history/chat_history.jsonl") # → assistant: "Found a potential SQL injection in db.py:42. Continuing..." # Recent tool activity -bash("tail -n 10 daemons/em-3-20260427-094215-abc123/logs/events.jsonl") +shell("tail -n 10 daemons/em-3-20260427-094215-abc123/logs/events.jsonl") # → series of read/grep events on src/db/, src/auth/ ``` diff --git a/src/lingtai/tools/email/manual/SKILL.md b/src/lingtai/tools/email/manual/SKILL.md index 6621d7d83..bcf1f25a7 100644 --- a/src/lingtai/tools/email/manual/SKILL.md +++ b/src/lingtai/tools/email/manual/SKILL.md @@ -7,7 +7,7 @@ description: > channel the message arrived on), addressing (bare paths like `human`, `mimo-1`), self-send for persistent notes that survive molt, time capsules (delayed self-send via `delay`; for recurring alarms use the - host scheduler — see `bash-manual` and `system-manual`), the full-body + host scheduler — see `shell-manual` and `system-manual`), the full-body persistent notification contract, the 50,000-character send cap, and addon ownership. This is for INTERNAL email only — for real internet email via IMAP/SMTP, see the `mcp-manual` @@ -177,16 +177,16 @@ email(action="send", address="", delay=3600, subject="check on long task", message="Did `daemon(check, id=...)` finish?") ``` -Combined with self-send, this gives you cheap one-shot alarms without standing up a cron. The notification is delivered exactly once. For **recurring** reminders, use a host scheduler (cron, launchd, systemd, or an event watcher) via `bash-manual`; do not use the email tool for recurring execution. +Combined with self-send, this gives you cheap one-shot alarms without standing up a cron. The notification is delivered exactly once. For **recurring** reminders, use a host scheduler (cron, launchd, systemd, or an event watcher) via `shell-manual`; do not use the email tool for recurring execution. -Use delayed self-send as a **future nudge**, not delayed tool execution. The message should tell the future you what to inspect and why, then let that future turn decide with current context whether to run `bash(action="poll")`, `daemon(check)`, a channel read, or nothing at all. This is the preferred escape hatch when a repeated-call `_advisory` tells you that you may be polling the same thing: write one concrete reminder, then yield/idle instead of immediately calling the same tool again. +Use delayed self-send as a **future nudge**, not delayed tool execution. The message should tell the future you what to inspect and why, then let that future turn decide with current context whether to run `shell(action="poll")`, `daemon(check)`, a channel read, or nothing at all. This is the preferred escape hatch when a repeated-call `_advisory` tells you that you may be polling the same thing: write one concrete reminder, then yield/idle instead of immediately calling the same tool again. ## 8. Recurring reminders live outside email The internal `email` tool no longer has a recurring scheduling API. Use delayed self-send (`delay=`) for one-shot time capsules only. For repeating reminders or agent-side scheduled work, use a host scheduler (cron, launchd, systemd, or an -event watcher) following `bash-manual`; consult `system-manual` for lifecycle and +event watcher) following `shell-manual`; consult `system-manual` for lifecycle and notification behavior. ## 9. Privacy — internal IDs @@ -205,7 +205,7 @@ This skill is the manual for the **kernel-intrinsic `email` tool** only. Adjacen | Send Telegram / Feishu / WeChat messages | `mcp-manual` → respective MCP addon | | Send a notification-style ping to another agent | This skill — it IS the notification channel | | Schedule a one-off wake-up of your own loop | This skill, `delay` + self-send (§7) | -| Run recurring agent-side work | Host scheduler / event watcher via `bash-manual` (§8) | +| Run recurring agent-side work | Host scheduler / event watcher via `shell-manual` (§8) | The IMAP, Telegram, Feishu, and WeChat MCP addons each ship their own SKILL.md and `mcp-manual` entry. They are separate processes, separate auth surfaces, and separate failure modes. Do not try to use the `email` tool for an external address: an unknown target is refused without creating a recipient inbox entry and is reported through `email.bounce`. @@ -236,7 +236,7 @@ email(action="send", address="", delay=300, subject="ding", message="check the deploy") # Recurring nudges/work are not an email feature; use a host scheduler -# or event watcher via bash-manual/system-manual instead. +# or event watcher via shell-manual/system-manual instead. # Find related mail email(action="search", query="helmholtz", diff --git a/src/lingtai/tools/registry.py b/src/lingtai/tools/registry.py index c94bf4f41..017725403 100644 --- a/src/lingtai/tools/registry.py +++ b/src/lingtai/tools/registry.py @@ -76,7 +76,10 @@ def __repr__(self) -> str: BUILTIN_TOOLS: dict[str, str] = { "knowledge": "lingtai.tools.knowledge", "skills": "lingtai.tools.skills", - "bash": "lingtai.tools.bash", + # ``bash`` remains a one-way input alias only; the public capability is + # canonically named ``shell`` while its PR1 implementation stays in the + # retained internal package. + "shell": "lingtai.tools.bash", "avatar": "lingtai.tools.avatar", "daemon": "lingtai.tools.daemon", "mcp": "lingtai.tools.mcp", @@ -98,14 +101,14 @@ def __repr__(self) -> str: # init.json's ``manifest.capabilities`` only needs to declare overrides (kwargs) # or opt-ins beyond this set; ``manifest.disable`` is the opt-out channel. # -# ``bash`` defaults to {"yolo": True} (unsandboxed). Hosts that want a sandbox +# ``shell`` defaults to {"yolo": True} (unsandboxed). Hosts that want a sandbox # pass {"policy_file": "..."} in init.json, which overrides the default kwargs. # ``vision`` and ``web_search`` are NOT in this set — they require provider # config and API keys, so they stay explicit opt-in. CORE_DEFAULTS: dict[str, dict] = { "knowledge": {}, "skills": {}, - "bash": {"yolo": True}, + "shell": {"yolo": True}, "avatar": {}, "daemon": {}, "mcp": {}, @@ -134,7 +137,9 @@ def apply_core_defaults( """ out: dict[str, dict] = {name: dict(kwargs) for name, kwargs in CORE_DEFAULTS.items()} if capabilities: - for name, kwargs in capabilities.items(): + # Normalize here too because callers loading init/preset data may call + # this helper directly without first passing through Agent. + for name, kwargs in normalize_capabilities(capabilities).items(): if kwargs is None: # Explicit ``"name": null`` from JSON — disable without needing # the ``disable`` list. Useful for one-off opt-outs in init.json. @@ -148,27 +153,38 @@ def apply_core_defaults( out[name] = kwargs if disable: for name in disable: - out.pop(name, None) + out.pop(canonical_capability_name(name), None) return out +_LEGACY_CAPABILITY_ALIASES: dict[str, str] = {"bash": "shell"} + + +def canonical_capability_name(name: str) -> str: + """Return the public capability name for a retained legacy input key.""" + return _LEGACY_CAPABILITY_ALIASES.get(name, name) + + def normalize_capabilities(capabilities: dict[str, dict]) -> dict[str, dict]: - """Normalize capability configuration. + """Normalize capability configuration to canonical public names. - ``knowledge`` is the only private durable knowledge capability name. The - former ``library`` and ``codex`` names are intentionally not normalized: - this is a breaking rename while the user base is still small. The only - normalization left here is group expansion fallout and deterministic merge - of duplicate ``skills.paths`` values. + PR1 accepts the old ``bash`` key as a one-way input migration, but stores + and resolves only ``shell``. If both keys are present, the explicit + canonical key wins regardless of input order. """ out: dict[str, dict] = {} def merge_dict(dst: str, value: object) -> None: - if value is None: - value = {} if dst not in out: out[dst] = value if isinstance(value, dict) else value # type: ignore[assignment] return + # A canonical value already present wins over a legacy alias, including + # an explicit null/disable sentinel. + if value is None: + return + if out[dst] is None: + out[dst] = value if isinstance(value, dict) else value # type: ignore[assignment] + return if isinstance(out[dst], dict) and isinstance(value, dict): merged = dict(value) merged.update(out[dst]) @@ -186,8 +202,20 @@ def merge_dict(dst: str, value: object) -> None: merged["paths"] = paths out[dst] = merged - for name, kwargs in capabilities.items(): - merge_dict(name, kwargs) + # Process canonical keys first so a legacy alias can never overwrite one, + # including an explicit canonical null/disable sentinel. + items = list(capabilities.items()) + canonical_destinations = { + canonical_capability_name(name) + for name, _ in items + if name not in _LEGACY_CAPABILITY_ALIASES + } + items.sort(key=lambda item: item[0] in _LEGACY_CAPABILITY_ALIASES) + for name, kwargs in items: + destination = canonical_capability_name(name) + if name in _LEGACY_CAPABILITY_ALIASES and destination in canonical_destinations: + continue + merge_dict(destination, kwargs) return out @@ -212,6 +240,7 @@ def setup_capability(agent: "BaseAgent", name: str, **kwargs: Any) -> Any: Raises ``ValueError`` if the name is unknown or the module lacks ``setup``. """ + name = canonical_capability_name(name) module_path = BUILTIN_TOOLS.get(name) if module_path is None: raise ValueError( @@ -237,7 +266,7 @@ def get_all_providers() -> dict[str, dict]: """ _USER_FACING: dict[str, str] = { "file": "lingtai.tools.read", - "bash": "lingtai.tools.bash", + "shell": "lingtai.tools.bash", "web_search": "lingtai.tools.web_search", "knowledge": "lingtai.tools.knowledge", "skills": "lingtai.tools.skills", diff --git a/src/lingtai/tools/skills/manual/SKILL.md b/src/lingtai/tools/skills/manual/SKILL.md index a9e204884..14db81a01 100644 --- a/src/lingtai/tools/skills/manual/SKILL.md +++ b/src/lingtai/tools/skills/manual/SKILL.md @@ -382,7 +382,7 @@ To expand your skill catalog with another source directory: Two skills with the same `name` in the catalog would collide. Before authoring or installing a skill in `custom/`, grep the existing catalog: ``` -bash({"command": "grep -rh '^name:' .library/ ~/.lingtai-tui/utilities/ 2>/dev/null"}) +shell({"command": "grep -rh '^name:' .library/ ~/.lingtai-tui/utilities/ 2>/dev/null"}) ``` If you hit a collision: rename, or adapt the existing skill instead of forking a second one. diff --git a/tests/test_agent_capabilities.py b/tests/test_agent_capabilities.py index 78e0778be..884a02087 100644 --- a/tests/test_agent_capabilities.py +++ b/tests/test_agent_capabilities.py @@ -89,8 +89,8 @@ def test_agent_capabilities_dict_kwarg_overrides_default(tmp_path): service=make_mock_service(), agent_name="test", working_dir=tmp_path / "test", capabilities={"bash": {"yolo": False}}, ) - bash_entry = [(n, k) for n, k in agent._capabilities if n == "bash"] - assert bash_entry and bash_entry[0][1].get("yolo") is False + shell_entry = [(n, k) for n, k in agent._capabilities if n == "shell"] + assert shell_entry and shell_entry[0][1].get("yolo") is False agent.stop(timeout=1.0) diff --git a/tests/test_bash_async.py b/tests/test_bash_async.py index b82c3322b..7d4cf1435 100644 --- a/tests/test_bash_async.py +++ b/tests/test_bash_async.py @@ -75,7 +75,7 @@ def test_malformed_new_state_is_unrecoverable_without_raw_fallback( token = state["supervisor_start_lease"]["token"] _async_supervisor.write_initial_state(job_dir, state) monkeypatch.setattr( - "lingtai.adapters.bash_process.select_bash_async_process", + "lingtai.adapters.shell_process.select_shell_async_process", lambda: pytest.fail("malformed durable state selected a process adapter"), ) @@ -272,7 +272,7 @@ def test_schema_requires_reminder_with_runtime_default(self, tmp_path): assert result["status"] == "ok" assert result["handoff"] == ( "While waiting, go idle or call system(action='sleep'); the terminal result " - "will arrive and wake you as a notification; read bash-manual and " + "will arrive and wake you as a notification; read shell-manual and " "notification-manual for details." ) mgr.handle({"action": "cancel", "command": "", "job_id": result["job_id"]}) @@ -1120,7 +1120,7 @@ def test_supervisor_refuses_expired_or_terminal_start_lease( state["supervisor_start_lease"]["deadline_at"] = time.time() - 1 _async_supervisor.write_initial_state(job_dir, state) monkeypatch.setattr( - "lingtai.adapters.bash_process.select_bash_async_process", + "lingtai.adapters.shell_process.select_shell_async_process", lambda: pytest.fail("expired/terminal supervisor selected a process adapter"), ) diff --git a/tests/test_check_caps.py b/tests/test_check_caps.py index 52daa5c2b..6f8b4793b 100644 --- a/tests/test_check_caps.py +++ b/tests/test_check_caps.py @@ -11,7 +11,7 @@ def test_get_all_providers_returns_all_capabilities(): result = get_all_providers() expected = { - "file", "bash", "web_search", "knowledge", + "file", "shell", "web_search", "knowledge", "skills", "vision", "avatar", "daemon", } assert expected == set(result.keys()) @@ -27,7 +27,7 @@ def test_get_all_providers_structure(): def test_builtin_capabilities_have_empty_providers(): result = get_all_providers() - builtins = ["file", "bash", "knowledge", "skills", "avatar", "daemon"] + builtins = ["file", "shell", "knowledge", "skills", "avatar", "daemon"] for name in builtins: assert result[name]["providers"] == [], f"{name} should have empty providers" assert result[name]["default"] == "builtin", f"{name} should default to builtin" diff --git a/tests/test_daemon_preset_capabilities.py b/tests/test_daemon_preset_capabilities.py index 7eaf7ccab..31f85d7e4 100644 --- a/tests/test_daemon_preset_capabilities.py +++ b/tests/test_daemon_preset_capabilities.py @@ -579,7 +579,7 @@ def boom_for_vision(target, name, **kwargs): # # A preset selects the child LLM + provider-specific capabilities # (e.g. zhipu vision / web_search). It does NOT re-declare the always-on -# CORE_DEFAULTS floor (bash / read / write / edit / glob / grep), because the +# CORE_DEFAULTS floor (shell / read / write / edit / glob / grep), because the # TUI preset wizard only writes overrides/opt-ins into manifest.capabilities. # So requested host tools that are valid in the *parent* capability set must # stay valid even when a preset is provided — they must not become "Unknown @@ -587,10 +587,10 @@ def boom_for_vision(target, name, **kwargs): # --------------------------------------------------------------------------- def test_build_tool_surface_preset_keeps_parent_host_tools(tmp_path): - """Parent has the core floor (bash + file). Preset declares only a - provider capability (web_search). Requesting host tools like 'bash' must + """Parent has the core floor (shell + file). Preset declares only a + provider capability (web_search). Requesting host tools like 'shell' must resolve from the parent surface, not be rejected as unknown.""" - agent = _make_agent(tmp_path, ["file", "bash", "daemon"]) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"]) mgr = agent.get_capability("daemon") # Preset sandbox declares only a provider capability — NOT the host floor. @@ -598,22 +598,22 @@ def test_build_tool_surface_preset_keeps_parent_host_tools(tmp_path): {}, # provider caps would go here; empty is the minimal repro {"provider": "mock", "model": "mock"}, ) - assert "bash" not in preset_schemas # preset does not supply the floor + assert "shell" not in preset_schemas # preset does not supply the floor schemas, dispatch = mgr._build_tool_surface( - ["bash"], + ["shell"], preset_surface=(preset_schemas, preset_handlers), ) names = {s.name for s in schemas} - assert "bash" in names, "host tool 'bash' must survive a preset" - # Dispatch wires to the parent's real bash handler (preset didn't supply one) - assert dispatch["bash"] is agent._tool_handlers["bash"] + assert "shell" in names, "host tool 'shell' must survive a preset" + # Dispatch wires to the parent's real shell handler (preset didn't supply one) + assert dispatch["shell"] is agent._tool_handlers["shell"] def test_build_tool_surface_preset_keeps_full_host_tool_set(tmp_path): - """The exact failing request from the bug report: file group + bash with a + """The exact failing request from the bug report: file group + shell with a preset must all be accepted when valid in the parent surface.""" - agent = _make_agent(tmp_path, ["file", "bash", "daemon"]) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"]) mgr = agent.get_capability("daemon") preset_schemas, preset_handlers = mgr._instantiate_preset_capabilities( @@ -621,11 +621,11 @@ def test_build_tool_surface_preset_keeps_full_host_tool_set(tmp_path): {"provider": "mock", "model": "mock"}, ) schemas, dispatch = mgr._build_tool_surface( - ["read", "write", "edit", "bash", "grep", "glob"], + ["read", "write", "edit", "shell", "grep", "glob"], preset_surface=(preset_schemas, preset_handlers), ) names = {s.name for s in schemas} - for n in ("read", "write", "edit", "bash", "grep", "glob"): + for n in ("read", "write", "edit", "shell", "grep", "glob"): assert n in names, f"host tool {n!r} must survive a preset" assert n in dispatch @@ -633,7 +633,7 @@ def test_build_tool_surface_preset_keeps_full_host_tool_set(tmp_path): def test_build_tool_surface_preset_unknown_tool_still_rejected(tmp_path): """Carrying parent host tools forward must NOT weaken rejection of truly unknown tools — names absent from both preset and parent still raise.""" - agent = _make_agent(tmp_path, ["file", "bash", "daemon"]) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"]) mgr = agent.get_capability("daemon") preset_schemas, preset_handlers = mgr._instantiate_preset_capabilities( @@ -642,12 +642,12 @@ def test_build_tool_surface_preset_unknown_tool_still_rejected(tmp_path): ) try: mgr._build_tool_surface( - ["bash", "totally_made_up_tool"], + ["shell", "totally_made_up_tool"], preset_surface=(preset_schemas, preset_handlers), ) except ValueError as e: assert "totally_made_up_tool" in str(e) - assert "bash" not in str(e) # bash is valid; only the bogus one is named + assert "shell" not in str(e) # shell is valid; only the bogus one is named else: raise AssertionError("expected ValueError for unknown tool") @@ -656,30 +656,30 @@ def test_build_tool_surface_preset_capability_overrides_parent_handler(tmp_path) """When a preset re-instantiates a tool the parent also has, the preset's sandbox handler wins (it may be configured against the child LLM); the parent handler only fills in for floor tools the preset omitted.""" - agent = _make_agent(tmp_path, ["file", "bash", "daemon"]) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"]) mgr = agent.get_capability("daemon") - # Preset re-declares 'read' (gets its own sandbox handler) but omits 'bash'. + # Preset re-declares 'read' (gets its own sandbox handler) but omits 'shell'. preset_schemas, preset_handlers = mgr._instantiate_preset_capabilities( {"read": {}}, {"provider": "mock", "model": "mock"}, ) schemas, dispatch = mgr._build_tool_surface( - ["read", "bash"], + ["read", "shell"], preset_surface=(preset_schemas, preset_handlers), ) # read resolves from the preset sandbox, not the parent assert dispatch["read"] is preset_handlers["read"] assert dispatch["read"] is not agent._tool_handlers["read"] - # bash falls back to the parent (preset didn't supply it) - assert dispatch["bash"] is agent._tool_handlers["bash"] + # shell falls back to the parent (preset didn't supply it) + assert dispatch["shell"] is agent._tool_handlers["shell"] def test_build_tool_surface_preset_does_not_inherit_parent_mcp(tmp_path): """Preserving the parent host floor must NOT smuggle parent MCP tools into a preset emanation. Parent MCP names are excluded from the host floor and a request for one still raises the task-mcp-registration guard.""" - agent = _make_agent(tmp_path, ["file", "bash", "daemon"]) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"]) agent._sealed = False agent.add_tool("parent_mcp_tool", schema={"type": "object", "properties": {}}, handler=lambda args: {"ok": True}, description="Parent MCP tool") @@ -694,10 +694,10 @@ def test_build_tool_surface_preset_does_not_inherit_parent_mcp(tmp_path): # Host floor present, parent MCP absent. schemas, dispatch = mgr._build_tool_surface( - ["bash"], preset_surface=(preset_schemas, preset_handlers), + ["shell"], preset_surface=(preset_schemas, preset_handlers), ) names = {s.name for s in schemas} - assert "bash" in names + assert "shell" in names assert "parent_mcp_tool" not in names assert "parent_mcp_tool" not in dispatch @@ -714,13 +714,13 @@ def test_build_tool_surface_preset_does_not_inherit_parent_mcp(tmp_path): def test_build_tool_surface_preset_floor_excludes_extra_parent_tool(tmp_path): """dev-1 blocker: the parent host floor a preset may borrow is NARROW — - exactly {bash, read, write, edit, glob, grep}. An extra regular (non-MCP) + exactly {shell, read, write, edit, glob, grep}. An extra regular (non-MCP) parent tool that is NOT in that floor and NOT in the preset must stay unknown under a preset: not available, not dispatchable, and rejected when requested. This guards against optional/provider parent tools (vision, web_search, …) silently falling back to the parent when a preset omits or fails the provider capability.""" - agent = _make_agent(tmp_path, ["file", "bash", "daemon"]) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"]) # A regular (non-MCP) parent tool outside the core host floor. This stands # in for an optional/provider tool like vision/web_search. agent._sealed = False @@ -741,35 +741,35 @@ def test_build_tool_surface_preset_floor_excludes_extra_parent_tool(tmp_path): # Floor tools survive; the extra parent tool does not leak into the surface. schemas, dispatch = mgr._build_tool_surface( - ["bash"], preset_surface=(preset_schemas, preset_handlers), + ["shell"], preset_surface=(preset_schemas, preset_handlers), ) names = {s.name for s in schemas} - assert "bash" in names + assert "shell" in names assert "parent_extra_tool" not in names assert "parent_extra_tool" not in dispatch # Requesting it by name under a preset is rejected as unknown. try: mgr._build_tool_surface( - ["bash", "parent_extra_tool"], + ["shell", "parent_extra_tool"], preset_surface=(preset_schemas, preset_handlers), ) except ValueError as e: assert "parent_extra_tool" in str(e) - assert "bash" not in str(e) # bash is a valid floor tool + assert "shell" not in str(e) # shell is a valid floor tool else: raise AssertionError( "extra non-floor parent tool must be rejected under a preset") -def test_parent_host_tool_floor_is_exactly_bash_and_file(tmp_path): +def test_parent_host_tool_floor_is_exactly_shell_and_file(tmp_path): """Lock the borrowable floor to the intended set. Derived from CORE_DEFAULTS minus EMANATION_BLACKLIST minus mcp, it must equal exactly - {bash, read, write, edit, glob, grep} — no intrinsics, no mcp, no optional + {shell, read, write, edit, glob, grep} — no intrinsics, no mcp, no optional provider caps.""" from lingtai.tools.daemon import _parent_host_tool_floor assert _parent_host_tool_floor() == frozenset( - {"bash", "read", "write", "edit", "glob", "grep"} + {"shell", "read", "write", "edit", "glob", "grep"} ) @@ -803,14 +803,14 @@ def boom_for_vision(target, name, **kwargs): ) # Parent agent HAS the host floor. - agent = _make_agent(tmp_path, ["file", "bash", "daemon"], presets_dir=presets_dir) + agent = _make_agent(tmp_path, ["file", "shell", "daemon"], presets_dir=presets_dir) agent.inbox = queue.Queue() mgr = agent.get_capability("daemon") preset_path = str(presets_dir / "glm_like.json") with patch.object(preset_connectivity, "_probe_host", return_value=12.5): result = mgr.handle({"action": "emanate", "tasks": [ - {"task": "run a command", "tools": ["bash"], "preset": preset_path}, + {"task": "run a command", "tools": ["shell"], "preset": preset_path}, ]}) assert result["status"] == "dispatched", result.get("message") diff --git a/tests/test_layers_bash.py b/tests/test_layers_bash.py index 6be5cf252..0f1000521 100644 --- a/tests/test_layers_bash.py +++ b/tests/test_layers_bash.py @@ -446,7 +446,8 @@ def test_add_capability_bash_yolo(self, tmp_path): capabilities={"bash": {"yolo": True}}) mgr = agent.get_capability("bash") assert isinstance(mgr, BashManager) - assert "bash" in agent._tool_handlers + assert "shell" in agent._tool_handlers + assert "bash" not in agent._tool_handlers def test_add_capability_bash_with_policy(self, tmp_path): from lingtai.agent import Agent diff --git a/tests/test_shell_pr1_contract.py b/tests/test_shell_pr1_contract.py new file mode 100644 index 000000000..90e3670fb --- /dev/null +++ b/tests/test_shell_pr1_contract.py @@ -0,0 +1,256 @@ +"""Focused PR1 contract checks for canonical shell and Windows composition.""" +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import lingtai.adapters.shell as shell_adapter +from lingtai.adapters.windows.powershell import PowerShellDialect +from lingtai.adapters.windows.powershell_process import _Owned, WindowsShellAsyncProcessAdapter +from lingtai.tools.bash import ShellManager, ShellPolicy, setup +from lingtai.tools.bash._async_process import ProcessRef +from lingtai.tools.bash._async_supervisor import _invocation_from_state +from lingtai.tools.registry import ( + BUILTIN_TOOLS, + CORE_DEFAULTS, + apply_core_defaults, + normalize_capabilities, +) + + +def test_registry_has_one_canonical_public_shell_identity(): + assert BUILTIN_TOOLS["shell"] == "lingtai.tools.bash" + assert "bash" not in BUILTIN_TOOLS + assert "shell" in CORE_DEFAULTS + assert "bash" not in CORE_DEFAULTS + assert normalize_capabilities({"bash": {"yolo": False}}) == { + "shell": {"yolo": False} + } + assert normalize_capabilities({"bash": {"yolo": True}, "shell": {"yolo": False}}) == { + "shell": {"yolo": False} + } + assert normalize_capabilities({"shell": None, "bash": {"yolo": True}}) == { + "shell": None + } + assert "shell" not in apply_core_defaults(None, disable=["bash"]) + assert "shell" not in apply_core_defaults({"bash": None}) + + +def test_setup_registers_shell_and_advertises_selected_dialect(tmp_path): + agent = MagicMock() + agent._working_dir = Path(tmp_path) + manager = setup(agent, yolo=True) + assert isinstance(manager, ShellManager) + assert agent.add_tool.call_args.args[0] == "shell" + description = agent.add_tool.call_args.kwargs["description"] + expected_dialect = "powershell" if os.name == "nt" else "posix" + assert f"Active shell dialect: {expected_dialect}" in description + assert f"Host OS: {shell_adapter.describe_host_os()}" in description + properties = agent.add_tool.call_args.kwargs["schema"]["properties"] + assert "dialect" not in properties + assert "host_os" not in properties + + +def test_host_os_description_uses_human_readable_versions(monkeypatch): + monkeypatch.setattr(shell_adapter.platform, "system", lambda: "Darwin") + monkeypatch.setattr(shell_adapter.platform, "mac_ver", lambda: ("15.5", ("", "", ""), "")) + assert shell_adapter.describe_host_os() == "macOS 15.5" + + monkeypatch.setattr(shell_adapter.platform, "system", lambda: "Linux") + monkeypatch.setattr( + shell_adapter.platform, + "freedesktop_os_release", + lambda: {"PRETTY_NAME": "Ubuntu 24.04 LTS"}, + ) + assert shell_adapter.describe_host_os() == "Ubuntu 24.04 LTS" + + monkeypatch.setattr(shell_adapter.platform, "system", lambda: "Windows") + monkeypatch.setattr(shell_adapter.platform, "release", lambda: "11") + monkeypatch.setattr(shell_adapter.platform, "version", lambda: "10.0.26100") + assert shell_adapter.describe_host_os() == "Windows 11 (10.0.26100)" + + +def test_powershell_invocation_and_extractor_are_not_posix(): + dialect = PowerShellDialect(executable="pwsh") + assert dialect.state_key() == "powershell" + assert dialect.extract_commands( + "Write-Output hi | ForEach-Object { $_ }; Write-Output $(Get-Date)" + ) == ("Write-Output", "ForEach-Object", "Write-Output", "Get-Date") + invocation = dialect.make_invocation("Write-Output hi") + args, kwargs = invocation.process_args() + assert args[:5] == ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command"] + assert "Write-Output hi" in args[-1] + assert "$global:__lingtai_success = $?" in args[-1] + assert "exit $global:__lingtai_native_exit" in args[-1] + assert kwargs == {"shell": False} + assert invocation.encoding == "utf-8" + + +def test_powershell_parentheses_are_recursive_and_malformed_syntax_fails_closed(): + dialect = PowerShellDialect(executable="pwsh") + assert dialect.extract_commands(r"(Remove-Item -LiteralPath .\victim)") == ("Remove-Item",) + assert dialect.extract_commands( + r"Write-Output (Remove-Item -LiteralPath .\victim)" + ) == ("Write-Output", "Remove-Item") + assert dialect.extract_commands(r"(Remove-Item -LiteralPath .\victim") == ( + "__powershell_unsupported__", + ) + assert dialect.extract_commands(r"Write-Output ()") == ("__powershell_unsupported__",) + + +@pytest.mark.parametrize( + "policy", + [ShellPolicy(deny=["Remove-Item"]), ShellPolicy(allow=["Write-Output"])], +) +def test_powershell_parenthesized_policy_rejects_before_invocation(tmp_path, policy): + dialect = PowerShellDialect(executable="pwsh") + dialect.make_invocation = MagicMock(side_effect=AssertionError("pwsh must not run")) + manager = ShellManager( + policy=policy, + working_dir=str(tmp_path), + agent=SimpleNamespace(), + dialect=dialect, + ) + denied = manager.handle({"command": r"Write-Output (Remove-Item -LiteralPath .\victim)"}) + assert denied["status"] == "error" + assert "not allowed" in denied["message"] + assert "Remove-Item" in denied["message"] + dialect.make_invocation.assert_not_called() + + +@pytest.mark.parametrize( + "policy", + [ShellPolicy(deny=["Remove-Item"]), ShellPolicy(allow=["Write-Output"])], +) +@pytest.mark.parametrize( + "command", + [ + r"Rem`ove-Item -LiteralPath .\victim", + r"Write-Output (Rem`ove-Item -LiteralPath .\victim)", + r"& Rem`ove-Item -LiteralPath .\victim", + ], +) +def test_powershell_backtick_escaped_command_fails_closed_before_invocation( + tmp_path, policy, command +): + dialect = PowerShellDialect(executable="pwsh") + assert "__powershell_unsupported__" in dialect.extract_commands(command) + dialect.make_invocation = MagicMock(side_effect=AssertionError("pwsh must not run")) + manager = ShellManager( + policy=policy, + working_dir=str(tmp_path), + agent=SimpleNamespace(), + dialect=dialect, + ) + denied = manager.handle({"command": command}) + assert denied["status"] == "error" + assert "does not support this syntax" in denied["message"] + assert "refusing to run" in denied["message"] + dialect.make_invocation.assert_not_called() + + +def test_powershell_policy_is_case_insensitive_and_dynamic_syntax_fails_closed(tmp_path): + policy = ShellPolicy(deny=["Remove-Item"]) + manager = ShellManager( + policy=policy, + working_dir=str(tmp_path), + agent=SimpleNamespace(), + dialect=PowerShellDialect(executable="pwsh"), + ) + denied = manager.handle({"command": "remove-item file.txt"}) + assert denied["status"] == "error" + assert "not allowed" in denied["message"] + dynamic = manager.handle({"command": "& $command"}) + assert dynamic["status"] == "error" + expandable = manager.handle({"command": '& "$command" victim'}) + assert expandable["status"] == "error" + static = manager.handle({"command": "& Remove-Item victim"}) + assert static["status"] == "error" + quoted_static = manager.handle({"command": "& 'Remove-Item' victim"}) + assert quoted_static["status"] == "error" + + +def test_powershell_exit_wrapper_observes_final_native_type_without_rewriting_script(): + script = "& $env:ComSpec /d /c exit 7; if ($LASTEXITCODE -ne 7) { throw 'lost' }" + wrapped = PowerShellDialect(executable="pwsh").make_invocation(script).script + assert script in wrapped + assert "$PSNativeCommandUseErrorActionPreference = $true" in wrapped + assert "ProgramExitedWithNonZeroCode" in wrapped + assert "$global:LASTEXITCODE = 0" not in wrapped + + +def test_windows_root_exit_child_live_cancel_race_keeps_job_owned(monkeypatch): + import lingtai.adapters.windows.powershell_process as process_adapter + + class FakeProcess: + def poll(self): + return 0 + + def wait(self): + return 0 + + class FakeKernel: + def __init__(self): + self.terminated = [] + + def TerminateJobObject(self, handle, code): + self.terminated.append((handle, code)) + return 1 + + def CloseHandle(self, handle): + return 1 + + kernel = FakeKernel() + waits = [] + monkeypatch.setattr(process_adapter, "_kernel32", lambda: kernel) + + def wait_job(_handle, timeout): + waits.append(timeout) + return timeout == 5.0 + + monkeypatch.setattr(process_adapter, "_wait_job", wait_job) + requests = iter([False, True]) + job_handle = object() + owned = _Owned(FakeProcess(), ProcessRef(123, "test"), job_handle) + completion = WindowsShellAsyncProcessAdapter().wait(owned, lambda: next(requests)) + assert completion.cancellation_outcome == "group_cancelled" + assert kernel.terminated == [(job_handle, 1)] + assert waits == [0.05, 5.0] + + +def test_windows_selector_modules_are_real_adapter_composition_points(monkeypatch): + import lingtai.adapters.shell as dialect_selector + import lingtai.adapters.shell_process as process_selector + import lingtai.adapters.shell_state_lock as lock_selector + from lingtai.adapters.windows.powershell_process import WindowsShellAsyncProcessAdapter + from lingtai.adapters.windows.powershell_state_lock import WindowsShellStateLockAdapter + + monkeypatch.setattr(dialect_selector.os, "name", "nt") + monkeypatch.setattr(process_selector.os, "name", "nt") + monkeypatch.setattr(lock_selector.os, "name", "nt") + monkeypatch.setattr("shutil.which", lambda name: "C:/Program Files/PowerShell/7/pwsh.exe") + assert dialect_selector.select_shell_dialect().state_key() == "powershell" + assert isinstance(process_selector.select_shell_async_process(), WindowsShellAsyncProcessAdapter) + assert isinstance(lock_selector.select_shell_state_lock(), WindowsShellStateLockAdapter) + + +def test_windows_resume_uses_retained_process_handle_not_a_missing_thread(monkeypatch): + import lingtai.adapters.windows.powershell_process as process_adapter + + calls = [] + fake_ntdll = SimpleNamespace( + NtResumeProcess=lambda handle: calls.append(handle) or 0 + ) + monkeypatch.setattr(process_adapter, "_ntdll", lambda: fake_ntdll) + process_adapter._resume_suspended_process(SimpleNamespace(_handle=1234)) + assert calls == [1234] + + +def test_non_posix_legacy_state_is_not_reinterpreted(monkeypatch): + import lingtai.tools.bash._async_supervisor as supervisor + + monkeypatch.setattr(supervisor.os, "name", "nt") + with pytest.raises(ValueError, match="refusing to reinterpret"): + _invocation_from_state({"command": "echo old"}, "echo old") diff --git a/tests/test_shell_windows_native.py b/tests/test_shell_windows_native.py new file mode 100644 index 000000000..9af845ebf --- /dev/null +++ b/tests/test_shell_windows_native.py @@ -0,0 +1,258 @@ +"""Native Windows/PowerShell evidence for the canonical shell PR1. + +These tests intentionally skip outside Windows. The pull-request workflow runs +them on ``windows-latest`` with PowerShell 7 available as ``pwsh``; mock tests +alone are not evidence for Job Object, msvcrt lock, or PowerShell exit behavior. +""" +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from lingtai.adapters.windows.powershell_state_lock import WindowsShellStateLockAdapter +from lingtai.tools.bash import ShellManager, ShellPolicy + + +_PWSH = shutil.which("pwsh") +pytestmark = [ + pytest.mark.skipif(os.name != "nt", reason="native Windows contract"), + pytest.mark.skipif(_PWSH is None, reason="PowerShell 7 (pwsh) is required"), +] + + +class _NotificationSink: + """Small no-I/O notification Port sufficient for completion publication.""" + + def publish(self, channel: str, payload: dict) -> None: + return None + + def compare_update_channel(self, channel, expected_version, mutator): + _payload, _changed, value = mutator({}) + return SimpleNamespace(value=value) + + +def _manager(root: Path) -> ShellManager: + agent = SimpleNamespace(_notification_store=_NotificationSink()) + return ShellManager( + policy=ShellPolicy.yolo(), + working_dir=str(root), + agent=agent, + ) + + +def _poll_terminal(manager: ShellManager, job_id: str, timeout: float = 15.0) -> dict: + deadline = time.monotonic() + timeout + latest: dict = {"status": "not-polled"} + while time.monotonic() < deadline: + latest = manager.handle({"action": "poll", "job_id": job_id}) + if latest.get("status") == "done": + return latest + assert latest.get("status") == "running", latest + time.sleep(0.05) + pytest.fail(f"async job {job_id} did not finish: {latest}") + + +def _wait_for_file(path: Path, timeout: float = 8.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + return + time.sleep(0.05) + pytest.fail(f"timed out waiting for {path}") + + +def _ps_literal(path: Path) -> str: + return "'" + str(path).replace("'", "''") + "'" + + +def test_native_powershell_sync_captures_streams_and_exact_native_exit(tmp_path): + manager = _manager(tmp_path) + result = manager.handle({ + "command": ( + "Write-Output 'native-stdout'; " + "[Console]::Error.WriteLine('native-stderr'); " + "& $env:ComSpec /d /c exit 7" + ), + "timeout": 10, + }) + + assert result["status"] == "ok" + assert result["exit_code"] == 7 + assert result["ok"] is False + assert result["command_status"] == "failed" + assert "native-stdout" in result["stdout"] + assert "native-stderr" in result["stderr"] + + +def test_native_powershell_failure_and_sync_timeout_are_explicit(tmp_path): + manager = _manager(tmp_path) + + failed = manager.handle({"command": "Write-Error 'powershell-failure'", "timeout": 10}) + assert failed["status"] == "ok" + assert failed["exit_code"] == 1 + assert failed["ok"] is False + assert "powershell-failure" in failed["stderr"] + + sticky_native_then_powershell = manager.handle({ + "command": ( + "& $env:ComSpec /d /c exit 7; " + "Write-Error 'final-powershell-failure'" + ), + "timeout": 10, + }) + assert sticky_native_then_powershell["status"] == "ok" + assert sticky_native_then_powershell["exit_code"] == 1 + + native_then_success = manager.handle({ + "command": "& $env:ComSpec /d /c exit 7; Write-Output 'final-success'", + "timeout": 10, + }) + assert native_then_success["status"] == "ok" + assert native_then_success["exit_code"] == 0 + + timed_out = manager.handle({"command": "Start-Sleep -Seconds 10", "timeout": 0.5}) + assert timed_out["status"] == "error" + assert "timed out" in timed_out["message"].lower() + + +def test_native_powershell_async_poll_preserves_streams_and_exit_code(tmp_path): + manager = _manager(tmp_path) + started = manager.handle({ + "command": ( + "Write-Output 'async-stdout'; " + "[Console]::Error.WriteLine('async-stderr'); " + "& $env:ComSpec /d /c exit 7" + ), + "async": True, + "reminder": 30, + }) + + assert started["status"] == "ok", started + terminal = _poll_terminal(manager, started["job_id"]) + assert terminal["exit_status_known"] is True + assert terminal["exit_code"] == 7 + assert terminal["ok"] is False + assert terminal["command_status"] == "failed" + assert "async-stdout" in terminal["stdout"] + assert "async-stderr" in terminal["stderr"] + + +def test_native_windows_state_lock_serializes_processes(tmp_path): + lock_root = tmp_path / "state-lock" + lock_root.mkdir() + held = lock_root / "held" + repo_root = Path(__file__).resolve().parents[1] + child = r""" +import sys +import time +from pathlib import Path +from lingtai.adapters.windows.powershell_state_lock import WindowsShellStateLockAdapter + +root = Path(sys.argv[1]) +with WindowsShellStateLockAdapter().exclusive(root): + (root / "held").write_text("held", encoding="utf-8") + time.sleep(0.8) +""" + env = os.environ.copy() + env["PYTHONPATH"] = str(repo_root / "src") + os.pathsep + env.get("PYTHONPATH", "") + process = subprocess.Popen( + [sys.executable, "-c", child, str(lock_root)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + _wait_for_file(held) + + began = time.monotonic() + with WindowsShellStateLockAdapter().exclusive(lock_root): + elapsed = time.monotonic() - began + + stdout, stderr = process.communicate(timeout=5) + assert process.returncode == 0, {"stdout": stdout, "stderr": stderr} + assert elapsed >= 0.3 + + +def test_native_job_object_cancel_after_root_exit_terminates_descendant_tree(tmp_path): + manager = _manager(tmp_path) + ready = tmp_path / "root-exited.ready" + survived = tmp_path / "root-exited-survived.txt" + + child_script = ( + "Start-Sleep -Seconds 5; " + f"Set-Content -LiteralPath {_ps_literal(survived)} -Value 'survived'" + ) + encoded_child = base64.b64encode(child_script.encode("utf-16le")).decode("ascii") + parent_script = ( + "$null = Start-Process -FilePath (Get-Command pwsh).Source " + "-ArgumentList @('-NoLogo','-NoProfile','-NonInteractive'," + f"'-EncodedCommand','{encoded_child}'); " + f"Set-Content -LiteralPath {_ps_literal(ready)} -Value 'root spawned'; " + "exit 0" + ) + + started = manager.handle({ + "command": parent_script, + "async": True, + "reminder": 30, + }) + assert started["status"] == "ok", started + _wait_for_file(ready) + cancelled = manager.handle({"action": "cancel", "job_id": started["job_id"]}) + assert cancelled == {"status": "cancelled", "job_id": started["job_id"]} + + state_path = tmp_path / "system" / "jobs" / started["job_id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["status"] == "completed" + assert state["exit_status_known"] is True + assert state["cancellation_outcome"] == "group_cancelled" + time.sleep(1.0) + assert not survived.exists(), "a descendant escaped after the root pwsh exited" + + +def test_native_job_object_cancel_terminates_descendant_tree(tmp_path): + manager = _manager(tmp_path) + ready = tmp_path / "descendant.ready" + survived = tmp_path / "descendant-survived.txt" + + child_script = ( + "Start-Sleep -Milliseconds 1200; " + f"Set-Content -LiteralPath {_ps_literal(survived)} -Value 'survived'" + ) + encoded_child = base64.b64encode(child_script.encode("utf-16le")).decode("ascii") + parent_script = ( + "$child = Start-Process -FilePath (Get-Command pwsh).Source " + "-ArgumentList @('-NoLogo','-NoProfile','-NonInteractive'," + f"'-EncodedCommand','{encoded_child}') -PassThru; " + f"Set-Content -LiteralPath {_ps_literal(ready)} -Value $child.Id; " + "Wait-Process -Id $child.Id" + ) + + started = manager.handle({ + "command": parent_script, + "async": True, + "reminder": 30, + }) + assert started["status"] == "ok", started + _wait_for_file(ready) + + cancelled = manager.handle({"action": "cancel", "job_id": started["job_id"]}) + assert cancelled == {"status": "cancelled", "job_id": started["job_id"]} + + state_path = tmp_path / "system" / "jobs" / started["job_id"] / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["status"] == "completed" + assert state["exit_status_known"] is True + assert state["cancellation_outcome"] == "group_cancelled" + + time.sleep(1.5) + assert not survived.exists(), "a descendant escaped the owned Windows Job Object" diff --git a/tests/test_skills.py b/tests/test_skills.py index 5b5122be1..f1d4838e0 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -175,11 +175,11 @@ def test_skills_setup_hard_copies_intrinsics(tmp_path): assert "validate.py reference/topic-a/" in body bash_md = ( - workdir / ".library" / "intrinsic" / "capabilities" / "bash" / "SKILL.md" + workdir / ".library" / "intrinsic" / "capabilities" / "shell" / "SKILL.md" ) assert bash_md.is_file() bash_body = bash_md.read_text(encoding="utf-8") - assert "name: bash-manual" in bash_body + assert "name: shell-manual" in bash_body assert "Nested reference catalog" in bash_body assert "reference/scheduled-work/SKILL.md" in bash_body assert "reference/notification-reminders/SKILL.md" in bash_body @@ -222,7 +222,7 @@ def test_skills_setup_hard_copies_intrinsics(tmp_path): ): bash_reference = bash_reference_dir / reference_name / "SKILL.md" assert bash_reference.is_file() - assert "Nested bash-manual reference" in ( + assert "Nested shell-manual reference" in ( bash_reference_dir / "scheduled-work" / "SKILL.md" ).read_text(encoding="utf-8") diff --git a/tests/test_telegram_task_card_templates.py b/tests/test_telegram_task_card_templates.py index 6db4273dd..67008c34a 100644 --- a/tests/test_telegram_task_card_templates.py +++ b/tests/test_telegram_task_card_templates.py @@ -116,7 +116,7 @@ } # The exact model-facing stop call the templates/manual must teach (action-based, -# like bash(action="poll") / daemon(action="check")) and the terminal footer text. +# like shell(action="poll") / daemon(action="check")) and the terminal footer text. _STOP_CALL = 'task_card(action="stop", watch_id="")' _TERMINAL_FOOTER_TEXT = "terminal snapshot — stop/clear this watch now" _TERMINAL_FOOTER_MARKER = "stop/clear this watch" @@ -162,7 +162,7 @@ def test_top_level_manual_binds_watcher_default_in_taskcard_section(): assert "human-visible" in section assert "watcher" in section # Both kinds of long-running work. - assert "bash(async=true)" in section + assert "shell(async=true)" in section assert "daemon" in section # Route onward to the concrete templates + nested manual. assert "render_bash_async.py" in section and "render_daemon.py" in section @@ -224,7 +224,7 @@ def test_nested_manual_binds_watcher_default_in_when_to_reach_section(): assert "telegram-originated turn" in section assert "meaningful" in section and "long-running" in section # Both kinds of long-running work. - assert "bash(async=true)" in section + assert "shell(async=true)" in section assert "daemon" in section # A human-visible watcher is the recommended response. assert "human-visible" in section @@ -462,7 +462,7 @@ def test_non_terminal_state_footer_stays_resident_awaiting(asset, state_file, id # ========================================================================= # # The bash `status` an orchestrator records is a DISPLAY state derived from the -# sanctioned `bash(action="poll")` / `bash(action="cancel")` result — NOT the raw +# sanctioned `shell(action="poll")` / `shell(action="cancel")` result — NOT the raw # top-level bash `status`, which is ALWAYS "done" on completion (a nonzero inner # command is signalled by the additive `exit_status_known`/`exit_code`/`ok`/ # `command_status` fields, never by a top-level "failed"). These fixtures are the