@staix/agent-hub 0.12.17 → 0.12.18
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 +6 -0
- package/README.md +2 -2
- package/docs/events.md +37 -3
- package/docs/operations.md +50 -8
- package/docs/quickstart.md +1 -1
- package/docs/security.md +5 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +56 -0
- package/docs/verified.json +11 -11
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/local-worker.ts +11 -7
- package/src/cli/console-state.ts +85 -19
- package/src/cli/console.ts +38 -15
- package/src/cli/main.ts +16 -6
- package/src/hub/attribution.ts +20 -0
- package/src/hub/bus.ts +6 -0
- package/src/hub/daemon.ts +23 -7
- package/src/hub/events.ts +4 -3
- package/src/hub/report.ts +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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.18
|
|
8
|
+
|
|
9
|
+
- 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).
|
|
10
|
+
- 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).
|
|
11
|
+
- Control protocol stays 15 and events schema stays 1; a running 0.12.17 hub upgrades with the 0.12.18 coordinator.
|
|
12
|
+
|
|
7
13
|
## 0.12.17
|
|
8
14
|
|
|
9
15
|
- 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.
|
|
7
|
+
Status: 0.12.18, 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.
|
|
31
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.12.18 && ahub setup
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
The installed commands remain `ahub` and `agent-hub`.
|
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
|
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
-
This guide describes ahub 0.12.
|
|
3
|
+
This guide describes ahub 0.12.18 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
|
|
@@ -26,6 +26,43 @@ a reason. Tasks use the same public redaction as the board. Panels need at least
|
|
|
26
26
|
polling runs only while the corresponding panel is visible. Leaving restores
|
|
27
27
|
the terminal and returning from panels replays the bounded stream buffer.
|
|
28
28
|
|
|
29
|
+
Console color policy is `--color=auto|always|never`, with `auto` as the default.
|
|
30
|
+
Auto enables color only when both input and output are TTYs, `TERM` is not
|
|
31
|
+
`dumb`, and `NO_COLOR` is empty or absent. Explicit `always` overrides these
|
|
32
|
+
conditions, including redirected stream output; `never` disables styling.
|
|
33
|
+
Neither option changes terminal size requirements, panel decisions, cursor
|
|
34
|
+
management or the final reset. Invalid values fail before connecting.
|
|
35
|
+
|
|
36
|
+
| Meaning | Palette | Examples |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| Information and navigation | Cyan, bold cyan for active/selected labels | Tabs, section/peer labels and `>` selection marker |
|
|
39
|
+
| Success and availability | Green | Idle peer, approved task |
|
|
40
|
+
| Waiting and attention | Yellow | Busy/paused peer, pending approval, confirmation, review/ready task, important priority |
|
|
41
|
+
| Failure and intervention | Red | Failed/check-failed task, undeliverable/overflow, `needs_review` queue, denial request or expired/cancelled approval |
|
|
42
|
+
| Metadata | Bright black | Ages and remaining approval time |
|
|
43
|
+
|
|
44
|
+
Offline peers, primary titles, action details and message body lines keep the
|
|
45
|
+
terminal's default foreground. Labels and prompts remain readable without
|
|
46
|
+
color. Stream styling applies only to the header, using structured event data;
|
|
47
|
+
message text cannot choose a color. A local denial is shown as requested, not
|
|
48
|
+
as a confirmed receipt; a remote answered closure has no option-kind metadata
|
|
49
|
+
and is not guessed to be a denial. A fixed palette is applied after terminal
|
|
50
|
+
control sanitization. Width, clipping, wrapping and cursor placement use plain
|
|
51
|
+
Unicode text. Each styled span and every interactive exit restores attributes.
|
|
52
|
+
Color adds no polling, timers or extra redraws. `ahub tail`, logs and JSON stay
|
|
53
|
+
unchanged.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
ahub console --panels --color=auto
|
|
57
|
+
NO_COLOR=1 ahub console
|
|
58
|
+
ahub console --color=never
|
|
59
|
+
ahub console --color=always > console-stream.txt
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Actual light/dark terminal readability and bounded idle-CPU observations are
|
|
63
|
+
recorded separately in the smoke ledger; fake-terminal tests establish policy,
|
|
64
|
+
geometry, sanitization and restoration only.
|
|
65
|
+
|
|
29
66
|
The command input accepts existing status, board, task, review, say, pause,
|
|
30
67
|
resume, budget, queue, permit, ask, remember, route, turns, undo, check-path and
|
|
31
68
|
report operations. It executes an argument vector with closed stdin. Lifecycle,
|
|
@@ -346,11 +383,16 @@ token counts, never message bodies or task titles.
|
|
|
346
383
|
```bash
|
|
347
384
|
ahub report --since 7d # turns, busy time and tokens per peer, messages, overlaps, task events
|
|
348
385
|
ahub report --since 7d --json # the same numbers as JSON
|
|
386
|
+
ahub report --by task # tokens, turns and wall time per task and class, with the unattributed share
|
|
349
387
|
ahub export --since 24h # the raw events as JSON lines, for your own analysis
|
|
350
388
|
```
|
|
351
389
|
|
|
352
390
|
`ahub report` counts the same overlap warnings as `scripts/overlaps.ts`, from the
|
|
353
|
-
structured events instead of log lines.
|
|
391
|
+
structured events instead of log lines. `--by task` uses the task each usage and
|
|
392
|
+
token record was attributed to when it was written: the delivery that started the
|
|
393
|
+
turn, otherwise the peer's only `in_progress` task. Everything else is reported as
|
|
394
|
+
unattributed, and records from before 0.12.18 as a separate bucket; neither is
|
|
395
|
+
redistributed (rules: [events](events.md#per-task-usage-reports)).
|
|
354
396
|
|
|
355
397
|
## Turns and undo
|
|
356
398
|
|
|
@@ -744,21 +786,21 @@ Rows without a live process are stale registrations; forget them with
|
|
|
744
786
|
|
|
745
787
|
Upgrade running projects with the target release's own coordinator. It accepts
|
|
746
788
|
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
|
|
789
|
+
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 0.12.18), and only
|
|
748
790
|
a target on its own protocol, so the target's coordinator fits every supported
|
|
749
791
|
source and carries every recovery fix released up to it. Protocol 8 and older
|
|
750
792
|
(0.5.x and earlier) are refused as `manual-bootstrap-required`. Run from the
|
|
751
793
|
project directory, without replacing the global CLI first:
|
|
752
794
|
|
|
753
795
|
```bash
|
|
754
|
-
bunx --package @staix/agent-hub@0.12.
|
|
755
|
-
bunx --package @staix/agent-hub@0.12.
|
|
796
|
+
bunx --package @staix/agent-hub@0.12.18 ahub upgrade --to 0.12.18 --dry-run
|
|
797
|
+
bunx --package @staix/agent-hub@0.12.18 ahub upgrade --to 0.12.18 --yes
|
|
756
798
|
```
|
|
757
799
|
|
|
758
800
|
| Running now | Coordinator to use |
|
|
759
801
|
| --- | --- |
|
|
760
802
|
| 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 |
|
|
803
|
+
| 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 and 0.12.18 (protocol 15) | the target's, through `bunx` as above |
|
|
762
804
|
| any supported source, with the installed CLI already at the target | `ahub upgrade` below, which is the same coordinator |
|
|
763
805
|
| 0.5.x or earlier (protocol 8 and older) | not supported: bootstrap by hand with the matching CLI |
|
|
764
806
|
|
|
@@ -790,14 +832,14 @@ projects first:
|
|
|
790
832
|
|
|
791
833
|
```bash
|
|
792
834
|
ahub restart --dry-run
|
|
793
|
-
ahub upgrade --to 0.12.
|
|
835
|
+
ahub upgrade --to 0.12.18 --dry-run
|
|
794
836
|
```
|
|
795
837
|
|
|
796
838
|
Apply only after reviewing the plan:
|
|
797
839
|
|
|
798
840
|
```bash
|
|
799
841
|
ahub restart --yes
|
|
800
|
-
ahub upgrade --to 0.12.
|
|
842
|
+
ahub upgrade --to 0.12.18 --yes
|
|
801
843
|
ahub recovery status <operation-id>
|
|
802
844
|
ahub recovery resume <operation-id>
|
|
803
845
|
ahub recovery abort <operation-id>
|
package/docs/quickstart.md
CHANGED
package/docs/security.md
CHANGED
|
@@ -8,7 +8,7 @@ agent-hub connects agents that can each run commands. This page says what the hu
|
|
|
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
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.
|
|
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
|
|
@@ -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,35 @@ 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.
|
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
5
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
14
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
21
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
38
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
46
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
71
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
83
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
95
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
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": "
|
|
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": "
|
|
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": "
|
|
154
|
+
"verifiedAgainst": "4b44b92dd2dc4b5515b6e87627fe4bdfcd6742c3",
|
|
155
155
|
"paths": [
|
|
156
156
|
"test/",
|
|
157
157
|
"scripts/check.sh",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.18",
|
|
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.
|
|
15406
|
+
version: "0.12.18",
|
|
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
|
});
|
package/src/cli/console-state.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface Approval {
|
|
|
4
4
|
id: string; peer: string; title: string; expiresAt: number;
|
|
5
5
|
options: { optionId: string; name: string; kind: string }[];
|
|
6
6
|
}
|
|
7
|
-
export interface ConsoleEvent { text: string; peer?: string; kind?: string }
|
|
7
|
+
export interface ConsoleEvent { text: string; peer?: string; kind?: string; tone?: Tone }
|
|
8
8
|
export interface ConsoleState {
|
|
9
9
|
mode: "stream" | "panels"; panel: number; selection: number; approvalIndex: number;
|
|
10
10
|
input: string; editing: boolean; history: string[]; historyIndex: number;
|
|
@@ -22,11 +22,48 @@ export function initialConsoleState(panels = false): ConsoleState {
|
|
|
22
22
|
}
|
|
23
23
|
/** Strip terminal controls before any daemon or child output reaches a terminal. Preserve printable Unicode. */
|
|
24
24
|
export function terminalText(value: unknown): string {
|
|
25
|
-
return sanitize(String(value ?? "")
|
|
25
|
+
return sanitize(String(value ?? "")
|
|
26
26
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
|
|
27
27
|
.replace(/\x1b[P_X^][\s\S]*?(?:\x1b\\|$)/g, "")
|
|
28
28
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
29
|
-
.replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
29
|
+
.replace(/\x1b[^\n]?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""));
|
|
30
|
+
}
|
|
31
|
+
export type Tone = "info" | "strong" | "success" | "attention" | "failure" | "muted";
|
|
32
|
+
export interface Span { text: string; tone?: Tone }
|
|
33
|
+
export const PALETTE: Readonly<Record<Tone, string>> = Object.freeze({
|
|
34
|
+
info: "\x1b[36m", strong: "\x1b[1;36m", success: "\x1b[32m", attention: "\x1b[33m", failure: "\x1b[31m", muted: "\x1b[90m",
|
|
35
|
+
});
|
|
36
|
+
export function paint(line: Span[], color: boolean): string {
|
|
37
|
+
return line.map(span => {
|
|
38
|
+
const text = terminalText(span.text);
|
|
39
|
+
const sgr = span.tone && Object.hasOwn(PALETTE, span.tone) ? PALETTE[span.tone] : undefined;
|
|
40
|
+
return color && sgr && text ? sgr + text + "\x1b[0m" : text;
|
|
41
|
+
}).join("");
|
|
42
|
+
}
|
|
43
|
+
export function resolveColor(flag: string | undefined, env: { isTTY: boolean; TERM?: string; NO_COLOR?: string }): boolean | Error {
|
|
44
|
+
if (flag === "always") return true;
|
|
45
|
+
if (flag === "never") return false;
|
|
46
|
+
if (flag === undefined || flag === "auto") return env.isTTY && env.TERM !== "dumb" && !env.NO_COLOR;
|
|
47
|
+
return new Error("usage: ahub console [--panels] [--color=auto|always|never]");
|
|
48
|
+
}
|
|
49
|
+
export function stateTone(state: string): Tone | undefined {
|
|
50
|
+
if (state === "idle" || state === "approved") return "success";
|
|
51
|
+
if (["busy", "paused", "in_review", "changes_requested", "ready"].includes(state)) return "attention";
|
|
52
|
+
if (["failed", "check failed", "needs_review"].includes(state)) return "failure";
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const span = (text: unknown, tone?: Tone): Span => ({ text: String(text ?? ""), ...(tone ? { tone } : {}) });
|
|
56
|
+
/** Clip only sanitized plain text, then retain the tones of the surviving prefix. */
|
|
57
|
+
function fitLine(line: Span[], columns: number): Span[] {
|
|
58
|
+
const clean = line.map(s => ({ ...s, text: terminalText(s.text).replace(/\n/g, " ") }));
|
|
59
|
+
const text = clean.map(s => s.text).join("");
|
|
60
|
+
const clipped = fit(text, columns);
|
|
61
|
+
const marker = text === clipped ? "" : ".".repeat(Math.max(0, Math.min(3, columns)));
|
|
62
|
+
let remaining = clipped.length - marker.length;
|
|
63
|
+
const out: Span[] = [];
|
|
64
|
+
for (const s of clean) { const part = s.text.slice(0, Math.max(0, remaining)); if (part) out.push({ ...s, text: part }); remaining -= part.length; }
|
|
65
|
+
if (marker) out.push(span(marker));
|
|
66
|
+
return out;
|
|
30
67
|
}
|
|
31
68
|
export function fit(value: unknown, columns: number): string {
|
|
32
69
|
const text = terminalText(value).replace(/\n/g, " ");
|
|
@@ -179,35 +216,64 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
179
216
|
return done();
|
|
180
217
|
}
|
|
181
218
|
export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
|
|
219
|
+
return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
|
|
220
|
+
}
|
|
221
|
+
export function renderConsoleLines(s: ConsoleState, columns: number, rows = 24, now = Date.now()): Span[][] {
|
|
182
222
|
const permission = s.approvals[s.approvalIndex];
|
|
183
223
|
let prompt = s.editing ? `: ${s.input}` : "q quit | Tab panels | : command | a allow d deny v view [ ] select";
|
|
184
224
|
if (s.confirm?.type === "permission") prompt = `allow ${s.confirm.option} for ${s.confirm.peer} (request ${s.confirm.id})? y/N`;
|
|
185
225
|
if (s.confirm?.type === "command") prompt = `${s.confirm.args.join(" ")}? y/N`;
|
|
186
226
|
if (s.optionChoice) prompt = s.approvals.find(a => a.id === s.optionChoice)?.options.filter(o => o.kind.startsWith("allow")).map((o, i) => `${i + 1}:${o.name}`).join(" | ") ?? "";
|
|
227
|
+
const summary: Span[] = [];
|
|
228
|
+
for (const [id, p] of Object.entries(s.peers)) {
|
|
229
|
+
if (summary.length) summary.push(span(" "));
|
|
230
|
+
summary.push(span(id, p.state === "offline" ? undefined : "info"), span(":"), span(p.state, stateTone(p.state)), span(` q${p.queued ?? 0}`));
|
|
231
|
+
if (p.needsReview) summary.push(span(` review${p.needsReview}`, "failure"));
|
|
232
|
+
if (p.paused) summary.push(span(" paused", "attention"));
|
|
233
|
+
}
|
|
187
234
|
const quotas = Object.entries(s.budget).map(([id, b]) => `${id}:${b.windows?.map((w: any) => `${w.id} ${Math.round(w.used * 100)}%`).join("/") ?? "?"}`).join(" ");
|
|
188
|
-
|
|
189
|
-
const
|
|
235
|
+
if (quotas) summary.push(span(` | ${quotas}`));
|
|
236
|
+
const approvals = [span(`${s.approvals.length} approvals`, s.approvals.length ? "attention" : undefined)];
|
|
237
|
+
if (permission) approvals.push(span(` | ${permission.peer} ${permission.id}`, "attention"), span(` ${Math.max(0, Math.ceil((permission.expiresAt - now) / 1000))}s`, "muted"));
|
|
238
|
+
if (s.notice) approvals.push(span(` | ${s.notice}`));
|
|
239
|
+
const footer = [fitLine(summary, columns), fitLine(approvals, columns), fitLine([span(prompt, s.confirm || s.optionChoice ? "attention" : undefined)], columns)];
|
|
190
240
|
if (s.mode === "stream") return footer;
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
241
|
+
const titles = ["Peers", "Approvals", "Tasks", "Queue", "Events"];
|
|
242
|
+
const title = titles[s.panel - 1];
|
|
243
|
+
const tabs = [span("agent-hub | ", "info")];
|
|
244
|
+
for (const [i, name] of titles.entries()) tabs.push(span(`${i + 1} ${name}${i < 4 ? " " : ""}`, i + 1 === s.panel ? "strong" : "info"));
|
|
245
|
+
tabs.push(span(` | ${title}`, "strong"));
|
|
246
|
+
const lines: Span[][] = [fitLine(tabs, columns), fitLine([span(`j/k move Enter detail Esc back ? keys${s.panel === 5 ? ` | f peer:${s.peerFilter ?? "all"} g kind:${s.kindFilter ?? "all"}` : ""}`)], columns)];
|
|
247
|
+
if (s.help) lines.push(...wrap("Tab stream/panels; 1-5 panel; j/k or arrows move; Enter detail; Esc back; : command; q quit. Approvals: a allow (confirm y), d deny, v title, [ ] select. Peers: p pause, r resume. Tasks: a assign (confirm y), r review. Queue: r resolve (reason and confirm y). Events: f peer filter, g kind filter.", columns).map(text => [span(text)]));
|
|
248
|
+
else if (s.detail) { const detail = wrap(s.detail, columns); lines.push(...detail.slice(Math.min(s.detailOffset, Math.max(0, detail.length - (rows - 5)))).map(text => [span(text)])); }
|
|
195
249
|
else {
|
|
196
250
|
const data = panelRows(s); const capacity = Math.max(1, s.panel === 2 ? Math.floor((rows - 6) / 2) : rows - 6); const start = Math.max(0, s.selection - capacity + 1);
|
|
197
251
|
for (const [i, row] of data.slice(start, start + capacity).entries()) {
|
|
198
|
-
|
|
252
|
+
const selected = start + i === s.selection;
|
|
253
|
+
const line = [span(`${selected ? ">" : " "} `, selected ? "strong" : undefined)];
|
|
199
254
|
if (s.panel === 1) {
|
|
200
255
|
const b = s.budget[row.id];
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
256
|
+
line.push(span(row.id, row.state === "offline" ? undefined : selected ? "strong" : "info"), span(" "), span(row.state, stateTone(row.state)),
|
|
257
|
+
span(` ${row.attached === false ? "detached" : "attached"} q:${row.queued ?? 0} `), span(`!:${row.queuedImportant ?? 0}`, row.queuedImportant ? "attention" : undefined),
|
|
258
|
+
span(` review:${row.needsReview ?? 0}`, row.needsReview ? "failure" : undefined), span(" "),
|
|
259
|
+
span(row.paused ? `paused:${JSON.stringify(row.paused)}` : "", "attention"), span(" "),
|
|
260
|
+
span(b?.paused ? `budget pause ${b.paused.reason} reset:${b.paused.resetsAt}` : "", "attention"), span(" "),
|
|
261
|
+
span(b?.windows?.map((w: any) => `${w.id}:${Math.round(w.used * 100)}% reset:${w.resetsAt ?? "?"}`).join(" ") ?? "quota:?"), span(` model:${row.servedBy ?? row.requestedModel ?? "?"}`));
|
|
262
|
+
} else if (s.panel === 2) line.push(span(`${row.id} ${row.peer}`, "attention"), span(` ${Math.max(0, Math.ceil((row.expiresAt - now) / 1000))}s`, "muted"), span(` ${row.title}`));
|
|
263
|
+
else if (s.panel === 3) {
|
|
264
|
+
const last = row.history?.at(-1);
|
|
265
|
+
const at = last?.at ?? row.updated ?? row.created;
|
|
266
|
+
const tone = last?.event === "check failed" || last?.event === "failed" ? "failure" : stateTone(row.state);
|
|
267
|
+
const failure = (last?.event === "check failed" || last?.event === "failed") && row.state !== last.event ? ` ${last.event}` : "";
|
|
268
|
+
line.push(span(`#${row.id}`, selected ? "strong" : "info"), span(" "), span(row.state, tone), span(failure, "failure"), span(row.ready ? " ready" : "", "attention"),
|
|
269
|
+
span(` ${row.owner ?? "-"} review:${row.reviewer ?? "-"} ${row.class} `), span(`age:${at ? Math.max(0, Math.floor((now - at) / 1000)) + "s" : "?"}`, "muted"), span(` ${row.title}`));
|
|
270
|
+
} else if (s.panel === 4) line.push(span(row.id, selected ? "strong" : "info"), span(` ${row.peer} `), span(row.state, stateTone(row.state)), span(` rev:${row.revision} `), span(`age:${row.createdAt ? Math.max(0, Math.floor((now - row.createdAt) / 1000)) + "s" : "?"}`, "muted"));
|
|
271
|
+
else { const [header, ...body] = String(row.text).split("\n"); line.push(span(header, row.tone)); if (body.length) line.push(span("\n" + body.join("\n"))); }
|
|
272
|
+
lines.push(fitLine(line, columns));
|
|
207
273
|
}
|
|
208
|
-
if (!data.length) lines.push("(empty)");
|
|
209
|
-
if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)));
|
|
274
|
+
if (!data.length) lines.push([span("(empty)")]);
|
|
275
|
+
if (s.panel === 2 && permission) lines.push(...wrap(permission.title, columns).slice(0, Math.max(0, rows - 3 - lines.length)).map(text => [span(text)]));
|
|
210
276
|
}
|
|
211
|
-
const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push(
|
|
277
|
+
const body = lines.slice(0, rows - 3); while (body.length < rows - 3) body.push([]);
|
|
212
278
|
return [...body, ...footer];
|
|
213
279
|
}
|
package/src/cli/console.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
3
3
|
import type { ControlClient } from "../hub/control-client.ts";
|
|
4
4
|
import { contextLine } from "./status-lines.ts";
|
|
5
5
|
import { renderTailEvent } from "./tail-render.ts";
|
|
6
|
-
import { initialConsoleState, pruneApprovals, reduceConsole,
|
|
7
|
-
import type { ConsoleEffect, ConsoleEvent } from "./console-state.ts";
|
|
6
|
+
import { initialConsoleState, paint, pruneApprovals, reduceConsole, renderConsoleLines, resolveColor, stateTone, terminalText, wrap } from "./console-state.ts";
|
|
7
|
+
import type { ConsoleEffect, ConsoleEvent, Tone } from "./console-state.ts";
|
|
8
|
+
import type { BusEvent } from "../hub/bus.ts";
|
|
8
9
|
|
|
9
10
|
export interface ConsoleTerminal {
|
|
10
11
|
columns: number; rows: number; isTTY: boolean; isRaw?: boolean;
|
|
@@ -19,11 +20,23 @@ export interface ConsoleOptions {
|
|
|
19
20
|
client: Pick<ControlClient, "request" | "send" | "close" | "onPush" | "onClose">;
|
|
20
21
|
cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
|
|
21
22
|
terminal?: ConsoleTerminal;
|
|
23
|
+
color?: boolean;
|
|
22
24
|
/** Injected command runner must use argv, closed stdin, and return a cancellation handle. */
|
|
23
25
|
runCommand?: (args: string[], output: (text: string) => void, done: () => void) => (() => void);
|
|
24
26
|
pollMs?: number;
|
|
25
27
|
}
|
|
26
28
|
export const RESTORE_CONSOLE = "\x1b[?1049l\x1b[r\x1b[0m\x1b[?25h";
|
|
29
|
+
/** Console-only styling uses event structure; tail text and message bodies are never parsed for meaning. */
|
|
30
|
+
export function eventTone(e: BusEvent): Tone | undefined {
|
|
31
|
+
if (e.t === "state") return stateTone(e.state);
|
|
32
|
+
if (e.t === "undeliverable" || e.t === "overflow") return "failure";
|
|
33
|
+
if (e.t === "envelope") {
|
|
34
|
+
if (e.dropped === "hop") return "failure";
|
|
35
|
+
if (e.env.priority === "important" || e.env.kind === "task" || e.env.kind === "review" || e.env.kind === "budget") return "attention";
|
|
36
|
+
return "info";
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
27
40
|
function nativeTerminal(): ConsoleTerminal {
|
|
28
41
|
return {
|
|
29
42
|
get columns() { return process.stdout.columns ?? 80; }, get rows() { return process.stdout.rows ?? 24; },
|
|
@@ -46,6 +59,7 @@ function nativeTerminal(): ConsoleTerminal {
|
|
|
46
59
|
/** One terminal client, no daemon ownership. All text crosses terminalText before writing. */
|
|
47
60
|
export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
48
61
|
const { client } = options; const terminal = options.terminal ?? nativeTerminal();
|
|
62
|
+
const color = options.color ?? resolveColor(undefined, { isTTY: terminal.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR }) === true;
|
|
49
63
|
let state = initialConsoleState(!!options.panels);
|
|
50
64
|
let columns = options.columns ?? terminal.columns; let rows = options.rows ?? terminal.rows;
|
|
51
65
|
let active = true; let inAlternate = false; let plain = !terminal.isTTY || columns < 80 || rows < 24;
|
|
@@ -54,15 +68,19 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
54
68
|
const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
|
|
55
69
|
let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
|
|
56
70
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
57
|
-
const safeWrite = (text: string) => terminal.write(
|
|
58
|
-
const
|
|
71
|
+
const safeWrite = (text: string) => terminal.write(paint([{ text }], color));
|
|
72
|
+
const streamLines = (event: ConsoleEvent) => terminalText(event.text).split("\n").flatMap((text, index) => wrap(text, columns).map(line => [{ text: line, ...(index === 0 && event.tone ? { tone: event.tone } : {}) }]));
|
|
73
|
+
const writeStream = (event: ConsoleEvent) => {
|
|
59
74
|
terminal.write(`\x1b[${rows - 3};1H`);
|
|
60
|
-
for (const line of
|
|
75
|
+
for (const line of streamLines(event)) { terminal.write(paint(line, color)); terminal.write("\r\n"); }
|
|
61
76
|
};
|
|
62
77
|
const stream = (event: ConsoleEvent) => {
|
|
63
78
|
state.events = [...state.events, { ...event, text: terminalText(event.text) }].slice(-1000);
|
|
64
|
-
if (plain)
|
|
65
|
-
|
|
79
|
+
if (plain) {
|
|
80
|
+
const [header, ...body] = terminalText(event.text).split("\n");
|
|
81
|
+
terminal.write(paint([{ text: header ?? "", tone: event.tone }, ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
|
|
82
|
+
}
|
|
83
|
+
else if (state.mode === "stream") writeStream(event);
|
|
66
84
|
else { pendingStream.push(event); if (pendingStream.length > 1000) { pendingStream.shift(); droppedStream++; } }
|
|
67
85
|
};
|
|
68
86
|
const draw = () => {
|
|
@@ -70,15 +88,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
70
88
|
if (state.mode === "panels" && !inAlternate) { terminal.write("\x1b[r\x1b[?1049h\x1b[2J"); inAlternate = true; }
|
|
71
89
|
else if (state.mode === "stream" && inAlternate) {
|
|
72
90
|
terminal.write(`\x1b[?1049l\x1b[1;${rows - 3}r`); inAlternate = false;
|
|
73
|
-
if (droppedStream) writeStream(`${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.`);
|
|
74
|
-
for (const event of pendingStream) writeStream(event
|
|
91
|
+
if (droppedStream) writeStream({ text: `${droppedStream} older panel-mode events omitted from console memory; inspect hub.log for the full stream.` });
|
|
92
|
+
for (const event of pendingStream) writeStream(event);
|
|
75
93
|
pendingStream = []; droppedStream = 0;
|
|
76
94
|
}
|
|
77
95
|
terminal.write(state.mode === "stream" ? `\x1b[1;${rows - 3}r` : "\x1b[r");
|
|
78
|
-
const lines =
|
|
96
|
+
const lines = renderConsoleLines(state, columns, rows);
|
|
79
97
|
const start = state.mode === "stream" ? rows - 2 : 1;
|
|
80
|
-
for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`);
|
|
81
|
-
terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(lines.at(-1) ??
|
|
98
|
+
for (const [index, line] of lines.entries()) { terminal.write(`\x1b[${start + index};1H\x1b[2K`); terminal.write(paint(line, color)); }
|
|
99
|
+
terminal.write(`\x1b[${rows};${Math.min(columns, Bun.stringWidth(paint(lines.at(-1) ?? [], false)) + 1)}H\x1b[?25h`);
|
|
82
100
|
};
|
|
83
101
|
const stop = (reason?: string) => {
|
|
84
102
|
if (!active) return;
|
|
@@ -130,7 +148,11 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
130
148
|
});
|
|
131
149
|
const effect = async (action: ConsoleEffect) => {
|
|
132
150
|
if (action.type === "exit") return stop();
|
|
133
|
-
if (action.type === "permit") {
|
|
151
|
+
if (action.type === "permit") {
|
|
152
|
+
client.send({ t: "permit", surface: "console", id: action.id, ...(action.option ? { option: action.option } : {}) });
|
|
153
|
+
if (!action.option) stream({ text: ` ! denial requested for permission ${action.id}`, kind: "permission", tone: "failure" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
134
156
|
if (action.type === "show") {
|
|
135
157
|
const result = await client.request(action.panel === 3 ? { t: "task", op: "task_show", args: { id: action.id } } : { t: "queue", op: "show", id: action.id }, 3000);
|
|
136
158
|
if (!active) return;
|
|
@@ -157,14 +179,15 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
157
179
|
client.onPush = msg => {
|
|
158
180
|
if (!active) return;
|
|
159
181
|
try {
|
|
160
|
-
if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t });
|
|
182
|
+
if (msg.t === "event") stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
|
|
161
183
|
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading)}`, peer: msg.peer, kind: "context" });
|
|
162
184
|
else if (msg.t === "notice") stream({ text: ` * ${msg.line}`, kind: "notice" });
|
|
163
185
|
else if (msg.t === "permission") {
|
|
164
|
-
stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission" });
|
|
186
|
+
stream({ text: ` ? ${msg.peer} asks permission: ${String(msg.title).replace(/\n/g, "\n | ")}\n answer with: ahub permit ${msg.id} <${msg.options.map((o: any) => `${o.optionId} (${o.name})`).join(", ")}> | deny`, peer: msg.peer, kind: "permission", tone: "attention" });
|
|
165
187
|
if (typeof msg.expiresAt === "number" && msg.expiresAt > Date.now() && !state.approvals.some(a => a.id === msg.id)) state.approvals.push(msg);
|
|
166
188
|
} else if (msg.t === "permission_closed") {
|
|
167
189
|
state.approvals = state.approvals.filter(a => a.id !== msg.id); state = pruneApprovals(state, Date.now());
|
|
190
|
+
if (msg.reason === "expired" || msg.outcome === "cancelled" || msg.outcome?.startsWith("reject")) stream({ text: ` ! ${msg.peer} permission ${msg.id} ${msg.reason ?? msg.outcome}`, peer: msg.peer, kind: "permission", tone: "failure" });
|
|
168
191
|
}
|
|
169
192
|
draw();
|
|
170
193
|
} catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
|
package/src/cli/main.ts
CHANGED
|
@@ -33,12 +33,13 @@ import { setupOllamaModel } from "./models-setup.ts";
|
|
|
33
33
|
import { unknownContext } from "../hub/context-window.ts";
|
|
34
34
|
import { backendLine, contextLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
35
35
|
import { parseSince, readEvents } from "../hub/events.ts";
|
|
36
|
-
import { formatReport, summarize } from "../hub/report.ts";
|
|
36
|
+
import { formatReport, summarize, formatTaskReport, summarizeByTask } from "../hub/report.ts";
|
|
37
37
|
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
38
38
|
import { pathWarnings } from "../hub/conflicts.ts";
|
|
39
39
|
import { classifyPeerCommand, cliCommandLabel, detectCliIdentity, peerCommandRefusal } from "./identity.ts";
|
|
40
40
|
import { recordCliAudit } from "./identity-audit.ts";
|
|
41
41
|
import { runConsole } from "./console.ts";
|
|
42
|
+
import { resolveColor } from "./console-state.ts";
|
|
42
43
|
import { renderTailEvent } from "./tail-render.ts";
|
|
43
44
|
|
|
44
45
|
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
@@ -61,7 +62,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
61
62
|
ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
|
|
62
63
|
ahub init [--dry-run --json] preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block)
|
|
63
64
|
ahub up [--unattended] [--no-console] start the daemon; interactive terminals enter console
|
|
64
|
-
ahub console [--panels]
|
|
65
|
+
ahub console [--panels] [--color=auto|always|never] enter the human console, leaving the daemon running on exit
|
|
65
66
|
ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
|
|
66
67
|
ahub restart [--dry-run] [--yes] recover this project's runtime
|
|
67
68
|
ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
|
|
@@ -96,7 +97,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
96
97
|
ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
|
|
97
98
|
ahub status | logs [-f] | doctor | kill
|
|
98
99
|
ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
|
|
99
|
-
ahub report [--since 7d|<iso>] [--json] turns, tokens, messages, overlaps and task events per period
|
|
100
|
+
ahub report [--since 7d|<iso>] [--by task] [--json] turns, tokens, messages, overlaps and task events per period
|
|
100
101
|
ahub check-path <file> [--peer <id>] other owners' open tasks that claim or changed a file
|
|
101
102
|
ahub check-path --hook the same as a Claude Code PreToolUse hook (templates/claude-hooks.json); never blocks
|
|
102
103
|
ahub facts --hook turn-free facts as a Claude Code PreToolUse, PostToolUse and Stop hook (issue #108); never blocks
|
|
@@ -491,8 +492,10 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
491
492
|
},
|
|
492
493
|
|
|
493
494
|
console: async () => {
|
|
494
|
-
if (args.some(arg => arg !== "--panels")) fail("usage: ahub console [--panels]");
|
|
495
|
-
|
|
495
|
+
if (args.some(arg => arg !== "--panels" && !arg.startsWith("--color=")) || args.filter(arg => arg.startsWith("--color=")).length > 1) fail("usage: ahub console [--panels] [--color=auto|always|never]");
|
|
496
|
+
const color = resolveColor(args.find(arg => arg.startsWith("--color="))?.slice("--color=".length), { isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY, TERM: process.env.TERM, NO_COLOR: process.env.NO_COLOR });
|
|
497
|
+
if (color instanceof Error) return fail(color.message);
|
|
498
|
+
await runConsole({ client: await connect(), cwd, stateDir, panels: args.includes("--panels"), color });
|
|
496
499
|
},
|
|
497
500
|
|
|
498
501
|
up: async () => {
|
|
@@ -825,7 +828,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
825
828
|
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
826
829
|
},
|
|
827
830
|
report: () => {
|
|
828
|
-
const
|
|
831
|
+
const by = args.indexOf("--by");
|
|
832
|
+
if (by >= 0 && args[by + 1] !== "task") return fail("--by takes task");
|
|
833
|
+
const events = readEvents(join(stateDir, "events.jsonl"), since());
|
|
834
|
+
if (by >= 0) {
|
|
835
|
+
const r = summarizeByTask(events);
|
|
836
|
+
return console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatTaskReport(r).join("\n"));
|
|
837
|
+
}
|
|
838
|
+
const r = summarize(events);
|
|
829
839
|
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
|
|
830
840
|
},
|
|
831
841
|
facts: async () => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Envelope } from "./envelope.ts";
|
|
2
|
+
|
|
3
|
+
export type Attribution = "delivery" | "single_open" | "unattributed";
|
|
4
|
+
export interface TaskAttribution { task?: number; attribution: Attribution; pii?: true }
|
|
5
|
+
|
|
6
|
+
/** Only one distinct positive task id in the original delivery can identify its turn. */
|
|
7
|
+
export function deliveryTask(originals: Envelope[]): number | undefined {
|
|
8
|
+
const ids = new Set(originals.flatMap(env => {
|
|
9
|
+
const id = Number(env.refs?.task);
|
|
10
|
+
return Number.isSafeInteger(id) && id > 0 ? [id] : [];
|
|
11
|
+
}));
|
|
12
|
+
return ids.size === 1 ? ids.values().next().value : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Rule order is deliberate: a delivered review belongs to its task, regardless of ownership. */
|
|
16
|
+
export function attribute(delivery: number | undefined, inProgress: number[]): TaskAttribution {
|
|
17
|
+
if (delivery !== undefined && Number.isSafeInteger(delivery) && delivery > 0) return { task: delivery, attribution: "delivery" };
|
|
18
|
+
if (inProgress.length === 1) return { task: inProgress[0]!, attribution: "single_open" };
|
|
19
|
+
return { attribution: "unattributed" };
|
|
20
|
+
}
|
package/src/hub/bus.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type BusEvent =
|
|
|
17
17
|
| { t: "state"; peer: PeerId; state: PeerState };
|
|
18
18
|
|
|
19
19
|
export interface BusOptions {
|
|
20
|
+
/** Observe original envelopes immediately before a transport starts its turn. */
|
|
21
|
+
onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
|
|
20
22
|
retryMs: number;
|
|
21
23
|
/** A queue this long is delivered without waiting for the batch window. */
|
|
22
24
|
batchMax: number;
|
|
@@ -125,6 +127,8 @@ export class Bus {
|
|
|
125
127
|
onQueues?: () => void;
|
|
126
128
|
/** Actual transport admission, using originals even when a digest was condensed. Never an enqueue metric. */
|
|
127
129
|
onDelivered?: (peer: PeerId, originals: Envelope[]) => void;
|
|
130
|
+
/** Before deliver's synchronous busy transition, with originals rather than a condensed digest. */
|
|
131
|
+
onDeliver?: (peer: PeerId, originals: Envelope[]) => void;
|
|
128
132
|
/** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
|
|
129
133
|
onDeliveryFailed?: (peer: PeerId) => void;
|
|
130
134
|
private recoveryHeld = false;
|
|
@@ -135,6 +139,7 @@ export class Bus {
|
|
|
135
139
|
|
|
136
140
|
constructor(opts: Partial<BusOptions> = {}) {
|
|
137
141
|
this.opts = { ...DEFAULT_BUS, ...opts };
|
|
142
|
+
this.onDeliver = opts.onDeliver;
|
|
138
143
|
this.journal = opts.journal;
|
|
139
144
|
if (this.journal) {
|
|
140
145
|
const state = this.journal.snapshot();
|
|
@@ -753,6 +758,7 @@ export class Bus {
|
|
|
753
758
|
const deliveryId = crypto.randomUUID();
|
|
754
759
|
if (this.journal) { this.durableHandoff(id, deliveryId, delivery, out); this.activeDeliveries.set(deliveryId, id); }
|
|
755
760
|
try {
|
|
761
|
+
try { this.onDeliver?.(id, delivery); } catch { /* observation must never affect delivery */ }
|
|
756
762
|
if (this.journal) await peer.deliver(out, deliveryId);
|
|
757
763
|
else { await peer.deliver(out); this.delivered(id, delivery); }
|
|
758
764
|
} catch {
|
package/src/hub/daemon.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { DEFAULT_INFERENCE, DIGEST, Inference, type InferenceConfig } from "./in
|
|
|
46
46
|
import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
|
|
47
47
|
import { currentRouting, detectSignals } from "./routing.ts";
|
|
48
48
|
import { Bus } from "./bus.ts";
|
|
49
|
+
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
49
50
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
50
51
|
import { startDashboard } from "./ui.ts";
|
|
51
52
|
import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
@@ -956,14 +957,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
956
957
|
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
957
958
|
const runId = Date.now().toString(36);
|
|
958
959
|
let turnSeq = 0;
|
|
959
|
-
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean }>();
|
|
960
|
+
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution }>();
|
|
961
|
+
const pendingDeliveryTask = new Map<PeerId, number>();
|
|
962
|
+
const taskAttribution = (peer: PeerId, delivery = turns.get(peer)?.deliveryTask): TaskAttribution => {
|
|
963
|
+
const result = attribute(delivery, board.list("in_progress").filter(t => t.owner === peer).map(t => t.id));
|
|
964
|
+
const task = result.task === undefined ? undefined : board.get(result.task);
|
|
965
|
+
return { ...result, ...(task && tasks.isPii(task) ? { pii: true as const } : {}) };
|
|
966
|
+
};
|
|
960
967
|
const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
|
|
968
|
+
bus.onDeliver = (peer, originals) => {
|
|
969
|
+
pendingDeliveryTask.delete(peer);
|
|
970
|
+
const task = deliveryTask(originals);
|
|
971
|
+
if (task !== undefined) pendingDeliveryTask.set(peer, task);
|
|
972
|
+
};
|
|
961
973
|
bus.onDelivered = (peer, originals) => {
|
|
974
|
+
pendingDeliveryTask.delete(peer);
|
|
962
975
|
if (!originals.some(env => env.from === HUB && env.refs?.supervision) || supervisionTurns.has(peer)) return;
|
|
963
976
|
const turn = turns.get(peer);
|
|
964
977
|
supervisionTurns.set(peer, { id: turn?.id ?? `supervision-${randomUUID()}`, at: turn?.start ?? Date.now(), ...(turn?.tokens ? { tokens: turn.tokens } : {}), ...(contextSession(peer) ? { session: contextSession(peer) } : {}) });
|
|
965
978
|
};
|
|
966
|
-
bus.onDeliveryFailed = peer => { supervisionTurns.delete(peer); };
|
|
979
|
+
bus.onDeliveryFailed = peer => { pendingDeliveryTask.delete(peer); supervisionTurns.delete(peer); };
|
|
967
980
|
const finishSupervisionTurn = (peer: PeerId) => {
|
|
968
981
|
const receipt = supervisionTurns.get(peer);
|
|
969
982
|
if (!receipt) return;
|
|
@@ -1000,7 +1013,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1000
1013
|
const supervisionTurn = supervisionTurns.get(peer);
|
|
1001
1014
|
if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
|
|
1002
1015
|
if (n > 0) {
|
|
1003
|
-
event({ type: "tokens", peer, n });
|
|
1016
|
+
event({ type: "tokens", peer, n, ...taskAttribution(peer) });
|
|
1004
1017
|
const turn = turns.get(peer);
|
|
1005
1018
|
if (turn) turn.tokens += n;
|
|
1006
1019
|
}
|
|
@@ -1382,7 +1395,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1382
1395
|
claudeNativeUsageSeen.add(record.id);
|
|
1383
1396
|
const reported = claudeReportedTokens(record.usage);
|
|
1384
1397
|
if (reported !== undefined) addTokens("claude", reported);
|
|
1385
|
-
event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
|
|
1398
|
+
event({ type: "usage", peer: "claude", source: "claude_transcript", id: record.id, ...record.usage, ...taskAttribution("claude"), ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.at ? { measuredAt: record.at } : {}) });
|
|
1386
1399
|
}
|
|
1387
1400
|
};
|
|
1388
1401
|
const claudeUsageTimer = setInterval(collectClaudeUsage, 2000);
|
|
@@ -1631,6 +1644,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1631
1644
|
// The adapter's own state: a pause shows a busy peer as paused, and its turn goes on all the same.
|
|
1632
1645
|
const busy = (bus.peers.get(e.peer)?.state ?? e.state) === "busy";
|
|
1633
1646
|
if (busy && !open) {
|
|
1647
|
+
const delivery = pendingDeliveryTask.get(e.peer);
|
|
1648
|
+
pendingDeliveryTask.delete(e.peer);
|
|
1649
|
+
const attribution = taskAttribution(e.peer, delivery);
|
|
1634
1650
|
const id = `${e.peer}#${runId}.${++turnSeq}`;
|
|
1635
1651
|
// A turn of a peer with a PII task open is not snapshotted: what it writes would stay in git's object store until
|
|
1636
1652
|
// gc. It is still recorded, without trees, so an overlapping turn's undo knows its changes are unknown.
|
|
@@ -1639,7 +1655,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1639
1655
|
turnTasks.set(id, board.list().filter((t) => t.owner === e.peer && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state)).map((t) => t.id));
|
|
1640
1656
|
const start = turnLog && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
1641
1657
|
try { turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
1642
|
-
turns.set(e.peer, { id, start: Date.now(), tokens: 0, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
1658
|
+
turns.set(e.peer, { id, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
1643
1659
|
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
1644
1660
|
} else if (!busy && open) {
|
|
1645
1661
|
turns.delete(e.peer);
|
|
@@ -1658,7 +1674,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1658
1674
|
if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
|
|
1659
1675
|
}
|
|
1660
1676
|
// A lost Claude channel/watchdog is not native completion; only its real Stop closes a counted turn.
|
|
1661
|
-
if (e.peer !== "claude" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
|
|
1677
|
+
if (e.peer !== "claude" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...open.attribution, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
|
|
1662
1678
|
if (e.peer !== "claude") { // Claude's native turn end is its Stop hook
|
|
1663
1679
|
if ((bus.peers.get(e.peer)?.state ?? e.state) === "idle") queueMicrotask(() => finishSupervisionTurn(e.peer));
|
|
1664
1680
|
turnEnded.set(e.peer, Date.now());
|
|
@@ -2087,7 +2103,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2087
2103
|
const local = new LocalPeer("local", {
|
|
2088
2104
|
cwd: opts.cwd,
|
|
2089
2105
|
omni,
|
|
2090
|
-
onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
|
|
2106
|
+
onUsage: record => event({ type: "usage", peer: "local", source: "omniroute", id: record.id, ...record.usage, ...taskAttribution("local", record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
|
|
2091
2107
|
admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
|
|
2092
2108
|
...(route ? { route } : {}),
|
|
2093
2109
|
...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
|
package/src/hub/events.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
import type { TaskAttribution } from "./attribution.ts";
|
|
2
3
|
|
|
3
4
|
/** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
|
|
4
5
|
export const EVENTS_SCHEMA = 1;
|
|
@@ -23,7 +24,7 @@ export type HubEvent =
|
|
|
23
24
|
| { type: "split"; task: number; where?: "routing" | "cohort"; verdict: "split" | "single" | "unknown"; single?: string; splitS?: number; singleS?: number; reason?: string; trace?: string[] }
|
|
24
25
|
| { type: "state"; peer: string; state: string }
|
|
25
26
|
| { type: "turn_start"; peer: string; turn: string }
|
|
26
|
-
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|
|
27
|
+
| ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number } & Partial<TaskAttribution>)
|
|
27
28
|
| { type: "native_turn_end"; peer: string; id?: string }
|
|
28
29
|
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
29
30
|
| { type: "agent_cli"; peer: string; command: string; refused: boolean }
|
|
@@ -32,8 +33,8 @@ export type HubEvent =
|
|
|
32
33
|
| { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
|
|
33
34
|
| { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
|
|
34
35
|
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
35
|
-
| { type: "tokens"; peer: string; n: number }
|
|
36
|
-
| { type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number }
|
|
36
|
+
| ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
|
|
37
|
+
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript"; id: string; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
37
38
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
38
39
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
39
40
|
| { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
|
package/src/hub/report.ts
CHANGED
|
@@ -207,3 +207,112 @@ export function formatReport(r: Report): string[] {
|
|
|
207
207
|
lines.push(`quota readings: ${r.quota.readings} (${r.quota.hard} hard limits)`);
|
|
208
208
|
return lines;
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
const usageCounters = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens"] as const;
|
|
212
|
+
const usageCounts = ["inputRecords", "outputRecords", "cacheReadRecords", "cacheWriteRecords", "totalRecords"] as const;
|
|
213
|
+
type TaskUsage = Omit<UsageCoverage, typeof usageCounters[number]> & Record<typeof usageCounters[number], number | null>;
|
|
214
|
+
export interface TaskPeerReport { tokens: number | null; usage: TaskUsage }
|
|
215
|
+
export interface TaskTotals { turns: number; peers: Record<string, TaskPeerReport> }
|
|
216
|
+
export interface TaskSummary extends TaskTotals { class: string | null; outcome: string | null; wallMs: number | null; pii?: true }
|
|
217
|
+
export interface TaskReport {
|
|
218
|
+
from?: string;
|
|
219
|
+
to?: string;
|
|
220
|
+
tasks: Record<string, TaskSummary>;
|
|
221
|
+
classes: Record<string, TaskTotals>;
|
|
222
|
+
totals: { tokens: number; usageRecords: number };
|
|
223
|
+
unattributed: TaskTotals & { tokens: number; usageRecords: number; tokenShare: number | null; usageShare: number | null };
|
|
224
|
+
beforeAttribution: TaskTotals & { tokens: number; usageRecords: number };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const taskTotals = (): TaskTotals => ({ turns: 0, peers: {} });
|
|
228
|
+
const taskPeer = (group: TaskTotals, id: string): TaskPeerReport => group.peers[id] ??= {
|
|
229
|
+
tokens: null, usage: { ...emptyUsage(), inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, totalTokens: null },
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
function addTaskUsage(peer: TaskPeerReport, e: Extract<StampedEvent, { type: "usage" }>): void {
|
|
233
|
+
const known = emptyUsage();
|
|
234
|
+
recordUsage(known, e);
|
|
235
|
+
const u = peer.usage;
|
|
236
|
+
u.records += known.records; u.withUsage += known.withUsage; u.withoutUsage += known.withoutUsage;
|
|
237
|
+
for (let i = 0; i < usageCounters.length; i++) {
|
|
238
|
+
const field = usageCounters[i]!, count = usageCounts[i]!;
|
|
239
|
+
if (known[count]) u[field] = (u[field] ?? 0) + known[field];
|
|
240
|
+
u[count] += known[count];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Task identities and history come only from telemetry. Historical usage is never reassigned. */
|
|
245
|
+
export function summarizeByTask(events: StampedEvent[]): TaskReport {
|
|
246
|
+
const r: TaskReport = { tasks: {}, classes: {}, totals: { tokens: 0, usageRecords: 0 },
|
|
247
|
+
unattributed: { ...taskTotals(), tokens: 0, usageRecords: 0, tokenShare: null, usageShare: null },
|
|
248
|
+
beforeAttribution: { ...taskTotals(), tokens: 0, usageRecords: 0 } };
|
|
249
|
+
const task = (id: number) => r.tasks[String(id)] ??= { ...taskTotals(), class: null, outcome: null, wallMs: null };
|
|
250
|
+
const starts = new Map<number, number>(), approvals = new Map<number, number>();
|
|
251
|
+
const seenUsage = new Set<string>();
|
|
252
|
+
// Resolve latest class/outcome before rolling usage up: usage may precede the task's history in a slice.
|
|
253
|
+
for (const e of events) {
|
|
254
|
+
r.from ??= e.at; r.to = e.at;
|
|
255
|
+
if (e.type !== "task") continue;
|
|
256
|
+
const t = task(e.id);
|
|
257
|
+
t.class = e.class; t.outcome = e.state;
|
|
258
|
+
if (e.pii) t.pii = true;
|
|
259
|
+
const at = Date.parse(e.at);
|
|
260
|
+
if (!Number.isFinite(at)) continue;
|
|
261
|
+
if (e.state === "in_progress" && !starts.has(e.id)) starts.set(e.id, at);
|
|
262
|
+
if (e.state === "approved" && !approvals.has(e.id)) approvals.set(e.id, at);
|
|
263
|
+
}
|
|
264
|
+
for (const [id, t] of Object.entries(r.tasks)) {
|
|
265
|
+
const start = starts.get(Number(id)), end = approvals.get(Number(id));
|
|
266
|
+
if (t.outcome === "approved" && start !== undefined && end !== undefined && end >= start) t.wallMs = end - start;
|
|
267
|
+
r.classes[t.class ?? "unknown"] ??= taskTotals();
|
|
268
|
+
}
|
|
269
|
+
for (const e of events) {
|
|
270
|
+
if (e.type !== "tokens" && e.type !== "usage" && e.type !== "turn_end") continue;
|
|
271
|
+
if (e.type === "usage") {
|
|
272
|
+
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
273
|
+
if (seenUsage.has(key)) continue;
|
|
274
|
+
seenUsage.add(key);
|
|
275
|
+
r.totals.usageRecords++;
|
|
276
|
+
} else if (e.type === "tokens") r.totals.tokens += e.n;
|
|
277
|
+
let groups: TaskTotals[];
|
|
278
|
+
if (e.attribution === undefined) {
|
|
279
|
+
groups = [r.beforeAttribution];
|
|
280
|
+
if (e.type === "tokens") r.beforeAttribution.tokens += e.n;
|
|
281
|
+
if (e.type === "usage") r.beforeAttribution.usageRecords++;
|
|
282
|
+
} else if (e.attribution === "unattributed" || e.task === undefined) {
|
|
283
|
+
groups = [r.unattributed];
|
|
284
|
+
if (e.type === "tokens") r.unattributed.tokens += e.n;
|
|
285
|
+
if (e.type === "usage") r.unattributed.usageRecords++;
|
|
286
|
+
} else {
|
|
287
|
+
const t = task(e.task);
|
|
288
|
+
if (e.pii) t.pii = true;
|
|
289
|
+
groups = [t, r.classes[t.class ?? "unknown"] ??= taskTotals()];
|
|
290
|
+
}
|
|
291
|
+
for (const group of groups) {
|
|
292
|
+
const p = taskPeer(group, e.peer);
|
|
293
|
+
if (e.type === "turn_end") group.turns++;
|
|
294
|
+
else if (e.type === "tokens") p.tokens = (p.tokens ?? 0) + e.n;
|
|
295
|
+
else addTaskUsage(p, e);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
r.unattributed.tokenShare = r.totals.tokens ? r.unattributed.tokens / r.totals.tokens : null;
|
|
299
|
+
r.unattributed.usageShare = r.totals.usageRecords ? r.unattributed.usageRecords / r.totals.usageRecords : null;
|
|
300
|
+
return r;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function formatTaskReport(r: TaskReport): string[] {
|
|
304
|
+
const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
|
|
305
|
+
const peers = (group: TaskTotals) => Object.entries(group.peers).sort(([a], [b]) => a.localeCompare(b)).map(([id, p]) => {
|
|
306
|
+
const u = p.usage;
|
|
307
|
+
const counts = usageCounters.map((field, i) => `${field} ${u[field] ?? "unknown"} (${u[usageCounts[i]!]}/${u.records} known)`).join(", ");
|
|
308
|
+
return ` peer ${id}: tokens ${p.tokens ?? "unknown"}; ${counts}`;
|
|
309
|
+
});
|
|
310
|
+
for (const [id, t] of Object.entries(r.tasks).sort(([a], [b]) => Number(a) - Number(b))) {
|
|
311
|
+
lines.push(`task #${id}${t.pii ? " [pii]" : ""}: class ${t.class ?? "unknown"}, outcome ${t.outcome ?? "unknown"}, turns ${t.turns}, wall ${t.wallMs === null ? "unknown" : `${t.wallMs} ms`}`, ...peers(t));
|
|
312
|
+
}
|
|
313
|
+
for (const [name, group] of Object.entries(r.classes).sort(([a], [b]) => a.localeCompare(b))) lines.push(`class ${name}: turns ${group.turns}`, ...peers(group));
|
|
314
|
+
const share = (value: number | null) => value === null ? "unknown" : `${(value * 100).toFixed(1)}%`;
|
|
315
|
+
lines.push(`unattributed: tokens ${r.unattributed.tokens}/${r.totals.tokens} (${share(r.unattributed.tokenShare)}), usage records ${r.unattributed.usageRecords}/${r.totals.usageRecords} (${share(r.unattributed.usageShare)}), turns ${r.unattributed.turns}`, ...peers(r.unattributed));
|
|
316
|
+
lines.push(`before attribution: tokens ${r.beforeAttribution.tokens}, usage records ${r.beforeAttribution.usageRecords}, turns ${r.beforeAttribution.turns}`, ...peers(r.beforeAttribution));
|
|
317
|
+
return lines;
|
|
318
|
+
}
|