@staix/agent-hub 0.12.18 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +419 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +18 -11
  12. package/docs/smoke.md +91 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +412 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +70 -12
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
@@ -986,8 +986,8 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
986
986
  route explain` and assignment agree. Accept and done refuse a waiting task,
987
987
  for the console user too.
988
988
  - A proposal that names an owner (a claim, or an owner for someone else) and
989
- would wait is refused: the owner of waiting work is chosen when it is ready,
990
- by routing or by `ahub task assign`.
989
+ would wait stores that owner as the task's reserved owner (issue #207 below
990
+ replaced the refusal this bullet first specified).
991
991
  - Approval, by review or by a class without a reviewer, releases the
992
992
  dependents that wait for nothing else; each is recorded as `ready` and assigned.
993
993
  A proposal re-reads what it waits for after its insert, since triage may have
@@ -1377,13 +1377,66 @@ checks even for redirected stream output, without enabling panels or raw mode;
1377
1377
  never disables styling. Invalid values fail with usage text before connection.
1378
1378
  Plain `renderConsole` remains available, while `renderConsoleLines` exposes
1379
1379
  semantic spans. Geometry is computed from sanitized plain Unicode text before
1380
- painting. `paint` sanitizes every span, inserts only fixed palette SGR sequences
1380
+ painting. Wrapping breaks at whitespace and splits only a word longer than a whole
1381
+ line; a wrapped line's continuations start four columns deeper than
1382
+ its source line; a source indent and a continuation indent are both capped at half the width, so wrapped peer text in the stream
1383
+ never starts at a column where hub-written lines start (#212). `paint` sanitizes every span, inserts only fixed palette SGR sequences
1381
1384
  and resets each styled span. Interactive exits, signals, disconnect and errors
1382
1385
  retain the console restoration sequence. No new dependencies, protocol changes,
1383
1386
  polling or redraw triggers are added. Tail, logs and machine-readable output
1384
1387
  remain unchanged. Native light/dark readability and idle-CPU observations are
1385
1388
  separate smoke evidence, not inferred from fake-terminal tests.
1386
1389
 
1390
+ Console layout (issue #213) separates the panel header (the project root's
1391
+ directory name, since the registry id is a hash, then the tabs), the body and
1392
+ the footer with ASCII `-` rules, and puts one rule above the stream footer; the
1393
+ console emits no box-drawing or other ambiguous-width glyph. The active tab is
1394
+ bracketed; the Approvals and Queue tabs count pending requests and
1395
+ `needs_review` deliveries. Peers, Approvals, Tasks and Queue are tables: a
1396
+ header row, columns two spaces apart sized with `Bun.stringWidth` from every
1397
+ row (at most a third of the width), the last column taking the rest. Peers' LINK
1398
+ column reads `tools-only` when the optional `toolsOnly` status field (#205) is
1399
+ present. Zero counters read `-`, durations `45s`, `12m`, `3h05m` or `2d03h`, and
1400
+ resets and pauses are relative. Detail views and the Approvals panel's request
1401
+ block put labels at column 0 and values one column past the longest label; a
1402
+ list puts `- ` before each item, and every other line of a value (its own
1403
+ newlines, wrapped continuations) starts two columns deeper, so untrusted text
1404
+ passes neither for a label nor for an item. Only a field's own string shows as
1405
+ written: every agent-written string below it (nested pairs and lists, list
1406
+ items) and every option name or id in the Approvals block, the prompts and the
1407
+ stream's permission line is JSON-quoted unless it is a plain word starting with
1408
+ a letter, so `;`,
1409
+ `,`, `)`, quotes and newlines in it cannot pass for hub structure. Values show
1410
+ no JSON objects, epoch milliseconds or ISO times. Allow options keep one line
1411
+ each; `d deny` always stays in view, options that do not fit are counted on a
1412
+ `(more)` line, table rows give way, and a cut title is marked `(more)` in the
1413
+ label column. The selected request is tracked by id: only the first request is
1414
+ selected on arrival, `[`, `]` and j/k move the selection, `a`, `d` and `v` act on
1415
+ it alone, and its detail is rendered from the live request. When it closes, the
1416
+ selection and its detail clear with a notice; `a`, `d` and `v` act on nothing
1417
+ until `[`, `]` or j/k select again, so a request that arrives as the person
1418
+ reacts is never acted on. Everything pending on a request belongs to the
1419
+ selected one, checked after every key and every prune: an option choice or
1420
+ confirmation for another request is dropped, an open detail follows the
1421
+ selection from its top, and the option prompt names the request. `a`, `d` and
1422
+ `v` act only while the request list or its detail is shown, and a task or
1423
+ delivery detail that arrives after the person moved to another panel or row is
1424
+ dropped; with a task, delivery or peer detail open, the row keys act on the item
1425
+ the detail shows, by its id. A pushed request's agent-supplied fields are kept as
1426
+ strings, so a malformed one cannot throw on a draw. The key table is modal: only
1427
+ `?`, Escape and `q` act under it, and a paste is dropped. Offline peers keep the default foreground, also
1428
+ when selected, and a tab counts as one space when a line is cut. The footer
1429
+ hint lists only the keys valid in the mode, panel and state, and `?` shows a
1430
+ key table grouped by panel (in stream mode it is printed into the stream, as
1431
+ `v`'s copy of a request is, without entering the Events buffer). Errors and refusals take the failure tone, informing notices (a closed or
1432
+ missing selection) the attention tone. A notice clears at the next key, or once 10 s have passed when the
1433
+ existing 1 s tick redraws. In the stream, `v` prints the selected request with
1434
+ the push's framing, and `:` command output lines start at column 4. `fit`
1435
+ measures a cut line as `paint` prints it, so a cut that ends a span in
1436
+ `[agent-hub` cannot widen the line with a `> ` marker. A context reading in the
1437
+ console stream and Events panel shows its measurement time as local time, as
1438
+ event headers do; `ahub tail` and `ahub status` keep the ISO time.
1439
+
1387
1440
  Agent shell CLI calls connect as the detected peer in tools mode. Hub launches
1388
1441
  set `AGENTHUB_PEER_ID`; the pinned installed Codex shell injects
1389
1442
  `CODEX_THREAD_ID` after environment filtering, and Claude uses `CLAUDECODE`.
@@ -1521,3 +1574,359 @@ to a task; both buckets appear even when empty. A zero denominator has unknown
1521
1574
  share. Export carries the new fields without task text. No prices or savings
1522
1575
  counterfactuals are derived. These additive fields retain events schema 1 and
1523
1576
  the existing control protocol. Plain `ahub report` remains unchanged.
1577
+
1578
+ ## Amendment: Pi "always" approvals (issue #209)
1579
+
1580
+ Pi's `write`, `edit`, `bash` and `git` write calls ask with three options:
1581
+ `allow` (`allow_once`), `always` (`allow_always`, named `Always allow <tool>
1582
+ until Pi restarts`) and `deny` (`reject_once`). `always` allows the call and
1583
+ every later call of the same hub tool by that Pi start without asking. The grant
1584
+ is held in memory by the Pi start: stopping or replacing Pi, or restarting the
1585
+ hub, drops it, and nothing is persisted. A granted call is logged by tool name
1586
+ only. The path guard, denylist and sandbox still apply to each call; the
1587
+ dashboard still offers Pi only deny; unattended mode still picks `allow_once`;
1588
+ the local worker keeps allow and deny. The console and `ahub permit` already
1589
+ handle more than one allow option, so the control protocol is unchanged.
1590
+
1591
+ ## Amendment: Claude sessions without channel evidence (issue #205)
1592
+
1593
+ Claude Code without `--dangerously-load-development-channels` drops channel
1594
+ notifications without an error, so a plain `claude` session used to mark every
1595
+ push `accepted` and show none of them.
1596
+
1597
+ - Evidence: `ahub claude` passes the flag and, in the same launch, sets
1598
+ `AGENTHUB_CHANNEL=1` in Claude's environment (`nativeLaunchEnv`);
1599
+ `peerChildEnv` removes the marker from every other native child. The plugin's
1600
+ MCP server inherits Claude Code's environment (a local check on 2026-10-09,
1601
+ not a live smoke: a plain session's plugin server inherited a shell-only
1602
+ `AGENTHUB_*` variable, and the plugin's `.mcp.json` sets no env), as it
1603
+ already does for
1604
+ `AGENTHUB_STATE_DIR` and `AGENTHUB_PEER_ID`. Only the value `1` counts. With
1605
+ the marker the server keeps
1606
+ the push and settlement contract of "Live channel settlement" unchanged.
1607
+ Without it (a plain `claude`, an IDE session, or the flag passed by hand) the
1608
+ session attaches tools-only.
1609
+ - Decision: the launch marker is sufficient evidence, and no positive readback
1610
+ is required before the first `accepted`.
1611
+ - The marker and the flag come from the same launch, so the marker shows that
1612
+ this Claude process got the flag. A readback would add per-push proof of
1613
+ display, which stays out of scope: `accepted` means the bridge handed the
1614
+ notification over, and `hub_delivery_done` or a correlated reply settles
1615
+ handled work.
1616
+ - A readback (a native turn citing the pushed id) would hold every first
1617
+ delivery until the model chose to answer, and a session that never answers
1618
+ would look the same as one that never rendered.
1619
+ - The existing launch identity (`AGENTHUB_INSTANCE_ID`, `AGENTHUB_LAUNCH_ID`)
1620
+ is not used: `ahub claude` sets it only when a daemon is already running at
1621
+ launch, so an `ahub claude` started before `ahub up` would lose pushes it
1622
+ can show.
1623
+ - Limits: the marker is inherited by the session's own shell children, so a
1624
+ plain `claude` started inside an `ahub claude` session reads as
1625
+ channel-capable (it also inherits the peer id and takes the peer over); so
1626
+ does `claude -p` run by a hook or script inside such a session, with its
1627
+ state dir and peer id. The marker is per process while the flag names MCP
1628
+ servers: `ahub claude --mcp-config <candidate>` without
1629
+ `--strict-mcp-config` loads channels only for `server:agent-hub`, yet the
1630
+ installed plugin server in the same process also sees
1631
+ `AGENTHUB_CHANNEL=1`. A session started with the flag by hand reads as
1632
+ tools-only and is pointed to `ahub claude`.
1633
+ - Tools-only attach: the server declares tools only, sends `channel: false` in
1634
+ its `peer` hello and lists `hub_inbox` but not `hub_delivery_done`. It never
1635
+ reports `accepted`: a `deliver` it is handed anyway goes back as a
1636
+ `needs_review` receipt. The daemon attaches its `WsPeer` as `pullOnly`; a
1637
+ push drain needs the peer deliverable (`Bus.deliverable`: not held, idle, not
1638
+ pull-only), checked at each loop and again after condensation, so a plain
1639
+ session that takes the peer while a push is condensed gets nothing. Its
1640
+ messages stay queued and count as `queued`. Board routing still treats the
1641
+ peer as attached.
1642
+ - `hub_inbox` sends `inbox`. The daemon answers "not attached yet; retry"
1643
+ between welcome and attach, and refuses it for a session with pushes.
1644
+ `Bus.pull` is held by every hold a drain respects (`Bus.held`: recovery, an
1645
+ uncertain delivery, a console, budget or conductor pause), though unlike a
1646
+ drain it does not wait for the peer to be idle; a held pull journals nothing
1647
+ and the refusal names the hold and the next action. Otherwise it takes what one
1648
+ push delivery would (the preface, then `take`'s batch of at most ten,
1649
+ important first; the reply says how many still wait) and checkpoints it like
1650
+ a push, so queued journal rows of the same envelopes (an operator retry) are
1651
+ grouped into it, then marks it `completed`, reason `read through hub_inbox`,
1652
+ in the same transaction: the tool result that returns them is the readback,
1653
+ so nothing waits in `accepted`. What it hands over is registered (and moved
1654
+ to the newest end of the cache) for `reply_to` like a push, and a read clears
1655
+ the peer's failure streak as a completed push does. If that journal write fails, only this peer's queue
1656
+ and preface are put back (every pause stays) and the bus stops with
1657
+ `delivery journal unavailable`. A pull is not a native turn and is not
1658
+ counted as supervision. The row is completed at hand-out, before anything
1659
+ shows the model saw it: a reply lost on the way, a tool call cancelled with
1660
+ Esc or a plugin that dies loses the batch (a `ponytail:` ceiling in
1661
+ `Bus.pull`), and `ahub queue resolve --action retry` refuses a terminal row.
1662
+ The completed row keeps the envelopes, so the operator reads them with `ahub
1663
+ queue show <id>` and sends what matters again.
1664
+ - Who holds the peer: an `ahub claude` session takes it from a plain session
1665
+ as any newer hello does, a standing-by one included (its standby check does
1666
+ not wait for a holder that status reports `toolsOnly`); attach clears
1667
+ `pullOnly`, the waiting queue is pushed to it, and the plain session stands by
1668
+ (close 4000). A plain session
1669
+ never takes it from a session with pushes, attached or still arriving: its
1670
+ hello is closed with 4000 and it stands by the same way, so an IDE or plain
1671
+ `claude` with the user-scope plugin cannot strand the channel session's
1672
+ unsettled deliveries. It attaches once that session leaves.
1673
+ - Status: `status.json` peers carry `toolsOnly`, a line naming the state and the
1674
+ next action (`ahub claude`); `ahub status`, the console footer and the
1675
+ dashboard show it.
1676
+ - Control protocol 16 adds the hello `channel` field and `inbox`. Recovery
1677
+ sources add protocol 15 (0.12.17 through 0.12.19); the transition from a real
1678
+ 0.12.19 hub is not yet proven.
1679
+ ## Amendment: owner and reviewer read their task (issue #208)
1680
+
1681
+ `hub_task_show` stays a conductor tool, and a task's current owner and current
1682
+ reviewer may also call it for that task. They get exactly the conductor's
1683
+ view: `publicConductorTask` over `Tasks.publicView(task, true)`, so the whole
1684
+ history with its done summary and check line, notes that match a PII pattern
1685
+ withheld, and a PII task as a stub with no history. Any other peer is refused
1686
+ with the conductor-role error, whatever the task. That refusal is about the
1687
+ tool, not a secret: the view holds nothing private beyond the PII stub, and the
1688
+ control op `task_show` (`ahub task show` from an agent shell) already returns
1689
+ the same public view to any peer. These reads write no conduct audit event, as
1690
+ task list polling does not. `hub_status` stays conductor-only.
1691
+
1692
+ ## Amendment: reserved owners and proposer redirects (issue #207)
1693
+
1694
+ - `reserved` is a nullable text column on `tasks`, added to existing boards
1695
+ like `plan` and `deps` (old rows reserve nobody). `hub_task_propose` with an
1696
+ `owner` and an `after` that still waits stores the owner there instead of
1697
+ refusing; the capability check for naming another peer is unchanged. With
1698
+ nothing left to wait for, a named owner is assigned as before.
1699
+ - The reservation is an input of `assign()`, read from the task, so `ahub
1700
+ route explain` and assignment agree. When no candidate list is given, the
1701
+ reserved owner is the only candidate if `blocked()` passes it; otherwise the
1702
+ trace says why it is not honored and routing proceeds through the class
1703
+ peers. PII, capability limits (`local_allowed`, Pi's context limit),
1704
+ exclusions, failing peers and offline or paused peers all pass it over, so a
1705
+ PII task still goes to `local` or nobody.
1706
+ - Lasting exclusions are refusals only: a decline records the owner it was
1707
+ declined for in its history entry's `from` (also when the console declines
1708
+ for that owner), and an escalation with reason `rejections` (a reviewer's
1709
+ repeated changes_requested) or `manual` (by hand) records the owner it took
1710
+ the task from the same way. `excluded()` reads both (older decline rows name
1711
+ only the decliner), so those peers stay out of every later reroute and of
1712
+ `route explain`: the reservation never hands the task back to them. The
1713
+ hub's own escalations after a failed delivery or inference
1714
+ (`delivery_failed`, `inference_failed`), and an owner released as gone, moved
1715
+ by the idle sweep or relayed for its budget, exclude that owner only from
1716
+ that move's own reroute: none of these is a refusal, so a later reroute may
1717
+ give it the task again from its reservation. A person's explicit assign
1718
+ (`ahub task assign`, the dashboard) is not blocked by lasting exclusions and
1719
+ clears an agent's reservation, so a later reroute does not hand the task to
1720
+ the agent's choice; the conductor's and a proposer's assigns respect the
1721
+ exclusions and keep the reservation.
1722
+ - A named candidate list (console or conductor assign, escalation, budget
1723
+ relay, the idle sweep's auto-reassignment of its suggestion) replaces the
1724
+ reservation; the idle sweep's step-3 suggestion itself is an `assign()`
1725
+ without candidates, so it consults the reservation like routing does. A task
1726
+ that waits shows its reservation in the trace before the readiness check,
1727
+ which then stops; the trace says whether routing would honor it only once
1728
+ the task is ready. `route explain <id>` also names it in the header. The
1729
+ reservation stays after it is honored or passed over.
1730
+ - Passing over a reservation is a console and hub.log notice naming the
1731
+ reserved peer and the reason, from assignment and from the idle sweep's
1732
+ suggestion alike, and the assignment's history note keeps it, appended to a
1733
+ note the caller gave (a gone owner's release). Only news is said: nothing
1734
+ when the reserved peer is the owner the task moves away from (its decline,
1735
+ escalation, release or idle sweep), and nothing once it refused the task for
1736
+ good (the decline or escalation said that). The reserved peer is not
1737
+ messaged.
1738
+ - `Tasks.assignTo` on a task that waits records a `reserved` event with reason
1739
+ `manual` and changes the reserved owner instead of the silent no-op it was;
1740
+ this applies to the console, the conductor and a proposer alike. The release
1741
+ timer treats a `reserved` last event like `blocked`.
1742
+ - `hub_task_assign` stays a conductor tool, and the task's proposer (the `by`
1743
+ of its first history entry) may call it while the task is `proposed` and its
1744
+ history has no `accepted` entry: a decline or a release puts a task that was
1745
+ worked on back in `proposed`, and that task is no longer the proposer's to
1746
+ redirect. Handing it to another peer needs `assign` when the proposer has a
1747
+ capabilities entry; redirecting to itself does not. The conductor needs
1748
+ `assign` for any assign when it has a capabilities entry. The proposer path
1749
+ is refused while the last owner or reservation change of the task
1750
+ (`assigned`, `reassigned`, `escalated`, `unassigned`, `reserved`) that the
1751
+ hub did not make itself was made by the console user: a person's assign or
1752
+ reservation stands until the conductor or the console moves it, also after
1753
+ the hub carried it out or passed it over. The id is taken as `Tasks.need()`
1754
+ takes it, a whole number or a digit string. Any other non-conductor peer, and
1755
+ the proposer once the task was accepted or a person moved it, get the
1756
+ conductor-role error. A proposer's redirect goes through `Tasks`, keeps the
1757
+ proposer as the actor with reason `manual` (on the `reassigned` or `reserved`
1758
+ entry), and writes no conduct audit event; during a PII turn it is refused
1759
+ like the conductor's.
1760
+ ## Amendment: ahub reset (issue #214)
1761
+
1762
+ `ahub reset [--all] [--yes]` returns a project's hub to a clean state. It is
1763
+ human-only (the CLI identity gate refuses it from an agent shell) and a dry run
1764
+ unless `--yes`: the dry run reads `hub.db` read-only (with `immutable=1` when
1765
+ `hub.db-wal` or `-shm` is absent, so it creates neither) and prints delivery ids,
1766
+ peer names and counts per category, never task or message text. With `--yes`
1767
+ it stops a running hub through `stopProject`, the path `ahub kill` takes, then
1768
+ acts on the stopped state directory.
1769
+
1770
+ - Refusals, each before anything changes: the machine's recovery lock is held
1771
+ (`recoveryLock()` read directly: a reset is never part of an operation, so
1772
+ `AGENTHUB_RECOVERY_OPERATION` does not let it through as it lets the
1773
+ operation's own processes through `assertLifecycleAvailable`); no
1774
+ registration matches this root and state directory, running or not (`ahub
1775
+ up` registers a stopped project; when this root is registered with another
1776
+ state directory the error names `ahub --project <id> reset` instead, since
1777
+ `ahub up` would register the default directory and orphan that state); the
1778
+ hub's ownership is not verified (any inspection state other than stopped, running
1779
+ or stopping); a running hub reports a recovery operation whose phase is not
1780
+ `released`; `--all` with a state directory other than the literal
1781
+ `<root>/.agenthub/state` (an override or symlink elsewhere would cross file
1782
+ systems or pull outside state into the tree); `--all` when `.agenthub` or
1783
+ the state directory is not a real directory of this user, when
1784
+ `.agenthub/archive` exists as anything else (a symlink could carry hub.db,
1785
+ PII included, out of the project), or when its `.gitignore` is not a regular
1786
+ file (a symlink would truncate the file it names). These are checked before
1787
+ the stop and again right before the rename (`archiveProblem`); a hard-linked
1788
+ `.gitignore` is replaced by a temp file and rename, never written through.
1789
+ Checked, then renamed by path: a symlink swapped in between still wins
1790
+ (marked `ponytail:`; `renameat` on a held descriptor would close it).
1791
+ - Between stop and act: after `stopProject` the CLI takes the project's
1792
+ registry claim (`Registry.claim` with its own pid), the claim a daemon must
1793
+ hold to run, and checks that no manifest names a daemon whose pid is alive
1794
+ or uncertain (`processAlive`, the rule `inspectProject` uses); either failing
1795
+ means a hub started in between and nothing is reset. A reset's claim carries
1796
+ the instance prefix `reset-`, so losing the claim to a concurrent reset says
1797
+ so. A crashed daemon's manifest (`status.json`, `control-token`, `hub.pid`
1798
+ with a dead pid, after SIGKILL or the forced shutdown exit; an empty or
1799
+ garbled `hub.pid` beside a dead `status.json` included) does not count: the runtime reset
1800
+ removes it and `--all` archives it. With the claim held, the CLI checks the
1801
+ machine's recovery lock again, so an upgrade or recovery that took it in
1802
+ between does not run beside the reset. The claim is released when the reset
1803
+ ends; a reset that dies leaves a dead pid, which the next claim takes over.
1804
+ - Failures: the plan is read only for the dry run, so `--all --yes` archives a
1805
+ state directory it cannot read as it is (its dry run says what it could not
1806
+ read). A failure after the stop says how far it got. A runtime reset that
1807
+ cannot read the state (a corrupt `hub.db`, malformed JSON or valid JSON of
1808
+ the wrong shape, which `planReset` checks as the journal would, a journal the
1809
+ constructor refuses: root mismatch, corrupt metadata, too many rows) fails
1810
+ the same way on a rerun, so its error points to `--all`; any other runtime
1811
+ failure is finished by a rerun, every step being idempotent. `--all` prints
1812
+ the archive path as soon as the rename happened; a failure before it says
1813
+ the hub is stopped and nothing was moved. JSON and SQLite errors are printed
1814
+ by class and code only (`failureText`): a parser's message can quote the
1815
+ data it choked on, envelope bodies and task text included.
1816
+ - Runtime scope (default): the CLI opens the journal as a new instance, which
1817
+ turns the stopped run's dispatching and accepted rows into `needs_review`,
1818
+ and settles every `queued` and `needs_review` entry of `Bus.queueList`,
1819
+ including queue entries without a row, with `resolveDelivery(..., "discard",
1820
+ "reset")`: one `resolution_history` entry each. It clears manual holds
1821
+ through the bus, deletes the `budget_pauses` and `conductor_holds` rows and
1822
+ removes `sessions.json`, `claude-session.json` and `claude-context.json`,
1823
+ and a crashed run's manifest.
1824
+ The board, turns and touches, execution budgets, logs, `events.jsonl`,
1825
+ `cli-audit/`, recovery records, `pi-sessions/` and configuration stay.
1826
+ - Full scope (`--all`): the state directory is renamed, unchanged, to
1827
+ `<root>/.agenthub/archive/state-<YYYYMMDDTHHMMSSZ>/` (0700); an existing
1828
+ target refuses the move. The archive directory gets a `.gitignore` of `*`
1829
+ (checked on every `--all` and rewritten when it differs) because `hub.db`
1830
+ holds task text, PII included; turn snapshots exclude `**/.agenthub/archive/**`
1831
+ as they exclude the state directory, also when the user's index tracks it or
1832
+ the `.gitignore` is gone; and `.agenthub/archive` is a
1833
+ `DENY_SEGMENTS` entry (`src/local/deny.ts`), so the local worker's and Pi's
1834
+ path guard, seatbelt profile and memory capture refuse it like
1835
+ `.agenthub/state`. A new 0700 state directory (created with `recursive`, as
1836
+ the status line tee may have re-created it) holds only a copy of
1837
+ `project.json`; the project id is the hash of its root,
1838
+ so the registration and id are unchanged. Archives are never pruned.
1839
+ - Output says that claude-mem is not touched and that attached Claude Code
1840
+ sessions must be relaunched with `ahub claude`. The control protocol is
1841
+ unchanged: reset never talks to a running hub except to inspect and stop it.
1842
+
1843
+ ## Amendment: model-based PII screen (issue #198)
1844
+
1845
+ - `routing.toml` `[signals] pii_screen = "off" | "local"`, default `off`; any other
1846
+ value fails the load like a bad pattern. With `off`, or with `constraints.pii =
1847
+ "off"`, nothing changes and no screen call is made.
1848
+ - Order. With `local`, `Tasks.propose` screens the task's title, detail and plan
1849
+ text right after the pattern check and before triage. "Held as private" is met
1850
+ by holding the task back entirely: until the verdict it is not on the board, so
1851
+ no envelope, notice, triage call, brief or note of the hub's can read it. The
1852
+ local worker's claude-mem capture of its own tool calls is outside this: in an
1853
+ ordinary turn it sends a proposal's arguments whatever the verdict (an existing
1854
+ gap for every hub tool, not closed here). Writing it as
1855
+ a PII task first was rejected: a PII task opening lifts every silent turn-free
1856
+ cohort for good and switches facts off (#107, #108), so every proposal would do
1857
+ that. The proposal's tool call waits for the verdict, as it already waits for
1858
+ triage: for an ordinary task at worst the screen (8 s), triage (8 s) and a brief
1859
+ (2 x 2 s), 20 s; for a task the screen marked PII, triage first runs its own
1860
+ on-campus probe (two 4 s rounds when the gateway stalls), so about 24 s. Both
1861
+ stay under the plugin's 30 s control timeout. `hub_task_done` and `hub_review`
1862
+ put the screen (8 s) ahead of their existing waits, such as the briefs
1863
+ `releaseDependents` fetches for the tasks an approval releases.
1864
+ - The screener (`screenPii`, `src/hub/inference.ts`): the on-device model when
1865
+ `mlx.enabled` and it can be had (`ensureMlx`, loopback only; a legacy MLX runtime
1866
+ is started the way Pi's relay starts it and keeps running until `ahub models
1867
+ stop`), else the gateway's `local.fixed_model` only while the
1868
+ daemon's `onCampus()` holds, with `onCampusOnly` so the client refuses an Access
1869
+ host once more before transport; never the Switchyard sidecar. One call per item,
1870
+ an 8 s deadline over the whole call, and a generation slot of its own: it asks
1871
+ for one without waiting (`acquire(abort.signal, 0)`: one look, then
1872
+ `MlxBusyError`); with all taken (Pi generating, another screen) it uses the
1873
+ campus gateway when `onCampus()` holds and otherwise waits for the slot under the
1874
+ deadline. On campus the device gets 60% of the deadline: an error reply or a
1875
+ device still loading then hands over to the gateway within the rest; off campus
1876
+ it keeps the whole deadline. `max_tokens` 32, temperature 0 and `reasoning_effort: "none"` on both
1877
+ paths, so a reasoning model does not spend the budget thinking. The
1878
+ prompt frames the text as data and carries Korean and English examples and hard
1879
+ negatives. The answer must be exactly `clear` or `pii <category>` with a category
1880
+ from `name, student_id, phone, address, grade, health, other`.
1881
+ - Fail closed: an answer outside that form, a timeout, a failed or missing model,
1882
+ an off-campus gateway, or text over 6000 UTF-8 bytes (every token is at least a
1883
+ byte, so text and prompt fit the 8k on-device context; Ollama could otherwise
1884
+ judge cut input) is `unknown`. `pii` and `unknown` add the `pii` signal,
1885
+ so the task takes the whole existing PII path (local or nobody, user review,
1886
+ private envelopes, redacted views, no claude-mem). A pattern match is PII without
1887
+ a call. The source is recorded as a `screened` history entry (`pii: regex`,
1888
+ `pii: screen, <category>`, `pii: unknown, <reason>`, `clear`) and a `pii_screen`
1889
+ event; an unknown verdict is a console and hub.log line with the task id and the
1890
+ closed reason only. `route explain` on a draft does not call the screen and adds
1891
+ a trace line saying a proposal would. The cost of `unknown` is the cost of any PII
1892
+ task, even if a re-screen clears it later: it lifts every silent turn-free cohort
1893
+ for good and turns facts off while open, and off campus triage is not asked, so a
1894
+ classless proposal that is not a claim is refused with an error naming that.
1895
+ - Re-screen. Off campus with the device slot taken, the verdict is `unknown`. A
1896
+ task whose last `screened` entry is `pii: unknown` (not `too long`, which a
1897
+ second look cannot change), that is still `proposed` and that no peer ever owned
1898
+ (no history entry carries an owner) has had nothing private sent anywhere, so
1899
+ `Tasks.rescreen()`, on the daemon's 60 s release timer (one task per tick, the
1900
+ least tried first, at most 10 tries per task in a hub run, in memory), screens it
1901
+ again. `clear` lifts the `pii` signal through `Board.update` (a `screened` entry
1902
+ `clear, screened again`), notifies by task, and routes it through the class
1903
+ peers (a named owner or a claim is not remembered; a reserved owner, #207, is
1904
+ offered first as `assign()` always does); `pii` settles it; another `unknown`
1905
+ changes only the event record. A task an owner took (`local`) keeps its PII path,
1906
+ also after it was declined back to nobody: its review notes, summary and decline
1907
+ reason were never screened.
1908
+ - Free text (#69): the done summary, the review note with its unmet items, a
1909
+ budget hand-off (once per hand-off, however many tasks it moves) and a
1910
+ `hub_remember` note go through the same screen on an ordinary task when they
1911
+ match no pattern; a match is recorded as a `regex` verdict without a call. The
1912
+ call's cheap checks (owner, state, verdict, a running check, memory enabled) run
1913
+ first, then the screen, then the checks again on a fresh read. An escalation's
1914
+ reason is the hub's own text and stays pattern-only. `pii` or `unknown` withholds the
1915
+ item exactly as a pattern match does (stubs, no memory note, no summary line in
1916
+ completed-change or integration notices; `hub_remember` is refused). The board
1917
+ keeps the text and marks the history entry `withheld: true`, so public views,
1918
+ later reviewers, held cohort notices and `ahub ask` keep withholding it after a
1919
+ restart without another call. The check output tail of a done note, a plan given
1920
+ at accept, a decline reason, refs (paths, branch) and context checkpoint
1921
+ summaries stay pattern-only.
1922
+ - Calibration (AC4): `test/fixtures/pii-screen.json` holds synthetic Korean and
1923
+ English positives across the categories and hard negatives (roles,
1924
+ placeholders, field and function names, ports, codes); none is copied from the
1925
+ prompt, and its account, passport and resident numbers have impossible lengths
1926
+ or dates. `bun scripts/pii-screen-eval.ts [--project <dir>] [--out <file>]` screens
1927
+ each item once, in order, through `screenPii` with the project's configuration
1928
+ and reports recall, recall as handled (pii or unknown), precision, the unknown
1929
+ rate, held negatives, category agreement and latency. The bound, fixed here
1930
+ before any measurement: the model itself labels at least 90% of the positives
1931
+ `pii` (an unknown verdict counts as a miss). The report goes to
1932
+ `docs/verification/`; the script exits 1 under the bound.
@@ -18,6 +18,8 @@ Issue: #25. Pi is a managed project peer for implementation, edits, tests, summa
18
18
  - Apple Silicon setup pins Python 3.12, `mlx-lm==0.31.3`, and `Qwen/Qwen3-8B-MLX-4bit` revision `383413e909f3bc5303ce195ebbdf0339c5a1a2a3`.
19
19
  - The MLX input estimate is capped at 16,000 tokens; decode and prompt concurrency are one. DGX's default input estimate cap is 262,144 tokens.
20
20
  - The relay authenticates every call, rejects browser origins and unknown model aliases, and preserves streamed bytes. It records the requested route and model reported by the backend. Per request it also journals sanitized identity and lifecycle evidence (#139): resolved alias, upstream-configured model, observed provider and served model with their source (gateway header, generation SSE event, or local MLX configuration), outcome (completed, cancelled, failed), duration, and a confirmed-mismatch flag. A request cancelled before any identification stays explicitly unidentified; a backend's mutable last-served label is never a request's own evidence, and a primary/auxiliary role stays unknown without native evidence. Records carry no prompts, tools, keys or Access headers.
21
+ - Load and cooldowns (#199, amended 2026-10-09). A `hub/auto` request bound for MLX keeps exactly its two candidates, MLX then its DGX fallback; load and cooldowns only reorder them, and a fixed alias (`mlx/fast`, `pi_backend = "mlx"`) is never reordered. The fallback goes first when MLX is cooling down, unless the fallback is cooling down or its own last dispatch failed in any way (an error status or a failed stream included) within 30 s and it has not succeeded since. Load moves are opt-in: with `[pi] efficient_wait_ms` set (below the 120 s slot wait; read when the hub first starts Pi), a movable request whose MLX slot is still busy after it, and an enforced tool loop pinned to the fallback, put the fallback first under the same health rule, and only while the request runs under no execution budget, where a move could spend the model call or the time MLX needs (budget-aware moves from the admission's remaining units are the upgrade path). Under #197 `enforce` a load move happens only at a user turn. A moved attempt gets a 15 s deadline to its response headers, after the gateway lookup, and is then abandoned for MLX; a reorder for a cooling MLX keeps the full request deadline. The bound ends with the headers: a stream that stalls or fails after them cannot fall back to MLX, and a failed one marks the fallback failing so later requests do not move. Each relay alias cools down after three consecutive transport or startup failures outside a cooldown (a failure more than twice the cap after the last counted one starts the count over), for 30 s doubling up to 5 min; a busy MLX slot and timeouts cut short by the execution budget never count, and any HTTP answer, a success or an error status, proves the transport works and clears the streak and the cooldown. A cooling DGX alias with no alternative is still tried. An alias that fails before it has a status row (gateway or key unavailable) gets one, so its cooldown shows. Both reorders are invisible to PII, which never reaches the relay (Pi refuses PII work). A load move journals no MLX attempt unless MLX then serves as the fallback. Route events record `source: "load"` or `"cooldown"` and `stateless` for a request without a session key; `events.jsonl` records `cooldown` start and end, and `ahub status` shows the cooldown on the backend line. Before the relay first starts MLX, startup decides as before. The live wait-time measurement on a mixed workload (#199 AC5) is open.
22
+ - Decision, 2026-10-09: load moves are off unless `efficient_wait_ms` is set, a deviation from the issue's "default about 500 ms". Reason: the move is unmeasured (AC5 is open), its bound covers only the response headers, and each review round found another way an unmeasured move could make a request worse than main. Revisit with the AC5 measurement.
21
23
  - MLX may fall back to DGX before response streaming begins. Failed non-PII tasks can escalate to configured cloud peers with an explicit warning to reconcile partial effects.
22
24
  - Runtime ownership includes process start identity. Project shutdown releases its relay, while explicit `ahub models stop` controls the shared MLX process.
23
25