@staix/agent-hub 0.6.4 → 0.7.1

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.
@@ -0,0 +1,675 @@
1
+ # agent-hub design spec
2
+
3
+ Date: 2026-09-19. Status: M1 to M5 merged; M6 implemented on `feat/m6-packaging-inference` (phase spec: issue #2 of the public repository). Version 0.1.0.
4
+ Owner: Young Joon Lee. Repo: STAIxBWLB/agent-hub.
5
+
6
+ Facts below are tagged **verified** (measured on 2026-09-19 on the owner's Mac) or
7
+ **inferred** (from docs or schema, not yet exercised).
8
+
9
+ ## Context
10
+
11
+ - Goal: three coding agents on one machine (Claude Code, Codex, Kimi Code) plus a
12
+ hub-owned local-LLM worker collaborate efficiently as peers in one project: real-time
13
+ push messaging with busy-state coordination, role-based task split with cross review,
14
+ subscription-quota relay, and task-aware model selection that can send bulk or
15
+ low-stakes work to self-hosted models.
16
+ - Prior art: raysonmeng/agent-bridge v0.1.31 (Bun/TS, MIT). Verified on 2026-09-19: its
17
+ v1 pair is two-party by construction (single Claude seat, `source: "claude" | "codex"`),
18
+ its v3 rooms carry signals only, and a third agent can only join through the room
19
+ broker. A room MCP adapter for Kimi was built and verified end to end
20
+ (kept as a private memo). Decision: do not fork; reuse its
21
+ protocol ideas (agentMessage-only forwarding, marker tiers, busy guard, turn watchdog).
22
+ - Native control surfaces, all verified on 2026-09-19:
23
+ - Claude Code 2.1.277: channels. MCP server declares
24
+ `capabilities.experimental['claude/channel'] = {}`, pushes
25
+ `notifications/claude/channel` with `{content, meta}`; events queue while Claude is
26
+ busy and are delivered as a group on the next turn. Loaded with
27
+ `--dangerously-load-development-channels plugin:<name>@<marketplace>` (hidden flag,
28
+ accepted by 2.1.277). Reply is an ordinary MCP tool.
29
+ - Codex 0.154.0: `codex app-server --listen ws://127.0.0.1:<port>` spawns and answers
30
+ (verified through agent-bridge's daemon). Schema v2 has `turn/start`,
31
+ `turn/steer` (`threadId, expectedTurnId, input`), `turn/interrupt`,
32
+ `thread/start` with `developerInstructions` and `dynamicTools`,
33
+ `account/rateLimits/read`. TUI attaches with `--enable tui_app_server --remote <ws>`.
34
+ - Kimi Code 0.43.1: `kimi acp` JSON-RPC over stdio. `initialize`, `session/new`
35
+ (returns `configOptions` including a `model` select), `session/prompt` round trip
36
+ (`stopReason: end_turn`), `session/update` stream (`agent_message_chunk`,
37
+ `agent_thought_chunk`, `usage_update`), `session/cancel` (`stopReason: cancelled`),
38
+ `session/load`. A second `session/prompt` during a turn fails with
39
+ `turn.agent_busy`. Hooks exist (Stop, SessionStart, UserPromptSubmit, PreToolUse).
40
+ - Local serving (verified): OmniRoute 3.8.50 on a campus DGX H100 node, reachable on the
41
+ internal network (VPN), `/v1/models` answers 401 without a key, Anthropic and Responses surfaces exist.
42
+ vLLM serves `deepseek-ai/DeepSeek-V4-Flash-0731`; GLM-5.3-Flash and
43
+ Qwen3.8-27B are planned (internal deployment plan).
44
+ OpenCode 1.18.31 already has a `dgx-dsv4f` provider for this gateway.
45
+ - Routing policy engine: NVIDIA-NeMo/Switchyard v0.2.0 (Apache-2.0). Route types
46
+ `llm_classifier`, `stage_router`, `escalation_router`; `auto` needs a source build
47
+ until v0.3.0. Standalone `switchyard-server` proxies `/v1/chat/completions`,
48
+ `/v1/messages`, `/v1/responses` and forwards to any `base_url` upstream. Stability:
49
+ libsy Beta, server Demo ("not for production"), pre-1.0.
50
+ - Shared memory (verified 2026-09-19): claude-mem 13.25.1 keeps one store
51
+ (`~/.claude-mem/claude-mem.db`, worker `127.0.0.1:37701`, loopback, no auth) already
52
+ written by three platforms (`sdk_sessions.platform_source`: claude 1211, kimi 55,
53
+ codex 41). Observations carry `project` (git-root basename), `agent_type`, `agent_id`,
54
+ `metadata`. Kimi is captured by the `dot ai memory` transcript bridge (LaunchAgent
55
+ watching `~/.kimi-code/sessions/**/wire.jsonl`); an ACP-driven `kimi acp` session was
56
+ captured the same way. Worker HTTP API: `POST /api/sessions/init
57
+ {contentSessionId, project, prompt, platformSource}`, `POST /api/sessions/observations
58
+ {contentSessionId, platformSource, tool_name, tool_input, tool_response, cwd, agentId,
59
+ agentType, tool_use_id}`, `POST /api/sessions/summarize`, `POST /api/sessions/session-end`,
60
+ `GET /api/context/inject?projects=<chain>[&platformSource=]`, `POST /api/memory/save
61
+ {text, title, project, metadata}`, `GET /api/search`, `GET /api/timeline`, `/api/corpus/*`.
62
+ Each agent's SessionStart hook injects with its own `platformSource`, so today storage
63
+ is shared but recall is siloed per platform (work project: 6.9 KB all platforms vs
64
+ 2.7 KB kimi-only). The Codex claude-mem plugin was reinstalled on 2026-09-19
65
+ (`codex plugin add claude-mem@claude-mem-local`).
66
+ - Decisions inherited from the owner's infrastructure plan:
67
+ no OmniRoute daemon on the Mac; subscription OAuths (Claude Max, ChatGPT, Kimi plan)
68
+ are never pooled through a gateway; local models are for bulk and low-stakes work;
69
+ PII never leaves the campus network (internal routing criteria).
70
+
71
+ ## Decisions
72
+
73
+ 1. Greenfield hub, not a fork. Bun + TypeScript, one repo, MIT.
74
+ 2. Peers are equal. One `PeerAdapter` interface; three adapter kinds (channel,
75
+ app-server, ACP) and one hub-native worker. Four default peers: `claude`, `codex`,
76
+ `kimi`, `local`.
77
+ 3. Kimi runs headless under ACP; the user talks to it through the hub console
78
+ (`ahub say @kimi ...`, `ahub tail`). No Kimi TUI in the loop.
79
+ 4. Local peer is a hub-native agent loop (model call + tools), not OpenCode. Reason:
80
+ per-call model selection needs the hub to own the call. OpenCode ACP is kept as an
81
+ optional fallback runtime behind the same adapter interface.
82
+ 5. Model selection has three layers: L1 hub policy (which peer, which route),
83
+ L2 Switchyard (which model per call for hub-owned traffic), L3 OmniRoute (keys,
84
+ provider fallback, usage). Switchyard runs as a hub-managed sidecar on the Mac for the
85
+ duration of a hub session only; it is not resident. The hub must work with L2 absent
86
+ (fixed model ids straight to OmniRoute).
87
+ 6. All hub-originated model traffic (local worker, internal summaries, triage) goes
88
+ Switchyard -> OmniRoute. Subscription CLIs keep their own auth.
89
+ 7. Quota relay hands open tasks to the `local` peer first, then waits for the window
90
+ reset for judgment-heavy classes. Task-level escalation on verification failure.
91
+ 8. Safety defaults: cross-peer text is framed as untrusted; permission prompts stay on
92
+ unless `--unattended`; ports bind loopback only; Kimi ACP permission requests are
93
+ relayed to the hub console. Loopback is not enough on its own (any web page can open a
94
+ WebSocket to 127.0.0.1), so the control WS requires a per-run token and both hub
95
+ servers refuse requests that carry an `Origin` header (amended in M1).
96
+ 9. Single machine, one hub daemon per project directory. Cross-machine broker is out.
97
+ 10. Shared memory reuses claude-mem as the single store; the hub never runs its own
98
+ memory database. Native capture stays where it exists (Claude hooks, Codex plugin,
99
+ Kimi transcript bridge); the hub captures only the local worker and adds
100
+ cross-platform recall, task-scoped briefs and explicit shared notes. The bus still
101
+ passes messages, never transcripts.
102
+
103
+ ## Design
104
+
105
+ ### Components
106
+
107
+ ```
108
+ Claude Code TUI ── MCP stdio ── plugins/agent-hub (channel server) ──┐
109
+ Codex TUI ──── --remote ws://127.0.0.1:<proxy> ── app-server proxy ──┤ ahub daemon (Bun)
110
+ kimi acp (child, stdio) ── ACP client adapter ────────────────────────┤ bus · board · budget
111
+ local worker (in-process agent loop) ─────────────────────────────────┤ router L1
112
+ hub CLI / console ── control WS 127.0.0.1:<ctl> ──────────────────────┘
113
+ │ model calls
114
+ switchyard-server sidecar (127.0.0.1, session-scoped) L2
115
+ │ base_url
116
+ OmniRoute: internal URL (VPN) / Access-protected public URL L3
117
+ ```
118
+
119
+ - `ahub daemon`: owns the message bus, peer registry and state machines, task board,
120
+ budget coordinator, router L1, and the Codex proxy, ACP child and local worker. Survives
121
+ Claude Code restarts. State in `.agenthub/state/` (pid, status.json, sqlite, logs).
122
+ - `plugins/agent-hub`: Claude Code plugin. Its MCP server is the channel; it reconnects to
123
+ the daemon over the control WS with backoff, except after a close no retry can fix: 4401,
124
+ 4403, 4409 and 4426. It then stays detached and its tools report why. Close 4000 (another
125
+ session attached as the same peer; the newest hello wins, whatever order the session-start
126
+ recalls finish in) is not one of them (amended, issue #30): that session stands by, and
127
+ reconnects only once `status.json` reports the peer offline and not `claiming`, so it never
128
+ evicts the session holding the id - nor one whose hello is still finishing its preface - and
129
+ the two cannot trade it back and forth. The daemon writes the status file at the claim, before
130
+ the preface, so the arriving session is visible for that window. Its tools say it is standing by
131
+ until it has the peer again. It exits when its host closes stdin. Exposes tools `hub_send`,
132
+ `hub_inbox` (fallback drain), `hub_task_*`, `hub_review`, `hub_remember`, `hub_checkpoint`.
133
+ - Codex adapter: spawns `codex app-server --listen ws://127.0.0.1:<port>`, runs a
134
+ transparent proxy the TUI attaches to (`--enable tui_app_server --remote`). The proxy never
135
+ runs its own handshake: it learns the thread id from the TUI's `thread/start` /
136
+ `thread/resume` responses and sends hub requests with negative JSON-RPC ids whose
137
+ responses are swallowed. It shares one message per turn, the last `agentMessage` whose
138
+ `phase` is not `commentary` (0.154.0 items carry `text` and `phase`, verified), emitted on
139
+ `turn/completed`; injects via `turn/start` when idle and `turn/steer`
140
+ for `important` messages while busy, registers hub tools through `dynamicTools` on
141
+ `thread/start` and answers `item/tool/call`. Per-turn inactivity watchdog.
142
+ - Kimi adapter (ACP client): spawns `kimi acp`, `initialize`, `session/new` (or
143
+ `session/load` for resume), injects via `session/prompt`, collects
144
+ `agent_message_chunk` into one outbound message per turn, tracks busy from the
145
+ in-flight prompt, queues on `turn.agent_busy`. Relays `session/request_permission`
146
+ to the console (`ahub tail` shows the request, `ahub permit <id> <option>` answers, 120 s of
147
+ silence cancels; `ahub up --unattended` auto-selects the agent's `allow_once` option). The
148
+ prompt names what will run, not only the tool (amended, issue #31): the request's `rawInput`
149
+ when it has one, otherwise what the `tool_call` update announced for the same `toolCallId`.
150
+ A payload that cannot be resolved either way is titled as unresolved and its `allow_always`
151
+ option is withheld, so a blind click cannot grant every later call of the session. The
152
+ watchdog sends `session/cancel` before forcing idle. `--model` maps to `kimi --model <alias> acp`
153
+ (verified flag). Reusable for `opencode acp`.
154
+ - Local worker (amended in M3): a hub-native tool-calling loop over non-streaming
155
+ `POST /chat/completions` with plain `fetch`, no SDK (the repo keeps zero runtime
156
+ dependencies). Tools `read`, `write`, `edit`, `bash`, `git`, `hub_send` (`hub_task_*` with
157
+ M4). Paths are realpath-scoped to cwd with a secrets denylist; `.git` and `.agenthub` are
158
+ not writable. `write`, `edit`, `bash` and mutating `git` go through the console permission
159
+ relay unless the hub runs `--unattended`. Everything that executes runs under seatbelt
160
+ (`sandbox-exec`), attended or not: writes only under cwd, its real git dir (submodule,
161
+ worktree) and temp; the home directory is unreadable except the project, toolchain dirs and
162
+ `local.read_allow` (whatever a command reads can reach cloud-hosted peers through the
163
+ answer); no reads of credential stores or denylisted files, no `.git/hooks` or `.git/config` writes, no network, loopback included, unless
164
+ `local.bash_network`, scrubbed environment; without seatbelt there is no `bash`. 30 steps per
165
+ turn, `reasoning_content` is never stored or shared. Model comes from `routing.toml`:
166
+ a Switchyard route id, or `fixed_model` straight to OmniRoute.
167
+ - Console: `ahub tail` (live stream), `ahub say [@peer] <text>`, `ahub permit`, `ahub doctor`, `ahub board`,
168
+ `ahub route explain <task>`, `ahub budget`, `ahub status`, `ahub logs`, `ahub kill`.
169
+ - Launchers: `ahub up`, `ahub claude [--safe|--unattended] [--via dgx]`,
170
+ `ahub codex [--new] [--profile dgx]`, `ahub kimi [--model <alias>]`, `ahub local`.
171
+ Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
172
+ M1 accepts `--safe` and `--new` as explicit spellings of the default; `--via` and
173
+ `--profile dgx` arrive with M3. Peers are registered lazily, on first attach, so a peer
174
+ that is never launched accumulates no queue.
175
+
176
+ ### Message model
177
+
178
+ ```ts
179
+ interface Envelope {
180
+ id: string; trace: string; hop: number;
181
+ from: PeerId; to?: PeerId[]; // absent = broadcast
182
+ kind: "chat" | "task" | "review" | "status" | "budget" | "presence";
183
+ priority: "important" | "status" | "fyi";
184
+ body: string; // agent conclusions only, never tool noise
185
+ refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string };
186
+ ts: number;
187
+ }
188
+ ```
189
+
190
+ - Never delivered back to `from`; `hop` capped at 3. A message produced by a turn the hub
191
+ injected inherits that envelope's `trace` with `hop + 1` (Claude passes `reply_to` on
192
+ `hub_send`); a turn the user started begins a fresh trace at hop 0. Envelopes over the
193
+ cap are dropped for peers but still shown on the console.
194
+ - A reply is addressed, not broadcast (amended after the 0.6.1 measurement run, issue #29).
195
+ An adapter answering a delivery addresses `to` at the senders it answers (`replyAudience`);
196
+ a reply that names no target inherits the sender of the envelope it answers. `user` and `hub`
197
+ are not peers, so an answer to the console or to a hub workflow envelope reaches the console
198
+ and the log and costs no peer a turn - stakeholders already hear about a task through the
199
+ hub's own `task` and `review` envelopes. A peer that wants every peer to hear it leaves `to`
200
+ empty on purpose. Before this, one directed question cost every attached peer a turn, and a
201
+ Pi task report woke the local worker into unsolicited work. A delivery that was condensed is
202
+ answered by what it stands for, not by what the peer was handed: the bus maps the reserved
203
+ `digest` sender back to the senders of the originals it kept next to `out`.
204
+ - One queue rule per peer (amended in M2): the whole queue goes out as one delivery, a single
205
+ envelope or a digest of up to 10, when the peer is idle and the queue holds an `important`
206
+ envelope, or `batch_max` (3) envelopes, or its oldest one has waited `batch_ms` (15 s).
207
+ `important` to a busy Codex is steered instead. `fyi` is never delivered to a peer: console
208
+ and log now, the task board from M4. A digest is one prompt (Codex, Kimi) or one channel
209
+ notification (`meta.source = hub-digest`, senders in `meta.sources`); its reply answers the
210
+ highest-hop item, so neither a digest nor a steer can reset the hop cap. Important items lead
211
+ the digest; an envelope that failed before is retried alone. The control WS hello carries a
212
+ wire version (2 since digests): a plugin bundle older than the daemon is refused with close
213
+ code 4426 rather than dropping digests silently.
214
+ - A hub-native peer (Pi, the local worker) may not rate its own urgency: `important` it claims
215
+ for itself is capped to `status` unless the delivery it answers held an `important` envelope
216
+ addressed to it. The whole delivery is checked, not `replyParent` alone - an important request
217
+ can share a hop with a later status item. A message that answers nothing is always `status`.
218
+ Peers driven by their own session keep the priority they claim.
219
+ - Markers `[IMPORTANT]`, `[STATUS]`, `[FYI]` at the start of agent text, `hub_send` or `ahub say`
220
+ set the priority and are stripped. Default `status` for agents, `important` for the console
221
+ user (a human typing `ahub say` should not wait out the batch window).
222
+ - Every inbound cross-peer body is wrapped as untrusted (Claude: `<channel>` tag with
223
+ `meta.source=<peer>`; Codex and Kimi: a fixed prefix line plus a standing instruction
224
+ injected once per session).
225
+ - The fixed prefix includes envelope `kind`; Claude uses `meta.kind` for a single
226
+ item and includes each item's kind in digest headers. Only `hub` / `presence`
227
+ items are reference-only recall. Hub `task`, `review`, and `budget` items are
228
+ workflow events handled through the board and the recipient's assigned role,
229
+ within existing user authorization. Sender and kind grant no higher authority.
230
+ Reply-parent selection skips only hub presence items, retaining workflow hops.
231
+
232
+ ### Peer state and delivery
233
+
234
+ | State | Enter | Delivery rule |
235
+ | --- | --- | --- |
236
+ | idle | turn completed / prompt returned | inject now |
237
+ | busy | turn started / prompt in flight | Claude: push (Claude Code queues); Codex: steer if important else queue; Kimi/local: queue |
238
+ | paused | budget gate (M5) or user `ahub pause` / `ahub resume`; a bus-level flag over the adapter state | queue, never steer; reassign open tasks (M5) |
239
+ | offline | adapter disconnected | queue |
240
+
241
+ Every queue is bounded (`queue_cap`, default 200): on overflow the oldest non-`important`
242
+ envelope is dropped and reported on the console.
243
+
244
+ Inactivity watchdog per busy turn (default 300 s) forces `idle` after cancelling the silent
245
+ turn (`session/cancel` for ACP, `turn/interrupt` for Codex); a late result of the cancelled
246
+ turn is discarded. Queues are drained in order on `idle`. Delivery is at-least-once with
247
+ idempotency by `id`: a failed delivery returns to the queue head and is retried after 1 s, and
248
+ after 3 failures it is reported as undeliverable on the console so one envelope cannot block a
249
+ peer. Peer ids claimed over the control WS must not be `user` or a hub-managed adapter id.
250
+
251
+ ### Task board and roles
252
+
253
+ - sqlite table `tasks` (`bun:sqlite`, `.agenthub/state/hub.db`, survives `ahub kill`): `id, title,
254
+ detail, class, owner, reviewer, state, refs, signals, rejections, history`.
255
+ - `class`: `plan | implement | bulk_edit | test | review | summarize | triage`.
256
+ - `state` (amended in M4): `proposed -> in_progress -> in_review -> approved | changes_requested`,
257
+ `changes_requested -> in_progress`. `accept` and `done` are transitions recorded in `history`,
258
+ not states: they carried no behaviour. A verdict is only accepted on a task in review.
259
+ - Roles in `.agenthub/config.json`; default `claude: [planner, reviewer]`,
260
+ `codex: [implementer]`, `kimi: [implementer, verifier]`, `local: [implementer, verifier]`.
261
+ - Flow: `hub_task_propose` creates a card and the hub assigns owner and reviewer (L1);
262
+ the owner accepts or declines (decline moves to the next candidate); `hub_task_done` sends a
263
+ `review` envelope with summary and refs to the reviewer, or approves directly when there
264
+ is none; `hub_review` returns the verdict. `changes_requested` reopens as `in_progress`; two
265
+ in a row escalate the task to the next attached peer in `escalate_to`, with its review
266
+ notes (`ahub task escalate` does it by hand). Only the owner, the reviewer or the console
267
+ user may act on a task.
268
+ - Tools on every peer (amended in M4): one implementation. The bundled MCP server of the
269
+ Claude plugin has a tools-only mode (`AGENTHUB_MODE=tools`, control WS role `tools`: acts for
270
+ a peer, never a delivery target). Kimi gets it through ACP `session/new` `mcpServers`
271
+ (verified live), Codex through `-c mcp_servers.agent-hub.*` on the app-server the hub
272
+ spawns (verified live with a real Codex turn: tools called without an approval prompt under
273
+ `approval_mode = "approve"`), `local`
274
+ natively. This replaces Codex `dynamicTools`: no rewriting of proxied TUI traffic.
275
+ - Role contract: Claude in the plugin `instructions`, Codex, Kimi and `local` with the
276
+ standing instruction of their first delivery, all of them in the `AGENT_HUB` marker
277
+ blocks; `roles` in `.agenthub/config.json` is the source. Codex `developerInstructions`
278
+ injection is dropped for the same reason as `dynamicTools`.
279
+ - PII (amended in M4): a task matching `signals.pii_patterns` is owned by `local` or by
280
+ nobody; its envelopes are `private` (console tail and `hub.log` print a stub), lists show
281
+ `[pii]` to everyone but `local`, `ahub task show` is the one place the console reads it; the
282
+ reviewer is the console user; `local` answers such a turn to the console only, keeps it out
283
+ of its history, refuses it when the only gateway is off campus (Cloudflare Access), and
284
+ nothing reaches claude-mem (no brief, no note, no capture), because claude-mem's observer is
285
+ a cloud model (verified: `/api/health` reports `ai.provider: claude`).
286
+
287
+ ### Routing (three layers)
288
+
289
+ L1, hub policy, `.agenthub/routing.toml`, hot-reloaded:
290
+
291
+ ```toml
292
+ [signals]
293
+ pii_patterns = ["\\b\\d{6}-\\d{7}\\b"] # PII => local_only; add your own
294
+ long_context_tokens = 120000
295
+
296
+ [classes.implement]
297
+ peers = ["codex", "kimi", "local"] # preference order among idle, unpaused peers
298
+ route = "sy/coding" # Switchyard route id for the local worker
299
+ escalate_to = ["kimi", "claude"] # on verification failure
300
+
301
+ [classes.bulk_edit]
302
+ peers = ["local", "kimi"]
303
+ route = "sy/fast"
304
+
305
+ [classes.review]
306
+ peers = ["claude", "codex"]
307
+ route = "sy/review"
308
+ local_allowed = false # never route judgment classes to local
309
+
310
+ [classes.summarize]
311
+ peers = ["local"]
312
+ route = "sy/fast"
313
+
314
+ [constraints]
315
+ pii = "local_only" # hard: on-prem models only
316
+ budget_paused = "skip_peer"
317
+ ```
318
+
319
+ L2, Switchyard sidecar (amended in M3, verified against `switchyard-server` 0.2.0 built with
320
+ `cargo install --locked switchyard-server`; there is no prebuilt macOS binary).
321
+ `routing.toml` carries `[targets.*]` and `[routes."sy/..."]` in Switchyard's own table
322
+ shapes; the hub copies them into `.agenthub/state/switchyard.toml` (0600, removed on stop)
323
+ and adds `schema_version`, one `[llm_clients.gateway]` (`format = "openai_chat"`, the live
324
+ OmniRoute URL, `api_key_env = "OMNIROUTE_API_KEY"`, Access `extra_headers` only for an
325
+ Access host), `llm_client` on targets and the public `id` on routes. The key reaches the
326
+ sidecar through its environment only. Verified facts: the binary rejects `timeout_ms` on
327
+ `llm_clients`; there is no `escalation_router` type, escalation is `llm_classifier` with
328
+ `mode = "escalation"` and a mandatory `[routes.<name>.escalation]` table; the default bind is
329
+ `0.0.0.0`, so the hub always passes `--host 127.0.0.1`; a route is selected by sending its id
330
+ as `model`; the chosen target returns in `x-model-router-selected-model`; tool calls pass
331
+ through. The config goes through `--dry-run` first. The sidecar starts on the first
332
+ hub-owned model call and stops with the hub. A missing binary, a rejected config, failed
333
+ health, an exit or a failed call turns L2 off for the hub run with one log line and the
334
+ worker calls `fixed_model` on OmniRoute directly. Until a second model (GLM-5.3-Flash) is served the
335
+ shipped routes are `passthrough` to DeepSeek-V4-Flash; the `stage_router` and escalation
336
+ blocks ship commented out and validate against the real binary.
337
+
338
+ L3, OmniRoute (amended in M3): candidates from `omniroute.urls` (for the owner: the internal
339
+ URL over VPN, then a public URL behind Cloudflare Access; the tool ships with none)
340
+ are probed at the same time with `GET <base>/models`; the most preferred one that answers 2xx
341
+ within 4 s wins (a Cloudflare Access 403 is not healthy);
342
+ OmniRoute 3.8.50 has `/healthz` and `/api/health` but no `/health` (verified). Key from
343
+ `OMNIROUTE_API_KEY` or `omniroute.api_key_file`; the two Cloudflare Access headers, read from
344
+ files, go only to hosts in `omniroute.access_hosts`. `omniroute tokens create` issues CLI
345
+ access tokens, not inference credentials. Inference keys come from `omniroute api api-keys`
346
+ (admin context): the owner issued `agent-hub-local` on 2026-09-19 and `omniroute.api_key_file`
347
+ points at it, so the dashboard separates the hub's usage.
348
+
349
+ Assignment is a pure function of `routing.toml`, the task's signals and the peers' bus states
350
+ (idle before busy, paused, offline and detached skipped, `local_allowed`, `long_context =
351
+ "skip_local"`, `pii = "local_only"`); `ahub route explain <id | --class <c> <title>>` runs the
352
+ same function and prints its trace: signals, every candidate with the reason it was kept
353
+ or skipped, owner, reviewer and route. `local` uses the class's `route` / `fixed_model` for a
354
+ task turn and `[local]` otherwise. Verification failure (tests fail, review `changes_requested` twice) escalates
355
+ the task to the next peer in `escalate_to`; Switchyard handles per-call escalation
356
+ inside a peer.
357
+
358
+ ### Budget relay
359
+
360
+ - Sources (amended in M5). Codex: `account/rateLimits/read` sent through the TUI's
361
+ connection with a hub id, `account/rateLimits/updated` forwarded from the proxy, and a
362
+ turn refused with `usageLimitExceeded` as a hard limit (verified live: the owner's account
363
+ answered `primary {usedPercent: 100, windowDurationMins: 10080, resetsAt}`). Claude: no
364
+ OAuth probe. Claude Code passes `rate_limits.five_hour` / `seven_day` (`used_percentage`,
365
+ `resets_at`) to the status line command (verified in the input this Mac's HUD script
366
+ reads); `ahub claude` puts a tee in front of the user's status line command through
367
+ `--settings` for that session, records the limits in `.agenthub/state/claude-usage.json`
368
+ and runs the original command unchanged. `~/.claude/settings.json` is never edited; a
369
+ user-supplied `--settings` wins and turns the tee off. The `--settings` injection is
370
+ verified live (2026-09-19: `claude-usage.json` appears within seconds, the wrapped HUD
371
+ renders unchanged, `ahub budget` shows both windows). Kimi: `usage_update` tokens over a
372
+ rolling 5 h against `budget.kimi_tokens_5h` (off by default). Verified live on kimi
373
+ 2.0.1: one update per turn, payload `{"sessionUpdate":"usage_update","used":<tokens>,
374
+ "size":<context window>}`; `used` is the session's context occupancy against `size`
375
+ (1M), not billed quota; the parser matches it through the `used` fallback and it grows
376
+ monotonically within a session (a compaction reads as a new session). `ahub budget set`
377
+ feeds a reading by hand. `local` has no quota and is never paused.
378
+ - Gate at `budget.gate` (default 0.9) on any fresh window; readings older than
379
+ `budget.stale_min` are ignored. Checkpoint first, pause second: the peer gets one
380
+ important envelope asking it to write `.agenthub/checkpoint.md` and call `hub_checkpoint`,
381
+ the hub waits up to `budget.checkpoint_timeout_s`, then pauses it; a hard-limited peer is
382
+ paused at once. Its open tasks are reassigned, `local` first and then the class list,
383
+ through the M4 constraints, with the checkpoint summary (or the peer's platform block
384
+ from claude-mem) and the task brief in the envelope; tasks it was reviewing get another
385
+ reviewer; PII tasks never get handoff text. One open record per peer in `hub.db`:
386
+ repeated readings do nothing, a restart keeps the pause and finishes an interrupted
387
+ handoff. Resume at `resetsAt` plus a minute, or on a fresh reading under `gate - 0.1`,
388
+ with one important envelope from the hub listing what moved; this replaces the per-peer
389
+ resume calls and the `hub_ack_resume` tool; the notice is queued before the peer is
390
+ released, so it leads the first delivery. Moved tasks stay with their new owners. A manual
391
+ `ahub pause` is never lifted by the coordinator; `ahub resume` does not override a budget
392
+ pause, `ahub budget resume <peer>` does, and the coordinator then leaves that peer alone
393
+ until the window that paused it has reset. A handoff waits until another peer is attached
394
+ (right after a restart nobody is), readings keep their own timestamp, and a window whose
395
+ reset time has passed no longer counts. A peer whose window reset while the hub was down is
396
+ not paused again, and still gets the resume envelope once it attaches. With no status line
397
+ of the user's own to wrap, the tee prints a short usage line instead of a blank one.
398
+
399
+ ### Shared memory (claude-mem)
400
+
401
+ - Capture. Claude: native plugin hooks. Codex: claude-mem Codex plugin. Kimi: `dot ai
402
+ memory` transcript bridge (covers ACP sessions, verified). Local worker: the hub acts
403
+ as the hook client: `sessions/init` on worker start (`platformSource: "agent-hub"`,
404
+ `agentId: "local"`, `project` = git-root basename of the hub cwd), one
405
+ `sessions/observations` per tool call (mirrors `CLAUDE_MEM_SKIP_TOOLS`; a call that names
406
+ a denylisted path is not posted at all), `sessions/summarize` at the end of a turn that used
407
+ tools, `session-end` on stop (awaited, the hub exits right after). Verified live: a
408
+ `summarize` that carries `agentId` is answered `skipped: subagent_context`, so only
409
+ observations carry `agentId = local`. Fail-open: a down worker never blocks a turn; events
410
+ are dropped with one log line.
411
+ - Session-start cross recall (amended in M2). Before a peer can receive anything the hub
412
+ fetches `context/inject?projects=<chain>` (chain = git superprojects down to this repo,
413
+ comma-separated, primary last; verified) and trims it to `memory.inject_tokens` (default
414
+ 2000, counted as 3 characters per token, cut at a line boundary). Kimi and the local worker
415
+ get all platforms in one call. Claude and Codex already get their own platform from their
416
+ own hooks, so they get one filtered call per other platform, legend stripped, the token
417
+ budget split evenly between the platforms that returned something. The block is
418
+ not a message of its own, which would cost a turn just to be acknowledged: it rides as the
419
+ first item (`from: hub`) of the peer's first delivery. Once per peer per hub run, so a
420
+ restarted peer session does not get it again. A `# claude-mem status` page (unknown project,
421
+ empty filter; verified) or a down worker means no block.
422
+ - Task brief on handoff. When a task is assigned, escalated or reassigned by the budget
423
+ relay, the hub runs `search(query = title + refs.paths, project, limit 10)` and
424
+ `timeline(anchor = top hit)`, and attaches a brief of at most `memory.brief_items`
425
+ (default 8) lines `#id time type title` plus one facts line to the task envelope.
426
+ The receiver calls `get_observations([ids])` only for the ones it needs (layered
427
+ workflow). Per peer, the hub keeps a `seen_ids` set for the session so a brief never
428
+ repeats an observation already delivered to that peer.
429
+ - Explicit shared notes. Tool `hub_remember(text, title?, tags?)` on every adapter and
430
+ in the console (`ahub remember`) posts `memory/save` with
431
+ `metadata: {peer, task, kind: decision | finding | contract}` (payload verified live). The
432
+ hub auto-saves the transitions that carry content, `done`, the review verdict and
433
+ escalation (amended in M4: `proposed` and `accepted` would add two empty memories per
434
+ task), and checkpoint summaries (M5), so handoff history is recallable next session.
435
+ - Budget relay (amended in M5). The hub owns a claude-mem session id only for `local`, so
436
+ `sessions/summarize` cannot be called for Claude, Codex or Kimi. The handoff context is
437
+ the peer's own checkpoint summary, else its platform block from `context/inject`, and it
438
+ travels with the task brief in the task envelope; `.agenthub/checkpoint.md` remains as the
439
+ file the peer writes.
440
+ - Efficiency rules. Index first (`search`), then `timeline`, then details; injection
441
+ and briefs are token-capped; project chain filtering only; tool_input containing
442
+ denylisted paths is never posted; no transcript or full observation bodies cross the
443
+ bus.
444
+ - `ahub ask` (added after 0.1.0). Retrieval first, model second, read-only. Evidence is gathered from the task
445
+ board, claude-mem (index search plus the timeline around the top hit) and this run's hub log, capped at 6000
446
+ characters; the hub's model (`sy/fast`) answers from that list only and has to cite its ids, and an answer that
447
+ cites nothing from the list is dropped. No evidence means no model call; no model means the evidence is the result.
448
+ Every row is capped and rows that match the question lead, so one long row cannot empty the list. Every cited id
449
+ has to be in the list: one invented id drops the answer. PII task text is evidence only when an on-campus model is
450
+ positively confirmed (a gateway answered, it is not behind Access, and no sidecar points off campus); otherwise
451
+ the row stays as a `[pii]` stub so counts are right. A question that itself carries PII skips the memory worker.
452
+ Such results are marked and never saved. `--remember` saves a real answer as a model-written note attributed to
453
+ the hub, under a title later asks exclude from their evidence; "nothing found" is never saved. The call is
454
+ interactive: its own 45 s clock, outside the backoff that protects deliveries. Console only. claude-mem's corpus endpoints were checked
455
+ (verified 2026-09-19: `GET /api/corpus` lists none, and a corpus has to be built and primed with a model session
456
+ of its own), so search plus timeline stays the retrieval path and the hub still owns no store.
457
+ - Config: `memory.enabled` (default true when the worker answers `/api/health`),
458
+ `memory.worker_url` (default from `~/.claude-mem/settings.json`
459
+ `CLAUDE_MEM_WORKER_PORT`), `memory.inject_tokens`, `memory.brief_items`,
460
+ `memory.platform_source` (default `agent-hub`).
461
+ - Prerequisites: claude-mem worker running; Codex claude-mem plugin installed; `dot ai
462
+ memory status` green for kimi. `ahub doctor` reports all three.
463
+
464
+ ### Safety
465
+
466
+ - Untrusted framing on all cross-peer text; standing instruction once per session.
467
+ - `ahub claude` and `ahub codex` keep normal permission prompts. `--unattended` opts into
468
+ `--dangerously-skip-permissions` (Claude) and `--dangerously-bypass-approvals-and-sandbox`
469
+ (Codex 0.154.0 documents this flag, not `--yolo`) and prints a warning.
470
+ - Kimi and local tools: cwd-scoped, secrets denylist (a secrets directory, `.env*`, keys).
471
+ - Loopback binds only; ports per project from a registry (base 4600, stride 10).
472
+ - No subscription OAuth through any gateway.
473
+
474
+ ### Configuration and state
475
+
476
+ - `.agenthub/config.json` (roles, ports, filter tiers, watchdog), `.agenthub/routing.toml`.
477
+ - `.agenthub/state/` gitignored: `hub.pid`, `status.json`, `control-token` (0600, per run),
478
+ `hub.db` (tasks, messages, budget; from M4), `hub.log`, `switchyard.toml`, `checkpoint.md`.
479
+ - Env: `AGENTHUB_STATE_DIR`, `AGENTHUB_OMNIROUTE_URL`, `OMNIROUTE_API_KEY`,
480
+ `AGENTHUB_SWITCHYARD_BIN`, `AGENTHUB_UNATTENDED`.
481
+
482
+ ### Testing
483
+
484
+ - Unit: bus routing, priority batching, state machines, L1 policy evaluation, envelope
485
+ framing, marker parsing.
486
+ - Fakes: fake ACP server, fake app-server (ws), fake MCP client, fake OpenAI-compatible
487
+ model server. Integration tests spawn the daemon against fakes.
488
+ - Live smoke (manual, documented): trio chat, steer during a Codex turn, Kimi
489
+ busy-queue drain, local worker completes a bulk edit through Switchyard -> OmniRoute
490
+ with `x-omniroute-provider` visible, budget pause and resume with a mocked probe.
491
+ - One command gates everything: `scripts/check.sh` (typecheck, unit, integration).
492
+
493
+ ## Changes
494
+
495
+ New repo layout:
496
+
497
+ ```
498
+ src/hub/ daemon, bus, peer-registry, state-machines, board, budget, router
499
+ src/adapters/ claude-channel.ts, codex-appserver.ts, acp.ts, local-worker.ts
500
+ src/switchyard/ config generator, sidecar lifecycle, health
501
+ src/memory/ claude-mem worker client, capture for the local worker, recall (inject, brief), remember
502
+ src/cli/ up, claude, codex, kimi, local, say, tail, board, route, budget, status, logs, kill, init
503
+ plugins/agent-hub/ .claude-plugin/plugin.json, .mcp.json, server bundle, hooks (SessionStart health, Stop announce)
504
+ .claude-plugin/marketplace.json
505
+ templates/ CLAUDE.md and AGENTS.md marker blocks, routing.toml default, config.json default
506
+ scripts/ check.sh, build.mjs, smoke-*.ts
507
+ docs/specs/ this file
508
+ CLAUDE.md, REVIEW.md
509
+ ```
510
+
511
+ ## Acceptance criteria
512
+
513
+ 1. `ahub up && ahub claude && ahub codex --new && ahub kimi` in one project: a message from
514
+ any peer reaches the other three within 2 s when idle, with untrusted framing.
515
+ 2. A message sent while Codex is mid-turn arrives as `turn/steer` if `important`, else
516
+ after `turn/completed`; while Kimi is mid-prompt it is queued and drained on
517
+ `end_turn`, never lost.
518
+ 3. `[STATUS]` messages from one peer are batched into one digest per recipient.
519
+ 4. A task proposed by Claude, accepted by Codex, marked done, is auto-routed to the
520
+ reviewer; `changes_requested` twice escalates per `routing.toml`.
521
+ 5. `ahub local` completes a `bulk_edit` task; OmniRoute log shows the request with the
522
+ per-peer token and `x-omniroute-provider: vllm`; `ahub route explain` shows the
523
+ chosen route.
524
+ 6. With Switchyard absent, the local worker still works via `fixed_model`.
525
+ 7. A PII-flagged task never leaves the `local` peer (test with a fake pattern).
526
+ 8. Mocked Codex quota at 0.95 pauses Codex, checkpoints, reassigns its open
527
+ `implement` task to `local`, and resumes Codex after the mocked reset.
528
+ 9. `scripts/check.sh` exits 0; live smoke checklist recorded in `docs/smoke.md`.
529
+ 10. Default launches keep permission prompts; `--unattended` prints the warning.
530
+ 11. A local-worker task produces observations in claude-mem with
531
+ `platform_source = agent-hub` and `agent_id = local`; `search` from any peer finds
532
+ them without a `platformSource` filter.
533
+ 12. Starting `ahub kimi` after a Claude session in the same project injects a context
534
+ block that includes at least one Claude-platform observation, capped at
535
+ `memory.inject_tokens`; starting a second Kimi session does not re-inject it.
536
+ 13. Assigning a task whose title matches an earlier observation attaches a brief with
537
+ that observation id; reassigning the same task to the same peer does not repeat
538
+ ids already delivered.
539
+ 14. `hub_remember` from Codex creates a `memory/save` note whose metadata carries
540
+ `peer = codex` and the task id; the budget-relay resume prompt for the receiving
541
+ peer contains the paused peer's session summary.
542
+ 15. With the claude-mem worker stopped, every acceptance criterion 1 to 10 still passes
543
+ and `ahub doctor` reports memory as unavailable.
544
+
545
+ ## Tasks
546
+
547
+ M1 messaging core and three adapters
548
+ - [x] Repo scaffold: Bun, TS strict, `scripts/check.sh`, `CLAUDE.md`, `REVIEW.md`, labels
549
+ - [x] Bus, envelope, peer registry, state machines, control WS, state dir, port registry
550
+ - [x] Claude channel plugin: capability, push, `hub_send`, `hub_inbox`, reconnect
551
+ - [x] Codex adapter: spawn, proxy, agentMessage intercept, `turn/start`, watchdog
552
+ - [x] ACP adapter: spawn `kimi acp`, session lifecycle, prompt, chunk aggregation
553
+ - [x] CLI `up/claude/codex/kimi/say/tail/status/logs/kill`, `ahub init` marker blocks
554
+ - [x] Fakes plus unit and integration tests
555
+ - [x] Live trio chat smoke (`docs/smoke.md`): Kimi, Codex and Claude interactive legs passed on 2026-09-19; the later four-peer broadcast also passed
556
+ - [x] claude-mem worker client (`src/memory/`), `ahub doctor` memory check, fake worker for tests
557
+
558
+ M2 coordination
559
+ - [x] Priority tiers and status batching, marker parsing
560
+ - [x] Codex `turn/steer` for important while busy (plain busy queue and drain for every peer shipped in M1: without it a second Kimi prompt fails with `turn.agent_busy`)
561
+ - [x] Paused and offline queues, idempotent delivery, drop rules
562
+ - [x] Session-start cross-platform recall (token cap, once per peer per hub run)
563
+ - [x] Live `turn/steer` against real Codex (2026-09-19, after the weekly window reset)
564
+
565
+ M3 local worker and routing L2/L3
566
+ - [x] Local worker agent loop with cwd-scoped tools, secrets denylist, approvals and seatbelt sandbox
567
+ - [x] OmniRoute client with Cloudflare Access headers and the owner-issued inference key; the off-campus live path remains blocked in `docs/smoke.md` (#12)
568
+ - [x] Switchyard sidecar: config generation, lifecycle, health, fallback to fixed model
569
+ - [x] `ahub local`, smoke through OmniRoute with provider header check, and through the real sidecar
570
+ - [x] Local worker capture into claude-mem (`sessions/init`, `observations`, `summarize`, `session-end`, skip list)
571
+
572
+ M4 task board, roles, routing L1
573
+ - [x] sqlite board, `hub_task_*` and `hub_review` tools on all adapters
574
+ - [x] Role contract injection per native surface
575
+ - [x] `routing.toml` loader, signals (PII, context length, quota), `ahub route explain`
576
+ - [x] Review handoff and task-level escalation
577
+ - [x] Task brief on handoff (search + timeline, `seen_ids`), `hub_remember` tool and console command, auto-saved board transitions
578
+ - [x] Live: Codex calling hub tools in a real turn (2026-09-19)
579
+ - [x] Live: Claude plugin task tools and digests in a real interactive session (`docs/smoke.md`, 2026-09-19)
580
+
581
+ M5 budget relay
582
+ - [x] Quota sources (Codex native, Claude status line, Kimi tokens, manual), gate, pause, checkpoint
583
+ - [x] Reassignment to local, one resume envelope, idempotency and restart recovery
584
+ - [x] Handoff context (checkpoint summary or memory block) plus task briefs in the task envelope
585
+ - [x] Live: the Claude status line tee in an interactive session (`docs/smoke.md`, 2026-09-19)
586
+ - [ ] Live: a real pause driven by Codex's own numbers with a TUI attached; the natural near-limit prerequisite remains blocked by issue #12
587
+
588
+ M6 internal inference, packaging
589
+ - [x] Status digests and triage through `sy/fast` (amended: optional and fail-open with a backoff. Only plain status
590
+ chatter over a threshold is condensed, into one item from `digest` that keeps every sender, envelope id and the
591
+ highest hop; important, task, review, budget, preface and private items are never touched. A task proposed
592
+ without a class is labelled from the closed class list, a PII task only when the gateway is on campus. The
593
+ model's output is capped text framed as untrusted or a validated enum, never a route, peer id or instruction.)
594
+ - [x] Packaging (amended: no compiled binary, because the daemon re-spawns itself and five places resolve assets
595
+ relative to the source tree; the package ships the tree and installs from GitHub with Bun, verified:
596
+ `bun add -g github:STAIxBWLB/agent-hub`. `package.json` is the one version, stamped into the plugin manifest
597
+ and the MCP server and checked by the gate. `ahub setup` installs or updates the Claude plugin from the
598
+ package after asking. CI runs the gate on Ubuntu and macOS; a `v*` tag matching `package.json` cuts a release.
599
+ v0.4.0 was published to npm with provenance; clean registry installation and provenance
600
+ readback remain smoke follow-ups, and brew remains out of scope.)
601
+ - [x] docs: `docs/quickstart.md`, `docs/security.md`, `docs/smoke.md`, `CONTRIBUTING.md`, `CHANGELOG.md`
602
+
603
+ ## npm distribution amendment (issue #4)
604
+
605
+ - Publish `@staix/agent-hub` under the `staix` npm organization; installed commands
606
+ remain `ahub` and `agent-hub`. GitHub installation remains available.
607
+ - Ship the source tree and relative runtime assets. The gate checks the actual
608
+ `npm pack --dry-run` file list, including hidden marketplace and plugin manifests.
609
+ - Use a plain-JavaScript `src/cli/main.js` bin entrypoint that checks for Bun before
610
+ dynamically importing `main.ts`. This refines the issue's proposed guard location:
611
+ a guard in `main.ts` cannot precede its static Bun-only imports, and older Node
612
+ versions cannot load the TypeScript file at all. No preinstall hook or Node build.
613
+ - The release workflow validates the tag/version, runs the gate, then publishes
614
+ publicly with provenance and `NPM_TOKEN`. The token belongs to an authorized
615
+ organization member. Organization conversion and credential provisioning are
616
+ owner operations; this implementation does not perform them.
617
+ - The v0.4.0 release workflow completed registry publication with provenance.
618
+ Registry metadata/provenance readback and real Claude setup from a clean registry
619
+ installation remain to be verified.
620
+
621
+ ## Out of scope
622
+
623
+ - Cross-machine broker or rooms, Windows. The web UI was outside the original six milestones; issue #6 adds the local dashboard described below.
624
+ - OmniRoute daemon on the Mac; pooling subscription OAuth through any gateway.
625
+ - Gemini CLI and OpenCode adapters beyond the ACP fallback note.
626
+ - Switchyard `auto` route until v0.3.0 ships.
627
+ - Running Claude Code itself on a local model (depends on the Anthropic-to-chat
628
+ translation check in the OmniRoute plan v1, Phase 0).
629
+ - A hub-owned memory store, cmem.ai cloud sync, automatic corpus building (a project
630
+ corpus for `ahub ask` is an optional M6 item), and any change to the vault LEARN loop.
631
+
632
+ ## Risks
633
+
634
+ | Risk | Mitigation |
635
+ | --- | --- |
636
+ | Claude channels are research preview; flag or protocol may change | Adapter isolated in one file; plugin version pinned to a Claude Code range; `hub_inbox` fallback |
637
+ | `switchyard-server` is Demo grade, pre-1.0 | Session-scoped sidecar, pinned version, L1 `fixed_model` fallback |
638
+ | Anthropic-to-chat translation drops `tool_use` | Claude never runs on local models in this design |
639
+ | Local worker executes untrusted room text | Untrusted framing, cwd scope, denylist, no `--unattended` by default |
640
+ | Steering a Codex turn changes its plan mid-flight | Only `important` steers; default tier is `status` |
641
+ | Kimi ACP has no quota API | Token-based soft limit only; documented |
642
+ | claude-mem worker API is internal and unversioned for third-party clients | Client isolated in `src/memory/`, pinned to a plugin version range, fail-open everywhere, contract test against a fake worker plus one live smoke |
643
+
644
+
645
+ ## Post-milestone amendment: local dashboard (issue #6)
646
+
647
+ Owner instruction to implement issue #6 approves its local, read-mostly dashboard.
648
+ The session/origin model is defined in `docs/security.md`, written before the code.
649
+ `ahub ui` lazily opens an ephemeral loopback listener and exchanges a single-use
650
+ fragment ticket for an HttpOnly, SameSite=Strict browser session. The control link
651
+ retains its blanket Origin refusal. The browser receives no control token.
652
+
653
+ One static HTML file, without a build step or runtime dependency, polls public
654
+ snapshots once per second. The latest 200 redacted bus events are retained only
655
+ once the listener starts. The API exposes only permission answers, peer
656
+ pause/resume, console messages, task proposal and assignment. All task changes
657
+ use `Tasks`; budget pauses keep the terminal's override requirement.
658
+
659
+ Local-worker permission titles may contain PII file contents or commands, so the
660
+ page shows a terminal-only stub and offers denial only. Allowing those requests
661
+ requires reading `ahub tail` and answering with `ahub permit`. Other agent
662
+ approvals offer their original options. Task refs/history and checkpoint summaries
663
+ are omitted from browser snapshots. Private envelope bodies retain the tail stub.
664
+
665
+
666
+ ## Amendment: concurrent projects and unified dashboard (issue #19)
667
+
668
+ The approved [multi-project specification](2026-09-19-multi-project-design.md)
669
+ extends the single-machine model to simultaneous independent repository/worktree
670
+ hubs. It replaces the unlocked JSON port registry with a transactional SQLite
671
+ project registry and adds explicit project selection, authenticated instance
672
+ ownership and a separate optional dashboard manager. Runtime data and routing
673
+ remain per project. Native memory aliases remain compatible and shared.
674
+ Control protocol 7 adds project/instance identity and console-only dashboard
675
+ snapshot/action forwarding. Existing project-local UI security rules still apply.