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.
Files changed (51) hide show
  1. {hum_cli-0.1.1 → hum_cli-0.1.2}/PKG-INFO +1 -1
  2. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/__init__.py +1 -1
  3. hum_cli-0.1.2/hum/catalog.py +64 -0
  4. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/cli.py +19 -14
  5. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/engine.py +55 -12
  6. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/sync.py +35 -0
  7. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/tui/cli.mjs +51 -4
  8. {hum_cli-0.1.1 → hum_cli-0.1.2}/npm/package.json +1 -1
  9. {hum_cli-0.1.1 → hum_cli-0.1.2}/pyproject.toml +1 -1
  10. {hum_cli-0.1.1 → hum_cli-0.1.2}/server/app.py +31 -2
  11. {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_sme_ready.py +89 -0
  12. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/app.jsx +36 -3
  13. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/cli.jsx +1 -1
  14. {hum_cli-0.1.1 → hum_cli-0.1.2}/.github/workflows/publish.yml +0 -0
  15. {hum_cli-0.1.1 → hum_cli-0.1.2}/.github/workflows/test.yml +0 -0
  16. {hum_cli-0.1.1 → hum_cli-0.1.2}/.gitignore +0 -0
  17. {hum_cli-0.1.1 → hum_cli-0.1.2}/README.md +0 -0
  18. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/adapters/__init__.py +0 -0
  19. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/adapters/harbor.py +0 -0
  20. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/auth.py +0 -0
  21. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/__init__.py +0 -0
  22. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/agents.py +0 -0
  23. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/autonomy.py +0 -0
  24. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/config.py +0 -0
  25. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/drivers.py +0 -0
  26. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/executor.py +0 -0
  27. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/grader.py +0 -0
  28. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/llm.py +0 -0
  29. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/loop.py +0 -0
  30. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/models.py +0 -0
  31. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/observe.py +0 -0
  32. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/prompt.py +0 -0
  33. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/session.py +0 -0
  34. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/store.py +0 -0
  35. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/tools.py +0 -0
  36. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/trajectory.py +0 -0
  37. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/runtime/workspace.py +0 -0
  38. {hum_cli-0.1.1 → hum_cli-0.1.2}/hum/ui.py +0 -0
  39. {hum_cli-0.1.1 → hum_cli-0.1.2}/npm/bin/hum.js +0 -0
  40. {hum_cli-0.1.1 → hum_cli-0.1.2}/npm/scripts/postinstall.js +0 -0
  41. {hum_cli-0.1.1 → hum_cli-0.1.2}/server/__init__.py +0 -0
  42. {hum_cli-0.1.1 → hum_cli-0.1.2}/server/modal_app.py +0 -0
  43. {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_cli.py +0 -0
  44. {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_runtime.py +0 -0
  45. {hum_cli-0.1.1 → hum_cli-0.1.2}/tests/test_server_and_auth.py +0 -0
  46. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/package-lock.json +0 -0
  47. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/package.json +0 -0
  48. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/devtools-stub.js +0 -0
  49. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/engine.js +0 -0
  50. {hum_cli-0.1.1 → hum_cli-0.1.2}/tui/src/input.jsx +0 -0
  51. {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.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
@@ -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.1.1"
4
+ __version__ = "0.1.2"
@@ -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
- for p in list_sessions():
358
- try:
359
- s = Session.load(p)
360
- hv = sum(1 for n in s.nodes.values() if n.intervention)
361
- ui.console.print(f"[dim]{p.stem}[/] {len(s.order):>3} turns {hv:>2} yours {s.task.get('instruction', '')[:70]}")
362
- except Exception as e: # noqa: BLE001
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
- ms = [p for p in list_sessions() if name in p.name] if name and name != "last" else list_sessions()
368
- if not ms:
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 ms[-1]
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
- return asyncio.run(Client(cfg, cwd, ui).chat(None, resume=s))
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.session is None:
204
- name = str(cmd.get("name") or "last")
205
- ms = [p for p in list_sessions() if name in p.name] if name != "last" else list_sessions()
206
- if not ms:
207
- self.wire.send("error", text=f"no session matching {name!r}")
208
- return
209
- s = Session.load(ms[-1])
210
- self._open(s)
211
- self.wire.send("idle")
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) engine2.send({ cmd: "resume", name: 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] : "last";
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
  {
2
2
  "name": "@metaphi/hum",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Hum — हम. Human and model, one harness. A coding agent for your terminal.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hum-cli"
3
- version = "0.1.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 (NDJSON body $HUM_DATA_DIR/sessions/<email>/)
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.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] : 'last'; }
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