hum-cli 0.1.1__tar.gz → 0.1.2__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.
- {hum_cli-0.1.1 → hum_cli-0.1.2}/PKG-INFO +1 -1
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/__init__.py +1 -1
- hum_cli-0.1.2/hum/catalog.py +64 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/cli.py +19 -14
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/engine.py +55 -12
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/sync.py +35 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/tui/cli.mjs +51 -4
- {hum_cli-0.1.1 → hum_cli-0.1.2}/npm/package.json +1 -1
- {hum_cli-0.1.1 → hum_cli-0.1.2}/pyproject.toml +1 -1
- {hum_cli-0.1.1 → hum_cli-0.1.2}/server/app.py +31 -2
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_sme_ready.py +89 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/app.jsx +36 -3
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/cli.jsx +1 -1
- {hum_cli-0.1.1 → hum_cli-0.1.2}/.github/workflows/publish.yml +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/.github/workflows/test.yml +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/.gitignore +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/README.md +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/adapters/__init__.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/adapters/harbor.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/auth.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/__init__.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/agents.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/autonomy.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/config.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/drivers.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/executor.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/grader.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/llm.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/loop.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/models.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/observe.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/prompt.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/session.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/store.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/tools.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/trajectory.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/workspace.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/ui.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/npm/bin/hum.js +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/npm/scripts/postinstall.js +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/server/__init__.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/server/modal_app.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_cli.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_runtime.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_server_and_auth.py +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/package-lock.json +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/package.json +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/devtools-stub.js +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/engine.js +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/input.jsx +0 -0
- {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/markdown.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hum-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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,64 @@
|
|
|
1
|
+
"""What a person sees when they choose a session to pick up: when, what, how far,
|
|
2
|
+
what it cost, where. Local files first; the server's list fills in sessions from
|
|
3
|
+
other machines (pulled on demand when chosen)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from hum.runtime.store import list_sessions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _summary(path: Path) -> dict[str, Any]:
|
|
15
|
+
steps = 0
|
|
16
|
+
cost = 0.0
|
|
17
|
+
task: dict[str, Any] = {}
|
|
18
|
+
last = 0.0
|
|
19
|
+
try:
|
|
20
|
+
with path.open(encoding="utf-8") as f:
|
|
21
|
+
for line in f:
|
|
22
|
+
try:
|
|
23
|
+
r = json.loads(line)
|
|
24
|
+
except json.JSONDecodeError:
|
|
25
|
+
continue
|
|
26
|
+
k = r.get("kind")
|
|
27
|
+
if k == "session":
|
|
28
|
+
task = r.get("task") or {}
|
|
29
|
+
elif k == "meta":
|
|
30
|
+
task.update({kk: v for kk, v in r.items() if kk != "kind"})
|
|
31
|
+
elif k == "node":
|
|
32
|
+
if r.get("branch") == "main":
|
|
33
|
+
steps += 1
|
|
34
|
+
u = (r.get("turn") or {}).get("usage") or {}
|
|
35
|
+
cost += float(u.get("cost_usd") or 0)
|
|
36
|
+
last = max(last, float(r.get("at") or 0))
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
return {"name": path.name, "instruction": (task.get("instruction") or "")[:200], "cwd": task.get("cwd"), "model": task.get("model"),
|
|
40
|
+
"steps": steps, "cost_usd": round(cost, 4), "at": last or path.stat().st_mtime, "local": True}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def catalog(remote: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
|
44
|
+
"""Newest first. Remote entries not present locally are included with local=False."""
|
|
45
|
+
items = {p.name: _summary(p) for p in list_sessions()}
|
|
46
|
+
for r in remote or []:
|
|
47
|
+
n = r.get("name")
|
|
48
|
+
if n and n not in items:
|
|
49
|
+
items[n] = {"name": n, "instruction": (r.get("instruction") or "")[:200], "cwd": r.get("cwd"), "model": r.get("model"),
|
|
50
|
+
"steps": None, "cost_usd": None, "at": float(r.get("created") or 0), "local": False}
|
|
51
|
+
return sorted(items.values(), key=lambda x: x["at"], reverse=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def when(at: float, now: float | None = None) -> str:
|
|
55
|
+
d = (now or time.time()) - at
|
|
56
|
+
if d < 90:
|
|
57
|
+
return "just now"
|
|
58
|
+
if d < 3600:
|
|
59
|
+
return f"{int(d // 60)}m ago"
|
|
60
|
+
if d < 86400:
|
|
61
|
+
return f"{int(d // 3600)}h ago"
|
|
62
|
+
if d < 7 * 86400:
|
|
63
|
+
return f"{int(d // 86400)}d ago"
|
|
64
|
+
return time.strftime("%b %d", time.localtime(at))
|
|
@@ -37,6 +37,7 @@ HELP = """/help this
|
|
|
37
37
|
/model [name] show or switch the model for this session
|
|
38
38
|
/tools what's mounted
|
|
39
39
|
/cost tokens and cost so far
|
|
40
|
+
/compact summarise and restart the context now
|
|
40
41
|
/quit leave (Ctrl-D works too)
|
|
41
42
|
!cmd run a command yourself
|
|
42
43
|
Ctrl-C stop the current turn"""
|
|
@@ -336,6 +337,10 @@ class Client:
|
|
|
336
337
|
self.ui.info(f"model: {self.cfg.model}" + (f" via {self.cfg.api_base}" if self.cfg.api_base else ""))
|
|
337
338
|
elif cmd == "tools":
|
|
338
339
|
self.ui.info(", ".join(sorted(self.tools.tools)))
|
|
340
|
+
elif cmd == "compact":
|
|
341
|
+
head = session.head("main")
|
|
342
|
+
cid = asyncio.get_event_loop().run_until_complete(runner._compact(head.id if head else None, runner.policy, 0)) if False else None
|
|
343
|
+
self.ui.info("use /compact from the terminal UI; the plain client compacts automatically at the threshold")
|
|
339
344
|
elif cmd == "cost":
|
|
340
345
|
u = runner.usage + getattr(getattr(self, "delegation", None), "usage", u.__class__())
|
|
341
346
|
self.ui.info(f"{u.input_tokens}+{u.output_tokens} tokens · ${u.cost_usd:.4f} · {runner.turns} turns" +
|
|
@@ -354,20 +359,20 @@ class Client:
|
|
|
354
359
|
# --- commands -------------------------------------------------------------------------------------
|
|
355
360
|
|
|
356
361
|
def cmd_sessions(ui: UI) -> None:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
ui.console.print(f"[dim]{p.name}[/] (unreadable: {e})")
|
|
362
|
+
from hum import sync
|
|
363
|
+
from hum.catalog import catalog, when
|
|
364
|
+
for it in catalog(sync.remote_list()):
|
|
365
|
+
steps = f"{it['steps']:>3} steps" if it["steps"] is not None else " (on the server)"
|
|
366
|
+
cost = f"${it['cost_usd']:.2f}" if it["cost_usd"] else ""
|
|
367
|
+
ui.console.print(f"[dim]{when(it['at']):>9}[/] {steps:<16} {cost:>6} {it['instruction'][:60]} [dim]{Path(it['cwd']).name if it.get('cwd') else ''}[/]")
|
|
364
368
|
|
|
365
369
|
|
|
366
370
|
def _find(name: str) -> Path:
|
|
367
|
-
|
|
368
|
-
|
|
371
|
+
from hum.engine import find_session
|
|
372
|
+
p = find_session(name or "last")
|
|
373
|
+
if p is None:
|
|
369
374
|
sys.exit(f"no session matching {name!r}")
|
|
370
|
-
return
|
|
375
|
+
return p
|
|
371
376
|
|
|
372
377
|
|
|
373
378
|
def cmd_show(ui: UI, name: str) -> None:
|
|
@@ -494,6 +499,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
494
499
|
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")
|
|
495
500
|
rp = sub.add_parser("run"); rp.add_argument("task"); rp.add_argument("-C", "--cwd", default=".")
|
|
496
501
|
rs = sub.add_parser("resume"); rs.add_argument("name", nargs="?", default="last"); rs.add_argument("--plain", action="store_true")
|
|
502
|
+
rs.add_argument("--last", dest="pick_last", action="store_true", help="skip the picker; continue the newest session")
|
|
497
503
|
lg = sub.add_parser("login"); lg.add_argument("--auth-url")
|
|
498
504
|
sub.add_parser("logout"); sub.add_parser("whoami"); sub.add_parser("sessions"); sub.add_parser("sync")
|
|
499
505
|
mp = sub.add_parser("model"); mp.add_argument("name", nargs="?")
|
|
@@ -533,13 +539,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
533
539
|
if a.cmd == "run":
|
|
534
540
|
return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).one_shot(a.task))
|
|
535
541
|
if a.cmd == "resume":
|
|
536
|
-
s = Session.load(_find(a.name))
|
|
537
|
-
cwd = Path(s.task.get("cwd", ".")).resolve()
|
|
538
542
|
if not a.plain and sys.stdin.isatty():
|
|
539
|
-
rc = launch_tui(cfg, cwd, None, a.name)
|
|
543
|
+
rc = launch_tui(cfg, Path(a.cwd if hasattr(a, "cwd") else ".").resolve(), None, "pick" if a.name == "last" and not a.pick_last else a.name)
|
|
540
544
|
if rc >= 0:
|
|
541
545
|
return rc
|
|
542
|
-
|
|
546
|
+
s = Session.load(_find(a.name))
|
|
547
|
+
return asyncio.run(Client(cfg, Path(s.task.get("cwd", ".")).resolve(), ui).chat(None, resume=s))
|
|
543
548
|
if not a.plain and sys.stdin.isatty():
|
|
544
549
|
rc = launch_tui(cfg, Path(a.cwd).resolve(), a.task, None)
|
|
545
550
|
if rc >= 0:
|
|
@@ -8,7 +8,9 @@ other way round (the front-end was spawned by Python and told where to connect).
|
|
|
8
8
|
|
|
9
9
|
Front-end → engine (commands)
|
|
10
10
|
{"cmd": "start", "task": "…"} begin a session with this instruction
|
|
11
|
-
{"cmd": "resume", "name": "…"} re-open a saved session (name or "last")
|
|
11
|
+
{"cmd": "resume", "name": "…"} re-open a saved session (name or "last"); while idle, switches sessions
|
|
12
|
+
{"cmd": "sessions"} list sessions to pick from (local and on the server) → sessions {items}
|
|
13
|
+
{"cmd": "compact"} summarise and restart the context now (while idle)
|
|
12
14
|
{"cmd": "message", "text": "…"} steer (while busy) or a follow-up (while idle)
|
|
13
15
|
{"cmd": "shell", "command": "…"} the person runs a command themselves (!cmd)
|
|
14
16
|
{"cmd": "interrupt"} stop the current turn; what it had so far is kept
|
|
@@ -22,7 +24,7 @@ Engine → front-end (events); every event has "type"
|
|
|
22
24
|
human_edit {stat} compaction {prompt_tokens_before}
|
|
23
25
|
turn_end {turns, cost_usd, input_tokens, output_tokens, elapsed_s, stop_reason}
|
|
24
26
|
interrupted {kept} info {text} · warn {text} · error {text}
|
|
25
|
-
session {name, path} idle · busy {phase} · retry {attempt, text} · bye
|
|
27
|
+
session {name, path} idle · busy {phase} · retry {attempt, text} · bye · sessions {items}
|
|
26
28
|
"""
|
|
27
29
|
from __future__ import annotations
|
|
28
30
|
|
|
@@ -38,7 +40,7 @@ from hum.cli import Client, Uploader, make_llm
|
|
|
38
40
|
from hum.runtime import CURRENT_AGENT, HumanAction, HumanDriver, PolicyDriver, Session, StreamEvents, ToolCall, ToolRegistry, Turn
|
|
39
41
|
from hum.runtime.config import Config
|
|
40
42
|
from hum.runtime.llm import describe_partial_call, error_text
|
|
41
|
-
from hum.runtime.store import list_sessions
|
|
43
|
+
from hum.runtime.store import home, list_sessions
|
|
42
44
|
from hum.ui import UI
|
|
43
45
|
|
|
44
46
|
|
|
@@ -200,15 +202,44 @@ class Engine:
|
|
|
200
202
|
self._open(self.client._session(str(cmd.get("task", "")).strip(), "chat"))
|
|
201
203
|
self._launch(None, None)
|
|
202
204
|
elif kind == "resume":
|
|
203
|
-
if self.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
if self.busy:
|
|
206
|
+
self.wire.send("warn", text="finish or stop the current turn first")
|
|
207
|
+
return
|
|
208
|
+
name = str(cmd.get("name") or "last")
|
|
209
|
+
path = await asyncio.to_thread(find_session, name)
|
|
210
|
+
if path is None:
|
|
211
|
+
self.wire.send("error", text=f"no session matching {name!r}")
|
|
212
|
+
return
|
|
213
|
+
if self.session is not None:
|
|
214
|
+
if self.uploader:
|
|
215
|
+
await self.uploader.stop()
|
|
216
|
+
await self.client.close()
|
|
217
|
+
s = Session.load(path)
|
|
218
|
+
cwd = Path(s.task.get("cwd") or self.cwd).resolve()
|
|
219
|
+
if cwd != self.cwd:
|
|
220
|
+
# the session belongs to another directory: work there
|
|
221
|
+
self.cwd = cwd
|
|
222
|
+
self.client = Client(self.cfg, cwd, self.ui)
|
|
223
|
+
await self.client.mount()
|
|
224
|
+
self._open(s)
|
|
225
|
+
self.wire.send("idle")
|
|
226
|
+
elif kind == "sessions":
|
|
227
|
+
from hum import sync
|
|
228
|
+
from hum.catalog import catalog, when
|
|
229
|
+
remote = await asyncio.to_thread(sync.remote_list)
|
|
230
|
+
items = catalog(remote)
|
|
231
|
+
for it in items:
|
|
232
|
+
it["when"] = when(it["at"])
|
|
233
|
+
self.wire.send("sessions", items=items[:50])
|
|
234
|
+
elif kind == "compact":
|
|
235
|
+
if self.busy or self.runner is None or self.session is None:
|
|
236
|
+
self.wire.send("warn", text="nothing to compact yet" if self.session is None else "wait for the current turn to finish")
|
|
237
|
+
return
|
|
238
|
+
head = self.session.head("main")
|
|
239
|
+
self.wire.send("busy", phase="compacting")
|
|
240
|
+
cid = await self.runner._compact(head.id if head else None, self.runner.policy, 0)
|
|
241
|
+
self.wire.send("info", text="context summarised and restarted" if cid else "compaction failed; context unchanged")
|
|
242
|
+
self.wire.send("idle")
|
|
212
243
|
elif kind == "message":
|
|
213
244
|
text = str(cmd.get("text", "")).strip()
|
|
214
245
|
if not text:
|
|
@@ -301,6 +332,18 @@ class Engine:
|
|
|
301
332
|
pass
|
|
302
333
|
|
|
303
334
|
|
|
335
|
+
def find_session(name: str) -> Path | None:
|
|
336
|
+
"""A local session by name (substring; "last" = newest), else pull it from the server."""
|
|
337
|
+
from hum import sync
|
|
338
|
+
ms = [p for p in list_sessions() if name in p.name] if name != "last" else list_sessions()
|
|
339
|
+
if ms:
|
|
340
|
+
return ms[-1]
|
|
341
|
+
for r in sync.remote_list():
|
|
342
|
+
if name == "last" or name in r.get("name", ""):
|
|
343
|
+
return sync.pull(r["name"])
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
304
347
|
async def serve(cfg: Config, cwd: Path, *, listen: bool, port: int | None) -> int:
|
|
305
348
|
"""``listen``: bind an ephemeral localhost port, print it, accept one front-end.
|
|
306
349
|
Otherwise connect to ``port`` (the front-end is listening)."""
|
|
@@ -54,3 +54,38 @@ def sync_all(client: Any = None) -> tuple[int, int]:
|
|
|
54
54
|
if upload(p, c, client=client):
|
|
55
55
|
sent += 1
|
|
56
56
|
return sent, skipped
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remote_list(creds: Credentials | None = None, client: Any = None, timeout: float = 8.0) -> list[dict[str, Any]]:
|
|
60
|
+
"""Sessions the server holds for this person (any machine). Empty on any failure."""
|
|
61
|
+
import httpx
|
|
62
|
+
|
|
63
|
+
c = creds or load_creds()
|
|
64
|
+
if not c.logged_in:
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
http = client or httpx.Client(timeout=timeout)
|
|
68
|
+
r = http.get(f"{c.auth_url}/sessions", headers={"Authorization": f"Bearer {c.access_token}"})
|
|
69
|
+
return list(r.json()) if r.status_code == 200 else []
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pull(name: str, creds: Credentials | None = None, client: Any = None, timeout: float = 30.0) -> Path | None:
|
|
75
|
+
"""Fetch one session file from the server into ~/.hum/sessions (so it can be resumed here)."""
|
|
76
|
+
import httpx
|
|
77
|
+
|
|
78
|
+
c = creds or load_creds()
|
|
79
|
+
if not c.logged_in:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
http = client or httpx.Client(timeout=timeout)
|
|
83
|
+
r = http.get(f"{c.auth_url}/sessions/{name}", headers={"Authorization": f"Bearer {c.access_token}"})
|
|
84
|
+
if r.status_code != 200 or not r.content:
|
|
85
|
+
return None
|
|
86
|
+
dest = home() / "sessions" / Path(name).name
|
|
87
|
+
dest.write_bytes(r.content)
|
|
88
|
+
_mark(dest.name, {"size": dest.stat().st_size, "id": None})
|
|
89
|
+
return dest
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
return None
|
|
@@ -83713,13 +83713,15 @@ var tail = (s, n) => {
|
|
|
83713
83713
|
return l.length <= n ? s : "\u2026\n" + l.slice(-n).join("\n");
|
|
83714
83714
|
};
|
|
83715
83715
|
var COMMANDS = [
|
|
83716
|
+
{ name: "/resume", desc: "pick a past session to continue (this machine or any other)" },
|
|
83717
|
+
{ name: "/compact", desc: "summarise and restart the context now" },
|
|
83716
83718
|
{ name: "/model", desc: "switch the model for this session (/model <name>)" },
|
|
83717
83719
|
{ name: "/cost", desc: "tokens and cost so far" },
|
|
83718
83720
|
{ name: "/tools", desc: "what is mounted" },
|
|
83719
83721
|
{ name: "/help", desc: "keys and commands" },
|
|
83720
83722
|
{ name: "/quit", desc: "save and leave" }
|
|
83721
83723
|
];
|
|
83722
|
-
var HELP = "!cmd runs a command as you \xB7 esc interrupts the model (what it had is kept) \xB7 type while it works to steer \xB7 edit files in your editor any time (recorded as your turn) \xB7 ctrl+c twice quits";
|
|
83724
|
+
var HELP = "!cmd runs a command as you \xB7 esc interrupts the model (what it had is kept) \xB7 type while it works to steer \xB7 edit files in your editor any time (recorded as your turn) \xB7 /resume picks a past session \xB7 /compact restarts the context from a summary \xB7 ctrl+c twice quits";
|
|
83723
83725
|
function App2({ engine: engine2, task, resume }) {
|
|
83724
83726
|
const { exit } = use_app_default();
|
|
83725
83727
|
const { stdout } = use_stdout_default();
|
|
@@ -83736,6 +83738,7 @@ function App2({ engine: engine2, task, resume }) {
|
|
|
83736
83738
|
const [menuIndex, setMenuIndex] = (0, import_react24.useState)(0);
|
|
83737
83739
|
const [elapsed, setElapsed] = (0, import_react24.useState)(0);
|
|
83738
83740
|
const [ctrlc, setCtrlc] = (0, import_react24.useState)(0);
|
|
83741
|
+
const [picker, setPicker] = (0, import_react24.useState)(null);
|
|
83739
83742
|
const nextId = (0, import_react24.useRef)(0);
|
|
83740
83743
|
const phaseRef = (0, import_react24.useRef)("connecting");
|
|
83741
83744
|
const turnT0 = (0, import_react24.useRef)(null);
|
|
@@ -83813,12 +83816,24 @@ function App2({ engine: engine2, task, resume }) {
|
|
|
83813
83816
|
modelRef.current = ev.model;
|
|
83814
83817
|
append({ kind: "banner", text: `${ev.model}${ev.user ? ` \xB7 ${ev.user}` : ""} \xB7 ${ev.cwd} \xB7 tools: ${ev.tools.join(", ")}` });
|
|
83815
83818
|
if (task) engine2.send({ cmd: "start", task });
|
|
83816
|
-
else if (resume
|
|
83819
|
+
else if (resume === "pick") {
|
|
83820
|
+
setPhaseBoth("idle");
|
|
83821
|
+
engine2.send({ cmd: "sessions" });
|
|
83822
|
+
} else if (resume) engine2.send({ cmd: "resume", name: resume });
|
|
83817
83823
|
else setPhaseBoth("idle");
|
|
83818
83824
|
break;
|
|
83825
|
+
case "sessions":
|
|
83826
|
+
if (!ev.items || ev.items.length === 0) {
|
|
83827
|
+
append({ kind: "info", text: "no sessions yet" });
|
|
83828
|
+
break;
|
|
83829
|
+
}
|
|
83830
|
+
setPicker({ items: ev.items.slice(0, 12), index: 0 });
|
|
83831
|
+
break;
|
|
83819
83832
|
case "session":
|
|
83820
83833
|
setSession(ev.name);
|
|
83834
|
+
setSpend(0);
|
|
83821
83835
|
if (ev.model) modelRef.current = ev.model;
|
|
83836
|
+
append({ kind: "info", text: `session ${ev.name}` });
|
|
83822
83837
|
break;
|
|
83823
83838
|
case "turn_start":
|
|
83824
83839
|
turnT0.current = turnT0.current || Date.now();
|
|
@@ -83937,6 +83952,16 @@ function App2({ engine: engine2, task, resume }) {
|
|
|
83937
83952
|
quit();
|
|
83938
83953
|
return;
|
|
83939
83954
|
}
|
|
83955
|
+
if (picker) {
|
|
83956
|
+
if (key.upArrow) setPicker((p) => ({ ...p, index: (p.index + p.items.length - 1) % p.items.length }));
|
|
83957
|
+
else if (key.downArrow || key.tab) setPicker((p) => ({ ...p, index: (p.index + 1) % p.items.length }));
|
|
83958
|
+
else if (key.return) {
|
|
83959
|
+
const it = picker.items[picker.index];
|
|
83960
|
+
setPicker(null);
|
|
83961
|
+
engine2.send({ cmd: "resume", name: it.name });
|
|
83962
|
+
} else if (key.escape) setPicker(null);
|
|
83963
|
+
return;
|
|
83964
|
+
}
|
|
83940
83965
|
if (menu.length > 0) {
|
|
83941
83966
|
if (key.upArrow) setMenuIndex((i) => (i + menu.length - 1) % menu.length);
|
|
83942
83967
|
else if (key.downArrow) setMenuIndex((i) => (i + 1) % menu.length);
|
|
@@ -83963,6 +83988,12 @@ function App2({ engine: engine2, task, resume }) {
|
|
|
83963
83988
|
case "/help":
|
|
83964
83989
|
append({ kind: "info", text: HELP });
|
|
83965
83990
|
break;
|
|
83991
|
+
case "/resume":
|
|
83992
|
+
engine2.send({ cmd: "sessions" });
|
|
83993
|
+
break;
|
|
83994
|
+
case "/compact":
|
|
83995
|
+
engine2.send({ cmd: "compact" });
|
|
83996
|
+
break;
|
|
83966
83997
|
case "/model":
|
|
83967
83998
|
if (arg) {
|
|
83968
83999
|
modelRef.current = arg;
|
|
@@ -84087,6 +84118,22 @@ function App2({ engine: engine2, task, resume }) {
|
|
|
84087
84118
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: ACCENT, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(build_default, { type: "dots" }) }),
|
|
84088
84119
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, children: spinnerLabel })
|
|
84089
84120
|
] }),
|
|
84121
|
+
picker && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Box_default, { flexDirection: "column", paddingX: 1, marginTop: 1, children: [
|
|
84122
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Text, { children: [
|
|
84123
|
+
"pick a session to continue ",
|
|
84124
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { dimColor: true, children: "\xB7 enter continues \xB7 esc cancels" })
|
|
84125
|
+
] }),
|
|
84126
|
+
picker.items.map((it, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Box_default, { children: [
|
|
84127
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: "cyan", inverse: i === picker.index, children: (i === picker.index ? "\u276F " : " ") + (it.when || "").padEnd(9) }),
|
|
84128
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Text, { dimColor: i !== picker.index, children: [
|
|
84129
|
+
" ",
|
|
84130
|
+
(it.instruction || "(untitled)").slice(0, Math.max(20, width - 40)),
|
|
84131
|
+
it.steps != null ? ` \xB7 ${it.steps} steps` : " \xB7 on the server",
|
|
84132
|
+
it.cost_usd ? ` \xB7 ${fmtCost(it.cost_usd)}` : "",
|
|
84133
|
+
it.cwd ? ` \xB7 ${path.basename(it.cwd)}` : ""
|
|
84134
|
+
] })
|
|
84135
|
+
] }, it.name))
|
|
84136
|
+
] }),
|
|
84090
84137
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Box_default, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [
|
|
84091
84138
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Text, { color: "cyan", children: "\u276F " }),
|
|
84092
84139
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
@@ -84095,7 +84142,7 @@ function App2({ engine: engine2, task, resume }) {
|
|
|
84095
84142
|
value,
|
|
84096
84143
|
onChange: setValue,
|
|
84097
84144
|
onSubmit,
|
|
84098
|
-
focus: phase !== "dead",
|
|
84145
|
+
focus: phase !== "dead" && !picker,
|
|
84099
84146
|
navActive: menu.length === 0,
|
|
84100
84147
|
history: historyRef.current,
|
|
84101
84148
|
placeholder: phase === "busy" ? "type to steer \xB7 esc to stop \xB7 !cmd runs a command" : session ? "what next?" : "what are we doing?"
|
|
@@ -84221,7 +84268,7 @@ function parseArgs(argv) {
|
|
|
84221
84268
|
for (let i = 0; i < argv.length; i += 1) {
|
|
84222
84269
|
const x = argv[i];
|
|
84223
84270
|
if (x === "resume") {
|
|
84224
|
-
a.resume = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "
|
|
84271
|
+
a.resume = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "pick";
|
|
84225
84272
|
} else if ((x === "-C" || x === "--cwd") && argv[i + 1]) {
|
|
84226
84273
|
a.cwd = argv[++i];
|
|
84227
84274
|
} else if (x === "-h" || x === "--help") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hum-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "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
|
dependencies = ["pydantic>=2", "litellm>=1.70", "mcp>=1.2", "rich>=13", "prompt_toolkit>=3.0", "httpx>=0.27"]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
sign-in POST /device/code · GET /device (Firebase sign-in page) · POST /device/approve · POST /device/token
|
|
4
4
|
identity GET /me
|
|
5
5
|
model proxy POST /v1/chat/completions (OpenAI-compatible; bearer = the person's Hum token)
|
|
6
|
-
sessions POST /sessions
|
|
6
|
+
sessions POST /sessions · GET /sessions · GET /sessions/<name> (NDJSON files under $HUM_DATA_DIR/sessions/<email>/)
|
|
7
7
|
tool servers /mcp/<name> (streamable-HTTP MCP, proxied upstream; bearer = the person's Hum token)
|
|
8
8
|
admin POST /admin/invites · GET /admin/users · GET /admin/usage (X-Admin-Key)
|
|
9
9
|
|
|
@@ -64,7 +64,7 @@ if os.environ.get("HUM_ROUTES_JSON"):
|
|
|
64
64
|
# A header value starting with "$" names an environment variable.
|
|
65
65
|
MCPS: dict[str, dict[str, Any]] = json.loads(os.environ["HUM_MCP_JSON"]) if os.environ.get("HUM_MCP_JSON") else {}
|
|
66
66
|
|
|
67
|
-
app = FastAPI(title="hum", version="0.1.
|
|
67
|
+
app = FastAPI(title="hum", version="0.1.2")
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
# --- storage ------------------------------------------------------------------------
|
|
@@ -493,6 +493,35 @@ async def upload_session(request: Request, user: sqlite3.Row = Depends(bearer),
|
|
|
493
493
|
return {"id": sid, "bytes": len(body)}
|
|
494
494
|
|
|
495
495
|
|
|
496
|
+
@app.get("/sessions")
|
|
497
|
+
def list_sessions(user: sqlite3.Row = Depends(bearer)) -> list[dict[str, Any]]:
|
|
498
|
+
"""This person's sessions on the server, newest last: enough to pick one to resume from another machine."""
|
|
499
|
+
c = db()
|
|
500
|
+
rows = c.execute("select filename, size, created from sessions where email=? order by filename", (user["email"],)).fetchall()
|
|
501
|
+
out = []
|
|
502
|
+
for r in rows:
|
|
503
|
+
path = DATA / "sessions" / user["email"] / r["filename"]
|
|
504
|
+
meta: dict[str, Any] = {}
|
|
505
|
+
try:
|
|
506
|
+
with path.open(encoding="utf-8") as f:
|
|
507
|
+
first = json.loads(f.readline() or "{}")
|
|
508
|
+
t = first.get("task") or {}
|
|
509
|
+
meta = {"instruction": (t.get("instruction") or "")[:200], "cwd": t.get("cwd"), "model": t.get("model")}
|
|
510
|
+
except Exception: # noqa: BLE001
|
|
511
|
+
pass
|
|
512
|
+
out.append({"name": r["filename"], "size": r["size"], "created": r["created"], **meta})
|
|
513
|
+
return out
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@app.get("/sessions/{name}")
|
|
517
|
+
def get_session(name: str, user: sqlite3.Row = Depends(bearer)) -> Any:
|
|
518
|
+
from fastapi.responses import Response
|
|
519
|
+
path = DATA / "sessions" / user["email"] / Path(name).name
|
|
520
|
+
if not path.is_file():
|
|
521
|
+
raise HTTPException(404, "no such session")
|
|
522
|
+
return Response(path.read_bytes(), media_type="application/x-ndjson")
|
|
523
|
+
|
|
524
|
+
|
|
496
525
|
class InviteIn(BaseModel):
|
|
497
526
|
email: str
|
|
498
527
|
budget_usd: float | None = None
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
node, the interrupted generation, a compacted context, streamed thinking and
|
|
3
3
|
tool calls, and tool servers reached through the home server."""
|
|
4
4
|
import json
|
|
5
|
+
import time
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -389,3 +390,91 @@ def test_explore_agents_cannot_edit_and_delegate_cannot_nest():
|
|
|
389
390
|
assert set(d._child_tools("explore").tools) == {"read_file", "grep", "glob", "bash"}
|
|
390
391
|
assert "delegate" not in d._child_tools("general").tools and "write_file" in d._child_tools("general").tools
|
|
391
392
|
assert reg.tools["delegate"].concurrent and reg.tools["delegate"].mutating
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_catalog_lists_local_and_server_sessions(tmp_path, monkeypatch):
|
|
396
|
+
home = _cfg(tmp_path, monkeypatch)
|
|
397
|
+
from hum.catalog import catalog, when
|
|
398
|
+
from hum.runtime.models import Node
|
|
399
|
+
s = Session({"instruction": "build the posting batch", "cwd": "/w/ccpay", "model": "glm-5.3"}, path=home / "sessions" / "20260822-010000-chat.jsonl")
|
|
400
|
+
s.append(Node(turn=Turn(author="policy", content="x", usage=Usage(cost_usd=0.02))))
|
|
401
|
+
items = catalog([{"name": "20260821-000000-chat.jsonl", "instruction": "from the desktop", "created": 1.0},
|
|
402
|
+
{"name": "20260822-010000-chat.jsonl", "instruction": "dup of local", "created": 2.0}])
|
|
403
|
+
assert [i["name"] for i in items] == ["20260822-010000-chat.jsonl", "20260821-000000-chat.jsonl"]
|
|
404
|
+
assert items[0]["local"] and items[0]["steps"] == 1 and items[0]["cost_usd"] == 0.02 and items[0]["instruction"].startswith("build")
|
|
405
|
+
assert not items[1]["local"] and items[1]["steps"] is None
|
|
406
|
+
assert when(time.time() - 30) == "just now" and when(time.time() - 7200) == "2h ago"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_server_lists_and_serves_a_persons_sessions(srv):
|
|
410
|
+
S, client = srv
|
|
411
|
+
tok = _token(S, client)["access_token"]
|
|
412
|
+
body = b'{"kind": "session", "id": "s1", "task": {"instruction": "fix the reject file", "cwd": "/w"}}\n{"kind": "node", "id": "n1", "branch": "main", "turn": {"author": "policy"}}\n'
|
|
413
|
+
assert client.post("/sessions", content=body, headers={"Authorization": f"Bearer {tok}", "X-Hum-Session": "20260822-x.jsonl"}).status_code == 201
|
|
414
|
+
lst = client.get("/sessions", headers={"Authorization": f"Bearer {tok}"}).json()
|
|
415
|
+
assert [x["name"] for x in lst] == ["20260822-x.jsonl"] and lst[0]["instruction"] == "fix the reject file" and lst[0]["cwd"] == "/w"
|
|
416
|
+
r = client.get("/sessions/20260822-x.jsonl", headers={"Authorization": f"Bearer {tok}"})
|
|
417
|
+
assert r.status_code == 200 and r.content == body
|
|
418
|
+
assert client.get("/sessions/nope.jsonl", headers={"Authorization": f"Bearer {tok}"}).status_code == 404
|
|
419
|
+
assert client.get("/sessions").status_code == 401
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def test_resume_pulls_from_the_server_when_not_local(tmp_path, monkeypatch):
|
|
423
|
+
home = _cfg(tmp_path, monkeypatch)
|
|
424
|
+
(home / "credentials.json").write_text(json.dumps({"auth_url": "https://auth.test", "access_token": "tok", "email": "e@x.com"}))
|
|
425
|
+
body = b'{"kind": "session", "id": "s9", "task": {"instruction": "remote one", "cwd": "/elsewhere"}}\n'
|
|
426
|
+
|
|
427
|
+
def h(request):
|
|
428
|
+
if request.url.path == "/sessions":
|
|
429
|
+
return httpx.Response(200, json=[{"name": "20260820-remote.jsonl", "instruction": "remote one", "created": 5.0}])
|
|
430
|
+
if request.url.path == "/sessions/20260820-remote.jsonl":
|
|
431
|
+
return httpx.Response(200, content=body)
|
|
432
|
+
return httpx.Response(404)
|
|
433
|
+
from hum import sync
|
|
434
|
+
c = httpx.Client(transport=httpx.MockTransport(h))
|
|
435
|
+
real_list, real_pull = sync.remote_list, sync.pull
|
|
436
|
+
monkeypatch.setattr(sync, "remote_list", lambda creds=None, client=None, timeout=8.0: real_list(creds, c, timeout))
|
|
437
|
+
monkeypatch.setattr(sync, "pull", lambda name, creds=None, client=None, timeout=30.0: real_pull(name, None, c, timeout))
|
|
438
|
+
from hum.engine import find_session
|
|
439
|
+
p = find_session("remote")
|
|
440
|
+
assert p is not None and p.name == "20260820-remote.jsonl" and p.read_bytes() == body and p.parent == home / "sessions"
|
|
441
|
+
assert Session.load(p).task["instruction"] == "remote one"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def test_engine_sessions_resume_switch_and_compact(tmp_path, monkeypatch):
|
|
445
|
+
import asyncio
|
|
446
|
+
from hum import engine as E
|
|
447
|
+
home = _cfg(tmp_path, monkeypatch)
|
|
448
|
+
ws = tmp_path / "ws"; ws.mkdir()
|
|
449
|
+
monkeypatch.setattr("hum.sync.remote_list", lambda *a, **k: [])
|
|
450
|
+
llm = ScriptedLLM([
|
|
451
|
+
Turn(author="policy", content="first done", done=True, usage=Usage(input_tokens=10)),
|
|
452
|
+
Turn(author="policy", content="HANDOFF: nothing yet"), # /compact summary
|
|
453
|
+
Turn(author="policy", content="second done", done=True),
|
|
454
|
+
])
|
|
455
|
+
monkeypatch.setattr(cli, "make_llm", lambda cfg: llm)
|
|
456
|
+
from hum.runtime.config import load
|
|
457
|
+
cfg = load()
|
|
458
|
+
server = await asyncio.start_server(lambda r, w: E.Engine(cfg, ws, E.Wire(r, w)).serve(), "127.0.0.1", 0)
|
|
459
|
+
r, w = await asyncio.open_connection("127.0.0.1", server.sockets[0].getsockname()[1])
|
|
460
|
+
async def recv():
|
|
461
|
+
return json.loads(await r.readline())
|
|
462
|
+
async def until(t):
|
|
463
|
+
while (e := await recv())["type"] != t:
|
|
464
|
+
pass
|
|
465
|
+
return e
|
|
466
|
+
def send(**cmd):
|
|
467
|
+
w.write((json.dumps(cmd) + "\n").encode())
|
|
468
|
+
await until("hello")
|
|
469
|
+
send(cmd="start", task="session A"); await until("turn_end")
|
|
470
|
+
send(cmd="compact"); e = await until("info"); assert "summarised" in e["text"]
|
|
471
|
+
s = Session.load(next((home / "sessions").glob("*.jsonl")))
|
|
472
|
+
assert s.head("main").turn.author == "system" and s.head("main").extra["event"] == "compaction"
|
|
473
|
+
# a second session exists on disk: the picker lists both, newest first; resume switches to it
|
|
474
|
+
s2 = Session({"instruction": "session B", "cwd": str(ws)}, path=home / "sessions" / "20260822-020000-chat.jsonl")
|
|
475
|
+
send(cmd="sessions"); e = await until("sessions")
|
|
476
|
+
assert [i["instruction"] for i in e["items"]] == ["session B", "session A"] and e["items"][0]["when"] == "just now"
|
|
477
|
+
send(cmd="resume", name="020000"); e = await until("session"); assert e["name"] == "20260822-020000-chat.jsonl"
|
|
478
|
+
send(cmd="message", text="go"); await until("turn_end")
|
|
479
|
+
assert Session.load(home / "sessions" / "20260822-020000-chat.jsonl").head("main").turn.content == "second done"
|
|
480
|
+
send(cmd="quit"); await until("bye"); server.close(); await server.wait_closed()
|
|
@@ -36,6 +36,8 @@ const DRAFT_TAIL_LINES = 14;
|
|
|
36
36
|
const tail = (s, n) => { const l = s.split('\n'); return l.length <= n ? s : '…\n' + l.slice(-n).join('\n'); };
|
|
37
37
|
|
|
38
38
|
const COMMANDS = [
|
|
39
|
+
{ name: '/resume', desc: 'pick a past session to continue (this machine or any other)' },
|
|
40
|
+
{ name: '/compact', desc: 'summarise and restart the context now' },
|
|
39
41
|
{ name: '/model', desc: 'switch the model for this session (/model <name>)' },
|
|
40
42
|
{ name: '/cost', desc: 'tokens and cost so far' },
|
|
41
43
|
{ name: '/tools', desc: 'what is mounted' },
|
|
@@ -44,7 +46,7 @@ const COMMANDS = [
|
|
|
44
46
|
];
|
|
45
47
|
|
|
46
48
|
const HELP = '!cmd runs a command as you · esc interrupts the model (what it had is kept) · type while it works to steer · ' +
|
|
47
|
-
'edit files in your editor any time (recorded as your turn) · ctrl+c twice quits';
|
|
49
|
+
'edit files in your editor any time (recorded as your turn) · /resume picks a past session · /compact restarts the context from a summary · ctrl+c twice quits';
|
|
48
50
|
|
|
49
51
|
export default function App({ engine, task, resume }) {
|
|
50
52
|
const { exit } = useApp();
|
|
@@ -63,6 +65,7 @@ export default function App({ engine, task, resume }) {
|
|
|
63
65
|
const [menuIndex, setMenuIndex] = useState(0);
|
|
64
66
|
const [elapsed, setElapsed] = useState(0);
|
|
65
67
|
const [ctrlc, setCtrlc] = useState(0);
|
|
68
|
+
const [picker, setPicker] = useState(null); // {items, index} | null
|
|
66
69
|
|
|
67
70
|
const nextId = useRef(0);
|
|
68
71
|
const phaseRef = useRef('connecting');
|
|
@@ -123,12 +126,18 @@ export default function App({ engine, task, resume }) {
|
|
|
123
126
|
setHello(ev); modelRef.current = ev.model;
|
|
124
127
|
append({ kind: 'banner', text: `${ev.model}${ev.user ? ` · ${ev.user}` : ''} · ${ev.cwd} · tools: ${ev.tools.join(', ')}` });
|
|
125
128
|
if (task) engine.send({ cmd: 'start', task });
|
|
129
|
+
else if (resume === 'pick') { setPhaseBoth('idle'); engine.send({ cmd: 'sessions' }); }
|
|
126
130
|
else if (resume) engine.send({ cmd: 'resume', name: resume });
|
|
127
131
|
else setPhaseBoth('idle');
|
|
128
132
|
break;
|
|
133
|
+
case 'sessions':
|
|
134
|
+
if (!ev.items || ev.items.length === 0) { append({ kind: 'info', text: 'no sessions yet' }); break; }
|
|
135
|
+
setPicker({ items: ev.items.slice(0, 12), index: 0 });
|
|
136
|
+
break;
|
|
129
137
|
case 'session':
|
|
130
|
-
setSession(ev.name);
|
|
138
|
+
setSession(ev.name); setSpend(0);
|
|
131
139
|
if (ev.model) modelRef.current = ev.model;
|
|
140
|
+
append({ kind: 'info', text: `session ${ev.name}` });
|
|
132
141
|
break;
|
|
133
142
|
case 'turn_start':
|
|
134
143
|
turnT0.current = turnT0.current || Date.now();
|
|
@@ -222,6 +231,13 @@ export default function App({ engine, task, resume }) {
|
|
|
222
231
|
return;
|
|
223
232
|
}
|
|
224
233
|
if (key.ctrl && input === 'd') { quit(); return; }
|
|
234
|
+
if (picker) {
|
|
235
|
+
if (key.upArrow) setPicker((p) => ({ ...p, index: (p.index + p.items.length - 1) % p.items.length }));
|
|
236
|
+
else if (key.downArrow || key.tab) setPicker((p) => ({ ...p, index: (p.index + 1) % p.items.length }));
|
|
237
|
+
else if (key.return) { const it = picker.items[picker.index]; setPicker(null); engine.send({ cmd: 'resume', name: it.name }); }
|
|
238
|
+
else if (key.escape) setPicker(null);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
225
241
|
if (menu.length > 0) {
|
|
226
242
|
if (key.upArrow) setMenuIndex((i) => (i + menu.length - 1) % menu.length);
|
|
227
243
|
else if (key.downArrow) setMenuIndex((i) => (i + 1) % menu.length);
|
|
@@ -240,6 +256,8 @@ export default function App({ engine, task, resume }) {
|
|
|
240
256
|
switch (cmd.name) {
|
|
241
257
|
case '/quit': quit(); break;
|
|
242
258
|
case '/help': append({ kind: 'info', text: HELP }); break;
|
|
259
|
+
case '/resume': engine.send({ cmd: 'sessions' }); break;
|
|
260
|
+
case '/compact': engine.send({ cmd: 'compact' }); break;
|
|
243
261
|
case '/model':
|
|
244
262
|
if (arg) { modelRef.current = arg; engine.send({ cmd: 'model', name: arg }); }
|
|
245
263
|
else append({ kind: 'info', text: `model: ${modelRef.current}` });
|
|
@@ -342,10 +360,25 @@ export default function App({ engine, task, resume }) {
|
|
|
342
360
|
</Box>
|
|
343
361
|
)}
|
|
344
362
|
|
|
363
|
+
{picker && (
|
|
364
|
+
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
|
365
|
+
<Text>pick a session to continue <Text dimColor>· enter continues · esc cancels</Text></Text>
|
|
366
|
+
{picker.items.map((it, i) => (
|
|
367
|
+
<Box key={it.name}>
|
|
368
|
+
<Text color="cyan" inverse={i === picker.index}>{(i === picker.index ? '❯ ' : ' ') + (it.when || '').padEnd(9)}</Text>
|
|
369
|
+
<Text dimColor={i !== picker.index}>
|
|
370
|
+
{' '}{(it.instruction || '(untitled)').slice(0, Math.max(20, width - 40))}
|
|
371
|
+
{it.steps != null ? ` · ${it.steps} steps` : ' · on the server'}{it.cost_usd ? ` · ${fmtCost(it.cost_usd)}` : ''}{it.cwd ? ` · ${path.basename(it.cwd)}` : ''}
|
|
372
|
+
</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
))}
|
|
375
|
+
</Box>
|
|
376
|
+
)}
|
|
377
|
+
|
|
345
378
|
<Box borderStyle="round" borderColor="gray" paddingX={1} marginTop={1}>
|
|
346
379
|
<Text color="cyan">{'❯ '}</Text>
|
|
347
380
|
<MultilineInput
|
|
348
|
-
value={value} onChange={setValue} onSubmit={onSubmit} focus={phase !== 'dead'}
|
|
381
|
+
value={value} onChange={setValue} onSubmit={onSubmit} focus={phase !== 'dead' && !picker}
|
|
349
382
|
navActive={menu.length === 0} history={historyRef.current}
|
|
350
383
|
placeholder={phase === 'busy' ? 'type to steer · esc to stop · !cmd runs a command' : (session ? 'what next?' : 'what are we doing?')}
|
|
351
384
|
/>
|
|
@@ -12,7 +12,7 @@ function parseArgs(argv) {
|
|
|
12
12
|
const a = { task: null, resume: null, cwd: process.cwd() };
|
|
13
13
|
for (let i = 0; i < argv.length; i += 1) {
|
|
14
14
|
const x = argv[i];
|
|
15
|
-
if (x === 'resume') { a.resume = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : '
|
|
15
|
+
if (x === 'resume') { a.resume = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : 'pick'; }
|
|
16
16
|
else if ((x === '-C' || x === '--cwd') && argv[i + 1]) { a.cwd = argv[++i]; }
|
|
17
17
|
else if (x === '-h' || x === '--help') {
|
|
18
18
|
process.stdout.write('usage: hum [task] | hum resume [name]\nIn a session: type to talk, !cmd runs a command, esc stops the model, / for commands.\n');
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|