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.
@@ -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 (bindOnClaim) {
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
- if (bound === undefined) {
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
- recordSessionBinding(claimed, via);
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(
@@ -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: annotated,
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
@@ -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 any gated tool, `attach_agent` included),
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
@@ -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 below the first line of UNTYPED messages — the cost still being paid. */
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 sit below line 1 in untyped messages, ` +
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 };