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/README.md +1 -0
- package/dist/capabilities.js +82 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +29 -4
- package/dist/server.js.map +1 -1
- package/dist/store.js +108 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/index.js +1 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/pane.js +46 -0
- package/dist/tools/pane.js.map +1 -0
- package/dist/tools/registry.js +8 -5
- package/dist/tools/registry.js.map +1 -1
- package/dist/transports/herdr.js +29 -0
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/types.js +16 -0
- package/dist/transports/types.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities.ts +83 -1
- package/src/server.ts +41 -2
- package/src/store.ts +130 -1
- package/src/tools/index.ts +1 -0
- package/src/tools/pane.ts +49 -0
- package/src/tools/registry.ts +8 -7
- package/src/transports/herdr.ts +28 -1
- package/src/transports/types.ts +60 -0
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
|
|
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
|
-
|
|
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.
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
package/src/transports/herdr.ts
CHANGED
|
@@ -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
|
*
|
package/src/transports/types.ts
CHANGED
|
@@ -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
|
}
|