agent-coord-mcp 0.26.40 → 0.26.42
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 +1 -1
- package/dist/binding-decision.js +16 -0
- package/dist/binding-decision.js.map +1 -0
- package/dist/capabilities.js +84 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +33 -9
- package/dist/server.js.map +1 -1
- package/dist/tools/messaging.js +43 -1
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/registry.js +19 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/typed-records.js +9 -3
- package/dist/typed-records.js.map +1 -1
- package/hooks/tier.mjs +23 -5
- package/package.json +1 -1
- package/scripts/coord-seat.mjs +16 -1
- package/src/binding-decision.ts +33 -0
- package/src/capabilities.ts +86 -0
- package/src/server.ts +32 -9
- package/src/tools/messaging.ts +45 -1
- package/src/tools/registry.ts +19 -1
- package/src/typed-records.ts +11 -5
package/src/capabilities.ts
CHANGED
|
@@ -71,6 +71,7 @@ import { detectSpread } from "./server-spread.js";
|
|
|
71
71
|
import { answeringServerIdentity } from "./tools/registry.js";
|
|
72
72
|
import { HerdrTransport, herdrKeyName, interpretHerdrReply, submitModeOf, SUBMIT_MODE_ENV_VAR } from "./transports/herdr.js";
|
|
73
73
|
import { tickVerdict } from "./tools/tick.js";
|
|
74
|
+
import { headersOf } from "./tools/messaging.js";
|
|
74
75
|
import { ensureHerdrTail, herdrTailState, newContext, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
75
76
|
import { HERDR, TMUX_PUSH, TICK_READS_AS, TICK_STORED_AS, ownPaneTarget, type TickState } from "./transports/types.js";
|
|
76
77
|
|
|
@@ -799,6 +800,90 @@ const PROBES: Probe[] = [
|
|
|
799
800
|
// flight must still ARM, where the old predicate refused because it compared against a non-zero
|
|
800
801
|
// `checked`. (`inFlight === 0` is documentation rather than arithmetic — `0 >= 0` is already true — so
|
|
801
802
|
// this cell is about the DIVISOR, not about that ternary.)
|
|
803
|
+
// ⟨q-bf7fd8f3⟩ ADMINISTERING A SEAT IS NOT A CLAIM TO BE IT. `/coord-resume` §3 tells the RESUMER to
|
|
804
|
+
// call `attach_agent { agentId: "<the seat>" }` for every seat it restores; on a freshly respawned
|
|
805
|
+
// server that was the session's FIRST identity-naming call, so the resumer silently BECAME seat one and
|
|
806
|
+
// every later verb refused it as itself — read-only `status` included. There was no in-session
|
|
807
|
+
// recovery, and for the seat that is David's only comms channel that meant losing the channel to fix it.
|
|
808
|
+
// ⭐ ASKED BY CALLING THE DECISION, NOT BY READING A SHAPE. `bindingDecision` was lifted out of
|
|
809
|
+
// `gate()`'s closure precisely so this probe could put questions to it: a probe that can only assert
|
|
810
|
+
// that a symbol EXISTS would pass on a build that never wired it in — @groundwork-console-worker-1's
|
|
811
|
+
// own `withDeliveryLock: function` probe is the worked example, and it survived a build that took no
|
|
812
|
+
// lock at all.
|
|
813
|
+
// ⛔ EVERY CELL BELOW ANSWERS THE OPPOSITE WAY ON THE PRE-FIX DECISION, which is what makes it a probe
|
|
814
|
+
// rather than a stamp:
|
|
815
|
+
// admin — an UNBOUND session naming a seat through an administrative verb: pre-fix BOUND it
|
|
816
|
+
// (TOFU), and that is the incident. Now `keep`: the caller is untouched.
|
|
817
|
+
// rescue — a PROVISIONAL binding (a tool named an id; nobody asserted it) plus an explicit `join`
|
|
818
|
+
// of the session's real identity: pre-fix `refuse`, because binding was immovable and the
|
|
819
|
+
// only remedy was relaunching the process. Now `rebind`.
|
|
820
|
+
// guard — an ASSERTED binding plus a `join` of a DIFFERENT id: `refuse`, and this cell is the
|
|
821
|
+
// reason the other two are safe. Without it the rescue path is an impersonation route, so
|
|
822
|
+
// a build that answered `rebind` here would be worse than the defect it replaced.
|
|
823
|
+
// ⚠ The guard cell does not discriminate PRE-FIX from POST-FIX — a pre-fix build also refuses it — so
|
|
824
|
+
// it is not counted toward this probe's coverage of the change. It is NOT inert, though: it is the only
|
|
825
|
+
// cell that kills a PERMISSIVE build, which is a different axis and the one that matters if anyone
|
|
826
|
+
// later "simplifies" the rebind condition.
|
|
827
|
+
// ⭐ MUTATION MATRIX, 3 cells over 3 sites, sites enumerated by: each clause of `bindingDecision` whose
|
|
828
|
+
// outcome changes a decision the server acts on. 3 KILLED, 0 SURVIVED, baseline restored byte-identical
|
|
829
|
+
// and the worktree left clean:
|
|
830
|
+
// M1 admin verbs bind again (`return "bind"` when unbound) → admin=bind → present=false
|
|
831
|
+
// M2 rebind clause removed → rescue=refuse → present=false
|
|
832
|
+
// M3 rebind made unconditional (drops `!boundAsserted`) → guard=rebind → present=false
|
|
833
|
+
// ⛔ M3 is why the guard cell ships: without it the rescue path is an impersonation route and M3 would
|
|
834
|
+
// have SURVIVED — a probe that only proved the fix present would have passed a build strictly worse
|
|
835
|
+
// than the defect.
|
|
836
|
+
id: "administering-a-seat-does-not-bind-the-caller",
|
|
837
|
+
since: "0.26.41",
|
|
838
|
+
run: () => {
|
|
839
|
+
const admin = bindingDecision({ bound: undefined, boundAsserted: false, claimed: "seat-one", asserting: false, bindOnClaim: false });
|
|
840
|
+
const rescue = bindingDecision({ bound: "ghost", boundAsserted: false, claimed: "real-seat", asserting: true, bindOnClaim: true });
|
|
841
|
+
const guard = bindingDecision({ bound: "owner", boundAsserted: true, claimed: "impostor", asserting: true, bindOnClaim: true });
|
|
842
|
+
const ordinary = bindingDecision({ bound: undefined, boundAsserted: false, claimed: "seat-one", asserting: false, bindOnClaim: true });
|
|
843
|
+
const present = admin === "keep" && rescue === "rebind" && guard === "refuse" && ordinary === "bind";
|
|
844
|
+
return {
|
|
845
|
+
present,
|
|
846
|
+
evidence:
|
|
847
|
+
`admin verb on an unbound session -> ${admin} (pre-fix: bind) · ` +
|
|
848
|
+
`join over a PROVISIONAL binding -> ${rescue} (pre-fix: refuse) · ` +
|
|
849
|
+
`join over an ASSERTED binding -> ${guard} (regression guard, not discriminating) · ` +
|
|
850
|
+
`ordinary gated tool still TOFU-binds -> ${ordinary} -> present=${present}`,
|
|
851
|
+
};
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
// ⟨q-5d10a7bf⟩ — a typed single-line message past 300 chars reaches a pane as its own
|
|
856
|
+
// payload.summary plus a retrieve_message handle, through the same injectLine every pusher uses.
|
|
857
|
+
// Pre-change it arrived as the whole 2,000-char body.
|
|
858
|
+
id: "typed-long-message-injected-as-summary",
|
|
859
|
+
since: "0.26.42",
|
|
860
|
+
run: () => {
|
|
861
|
+
const m = { id: "m-probe", ts: 0, tag: "DM", from: "probe", text: "z".repeat(2000), record: { type: "action", payload: { summary: "do it" }, cites: [] } };
|
|
862
|
+
const line = String(injectLine(m));
|
|
863
|
+
const short = String(injectLine({ ...m, text: "short" }));
|
|
864
|
+
const present = line === " [DM 00:00 probe] do it [+2000 chars · record:action · retrieve_message id=m-probe]" && short.endsWith("] short");
|
|
865
|
+
return { present, evidence: `injectLine(typed, 2000 chars) -> ${line.length} chars: ${line.slice(0, 120)} · short typed -> ${short} -> present=${present}` };
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
// ⟨q-0b1f5da9⟩ — read_messages is headers by default: a long typed message is slimmed to its
|
|
870
|
+
// summary + handle, `full:true` returns it whole, and a human sender's message is never slimmed.
|
|
871
|
+
id: "read-messages-headers-by-default",
|
|
872
|
+
since: "0.26.42",
|
|
873
|
+
run: () => {
|
|
874
|
+
const body = "z".repeat(2000);
|
|
875
|
+
const typed = { id: "m-t", ts: 1, from: "peer", to: "me", text: body, record: { type: "action", payload: { summary: "do it" }, cites: [] } };
|
|
876
|
+
const fromHuman = { id: "m-h", ts: 2, from: "david", to: "me", text: body };
|
|
877
|
+
const opts = { readerIsHuman: false, humans: new Set(["david"]) };
|
|
878
|
+
const slim = headersOf([typed, fromHuman] as never[], opts) as unknown as { text: string }[];
|
|
879
|
+
const full = headersOf([typed, fromHuman] as never[], { ...opts, full: true }) as unknown as { text: string }[];
|
|
880
|
+
const present =
|
|
881
|
+
slim[0].text === "do it [+2000 chars · record:action · retrieve_message id=m-t]" &&
|
|
882
|
+
slim[1].text === body && full[0].text === body && full[1].text === body;
|
|
883
|
+
return { present, evidence: `default read: typed -> ${slim[0].text.length} chars, human sender -> ${slim[1].text.length} chars [required 2000] · full:true -> ${full[0].text.length}/${full[1].text.length} chars [required 2000/2000] -> present=${present}` };
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
{
|
|
802
887
|
id: "coverage-counts-the-in-flight-population",
|
|
803
888
|
since: "0.26.40",
|
|
804
889
|
run: () => {
|
|
@@ -1097,6 +1182,7 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
|
|
|
1097
1182
|
/* ── the verb ──────────────────────────────────────────────────────────────── */
|
|
1098
1183
|
|
|
1099
1184
|
import { resolveServerIdentity } from "./server-identity.js";
|
|
1185
|
+
import { bindingDecision } from "./binding-decision.js";
|
|
1100
1186
|
import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
|
|
1101
1187
|
import { isInFlightStatus, gateClaimsIn, mergeWindowLivePopulation } from "./tools/stall.js";
|
|
1102
1188
|
import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
|
package/src/server.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
3
3
|
import { readyProfileStartupLine } from "../hooks/submit.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { bindingDecision } from "./binding-decision.js";
|
|
5
6
|
import { HERDR, initTransportFromConfig } from "./transports/index.js";
|
|
6
7
|
import { startTailOnBind, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
7
8
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
@@ -168,6 +169,11 @@ function jsonResult(data: unknown) {
|
|
|
168
169
|
// make pid-liveness meaningless.
|
|
169
170
|
function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {}): McpServer {
|
|
170
171
|
let bound = initialBound;
|
|
172
|
+
// ⟨q-bf7fd8f3⟩ HOW the binding was acquired, not just what it is. A binding a
|
|
173
|
+
// gated tool TOFU'd on the caller's behalf is PROVISIONAL: the session never
|
|
174
|
+
// said "I am this", it merely named an id while unbound. An ASSERTED binding
|
|
175
|
+
// (env/bearer pre-bind, or an explicit `join`) is sticky and may not be replaced.
|
|
176
|
+
let boundAsserted = initialBound !== undefined;
|
|
171
177
|
const trackSession = opts.trackSession ?? false;
|
|
172
178
|
let sessionMarker: string | undefined;
|
|
173
179
|
let exitHooksInstalled = false;
|
|
@@ -344,13 +350,16 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
344
350
|
if (field) {
|
|
345
351
|
const claimed = args[field];
|
|
346
352
|
if (typeof claimed === "string") {
|
|
353
|
+
const decision = bindingDecision({ bound, boundAsserted, claimed, asserting: false, bindOnClaim });
|
|
347
354
|
if (bound === undefined) {
|
|
348
|
-
if (
|
|
355
|
+
if (decision === "bind") {
|
|
349
356
|
// TOFU: first claim wins, then sticky — but only after the
|
|
350
357
|
// first-claim guard agrees the id isn't someone else's live
|
|
351
358
|
// session (see guardFirstClaim).
|
|
352
359
|
const via = await guardFirstClaim(claimed, args);
|
|
353
360
|
bound = claimed;
|
|
361
|
+
// provisional: no one asserted this identity, a tool just named it.
|
|
362
|
+
boundAsserted = false;
|
|
354
363
|
recordSessionBinding(claimed, via);
|
|
355
364
|
// ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
|
|
356
365
|
startTailOnBind(claimed);
|
|
@@ -404,16 +413,30 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
404
413
|
async (args: Record<string, unknown>) => {
|
|
405
414
|
const claimed = args["agentId"];
|
|
406
415
|
if (typeof claimed === "string") {
|
|
407
|
-
|
|
416
|
+
// ⟨q-bf7fd8f3⟩ `join` is the one place a session SAYS who it is, so it is the
|
|
417
|
+
// one place a PROVISIONAL binding may be corrected. Measured 2026-09-28: a
|
|
418
|
+
// resumer's fresh server was TOFU-bound to another seat by `attach_agent`,
|
|
419
|
+
// after which every verb — including read-only `status` — refused it as
|
|
420
|
+
// itself, and the only documented remedy was relaunching the process. A
|
|
421
|
+
// session that can never say "no, I am this" has to be killed to be fixed.
|
|
422
|
+
const jd = bindingDecision({ bound, boundAsserted, claimed, asserting: true, bindOnClaim: true });
|
|
423
|
+
if (jd === "bind" || jd === "rebind") {
|
|
408
424
|
const via = await guardFirstClaim(claimed, args);
|
|
425
|
+
const rebound = bound !== undefined && bound !== claimed;
|
|
409
426
|
bound = claimed;
|
|
410
|
-
|
|
427
|
+
boundAsserted = true;
|
|
428
|
+
recordSessionBinding(claimed, rebound ? `${via}+rebind` : via);
|
|
411
429
|
// ⛔ THE TAIL STARTS HERE, AT THE BINDING — not from an env var no seat sets.
|
|
412
430
|
startTailOnBind(claimed);
|
|
413
431
|
} else if (bound !== claimed) {
|
|
414
432
|
throw new Error(
|
|
415
|
-
`identity bound to '${bound}'; rejected attempt to act as '${claimed}'
|
|
433
|
+
`identity bound to '${bound}'; rejected attempt to act as '${claimed}'. ` +
|
|
434
|
+
`That binding was ASSERTED (join/token/env), so it is not provisional and ` +
|
|
435
|
+
`cannot be replaced from inside this session.`,
|
|
416
436
|
);
|
|
437
|
+
} else {
|
|
438
|
+
// re-joining as itself asserts the binding it already holds
|
|
439
|
+
boundAsserted = true;
|
|
417
440
|
}
|
|
418
441
|
}
|
|
419
442
|
return jsonResult(await (joinTool as (a: Record<string, unknown>) => Promise<unknown>)(args));
|
|
@@ -492,7 +515,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
492
515
|
|
|
493
516
|
addTool(
|
|
494
517
|
"read_messages",
|
|
495
|
-
"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.",
|
|
518
|
+
"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. Messages come back as HEADERS: a typed message past ~300 chars or spanning lines is its summary (or first line) plus a `retrieve_message id=…` handle; pass full:true for whole bodies. Advances the per-channel cursor unless peek=true.",
|
|
496
519
|
readMessagesSchema,
|
|
497
520
|
gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
498
521
|
);
|
|
@@ -611,28 +634,28 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
611
634
|
"attach_agent",
|
|
612
635
|
"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.",
|
|
613
636
|
attachAgentSchema,
|
|
614
|
-
gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown
|
|
637
|
+
gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
615
638
|
);
|
|
616
639
|
|
|
617
640
|
addTool(
|
|
618
641
|
"detach_agent",
|
|
619
642
|
"Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
|
|
620
643
|
detachAgentSchema,
|
|
621
|
-
gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown
|
|
644
|
+
gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
622
645
|
);
|
|
623
646
|
|
|
624
647
|
addTool(
|
|
625
648
|
"report_transport",
|
|
626
649
|
"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.",
|
|
627
650
|
reportTransportSchema,
|
|
628
|
-
gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown
|
|
651
|
+
gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
629
652
|
);
|
|
630
653
|
|
|
631
654
|
addTool(
|
|
632
655
|
"clear_transport",
|
|
633
656
|
"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.",
|
|
634
657
|
clearTransportSchema,
|
|
635
|
-
gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown
|
|
658
|
+
gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
|
|
636
659
|
);
|
|
637
660
|
|
|
638
661
|
addTool(
|
package/src/tools/messaging.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { priorDeliveries, replayInfo } from "../../hooks/replay.mjs";
|
|
|
23
23
|
// ⟨q-e5cb3538⟩ The pane-unsafe byte class is single-sourced there too, shared with the renderer.
|
|
24
24
|
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
25
25
|
import { findControlByte } from "../../hooks/control-bytes.mjs";
|
|
26
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
27
|
+
import { slimText, SLIM_LENGTH } from "../../hooks/tier.mjs";
|
|
26
28
|
import { renderRecord } from "./render.js";
|
|
27
29
|
import path from "node:path";
|
|
28
30
|
import {
|
|
@@ -574,6 +576,7 @@ export const readMessagesSchema = {
|
|
|
574
576
|
limit: z.number().int().positive().max(500).optional(),
|
|
575
577
|
peek: z.boolean().optional(),
|
|
576
578
|
sinceTs: z.number().optional(),
|
|
579
|
+
full: z.boolean().optional(),
|
|
577
580
|
};
|
|
578
581
|
|
|
579
582
|
export async function readMessagesTool(args: {
|
|
@@ -583,6 +586,7 @@ export async function readMessagesTool(args: {
|
|
|
583
586
|
limit?: number;
|
|
584
587
|
peek?: boolean;
|
|
585
588
|
sinceTs?: number;
|
|
589
|
+
full?: boolean;
|
|
586
590
|
}) {
|
|
587
591
|
const file = sourceFile(args.source, args.agentId, args.room);
|
|
588
592
|
const all = await readJsonl<Message | StatusEntry>(file);
|
|
@@ -666,9 +670,26 @@ export async function readMessagesTool(args: {
|
|
|
666
670
|
return replay ? { ...e, replay } : e;
|
|
667
671
|
});
|
|
668
672
|
|
|
673
|
+
// ⟨q-0b1f5da9⟩ HEADERS BY DEFAULT. A drain returned every body whole — one 78-message inbox was
|
|
674
|
+
// 102,423 chars, the largest single input in a seat's session. A message the transport would slim is
|
|
675
|
+
// slimmed here with the SAME rule (hooks/tier.mjs slimText); an untyped one past SLIM_LENGTH keeps
|
|
676
|
+
// its first line cut to SLIM_LENGTH. Either way `text` ends in the retrieve_message handle, and the
|
|
677
|
+
// record keeps its type, summary and cites. `full:true` is today's output byte for byte, and a human
|
|
678
|
+
// reader is never slimmed.
|
|
679
|
+
// A message FROM a human is never slimmed either: David's instructions are untyped prose, the push
|
|
680
|
+
// path has never truncated them, and a cut instruction is worse than a long one.
|
|
681
|
+
const humans = new Set<string>();
|
|
682
|
+
const readerIsHuman = await isKnownHuman(args.agentId);
|
|
683
|
+
if (!args.full && args.source !== "status" && !readerIsHuman) {
|
|
684
|
+
for (const from of new Set(annotated.map((e) => (e as Message).from))) {
|
|
685
|
+
if (from && (await isKnownHuman(from))) humans.add(from);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
const slim = headersOf(annotated, { full: args.full, source: args.source, readerIsHuman, humans });
|
|
689
|
+
|
|
669
690
|
return {
|
|
670
691
|
ok: true,
|
|
671
|
-
messages:
|
|
692
|
+
messages: slim,
|
|
672
693
|
totalNew,
|
|
673
694
|
returned: annotated.length,
|
|
674
695
|
room: args.source === "room" ? normalizeRoom(args.room) : undefined,
|
|
@@ -676,6 +697,29 @@ export async function readMessagesTool(args: {
|
|
|
676
697
|
};
|
|
677
698
|
}
|
|
678
699
|
|
|
700
|
+
export function headersOf<T extends Message | StatusEntry>(
|
|
701
|
+
entries: T[],
|
|
702
|
+
opts: { full?: boolean; source?: string; readerIsHuman: boolean; humans: ReadonlySet<string> },
|
|
703
|
+
): T[] {
|
|
704
|
+
if (opts.full || opts.source === "status" || opts.readerIsHuman) return entries;
|
|
705
|
+
return entries.map((e) => (opts.humans.has((e as Message).from) ? e : (slimMessage(e as Message) as unknown as T)));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function slimMessage(m: Message): Message {
|
|
709
|
+
const text = m.text ?? "";
|
|
710
|
+
// Only `text` is rewritten. The record is left whole: its payload is not always a summary (a
|
|
711
|
+
// `decision` carries options and a recommendation, a `verdict` carries the head sha a merge keys
|
|
712
|
+
// on), and a header that dropped those would be a read that lost the decision.
|
|
713
|
+
const typed = slimText(m) as string | null;
|
|
714
|
+
if (typed !== null) return { ...m, text: typed };
|
|
715
|
+
// Untyped: only a LONG, ordinary message is cut. A system notice, an urgent message and a control
|
|
716
|
+
// command are never cut, nor is a short multi-line one — the push path leaves all of these whole.
|
|
717
|
+
if (m.record || !m.id || m.system || m.urgent || m.control || text.length <= SLIM_LENGTH) return m;
|
|
718
|
+
let first = text.split("\n")[0].slice(0, SLIM_LENGTH);
|
|
719
|
+
if (/[\ud800-\udbff]$/.test(first)) first = first.slice(0, -1); // never leave half a surrogate pair
|
|
720
|
+
return { ...m, text: `${first} [+${text.length - first.length} chars · untyped · retrieve_message id=${m.id}]` };
|
|
721
|
+
}
|
|
722
|
+
|
|
679
723
|
// Lossless summary of a stashed backlog slice: surfaces error/failure posts
|
|
680
724
|
// verbatim (the lines that usually matter most in a flood) and collapses the
|
|
681
725
|
// rest to counts. Mirrors headroom's content-aware digest, kept deliberately
|
package/src/tools/registry.ts
CHANGED
|
@@ -292,7 +292,9 @@ export const heartbeatSchema = { agentId: z.string().min(1) };
|
|
|
292
292
|
* ⟨q-18a719c5⟩ THE ANSWERING SERVER'S IDENTITY — what `serverSpread` places a seat by. It exists
|
|
293
293
|
* only inside the process that serves the seat, so every path on which a session binds an
|
|
294
294
|
* identity stamps it: `register` (which `join` calls), the first-claim binding in server.ts
|
|
295
|
-
* (a server that restarts and re-binds through
|
|
295
|
+
* (a server that restarts and re-binds through a gated tool — ⟨q-bf7fd8f3⟩: NO LONGER
|
|
296
|
+
* `attach_agent`, `detach_agent`, `report_transport` or `clear_transport`, which pass
|
|
297
|
+
* `bindOnClaim: false` because administering a seat is not a claim to BE it),
|
|
296
298
|
* `rename_agent` (the renaming session serves the new id), and `heartbeat`. Before this,
|
|
297
299
|
* only `heartbeat` stamped, no code path called it, and `register` rebuilt the entry and
|
|
298
300
|
* dropped whatever it had written.
|
|
@@ -655,6 +657,22 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
|
|
|
655
657
|
reasons.push(
|
|
656
658
|
`live ${marker.transport} transport (pid ${marker.pid}${targetOf(marker) ? `, pane ${targetOf(marker)}` : ""})`,
|
|
657
659
|
);
|
|
660
|
+
}
|
|
661
|
+
// ⛔⛆ ⟨q-f995c3c7⟩ THE OWN-PANE DETERMINATION IS NOT GATED ON THE MARKER READING LIVE, AND THE NESTING
|
|
662
|
+
// THAT GATED IT WAS THE DEFECT. Everything below used to sit inside the `isMarkerLive` branch above, so a
|
|
663
|
+
// marker herdr no longer recognises — `paneExists` false, which `isMarkerLive` correctly calls dead —
|
|
664
|
+
// skipped the escape entirely. `live` then stayed true from the REGISTRY HEARTBEAT ALONE and the seat was
|
|
665
|
+
// refused with no way out but a token.
|
|
666
|
+
// ⚠ That is this function's own documented case (1): "own pane + a marker NO PROCESS HOLDS -> ACCEPTED".
|
|
667
|
+
// A DEAD MARKER IS A MARKER NO PROCESS HOLDS, so the gate contradicted the requirement it implements.
|
|
668
|
+
// ⭐ REAL TRIGGER, not hypothetical: any seat whose pane id herdr stops recognising — a recreated pane, a
|
|
669
|
+
// renamed workspace, a herdr restart that renumbers. 4 of 14 seats moved panes across the 2026-09-28
|
|
670
|
+
// standdown and nobody hit this only because herdr keeps an old id RESOLVING after a move; that is luck,
|
|
671
|
+
// and a renumbering restart removes it.
|
|
672
|
+
// ⛔ AND IT WIDENS NOTHING: `samePane` still requires the marker to name THIS process's pane, and the
|
|
673
|
+
// permit at `server.ts` pairs it with `boundElsewhere === 0`, so a genuinely live SESSION still refuses.
|
|
674
|
+
// Being in one's own pane has never been the whole permit and is not now.
|
|
675
|
+
if (marker) {
|
|
658
676
|
// ⟨q-f995c3c7⟩ THE SAME SEAT RESTARTING INTO ITS OWN PANE, for herdr as well as tmux. This read
|
|
659
677
|
// `process.env.TMUX_PANE` alone, which a herdr seat never has, so the exception could not fire
|
|
660
678
|
// for the fleet that runs on herdr: every seat was refused by its own leftover marker (6 of 6 on
|
package/src/typed-records.ts
CHANGED
|
@@ -164,11 +164,14 @@ export function typedRecordGuidance(s: RecordSuggestion): string {
|
|
|
164
164
|
// large share of real traffic, so this fires on domination, not on presence.
|
|
165
165
|
export const FYI_DOMINANCE_THRESHOLD = 0.75;
|
|
166
166
|
|
|
167
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
168
|
+
import { slimText, SLIM_LENGTH } from "../hooks/tier.mjs";
|
|
169
|
+
|
|
167
170
|
export type TypedRecordStats = {
|
|
168
171
|
total: number;
|
|
169
172
|
typed: number;
|
|
170
173
|
coverage: number;
|
|
171
|
-
/** Bytes
|
|
174
|
+
/** Bytes still delivered whole: below line 1 of UNTYPED messages, plus TYPED bodies past SLIM_LENGTH the transport cannot slim (no summary, no id). */
|
|
172
175
|
unslimmedBytes: number;
|
|
173
176
|
/** count by record.type, plus `untyped`. */
|
|
174
177
|
distribution: Record<string, number>;
|
|
@@ -177,8 +180,8 @@ export type TypedRecordStats = {
|
|
|
177
180
|
note: string;
|
|
178
181
|
};
|
|
179
182
|
|
|
180
|
-
/** `messages` is any array of stored messages ({text, record?}). */
|
|
181
|
-
export function typedRecordStats(messages: ReadonlyArray<{ text?: string; record?: { type?: string } }>): TypedRecordStats {
|
|
183
|
+
/** `messages` is any array of stored messages ({id?, text, record?}). */
|
|
184
|
+
export function typedRecordStats(messages: ReadonlyArray<{ id?: string; text?: string; record?: { type?: string; payload?: { summary?: unknown } } }>): TypedRecordStats {
|
|
182
185
|
const distribution: Record<string, number> = {};
|
|
183
186
|
let typed = 0;
|
|
184
187
|
let unslimmedBytes = 0;
|
|
@@ -186,11 +189,14 @@ export function typedRecordStats(messages: ReadonlyArray<{ text?: string; record
|
|
|
186
189
|
for (const m of messages) {
|
|
187
190
|
const type = typeof m.record?.type === "string" ? m.record.type : undefined;
|
|
188
191
|
distribution[type ?? "untyped"] = (distribution[type ?? "untyped"] ?? 0) + 1;
|
|
192
|
+
const text = m.text ?? "";
|
|
189
193
|
if (type) {
|
|
190
194
|
typed++;
|
|
195
|
+
// ⟨q-ee679d20⟩ A typed single-line body was never counted — only bytes after a newline were — so
|
|
196
|
+
// the traffic slimming missed read as zero cost. Count what the transport cannot slim.
|
|
197
|
+
if (text.length > SLIM_LENGTH && slimText(m) === null) unslimmedBytes += Buffer.byteLength(text, "utf8");
|
|
191
198
|
continue;
|
|
192
199
|
}
|
|
193
|
-
const text = m.text ?? "";
|
|
194
200
|
const nl = text.indexOf("\n");
|
|
195
201
|
if (nl !== -1) unslimmedBytes += Buffer.byteLength(text.slice(nl + 1), "utf8");
|
|
196
202
|
}
|
|
@@ -209,7 +215,7 @@ export function typedRecordStats(messages: ReadonlyArray<{ text?: string; record
|
|
|
209
215
|
`stopped carrying information. Agents are typing to pass the gate, which is 12.5 FAILING, not holding.`
|
|
210
216
|
: verdict === "healthy"
|
|
211
217
|
? `${(coverage * 100).toFixed(0)}% typed with a spread distribution — the reader-side cost is paid down and the type still discriminates.`
|
|
212
|
-
: `${(coverage * 100).toFixed(0)}% typed; ${unslimmedBytes.toLocaleString("en-US")} bytes still
|
|
218
|
+
: `${(coverage * 100).toFixed(0)}% typed; ${unslimmedBytes.toLocaleString("en-US")} bytes are still delivered whole (untyped past line 1, typed with no summary to stand in), ` +
|
|
213
219
|
`carried by every reader.`;
|
|
214
220
|
|
|
215
221
|
return { total, typed, coverage: Number(coverage.toFixed(3)), unslimmedBytes, distribution, fyiShareOfTyped: Number(fyiShareOfTyped.toFixed(3)), verdict, note };
|