hum-cli 0.0.1__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. hum_cli-0.1.0/.github/workflows/publish.yml +41 -0
  2. {hum_cli-0.0.1 → hum_cli-0.1.0}/.github/workflows/test.yml +4 -0
  3. hum_cli-0.1.0/.gitignore +8 -0
  4. {hum_cli-0.0.1 → hum_cli-0.1.0}/PKG-INFO +1 -1
  5. hum_cli-0.1.0/README.md +57 -0
  6. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/__init__.py +1 -1
  7. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/adapters/harbor.py +4 -1
  8. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/auth.py +27 -0
  9. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/cli.py +206 -36
  10. hum_cli-0.1.0/hum/engine.py +322 -0
  11. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/__init__.py +3 -1
  12. hum_cli-0.1.0/hum/runtime/agents.py +103 -0
  13. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/config.py +20 -2
  14. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/drivers.py +25 -4
  15. hum_cli-0.1.0/hum/runtime/llm.py +343 -0
  16. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/loop.py +128 -34
  17. hum_cli-0.1.0/hum/runtime/prompt.py +70 -0
  18. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/session.py +30 -4
  19. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/tools.py +25 -1
  20. hum_cli-0.1.0/hum/runtime/workspace.py +128 -0
  21. hum_cli-0.1.0/hum/ui.py +210 -0
  22. hum_cli-0.1.0/npm/bin/hum.js +8 -0
  23. hum_cli-0.1.0/npm/package.json +12 -0
  24. hum_cli-0.1.0/npm/scripts/postinstall.js +23 -0
  25. {hum_cli-0.0.1 → hum_cli-0.1.0}/pyproject.toml +3 -1
  26. {hum_cli-0.0.1 → hum_cli-0.1.0}/server/app.py +114 -9
  27. {hum_cli-0.0.1 → hum_cli-0.1.0}/server/modal_app.py +3 -1
  28. {hum_cli-0.0.1 → hum_cli-0.1.0}/tests/test_runtime.py +1 -1
  29. {hum_cli-0.0.1 → hum_cli-0.1.0}/tests/test_server_and_auth.py +18 -1
  30. hum_cli-0.1.0/tests/test_sme_ready.py +391 -0
  31. hum_cli-0.1.0/tui/package-lock.json +1562 -0
  32. hum_cli-0.1.0/tui/package.json +24 -0
  33. hum_cli-0.1.0/tui/src/app.jsx +369 -0
  34. hum_cli-0.1.0/tui/src/cli.jsx +49 -0
  35. hum_cli-0.1.0/tui/src/devtools-stub.js +3 -0
  36. hum_cli-0.1.0/tui/src/engine.js +80 -0
  37. hum_cli-0.1.0/tui/src/input.jsx +116 -0
  38. hum_cli-0.1.0/tui/src/markdown.js +224 -0
  39. hum_cli-0.0.1/.github/workflows/publish.yml +0 -24
  40. hum_cli-0.0.1/README.md +0 -50
  41. hum_cli-0.0.1/hum/runtime/llm.py +0 -225
  42. hum_cli-0.0.1/hum/runtime/prompt.py +0 -9
  43. hum_cli-0.0.1/hum/runtime/workspace.py +0 -48
  44. hum_cli-0.0.1/hum/ui.py +0 -109
  45. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/adapters/__init__.py +0 -0
  46. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/autonomy.py +0 -0
  47. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/executor.py +0 -0
  48. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/grader.py +0 -0
  49. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/models.py +0 -0
  50. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/observe.py +0 -0
  51. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/store.py +0 -0
  52. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/runtime/trajectory.py +0 -0
  53. {hum_cli-0.0.1 → hum_cli-0.1.0}/hum/sync.py +0 -0
  54. {hum_cli-0.0.1 → hum_cli-0.1.0}/server/__init__.py +0 -0
  55. {hum_cli-0.0.1 → hum_cli-0.1.0}/tests/test_cli.py +0 -0
@@ -0,0 +1,41 @@
1
+ name: publish
2
+ # Tag vX.Y.Z → build the terminal UI bundle, build the Python package (bundle inside),
3
+ # publish to PyPI (trusted publishing, no token) and to npm (@metaphi/hum, NPM_TOKEN secret).
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with: { node-version: 20 }
14
+ - run: cd tui && npm ci && npm run build
15
+ - uses: astral-sh/setup-uv@v5
16
+ - run: uv build
17
+ - uses: actions/upload-artifact@v4
18
+ with: { name: dist, path: dist/ }
19
+ - uses: actions/upload-artifact@v4
20
+ with: { name: npm, path: npm/ }
21
+ publish-pypi:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ environment: pypi
25
+ permissions:
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/download-artifact@v4
29
+ with: { name: dist, path: dist/ }
30
+ - uses: pypa/gh-action-pypi-publish@release/v1
31
+ publish-npm:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ continue-on-error: true # until the npm org + NPM_TOKEN exist, PyPI still ships
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with: { name: npm, path: npm/ }
38
+ - uses: actions/setup-node@v4
39
+ with: { node-version: 20, registry-url: "https://registry.npmjs.org" }
40
+ - run: cd npm && npm publish --access public
41
+ env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" }
@@ -9,6 +9,10 @@ jobs:
9
9
  runs-on: ${{ matrix.os }}
10
10
  steps:
11
11
  - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with: { node-version: 20 }
14
+ - run: cd tui && npm ci && npm run build
15
+ shell: bash
12
16
  - uses: astral-sh/setup-uv@v5
13
17
  - run: uv python install 3.12
14
18
  - run: uv venv --python 3.12 && uv pip install -e ".[dev,server,harbor]"
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ node_modules/
7
+ hum/tui/cli.mjs
8
+ npm/dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hum-cli
3
- Version: 0.0.1
3
+ Version: 0.1.0
4
4
  Summary: An agent harness built as an RL environment that humans can drive: session trees, interchangeable human/policy drivers, counterfactual shadow, earned autonomy, verifier in the loop.
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.27
@@ -0,0 +1,57 @@
1
+ # Hum — हम
2
+
3
+ *We.* Human and model, one harness.
4
+
5
+ Hum is an agent harness built as an RL environment that humans can drive. The same loop serves a person at a keyboard, a policy in a rollout, or a shadow continuing from a fork — and the loop remembers who did what.
6
+
7
+ What makes it different from a coding agent:
8
+
9
+ - **Interchangeable drivers.** A human turn is a first-class move in the session tree, not a prompt.
10
+ - **Counterfactual shadow.** When a human overrides a proposal, the pre-intervention state is forked, the policy finishes in shadow, and the grader scores both futures. Every override becomes a preference pair whose preference is a verifier, not a rater.
11
+ - **Earned autonomy.** How much the policy may do unsupervised on a class of tasks is a number the world assigns from graded outcomes, updated online — `observe → propose → review → act`.
12
+ - **Verifier in the loop.** The grader is both a tool the agent calls and the reward the trainer reads.
13
+ - **Trajectory-native.** Every session is a valid ATIF trajectory with authorship, interventions, shadow branches, verdicts and token custody — a training sample as it is written.
14
+ - **Model-agnostic, sovereign.** Any OpenAI-compatible endpoint: frontier, open weights, or your own policy served from the trainer. Runs on Modal or on your iron.
15
+
16
+ ## Run it
17
+
18
+ ```bash
19
+ npm install -g @metaphi/hum # or: uv tool install hum-cli (either one installs both halves)
20
+ hum login # browser opens → sign in with Google → back to the terminal
21
+ cd your-repo
22
+ hum # say what we're doing; it works, streams, shows its tool calls
23
+ hum "make the nightly job idempotent"
24
+ hum resume # pick up the last session
25
+ ```
26
+
27
+ Two halves, one command: the terminal UI is Ink (Node ≥ 18) and the loop is Python — the npm package installs the engine through uv on first install; the Python package ships the UI bundle and uses it when Node is present (`hum --plain` is the Rich fallback; `HUM_PLAIN=1` too).
28
+
29
+ While it works you see its thinking (dim), its words, and each tool call as it is being written — `writing src/PAYPOST.cbl…` appears the moment the path is known. Just type: a message steers it, `!cmd` runs a command yourself, Ctrl-C stops the current turn (what it had produced so far is kept). Edit files in your editor whenever you like — the session records the exact patch as your turn. Long sessions stay affordable: old tool output fades from the prompt and the context is summarised and restarted when it gets large (`/cost` shows how often). `/help` in a session lists the rest (`/model`, `/tools`, `/cost`).
30
+
31
+ Signing in gives you a key on Metaphi's model proxy — GLM-5.3 by default. Bringing your own model is allowed (`hum model anthropic/claude-fable-5`, `hum key anthropic <key>`); those sessions are marked as running on your model.
32
+
33
+ Other commands: `hum run "task"` (unattended, then exit — the mode rollouts use), `hum sessions`, `hum show <id>`, `hum sync`, `hum whoami`, `hum logout`.
34
+
35
+ ## Deployment (Metaphi)
36
+
37
+ One Modal app, `server/modal_app.py`: sign-in (Firebase Auth in the browser, ID token verified server-side; allowlist), the **model proxy** every client talks to (OpenAI-compatible `/v1/chat/completions`; the person's Hum token is their key; every call metered per person with a monthly budget), and session intake. State is SQLite + session files on the volume `hum-data`. Routes are named — `glm-5.3` on zero-data-retention providers, `policy` = our weights from the trainer, `fable` — so moving the fleet onto our model is one route change, no client update.
38
+
39
+ Tool servers (`control`, `run-unit`, …) are mounted for everyone from the server: `HUM_MCP_JSON` names the upstream MCP endpoints and their credentials; the client learns the list at login and reaches each one at `/mcp/<name>` with the person's own Hum token, so no upstream credential ever leaves the server. `~/.hum/config.toml` on a machine can additionally override the model, mount local MCP servers, and turn on backend behaviour — grader, autonomy-gated sessions, fork-and-grade on intervention, compaction threshold. Defaults are "be a great agent".
40
+
41
+ ## What a session records
42
+
43
+ One JSONL per session, a tree of nodes. Every node: who acted (policy / human / shadow / system), what was said and called, the tool results, and the workspace **tree id** after it executed — Hum keeps a private git directory per workspace (`~/.hum/shadow/…`, never the project's `.git`) and snapshots into it, so any state in the session can be diffed against any other. A hand edit is the tree moving when no tool moved it: recorded as the person's turn with the **full patch**. An interrupted generation is kept on an `aborted` branch next to what the person did instead. A compaction is a `system` node whose summary restarts the context; nothing before it leaves the tree. The file is uploaded while the session runs (every 20 s) and at the end.
44
+
45
+ ## As a Harbor agent
46
+
47
+ `harbor run -a hum.adapters.harbor:HumAgent -m openrouter/z-ai/glm-5.3 ...` — Terminal-Bench, MainframeBench through simhub `task_run --harness hum`, and Slime rollouts run the same class, with no human driver.
48
+
49
+ ## Layout
50
+
51
+ ```
52
+ hum/runtime/ session tree · drivers · loop (shadow, compaction) · workspace (tree ids, patches) · autonomy · tools (+MCP) · grader · llm (streams) · observe · trajectory (ATIF)
53
+ hum/adapters/ harbor
54
+ hum/cli.py commands, the Rich client, the TUI launcher · hum/engine.py the loop as a line protocol for a front-end
55
+ tui/ the Ink terminal UI (builds to hum/tui/cli.mjs and npm/dist) · npm/ the npm package (@metaphi/hum)
56
+ server/ sign-in · model proxy · MCP proxy · session intake (one Modal app)
57
+ ```
@@ -1,4 +1,4 @@
1
1
  """Hum — an agent harness built as an RL environment that humans can drive.
2
2
 
3
3
  हम (Hindi): we, us. Human and model, one harness."""
4
- __version__ = "0.0.1"
4
+ __version__ = "0.1.0"
@@ -28,6 +28,7 @@ from hum.runtime.executor import ExecResult, decode
28
28
  from hum.runtime.prompt import system_prompt
29
29
  from hum.runtime.tools import MCPToolset
30
30
  from hum.runtime.trajectory import to_atif
31
+ from hum.runtime.workspace import Workspace
31
32
 
32
33
  AGENT_NAME = "hum"
33
34
 
@@ -111,7 +112,9 @@ class HumAgent(BaseAgent):
111
112
  temperature=self._temperature, reasoning_effort=self._reasoning_effort)
112
113
  prompt = system_prompt(tools_note="\n".join(notes))
113
114
  session = Session({"instruction": instruction}, session_id=self.session_id, path=self.logs_dir / "session.jsonl")
114
- runner = Runner(session, executor, tools, PolicyDriver(llm), config=self._cfg, system_prompt=prompt)
115
+ # every node carries the sandbox tree id; the shadow git dir lives in the sandbox, outside /app
116
+ runner = Runner(session, executor, tools, PolicyDriver(llm), config=self._cfg, system_prompt=prompt,
117
+ workspace=Workspace(executor, "/tmp/hum-shadow"))
115
118
  try:
116
119
  result = await runner.run()
117
120
  finally:
@@ -30,6 +30,7 @@ class Credentials:
30
30
  default_model: str | None = None
31
31
  byo: dict[str, str] = field(default_factory=dict) # provider -> key, user-supplied
32
32
  model: str | None = None # user's chosen model, if any
33
+ mcp: list[dict[str, Any]] = field(default_factory=list) # tool servers the deployment mounts for this person
33
34
 
34
35
  @property
35
36
  def logged_in(self) -> bool:
@@ -94,6 +95,7 @@ def login(auth_url: str | None = None, open_browser: bool = True, poll_timeout_s
94
95
  c.name = body["user"].get("name")
95
96
  m = body.get("model") or {}
96
97
  c.api_base, c.api_key, c.default_model = m.get("api_base"), m.get("api_key"), m.get("default_model")
98
+ c.mcp = list(body.get("mcp") or [])
97
99
  save(c)
98
100
  return c
99
101
  err = body.get("error")
@@ -106,7 +108,32 @@ def login(auth_url: str | None = None, open_browser: bool = True, poll_timeout_s
106
108
  raise TimeoutError("login timed out; run `hum login` again")
107
109
 
108
110
 
111
+ def refresh(c: Credentials | None = None, timeout: float = 3.0, client: Any = None) -> Credentials:
112
+ """Best-effort: ask the server what this person has today (default model, tool
113
+ servers, budget). Never raises; on any failure the saved credentials stand."""
114
+ import httpx
115
+
116
+ c = c or load()
117
+ if not c.logged_in:
118
+ return c
119
+ try:
120
+ http = client or httpx.Client(timeout=timeout)
121
+ r = http.get(f"{c.auth_url}/me", headers={"Authorization": f"Bearer {c.access_token}"})
122
+ if r.status_code != 200:
123
+ return c
124
+ d = r.json()
125
+ m = d.get("model") or {}
126
+ c.default_model = m.get("default_model") or c.default_model
127
+ c.api_base = m.get("api_base") or c.api_base
128
+ c.mcp = list(d.get("mcp") or [])
129
+ save(c)
130
+ except Exception: # noqa: BLE001
131
+ pass
132
+ return c
133
+
134
+
109
135
  def logout() -> None:
110
136
  c = load()
111
137
  c.access_token = c.email = c.name = c.api_base = c.api_key = c.default_model = None
138
+ c.mcp = []
112
139
  save(c)
@@ -23,11 +23,12 @@ from pathlib import Path
23
23
  from typing import Any
24
24
 
25
25
  from hum import __version__
26
- from hum.runtime import (AutonomyPolicy, HumanAction, HumanDriver, LiteLLMClient, LocalExecutor, OpenAICompatClient, PolicyDriver,
27
- RunConfig, Runner, Session, ToolCall, ToolRegistry, Turn, builtin_tools)
28
- from hum.runtime.config import Config, load as load_config
26
+ from hum.runtime import (AutonomyPolicy, Delegation, HumanAction, HumanDriver, LiteLLMClient, LocalExecutor, OpenAICompatClient, PolicyDriver,
27
+ RunConfig, Runner, Session, StreamEvents, ToolCall, ToolRegistry, Turn, Workspace, builtin_tools, shadow_dir_for)
28
+ from hum.runtime.config import Config, MCPConfig, load as load_config
29
29
  from hum.runtime.grader import PredicateGrader
30
- from hum.runtime.prompt import system_prompt
30
+ from hum.runtime.llm import error_text
31
+ from hum.runtime.prompt import project_instructions, system_prompt
31
32
  from hum.runtime.store import autonomy_path, home, list_sessions, session_path
32
33
  from hum.runtime.tools import MCPToolset
33
34
  from hum.ui import UI
@@ -40,11 +41,14 @@ HELP = """/help this
40
41
  !cmd run a command yourself
41
42
  Ctrl-C stop the current turn"""
42
43
 
44
+ UPLOAD_EVERY_S = 20.0
45
+
43
46
 
44
47
  def make_llm(cfg: Config):
45
48
  """Our proxy (or any OpenAI-compatible base): talk to it directly. BYO providers: litellm."""
46
49
  if cfg.api_base:
47
- return OpenAICompatClient(cfg.model, api_base=cfg.api_base, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort)
50
+ return OpenAICompatClient(cfg.model, api_base=cfg.api_base, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort,
51
+ idle_timeout=cfg.idle_timeout_s)
48
52
  return LiteLLMClient(cfg.model, api_key=cfg.api_key, temperature=cfg.temperature, reasoning_effort=cfg.reasoning_effort)
49
53
 
50
54
 
@@ -69,20 +73,77 @@ class VisibleTools(ToolRegistry):
69
73
  return results
70
74
 
71
75
 
76
+ class VisiblePolicy(PolicyDriver):
77
+ """The policy driver with the person watching: thinking, text and tool calls stream as they happen."""
78
+
79
+ def __init__(self, llm: Any, ui: UI):
80
+ super().__init__(llm, events=StreamEvents(text=ui.text, reasoning=ui.reasoning, tool=ui.tool_delta))
81
+ self.ui = ui
82
+ self.on_retry = lambda attempt, e: ui.warn(f"model connection: {error_text(e)} — retrying ({attempt})")
83
+
84
+ async def next(self, messages, tools): # type: ignore[override]
85
+ self.ui.turn_started()
86
+ t = await super().next(messages, tools)
87
+ self.ui.end_text()
88
+ return t
89
+
90
+
91
+ class Uploader:
92
+ """Sessions go home while they happen, not only at the end: every ``UPLOAD_EVERY_S``
93
+ the file is re-sent if it grew. A crashed terminal loses at most that window."""
94
+
95
+ def __init__(self, path: Path):
96
+ self.path = path
97
+ self._sent = -1
98
+ self._task: asyncio.Task | None = None
99
+
100
+ def start(self) -> None:
101
+ self._task = asyncio.create_task(self._loop())
102
+
103
+ async def _loop(self) -> None:
104
+ while True:
105
+ await asyncio.sleep(UPLOAD_EVERY_S)
106
+ await self.push()
107
+
108
+ async def push(self) -> None:
109
+ try:
110
+ size = self.path.stat().st_size
111
+ except OSError:
112
+ return
113
+ if size == self._sent:
114
+ return
115
+ try:
116
+ from hum import sync
117
+ if await asyncio.to_thread(sync.upload, self.path, None, None, 8.0):
118
+ self._sent = size
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+
122
+ async def stop(self) -> None:
123
+ if self._task:
124
+ self._task.cancel()
125
+ try:
126
+ await self._task
127
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
128
+ pass
129
+ await self.push()
130
+
131
+
72
132
  class Client:
73
133
  def __init__(self, cfg: Config, cwd: Path, ui: UI | None = None):
74
134
  self.cfg, self.cwd = cfg, cwd
75
- self.ui = ui or UI()
135
+ self.ui = ui or UI(show_reasoning=cfg.show_reasoning)
76
136
  self.executor = LocalExecutor(cwd)
77
137
  self.tools = VisibleTools(self.ui).extend(builtin_tools(self.executor.shell))
78
138
  self.toolsets: list[MCPToolset] = []
79
139
  self.task_class = cfg.task_class or cwd.name
80
140
  self.llm = make_llm(cfg)
141
+ self.workspace = Workspace(self.executor, shadow_dir_for(cwd, home()))
81
142
 
82
143
  # -- setup ---------------------------------------------------------------------------
83
144
  async def mount(self) -> list[str]:
84
145
  names = [self.executor.shell, "files", "grep"]
85
- for m in self.cfg.mcp:
146
+ for m in [*self.cfg.mcp, *self.cfg.remote_mcp]:
86
147
  ts = MCPToolset(m.name, url=m.url, command=m.command, args=m.args, headers=m.headers)
87
148
  try:
88
149
  mounted = await ts.connect()
@@ -98,13 +159,35 @@ class Client:
98
159
  for ts in self.toolsets:
99
160
  await ts.close()
100
161
 
162
+ def _delegation(self, session: Session) -> Delegation:
163
+ ui = self.ui
164
+
165
+ def events_for(n: int) -> StreamEvents:
166
+ return StreamEvents(text=ui.text, reasoning=ui.reasoning, tool=ui.tool_delta)
167
+
168
+ def wrap(reg: ToolRegistry) -> ToolRegistry:
169
+ v = VisibleTools(ui, observation_max_bytes=reg.observation_max_bytes)
170
+ for t in reg.tools.values():
171
+ v.add(t)
172
+ return v
173
+
174
+ return Delegation(session=session, executor=self.executor, tools=self.tools, llm_factory=lambda: make_llm(self.cfg),
175
+ events_for=events_for, tools_wrapper=wrap, on_start=ui.agent_start, on_end=ui.agent_end)
176
+
101
177
  def runner(self, session: Session, human: HumanDriver | None) -> Runner:
102
- policy = PolicyDriver(self.llm, on_text=self.ui.text)
178
+ policy = VisiblePolicy(self.llm, self.ui)
103
179
  grader = command_grader(self.cfg.grader) if self.cfg.grader and not self.cfg.grader.startswith("mcp:") else None
104
- rc = RunConfig(gate=self.cfg.gate, shadow=self.cfg.shadow and human is not None, max_turns=self.cfg.max_turns, task_class=self.task_class)
105
- notes = "\n".join(f"- {t.name}" for t in self.tools.tools.values() if "__" in t.name)
106
- return Runner(session, self.executor, self.tools, policy, human=human, grader=grader, autonomy=AutonomyPolicy(autonomy_path()),
107
- config=rc, system_prompt=system_prompt(tools_note=notes, world_note=f"Working directory: {self.cwd} (shell: {self.executor.shell}, {sys.platform})"))
180
+ rc = RunConfig(gate=self.cfg.gate, shadow=self.cfg.shadow and human is not None, max_turns=self.cfg.max_turns, task_class=self.task_class,
181
+ compact_at_tokens=self.cfg.compact_at_tokens)
182
+ if "delegate" not in self.tools.tools:
183
+ self.delegation = self._delegation(session)
184
+ self.tools.add(self.delegation.tool())
185
+ notes = "\n".join(f"- {t.name}: {t.description[:120]}" for t in self.tools.tools.values() if "__" in t.name)
186
+ r = Runner(session, self.executor, self.tools, policy, human=human, grader=grader, autonomy=AutonomyPolicy(autonomy_path()),
187
+ config=rc, workspace=self.workspace,
188
+ system_prompt=system_prompt(tools_note=notes, world_note=f"Working directory: {self.cwd} (shell: {self.executor.shell}, {sys.platform})",
189
+ instructions=project_instructions(self.cwd)))
190
+ return r
108
191
 
109
192
  def _session(self, task: str, kind: str) -> Session:
110
193
  meta = {"instruction": task, "task_class": self.task_class, "cwd": str(self.cwd), "model": self.cfg.model,
@@ -115,19 +198,20 @@ class Client:
115
198
  async def one_shot(self, task: str) -> int:
116
199
  names = await self.mount()
117
200
  session = self._session(task, "run")
201
+ up = Uploader(session.path); up.start()
118
202
  self.ui.banner(self.cfg.model, names, session.path.name, self.cfg.user)
119
203
  r = self.runner(session, None)
120
204
  self.ui.thinking(True)
121
205
  try:
122
206
  res = await r.run()
123
207
  except Exception as e: # noqa: BLE001
124
- self.ui.error(f"model error: {str(e).splitlines()[0][:300]}")
208
+ self.ui.error(f"model error: {error_text(e)} — the session is saved; `hum resume` picks it up")
125
209
  return 2
126
210
  finally:
127
211
  self.ui.thinking(False)
128
212
  self.ui.end_text()
129
213
  await self.close()
130
- _sync_quietly(session.path)
214
+ await up.stop()
131
215
  self.ui.summary(res.turns, res.usage.cost_usd, res.verdict)
132
216
  return 0 if (res.verdict is None or res.verdict.passed) else 1
133
217
 
@@ -139,9 +223,11 @@ class Client:
139
223
  from prompt_toolkit.patch_stdout import patch_stdout
140
224
 
141
225
  names = await self.mount()
142
- ps: PromptSession = PromptSession(history=FileHistory(str(home() / "history")))
226
+ ps: PromptSession = PromptSession(history=FileHistory(str(home() / "history")),
227
+ bottom_toolbar=lambda: self.ui.toolbar() or None, refresh_interval=0.5)
143
228
  session = resume
144
229
  pending: Turn | None = None
230
+ up: Uploader | None = None
145
231
  with patch_stdout(raw=True):
146
232
  if session is None:
147
233
  task = first
@@ -155,6 +241,7 @@ class Client:
155
241
  session = self._session(task.strip(), "chat")
156
242
  else:
157
243
  pending = None
244
+ up = Uploader(session.path); up.start()
158
245
  self.ui.banner(self.cfg.model, names, session.path.name, self.cfg.user)
159
246
  human = HumanDriver()
160
247
  runner = self.runner(session, human)
@@ -164,6 +251,7 @@ class Client:
164
251
  if head is not None and pending is None and (head.turn.author == "policy" or resume is not None):
165
252
  # model is idle (resume, or after a finished turn): ask the person
166
253
  resume = None
254
+ self.ui.idle()
167
255
  try:
168
256
  line = await ps.prompt_async(HTML("<b>›</b> "))
169
257
  except EOFError:
@@ -181,11 +269,12 @@ class Client:
181
269
  pending = None
182
270
  await self._attend(ps, run_task, human, runner)
183
271
  except Exception as e: # noqa: BLE001
184
- self.ui.error(f"model error: {str(e).splitlines()[0][:300]}")
272
+ self.ui.error(f"model error: {error_text(e)} — the session is saved; `hum resume` picks it up")
185
273
  return 2
186
274
  finally:
275
+ self.ui.idle()
187
276
  await self.close()
188
- _sync_quietly(session.path)
277
+ await up.stop()
189
278
  self.ui.info(f"session saved: {session.path.name}")
190
279
  return 0
191
280
 
@@ -199,14 +288,15 @@ class Client:
199
288
  try:
200
289
  line = line_task.result()
201
290
  except KeyboardInterrupt:
291
+ partial = runner.policy.partial()
202
292
  run_task.cancel()
203
293
  try:
204
294
  await run_task
205
295
  except (asyncio.CancelledError, Exception): # noqa: BLE001
206
296
  pass
207
297
  self.ui.end_text()
208
- runner.record_interrupt()
209
- self.ui.info("stopped.")
298
+ runner.record_interrupt(partial=partial)
299
+ self.ui.info("stopped." + (" (what it had so far is kept)" if partial else ""))
210
300
  return
211
301
  except EOFError:
212
302
  run_task.cancel()
@@ -247,8 +337,9 @@ class Client:
247
337
  elif cmd == "tools":
248
338
  self.ui.info(", ".join(sorted(self.tools.tools)))
249
339
  elif cmd == "cost":
250
- u = runner.usage
251
- self.ui.info(f"{u.input_tokens}+{u.output_tokens} tokens · ${u.cost_usd:.4f} · {runner.turns} turns")
340
+ u = runner.usage + getattr(getattr(self, "delegation", None), "usage", u.__class__())
341
+ self.ui.info(f"{u.input_tokens}+{u.output_tokens} tokens · ${u.cost_usd:.4f} · {runner.turns} turns" +
342
+ (f" · context compacted {runner.compactions}×" if runner.compactions else ""))
252
343
  else:
253
344
  self.ui.warn(f"unknown /{cmd} — /help")
254
345
  return "handled"
@@ -260,14 +351,6 @@ class Client:
260
351
  return Turn(author="human", content=s)
261
352
 
262
353
 
263
- def _sync_quietly(path: Path) -> None:
264
- try:
265
- from hum import sync
266
- sync.upload(path, timeout=5.0)
267
- except Exception: # noqa: BLE001
268
- pass
269
-
270
-
271
354
  # --- commands -------------------------------------------------------------------------------------
272
355
 
273
356
  def cmd_sessions(ui: UI) -> None:
@@ -294,8 +377,10 @@ def cmd_show(ui: UI, name: str) -> None:
294
377
  n = s.nodes[nid]
295
378
  iv = f" [yellow]{n.intervention.kind}[/]" if n.intervention else ""
296
379
  vd = "" if not n.verdict else (" [green]✓[/]" if n.verdict.passed else " [red]✗[/]")
380
+ ev = f" [magenta]{n.extra['event']}[/]" if n.extra.get("event") in ("compaction", "aborted", "human_edit") else ""
297
381
  calls = ", ".join(c.name for c in n.turn.tool_calls) or "—"
298
- ui.console.print(f"[dim]{n.branch:>8}[/] {n.turn.author:<6}{iv}{vd} {calls} [dim]{(n.turn.content or '')[:70]!r}[/]")
382
+ tree = f" [dim]{n.extra['tree'][:7]}[/]" if n.extra.get("tree") else ""
383
+ ui.console.print(f"[dim]{n.branch:>9}[/] {n.turn.author:<6}{iv}{vd}{ev} {calls}{tree} [dim]{(n.turn.content or '')[:70]!r}[/]")
299
384
  for p in s.pairs:
300
385
  ui.console.print(f"[bold]pair[/] preferred={p.preferred}")
301
386
 
@@ -307,7 +392,8 @@ def cmd_login(ui: UI, auth_url: str | None) -> int:
307
392
  except Exception as e: # noqa: BLE001
308
393
  ui.error(f"login failed: {e}")
309
394
  return 1
310
- ui.info(f"signed in as {c.email}" + (f" · model {c.default_model} via {c.api_base}" if c.api_base else ""))
395
+ ui.info(f"signed in as {c.email}" + (f" · model {c.default_model} via {c.api_base}" if c.api_base else "") +
396
+ (f" · tools: {', '.join(m['name'] for m in c.mcp)}" if c.mcp else ""))
311
397
  return 0
312
398
 
313
399
 
@@ -315,7 +401,8 @@ def cmd_whoami(ui: UI) -> None:
315
401
  from hum import auth
316
402
  c = auth.load()
317
403
  cfg = load_config()
318
- ui.info(f"{c.email or 'not signed in'} · model {cfg.model}" + (f" via {cfg.api_base}" if cfg.api_base else (" (your own key)" if cfg.byo else " (no key)")))
404
+ ui.info(f"{c.email or 'not signed in'} · model {cfg.model}" + (f" via {cfg.api_base}" if cfg.api_base else (" (your own key)" if cfg.byo else " (no key)")) +
405
+ (f" · tools: {', '.join(m.name for m in [*cfg.mcp, *cfg.remote_mcp])}" if cfg.mcp or cfg.remote_mcp else ""))
319
406
 
320
407
 
321
408
  def cmd_model(ui: UI, name: str | None) -> None:
@@ -334,22 +421,86 @@ def cmd_key(ui: UI, provider: str, key: str) -> None:
334
421
  ui.info(f"stored your {provider} key (sessions on it are marked as your own model)")
335
422
 
336
423
 
424
+ TUI_BUNDLE = Path(__file__).parent / "tui" / "cli.mjs"
425
+
426
+
427
+ def _node() -> str | None:
428
+ """A Node ≥ 18 on PATH, or None (then the Rich client runs)."""
429
+ import shutil
430
+ import subprocess
431
+ exe = shutil.which("node")
432
+ if not exe:
433
+ return None
434
+ try:
435
+ v = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=5).stdout.strip().lstrip("v")
436
+ return exe if int(v.split(".")[0]) >= 18 else None
437
+ except Exception: # noqa: BLE001
438
+ return None
439
+
440
+
441
+ def launch_tui(cfg: Config, cwd: Path, task: str | None, resume: str | None) -> int:
442
+ """Run the loop here; hand the terminal to the Ink front-end, which connects back over localhost."""
443
+ import subprocess
444
+ import threading
445
+
446
+ node = _node()
447
+ if node is None or not TUI_BUNDLE.exists() or os.environ.get("HUM_PLAIN"):
448
+ return -1
449
+ ready: dict[str, int] = {}
450
+ evt = threading.Event()
451
+
452
+ async def run() -> int:
453
+ from hum.engine import Engine, Wire
454
+ done: asyncio.Future[int] = asyncio.get_event_loop().create_future()
455
+
456
+ async def on_conn(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
457
+ try:
458
+ rc = await Engine(cfg, cwd, Wire(reader, writer)).serve()
459
+ finally:
460
+ if not done.done():
461
+ done.set_result(rc)
462
+ server = await asyncio.start_server(on_conn, "127.0.0.1", 0)
463
+ ready["port"] = server.sockets[0].getsockname()[1]
464
+ evt.set()
465
+ async with server:
466
+ return await done
467
+
468
+ result: dict[str, int] = {}
469
+ t = threading.Thread(target=lambda: result.setdefault("rc", asyncio.run(run())), daemon=True)
470
+ t.start()
471
+ evt.wait(10)
472
+ argv = [node, str(TUI_BUNDLE)]
473
+ if resume:
474
+ argv += ["resume", resume]
475
+ elif task:
476
+ argv.append(task)
477
+ env = {**os.environ, "HUM_ENGINE_PORT": str(ready.get("port", 0))}
478
+ try:
479
+ rc = subprocess.run(argv, cwd=str(cwd), env=env).returncode
480
+ except KeyboardInterrupt:
481
+ rc = 130
482
+ t.join(15)
483
+ return rc
484
+
485
+
337
486
  def main(argv: list[str] | None = None) -> int:
338
487
  argv = list(sys.argv[1:] if argv is None else argv)
339
- subs = ("chat", "run", "resume", "login", "logout", "whoami", "model", "key", "sessions", "show", "sync")
488
+ subs = ("chat", "run", "resume", "login", "logout", "whoami", "model", "key", "sessions", "show", "sync", "engine")
340
489
  if not argv or (argv[0] not in subs and argv[0] not in ("-h", "--help", "--version")):
341
490
  argv = ["chat", *argv]
342
491
  ap = argparse.ArgumentParser(prog="hum", description="Hum — हम. Human and model, one harness.")
343
492
  ap.add_argument("--version", action="version", version=f"hum {__version__}")
344
493
  sub = ap.add_subparsers(dest="cmd")
345
- cp = sub.add_parser("chat"); cp.add_argument("task", nargs="?"); cp.add_argument("-C", "--cwd", default=".")
494
+ cp = sub.add_parser("chat"); cp.add_argument("task", nargs="?"); cp.add_argument("-C", "--cwd", default="."); cp.add_argument("--plain", action="store_true", help="the Rich client, no Ink")
346
495
  rp = sub.add_parser("run"); rp.add_argument("task"); rp.add_argument("-C", "--cwd", default=".")
347
- rs = sub.add_parser("resume"); rs.add_argument("name", nargs="?", default="last")
496
+ rs = sub.add_parser("resume"); rs.add_argument("name", nargs="?", default="last"); rs.add_argument("--plain", action="store_true")
348
497
  lg = sub.add_parser("login"); lg.add_argument("--auth-url")
349
498
  sub.add_parser("logout"); sub.add_parser("whoami"); sub.add_parser("sessions"); sub.add_parser("sync")
350
499
  mp = sub.add_parser("model"); mp.add_argument("name", nargs="?")
351
500
  kp = sub.add_parser("key"); kp.add_argument("provider"); kp.add_argument("key")
352
501
  sp = sub.add_parser("show"); sp.add_argument("name")
502
+ ep = sub.add_parser("engine", help="serve the loop to a front-end over a localhost socket")
503
+ ep.add_argument("-C", "--cwd", default="."); ep.add_argument("--listen", action="store_true"); ep.add_argument("--port", type=int, default=int(os.environ.get("HUM_ENGINE_PORT", "0") or 0))
353
504
  a = ap.parse_args(argv)
354
505
  ui = UI()
355
506
  if a.cmd == "sessions":
@@ -369,20 +520,39 @@ def main(argv: list[str] | None = None) -> int:
369
520
  if a.cmd == "sync":
370
521
  from hum import sync
371
522
  sent, skipped = sync.sync_all(); ui.info(f"sent {sent}, already there {skipped}"); return 0
523
+ from hum import auth
524
+ auth.refresh() # what the deployment gives this person today (model, tool servers); quiet on failure
372
525
  cfg = load_config()
373
526
  from hum.auth import PROVIDER_ENV, provider_of
374
527
  if cfg.api_key is None and cfg.api_base is None and "/" in cfg.model and provider_of(cfg.model) in PROVIDER_ENV:
375
528
  ui.error("no model access — `hum login`, or `hum key openrouter <key>`, or ~/.hum/config.toml"); return 2
376
529
  try:
530
+ if a.cmd == "engine":
531
+ from hum.engine import serve
532
+ return asyncio.run(serve(cfg, Path(a.cwd).resolve(), listen=a.listen or not a.port, port=a.port or None))
377
533
  if a.cmd == "run":
378
534
  return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).one_shot(a.task))
379
535
  if a.cmd == "resume":
380
536
  s = Session.load(_find(a.name))
381
- return asyncio.run(Client(cfg, Path(s.task.get("cwd", ".")).resolve(), ui).chat(None, resume=s))
537
+ cwd = Path(s.task.get("cwd", ".")).resolve()
538
+ if not a.plain and sys.stdin.isatty():
539
+ rc = launch_tui(cfg, cwd, None, a.name)
540
+ if rc >= 0:
541
+ return rc
542
+ return asyncio.run(Client(cfg, cwd, ui).chat(None, resume=s))
543
+ if not a.plain and sys.stdin.isatty():
544
+ rc = launch_tui(cfg, Path(a.cwd).resolve(), a.task, None)
545
+ if rc >= 0:
546
+ return rc
382
547
  return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).chat(a.task))
383
548
  except KeyboardInterrupt:
384
549
  print(); return 130
385
550
 
386
551
 
552
+ def main_engine(argv: list[str] | None = None) -> int:
553
+ """``hum-engine``: the loop as a service for a front-end (the npm package's entry spawns this)."""
554
+ return main(["engine", *(sys.argv[1:] if argv is None else argv)])
555
+
556
+
387
557
  if __name__ == "__main__":
388
558
  sys.exit(main())