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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/nano.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ chokepoints.
clawcodex --nano -p "fix the failing test in src/parser.py" # headless
clawcodex --nano # interactive TUI
clawcodex tui --nano # explicit form
clawcodex web --nano # browser client
clawcodex serve --nano # desktop/web gateway
```

In the interactive TUI, the flag is forwarded to the spawned agent-server
Expand Down Expand Up @@ -86,9 +88,11 @@ The Harbor adapter forwards nano with `--ak nano=1`; see
tool for text-only main models; unconfigured nano stays exactly six.
- `--allowed-tools`/`--disallowed-tools` still filter the six.
- Nano is process-global (the /eco contract): on the TUI's `--stdio`
transport that is exactly one session; on a multi-session
`agent-server --http` process, `--nano` applies to every session it
hosts.
transport that is exactly one session; on a multi-session process
(`agent-server --http`, `clawcodex serve`, `clawcodex web`), `--nano`
applies to every session it hosts — including sessions created with
their own provider/model picks. The gateway reports it on
`/api/status` and stamps every `session.info` with `nano`.

Design rationale and the pi study behind it: the harness comparison in
`my-docs/clawcodex-nano/` (kept out of git) — headline: pi matched or
Expand Down
22 changes: 21 additions & 1 deletion src/entrypoints/serve_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

clawcodex serve [--host H] [--port P] [--token T] [--workspace DIR]
[--provider NAME] [--model M] [--effort E]
[--permission-mode MODE]
[--permission-mode MODE] [--nano]
[--dangerously-skip-permissions]
[--allow-dangerously-skip-permissions]
"""
Expand Down Expand Up @@ -97,6 +97,12 @@ def _build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--permission-mode", default=None, dest="permission_mode",
help="default | acceptEdits | bypassPermissions | plan | auto")
parser.add_argument(
"--nano", action="store_true",
help="Nano mode (docs/nano.md): six tools, pi-style minimal prompt, "
"byte-stable context, /eco on, no MCP. Process-global: every "
"session this gateway hosts is nano.",
)
parser.add_argument("--dangerously-skip-permissions", action="store_true",
dest="dangerously_skip_permissions",
help="Bypass all permission checks (start in bypassPermissions).")
Expand Down Expand Up @@ -197,6 +203,17 @@ def run_serve_subcommand(argv: list[str], *, on_ready: ReadyHook | None = None)
print("serve: --fallback-model must differ from --model", file=sys.stderr)
return 2

if args.nano:
# Set the process-global BEFORE any session spawns (sessions are
# created lazily by the gateway), so pre-session surfaces — the
# ``/api/status`` nano field the web client's welcome screen reads —
# report the truth from the first request. ``_build_runtime`` sets it
# again per spawn (idempotent) and builds the nano registry off
# ``cfg.nano`` below.
from src.nano.state import set_nano_mode

set_nano_mode(True)

token = args.token if args.token is not None else os.environ.get(TOKEN_ENV) or ""
if not token:
token = secrets.token_urlsafe(32)
Expand All @@ -209,6 +226,9 @@ def run_serve_subcommand(argv: list[str], *, on_ready: ReadyHook | None = None)
permission_mode=args.permission_mode,
is_bypass_available=is_bypass_available,
bypass_selectable=bypass_selectable,
# Rides every per-session copy (spawn_for's dataclasses.replace keeps
# it), so a session created with its own provider/model is still nano.
nano=bool(args.nano),
max_turns=args.max_turns,
)

Expand Down
10 changes: 9 additions & 1 deletion src/entrypoints/web_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

clawcodex web [--host H] [--port P] [--no-open] [--build]
[--workspace DIR] [--provider NAME] [--model M] [--effort E]
[--permission-mode MODE] [--allow-remote]
[--permission-mode MODE] [--nano] [--allow-remote]

Binding
-------
Expand Down Expand Up @@ -100,6 +100,12 @@ def _build_parser() -> argparse.ArgumentParser:
help="Reasoning effort seed for sessions.")
parser.add_argument("--permission-mode", default=None, dest="permission_mode",
help="default | acceptEdits | bypassPermissions | plan | auto")
parser.add_argument(
"--nano", action="store_true",
help="Nano mode (docs/nano.md): six tools, pi-style minimal prompt, "
"byte-stable context, /eco on, no MCP. Every session this "
"server hosts is nano; the client shows a 'nano' chip.",
)
parser.add_argument("--dangerously-skip-permissions", action="store_true",
dest="dangerously_skip_permissions",
help="Bypass all permission checks (start in bypassPermissions).")
Expand Down Expand Up @@ -222,6 +228,8 @@ def _serve_argv(args: argparse.Namespace) -> list[str]:
argv += ["--effort", args.effort]
if args.permission_mode:
argv += ["--permission-mode", args.permission_mode]
if args.nano:
argv.append("--nano")
if args.dangerously_skip_permissions:
argv.append("--dangerously-skip-permissions")
return argv
Expand Down
7 changes: 7 additions & 0 deletions src/server/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,11 +704,18 @@ async def _handle_control_request(self, msg: dict) -> None:
self._do_workflow_command(request_id, inner.get("name"), inner.get("args"))
return
if subtype == "get_settings":
from src.nano.state import is_nano_mode

self._reply(request_id, {
"permission_mode": _current_mode(self.tool_context, self.config.permission_mode),
"model": getattr(self.provider, "model", None),
"provider": self.provider_name,
"available_models": self._available_models(),
# Nano mode (docs/nano.md) — same live process-global the init
# frame reports, so the serve gateway's session.info republish
# (publish_session_info) keeps the client's nano chip truthful
# across model switches and turn ends.
"nano": is_nano_mode(),
# The active fusion model's NAME, or "" when not on one.
# ``model`` above stays the base model id (what serves the
# turn, and what cost/context-window lookups key off), so
Expand Down
9 changes: 9 additions & 0 deletions src/server/desktop_gateway_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ def _init_session_info(init: dict[str, Any]) -> dict[str, Any]:
provider = init.get("provider")
if provider:
payload["provider"] = provider
# Nano mode (docs/nano.md) — the client renders a "nano" chip beside the
# model name. Strict ``is True``, always stamped: an init frame without
# the field (an older agent) must read as not-nano, never as unknown.
payload["nano"] = init.get("nano") is True
session_id = init.get("session_id")
if session_id:
payload["stored_session_id"] = session_id
Expand Down Expand Up @@ -232,6 +236,11 @@ async def publish_session_info(self, **extra: Any) -> None:
effort = settings.get("reasoning_effort") or settings.get("effort")
if effort:
payload["reasoning_effort"] = str(effort)
# Same strict mapping as _init_session_info, and stamped on every
# republish for the same reason model is: a client doing full
# replaces must never lose (or invent) the nano chip on a model
# switch or turn end.
payload["nano"] = settings.get("nano") is True
payload.update(extra)
await self._broadcast("session.info", payload)

Expand Down
6 changes: 6 additions & 0 deletions src/server/desktop_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,19 @@ async def health(_: Request) -> Response:
async def status(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
# Nano is process-global (serve_cli sets it at startup, before any
# session spawns), so this is the one fact a client can show on its
# welcome screen — session.info carries it only once a session exists.
from src.nano.state import is_nano_mode

return JSONResponse(
{
"status": "ok",
"auth_required": False,
"protocol_version": state.protocol_version,
"workspace": state.workspace,
"app": "clawcodex",
"nano": is_nano_mode(),
}
)

Expand Down
6 changes: 3 additions & 3 deletions tests/entrypoints/test_web_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_ipv6_host_is_bracketed() -> None:
def _args(**overrides: object) -> argparse.Namespace:
base = dict(
host="127.0.0.1", port=0, token=None, workspace=None, provider=None, model=None,
effort=None, permission_mode=None, dangerously_skip_permissions=False,
effort=None, permission_mode=None, nano=False, dangerously_skip_permissions=False,
)
base.update(overrides)
return argparse.Namespace(**base)
Expand All @@ -89,13 +89,13 @@ def test_serve_argv_forwards_every_agent_flag() -> None:
_args(
port=8317, token="t", workspace="/w", provider="deepseek",
model="deepseek-v4-pro", effort="high", permission_mode="plan",
dangerously_skip_permissions=True,
nano=True, dangerously_skip_permissions=True,
)
)
assert argv == [
"--host", "127.0.0.1", "--port", "8317", "--token", "t", "--workspace", "/w",
"--provider", "deepseek", "--model", "deepseek-v4-pro", "--effort", "high",
"--permission-mode", "plan", "--dangerously-skip-permissions",
"--permission-mode", "plan", "--nano", "--dangerously-skip-permissions",
]


Expand Down
Loading
Loading