agent-coord-mcp 0.26.30 → 0.26.32

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/src/server.ts CHANGED
@@ -18,7 +18,10 @@ import { capabilitiesSchema, capabilitiesTool } from "./capabilities.js";
18
18
  import {
19
19
  ensureDirs,
20
20
  getTokenMap,
21
+ TOKENS_FILE,
21
22
  reloadTokenMapSync,
23
+ refreshTokenMapIfChanged,
24
+ type TokenMapRefresh,
22
25
  sessionFile,
23
26
  listSessionFiles,
24
27
  readJson,
@@ -81,6 +84,8 @@ import {
81
84
  setRoomTopicTool,
82
85
  statusSchema,
83
86
  statusTool,
87
+ readPaneSchema,
88
+ readPaneTool,
84
89
  unregisterSchema,
85
90
  unregisterTool,
86
91
  quitSchema,
@@ -306,7 +311,10 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
306
311
  leakLine +
307
312
  ` A live incumbent can only be superseded with ITS OWN TOKEN now (mint one: scripts/coord-token.mjs add ${claimed}, then SIGHUP the bus) — ` +
308
313
  `\`force:true\` binds a SECOND session to one identity rather than stopping the first, which is the defect this refusal exists to prevent, not a step past it. ` +
309
- `If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}. ` +
314
+ `If you ARE '${claimed}' restarting, re-join from its own pane${leaks.length ? " once nothing else holds it" : ""} — ` +
315
+ `⟨q-f995c3c7⟩ ANY SERVER RESTART REACHES THIS, including ones no operator sees: \`/model\` restarts this MCP server, ` +
316
+ `and so do a reconnect, a config reload and a crash-restart. The seat is then a NEW process meeting its own leftover ` +
317
+ `record, which is why this fired on seats nobody had touched. ` +
310
318
  `If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
311
319
  );
312
320
  }
@@ -435,6 +443,13 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
435
443
  gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
436
444
  );
437
445
 
446
+ addTool(
447
+ "read_pane",
448
+ "Read what a seat's pane is showing, THROUGH the seat's transport (⟨q-927e28cd⟩, ADR-023 d4). Read-only: it names no marker field, shells out to nothing, and deletes nothing. Answers `state: \"read\"` with the screen text, or `state: \"unknown\"` with the reason — a pane that is gone, a transport that cannot read panes from this host (tmux-push-remote), or no marker. Never an empty or stale screen. `lines` caps the read (default 200, max 2000); `colors: true` keeps ANSI styling.",
449
+ readPaneSchema,
450
+ gate("agentId", readPaneTool as unknown as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
451
+ );
452
+
438
453
  addTool(
439
454
  "heartbeat",
440
455
  "Refresh this agent's lastHeartbeat timestamp.",
@@ -1076,15 +1091,39 @@ async function startHttp(port: number): Promise<void> {
1076
1091
  return transport;
1077
1092
  }
1078
1093
 
1094
+ // ⟨q-54adf191⟩ ONE LINE PER RELOAD, AND ONE PER REFUSAL. A reload nobody can see is
1095
+ // indistinguishable from the bug: the operator reads a 401 and suspects the daemon.
1096
+ function reportTokenRefresh(r: TokenMapRefresh): void {
1097
+ if (r.changed) {
1098
+ console.error(`[agent-coord-mcp] token map reloaded (${r.count} agents) — ${TOKENS_FILE} ${r.reason}`);
1099
+ return;
1100
+ }
1101
+ if (r.refused) {
1102
+ console.error(
1103
+ `[agent-coord-mcp] token map NOT reloaded: ${r.refused} — keeping the previous map (${r.count} agents)`,
1104
+ );
1105
+ }
1106
+ }
1107
+
1079
1108
  // Reverse-lookup: extract bearer from header, map → bound agent. Returns
1080
1109
  // undefined if no map is configured (advisory mode); throws-like return of
1081
1110
  // null if the bearer doesn't match any known agent (caller responds 401).
1082
1111
  function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string } {
1083
1112
  if (!authHeader || !authHeader.startsWith("Bearer ")) return { ok: false };
1084
1113
  const bearer = authHeader.slice("Bearer ".length);
1114
+ // ⟨q-54adf191⟩ Pick up a tokens.json that changed while this daemon ran. Rate-limited, so the
1115
+ // steady state is one stat; the file is read only when its stamp moved. Every outcome that is
1116
+ // not "nothing happened" is SAID — a silent reload is the defect this row exists for.
1117
+ reportTokenRefresh(refreshTokenMapIfChanged());
1085
1118
  const tokenMap = getTokenMap();
1086
1119
  if (tokenMap) {
1087
- const agent = tokenMap.get(bearer);
1120
+ let agent = tokenMap.get(bearer);
1121
+ if (!agent) {
1122
+ // An unknown bearer is exactly the symptom of a token minted since the last check, so it
1123
+ // forces one check past the rate limit. It cannot grant anything the file does not hold.
1124
+ reportTokenRefresh(refreshTokenMapIfChanged({ force: true }));
1125
+ agent = getTokenMap()?.get(bearer);
1126
+ }
1088
1127
  // ⛔ THE ONLY PLACE A PRE-BOUND SEAT CAN GET A TAIL. The `startTailOnBind` sites in the tool
1089
1128
  // path all sit behind `bound === undefined` — the TOFU claim. Here identity arrives already
1090
1129
  // decided by the bearer, that branch is unreachable, and before this line NO tail ever
package/src/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { statSync, promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
@@ -199,6 +199,10 @@ export function getTokenMap(): Map<string, string> | null {
199
199
  if (!tokenMapInitialized) {
200
200
  tokenMapCache = readTokenMapSync();
201
201
  tokenMapInitialized = true;
202
+ // Stamp the file we just read, or the first change check would report a map that never
203
+ // changed as newly "appeared" and re-read a file nobody touched.
204
+ tokenMapStamp = stampOf();
205
+ tokenMapCheckedAt = Date.now();
202
206
  }
203
207
  return tokenMapCache;
204
208
  }
@@ -209,9 +213,134 @@ export function getTokenMap(): Map<string, string> | null {
209
213
  export function reloadTokenMapSync(): Map<string, string> | null {
210
214
  tokenMapCache = readTokenMapSync();
211
215
  tokenMapInitialized = true;
216
+ // Keep the change-detection stamp in step, or the next stat would read the same file again.
217
+ tokenMapStamp = stampOf();
218
+ tokenMapCheckedAt = Date.now();
212
219
  return tokenMapCache;
213
220
  }
214
221
 
222
+ // ⟨q-54adf191⟩ RELOAD WHEN THE FILE CHANGES, AND SAY WHAT HAPPENED.
223
+ //
224
+ // The daemon read tokens.json once at startup and had no watcher, so a token minted while it ran
225
+ // was refused with a 401 that looks exactly like a broken daemon — every file on disk correct. The
226
+ // operator had to remember SIGHUP. This weakens nothing: the file is the authority either way, and
227
+ // a reload cannot grant a bearer the file does not contain.
228
+ //
229
+ // THE TRIGGER IS A STAMP CHECK ON THE AUTH PATH, NOT A WATCHER AND NOT A TIMER.
230
+ // · a watcher (fs.watch) is per-platform and silently unreliable on network and virtualised
231
+ // filesystems — exactly where a shared bus dir often lives — and it would hold the event loop
232
+ // of an otherwise idle daemon;
233
+ // · a poll timer wakes a process that may serve nothing for hours, and still misses a change
234
+ // that lands between ticks;
235
+ // · a stat on a request the daemon is already serving costs one syscall, is rate-limited, and
236
+ // reads the file ONLY when the stamp moved. An unknown bearer forces one extra check, so a
237
+ // newly minted token authenticates on its first call rather than on the next tick.
238
+ // SIGHUP stays as the explicit path and still forces a read.
239
+ const TOKEN_RELOAD_MIN_MS = (() => {
240
+ const n = Number(process.env.AGENT_COORD_TOKEN_RELOAD_MS);
241
+ return Number.isFinite(n) && n >= 0 ? n : 1000;
242
+ })();
243
+
244
+ /** What a refresh did. `changed` means the in-process map was REPLACED. */
245
+ export type TokenMapRefresh = {
246
+ changed: boolean;
247
+ reason: "unbound" | "rate-limited" | "unchanged" | "changed" | "appeared" | "malformed" | "vanished";
248
+ count: number | null;
249
+ /** Present only when the reload was REFUSED; the previous map still serves. */
250
+ refused?: string;
251
+ };
252
+
253
+ // The file identity we compare against: mtime, size AND inode.
254
+ //
255
+ // ⛔ THE INODE IS LOAD-BEARING, NOT BELT-AND-BRACES. `coord-token` rotates by writing a temp file and
256
+ // renaming it into place, so the replacement is a DIFFERENT INODE that can carry the SAME mtime and
257
+ // the SAME size (a rotated token is the same length as the one it replaces). Without the inode this
258
+ // stamp reads "unchanged" and the daemon keeps serving the REVOKED bearer until someone remembers
259
+ // SIGHUP — the exact defect ⟨q-54adf191⟩ exists to end. Pinned by "a rotation that keeps mtime AND
260
+ // size" below, which goes red the moment the inode leaves this line.
261
+ //
262
+ // ⚠ STATED LIMIT, and the one case this stamp CANNOT see: a rewrite IN PLACE, at equal length, with
263
+ // mtime forced back (`utimes`). Same inode, same size, same mtime is indistinguishable from no write
264
+ // at all, so a bearer revoked that way keeps working until SIGHUP. Measured, and asserted below as a
265
+ // known limit rather than left to be discovered.
266
+ //
267
+ // ⚠⚠ WHY WE ARE SAFE TODAY IS ANOTHER TOOL'S IMPLEMENTATION DETAIL, NOT A PROPERTY OF THIS ONE:
268
+ // `coord-token` never writes this file in place — every write is temp+rename, which moves the inode.
269
+ // The day anything else writes `tokens.json` in place (an editor with a truncating save, a config
270
+ // manager, a hand-rolled script), this comment is the only warning that the reload will not see it.
271
+ // A content hash would close it, at the cost of reading the whole file on every check instead of one
272
+ // stat; that trade was not taken here and is the thing to revisit if that day comes.
273
+ let tokenMapStamp: string | null = null;
274
+ let tokenMapCheckedAt = 0;
275
+
276
+ function stampOf(): string | null {
277
+ try {
278
+ const st = statSync(TOKENS_FILE);
279
+ return `${st.mtimeMs}:${st.size}:${st.ino}`;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /** The state a refresh decides against: what is loaded, the file identity it came from, when it was last checked. */
286
+ export type TokenMapState = { map: Map<string, string> | null; stamp: string | null; checkedAt: number };
287
+ /** The filesystem, as the decision sees it. Injected so the rules can be exercised without a disk. */
288
+ export type TokenMapIo = { stamp: () => string | null; read: () => Map<string, string> | null };
289
+
290
+ /**
291
+ * THE WHOLE RULE, IN ONE PURE FUNCTION. The live path and the capability probe both call this, so
292
+ * there is no second implementation of "did it change, and may the new map replace the old one".
293
+ */
294
+ export function decideTokenMapRefresh(
295
+ state: TokenMapState,
296
+ io: TokenMapIo,
297
+ opts: { force?: boolean; now?: number } = {},
298
+ ): { next: TokenMapState; result: TokenMapRefresh } {
299
+ const now = opts.now ?? Date.now();
300
+ const keep = (result: TokenMapRefresh, checkedAt = state.checkedAt) => ({ next: { ...state, checkedAt }, result });
301
+ // A daemon with no tokens.json is in advisory mode: there is no map to keep fresh, and a file
302
+ // APPEARING must not silently promote it into bound mode mid-flight.
303
+ if (state.map === null) return keep({ changed: false, reason: "unbound", count: null });
304
+ if (!opts.force && now - state.checkedAt < TOKEN_RELOAD_MIN_MS) {
305
+ return keep({ changed: false, reason: "rate-limited", count: state.map.size });
306
+ }
307
+ const size = state.map.size;
308
+ const stamp = io.stamp();
309
+ if (stamp === null) {
310
+ // ⛔ A VANISHED FILE IS A REFUSAL, NEVER A DOWNGRADE. Dropping to advisory mode because the file
311
+ // is missing would turn `rm tokens.json` into "every bearer is now accepted".
312
+ return keep({ changed: false, reason: "vanished", count: size, refused: `${TOKENS_FILE} is gone` }, now);
313
+ }
314
+ if (stamp === state.stamp) return keep({ changed: false, reason: "unchanged", count: size }, now);
315
+ try {
316
+ const next = io.read();
317
+ if (next === null) {
318
+ return keep({ changed: false, reason: "vanished", count: size, refused: `${TOKENS_FILE} is gone` }, now);
319
+ }
320
+ return {
321
+ next: { map: next, stamp, checkedAt: now },
322
+ result: { changed: true, reason: state.stamp === null ? "appeared" : "changed", count: next.size },
323
+ };
324
+ } catch (e) {
325
+ // The stamp is deliberately NOT advanced: a half-written file that is fixed in place, with no
326
+ // further change to mtime or size, must still be picked up on the next check.
327
+ return keep({ changed: false, reason: "malformed", count: size, refused: (e as Error).message }, now);
328
+ }
329
+ }
330
+
331
+ export function refreshTokenMapIfChanged(opts: { force?: boolean; now?: number } = {}): TokenMapRefresh {
332
+ if (!tokenMapInitialized) getTokenMap();
333
+ const { next, result } = decideTokenMapRefresh(
334
+ { map: tokenMapCache, stamp: tokenMapStamp, checkedAt: tokenMapCheckedAt },
335
+ { stamp: stampOf, read: readTokenMapSync },
336
+ opts,
337
+ );
338
+ tokenMapCache = next.map;
339
+ tokenMapStamp = next.stamp;
340
+ tokenMapCheckedAt = next.checkedAt;
341
+ return result;
342
+ }
343
+
215
344
  // Atomically rotate the token entry for an agent rename (used by
216
345
  // rename_agent so the same bearer continues to authenticate the renamed
217
346
  // identity). No-op if the file is absent or the old id isn't in the map.
@@ -11,3 +11,4 @@ export * from "./worktrees.js";
11
11
  export * from "./stall.js";
12
12
  export * from "./events.js";
13
13
  export * from "./record-events.js";
14
+ export * from "./pane.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ⟨q-927e28cd⟩ ADR-023 d4, NARROW SLICE — A CONSUMER READS A SEAT'S PANE THROUGH THE BUS.
3
+ *
4
+ * The console showed every herdr seat's pane as `410 "pane not found (session gone?)"` for a
5
+ * live seat, because it captured panes itself through tmux and a herdr pane is not a tmux
6
+ * pane. ADR-023 d4 says a consumer asks the bus rather than learning each transport; this is
7
+ * the one question it could not ask, because no bus verb returned what a pane shows.
8
+ *
9
+ * ⛔ READ THROUGH THE TRANSPORT, NEVER AROUND IT (the coordinator's GO, and ADR-023 d1). This
10
+ * module names no field of the marker: it hands the whole marker to `Transport.readPane`,
11
+ * which decides which pane it is, whether it is its own kind, and whether it is still there.
12
+ * qa counted 47 marker-internal reads across 5 modules outside src/transports/; this is not
13
+ * the 48th, and a test says so by reading this file.
14
+ *
15
+ * ⛔ READ-ONLY, INCLUDING OF THE STATE DIR. `loadLiveTransports` deletes markers it judges
16
+ * dead, which is a write; this reads the one marker file and deletes nothing. Whether the
17
+ * pane is gone is the transport's answer, and it answers UNKNOWN, never an empty screen.
18
+ */
19
+ import { z } from "zod";
20
+ import type { PaneRead, Transport, TransportMarker } from "../transports/types.js";
21
+ import { activeTransport } from "../transports/index.js";
22
+ import { readJson, transportFile } from "../store.js";
23
+
24
+ export const readPaneSchema = {
25
+ agentId: z.string().min(1),
26
+ /** The seat whose pane to read. */
27
+ targetAgentId: z.string().min(1),
28
+ lines: z.number().int().positive().max(2000).optional(),
29
+ /** Keep the terminal's styling (ANSI escapes) rather than plain text. */
30
+ colors: z.boolean().optional(),
31
+ };
32
+
33
+ export type ReadPaneResult = { ok: true; agentId: string } & PaneRead;
34
+
35
+ export async function readPaneTool(
36
+ args: { agentId: string; targetAgentId: string; lines?: number; colors?: boolean },
37
+ transport: Transport | null | undefined = activeTransport(),
38
+ ): Promise<ReadPaneResult> {
39
+ const agentId = args.targetAgentId;
40
+ const marker = await readJson<TransportMarker | null>(transportFile(agentId), null);
41
+ if (!marker) return { ok: true, agentId, state: "unknown", why: "no transport marker — the seat is not attached, so there is no pane on record to read" };
42
+ if (!transport) return { ok: true, agentId, state: "unknown", why: "no transport is wired in this server process, so nothing can read a pane" };
43
+ if (typeof transport.readPane !== "function") return { ok: true, agentId, state: "unknown", why: `the ${transport.kind} transport cannot read a pane from this host` };
44
+ try {
45
+ return { ok: true, agentId, ...(await transport.readPane(marker, { lines: args.lines, format: args.colors ? "ansi" : "text" })) };
46
+ } catch (e) {
47
+ return { ok: true, agentId, state: "unknown", why: `the transport threw while reading the pane: ${(e as Error).message}` };
48
+ }
49
+ }
@@ -1,5 +1,5 @@
1
1
  import { detachAgentTool } from "./transport.js";
2
- import { isLocallyProbeable, isRemoteTmuxKind, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js";
2
+ import { isLocallyProbeable, isRemoteTmuxKind, ownPaneTarget, targetOf, HERDR, HerdrTransport, activeTransport } from "../transports/index.js";
3
3
  import { readAway, secondCoordinatorRefusal } from "./away.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { existsSync, openSync, statSync, watch } from "node:fs";
@@ -655,13 +655,14 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
655
655
  reasons.push(
656
656
  `live ${marker.transport} transport (pid ${marker.pid}${targetOf(marker) ? `, pane ${targetOf(marker)}` : ""})`,
657
657
  );
658
- if (
659
- isLocallyProbeable(marker.transport) &&
660
- targetOf(marker) &&
661
- process.env.TMUX_PANE &&
662
- targetOf(marker) === process.env.TMUX_PANE
663
- ) {
658
+ // ⟨q-f995c3c7⟩ THE SAME SEAT RESTARTING INTO ITS OWN PANE, for herdr as well as tmux. This read
659
+ // `process.env.TMUX_PANE` alone, which a herdr seat never has, so the exception could not fire
660
+ // for the fleet that runs on herdr: every seat was refused by its own leftover marker (6 of 6 on
661
+ // 2026-09-23). The pane comes from THIS process's environment, never from the caller's argument.
662
+ const ownPane = ownPaneTarget(marker.transport);
663
+ if (targetOf(marker) && ownPane && targetOf(marker) === ownPane) {
664
664
  samePane = true;
665
+ reasons.push(`the marker names THIS process's own pane (${ownPane})`);
665
666
  }
666
667
  }
667
668
 
@@ -34,7 +34,7 @@
34
34
  * same reason (identical evidence for a typo and a default).
35
35
  */
36
36
  import { spawnSync } from "node:child_process";
37
- import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
37
+ import type { ControlCommand, Liveness, PaneRead, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
38
38
  import { HERDR, TICK_READS_AS, TICK_STORED_AS, targetOf } from "./types.js";
39
39
  // ⟨q-dc83023a⟩ The chip grammar and the pane-unsafe byte class are single-sourced in hooks/, shared
40
40
  // with both pushers. `hooks/` ships beside `dist/`, so these resolve the same when installed.
@@ -587,6 +587,33 @@ export class HerdrTransport implements Transport {
587
587
  return { readable: false, why: `herdr reports agent_status "${status}" for pane ${target} — no agent state is published there, which is a MISSING signal, not a calm seat` };
588
588
  }
589
589
 
590
+ /**
591
+ * ⟨q-927e28cd⟩ THE SCREEN, FOR A CONSUMER — herdr's own `pane read`, the same read `push`
592
+ * verifies with. Same guards, in the same order, as `readTick`: a marker that is not herdr's,
593
+ * one with no target, or herdr absent answers UNKNOWN naming which; a pane herdr cannot find
594
+ * is UNKNOWN too — it is gone, and a gone pane has no screen, not an empty one.
595
+ */
596
+ async readPane(marker: TransportMarker, opts: { lines?: number; format?: "text" | "ansi" } = {}): Promise<PaneRead> {
597
+ return this.readPaneNow(marker, opts);
598
+ }
599
+
600
+ /** The whole rule, synchronous — `readPane` and the capabilities probe both call THIS, so the probe cannot pass while the verb behaves differently. */
601
+ readPaneNow(marker: TransportMarker, opts: { lines?: number; format?: "text" | "ansi" } = {}): PaneRead {
602
+ if (marker.transport !== HERDR) return { state: "unknown", why: `transport "${marker.transport}" is not herdr — this transport reads only its own panes` };
603
+ const target = targetOf(marker);
604
+ if (!target) return { state: "unknown", why: "no target recorded on the marker" };
605
+ const avail = this.availability();
606
+ if (!avail.available) return { state: "unknown", why: avail.reason };
607
+ const lines = Math.min(Math.max(Math.trunc(opts.lines ?? 200), 1), 2000);
608
+ const format = opts.format === "ansi" ? "ansi" : "text";
609
+ const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(lines), "--format", format]);
610
+ if (!read.ok) {
611
+ if (read.error?.code === "pane_not_found") return { state: "unknown", why: `herdr reports pane ${target} not found — a gone pane has no screen to read` };
612
+ return { state: "unknown", why: `herdr could not read pane ${target}: ${read.error?.message ?? read.stderr}` };
613
+ }
614
+ return { state: "read", text: read.stdout, lines, format, source: `herdr pane ${target}` };
615
+ }
616
+
590
617
  /**
591
618
  * ⟨q-1c95f7d4⟩ Task 1's open question — PUBLISH, and it needs a receipt it does not get.
592
619
  *
@@ -76,6 +76,21 @@ export function isLocallyProbeable(transport: string | undefined): boolean {
76
76
  return transport === TMUX_PUSH;
77
77
  }
78
78
 
79
+ /**
80
+ * ⟨q-f995c3c7⟩ THE PANE THIS PROCESS IS RUNNING IN, read from ITS OWN ENVIRONMENT — never from a
81
+ * caller's argument, which is what would make it spoofable.
82
+ *
83
+ * The first-claim guard's same-pane exception asked `process.env.TMUX_PANE` only, and a herdr seat
84
+ * has no TMUX_PANE: it has HERDR_PANE_ID. So a herdr seat restarting into its OWN pane could never
85
+ * satisfy the exception, and every one of them was refused against its own leftover marker. Measured
86
+ * 2026-09-23: 6 of 6 seats, cleared only by moving the markers aside by hand.
87
+ */
88
+ export function ownPaneTarget(transport: string | undefined, env: NodeJS.ProcessEnv = process.env): string | undefined {
89
+ if (transport === HERDR) return env.HERDR_PANE_ID || undefined;
90
+ if (isLocallyProbeable(transport)) return env.TMUX_PANE || undefined;
91
+ return undefined;
92
+ }
93
+
79
94
  /**
80
95
  * Is liveness for this marker decided by the registry heartbeat rather than by a
81
96
  * local pid? True only for the remote kind, whose pid is 0 on a foreign host.
@@ -137,6 +152,19 @@ export type TransportMarker = {
137
152
  * unasked question as full capability is the defect this field removes.
138
153
  */
139
154
  rooms?: boolean;
155
+ /**
156
+ * ⟨q-ec020f6a⟩(A) herdr ONLY: the `agent_session.value` herdr itself reported for this
157
+ * marker's pane at ATTACH TIME (`herdr pane get <target>` — populated from the pane's own
158
+ * live process, e.g. a Claude Code resume-session-id; never asserted by the attaching
159
+ * caller). This is what makes the pane a checkable identity rather than a trusted claim:
160
+ * `resolveBoundAgent`'s pane path re-reads the pane's CURRENT `agent_session.value` on
161
+ * every request and refuses if it no longer matches what was recorded here — the same
162
+ * pane_id with a DIFFERENT live session behind it (a restart, a reused pane, a swap) is
163
+ * exactly the class ⟨q-e439e4ad⟩/⟨q-8c23e5a3⟩ named. Absent means unknown (herdr had no
164
+ * session for that pane at attach time, or this marker predates the field) — never treated
165
+ * as a match.
166
+ */
167
+ herdrAgentSession?: string;
140
168
  };
141
169
 
142
170
  /**
@@ -221,6 +249,18 @@ export type TickReading =
221
249
  | { readable: true; state: TickState; source: string }
222
250
  | { readable: false; why: string };
223
251
 
252
+ /**
253
+ * ⟨q-927e28cd⟩ ADR-023 d4, narrow slice — WHAT A PANE SHOWS, READ THROUGH THE TRANSPORT.
254
+ *
255
+ * Two answers and no third: the screen, or UNKNOWN with the reason. A pane that is gone, a
256
+ * binary that is absent, a marker of another kind — each is `unknown`, never an empty screen
257
+ * and never a stale one: an empty string would read as "the seat is showing nothing", which
258
+ * is a claim about the seat that nothing measured.
259
+ */
260
+ export type PaneRead =
261
+ | { state: "read"; text: string; lines: number; format: "text" | "ansi"; source: string }
262
+ | { state: "unknown"; why: string };
263
+
224
264
  /** Keystroke-shaped commands a transport must deliver to an interactive pane. */
225
265
  export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"] as const;
226
266
  export type ControlCommand = (typeof CONTROL_COMMANDS)[number];
@@ -284,4 +324,24 @@ export interface Transport {
284
324
  * that can PUBLISH state to its external observer exposes it here.
285
325
  */
286
326
  publishTick?(marker: TransportMarker, state: TickState, opts?: { message?: string; source?: string }): Promise<{ ok: boolean; error?: string }>;
327
+
328
+ /**
329
+ * ⟨q-927e28cd⟩ OPTIONAL, AND ITS ABSENCE IS AN ANSWER: a transport that cannot see the
330
+ * seat's screen from this host (tmux-push-remote: the pane is on another machine) does not
331
+ * implement it, and every caller reports `unknown` for it rather than guessing.
332
+ *
333
+ * ⛔ THE ONE PLACE A PANE IS READ FOR A CONSUMER. Callers pass the WHOLE marker and read no
334
+ * field of it — which pane, which kind, whether it is this transport's to read are all
335
+ * decided in here (ADR-023 d1). A caller that needed `target` to use this would be the
336
+ * forty-eighth marker-internal read qa counted outside src/transports/.
337
+ */
338
+ readPane?(marker: TransportMarker, opts?: { lines?: number; format?: "text" | "ansi" }): Promise<PaneRead>;
339
+
340
+ /**
341
+ * ⟨q-ec020f6a⟩(A) — OPTIONAL, and its absence is the honest answer for tmux (a pane's live
342
+ * "session" concept is a herdr feature; tmux has no equivalent to re-check). Declaring the
343
+ * method optional is what keeps a pane-identity caller from inventing a match for a
344
+ * transport that has no way of asking one. See `HerdrTransport.verifyPaneSession`.
345
+ */
346
+ verifyPaneSession?(target: string, expectedSession: string | undefined): boolean | null;
287
347
  }