@staix/agent-hub 0.12.17 → 0.12.19

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.12.19
8
+
9
+ - Pi's write, edit, bash and git write approvals offer `Always allow <tool> until Pi restarts`: later calls of that tool by the running Pi skip the prompt, still under the path guard and sandbox. The grant is in memory only, logged by tool name, and the dashboard can still only deny (#209).
10
+ - Control protocol stays 15 and events schema stays 1; a running 0.12.18 hub upgrades with the 0.12.19 coordinator.
11
+
12
+ ## 0.12.18
13
+
14
+ - Attribute token increments, provider usage and completed turns to task ids by delivery or a single open task; add `ahub report --by task` with task/class totals, explicit unknown counters, unattributed shares and a separate historical bucket. Preserve PII id-only exports and derive no prices (#200).
15
+ - Add semantic colors to console stream headers, footer and panels with `--color=auto|always|never`; keep plain-text Unicode geometry, sanitize incoming controls before the fixed palette, and restore terminal attributes on exit. Tail, logs, JSON and polling remain unchanged (#201).
16
+ - Control protocol stays 15 and events schema stays 1; a running 0.12.17 hub upgrades with the 0.12.18 coordinator.
17
+
7
18
  ## 0.12.17
8
19
 
9
20
  - Add an operator console with confirmed approvals, bounded status polling and optional peer, approval, task, queue and event panels; open it from interactive `up` and preserve the tail renderer (#190, #191).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.12.17, control protocol 15. Durable delivery records distinguish queued
7
+ Status: 0.12.19, control protocol 15. Durable delivery records distinguish queued
8
8
  work from uncertain execution. The [smoke checklist](docs/smoke.md) records
9
9
  verified paths and remaining prerequisites.
10
10
 
@@ -28,7 +28,7 @@ cd <your project> && ahub init && ahub up
28
28
  Or install the same version from GitHub:
29
29
 
30
30
  ```bash
31
- bun add -g github:STAIxBWLB/agent-hub#v0.12.17 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.19 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
@@ -6,6 +6,7 @@ Scope: the Codex, ACP and Pi adapters and the Pi extension (turn `generation` an
6
6
  - Codex 0.154.0 `agentMessage` items carry `text` and `phase` (not `content[]`); only the last non-`commentary` message of a turn is shared.
7
7
  - Approval titles are agent-written text shown to a person: the daemon escapes control characters, and write/edit/bash show what will be written or run.
8
8
  - An ACP permission request may carry no `rawInput` (Kimi 2.0.1): the arguments were on the earlier `tool_call` update, which `acp.ts` remembers by `toolCallId`. Kimi 2.1.1 sends no `rawInput` before the answer at all: the argument JSON streams as `content` text on `tool_call_update`, and only complete JSON counts. A payload that still cannot be resolved, or is too long to show whole (marked `[cut, N chars]`), withholds its `allow_always` option - never render a bare tool name as if it described the call.
9
+ - Pi's `always` grant (#209) is keyed by `executeTool`'s tool name, never parsed from the approval title, and lives only in that Pi start's closure. Never persist it, share it with the local worker, or let the dashboard give it.
9
10
  - Auto-approval covers the hub's own tools by exact `mcp__agent-hub__<name>` title and only ever picks `allow_once`. Identity comes from the permission request title or, when an agent titles the request with the argument JSON (Qwen), from the announced `tool_call` title bound to the call id and resolved against the session's configured MCP servers, never from payload text, a prefix or an unrelated display title. Never widen it to a prefix, and never set Kimi's session mode to `auto` or `yolo` instead: those approve everything.
10
11
  - Every agent the hub spawns (the Codex app-server, ACP agents such as Kimi, Pi) runs in its own process group and is stopped as one (`stopOwnedProcess(proc, { group: true })`, `trackGroup` after spawn): `codex` is a node launcher, and mid-turn its native app-server does not exit on SIGTERM within the grace period, so a SIGKILL to the launcher alone left the app-server running under init and holding the daemon alive (#113, #115). A new adapter that spawns an agent does the same.
11
12
  - To stop a process tree, freeze it (SIGSTOP) before reading what it contains, then SIGKILL, and call it done only when the table shows none of it (with no table at all, the stop can only ask the group itself, which cannot see groups below it). A process a read after the freeze shows for the first time is frozen and read again before anything is killed. A snapshot taken while the tree runs misses what it starts next: three review rounds of #113 found such a gap (groups of its own, an unreadable first read, a child started during the grace period).
package/docs/events.md CHANGED
@@ -30,9 +30,9 @@ marked `private: true`, and PII tasks `pii: true`.
30
30
  | `split` | `task`, `where` (`routing`: routing chose the first owner of a task overlapping another owner's task not started yet, not an escalation, relay or reassignment, the record calibration reads; `cohort`: an overlap formed or changed a cohort), `verdict` (`split`, `single`, `unknown`), `single` (the peer that would finish both units alone soonest), `splitS`, `singleS`, `reason` (for `unknown`), `trace` (the inputs and steps: peer names, their profiles of versions and coordination, and numbers only): a shadow split prediction; it never changes the assignment (issue #109) |
31
31
  | `state` | `peer`, `state` |
32
32
  | `turn_start` | `peer`, `turn` (`<peer>#<hub run>.<n>`, unique across restarts). A turn follows the adapter: pausing a busy peer does not end it |
33
- | `turn_end` | `peer`, `turn`, `ms`, `tokens` (when the adapter reported any during the turn), `files` and `snapshotMs` (when snapshots are on: how many files the turn changed, and the time both snapshots took) |
34
- | `tokens` | `peer`, `n` (tokens added since the previous report) |
35
- | `usage` | `peer`, `source`, opaque `id`, optional `measuredAt` (provider/source time), requested/served model and provider labels, and any provider-reported input/output/cache/total counters. Missing counters stay unknown. |
33
+ | `turn_end` | `peer`, `turn`, `ms`, `tokens` (when the adapter reported any during the turn), `files` and `snapshotMs` (when snapshots are on: how many files the turn changed, and the time both snapshots took); optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`) frozen at turn start, `pii: true` for an attributed PII task |
34
+ | `tokens` | `peer`, `n` (tokens added since the previous report), optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`), `pii: true` for an attributed PII task |
35
+ | `usage` | `peer`, `source`, opaque `id`, optional `measuredAt` (provider/source time), requested/served model and provider labels, and any provider-reported input/output/cache/total counters; optional numeric `task`, `attribution` (`delivery`, `single_open`, `unattributed`), `pii: true` for an attributed PII task. Missing counters stay unknown. |
36
36
  | `task` | `id`, `event` (the board history event, e.g. `proposed`, `assigned`, `done`, `check failed`, `blocked`, `ready`), `by`, `state`, `owner`, `reviewer`, `class`, `pii` |
37
37
  | `overlap` | `task`, `owner`, `others` (`task`, `owner`, `paths`, and `symbols` when plans name the same symbol; a name that matches a PII pattern is left out, so either list can be empty), the structured twin of the console notice |
38
38
  | `quota` | `peer`, `windows` (`id`, `used`, `resetsAt`), `hard`, `measuredAt` (when the reading was taken, if not when it arrived: Claude's numbers come through a file) |
@@ -50,6 +50,40 @@ Token usage by adapter:
50
50
  - `ahub report` deduplicates usage records by peer, source and id. Coverage counts distinguish calls with provider usage from calls where usage was absent. Token counters are provider-reported values; the report never derives a price or treats missing spend as zero. Estimated price and measured provider spend remain unknown unless a future source reports them.
51
51
  - Usage telemetry has no prompt, completion, task text, credential, Access header, session id or transcript path.
52
52
 
53
+ ## Per-task usage reports
54
+
55
+ `ahub report --by task` (also `--json` and `--since`) reads only `events.jsonl`.
56
+ It reports task ids, the latest recorded class/outcome, completed logical turns,
57
+ and wall time from the first `in_progress` task event to the first `approved`
58
+ event. Wall time is unknown if either boundary is absent or the latest outcome
59
+ is not approved. Each task and class rollup includes per-peer native token
60
+ increments and provider-reported input/output/cache/total counters. Usage is
61
+ deduplicated by peer, source and id; missing counters are `null` in JSON and
62
+ `unknown` in text, with known-record counts alongside measured subsets. A
63
+ missing task history has unknown class/outcome and belongs to the unknown class
64
+ rollup. PII tasks have ids and a `pii: true` flag, never titles. No prices are derived.
65
+
66
+ At write time, the first applicable attribution rule wins:
67
+
68
+ - `delivery`: the current turn's original delivery names exactly one distinct positive task id, including work by a peer that does not own it.
69
+ - `single_open`: otherwise the peer owns exactly one `in_progress` task.
70
+ - `unattributed`: otherwise no task is assigned to the record.
71
+
72
+ `Bus.onDeliver` observes originals immediately before `peer.deliver` starts the
73
+ turn. Pending task identity is consumed at turn start and cleared on delivery
74
+ admission/failure, so later user-started native turns do not inherit it. Usage
75
+ and token events apply the rule at write time; `turn_end` keeps the start-time
76
+ attribution. Local usage uses its request-bound route policy task when present.
77
+ The existing relay has no task-bearing route usage event; this change adds no
78
+ new usage source. Schema version 1 and the control protocol are unchanged.
79
+
80
+ The unattributed share is always printed for token increments and deduplicated
81
+ usage records, against all recorded increments/usage records. A zero denominator
82
+ has unknown share. Records with no `attribution` field belong to a separate
83
+ `before attribution` bucket displayed beside the share, even if a task field is
84
+ present. Neither bucket is redistributed. `ahub export` preserves these fields
85
+ as raw JSON lines; plain `ahub report` keeps its existing behavior.
86
+
53
87
  The file is local and never uploaded. It grows without rotation; delete it to start
54
88
  over (the hub recreates it).
55
89
 
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.12.17 and control protocol 15. Live verification
3
+ This guide describes ahub 0.12.19 and control protocol 15. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
6
  ## Operator console and panels
@@ -17,6 +17,12 @@ approval titles are terminal-only; expiry and answers from another console remov
17
17
  the pending item. Approval audit records contain id, peer, option kind, response
18
18
  time and answering surface, without the title.
19
19
 
20
+ Pi's write, edit, bash and git write requests also offer `Always allow <tool>
21
+ until Pi restarts`. It allows later calls of that tool by the running Pi without
22
+ asking, under the same path guard and sandbox; a Pi restart or replacement, or a
23
+ hub restart, drops it. The hub log records each granted call by tool name only.
24
+ The dashboard can only deny Pi's requests.
25
+
20
26
  Tab toggles stream and panels; `ahub console --panels` starts in panels. Peers,
21
27
  Approvals, Tasks, Queue and Events support arrow keys or j/k, Enter for detail,
22
28
  Escape to return and `?` for help. `:` enters a command. Assignment, delivery
@@ -26,6 +32,43 @@ a reason. Tasks use the same public redaction as the board. Panels need at least
26
32
  polling runs only while the corresponding panel is visible. Leaving restores
27
33
  the terminal and returning from panels replays the bounded stream buffer.
28
34
 
35
+ Console color policy is `--color=auto|always|never`, with `auto` as the default.
36
+ Auto enables color only when both input and output are TTYs, `TERM` is not
37
+ `dumb`, and `NO_COLOR` is empty or absent. Explicit `always` overrides these
38
+ conditions, including redirected stream output; `never` disables styling.
39
+ Neither option changes terminal size requirements, panel decisions, cursor
40
+ management or the final reset. Invalid values fail before connecting.
41
+
42
+ | Meaning | Palette | Examples |
43
+ |---|---|---|
44
+ | Information and navigation | Cyan, bold cyan for active/selected labels | Tabs, section/peer labels and `>` selection marker |
45
+ | Success and availability | Green | Idle peer, approved task |
46
+ | Waiting and attention | Yellow | Busy/paused peer, pending approval, confirmation, review/ready task, important priority |
47
+ | Failure and intervention | Red | Failed/check-failed task, undeliverable/overflow, `needs_review` queue, denial request or expired/cancelled approval |
48
+ | Metadata | Bright black | Ages and remaining approval time |
49
+
50
+ Offline peers, primary titles, action details and message body lines keep the
51
+ terminal's default foreground. Labels and prompts remain readable without
52
+ color. Stream styling applies only to the header, using structured event data;
53
+ message text cannot choose a color. A local denial is shown as requested, not
54
+ as a confirmed receipt; a remote answered closure has no option-kind metadata
55
+ and is not guessed to be a denial. A fixed palette is applied after terminal
56
+ control sanitization. Width, clipping, wrapping and cursor placement use plain
57
+ Unicode text. Each styled span and every interactive exit restores attributes.
58
+ Color adds no polling, timers or extra redraws. `ahub tail`, logs and JSON stay
59
+ unchanged.
60
+
61
+ ```sh
62
+ ahub console --panels --color=auto
63
+ NO_COLOR=1 ahub console
64
+ ahub console --color=never
65
+ ahub console --color=always > console-stream.txt
66
+ ```
67
+
68
+ Actual light/dark terminal readability and bounded idle-CPU observations are
69
+ recorded separately in the smoke ledger; fake-terminal tests establish policy,
70
+ geometry, sanitization and restoration only.
71
+
29
72
  The command input accepts existing status, board, task, review, say, pause,
30
73
  resume, budget, queue, permit, ask, remember, route, turns, undo, check-path and
31
74
  report operations. It executes an argument vector with closed stdin. Lifecycle,
@@ -346,11 +389,16 @@ token counts, never message bodies or task titles.
346
389
  ```bash
347
390
  ahub report --since 7d # turns, busy time and tokens per peer, messages, overlaps, task events
348
391
  ahub report --since 7d --json # the same numbers as JSON
392
+ ahub report --by task # tokens, turns and wall time per task and class, with the unattributed share
349
393
  ahub export --since 24h # the raw events as JSON lines, for your own analysis
350
394
  ```
351
395
 
352
396
  `ahub report` counts the same overlap warnings as `scripts/overlaps.ts`, from the
353
- structured events instead of log lines.
397
+ structured events instead of log lines. `--by task` uses the task each usage and
398
+ token record was attributed to when it was written: the delivery that started the
399
+ turn, otherwise the peer's only `in_progress` task. Everything else is reported as
400
+ unattributed, and records from before 0.12.18 as a separate bucket; neither is
401
+ redistributed (rules: [events](events.md#per-task-usage-reports)).
354
402
 
355
403
  ## Turns and undo
356
404
 
@@ -744,21 +792,21 @@ Rows without a live process are stale registrations; forget them with
744
792
 
745
793
  Upgrade running projects with the target release's own coordinator. It accepts
746
794
  a running source on control protocol 9 (0.6.x), 10 (0.7.0 through 0.12.0),
747
- 11 (0.12.1 and 0.12.2), 12 (0.12.3), 13 (0.12.4 through 0.12.15) 14 (0.12.16) or 15 (0.12.17), and only
795
+ 11 (0.12.1 and 0.12.2), 12 (0.12.3), 13 (0.12.4 through 0.12.15) 14 (0.12.16) or 15 (0.12.17 through 0.12.19), and only
748
796
  a target on its own protocol, so the target's coordinator fits every supported
749
797
  source and carries every recovery fix released up to it. Protocol 8 and older
750
798
  (0.5.x and earlier) are refused as `manual-bootstrap-required`. Run from the
751
799
  project directory, without replacing the global CLI first:
752
800
 
753
801
  ```bash
754
- bunx --package @staix/agent-hub@0.12.17 ahub upgrade --to 0.12.17 --dry-run
755
- bunx --package @staix/agent-hub@0.12.17 ahub upgrade --to 0.12.17 --yes
802
+ bunx --package @staix/agent-hub@0.12.19 ahub upgrade --to 0.12.19 --dry-run
803
+ bunx --package @staix/agent-hub@0.12.19 ahub upgrade --to 0.12.19 --yes
756
804
  ```
757
805
 
758
806
  | Running now | Coordinator to use |
759
807
  | --- | --- |
760
808
  | 0.6.x (protocol 9) | the target's, through `bunx` as above |
761
- | 0.7.0 through 0.12.0 (protocol 10), 0.12.1 and 0.12.2 (protocol 11), 0.12.3 (protocol 12), 0.12.4 through 0.12.15 (protocol 13), 0.12.16 (protocol 14), 0.12.17 (protocol 15) | the target's, through `bunx` as above |
809
+ | 0.7.0 through 0.12.0 (protocol 10), 0.12.1 and 0.12.2 (protocol 11), 0.12.3 (protocol 12), 0.12.4 through 0.12.15 (protocol 13), 0.12.16 (protocol 14), 0.12.17 through 0.12.19 (protocol 15) | the target's, through `bunx` as above |
762
810
  | any supported source, with the installed CLI already at the target | `ahub upgrade` below, which is the same coordinator |
763
811
  | 0.5.x or earlier (protocol 8 and older) | not supported: bootstrap by hand with the matching CLI |
764
812
 
@@ -790,14 +838,14 @@ projects first:
790
838
 
791
839
  ```bash
792
840
  ahub restart --dry-run
793
- ahub upgrade --to 0.12.17 --dry-run
841
+ ahub upgrade --to 0.12.19 --dry-run
794
842
  ```
795
843
 
796
844
  Apply only after reviewing the plan:
797
845
 
798
846
  ```bash
799
847
  ahub restart --yes
800
- ahub upgrade --to 0.12.17 --yes
848
+ ahub upgrade --to 0.12.19 --yes
801
849
  ahub recovery status <operation-id>
802
850
  ahub recovery resume <operation-id>
803
851
  ahub recovery abort <operation-id>
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.12.17
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.19
21
21
  ahub setup
22
22
  ```
23
23
 
package/docs/security.md CHANGED
@@ -6,9 +6,9 @@ agent-hub connects agents that can each run commands. This page says what the hu
6
6
 
7
7
  - **Other agents' text is untrusted.** Every message that crosses from one peer to another is framed as untrusted input (a channel tag with `meta.source` for Claude, a fixed header line plus a standing instruction for the others). A message body cannot forge the hub's own headers: such lines are quoted (`sanitize`). Replies inherit a hop count capped at 3, so agents cannot ping-pong forever; neither a digest nor a steer can reset it.
8
8
  - **The control link is loopback plus a secret.** The daemon and the Codex proxy bind 127.0.0.1 only. The control WebSocket requires a per-run token (`.agenthub/state/control-token`, mode 600), and both servers refuse any request that carries an `Origin` header: any web page can open a WebSocket to localhost, and browsers always send `Origin`. External clients cannot claim the console user's id or a hub-managed peer's id.
9
- - **Permission prompts stay on.** `ahub claude` and `ahub codex` add nothing that weakens the agents' own prompts. Kimi's and `local`'s permission requests are relayed to the console and cancelled after `approvals.timeout_s` (default 120 s) of silence; the macOS notification for a waiting request carries the peer and the tool name only. The one exception is the hub's own tools (`hub_send` and the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets through `approval_mode` in the hub's config. They invoke hub-owned operations rather than arbitrary file or shell tools, and every call passes the hub's own checks. Identity comes from the exact permission title, or from the earlier tool-call title bound to the same call id and resolved against the configured MCP servers when the permission title contains argument JSON (Qwen). Payload text and unrelated display titles never establish identity. A payload longer than the console shows is marked as cut and never offers a session-wide grant. `--unattended` turns prompts off, says so loudly, and is never the default.
9
+ - **Permission prompts stay on.** `ahub claude` and `ahub codex` add nothing that weakens the agents' own prompts. Kimi's and `local`'s permission requests are relayed to the console and cancelled after `approvals.timeout_s` (default 120 s) of silence; the macOS notification for a waiting request carries the peer and the tool name only. The one exception is the hub's own tools (`hub_send` and the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets through `approval_mode` in the hub's config. They invoke hub-owned operations rather than arbitrary file or shell tools, and every call passes the hub's own checks. Identity comes from the exact permission title, or from the earlier tool-call title bound to the same call id and resolved against the configured MCP servers when the permission title contains argument JSON (Qwen). Payload text and unrelated display titles never establish identity. For ACP agents, a payload longer than the console shows is marked as cut and never offers a session-wide grant. Pi's write, edit, shell and git write requests are relayed the same way and can be answered `Always allow <tool> until Pi restarts`: later calls of that tool by the same Pi start skip the prompt but stay inside the path guard and sandbox. The grant covers every later call of that tool whatever its payload, is kept in memory only, cannot be given from the dashboard, and each call it allows is logged by tool name. `--unattended` turns prompts off, says so loudly, and is never the default.
10
10
  - **A committed config cannot choose launch commands, credential files, data endpoints or a wider sandbox.** The machine-local fields (`kimi_cmd`, `codex_bin`, `pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`, `omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`, `local.read_allow`, `local.bash_network`, `local.network_allow`) apply only from a config file git confirms nobody committed: `.agenthub/config.json` or `.agenthub/config.local.json`, matched by file identity so no other spelling the file system accepts slips past, and `.agenthub` itself not a committed symlink or submodule. Without a repository, or when git fails, they keep their defaults; an empty value always means the default. "Untracked" is answered by the repository that contains the project: a checkout copied or extracted into an unrelated repository, or into an ignored directory of one, is trusted like your own files. So a cloned repository cannot choose a launch command, a completion check, a gateway to send a key file to, a memory endpoint, or a wider sandbox. A command in `checks` runs as you, outside the local worker's sandbox, like a git hook. Nothing in `routing.toml` or in task text is ever run. `routing.toml` and the other shared fields still come from the checkout, and they matter: `routing.toml` picks the models the local worker and the hub's inference use at your gateway and can turn the PII constraint off, and roles and budget shape who does what. Review them in a repository you do not trust.
11
- - **Telemetry holds no bodies.** `.agenthub/state/events.jsonl` (issue #40) records envelope ids, routing and sizes, task ids and states, overlapping paths and token counts. It never records a message body, a task title or detail, and marks private (PII) envelopes and tasks as such. It stays on the machine; `ahub export` only prints it.
11
+ - **Telemetry holds no bodies.** `.agenthub/state/events.jsonl` (issue #40) records envelope ids, routing and sizes, task ids and states, overlapping paths and token counts, and the task id (with a PII flag) that each usage and token record is attributed to. It never records a message body, a task title or detail, and marks private (PII) envelopes and tasks as such. It stays on the machine; `ahub export` only prints it.
12
12
  - **Snapshots stay in your repository.** Per-turn snapshots (issue #33) are git objects in the project's own object store, written through a temporary index; nothing is referenced, pushed or copied elsewhere, and `git gc` prunes them. They hold what the work tree held, including untracked files that are not ignored, so keep secrets in ignored files. They carry the repository's own permissions, and nothing caps their disk use but `git gc`. A turn of a peer holding an open PII task is not snapshotted; a PII file left in the project is snapshotted by later turns like any other file. `ahub undo` restores only files whose current content is exactly what the turn left.
13
13
  - **The edit hook reads, never decides.** `ahub check-path --hook` (issue #32) reads hub.db and returns context for Claude and a line for you; it sets no permission decision, so your permission rules stay in charge. It names other owners' task ids, titles and states, which then reach Claude's model; PII tasks are left out.
14
14
  - **The session record holds identities only.** `.agenthub/state/sessions.json` (issue #37, mode 600) keeps each attached peer's recovery metadata: launch options, session and thread ids, Pi's session file path. No message or task text; loss notices name deliveries by id, sender and public task title.
@@ -38,7 +38,10 @@ capability. Conflicting or malformed markers fail closed. There is no `--as-user
38
38
  Human-only operations are refused before connecting, including permission answers,
39
39
  queue resolution, budget overrides, lifecycle and recovery operations, and `ask`.
40
40
  The operator uses a plain terminal or `ahub console`. A Claude `!` command that
41
- inherits the agent markers follows the same rule.
41
+ inherits the agent markers follows the same rule. The console strips control
42
+ sequences from agent and daemon text before it quotes forged headers, and its
43
+ colors come only from a fixed palette applied afterwards, so message text cannot
44
+ set styles or operate the terminal.
42
45
 
43
46
  This is an honest default against accidental impersonation and injected commands.
44
47
  It does not make the token inaccessible to an agent with unrestricted project
package/docs/smoke.md CHANGED
@@ -1252,3 +1252,36 @@ the sentinel. Missing served-model evidence remains `identified: false`.
1252
1252
  Before recording live acceptance, run a bounded unavailable-local/healthy-remote
1253
1253
  leg and retain the sanitized JSON plus process cleanup confirmation. Fixture
1254
1254
  verdicts do not certify provider availability or a real local generation.
1255
+
1256
+ ## 0.12.18 live checks: task attribution and console colors (#200, #201, 2026-10-09)
1257
+
1258
+ The agent-hub project's own hub was upgraded from 0.12.17 to 0.12.18 by the
1259
+ user with the 0.12.18 coordinator, run from a plain terminal (an agent shell is
1260
+ refused by the identity gate). Claude (the session writing this) and Pi on
1261
+ `dgx/coding` were attached for the checks. Times are UTC.
1262
+
1263
+ - **The upgrade left Claude held.** Operation `c671d786` (source 0.12.17,
1264
+ protocol 15, running) was created at 05:26:55, the 0.12.18 daemon started
1265
+ the same second, and the operation completed with the project verified. Ten deliveries Claude had accepted but never settled (three board review
1266
+ requests, seven Codex chats, all handled before the upgrade) were moved to
1267
+ `needs_review` with "peer disconnected before delivery settlement", and
1268
+ Claude's queue was held behind them. `ahub queue resolve` is a console
1269
+ command, so only the user can release it; the unsettled acceptances are #205.
1270
+ - **#200 AC4, attribution on a live turn.** Board task #19 (class `test`,
1271
+ owner Pi) was proposed at 05:30:03, accepted at 05:30:07, done at 05:34:52
1272
+ and approved at 05:35:16. `ahub report --by task` at 05:35:06 attributed Pi's
1273
+ one turn and 602,255 tokens to #19 by its delivery. Unattributed: 2,159,396
1274
+ of 38,171,695 token increments (5.7%) and 21 of 116 usage records (18.1%),
1275
+ all Claude's own user-driven turns, which have no task-bearing delivery and
1276
+ no single open task. The `before attribution` bucket held 35,410,044 tokens
1277
+ and 95 records from before the upgrade. Both shares equal the JSON
1278
+ `unattributed` counts over `totals`, and the JSON carried task ids, class and
1279
+ outcome but no title or detail text.
1280
+ - **Pi could not run the check itself.** Its sandbox denies
1281
+ `.agenthub/state/` (the `ahub` binary under the bunx cache and `events.jsonl`
1282
+ both failed with EPERM). It reported every check as not run instead of
1283
+ passing it, and the reviewer ran them.
1284
+ - **#201 AC7, idle console CPU.** Sampled with `ps` cumulative CPU time while
1285
+ nothing happened on the hub: plain `ahub console`, 0.11 s over 60 s
1286
+ (about 0.18%); `ahub console --panels`, 0.46 s over 120 s from 05:52:30
1287
+ (about 0.38%, 0.21 to 0.25 s per minute, not growing).
@@ -1360,6 +1360,30 @@ approval. Deny is direct. Pending requests include expiry and are withdrawn by
1360
1360
  Panels expose Peers, Approvals, Tasks, Queue and Events with bounded polling and
1361
1361
  Unicode cell widths, fall back below 80x24, and restore terminal state on exit.
1362
1362
 
1363
+ Console semantic colors (issue #201) use spans with a fixed terminal-native
1364
+ palette: cyan information, bold cyan active/selected labels, green availability
1365
+ and success, yellow attention/waiting, red failure/intervention, and restrained
1366
+ bright-black metadata. Offline peers, ordinary titles, action details and body
1367
+ lines use the default foreground. Existing labels, selection markers and
1368
+ confirmation prompts remain sufficient without color. Stream headers use event
1369
+ structure for their tone; body text is never parsed for meaning or passed
1370
+ through as terminal styling. Denial requests and observed expiry/cancellation
1371
+ are red; an answered remote closure lacks option-kind metadata and is not
1372
+ guessed to be a denial.
1373
+
1374
+ `--color=auto|always|never` affects styling only. Auto requires terminal input
1375
+ and output, a non-dumb TERM, and absent/empty NO_COLOR. Always overrides those
1376
+ checks even for redirected stream output, without enabling panels or raw mode;
1377
+ never disables styling. Invalid values fail with usage text before connection.
1378
+ Plain `renderConsole` remains available, while `renderConsoleLines` exposes
1379
+ semantic spans. Geometry is computed from sanitized plain Unicode text before
1380
+ painting. `paint` sanitizes every span, inserts only fixed palette SGR sequences
1381
+ and resets each styled span. Interactive exits, signals, disconnect and errors
1382
+ retain the console restoration sequence. No new dependencies, protocol changes,
1383
+ polling or redraw triggers are added. Tail, logs and machine-readable output
1384
+ remain unchanged. Native light/dark readability and idle-CPU observations are
1385
+ separate smoke evidence, not inferred from fake-terminal tests.
1386
+
1363
1387
  Agent shell CLI calls connect as the detected peer in tools mode. Hub launches
1364
1388
  set `AGENTHUB_PEER_ID`; the pinned installed Codex shell injects
1365
1389
  `CODEX_THREAD_ID` after environment filtering, and Claude uses `CLAUDECODE`.
@@ -1465,3 +1489,48 @@ provider/configuration change, account retry or authority relaxation. Once quota
1465
1489
  is available under an authorized account, the same isolated read-only status-tool
1466
1490
  and ordinary-role refusal probe must record a native tool event and daemon result
1467
1491
  before this Kimi prerequisite can be marked verified.
1492
+
1493
+ ## Amendment: per-task usage attribution (issue #200)
1494
+
1495
+ Task usage is attributed by a rule at write time, never by a proportional guess:
1496
+
1497
+ - A turn whose original delivery names exactly one distinct positive `refs.task` uses that id and `attribution: "delivery"`, even when the peer does not own the task. Two distinct delivery task ids fall through to the next rule.
1498
+ - Otherwise a peer with exactly one owned `in_progress` task uses that id and `attribution: "single_open"`.
1499
+ - Otherwise the record carries `attribution: "unattributed"` and no task id.
1500
+
1501
+ The bus calls `onDeliver(peer, originals)` immediately before `peer.deliver`,
1502
+ after retaining the original delivery. The daemon consumes pending delivery
1503
+ identity during the synchronous busy/turn-start transition and clears it on
1504
+ delivery admission/failure. A user-started native turn cannot inherit an older
1505
+ delivery. Tokens and usage use the rule at write time; turn ends preserve the
1506
+ start-time attribution. Local worker usage carries its request-bound route
1507
+ policy task when one exists. Relay usage would use a route decision's task when
1508
+ present; the current daemon has no such relay usage writer, so collection is
1509
+ unchanged. Attributed PII records carry only a task id and `pii: true`.
1510
+
1511
+ `ahub report --by task` and `--by task --json` use only events, never the board or
1512
+ task text. They report latest task class/outcome, attributed turn ends, first
1513
+ accept-to-first-approval wall time (unknown while not approved or without both
1514
+ boundaries), per-peer token increments and provider counters, and class rollups.
1515
+ Usage deduplication uses peer/source/id. Missing counters stay unknown, distinct
1516
+ from reported zero, and known-record counts identify measured subsets.
1517
+
1518
+ Unattributed token and usage-record shares always appear. Older records without
1519
+ `attribution` stay in a distinct `before attribution` bucket, never redistributed
1520
+ to a task; both buckets appear even when empty. A zero denominator has unknown
1521
+ share. Export carries the new fields without task text. No prices or savings
1522
+ counterfactuals are derived. These additive fields retain events schema 1 and
1523
+ the existing control protocol. Plain `ahub report` remains unchanged.
1524
+
1525
+ ## Amendment: Pi "always" approvals (issue #209)
1526
+
1527
+ Pi's `write`, `edit`, `bash` and `git` write calls ask with three options:
1528
+ `allow` (`allow_once`), `always` (`allow_always`, named `Always allow <tool>
1529
+ until Pi restarts`) and `deny` (`reject_once`). `always` allows the call and
1530
+ every later call of the same hub tool by that Pi start without asking. The grant
1531
+ is held in memory by the Pi start: stopping or replacing Pi, or restarting the
1532
+ hub, drops it, and nothing is persisted. A granted call is logged by tool name
1533
+ only. The path guard, denylist and sandbox still apply to each call; the
1534
+ dashboard still offers Pi only deny; unattended mode still picks `allow_once`;
1535
+ the local worker keeps allow and deny. The console and `ahub permit` already
1536
+ handle more than one allow option, so the control protocol is unchanged.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "c3d1e22f9b200cbe3f484c9e73d30bad046c9221",
5
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "c3d1e22f9b200cbe3f484c9e73d30bad046c9221",
14
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "c3d1e22f9b200cbe3f484c9e73d30bad046c9221",
21
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "c3d1e22f9b200cbe3f484c9e73d30bad046c9221",
38
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "20ad5e68fd1264fa43dd7426f60cb0c8211d6555",
46
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -68,7 +68,7 @@
68
68
  ]
69
69
  },
70
70
  "docs/agent-notes/budget.md": {
71
- "verifiedAgainst": "20ad5e68fd1264fa43dd7426f60cb0c8211d6555",
71
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
72
72
  "paths": [
73
73
  "src/hub/budget.ts",
74
74
  "src/cli/statusline-tee.ts",
@@ -80,7 +80,7 @@
80
80
  ]
81
81
  },
82
82
  "docs/agent-notes/bus.md": {
83
- "verifiedAgainst": "20ad5e68fd1264fa43dd7426f60cb0c8211d6555",
83
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
84
84
  "paths": [
85
85
  "src/hub/bus.ts",
86
86
  "src/hub/envelope.ts",
@@ -92,7 +92,7 @@
92
92
  ]
93
93
  },
94
94
  "docs/agent-notes/daemon.md": {
95
- "verifiedAgainst": "20ad5e68fd1264fa43dd7426f60cb0c8211d6555",
95
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
96
96
  "paths": [
97
97
  "src/hub/daemon.ts",
98
98
  "src/hub/control-client.ts",
@@ -118,7 +118,7 @@
118
118
  ]
119
119
  },
120
120
  "docs/agent-notes/local-worker.md": {
121
- "verifiedAgainst": "8b7d7254f1bb5602ec5d658e7814e236f8fb43e7",
121
+ "verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
122
122
  "paths": [
123
123
  "src/adapters/local-worker.ts",
124
124
  "src/local/",
@@ -136,7 +136,7 @@
136
136
  ]
137
137
  },
138
138
  "docs/agent-notes/tasks.md": {
139
- "verifiedAgainst": "c3d1e22f9b200cbe3f484c9e73d30bad046c9221",
139
+ "verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
140
140
  "paths": [
141
141
  "src/hub/tasks.ts",
142
142
  "src/hub/board.ts",
@@ -151,7 +151,7 @@
151
151
  ]
152
152
  },
153
153
  "docs/agent-notes/tests.md": {
154
- "verifiedAgainst": "2a274289e280252b264716da3a1112f67b0c278e",
154
+ "verifiedAgainst": "25d6ffe00c490a3690fc81782a45e62bfb383803",
155
155
  "paths": [
156
156
  "test/",
157
157
  "scripts/check.sh",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15403,7 +15403,7 @@ class ControlClient {
15403
15403
  // package.json
15404
15404
  var package_default = {
15405
15405
  name: "@staix/agent-hub",
15406
- version: "0.12.17",
15406
+ version: "0.12.19",
15407
15407
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15408
15408
  license: "MIT",
15409
15409
  type: "module",
@@ -35,7 +35,7 @@ export interface LocalOptions {
35
35
  /** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
36
36
  taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
37
37
  /** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
38
- onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string }) => void;
38
+ onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string; task?: number }) => void;
39
39
  /** Atomic task/run admission immediately before every model request or tool execution. */
40
40
  admitBudget?: (envs: Envelope[], unit: "model_calls" | "tool_calls") => Promise<ExecutionBudgetDecision[]>;
41
41
  /** Per-turn policy from the task the delivery carries: the class's route, and whether it is a PII task. */
@@ -76,6 +76,7 @@ export class LocalPeer extends BasePeer {
76
76
  private readonly routes: HubRouteRuntime;
77
77
  private routeEnvs: Envelope[] = [];
78
78
  private routePii = false;
79
+ private routeTask?: number;
79
80
  private labelTurnId?: string;
80
81
  private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
81
82
  private abort: AbortController | undefined;
@@ -94,13 +95,13 @@ export class LocalPeer extends BasePeer {
94
95
  this.routes = new HubRouteRuntime({
95
96
  execute: async (model, messages, judge, signal, maxTokens) => {
96
97
  if (signal.aborted) throw new Error("turn cancelled before model request");
97
- const envs = this.routeEnvs, pii = this.routePii, generation = this.turn;
98
+ const envs = this.routeEnvs, pii = this.routePii, generation = this.turn, task = this.routeTask;
98
99
  await this.requireBudget(envs, "model_calls");
99
100
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
100
101
  if (signal.aborted) throw new Error("turn cancelled before model request");
101
102
  const tools = judge ? undefined : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
102
103
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
103
- this.recordUsage(result, model);
104
+ this.recordUsage(result, model, task);
104
105
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
105
106
  this.lastServedBy = `hub ${model} (provider ${result.provider ?? "?"})`;
106
107
  return result;
@@ -329,11 +330,12 @@ export class LocalPeer extends BasePeer {
329
330
  }
330
331
 
331
332
  /** L2 when the sidecar is up, otherwise (or when a call through it fails) the fixed model on L3. */
332
- private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean } | undefined, envs: Envelope[]): Promise<ChatResult> {
333
+ private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string; pii?: boolean; task?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
333
334
  const { omni, sidecar } = this.opts;
334
335
  // A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
335
336
  const route = policy?.route ?? this.opts.route;
336
337
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
338
+ const task = policy?.task === undefined ? undefined : Number(policy.task);
337
339
  const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...TASK_TOOLS, ...CONDUCTOR_TOOLS].map(asFunction) : [])];
338
340
  const signal = this.abort!.signal;
339
341
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
@@ -345,7 +347,7 @@ export class LocalPeer extends BasePeer {
345
347
  try {
346
348
  const res = await omni.chat({ model: route!, messages, tools }, { via, sessionId: this.sessionId, signal });
347
349
  this.lastServedBy = `switchyard ${route} -> ${res.selectedModel ?? "?"}`;
348
- this.recordUsage(res, route!);
350
+ this.recordUsage(res, route!, task);
349
351
  return res;
350
352
  } catch (e) {
351
353
  if (signal.aborted) throw e;
@@ -355,7 +357,7 @@ export class LocalPeer extends BasePeer {
355
357
  await this.requireBudget(envs, "model_calls");
356
358
  const res = await omni.chat({ model: fixedModel, messages, tools }, { signal, ...(policy?.pii ? { onCampusOnly: true } : {}) });
357
359
  this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
358
- this.recordUsage(res, fixedModel);
360
+ this.recordUsage(res, fixedModel, task);
359
361
  return res;
360
362
  }
361
363
 
@@ -364,6 +366,7 @@ export class LocalPeer extends BasePeer {
364
366
  try { id = this.opts.turnId?.(); } catch { /* optional host metadata */ }
365
367
  id ??= `${this.id}#${this.sessionId}.${generation}`;
366
368
  const number = task ? Number(task) : undefined;
369
+ this.routeTask = Number.isSafeInteger(number) && number! > 0 ? number : undefined;
367
370
  this.labelTurnId = id;
368
371
  this.routes.beginTurn({ turn: id, pii, ...(Number.isSafeInteger(number) && number! > 0 ? { task: number } : {}) });
369
372
  return id;
@@ -418,13 +421,14 @@ export class LocalPeer extends BasePeer {
418
421
  } catch { /* optional research observations */ }
419
422
  }
420
423
 
421
- private recordUsage(res: ChatResult, requestedModel: string): void {
424
+ private recordUsage(res: ChatResult, requestedModel: string, task?: number): void {
422
425
  try {
423
426
  this.opts.onUsage?.({
424
427
  id: randomUUID(),
425
428
  at: new Date().toISOString(),
426
429
  ...(res.usage ? { usage: res.usage } : {}),
427
430
  requestedModel: safeModelLabel(requestedModel) ?? "unknown",
431
+ ...(Number.isSafeInteger(task) && task! > 0 ? { task } : {}),
428
432
  ...(safeModelLabel(res.servedModel ?? res.selectedModel) ? { servedModel: safeModelLabel(res.servedModel ?? res.selectedModel)! } : {}),
429
433
  ...(safeModelLabel(res.provider) ? { provider: safeModelLabel(res.provider)! } : {}),
430
434
  });