@staix/agent-hub 0.7.11 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -1
- package/README.md +2 -2
- package/docs/events.md +37 -0
- package/docs/operations.md +91 -10
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -0
- package/docs/smoke.md +36 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +16 -4
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/codex-appserver.ts +43 -3
- package/src/cli/main.ts +91 -0
- package/src/hub/board.ts +29 -9
- package/src/hub/budget.ts +3 -0
- package/src/hub/daemon.ts +139 -17
- package/src/hub/events.ts +91 -0
- package/src/hub/hub-tools.ts +15 -3
- package/src/hub/lifecycle.ts +13 -2
- package/src/hub/report.ts +68 -0
- package/src/hub/snapshots.ts +191 -0
- package/src/hub/tasks.ts +119 -28
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
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
|
-
##
|
|
5
|
+
## 0.8.1
|
|
6
|
+
|
|
7
|
+
- Upgrading a running 0.7.x hub that has a task on its board no longer stops at verification ("queue, manual pause, task board or budget preservation was not verified"): the 0.8.0 target digested its tasks with the new `plan` field, which the 0.7.x source never had. The target now also accepts the digest in the source's shape while every plan is empty; any real change to the board still fails the check. Use the 0.8.1 coordinator, not 0.8.0's, for such an upgrade (#57).
|
|
8
|
+
- `ahub kill` returns only once the hub has released its registry claim, so an `ahub projects remove` right after it no longer refuses, and an `ahub up` right after it no longer reads the project as starting and starts nothing (#58).
|
|
9
|
+
|
|
10
|
+
## 0.8.0
|
|
11
|
+
|
|
12
|
+
- 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).
|
|
13
|
+
- 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).
|
|
14
|
+
- 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).
|
|
6
15
|
|
|
7
16
|
## 0.7.11
|
|
8
17
|
|
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
|
+
Status: 0.8.1, 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.
|
|
31
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.8.1 && 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).
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
-
This guide describes ahub 0.
|
|
3
|
+
This guide describes ahub 0.8.1 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`.
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
230
|
-
bunx --package @staix/agent-hub@0.
|
|
305
|
+
bunx --package @staix/agent-hub@0.8.1 ahub upgrade --to 0.8.1 --dry-run
|
|
306
|
+
bunx --package @staix/agent-hub@0.8.1 ahub upgrade --to 0.8.1 --yes
|
|
231
307
|
```
|
|
232
308
|
|
|
233
309
|
| Running now | Coordinator to use |
|
|
@@ -237,7 +313,12 @@ bunx --package @staix/agent-hub@0.7.11 ahub upgrade --to 0.7.11 --yes
|
|
|
237
313
|
| any supported source, with the installed CLI already at the target | `ahub upgrade` below, which is the same coordinator |
|
|
238
314
|
| 0.5.x or earlier (protocol 8 and older) | not supported: bootstrap by hand with the matching CLI |
|
|
239
315
|
|
|
240
|
-
Do not use
|
|
316
|
+
Do not use the 0.8.0 coordinator for a running 0.7.x hub with tasks on its board:
|
|
317
|
+
its verification never matches the board and the operation stays blocked (fixed
|
|
318
|
+
in 0.8.1). Such a blocked operation can neither resume nor abort, and its lock
|
|
319
|
+
refuses `up` and `kill` for every project; the [smoke ledger](smoke.md) (0.7.11
|
|
320
|
+
to 0.8.0) records the manual cleanup. Do not use an older installed CLI as the
|
|
321
|
+
coordinator. A 0.6.x CLI cannot target
|
|
241
322
|
protocol 10: its plan does not check the target's protocol, so the dry-run shows
|
|
242
323
|
no blocker, and `--yes` stops at staging ("target protocol requires a newer
|
|
243
324
|
coordinator") with an operation left to clear by `ahub recovery abort <id>`. An
|
|
@@ -249,19 +330,19 @@ The coordinator verifies and retains the exact target package, preserves its
|
|
|
249
330
|
own source, and promotes the global CLI only after restored projects pass
|
|
250
331
|
readback.
|
|
251
332
|
|
|
252
|
-
Once the installed CLI is 0.
|
|
333
|
+
Once the installed CLI is 0.8.1, review the current project or all registered
|
|
253
334
|
projects first:
|
|
254
335
|
|
|
255
336
|
```bash
|
|
256
337
|
ahub restart --dry-run
|
|
257
|
-
ahub upgrade --to 0.
|
|
338
|
+
ahub upgrade --to 0.8.1 --dry-run
|
|
258
339
|
```
|
|
259
340
|
|
|
260
341
|
Apply only after reviewing the plan:
|
|
261
342
|
|
|
262
343
|
```bash
|
|
263
344
|
ahub restart --yes
|
|
264
|
-
ahub upgrade --to 0.
|
|
345
|
+
ahub upgrade --to 0.8.1 --yes
|
|
265
346
|
ahub recovery status <operation-id>
|
|
266
347
|
ahub recovery resume <operation-id>
|
|
267
348
|
ahub recovery abort <operation-id>
|
package/docs/quickstart.md
CHANGED
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,39 @@ 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
|
+
|
|
862
|
+
## 0.7.11 to 0.8.0 attended upgrade (2026-10-01)
|
|
863
|
+
|
|
864
|
+
A scratch project (a git work tree whose `.agenthub/config.json` predates 0.8.0
|
|
865
|
+
and has no `snapshots` block) ran a 0.7.11 hub with one proposed task and no
|
|
866
|
+
peers attached.
|
|
867
|
+
|
|
868
|
+
- `bunx --package @staix/agent-hub@0.8.0 ahub upgrade --to 0.8.0 --dry-run`
|
|
869
|
+
exited 0: one project, source 0.7.11, protocol 10, no blockers.
|
|
870
|
+
- `--yes` prepared and committed the source, started the 0.8.0 target and
|
|
871
|
+
reached the restored-peers phase (no peers were attached), then stopped at
|
|
872
|
+
verification: `phase: blocked`, "queue,
|
|
873
|
+
manual pause, task board or budget preservation was not verified". The
|
|
874
|
+
global CLI stayed at 0.7.11.
|
|
875
|
+
- Cause: the 0.7.11 source digested its task rows without `plan`, and the
|
|
876
|
+
0.8.0 target's rows carry `plan: {}` after the #31 migration, so any board
|
|
877
|
+
with a task failed the check. Fixed in 0.8.1 (#57).
|
|
878
|
+
- The blocked operation could not resume (the same target fails the same
|
|
879
|
+
check) or abort (the source was already stopped), and its recovery lock
|
|
880
|
+
refused `up` and `kill` for every project. The scratch hub was stopped under
|
|
881
|
+
the operation's environment, and the operation was marked cancelled with its
|
|
882
|
+
lock released: an operator step, not a designed path.
|
|
883
|
+
- The release gate for v0.8.0 (Actions run 36807880478) failed once on
|
|
884
|
+
"removing a running project is refused"; the rerun passed and published. Cause: `ahub kill` returned before
|
|
885
|
+
the hub released its registry claim. Fixed in 0.8.1 (#58).
|
|
@@ -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": "agent-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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.
|
|
15730
|
+
version: "0.8.1",
|
|
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
|
};
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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 ?? "
|
|
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
|
-
|
|
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);
|