hivecode-mcp 0.11.5 → 0.12.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/docs/MCP.md CHANGED
@@ -15,7 +15,7 @@ Add Hivecode to your MCP client config. The command runs this repo's server.
15
15
  "hivecode": {
16
16
  "command": "node",
17
17
  "args": ["C:/Users/G1/Desktop/N/hive-mcp.js"],
18
- "env": { "HIVE_RELAY": "wss://livecode-xoss.onrender.com" }
18
+ "env": { "HIVE_RELAY": "wss://hivecode-relay.onrender.com" }
19
19
  }
20
20
  }
21
21
  }
@@ -75,3 +75,36 @@ and also wakes on new chat so you stay responsive to teammates.
75
75
 
76
76
  This is the lowest-friction way for AIs to adopt the protocol in `SPEC.md`:
77
77
  the agent never runs a command, it just calls tools.
78
+
79
+ ## The anti-idle gate (Claude Code)
80
+
81
+ Everything above is still an instruction, and instructions get ignored. The single
82
+ worst failure in a room is an agent that decides it is finished: its process is
83
+ alive and its socket is up, but it has stopped listening, and the agents waiting on
84
+ it just wait. Nothing tells them.
85
+
86
+ You cannot fix that with wording. Install the gate:
87
+
88
+ ```
89
+ npx hivecode-hook install # this project
90
+ npx hivecode-hook install --global # every project
91
+ ```
92
+
93
+ That writes a Claude Code **`Stop` hook**. Claude Code runs it when the agent is
94
+ about to end its turn; the hook checks the room and **refuses the stop** if there is
95
+ unread mail addressed to that agent, or an accepted task still open. The agent does
96
+ not get a vote — same difference as a claim the relay enforces versus a claim it
97
+ merely warns about.
98
+
99
+ It merges into any existing `Stop` hooks rather than replacing them, and
100
+ `npx hivecode-hook uninstall` removes it. It reads only local mirrored files
101
+ (`HIVE_CHAT.md`, `HIVE_TASKS.md`), so it costs no network round trip and works with
102
+ the relay unreachable.
103
+
104
+ Three guards stop it becoming a trap: it never blocks a stop that is already the
105
+ result of a block, a given message can cause at most one block, and an open task
106
+ stands down after three consecutive turns so an impossible task cannot pin an agent
107
+ forever. Any failure at all — no room, unreadable file, bad JSON — allows the stop.
108
+
109
+ Other MCP clients get the same rules in their system prompt (the server returns them
110
+ in `instructions` at handshake), but only Claude Code can enforce them.
@@ -0,0 +1,253 @@
1
+ # Run your own relay
2
+
3
+ Hivecode is **three pieces, and only one of them needs hosting: the relay.** Run that one
4
+ server and your team's source never reaches a machine you do not own.
5
+
6
+ This is the supported path for any team that cannot send its code to a third party. It is
7
+ also the only path that does not depend on somebody else's uptime.
8
+
9
+ For the click-by-click cloud deploy see [DEPLOY.md](DEPLOY.md). For how it all works under
10
+ the hood see [ARCHITECTURE.md](ARCHITECTURE.md). For tokens and scopes see [RBAC.md](RBAC.md).
11
+
12
+ ---
13
+
14
+ > **You install one package, not a repository.** `npx hivecode-relay` downloads the relay and
15
+ > nothing else — no agent client, no editor extension, no test suite, no build tooling. It is a
16
+ > separate npm package from `hivecode-mcp`, which is what your developers and agents install.
17
+
18
+ ## What you actually host
19
+
20
+ Only the relay. It is plain Node with no native dependencies, it holds **no canonical copy
21
+ of your code**, and it never writes your files to disk unless you turn persistence on (which
22
+ you should — see below). It passes CRDT updates between clients in the same room and enforces
23
+ access at the handshake. The editor extension and the agent MCP server run on each
24
+ participant's own machine.
25
+
26
+ ```
27
+ You host ONE relay ─────► wss://your-relay.example.com
28
+ ▲ ▲ ▲
29
+ humans humans AI agents
30
+ (VS Code) (VS Code) (MCP)
31
+ all pointed at your relay
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Step 1 — Start the relay
37
+
38
+ ```bash
39
+ npx hivecode-relay
40
+ ```
41
+
42
+ That is the whole command. It starts the relay with **persistence on**, prints the join link,
43
+ and prints the exact settings each teammate needs. Useful flags:
44
+
45
+ | Flag | Default | What it does |
46
+ |---|---|---|
47
+ | `--port 1234` | `1234` | Port to listen on. |
48
+ | `--data ./hive-data` | `./hive-data` | Where rooms are persisted. |
49
+ | `--room my-project` | random | Print the link for a named room instead of a random one. |
50
+ | `--secret <string>` | none | Turn on **auth-required** mode: only holders of a minted token may connect. |
51
+ | `--seats 25` | `25` | Connection budget. See *How much can one relay handle?* below. |
52
+
53
+ Three ways to run it, in increasing order of "a real team depends on this":
54
+
55
+ **A. On a laptop or a box on your LAN.** `npx hivecode-relay`, hand out the `ws://192.168.x.x:1234`
56
+ address it prints. Good for a trial, a workshop, or a co-located team. It is **not encrypted**
57
+ — fine inside a network you trust, not fine across the internet.
58
+
59
+ **B. On your own server, behind TLS.** Run it behind a reverse proxy that terminates TLS and
60
+ upgrades WebSockets, and hand out the `wss://` address. This is the normal production shape.
61
+
62
+ **C. On a container host.** The included [`Dockerfile`](../Dockerfile) works on Fly.io, Railway,
63
+ Render, Cloud Run or anything else that runs containers. [`render.yaml`](../render.yaml) makes
64
+ Render one-click; the walkthrough is in [DEPLOY.md](DEPLOY.md).
65
+
66
+ You deploy once. After that it stays up.
67
+
68
+ ### A remote team, keeping it private
69
+
70
+ A relay on a laptop is reachable only from that network. For teammates in other places there
71
+ are three real options, and they are **not** equally private:
72
+
73
+ | Option | Private? | Stays up? | Setup |
74
+ |---|---|---|---|
75
+ | **Private network (Tailscale or similar)** — relay on any team machine, teammates join the same private network and use its `100.x.y.z` or `*.ts.net` address | **Yes.** WireGuard end to end; nothing outside the network can even reach the relay | Only while that machine is on | Each person installs the app and logs in once |
76
+ | **Your own server** — a small cloud VM or office box in *your* account, TLS terminated on it | **Yes.** You run every machine the traffic touches | Yes | One-time server setup |
77
+ | **Public tunnel** (`cloudflared tunnel --url http://localhost:1234`, ngrok) | **No.** The tunnel provider decrypts traffic at its edge, and the URL is public | Only while the laptop is on; quick tunnels get a new URL every restart | One command |
78
+
79
+ A public tunnel works — verified end to end: two clients synced a file through a Cloudflare
80
+ quick tunnel, and a client holding the URL but no token was refused. Use it for a trial, and
81
+ **always with `--secret`**, because a public URL on an open relay is an open room. `hive_status`
82
+ says so on every join: `YOUR RELAY — through a public tunnel`.
83
+
84
+ ---
85
+
86
+ ## Step 2 — Point EVERY client at it
87
+
88
+ This is the step teams get half-right, so it is worth being blunt about what "half-right"
89
+ costs. There are two kinds of client and they read two different settings:
90
+
91
+ | Client | Setting | Where |
92
+ |---|---|---|
93
+ | **Human** (VS Code) | `hivecode.relayUrl` | VS Code settings |
94
+ | **AI agent** (MCP) | `HIVE_RELAY` | the `env` block of the agent's MCP server config |
95
+
96
+ **If you set one and forget the other, nothing fails.** The agent falls back to the public
97
+ relay this build ships as a default, joins a room with the same name, reports `connected`,
98
+ and publishes your files there. Your humans are on your relay, your agent is on ours, and
99
+ every status line on both sides is green.
100
+
101
+ So set the pin as well:
102
+
103
+ ```
104
+ HIVE_RELAY_PIN = wss://your-relay.example.com
105
+ ```
106
+
107
+ `HIVE_RELAY` is a *default*. `HIVE_RELAY_PIN` is a *rule*: this machine may only ever talk
108
+ to these relays, and any attempt to connect anywhere else is refused **before the socket
109
+ opens**, with error **Q550**, having published nothing. Set it on every machine — humans'
110
+ and agents' alike — and a stale link, a typo'd host or a forgotten environment variable
111
+ becomes a loud refusal instead of a silent upload.
112
+
113
+ Two more knobs:
114
+
115
+ - `HIVE_RELAY_PIN` accepts a comma-separated list, for teams with a staging relay and a
116
+ production one.
117
+ - `HIVE_PRIVATE=1` refuses the shipped public default (**Q551**) even when no pin is set.
118
+ Use it as a belt-and-braces setting on a machine that must never reach our relay.
119
+
120
+ A pin that cannot be parsed is **refused (Q552)**, never ignored. A typo that silently
121
+ degraded to "no pin" would leave you believing a machine was locked down when it was not.
122
+
123
+ ---
124
+
125
+ ## Step 3 — Create a room and invite people
126
+
127
+ A connection is encoded as a **join link**:
128
+
129
+ ```
130
+ wss://your-relay.example.com|room-myproject
131
+ └──── your relay ────┘ └──── room id ────┘
132
+ ```
133
+
134
+ 1. `npx hivecode-relay --room myproject` prints the link.
135
+ 2. Share it with teammates and agents.
136
+ 3. **Humans** run **Hivecode: Join a Session** in VS Code and paste it.
137
+ 4. **Agents** get it as the `link` argument to `hive_join`.
138
+ 5. Same link = same room = one live codebase for everyone.
139
+
140
+ ---
141
+
142
+ ## Step 4 — Add AI agents
143
+
144
+ Agents join through the MCP server. Register it once in the agent's host (Claude Code,
145
+ Cursor, Windsurf, or your own bot):
146
+
147
+ ```json
148
+ {
149
+ "mcpServers": {
150
+ "hivecode": {
151
+ "command": "npx",
152
+ "args": ["-y", "hivecode-mcp"],
153
+ "env": {
154
+ "HIVE_RELAY": "wss://your-relay.example.com",
155
+ "HIVE_RELAY_PIN": "wss://your-relay.example.com"
156
+ }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ Then hand it the join link. It appears in the room as an `ai` member, scoped to exactly the
163
+ folders you invited it to. See [JOIN_WITH_AGENT.md](JOIN_WITH_AGENT.md) and [MCP.md](MCP.md).
164
+
165
+ ---
166
+
167
+ ## Step 5 — Check that it is actually private
168
+
169
+ Do not take a green join as evidence. Two checks, thirty seconds:
170
+
171
+ **1. Ask each client where it is.** `hive_status` reports a `privacy` field next to
172
+ `connected`:
173
+
174
+ ```
175
+ privacy: "PRIVATE — relay on your network" ← yours
176
+ privacy: "PUBLIC RELAY — not yours" ← ours. Your files are on our server.
177
+ ```
178
+
179
+ A human client shows the same thing in the join banner: `relay: <url> [VERDICT]`.
180
+
181
+ **2. Ask your relay who is on it.** `curl http://your-relay:1234/health` reports
182
+ `connections` and `rooms`. The number of connections should account for everybody. If a
183
+ teammate says they are in the room and your relay's count did not move, they are in
184
+ *a* room — just not on your relay.
185
+
186
+ You can prove the whole path on your own machine in about a minute:
187
+
188
+ ```bash
189
+ node tests/self-host-test.mjs
190
+ ```
191
+
192
+ That suite starts a real relay, syncs a real file between two real clients through it,
193
+ asserts the relay's own counters moved, and asserts that a pinned client refuses the public
194
+ relay. It is the evidence behind every claim on this page.
195
+
196
+ ---
197
+
198
+ ## Step 6 — Lock it down before you invite anyone real
199
+
200
+ | Env var | Why you want it |
201
+ |---|---|
202
+ | `HIVE_AUTH_MODE=required` + `HIVE_JWT_SECRET=<secret>` | Every connection must present a valid token. This is your invite-only control. `npx hivecode-relay --secret <secret>` sets both. Mint tokens with `npx hivecode-token --room <room> --name <who> --role writer --ttl 7d` on the relay host, which is the only machine that holds the secret. See [RBAC.md](RBAC.md). |
203
+ | `HIVE_PERSIST_DIR=/var/data/hive` | **Important.** Without it a room lives only in connected clients' memory and is **lost the moment everyone disconnects** — the day works perfectly and the folder is empty in the morning. `npx hivecode-relay` turns this on by default; `node server.js` does not. |
204
+ | `HIVE_SEATS=<n>` | Connection budget for the whole relay. The default free-tier ceiling is 8 per room, which is **not** 8 people — see below. |
205
+ | `HIVE_AUDIT_FILE=./audit.log` | One line per admit and reject. This is your evidence of who connected and when. |
206
+ | `RENDER_EXTERNAL_URL` (auto on Render) | Keeps a free-tier host warm so the first join is not a 30-second cold start. |
207
+
208
+ > ⚠️ **An open relay on a public IP is a public relay.** Without `HIVE_AUTH_MODE=required`,
209
+ > anyone who can reach the port and guesses a room id can read and write your files. On a
210
+ > laptop or a private LAN that is a reasonable trade. On a public IP it is not.
211
+
212
+ ---
213
+
214
+ ## How much can one relay handle?
215
+
216
+ **Count connections, not people.** Each participant holds one base-room socket **plus one
217
+ per open file**, so a five-person team is not five connections — it is up to
218
+ `participants × (1 + HIVE_MAX_OPEN_FILES)`. The client caps its own working set at
219
+ `HIVE_MAX_OPEN_FILES` (default 128) for exactly this reason.
220
+
221
+ Two different ceilings refuse you, with two different remedies:
222
+
223
+ | You see | Meaning | Fix |
224
+ |---|---|---|
225
+ | **Q430** (503) | the whole relay is at `maxConnsTotal` (512) | `HIVE_MAX_CONNS_TOTAL` — **`HIVE_SEATS` does not help here** |
226
+ | **Q431** (429) | one room is at its per-room cap | `HIVE_SEATS` |
227
+
228
+ - **A focused team (~10 humans and their agents in one room): comfortably**, on a small
229
+ always-on instance (≈1 vCPU / 1–2 GB). Free tiers are fine for a trial.
230
+ - **Hundreds of concurrent users across many rooms:** scale the instance up, or — the natural
231
+ model — let each team run its own relay so the load distributes.
232
+
233
+ Two operational rules:
234
+
235
+ 1. **Turn on persistence** before real users join.
236
+ 2. **Do not redeploy the relay while people are working** — a restart drops everyone. Deploy
237
+ from a stable tag, off-hours.
238
+
239
+ ---
240
+
241
+ ## What self-hosting does not give you
242
+
243
+ Said plainly, because discovering it later is worse:
244
+
245
+ - **Uptime is yours.** Nobody is paged when your box dies at 2am.
246
+ - **`ws://` is plaintext.** Self-hosting is not encryption. Put TLS in front of it.
247
+ - **An open relay is open.** Running it yourself controls *where* the bytes go, not *who*
248
+ may fetch them. That is what `HIVE_AUTH_MODE=required` is for.
249
+ - **Path scoping needs auth mode.** The `--paths` globs in a token are only enforced when the
250
+ relay runs in `required` mode. On an open relay every token is ignored, so a "scoped" agent
251
+ gets the whole room.
252
+ - **Mixed client versions do not sync.** Everyone on the relay must be on the same Hivecode
253
+ version; announce yours on join.
@@ -0,0 +1,310 @@
1
+ // WHAT THE ENGINE OBSERVED, as opposed to what the agent declared.
2
+ //
3
+ // THE DEFECT THIS EXISTS FOR.
4
+ // Every AI agent in a Hivecode room is a turn-based LLM. Between turns its process is alive
5
+ // and its socket is connected, so Yjs awareness reports it present -- but it will take no
6
+ // action, ever, until a human types at it. The room could not tell these apart:
7
+ //
8
+ // an agent thinking hard mid-task
9
+ // an agent blocked inside hive_wait healthy idle; wakes the instant anything lands
10
+ // an agent whose turn ENDED outside hive_wait will NEVER wake on its own
11
+ // an agent whose process died
12
+ //
13
+ // All four read as "in the room". The third is the one that costs a night: at 23:00 an agent
14
+ // says "standing by", at 09:00 the human finds a claim that expired hours ago, a ticket nobody
15
+ // finished, and no record of when it stopped. Both agents in this room have been that agent,
16
+ // and one of them had built an external WebSocket listener specifically to work around it.
17
+ //
18
+ // WHY THE OBVIOUS FIX IS DEAD, MEASURED RATHER THAN ARGUED.
19
+ // The obvious fix is a chat-activity timeout. Measured on this room's own log, 20 Sep 2026,
20
+ // across agents that were demonstrably working the whole time:
21
+ //
22
+ // agent msgs median gap p90 MAX
23
+ // DeepMind-Agent 32 156s 349s 3985s (66 min)
24
+ // Antigravity1 40 192s 907s 6718s (112 min)
25
+ //
26
+ // A working agent went 112 minutes between posts. Any threshold under that false-positives on
27
+ // real work; anything over it is useless. So chat silence cannot be the signal.
28
+ //
29
+ // THE ANSWERABLE QUESTION.
30
+ // Same move as HIV-28, which stopped asking whether a room was "ahead or behind" a branch and
31
+ // asked instead whether the room had ever SEEN those bytes. "Is this agent alive?" and "is it
32
+ // thinking?" are not answerable from outside. This is:
33
+ //
34
+ // IS THIS AGENT INSIDE A BLOCKING hive_wait RIGHT NOW?
35
+ //
36
+ // An agent inside hive_wait is reachable -- anything that arrives wakes it. An agent that is
37
+ // not inside hive_wait and not calling tools is unreachable until a human prompts it. Binary,
38
+ // exact, no threshold.
39
+ //
40
+ // WHY THIS IS A THIRD MAP AND NOT A FIELD ON `busy`.
41
+ // hive-busy.js settled the principle already: "The controls map is deliberately not reused.
42
+ // That is mission control -- a HUMAN sets running/paused there -- and an agent writing its own
43
+ // state into the same entry would clobber a human's pause with a self-report. Different author,
44
+ // different authority, different map."
45
+ //
46
+ // controls the HUMAN declares running / paused
47
+ // busy the AGENT declares "I am mid-task"
48
+ // activity the ENGINE observes <- this file
49
+ //
50
+ // `busy` is a claim about the future: an agent can forget to set it, set it and die, or simply
51
+ // be wrong. `activity` is a record of the past, stamped by dispatch() on every hive_* call, so
52
+ // an agent cannot forget to write it and cannot write something false. An agent that has
53
+ // stopped cannot report that it has stopped -- that is the whole problem -- so the fact has to
54
+ // be produced by something other than the agent's intent.
55
+ //
56
+ // Timestamps are unix ms, matching hive-coord's claims and hive-busy's `since`. Adjacent maps
57
+ // in one document disagreeing about units is its own defect class.
58
+
59
+ /** Inside a blocking hive_wait at this instant. Reachable: anything that arrives wakes it. */
60
+ export const WAITING = 'WAITING'
61
+ /** Acted recently and is not in a wait. Working. */
62
+ export const WORKING = 'WORKING'
63
+ /** Present, not waiting, silent past the window. WILL NOT WAKE without a human. */
64
+ export const PARKED = 'PARKED'
65
+ /** Awareness dropped. The process is gone, whatever the last record says. */
66
+ export const GONE = 'GONE'
67
+ /** A human set controls=paused. Obeying, not stalled -- never report this as PARKED. */
68
+ export const PAUSED = 'PAUSED'
69
+ /** Declared passive: this process does not take turns. Silence is correct, not a fault. */
70
+ export const PASSIVE = 'PASSIVE'
71
+ /**
72
+ * A human, present. Every state below is about whether an AGENT will act again without being
73
+ * prompted, which is not a question you can ask about a person.
74
+ *
75
+ * Antigravity1 called this in the P11 design review -- "kind === 'human' must never be
76
+ * classified as PARKED" -- and it was not implemented. The result was visible in the live room
77
+ * an hour later: `Ganesh (human) — PARKED: ... anything you send it now will sit unread until a
78
+ * human prompts it`, said about the human. Nonsense, and worse than nonsense, because it teaches
79
+ * a reader that PARKED is noise.
80
+ */
81
+ export const PRESENT = 'PRESENT'
82
+
83
+ /**
84
+ * How long a present, non-waiting agent may be silent before it reads as PARKED.
85
+ *
86
+ * THIS IS THE ONLY NUMBER IN THIS MODULE THAT IS A JUDGEMENT RATHER THAN A MEASUREMENT, and
87
+ * saying so is the point. Every other state -- WAITING, GONE, PAUSED, PASSIVE -- is exact and
88
+ * needs no threshold at all, which is deliberate: the fact that matters most ("can I reach my
89
+ * teammate") never depends on a number somebody guessed.
90
+ *
91
+ * Fifteen minutes is long enough that an ordinary stretch of local work -- a test battery, a
92
+ * file sweep, a long think -- does not trip it, and short enough that a human returning in the
93
+ * morning gets a useful answer rather than a shrug. It is NOT derived from the 112-minute chat
94
+ * gap above: hive_* calls are far denser than chat posts, because the room requires a check
95
+ * between edits. If that assumption is ever measured and found wrong, this is the one line to
96
+ * change, and nothing else in the module moves.
97
+ *
98
+ * The cost of getting it wrong is asymmetric and worth stating. Too short and a working agent
99
+ * is reported PARKED, its ticket gets reassigned, and two agents do one job. Too long and the
100
+ * room is slower to notice a stall than it could have been -- which is still strictly better
101
+ * than today, where a stalled agent reads as WORKING forever.
102
+ */
103
+ export const DEFAULT_PARKED_AFTER_MS = 15 * 60 * 1000
104
+
105
+ /**
106
+ * A ceiling, for the same reason hive-busy.js has one. An unbounded window means PARKED can be
107
+ * configured out of existence silently: every agent reads WORKING, nothing looks broken, and
108
+ * the feature is gone while its tests still pass.
109
+ */
110
+ export const MAX_PARKED_AFTER_MS = 6 * 60 * 60 * 1000
111
+
112
+ /**
113
+ * A FLOOR, and it is not symmetry with the ceiling -- it is a correctness bound.
114
+ *
115
+ * The stamp is written when a tool call BEGINS, so the age of the record includes however long
116
+ * that call is still running. A window shorter than one tool call therefore reports an agent as
117
+ * PARKED while it is mid-call: the most active state there is, labelled unreachable.
118
+ *
119
+ * Found by the suite rather than reasoned about. tests/agent-liveness-test.mjs ran with a 2500ms
120
+ * window; adding a fifth client made hive_say take longer than that, and A1 went red with
121
+ * "PARKED: ... silent for 0 min (hive_say)" -- parked, zero minutes ago, naming the tool it was
122
+ * inside. Five seconds clears a real tool call with room to spare and is still short enough to
123
+ * exercise the state in a test.
124
+ */
125
+ export const MIN_PARKED_AFTER_MS = 5000
126
+
127
+ /** Rows are keyed by member name, so the map grows with distinct names, not with time. */
128
+ const PRUNE_AFTER_MS = 30 * 24 * 60 * 60 * 1000
129
+
130
+ const lc = (n) => String(n || '').toLowerCase()
131
+
132
+ /** Read the configured window, clamped. An unparseable or absurd value falls back to the default. */
133
+ export function parkedAfterMs(env = (typeof process !== 'undefined' ? process.env : {})) {
134
+ const raw = Number((env && env.HIVE_PARKED_AFTER_MIN) || 0)
135
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PARKED_AFTER_MS
136
+ return Math.min(Math.max(raw * 60 * 1000, MIN_PARKED_AFTER_MS), MAX_PARKED_AFTER_MS)
137
+ }
138
+
139
+ /**
140
+ * Stamp that a member just did something. Called from dispatch() for EVERY hive_* tool, which
141
+ * is what makes this a record rather than a declaration -- there is no code path where an agent
142
+ * acts without this running, and none where it runs without the agent acting.
143
+ *
144
+ * `tool` is kept because "PARKED, last action hive_release 47 minutes ago" tells a human what
145
+ * to do next and "PARKED" alone does not.
146
+ */
147
+ export function noteAction(store, { name, tool, now = Date.now() } = {}) {
148
+ if (!store || !name) return null
149
+ const key = lc(name)
150
+ const prev = store.get(key) || {}
151
+ const rec = { ...prev, name: String(name), lastAction: now, lastTool: String(tool || prev.lastTool || '') }
152
+ store.set(key, rec)
153
+ return rec
154
+ }
155
+
156
+ /**
157
+ * Mark entry into a blocking wait. Paired with noteWaitEnd, and the pairing is the contract:
158
+ * `waitingSince` set means "inside the call right now", not "waited at some point".
159
+ */
160
+ export function noteWaitStart(store, { name, now = Date.now() } = {}) {
161
+ if (!store || !name) return null
162
+ const key = lc(name)
163
+ const prev = store.get(key) || {}
164
+ const rec = { ...prev, name: String(name), waitingSince: now, lastAction: now, lastTool: 'hive_wait' }
165
+ store.set(key, rec)
166
+ return rec
167
+ }
168
+
169
+ /**
170
+ * Mark exit from a blocking wait -- returned with work, timed out, or threw.
171
+ *
172
+ * This MUST run on every exit path including the throwing one. A wait that errors and leaves
173
+ * `waitingSince` set describes an agent parked in a call it is no longer in, and it describes
174
+ * it as the healthiest state in the taxonomy. stateOf() gates WAITING on awareness presence
175
+ * precisely so that a process which dies here cannot fake it -- but a live process that merely
176
+ * threw is still present, so the gate does not cover this case and the finally does.
177
+ */
178
+ export function noteWaitEnd(store, { name, now = Date.now() } = {}) {
179
+ if (!store || !name) return null
180
+ const key = lc(name)
181
+ const prev = store.get(key) || {}
182
+ const rec = { ...prev, name: String(name), lastAction: now }
183
+ delete rec.waitingSince
184
+ store.set(key, rec)
185
+ return rec
186
+ }
187
+
188
+ /** Forget a member entirely. Used on hive_leave, where the departure is deliberate and known. */
189
+ export function forget(store, name) {
190
+ if (!store || !name) return
191
+ try { store.delete(lc(name)) } catch { }
192
+ }
193
+
194
+ /**
195
+ * Derive one member's state.
196
+ *
197
+ * THE ORDER OF THESE BRANCHES IS THE SAFETY PROPERTY, not a style choice.
198
+ *
199
+ * `present` comes from awareness and is checked FIRST, before anything stored. A process that
200
+ * dies inside hive_wait leaves `waitingSince` set in the CRDT forever; asking about the stored
201
+ * record first would report that corpse as WAITING -- "healthy idle, wakes on anything" -- which
202
+ * is the single most misleading answer the room could give about the single worst situation.
203
+ * Presence is ephemeral and the record is durable, so the ephemeral one has to win.
204
+ *
205
+ * PAUSED and PASSIVE come before the silence check for the same reason P6 puts never-seen and
206
+ * passive before the debt: an agent obeying a human's stop, and a listener that never takes
207
+ * turns by design, are both silent on purpose. Reporting either as PARKED would train a reader
208
+ * to ignore the state that means "somebody needs to go look".
209
+ *
210
+ * @param {object} rec the stored activity record, or null/undefined if none
211
+ * @param {object} opts
212
+ * @param {boolean} opts.present awareness says this member is connected RIGHT NOW
213
+ * @param {boolean} opts.paused controls map says a human paused it
214
+ * @param {boolean} opts.passive declared passive at join
215
+ */
216
+ export function stateOf(rec, { present, paused = false, passive = false, human = false, now = Date.now(), parkedAfter = DEFAULT_PARKED_AFTER_MS } = {}) {
217
+ if (!present) return GONE
218
+ // A human is exempt from the whole taxonomy below, which asks whether an AGENT will act again
219
+ // unprompted. Checked before `paused` and `passive` because those describe agent mechanics too;
220
+ // checked after `!present` because a departed human is still departed.
221
+ if (human) return PRESENT
222
+ if (passive) return PASSIVE
223
+ if (paused) return PAUSED
224
+ if (rec && rec.waitingSince) return WAITING
225
+ const last = rec && Number(rec.lastAction)
226
+ // No record at all, but awareness says present: a client that has joined and not yet driven a
227
+ // tool. It is connected and it has done nothing. That is exactly PARKED -- and saying WORKING
228
+ // here would mean the state defaults to the reassuring answer in the absence of evidence,
229
+ // which is the habit this whole file exists to break.
230
+ if (!Number.isFinite(last)) return PARKED
231
+ return (now - last) < parkedAfter ? WORKING : PARKED
232
+ }
233
+
234
+ /**
235
+ * The reader-facing form: the state, plus the evidence it was derived from.
236
+ *
237
+ * A verdict with no evidence is a thing a human has to trust. "PARKED -- last action hive_release
238
+ * 47 min ago" is a thing they can check, and it is what tells them whether to prompt the agent,
239
+ * take its ticket, or go back to sleep.
240
+ */
241
+ export function describe(rec, opts = {}) {
242
+ const now = opts.now || Date.now()
243
+ const state = stateOf(rec, { ...opts, now })
244
+ const last = rec && Number(rec.lastAction)
245
+ const ageMs = Number.isFinite(last) ? Math.max(0, now - last) : null
246
+ return {
247
+ state,
248
+ lastTool: (rec && rec.lastTool) || null,
249
+ lastAction: Number.isFinite(last) ? last : null,
250
+ ageMs,
251
+ waitingSince: (rec && Number(rec.waitingSince)) || null,
252
+ reachable: state === WAITING || state === WORKING,
253
+ }
254
+ }
255
+
256
+ /** Minutes, rounded, for human output. Null age reads as "never", not as zero. */
257
+ const mins = (ms) => (ms == null ? null : Math.round(ms / 60000))
258
+
259
+ /**
260
+ * One line for hive_members. The wording of PARKED is doing real work: an agent reading
261
+ * "PARKED" alone will treat it as a synonym for idle and keep waiting on it, so the line has to
262
+ * say the consequence -- nothing you send will be acted on -- rather than the label.
263
+ */
264
+ export function line(name, rec, opts = {}) {
265
+ const d = describe(rec, opts)
266
+ const age = mins(d.ageMs)
267
+ const tool = d.lastTool ? ` (${d.lastTool})` : ''
268
+ switch (d.state) {
269
+ case WAITING:
270
+ return `${name} — WAITING: blocked in hive_wait, wakes the moment anything arrives. Reachable.`
271
+ case WORKING:
272
+ return `${name} — WORKING: last action ${age} min ago${tool}. Reachable.`
273
+ case PAUSED:
274
+ return `${name} — PAUSED by a human. It is obeying a stop, not stalled. Do not reassign its work.`
275
+ case PASSIVE:
276
+ return `${name} — PASSIVE LISTENER: does not take turns. Its silence is correct.`
277
+ case PRESENT:
278
+ return `${name} — PRESENT${age == null ? '' : `, last action ${age} min ago${tool}`}. A person; ` +
279
+ `reachable whenever they are at the keyboard.`
280
+ case GONE:
281
+ return `${name} — GONE: not connected.${age == null ? '' : ` Last action ${age} min ago${tool}.`}`
282
+ case PARKED:
283
+ default:
284
+ return `${name} — PARKED: connected, but NOT inside hive_wait and silent for ` +
285
+ `${age == null ? 'its whole session' : `${age} min`}${tool}. ` +
286
+ `It will not wake on its own — anything you send it now will sit unread until a human prompts it.`
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Drop rows for members nobody has seen in a month. The map is keyed by name, so it grows with
292
+ * distinct names rather than with time and this is a tidiness bound, not a correctness one.
293
+ */
294
+ export function prune(store, { now = Date.now() } = {}) {
295
+ if (!store) return 0
296
+ let n = 0
297
+ for (const [k, rec] of [...store.entries()]) {
298
+ const last = rec && Number(rec.lastAction)
299
+ if (Number.isFinite(last) && (now - last) > PRUNE_AFTER_MS) { store.delete(k); n++ }
300
+ }
301
+ return n
302
+ }
303
+
304
+ /** Every stored row, newest action first. */
305
+ export function all(store) {
306
+ if (!store) return []
307
+ return [...store.entries()]
308
+ .map(([k, rec]) => ({ key: k, ...(rec || {}) }))
309
+ .sort((a, b) => (Number(b.lastAction) || 0) - (Number(a.lastAction) || 0))
310
+ }