@staix/agent-hub 0.7.10 → 0.8.0

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
@@ -2,7 +2,15 @@
2
2
 
3
3
  Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the previous repository, archived on 2026-09-30 when this repository's history was rewritten; the one exception is the open smoke-check issue, formerly #12, which moved here as #1. Numbers in newer entries refer to this repository.
4
4
 
5
- ## Unreleased
5
+ ## 0.8.0
6
+
7
+ - Plans and completed-change notices: `hub_task_propose` and `hub_task_accept` take a `plan` (paths, symbols, signatures, insertion points), overlaps also count plan paths and shared symbols, the owners of overlapping tasks get the plan as a ride-along line, and when a task is done they get a notice of the changed files, signatures and summary. PII tasks are left out on both sides, and a plan matching a PII pattern makes a new task a PII task or is refused on an ordinary one. Overlap mentions, ride-alongs, completed-change notices and overlap events leave out any name that matches a PII pattern, and model-written titles, refs and plan items are folded onto one line. During a PII turn the local worker can no longer finish, review or accept-with-plan an ordinary task, which would carry its words to other peers and claude-mem (#31).
8
+ - Per-turn snapshots and undo: in a git work tree, with `snapshots.enabled`, which is on in any project with an `.agenthub/config.json`, existing ones included (off without a config file; `"snapshots": { "enabled": false }` opts out), the hub snapshots the files at each turn boundary, `ahub turns` lists the files each turn changed (shell-made changes included), and `ahub undo <turn> --yes` restores them; it restores nothing when a file changed again since, or when another peer's turn at the same time touched it or left its changes unknown. `--context` also asks Codex to drop the turn from its thread's saved history; whether a running Codex session forgets it too is still to be checked live (#33).
9
+ - Structured telemetry: the hub writes `events.jsonl` (envelopes without bodies, peer states, turns with per-turn tokens for Kimi and Codex, board changes, overlaps, quota readings), and `ahub export` and `ahub report` read it (#40).
10
+
11
+ ## 0.7.11
12
+
13
+ - A project whose path contains a backslash works with the local worker and Pi again: Bun 1.3.14's `realpathSync` throws for such paths, so the sandbox profile, the file tools' path guard and the Pi resume check now resolve paths through `realPath`, which then asks the system `realpath`; it returns each name as stored on disk, so another spelling of `.git/config` or `.env` is still refused (#26).
6
14
 
7
15
  ## 0.7.10
8
16
 
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.7.10, control protocol 10. Durable delivery records distinguish queued
7
+ Status: 0.8.0, control protocol 10. 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 && ahub tail
28
28
  Or install the same version from GitHub:
29
29
 
30
30
  ```bash
31
- bun add -g github:STAIxBWLB/agent-hub#v0.7.10 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.8.0 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
package/docs/events.md ADDED
@@ -0,0 +1,37 @@
1
+ # Hub events (`events.jsonl`)
2
+
3
+ Schema version 1. The hub appends one JSON object per line to
4
+ `.agenthub/state/events.jsonl`, next to `hub.log`. `hub.log` is for people; this
5
+ file is for tools (`ahub export`, `ahub report`, research measurements). The
6
+ version is bumped only when a field changes meaning or goes away; new fields and
7
+ new event types do not bump it.
8
+
9
+ Every event has `v` (schema version), `at` (ISO time) and `type`. No event ever
10
+ carries a message body or a task title or detail. Private (PII) envelopes are
11
+ marked `private: true`, and PII tasks `pii: true`.
12
+
13
+ | type | fields |
14
+ |---|---|
15
+ | `envelope` | `id`, `from`, `to` (absent for broadcast), `priority`, `hop`, `kind`, `task` (task id from refs), `bytes` (UTF-8 size of the body; absent on a private envelope, whose size would say something about the PII text), `private`, `dropped` (`hop` or `fyi` when not delivered) |
16
+ | `overflow`, `undeliverable` | `id`, `from`, `peer` |
17
+ | `state` | `peer`, `state` |
18
+ | `turn_start` | `peer`, `turn` (`<peer>#<hub run>.<n>`, unique across restarts). A turn follows the adapter: pausing a busy peer does not end it |
19
+ | `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) |
20
+ | `tokens` | `peer`, `n` (tokens added since the previous report) |
21
+ | `task` | `id`, `event` (the board history event, e.g. `proposed`, `assigned`, `done`, `check failed`), `by`, `state`, `owner`, `reviewer`, `class`, `pii` |
22
+ | `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 |
23
+ | `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) |
24
+
25
+ Token usage by adapter:
26
+
27
+ - Kimi (ACP `usage_update`): recorded.
28
+ - Codex (app-server `thread/tokenUsage/updated`): recorded as the growth of the thread's running total, so
29
+ compaction estimates, usage-limit refreshes and the replay to a reattaching connection add nothing. A thread
30
+ started under the hub counts from zero; a resumed thread's first update is its history and only sets the
31
+ baseline. The model call of a compaction itself is real usage and counts.
32
+ - Claude: not recorded. The status line tee carries quota percentages only; per-turn
33
+ tokens would need transcript parsing.
34
+ - Pi and the local worker: not recorded.
35
+
36
+ The file is local and never uploaded. It grows without rotation; delete it to start
37
+ over (the hub recreates it).
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.7.10 and control protocol 10. Live verification
3
+ This guide describes ahub 0.8.0 and control protocol 10. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
6
  ## Install and start
@@ -89,9 +89,16 @@ the routing configuration.
89
89
 
90
90
  An agent can claim work nobody assigned it by proposing a task with itself as
91
91
  owner; without a class, and with no model to name one, the claim is filed as
92
- `implement`. When a task's paths overlap another owner's open task, the newcomer
93
- is told to settle it and the earlier owner gets one line with its next message,
94
- at no turn of its own. An owner offline longer than `tasks.release_after_min`
92
+ `implement`. A claim or an accept can carry a plan: the files, symbols and
93
+ signatures it will change and where new code goes (`ahub task show <id>` prints
94
+ it). When a task's paths, or its plan's paths or symbols, overlap another
95
+ owner's open task, the newcomer is told to settle it and the earlier owner gets
96
+ one line with its next message, the plan included, at no turn of its own. When a
97
+ task is done (after its check passes, when one is configured), the owners of
98
+ open tasks on the same paths or symbols get a message with the changed files,
99
+ the plan's signatures and the first line of the summary, each left out when it
100
+ matches a PII pattern; nobody else does. PII
101
+ tasks are left out on both sides. An owner offline longer than `tasks.release_after_min`
95
102
  (default 30, `0` turns it off) in `.agenthub/config.json` loses its open tasks
96
103
  to a peer routing can give them to; with nobody to take them they stay, and a
97
104
  paused peer or a hub in a recovery operation is left alone.
@@ -109,6 +116,75 @@ to attached peers. `[IMPORTANT]` can bypass batching where the peer supports
109
116
  it; `[STATUS]` may batch, and `[FYI]` is recorded without follow-on
110
117
  delivery. A delivery or answer is not a task completion receipt.
111
118
 
119
+ ## Telemetry: export and report
120
+
121
+ The hub writes structured events to `.agenthub/state/events.jsonl` alongside
122
+ `hub.log` (schema: `docs/events.md`). They carry ids, routing, sizes, states and
123
+ token counts, never message bodies or task titles.
124
+
125
+ ```bash
126
+ ahub report --since 7d # turns, busy time and tokens per peer, messages, overlaps, task events
127
+ ahub report --since 7d --json # the same numbers as JSON
128
+ ahub export --since 24h # the raw events as JSON lines, for your own analysis
129
+ ```
130
+
131
+ `ahub report` counts the same overlap warnings as `scripts/overlaps.ts`, from the
132
+ structured events instead of log lines.
133
+
134
+ ## Turns and undo
135
+
136
+ In a git work tree the hub snapshots the project's tracked and unignored files (the
137
+ project directory only, when it is part of a larger repository) when a peer
138
+ turns busy and again when it stops. The snapshots are git tree objects written
139
+ through a temporary index, so your index, HEAD and branches stay as they are. The
140
+ files a turn changed are the difference between its two snapshots, which counts
141
+ changes made by shell commands as well as edits. The last 20 turns per peer are
142
+ kept (`"snapshots": { "enabled": true, "keep": 20 }` in `.agenthub/config.json`).
143
+ Snapshots are on in any project with a config file, including one written before
144
+ 0.8.0 without a `snapshots` block, and off without one; set `"enabled": false`
145
+ to opt out.
146
+
147
+ ```bash
148
+ ahub turns # recent turns of every peer and the files each changed
149
+ ahub turns codex --limit 5
150
+ ahub undo <turn> # lists what it would restore; changes nothing
151
+ ahub undo <turn> --yes # puts those files back as they were when the turn started
152
+ ahub undo <turn> --yes --context # Codex's latest turn: also drop it from Codex's conversation
153
+ ```
154
+
155
+ - A turn's files are everything that changed in the project while it ran,
156
+ whoever changed them: the hub cannot tell Claude's or your edits made during
157
+ the turn from the peer's own.
158
+ - `ahub undo` refuses the whole turn, naming the files, when a file it changed
159
+ has changed again since the turn ended (by anyone; modes, symlinks and a
160
+ directory in a deleted file's place count), or when another peer's turn that
161
+ ran at the same time changed it too, so the change may be theirs. It also
162
+ refuses while such an overlapping turn's changes are unknown (still running,
163
+ cut short by a stop, a failed snapshot, a PII turn, or pruned records).
164
+ Nothing is restored then. It plans again right before restoring and stops if
165
+ anything moved meanwhile. A turn that is already undone says so.
166
+ - A file the turn created is deleted; a file it deleted comes back.
167
+ - `--context` asks Codex (`thread/revert`) to drop the turn, and every later one,
168
+ from its thread's saved history before the files are restored; Codex receives
169
+ nothing while that runs. It changes no file itself, works only on Codex's
170
+ latest recorded turn while Codex is idle, and is logged in hub.log. Whether
171
+ Codex's running session also forgets the turn, or only its saved history, is
172
+ still to be checked against a live Codex (docs/smoke.md).
173
+ - A turn of a peer that holds an open PII task (assigned, accepted or sent
174
+ back) is not snapshotted, so it cannot be undone. Another peer's turn that
175
+ starts or ends meanwhile snapshots the whole project, a PII file the worker
176
+ has not removed yet included, and a PII file left in the project is
177
+ snapshotted by later turns like any other file. Keep PII files in an ignored
178
+ directory.
179
+ - A turn the hub stopped in the middle of shows as `(no end snapshot)` and
180
+ cannot be undone.
181
+ - Claude's turns are not recorded: its channel shows the hub no turn boundary.
182
+ Claude Code's own checkpoints cover its edits, though not its shell commands.
183
+ - The snapshots are unreferenced objects in the repository's own object store, so
184
+ `git gc` prunes them after `gc.pruneExpire` (two weeks by default); undoing an
185
+ older turn says so.
186
+ - Each `turn_end` event carries `files` and `snapshotMs` (`ahub export`).
187
+
112
188
  ## Approvals and pauses
113
189
 
114
190
  Inspect permission requests in the terminal:
@@ -226,8 +302,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
226
302
  project directory, without replacing the global CLI first:
227
303
 
228
304
  ```bash
229
- bunx --package @staix/agent-hub@0.7.10 ahub upgrade --to 0.7.10 --dry-run
230
- bunx --package @staix/agent-hub@0.7.10 ahub upgrade --to 0.7.10 --yes
305
+ bunx --package @staix/agent-hub@0.8.0 ahub upgrade --to 0.8.0 --dry-run
306
+ bunx --package @staix/agent-hub@0.8.0 ahub upgrade --to 0.8.0 --yes
231
307
  ```
232
308
 
233
309
  | Running now | Coordinator to use |
@@ -249,19 +325,19 @@ The coordinator verifies and retains the exact target package, preserves its
249
325
  own source, and promotes the global CLI only after restored projects pass
250
326
  readback.
251
327
 
252
- Once the installed CLI is 0.7.10, review the current project or all registered
328
+ Once the installed CLI is 0.8.0, review the current project or all registered
253
329
  projects first:
254
330
 
255
331
  ```bash
256
332
  ahub restart --dry-run
257
- ahub upgrade --to 0.7.10 --dry-run
333
+ ahub upgrade --to 0.8.0 --dry-run
258
334
  ```
259
335
 
260
336
  Apply only after reviewing the plan:
261
337
 
262
338
  ```bash
263
339
  ahub restart --yes
264
- ahub upgrade --to 0.7.10 --yes
340
+ ahub upgrade --to 0.8.0 --yes
265
341
  ahub recovery status <operation-id>
266
342
  ahub recovery resume <operation-id>
267
343
  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.7.10
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.8.0
21
21
  ahub setup
22
22
  ```
23
23
 
package/docs/security.md CHANGED
@@ -8,6 +8,8 @@ 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 touch no file and run no process, and every call passes the hub's own checks. The match relies on the agent putting the tool name in the request's `title`, as Kimi does; an ACP agent that titles calls with model-written text must not be configured as `kimi_cmd`. 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`) 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.
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.
11
13
  - **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not: no writes outside the project, its git dir and temp; the home directory is unreadable except toolchains; no `.git/hooks` or `.git/config` writes; no network, loopback included. Without the sandbox there is no `bash` tool.
12
14
  - **PII has an enforced path.** A task matching `signals.pii_patterns` goes to `local` or to nobody; its text is absent from other peers' envelopes, the console stream, the log and the board listing; the console user reviews it; `local` answers such a turn to the console only, keeps it out of its history, refuses it when the only gateway is off campus, and may not save notes or spin off tasks during it. Nothing about it is sent to claude-mem, whose observer is a cloud model.
13
15
  - **Secrets stay where they are read.** The gateway key and Cloudflare Access values are read inside the gateway client and go only into request headers: never into logs, errors, envelopes, tool output, memory, or the generated Switchyard config (the key travels by environment variable name). Subscription logins of Claude, Codex and Kimi are never proxied or pooled.
package/docs/smoke.md CHANGED
@@ -847,3 +847,15 @@ the claim overlap warnings (0.7.6 and later), then the owner decides in issue #8
847
847
  | Week of | Warnings | Task pairs | Pairs with a conflicting edit | Notes |
848
848
  |---|---|---|---|---|
849
849
  | 2026-09-28 | 0 | 0 | 0 | baseline counted on 2026-09-30 over every registered project; the issue #68 live run's project was already removed |
850
+
851
+ ## Per-turn snapshots (issue #33, 2026-10-01)
852
+
853
+ - AC1, measured on a clone of this repository (156 tracked files; Apple M5 Max,
854
+ git 2.55.0) with 10 turns that each change three files: a snapshot takes 16.8 ms
855
+ median, 47.6 ms max; each turn adds 68 KiB of loose objects. A turn that changes
856
+ nothing writes no objects.
857
+ - Live leg, pending: `ahub undo <turn> --yes --context` against a real Codex TUI,
858
+ checking that the TUI drops the reverted turn from its view on `thread/reverted`,
859
+ and that Codex's running session no longer knows the turn (ask it about the
860
+ reverted turn): the schema says `thread/revert` changes only the saved history.
861
+
@@ -839,3 +839,85 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
839
839
  start, `ahub codex` and `ahub models` print it, `ahub doctor` shows a row.
840
840
  `ahub init` adds `.agenthub/config.local.json` to `.gitignore`.
841
841
  - `routing.toml` and the shared fields still come from the checkout.
842
+
843
+ ## Amendment: telemetry export (issue #40)
844
+
845
+ - The daemon appends structured events to `.agenthub/state/events.jsonl` (schema
846
+ version 1, `docs/events.md`): envelopes (no bodies), overflow and undeliverable,
847
+ peer states, turns with per-turn tokens where the adapter reports them (Kimi,
848
+ Codex), board changes (ids and states, no titles), overlap records and quota
849
+ readings. Writes never throw.
850
+ - `ahub export [--since]` prints the events; `ahub report [--since] [--json]`
851
+ summarizes them. Both read the file directly, so the control protocol is
852
+ unchanged. Bodies are never exported (the issue's `--with-bodies` option was
853
+ dropped: the file never holds them).
854
+ - Codex tokens are the growth of each thread's `total`, with the baseline kept in
855
+ the adapter: Codex 0.156.1 also sends `thread/tokenUsage/updated` for
856
+ compaction, usage-limit refreshes and a replay to a reattaching connection,
857
+ where `last` is not new usage. A thread adopted from `thread/start` counts from
858
+ zero; one adopted from `thread/resume` takes its first total as the baseline.
859
+ Kimi reports a session total, which is turned into increments per session.
860
+ - Not in schema 1: per-delivery events and delivery-journal transitions, which
861
+ the issue's design listed. `ahub queue list` and hub.log keep them; a later
862
+ schema version can add them without changing the meaning of a field.
863
+
864
+ ## Amendment: per-turn snapshots and undo (issue #33)
865
+
866
+ - Backend: git tree objects only, written through a copy of the index
867
+ (`GIT_INDEX_FILE`) with `add -A` minus `.agenthub/state`, then `write-tree`. The
868
+ user's index, HEAD and refs are untouched. The issue's APFS `clonefile` and
869
+ reflink backends were not built: on this repository a snapshot takes 17 ms
870
+ (median) and a three-file turn adds 68 KiB of loose objects, and git objects
871
+ already deduplicate unchanged files.
872
+ - Snapshots are taken inside the state transition, before the peer is handed its
873
+ prompt. Turn records (`turns` table in hub.db) keep the last `snapshots.keep`
874
+ per peer. `DEFAULT_CONFIG` has snapshots off, like approvals.notify; a project
875
+ config turns them on.
876
+ - `ahub undo` refuses the whole turn when any of its files changed since it
877
+ ended (a file-by-file partial undo would leave a mixed state).
878
+ - Codex: the method is `thread/revert {threadId, beforeTurnId}` (codex-cli
879
+ 0.156.1; there is no `thread/rollback`). It changes conversation history only.
880
+ The CLI asks for it with `--context` (off by default) through the console task
881
+ op `turn_revert`, a new op name on an existing message shape.
882
+ - Claude's turns are not recorded: the channel has no turn boundary.
883
+ - Not built from the issue's design: a disk cap (the objects stay until `git gc`;
884
+ `snapshots.keep` prunes only the records) and 0700 snapshots (the objects carry
885
+ the repository's own permissions).
886
+ - After review: the undo plan reads the current state with one more snapshot
887
+ and refuses a path when anything at or under it differs from the turn's end
888
+ tree, or when another peer's turn that overlapped this one in time changed it;
889
+ paths reach git as `:(literal)` pathspecs; the plan is made again right before
890
+ restoring; snapshots cover the project prefix only and run with a 10 s git
891
+ timeout; turns of a peer holding an open PII task (any state before review)
892
+ are recorded without snapshots; turns left open by a stopped hub are closed at
893
+ the next start without an end snapshot; undo refuses while an overlapping
894
+ turn's changes are unknown; removals run before restorations (a case-only
895
+ rename on a case-insensitive disk); Codex receives nothing while
896
+ `thread/revert` runs.
897
+
898
+ ## Amendment: plans and completed-change notices (issue #31)
899
+
900
+ - A plan is a JSON column on `tasks` (`paths`, `symbols`, `signatures`,
901
+ `insertion_points`, each a list of short strings), not part of `refs`: refs ride
902
+ on every task envelope. A plan on accept replaces the old one whole. Boards
903
+ from before 0.8 get the column on open.
904
+ - A plan's text counts for PII detection when a task is proposed. On accept the
905
+ task's signals are fixed, so a plan that matches a PII pattern is refused for
906
+ an ordinary task.
907
+ - Overlap: paths from refs and plan (same file or a directory of it), or the
908
+ same symbol named in both plans. An overlap that only the new plan reveals on
909
+ accept is announced like one found at assignment (console notice and overlap
910
+ event); one already announced is not announced again, but the other owner
911
+ still gets the new plan.
912
+ - The completed-change notice is a normal-priority `task` envelope of its own,
913
+ not a ride-along line: an owner in the middle of those files needs it before
914
+ its next task arrives. It lists the files from refs and plan and the plan's
915
+ signatures, as declared; the hub does not read the diff.
916
+ - After review: during a PII turn, `hub_task_done`, `hub_review` and
917
+ `hub_task_accept` with a plan are refused for an ordinary task (its plan,
918
+ summary or note would reach other peers and claude-mem), as `hub_remember`
919
+ and `hub_task_propose` already were. Model-written refs and plan items are one
920
+ line (whitespace collapses), so none can forge a hub.log line; a `null` or
921
+ empty plan on accept keeps the plan there is. The completed-change notice
922
+ leaves out any file, signature or summary line that matches a PII pattern.
923
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.7.10",
3
+ "version": "0.8.0",
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.7.10",
3
+ "version": "0.8.0",
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",
@@ -15727,7 +15727,7 @@ class ControlClient {
15727
15727
  // package.json
15728
15728
  var package_default = {
15729
15729
  name: "@staix/agent-hub",
15730
- version: "0.7.10",
15730
+ version: "0.8.0",
15731
15731
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15732
15732
  license: "MIT",
15733
15733
  type: "module",
@@ -15797,14 +15797,26 @@ var refs = {
15797
15797
  properties: { branch: str, commit: str, paths: { type: "array", items: str } },
15798
15798
  additionalProperties: false
15799
15799
  };
15800
+ var list = (description) => ({ type: "array", items: str, description });
15801
+ var plan = {
15802
+ type: "object",
15803
+ description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
15804
+ properties: {
15805
+ paths: list("files you will edit or create"),
15806
+ symbols: list("functions, types or other names you will change, as they appear in code (e.g. Bus.publish)"),
15807
+ signatures: list("new or changed signatures, as you will write them"),
15808
+ insertion_points: list("where new code goes (file and the function or line it follows)")
15809
+ },
15810
+ additionalProperties: false
15811
+ };
15800
15812
  var tool = (name, description, properties, required2 = []) => ({
15801
15813
  name,
15802
15814
  description,
15803
15815
  inputSchema: { type: "object", properties, required: required2, additionalProperties: false }
15804
15816
  });
15805
15817
  var TASK_TOOLS = [
15806
- tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
15807
- tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
15818
+ tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
15819
+ tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
15808
15820
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
15809
15821
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
15810
15822
  tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
@@ -15815,7 +15827,7 @@ var TASK_TOOLS = [
15815
15827
  var TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
15816
15828
  var ROLE_TEXT = {
15817
15829
  planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs) instead of doing everything yourself.",
15818
- implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs.",
15830
+ implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
15819
15831
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
15820
15832
  reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix."
15821
15833
  };
@@ -31,7 +31,7 @@ export interface AcpOptions {
31
31
  /** Appended to the standing instruction of the first delivery (role contract). */
32
32
  preamble?: string;
33
33
  /** The session's running token total from `usage_update` (inferred shape: totalTokens, else input + output, else `used`). Cumulative, not a delta. */
34
- onTokens?: (sessionTotal: number) => void;
34
+ onTokens?: (sessionTotal: number, sessionId: string) => void;
35
35
  /** Resolve with an optionId, or undefined to cancel. Absent = every request is cancelled.
36
36
  * A request whose payload could not be resolved is titled as such and carries no session-wide allow option. */
37
37
  onPermission?: (req: PermissionRequest) => Promise<string | undefined>;
@@ -212,7 +212,7 @@ export class AcpPeer extends BasePeer {
212
212
  const f = { ...u, ...(typeof u.usage === "object" ? u.usage : {}) } as Record<string, unknown>;
213
213
  const num = (k: string) => (typeof f[k] === "number" ? (f[k] as number) : 0);
214
214
  const total = num("totalTokens") || num("total_tokens") || num("inputTokens") + num("outputTokens") || num("input_tokens") + num("output_tokens") || num("used");
215
- if (total > 0) this.opts.onTokens(total);
215
+ if (total > 0) this.opts.onTokens(total, this.sessionId);
216
216
  }
217
217
  } else if (msg.method === "session/request_permission") {
218
218
  void this.answerPermission(msg);
@@ -18,6 +18,10 @@ export interface CodexOptions {
18
18
  preamble?: string;
19
19
  /** Raw `rateLimits` snapshots from app-server, and `hard = true` when a turn was refused for quota. */
20
20
  onUsage?: (rateLimits: unknown, hard: boolean) => void;
21
+ /** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
22
+ onTokens?: (added: number) => void;
23
+ /** The native id of each turn as it starts, after the peer turned busy (issue #33: `ahub undo --context`). */
24
+ onTurn?: (turnId: string) => void;
21
25
  /** How often to ask app-server for the rate limits while a TUI is attached. */
22
26
  usagePollMs?: number;
23
27
  cwd: string;
@@ -49,6 +53,14 @@ export class CodexPeer extends BasePeer {
49
53
  /** Claimed as soon as a TUI WebSocket opens, before it can start a thread. */
50
54
  private claimedTui: Link | undefined;
51
55
  private threadId = "";
56
+ /**
57
+ * The thread's running token total at the last update, and whether the thread started here. Codex 0.156.1 also
58
+ * sends `thread/tokenUsage/updated` for compaction (an estimate in `last`, `total` unchanged), a usage-limit refresh
59
+ * and a replay to a connection that attaches, so `last` is not always new usage; differences of `total` are. A
60
+ * resumed thread's first update only sets the baseline: its total holds the history from before (issue #40).
61
+ */
62
+ private tokenTotal: number | undefined;
63
+ private freshThread = false;
52
64
  private readonly activeTurns = new Set<string>();
53
65
  private nextId = -1;
54
66
  private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
@@ -327,17 +339,20 @@ export class CodexPeer extends BasePeer {
327
339
  if (typeof msg.id === "number" && msg.id < 0 && !msg.method) {
328
340
  const p = this.pending.get(msg.id);
329
341
  this.pending.delete(msg.id);
330
- if (msg.error) p?.reject(new Error(msg.error.message ?? "turn/start rejected"));
342
+ if (msg.error) p?.reject(new Error(msg.error.message ?? "app-server rejected the request"));
331
343
  else p?.resolve(msg.result);
332
344
  return; // ours: the TUI never asked for it
333
345
  }
334
- if (msg.id !== undefined && !msg.method && link.tracked.delete(msg.id)) this.adopt(link, msg.result?.thread?.id);
346
+ const tracked = msg.id !== undefined && !msg.method ? link.tracked.get(msg.id) : undefined;
347
+ if (tracked && link.tracked.delete(msg.id)) this.adopt(link, msg.result?.thread?.id, tracked === "thread/start");
335
348
  else if (msg.method) this.onNotification(link, msg.method, msg.params ?? {});
336
349
  link.tui.send(raw);
337
350
  }
338
351
 
339
- private adopt(link: Link, threadId: unknown): void {
352
+ private adopt(link: Link, threadId: unknown, fresh = false): void {
340
353
  if (typeof threadId !== "string" || !threadId) return;
354
+ this.tokenTotal = undefined;
355
+ this.freshThread = fresh;
341
356
  this.link = link;
342
357
  this.threadId = threadId;
343
358
  this.activeTurns.clear();
@@ -350,6 +365,22 @@ export class CodexPeer extends BasePeer {
350
365
  this.usageTimer.unref?.();
351
366
  }
352
367
 
368
+ /**
369
+ * thread/revert through the TUI's connection (issue #33): drops `turnId` and every later turn from the conversation
370
+ * history. It touches no file; `ahub undo` restores those. The TUI sees the `thread/reverted` notification.
371
+ */
372
+ revert(turnId: string): Promise<void> {
373
+ const link = this.link;
374
+ if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN) return Promise.reject(new Error(`${this.id} is not idle`));
375
+ const id = this.nextId--;
376
+ return new Promise<void>((resolve, reject) => {
377
+ const timer = setTimeout(() => this.pending.delete(id) && reject(new Error("thread/revert got no answer")), 30_000);
378
+ timer.unref?.();
379
+ this.pending.set(id, { resolve: () => (clearTimeout(timer), resolve()), reject: (e) => (clearTimeout(timer), reject(e)) });
380
+ link.up.send(JSON.stringify({ method: "thread/revert", id, params: { threadId: this.threadId, beforeTurnId: turnId } }));
381
+ });
382
+ }
383
+
353
384
  /** account/rateLimits/read through the TUI's connection, with a hub id so the answer never reaches the TUI. */
354
385
  private readUsage(): void {
355
386
  const link = this.link;
@@ -367,12 +398,21 @@ export class CodexPeer extends BasePeer {
367
398
  this.opts.onUsage?.({ rateLimitReachedType: "usageLimitExceeded" }, true);
368
399
  }
369
400
  if (link !== this.link || params.threadId !== this.threadId) return;
401
+ if (method === "thread/tokenUsage/updated") {
402
+ const total = Number(params.tokenUsage?.total?.totalTokens);
403
+ if (!Number.isFinite(total)) return;
404
+ const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
405
+ this.tokenTotal = total;
406
+ if (added > 0) this.opts.onTokens?.(added);
407
+ return;
408
+ }
370
409
  if (method === "turn/started") {
371
410
  const nativeTurn = params.turn?.id ?? `unknown:${Date.now()}`;
372
411
  this.activeTurns.add(nativeTurn);
373
412
  for (const id of [...this.unboundDeliveries]) this.addTurnDelivery(nativeTurn, id);
374
413
  this.lastAnswer = "";
375
414
  this.setState("busy");
415
+ if (params.turn?.id) this.opts.onTurn?.(params.turn.id);
376
416
  } else if (method === "item/agentMessage/delta") {
377
417
  const buf = this.deltas.get(params.itemId) ?? [];
378
418
  buf.push(params.delta);
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { join, resolve, relative } from "node:path";
4
4
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
5
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
6
6
  import { BasePeer } from "../hub/peers.ts";
7
7
  import { stopOwnedProcess } from "../hub/child-process.ts";
8
+ import { realPath } from "../hub/project.ts";
8
9
 
9
10
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
10
11
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
@@ -139,11 +140,11 @@ export class PiPeer extends BasePeer {
139
140
  const sessions = join(this.opts.stateDir, "pi-sessions");
140
141
  mkdirSync(sessions, { recursive: true, mode: 0o700 });
141
142
  if (this.opts.sessionFile) {
142
- const file = realpathSync(this.opts.sessionFile);
143
- const rel = relative(realpathSync(sessions), file);
144
- if (!rel || rel.startsWith("..") || resolve(realpathSync(sessions), rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
143
+ const file = realPath(this.opts.sessionFile);
144
+ const rel = relative(realPath(sessions), file);
145
+ if (!rel || rel.startsWith("..") || resolve(realPath(sessions), rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
145
146
  const header = JSON.parse(readFileSync(file, "utf8").split("\n", 1)[0]!);
146
- if (header.type !== "session" || typeof header.cwd !== "string" || realpathSync(header.cwd) !== realpathSync(this.opts.cwd) || (this.opts.sessionId && header.id !== this.opts.sessionId)) throw new Error("Pi session header does not match the requested project/session");
147
+ if (header.type !== "session" || typeof header.cwd !== "string" || realPath(header.cwd) !== realPath(this.opts.cwd) || (this.opts.sessionId && header.id !== this.opts.sessionId)) throw new Error("Pi session header does not match the requested project/session");
147
148
  }
148
149
  const token = randomUUID();
149
150
  this.tuiExit = new Promise((resolvePromise) => { this.resolveTuiExit = resolvePromise; });