agent-coord-mcp 0.19.0 → 0.23.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/README.md +12 -2
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +164 -42
- package/dist/server.js.map +1 -1
- package/dist/store.js +33 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +24 -5
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/registry.js +69 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +97 -7
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +98 -24
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -259
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +16 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-pusher.mjs +39 -9
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +181 -47
- package/src/store.ts +45 -1
- package/src/tools/admin.ts +22 -5
- package/src/tools/registry.ts +96 -0
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +18 -77
- package/src/tools/transport.ts +116 -6
- package/src/tools/work.ts +118 -30
- package/src/work.ts +31 -329
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
package/src/server.ts
CHANGED
|
@@ -4,8 +4,17 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
-
import {
|
|
7
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { z, type ZodRawShape } from "zod";
|
|
8
9
|
import {
|
|
10
|
+
ensureDirs,
|
|
11
|
+
getTokenMap,
|
|
12
|
+
reloadTokenMapSync,
|
|
13
|
+
sessionFile,
|
|
14
|
+
type SessionBinding,
|
|
15
|
+
} from "./store.js";
|
|
16
|
+
import {
|
|
17
|
+
liveClaimEvidence,
|
|
9
18
|
attachAgentSchema,
|
|
10
19
|
attachAgentTool,
|
|
11
20
|
clearTransportSchema,
|
|
@@ -95,8 +104,103 @@ function jsonResult(data: unknown) {
|
|
|
95
104
|
// switching (the PR #45 spoof shape) is rejected.
|
|
96
105
|
// - rename_agent updates the binding to the new id on success so the
|
|
97
106
|
// renamed session keeps working.
|
|
98
|
-
|
|
107
|
+
// - First-claim guard (v0.20.0): TOFU no longer lets a fresh session claim
|
|
108
|
+
// an id that is currently LIVE on the bus (fresh heartbeat, live
|
|
109
|
+
// transport, or another live bound session) — that silently created a
|
|
110
|
+
// second session acting as an already-running agent (hit live 2026-07-06:
|
|
111
|
+
// a dev session bound itself to `disavow-liaison`). A live-id claim needs
|
|
112
|
+
// the agent's token or an explicit force (join/register params). See
|
|
113
|
+
// guardFirstClaim for how absent vs unreadable evidence is decided.
|
|
114
|
+
// - `trackSession` (stdio only): each successful bind writes a
|
|
115
|
+
// sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
|
|
116
|
+
// sessions bound to one id — closure state alone cannot be inspected
|
|
117
|
+
// from outside the process. Not tracked for HTTP sessions: tokens.json
|
|
118
|
+
// already enforces their identity and many share one pid, which would
|
|
119
|
+
// make pid-liveness meaningless.
|
|
120
|
+
function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {}): McpServer {
|
|
99
121
|
let bound = initialBound;
|
|
122
|
+
const trackSession = opts.trackSession ?? false;
|
|
123
|
+
let sessionMarker: string | undefined;
|
|
124
|
+
let exitHooksInstalled = false;
|
|
125
|
+
|
|
126
|
+
// Best-effort: a marker left behind by SIGKILL has a dead pid, which both
|
|
127
|
+
// the guard's evidence read and doctor's duplicate-session-binding check
|
|
128
|
+
// treat as garbage (doctor fix deletes it).
|
|
129
|
+
function recordSessionBinding(agentId: string, via: string): void {
|
|
130
|
+
if (!trackSession) return;
|
|
131
|
+
try {
|
|
132
|
+
const file = sessionFile(agentId, process.pid, randomUUID().slice(0, 8));
|
|
133
|
+
const marker: SessionBinding = {
|
|
134
|
+
agentId,
|
|
135
|
+
pid: process.pid,
|
|
136
|
+
boundAt: Date.now(),
|
|
137
|
+
via,
|
|
138
|
+
...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
|
|
139
|
+
};
|
|
140
|
+
writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
|
|
141
|
+
if (sessionMarker) {
|
|
142
|
+
try { unlinkSync(sessionMarker); } catch { /* already gone */ }
|
|
143
|
+
}
|
|
144
|
+
sessionMarker = file;
|
|
145
|
+
if (!exitHooksInstalled) {
|
|
146
|
+
exitHooksInstalled = true;
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
try { if (sessionMarker) unlinkSync(sessionMarker); } catch { /* already gone */ }
|
|
149
|
+
};
|
|
150
|
+
process.on("exit", cleanup);
|
|
151
|
+
// Default signal death skips 'exit' handlers; SIGHUP stays reserved
|
|
152
|
+
// for the token-map reload in loadTokenMap.
|
|
153
|
+
for (const sig of ["SIGTERM", "SIGINT"] as const) {
|
|
154
|
+
process.on(sig, () => { cleanup(); process.exit(0); });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch { /* marker is observability, never worth failing the bind */ }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Decide whether a fresh session may claim `claimed` as its identity, and
|
|
161
|
+
// how. Returns the bind provenance ("tofu" | "token" | "force" |
|
|
162
|
+
// "same-pane") or throws. Ordering is deliberate:
|
|
163
|
+
// - a presented token must MATCH or the claim fails loudly, even when the
|
|
164
|
+
// id is not live — a wrong credential silently succeeding via the
|
|
165
|
+
// not-live path would teach callers that garbage tokens work;
|
|
166
|
+
// - force is an explicit human/agent decision, honored before evidence;
|
|
167
|
+
// - evidence that exists but cannot be read REFUSES (cannot-verify ≠
|
|
168
|
+
// verified-absent; unreadable state must not disable the guard);
|
|
169
|
+
// - a live id refuses, except when its live pusher types into THIS
|
|
170
|
+
// process's own tmux pane — two sessions cannot share a pane, so that
|
|
171
|
+
// is the same seat restarting (the routine fleet-restart case), not a
|
|
172
|
+
// second session. The exception never applies when another live
|
|
173
|
+
// session is already bound to the id.
|
|
174
|
+
// - verified-not-live binds freely: refusing absent evidence would break
|
|
175
|
+
// every first onboarding, and the guard exists to protect LIVE ids.
|
|
176
|
+
async function guardFirstClaim(claimed: string, args: Record<string, unknown>): Promise<string> {
|
|
177
|
+
const token = typeof args["token"] === "string" ? (args["token"] as string) : undefined;
|
|
178
|
+
if (token !== undefined) {
|
|
179
|
+
if (getTokenMap()?.get(token) === claimed) return "token";
|
|
180
|
+
throw new Error(
|
|
181
|
+
`token presented for '${claimed}' does not match tokens.json (or no token map is loaded). ` +
|
|
182
|
+
`Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (args["force"] === true) return "force";
|
|
186
|
+
const ev = await liveClaimEvidence(claimed, Date.now());
|
|
187
|
+
if (!ev.verifiable) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
|
|
190
|
+
`Refusing to bind rather than treating unreadable evidence as absence. ` +
|
|
191
|
+
`Repair the state (doctor), or pass the agent's token or force:true (join/register).`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (ev.live) {
|
|
195
|
+
if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
|
|
196
|
+
throw new Error(
|
|
197
|
+
`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it. ` +
|
|
198
|
+
`If you ARE '${claimed}' restarting, re-join from its tmux pane, or pass its token or force:true (join/register). ` +
|
|
199
|
+
`If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return "tofu";
|
|
203
|
+
}
|
|
100
204
|
|
|
101
205
|
// Gate every tool that takes a caller identity. `field: null` (list_agents,
|
|
102
206
|
// list_rooms, prune) bypasses the check entirely.
|
|
@@ -117,7 +221,12 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
117
221
|
if (typeof claimed === "string") {
|
|
118
222
|
if (bound === undefined) {
|
|
119
223
|
if (bindOnClaim) {
|
|
120
|
-
|
|
224
|
+
// TOFU: first claim wins, then sticky — but only after the
|
|
225
|
+
// first-claim guard agrees the id isn't someone else's live
|
|
226
|
+
// session (see guardFirstClaim).
|
|
227
|
+
const via = await guardFirstClaim(claimed, args);
|
|
228
|
+
bound = claimed;
|
|
229
|
+
recordSessionBinding(claimed, via);
|
|
121
230
|
}
|
|
122
231
|
} else if (bound !== claimed) {
|
|
123
232
|
throw new Error(
|
|
@@ -132,12 +241,23 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
132
241
|
|
|
133
242
|
const server = new McpServer({
|
|
134
243
|
name: "agent-coord",
|
|
135
|
-
version: "0.
|
|
244
|
+
version: "0.23.0",
|
|
136
245
|
});
|
|
137
246
|
|
|
138
|
-
|
|
247
|
+
const addTool = (
|
|
248
|
+
name: string,
|
|
249
|
+
description: string,
|
|
250
|
+
inputSchema: ZodRawShape,
|
|
251
|
+
cb: (args: Record<string, unknown>) => Promise<ReturnType<typeof jsonResult>>,
|
|
252
|
+
) => {
|
|
253
|
+
server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) =>
|
|
254
|
+
cb((args ?? {}) as Record<string, unknown>),
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
addTool(
|
|
139
259
|
"join",
|
|
140
|
-
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated.",
|
|
260
|
+
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
|
|
141
261
|
joinSchema,
|
|
142
262
|
// join explicitly sets the session binding when unset, so each agent can
|
|
143
263
|
// declare its identity via join rather than relying on env vars.
|
|
@@ -145,7 +265,9 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
145
265
|
const claimed = args["agentId"];
|
|
146
266
|
if (typeof claimed === "string") {
|
|
147
267
|
if (bound === undefined) {
|
|
268
|
+
const via = await guardFirstClaim(claimed, args);
|
|
148
269
|
bound = claimed;
|
|
270
|
+
recordSessionBinding(claimed, via);
|
|
149
271
|
} else if (bound !== claimed) {
|
|
150
272
|
throw new Error(
|
|
151
273
|
`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
|
|
@@ -156,147 +278,147 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
156
278
|
},
|
|
157
279
|
);
|
|
158
280
|
|
|
159
|
-
|
|
281
|
+
addTool(
|
|
160
282
|
"register",
|
|
161
283
|
"Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
|
|
162
284
|
registerSchema,
|
|
163
285
|
gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
164
286
|
);
|
|
165
287
|
|
|
166
|
-
|
|
288
|
+
addTool(
|
|
167
289
|
"unregister",
|
|
168
290
|
"Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.",
|
|
169
291
|
unregisterSchema,
|
|
170
292
|
gate("agentId", unregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
171
293
|
);
|
|
172
294
|
|
|
173
|
-
|
|
295
|
+
addTool(
|
|
174
296
|
"quit",
|
|
175
297
|
"Clean shutdown: unregister this agent (detach transport, leave rooms, remove registry entry) then exit the MCP process. Only callable by the session's bound identity. Use this to cleanly hand off before a restart with a new name.",
|
|
176
298
|
quitSchema,
|
|
177
299
|
gate("agentId", quitTool as unknown as (a: Record<string, unknown>) => Promise<unknown>),
|
|
178
300
|
);
|
|
179
301
|
|
|
180
|
-
|
|
302
|
+
addTool(
|
|
181
303
|
"status",
|
|
182
304
|
"Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'. Read-only — naming an agentId here never binds this session's identity.",
|
|
183
305
|
statusSchema,
|
|
184
306
|
gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
185
307
|
);
|
|
186
308
|
|
|
187
|
-
|
|
309
|
+
addTool(
|
|
188
310
|
"heartbeat",
|
|
189
311
|
"Refresh this agent's lastHeartbeat timestamp.",
|
|
190
312
|
heartbeatSchema,
|
|
191
313
|
gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
192
314
|
);
|
|
193
315
|
|
|
194
|
-
|
|
316
|
+
addTool(
|
|
195
317
|
"ping",
|
|
196
318
|
"Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity, but read-only — naming 'from' here never binds this session's identity.",
|
|
197
319
|
pingSchema,
|
|
198
320
|
gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
199
321
|
);
|
|
200
322
|
|
|
201
|
-
|
|
323
|
+
addTool(
|
|
202
324
|
"list_agents",
|
|
203
325
|
"List all known agents and whether they appear online (heartbeat <5min).",
|
|
204
326
|
listAgentsSchema,
|
|
205
327
|
gate(null, listAgentsTool as () => Promise<unknown>),
|
|
206
328
|
);
|
|
207
329
|
|
|
208
|
-
|
|
330
|
+
addTool(
|
|
209
331
|
"send_message",
|
|
210
332
|
"Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. The 'from' field is enforced against the session's bound identity when binding is configured.",
|
|
211
333
|
sendMessageSchema,
|
|
212
334
|
gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
213
335
|
);
|
|
214
336
|
|
|
215
|
-
|
|
337
|
+
addTool(
|
|
216
338
|
"send_command",
|
|
217
339
|
"Inject a context-management slash command (/clear or /compact) directly into a sub-agent's live tmux session — delivered RAW with no banner or prefix, so the agent's CLI runs it as a real slash command. Target one agent with 'to' or broadcast to a channel's tmux-attached members with 'room' (never the sender). Hard-gated to tmux: returns ok:false if the target has no live tmux-push(-remote) transport. By default BLOCKS until the receiving pusher confirms it actually typed the command into the pane (out-of-band delivery receipt, no added agent context) and returns delivery:'confirmed' with deliveredAt, or delivery:'pending'+warning if no receipt arrived within deliveryTimeoutMs (default 8000) — a stale/wedged pusher. Pass waitForDelivery:false for fire-and-forget. Intended for a lead agent to clear/compact sub-agent context and save tokens. The command allowlist is locked to /clear and /compact; nothing else is accepted. 'from' is enforced against the session's bound identity.",
|
|
218
340
|
sendCommandSchema,
|
|
219
341
|
gate("from", sendCommandTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
220
342
|
);
|
|
221
343
|
|
|
222
|
-
|
|
344
|
+
addTool(
|
|
223
345
|
"read_messages",
|
|
224
346
|
"Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room and status reads return the most recent 50 entries per call — pass limit to override (max 500). When the backlog exceeds the window, the older overflow is replaced by a compact `history` digest carrying a retrieval hash; call retrieve_room_history(hash) to expand it. Inbox drains fully by default. Advances the per-channel cursor unless peek=true.",
|
|
225
347
|
readMessagesSchema,
|
|
226
348
|
gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
227
349
|
);
|
|
228
350
|
|
|
229
|
-
|
|
351
|
+
addTool(
|
|
230
352
|
"retrieve_room_history",
|
|
231
353
|
"Expand a compressed channel-history digest returned by read_messages. Pass the `hash` from the `history` field; optionally pass `query` to return only matching messages (case-insensitive substring). Entries are scoped to the agent that produced them and expire after 30 minutes — if expired, re-read the channel with a higher limit instead.",
|
|
232
354
|
retrieveRoomHistorySchema,
|
|
233
355
|
gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
234
356
|
);
|
|
235
357
|
|
|
236
|
-
|
|
358
|
+
addTool(
|
|
237
359
|
"retrieve_message",
|
|
238
360
|
"Expand a `retrieve_message id=<uuid>` handle from a pane digest into the full message and its typed `record`. A record whose text rendering spans multiple lines (a DAVID_DECISION packet) is delivered to a pane as ONE attributed line plus this handle; call it to get the structured record back. Reads the message by id from the channels you can read (your inbox and rooms you belong to), falling through to the append-only archive if compaction moved it — so unlike retrieve_room_history there is no TTL and nothing to expire. A handle for a message never delivered to you is simply not found.",
|
|
239
361
|
retrieveMessageSchema,
|
|
240
362
|
gate("agentId", retrieveMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
241
363
|
);
|
|
242
364
|
|
|
243
|
-
|
|
365
|
+
addTool(
|
|
244
366
|
"post_status",
|
|
245
367
|
"Append a status broadcast to the shared status stream.",
|
|
246
368
|
postStatusSchema,
|
|
247
369
|
gate("agentId", postStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
248
370
|
);
|
|
249
371
|
|
|
250
|
-
|
|
372
|
+
addTool(
|
|
251
373
|
"prune",
|
|
252
|
-
"Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted.
|
|
374
|
+
"Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted. `room` and `targets` compose: `room` scopes every sweep to that channel (and, alone, defaults the sweep to `rooms` only), while `targets` (rooms|status|inbox|receipts|members) selects which sweeps run and always wins over that default — so `{room, targets:['members']}` sweeps membership in that one channel. Sweeps room members that are unregistered or haven't heartbeated since the cutoff, and archives+removes non-default rooms left empty and inactive (disable via archiveEmptyRooms=false). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
|
|
253
375
|
pruneSchema,
|
|
254
376
|
gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
255
377
|
);
|
|
256
378
|
|
|
257
|
-
|
|
379
|
+
addTool(
|
|
258
380
|
"wait_for_message",
|
|
259
381
|
"Block (max 60s) until a new message appears on the given source, then return it. For source='room', pass 'room' to wait on a specific channel (default 'general').",
|
|
260
382
|
waitForMessageSchema,
|
|
261
383
|
gate("agentId", waitForMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
262
384
|
);
|
|
263
385
|
|
|
264
|
-
|
|
386
|
+
addTool(
|
|
265
387
|
"list_rooms",
|
|
266
388
|
"List all channels with their topic, MOTD (room rules), members, message count, and last activity.",
|
|
267
389
|
listRoomsSchema,
|
|
268
390
|
gate(null, listRoomsTool as () => Promise<unknown>),
|
|
269
391
|
);
|
|
270
392
|
|
|
271
|
-
|
|
393
|
+
addTool(
|
|
272
394
|
"join_room",
|
|
273
395
|
"Join a channel (creating it if new). Adds this agent to the channel's membership so the notification hooks push its messages. Posts a system join notice to the channel. Returns the channel's topic, MOTD, member list, and unread message count — but not the messages themselves. Call read_messages to fetch history if needed.",
|
|
274
396
|
joinRoomSchema,
|
|
275
397
|
gate("agentId", joinRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
276
398
|
);
|
|
277
399
|
|
|
278
|
-
|
|
400
|
+
addTool(
|
|
279
401
|
"leave_room",
|
|
280
402
|
"Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.",
|
|
281
403
|
leaveRoomSchema,
|
|
282
404
|
gate("agentId", leaveRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
283
405
|
);
|
|
284
406
|
|
|
285
|
-
|
|
407
|
+
addTool(
|
|
286
408
|
"set_room_topic",
|
|
287
409
|
"Set a channel's topic (a short one-line description). Posts a system notice to the channel.",
|
|
288
410
|
setRoomTopicSchema,
|
|
289
411
|
gate("agentId", setRoomTopicTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
290
412
|
);
|
|
291
413
|
|
|
292
|
-
|
|
414
|
+
addTool(
|
|
293
415
|
"set_room_motd",
|
|
294
416
|
"Set a channel's MOTD / room rules (shown to agents on join). Posts a system notice to the channel.",
|
|
295
417
|
setRoomMotdSchema,
|
|
296
418
|
gate("agentId", setRoomMotdTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
297
419
|
);
|
|
298
420
|
|
|
299
|
-
|
|
421
|
+
addTool(
|
|
300
422
|
"rename_agent",
|
|
301
423
|
"Rename an agent (NICK): migrates its registry entry, inbox, cursor, and channel memberships to the new id, then broadcasts a rename notice to its channels. When tokens.json identity binding is on, the caller's bearer token is atomically rotated to the new id so the same session keeps authenticating after rename. If a live tmux-push transport is attached it is detached first (the pusher is bound to the old id) — re-attach as the new id (join/attach_agent) to restore real-time delivery; the response sets detachedTransport + a warning when this happens.",
|
|
302
424
|
renameAgentSchema,
|
|
@@ -306,104 +428,116 @@ function buildServer(initialBound?: string): McpServer {
|
|
|
306
428
|
async (args: Record<string, unknown>) => {
|
|
307
429
|
const claimed = args.agentId;
|
|
308
430
|
if (typeof claimed === "string") {
|
|
309
|
-
if (bound === undefined)
|
|
310
|
-
|
|
431
|
+
if (bound === undefined) {
|
|
432
|
+
// Renaming a live agent from a fresh session is still a first
|
|
433
|
+
// claim of that agent's identity — same guard as any other.
|
|
434
|
+
const via = await guardFirstClaim(claimed, args);
|
|
435
|
+
bound = claimed;
|
|
436
|
+
recordSessionBinding(claimed, via);
|
|
437
|
+
} else if (bound !== claimed) {
|
|
311
438
|
throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
|
|
312
439
|
}
|
|
313
440
|
}
|
|
314
441
|
const result = await renameAgentTool(args as { agentId: string; newAgentId: string });
|
|
315
442
|
if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
|
|
316
443
|
const to = (result as { to?: unknown }).to;
|
|
317
|
-
if (typeof to === "string")
|
|
444
|
+
if (typeof to === "string") {
|
|
445
|
+
bound = to;
|
|
446
|
+
recordSessionBinding(to, "rename");
|
|
447
|
+
}
|
|
318
448
|
}
|
|
319
449
|
return jsonResult(result);
|
|
320
450
|
},
|
|
321
451
|
);
|
|
322
452
|
|
|
323
|
-
|
|
453
|
+
addTool(
|
|
324
454
|
"attach_agent",
|
|
325
455
|
"Start the tmux-push transport for an agent: spawns hooks/tmux-pusher.mjs as a background process so peer DMs (and optionally room messages) get typed into the agent's tmux pane in real time. tmuxTarget defaults to the MCP server's own $TMUX_PANE if this server is running inside tmux. allowlist restricts which peer agentIds can push. Updates list_agents to show transport=tmux-push.",
|
|
326
456
|
attachAgentSchema,
|
|
327
457
|
gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
328
458
|
);
|
|
329
459
|
|
|
330
|
-
|
|
460
|
+
addTool(
|
|
331
461
|
"detach_agent",
|
|
332
462
|
"Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
|
|
333
463
|
detachAgentSchema,
|
|
334
464
|
gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
335
465
|
);
|
|
336
466
|
|
|
337
|
-
|
|
467
|
+
addTool(
|
|
338
468
|
"report_transport",
|
|
339
469
|
"Publish a transport marker for an agent (used by the remote tmux pusher, scripts/coord-pusher.mjs, to surface itself in list_agents). Set transport='tmux-push-remote' and optionally host/tmuxTarget. Liveness for remote markers is heartbeat-based — keep calling heartbeat or this marker gets GC'd after staleness.",
|
|
340
470
|
reportTransportSchema,
|
|
341
471
|
gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
342
472
|
);
|
|
343
473
|
|
|
344
|
-
|
|
474
|
+
addTool(
|
|
345
475
|
"clear_transport",
|
|
346
476
|
"Idempotent delete of an agent's transport marker. The wire-callable counterpart to detach_agent for remote pushers: it only removes the marker — there's no local process to kill.",
|
|
347
477
|
clearTransportSchema,
|
|
348
478
|
gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
349
479
|
);
|
|
350
480
|
|
|
351
|
-
|
|
481
|
+
addTool(
|
|
352
482
|
"report_receipt",
|
|
353
483
|
"Append a delivery receipt for a message this agent's pusher just typed into its pane — the wire-callable counterpart to the local pusher's receipts/<id>.jsonl stamp, for remote pushers (scripts/coord-pusher.mjs) that cannot write this host's filesystem. This is what lets send_command to a tmux-push-remote agent return delivery:'confirmed'. For control commands pass exactly what submit verification observed (submitted/verified/reason); omitting 'submitted' means 'typed but unverified' and is reported as delivery:'pending', never 'confirmed'. 'agentId' (the receiving agent) is enforced against the session's bound identity, so a pusher can only stamp its own agent's receipt file.",
|
|
354
484
|
reportReceiptSchema,
|
|
355
485
|
gate("agentId", reportReceiptTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
356
486
|
);
|
|
357
487
|
|
|
358
|
-
|
|
488
|
+
addTool(
|
|
359
489
|
"doctor",
|
|
360
490
|
"Bus-wide health check: inspects the whole state dir and reports drift, leaks, and corruption (orphan transport markers / memberships / inboxes, cursor offsets past EOF, malformed JSONL, stale agents, oversized files, stale locks, channel/registry mismatches, environment). Read-only by default; pass fix=true to apply the safe, reversible repairs (malformed-line rewrites are backed up to .bak first). A clean report (healthy=true) means the bus is internally consistent.",
|
|
361
491
|
doctorSchema,
|
|
362
492
|
gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
363
493
|
);
|
|
364
494
|
|
|
365
|
-
|
|
495
|
+
addTool(
|
|
366
496
|
"list_scopes",
|
|
367
497
|
"Read the declared write scopes for managed documents (~/agent-coord/scopes.json). Call it with 'path' (and your 'agentId') to ask \"may I write this?\" BEFORE editing a shared doc like docs/QUEUE.md; call it bare to list every declared document and its owning role. ADVISORY ONLY: the bus does not mediate file writes, so this answers who owns a document, it does not stop anyone — enforcement arrives when work state moves into the store. Absent scopes.json means nothing is owned and nothing warns (opt-in).",
|
|
368
498
|
listScopesSchema,
|
|
369
499
|
gate(null, listScopesTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
370
500
|
);
|
|
371
501
|
|
|
372
|
-
|
|
502
|
+
addTool(
|
|
373
503
|
"import_work",
|
|
374
|
-
"Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date} and
|
|
504
|
+
"Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md and optional docs/FACTS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date}, board rows, and facts {id,claim,verified,by,method}. The markdown stays authoritative — this store is a derived index, and export_work renders queue/done/board back byte-identically (FACTS is not an export write target).",
|
|
375
505
|
importWorkSchema,
|
|
376
506
|
gate(null, importWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
377
507
|
);
|
|
378
508
|
|
|
379
|
-
|
|
509
|
+
addTool(
|
|
380
510
|
"list_work",
|
|
381
|
-
"Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields,
|
|
511
|
+
"Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, the board's lane rows, and facts. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
|
|
382
512
|
listWorkSchema,
|
|
383
513
|
gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
384
514
|
);
|
|
385
515
|
|
|
386
|
-
|
|
516
|
+
addTool(
|
|
387
517
|
"export_work",
|
|
388
|
-
"Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
|
|
518
|
+
"Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Refuses write:true when that write would emit a new 5-col lanes-v0 table (parse-only; write grammar is workstreams.v1). Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
|
|
389
519
|
exportWorkSchema,
|
|
390
520
|
gate(null, exportWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
391
521
|
);
|
|
392
522
|
|
|
393
|
-
|
|
523
|
+
addTool(
|
|
394
524
|
"delete_room",
|
|
395
525
|
"Permanently delete a channel: removes it from the registry, deletes its JSONL file, and clears all agent cursor offsets for that channel. Refuses if agents are still joined unless force=true. Cannot delete the default 'general' channel. Posts a system notice to #general on success.",
|
|
396
526
|
deleteRoomSchema,
|
|
397
527
|
gate("agentId", deleteRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
398
528
|
);
|
|
399
529
|
|
|
400
|
-
|
|
530
|
+
addTool(
|
|
401
531
|
"force_unregister",
|
|
402
532
|
"Admin eviction: unregisters any agent by targetAgentId regardless of the caller's identity. Detaches the agent's transport, removes it from all channel memberships, and drops its registry entry. Use after a reboot to clean up stale agents that can no longer unregister themselves.",
|
|
403
533
|
forceUnregisterSchema,
|
|
404
534
|
gate(null, forceUnregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
405
535
|
);
|
|
406
536
|
|
|
537
|
+
// An env pre-bound session is just as live as a TOFU-bound one — record it
|
|
538
|
+
// so doctor's duplicate check sees it too. (No-op unless trackSession.)
|
|
539
|
+
if (initialBound) recordSessionBinding(initialBound, "env");
|
|
540
|
+
|
|
407
541
|
return server;
|
|
408
542
|
}
|
|
409
543
|
|
|
@@ -452,7 +586,7 @@ async function main() {
|
|
|
452
586
|
" unregister before restarting so the new name starts fresh.",
|
|
453
587
|
);
|
|
454
588
|
}
|
|
455
|
-
const server = buildServer(boundAgent);
|
|
589
|
+
const server = buildServer(boundAgent, { trackSession: true });
|
|
456
590
|
const transport = new StdioServerTransport();
|
|
457
591
|
await server.connect(transport);
|
|
458
592
|
}
|
package/src/store.ts
CHANGED
|
@@ -55,6 +55,37 @@ export const ARCHIVE_ROOMS_DIR = path.join(ARCHIVE_DIR, "rooms");
|
|
|
55
55
|
export const ARCHIVE_INBOX_DIR = path.join(ARCHIVE_DIR, "inbox");
|
|
56
56
|
export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
|
|
57
57
|
|
|
58
|
+
// Live session-binding markers (v0.20.0). One small file per *bound* stdio MCP
|
|
59
|
+
// session: which agentId the session claimed, which pid holds it, and how the
|
|
60
|
+
// bind was established. Written at bind time, removed on clean exit; a file
|
|
61
|
+
// whose pid is dead is garbage doctor can clean. This is what makes two live
|
|
62
|
+
// sessions bound to the same id VISIBLE (doctor `duplicate-session-binding`)
|
|
63
|
+
// — in-process closure state can't be, by definition. HTTP sessions are not
|
|
64
|
+
// tracked here: with tokens.json they are already identity-enforced, and many
|
|
65
|
+
// share one pid, so pid-liveness would be meaningless for them.
|
|
66
|
+
export const SESSIONS_DIR = path.join(ROOT, "sessions");
|
|
67
|
+
|
|
68
|
+
export type SessionBinding = {
|
|
69
|
+
agentId: string;
|
|
70
|
+
pid: number;
|
|
71
|
+
boundAt: number;
|
|
72
|
+
// How the bind was established: "tofu" (first claim, id verified not live),
|
|
73
|
+
// "env" (AGENT_COORD_BOUND_AGENT), "token", "force", "same-pane" (live
|
|
74
|
+
// marker types into this session's own tmux pane), "rename".
|
|
75
|
+
via: string;
|
|
76
|
+
tmuxPane?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function sessionFile(agentId: string, pid: number, nonce: string): string {
|
|
80
|
+
return path.join(SESSIONS_DIR, `${sanitize(agentId)}.${pid}.${nonce}.json`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function listSessionFiles(): Promise<string[]> {
|
|
84
|
+
if (!existsSync(SESSIONS_DIR)) return [];
|
|
85
|
+
const names = await fs.readdir(SESSIONS_DIR);
|
|
86
|
+
return names.filter((n) => n.endsWith(".json")).map((n) => path.join(SESSIONS_DIR, n));
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
// Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk:
|
|
59
90
|
// { "alice": "tk_<random-secret>", "bob": "tk_<another-secret>" }
|
|
60
91
|
// HTTP transport reverse-looks-up the bearer to bind the session to an
|
|
@@ -82,7 +113,7 @@ export function workFile(project: string): string {
|
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
export function ensureDirs(): void {
|
|
85
|
-
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR]) {
|
|
116
|
+
for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR, SESSIONS_DIR]) {
|
|
86
117
|
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
87
118
|
}
|
|
88
119
|
for (const f of [ROOM_FILE, STATUS_FILE]) {
|
|
@@ -330,6 +361,19 @@ export async function readJson<T>(file: string, fallback: T): Promise<T> {
|
|
|
330
361
|
}
|
|
331
362
|
}
|
|
332
363
|
|
|
364
|
+
// Like readJson, but a file that EXISTS and cannot be parsed THROWS instead of
|
|
365
|
+
// silently returning the fallback. For callers whose decision flips on
|
|
366
|
+
// "verified absent" vs "cannot verify": the first-claim binding guard must
|
|
367
|
+
// refuse when evidence is unreadable, because a guard that treats unreadable
|
|
368
|
+
// evidence as absent is disabled by the very corruption it should be
|
|
369
|
+
// reporting (the absence-is-not-exemption class, #36).
|
|
370
|
+
export async function readJsonStrict<T>(file: string, fallback: T): Promise<T> {
|
|
371
|
+
if (!existsSync(file)) return fallback;
|
|
372
|
+
const raw = await fs.readFile(file, "utf8");
|
|
373
|
+
if (!raw.trim()) return fallback;
|
|
374
|
+
return JSON.parse(raw) as T;
|
|
375
|
+
}
|
|
376
|
+
|
|
333
377
|
export async function writeJson(file: string, data: unknown): Promise<void> {
|
|
334
378
|
await withLock(file, async () => {
|
|
335
379
|
await fs.writeFile(file, JSON.stringify(data, null, 2), "utf8");
|
package/src/tools/admin.ts
CHANGED
|
@@ -118,11 +118,22 @@ export async function pruneTool(args: {
|
|
|
118
118
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
119
119
|
const decisionCutoff = Date.now() - decisionDays * 24 * 60 * 60 * 1000;
|
|
120
120
|
const dryRun = args.dryRun ?? false;
|
|
121
|
-
// room
|
|
122
|
-
// the
|
|
121
|
+
// `room` scopes every sweep to that channel; `targets` narrows which sweeps
|
|
122
|
+
// run. They compose: `room` only changes the DEFAULT target set, so an
|
|
123
|
+
// explicit `targets` always wins.
|
|
124
|
+
//
|
|
125
|
+
// WHY (regression): this previously read `scopedRoom ? ["rooms"] : args.targets`,
|
|
126
|
+
// which silently DISCARDED an explicit `targets` whenever `room` was passed.
|
|
127
|
+
// `prune {room, targets:["members"], dryRun:true}` therefore reported
|
|
128
|
+
// `orphanMembers: []` because the member sweep never ran — a caller asking
|
|
129
|
+
// "which members are phantoms in this room" got a clean bill of health that
|
|
130
|
+
// had not been computed, while `roomMessages` (the one target the override
|
|
131
|
+
// left enabled) reported real messages a live run would have archived. Field
|
|
132
|
+
// report: two members that `ping` called `unregistered` were invisible here.
|
|
133
|
+
// A sweep must never answer a question it did not evaluate.
|
|
123
134
|
const scopedRoom = args.room ? normalizeRoom(args.room) : undefined;
|
|
124
135
|
const targets = new Set<PruneTarget>(
|
|
125
|
-
scopedRoom ? ["rooms"] :
|
|
136
|
+
args.targets ?? (scopedRoom ? ["rooms"] : [...PRUNE_TARGETS])
|
|
126
137
|
);
|
|
127
138
|
const keep = (e: { ts: number; kind?: string }) => keepEntry(e, cutoff, decisionCutoff);
|
|
128
139
|
|
|
@@ -133,6 +144,10 @@ export async function pruneTool(args: {
|
|
|
133
144
|
const staleAgent = (m: string) => !knownAgents.has(m) || (reg[m]?.lastHeartbeat ?? 0) <= cutoff;
|
|
134
145
|
|
|
135
146
|
const channels = scopedRoom ? [scopedRoom] : Object.keys(await getRooms());
|
|
147
|
+
// The membership sweeps walk the room registry rather than `channels`, so they
|
|
148
|
+
// need the scope predicate explicitly — without it, `room` scoped the message
|
|
149
|
+
// sweep while membership silently swept EVERY room.
|
|
150
|
+
const inScope = (chan: string) => !scopedRoom || chan === scopedRoom;
|
|
136
151
|
|
|
137
152
|
if (dryRun) {
|
|
138
153
|
let roomMessages = 0;
|
|
@@ -159,6 +174,7 @@ export async function pruneTool(args: {
|
|
|
159
174
|
if (targets.has("members")) {
|
|
160
175
|
const rooms = await getRooms();
|
|
161
176
|
for (const [chan, e] of Object.entries(rooms)) {
|
|
177
|
+
if (!inScope(chan)) continue;
|
|
162
178
|
const remaining: string[] = [];
|
|
163
179
|
for (const m of e.members ?? []) {
|
|
164
180
|
if (!knownAgents.has(m)) orphanMembers.add(m);
|
|
@@ -264,7 +280,8 @@ export async function pruneTool(args: {
|
|
|
264
280
|
const archivedRooms: string[] = [];
|
|
265
281
|
if (targets.has("members")) {
|
|
266
282
|
await updateJson<RoomRegistry>(ROOMS_FILE, {}, (current) => {
|
|
267
|
-
for (const e of Object.
|
|
283
|
+
for (const [chan, e] of Object.entries(current)) {
|
|
284
|
+
if (!inScope(chan)) continue;
|
|
268
285
|
if ((e.members?.length ?? 0) === 0) continue;
|
|
269
286
|
e.members = (e.members ?? []).filter((m) => {
|
|
270
287
|
if (!knownAgents.has(m)) {
|
|
@@ -284,7 +301,7 @@ export async function pruneTool(args: {
|
|
|
284
301
|
if (args.archiveEmptyRooms ?? true) {
|
|
285
302
|
const rooms = await getRooms();
|
|
286
303
|
for (const [chan, e] of Object.entries(rooms)) {
|
|
287
|
-
if (chan === DEFAULT_ROOM || (e.members?.length ?? 0) > 0) continue;
|
|
304
|
+
if (chan === DEFAULT_ROOM || !inScope(chan) || (e.members?.length ?? 0) > 0) continue;
|
|
288
305
|
const file = roomFile(chan);
|
|
289
306
|
const msgs = await readJsonl<Message>(file);
|
|
290
307
|
const lastTs = msgs[msgs.length - 1]?.ts ?? 0;
|