agent-coord-mcp 0.26.19 → 0.26.21

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.
Files changed (56) hide show
  1. package/README.md +82 -0
  2. package/dist/capabilities.js +57 -1
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/gated-head.js +130 -0
  5. package/dist/gated-head.js.map +1 -0
  6. package/dist/server.js +23 -0
  7. package/dist/server.js.map +1 -1
  8. package/dist/tools/queue-write.js +431 -0
  9. package/dist/tools/queue-write.js.map +1 -0
  10. package/dist/tools/records.js +406 -70
  11. package/dist/tools/records.js.map +1 -1
  12. package/dist/tools/registry.js +34 -5
  13. package/dist/tools/registry.js.map +1 -1
  14. package/dist/tools/shared.js.map +1 -1
  15. package/dist/tools/stall.js +2 -1
  16. package/dist/tools/stall.js.map +1 -1
  17. package/dist/tools/transport.js +82 -42
  18. package/dist/tools/transport.js.map +1 -1
  19. package/dist/tools/tree-provenance.js +107 -0
  20. package/dist/tools/tree-provenance.js.map +1 -0
  21. package/dist/tools/work.js +95 -3
  22. package/dist/tools/work.js.map +1 -1
  23. package/dist/transports/config.js +82 -0
  24. package/dist/transports/config.js.map +1 -0
  25. package/dist/transports/index.js +113 -0
  26. package/dist/transports/index.js.map +1 -0
  27. package/dist/transports/tmux.js +140 -0
  28. package/dist/transports/tmux.js.map +1 -0
  29. package/dist/transports/types.js +86 -0
  30. package/dist/transports/types.js.map +1 -0
  31. package/hooks/peek-coord.mjs +0 -0
  32. package/hooks/tmux-pusher.mjs +33 -3
  33. package/package.json +14 -11
  34. package/scripts/coord-attention-clock.mjs +0 -0
  35. package/scripts/coord-node.sh +0 -0
  36. package/scripts/coord-stall-clock.mjs +0 -0
  37. package/scripts/coord-token.mjs +0 -0
  38. package/scripts/probe-tmux-liveness.sh +0 -0
  39. package/scripts/spawn-agent.sh +0 -0
  40. package/scripts/stop-agent.sh +0 -0
  41. package/scripts/typed-record-stats.mjs +0 -0
  42. package/src/capabilities.ts +104 -1
  43. package/src/gated-head.ts +134 -0
  44. package/src/server.ts +29 -0
  45. package/src/tools/queue-write.ts +485 -0
  46. package/src/tools/records.ts +409 -40
  47. package/src/tools/registry.ts +36 -5
  48. package/src/tools/shared.ts +12 -36
  49. package/src/tools/stall.ts +2 -1
  50. package/src/tools/transport.ts +96 -43
  51. package/src/tools/tree-provenance.ts +136 -0
  52. package/src/tools/work.ts +95 -3
  53. package/src/transports/config.ts +110 -0
  54. package/src/transports/index.ts +126 -0
  55. package/src/transports/tmux.ts +177 -0
  56. package/src/transports/types.ts +201 -0
package/src/tools/work.ts CHANGED
@@ -139,8 +139,21 @@ function importedSummary(d: StoredDoc) {
139
139
  board: ["board"],
140
140
  legacy: ["queue", "done"],
141
141
  } as const;
142
+ // THE AXIS IS PASSED, NOT JUST ITERATED. It was already in scope here and was
143
+ // not handed to the predicate, so seam 0.1.16's queue-axis fix (#234) changed
144
+ // nothing any caller could observe: a pruned-but-healthy QUEUE.md kept
145
+ // reporting `unparsed: ["queue"]` because the predicate fell back to measuring
146
+ // authored CONTENT, and a queue keeps its headings and prose by design.
147
+ //
148
+ // Without the argument the queue axis asks "is there any prose here?" — which
149
+ // on a queue document is always yes. With it, it asks "did somebody write a
150
+ // ROW that failed to parse?", which is the question the zero actually needs.
151
+ // Every axis is passed its own name; only "queue" is treated differently
152
+ // inside the predicate. `done` and `board` keep the content measure
153
+ // deliberately — prose under an empty done log IS a fair reason to doubt that
154
+ // zero — so this is a narrowing of one axis, not a relaxation of all three.
142
155
  const unparsed = (AXES_BY_KIND[d.kind] as readonly (keyof typeof counts)[]).filter((axis) =>
143
- zeroIsUnparsed(counts[axis], d.source),
156
+ zeroIsUnparsed(counts[axis], d.source, axis),
144
157
  );
145
158
  return {
146
159
  path: d.path,
@@ -188,6 +201,41 @@ async function loadState(project: string): Promise<WorkState | null> {
188
201
  return readJson<WorkState | null>(workFile(project), null);
189
202
  }
190
203
 
204
+ /**
205
+ * WHICH STORED DOCS HAVE BEEN OVERTAKEN BY THE FILE ON DISK.
206
+ *
207
+ * THE FRESHNESS OF A RESPONSE IS THE FRESHNESS OF ITS STALEST FIELD, and before
208
+ * this the store was trusted because it existed. Measured on this fleet: a store
209
+ * imported at 17:29 already disagreed with 3 of its 4 documents by 17:42 —
210
+ * **thirteen minutes.** On a bus where several seats write coordination docs, the
211
+ * staleness window is minutes, not the weeks a dated store suggests.
212
+ *
213
+ * The instrument is mtime, and its limit is stated rather than hidden: an edit
214
+ * that PRESERVES mtime is not detected. mtime is used because the cheap question
215
+ * ("might the store be stale?") must not cost a full re-read of ~800KB of
216
+ * documents on every call; when the answer is yes, the re-read happens anyway.
217
+ * A missing file counts as stale — it cannot be compared, and "could not check"
218
+ * is not "checked and clean".
219
+ */
220
+ async function staleDocs(state: WorkState): Promise<{ path: string; why: string }[]> {
221
+ const out: { path: string; why: string }[] = [];
222
+ for (const d of state.docs) {
223
+ const full = path.join(state.repo, d.path);
224
+ try {
225
+ const st = await fsp.stat(full);
226
+ if (st.mtimeMs > state.importedAt) {
227
+ out.push({
228
+ path: d.path,
229
+ why: `modified ${new Date(st.mtimeMs).toISOString()}, after the store was imported at ${new Date(state.importedAt).toISOString()}`,
230
+ });
231
+ }
232
+ } catch (e) {
233
+ out.push({ path: d.path, why: `cannot be read (${(e as Error).message}) — unverifiable, not assumed clean` });
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+
191
239
  async function saveState(state: WorkState): Promise<void> {
192
240
  await fsp.mkdir(WORK_DIR, { recursive: true });
193
241
  await fsp.writeFile(workFile(state.project), JSON.stringify(state, null, 2) + "\n", "utf8");
@@ -293,6 +341,13 @@ export async function listWorkTool(args: {
293
341
  // rather than in a comment.
294
342
  let state = await loadState(args.project);
295
343
  let source: "store" | "markdown" = "store";
344
+ // A STALE STORE IS NEVER SERVED SILENTLY. `staleStore` is present in the
345
+ // response whenever the store lost a race with the documents, so the caller
346
+ // learns it from the ANSWER rather than from a `source` field they would have
347
+ // to know to check. The confound this removes: `issues` carried live-looking
348
+ // diagnostics beside a stale queue, so the field that made a careful reader
349
+ // trust the payload was the one field that was current.
350
+ let staleStore: { reparsed: boolean; docs: { path: string; why: string }[]; note: string } | undefined;
296
351
  if (!state) {
297
352
  const imported = await importFromDisk(args.project, args.repo ?? process.cwd());
298
353
  if (!imported) {
@@ -300,6 +355,33 @@ export async function listWorkTool(args: {
300
355
  }
301
356
  state = imported;
302
357
  source = "markdown";
358
+ } else {
359
+ const stale = await staleDocs(state);
360
+ if (stale.length) {
361
+ const fresh = await importFromDisk(state.project, state.repo);
362
+ if (fresh) {
363
+ state = fresh;
364
+ source = "markdown";
365
+ staleStore = {
366
+ reparsed: true,
367
+ docs: stale,
368
+ note:
369
+ `the store was older than ${stale.length} of its document(s) and was NOT used — these rows were re-parsed from disk. ` +
370
+ `Every field below therefore shares one provenance.`,
371
+ };
372
+ } else {
373
+ // Cannot re-read, so the stale store is all there is. It is still
374
+ // reported, because an answer that cannot be refreshed is the one most
375
+ // in need of saying so.
376
+ staleStore = {
377
+ reparsed: false,
378
+ docs: stale,
379
+ note:
380
+ `the store is older than ${stale.length} of its document(s) and could NOT be re-parsed from disk — ` +
381
+ `the rows below are as stale as the store and must not be read as current.`,
382
+ };
383
+ }
384
+ }
303
385
  }
304
386
 
305
387
  const queue: QueueItem[] = [];
@@ -325,9 +407,9 @@ export async function listWorkTool(args: {
325
407
  // the caller does not have to know which kind its own id belongs to.
326
408
  if (args.id !== undefined) {
327
409
  const queueHit = queue.find((q) => q.id === args.id);
328
- if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "queue" as const, item: queueHit };
410
+ if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), kind: "queue" as const, item: queueHit };
329
411
  const doneHit = done.find((d) => d.id === args.id);
330
- if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "done" as const, item: doneHit };
412
+ if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), kind: "done" as const, item: doneHit };
331
413
  return { ok: false as const, error: `no queue item or DONE entry with id '${args.id}' in project '${state.project}'` };
332
414
  }
333
415
 
@@ -339,6 +421,16 @@ export async function listWorkTool(args: {
339
421
  project: state.project,
340
422
  repo: state.repo,
341
423
  source,
424
+ ...(staleStore ? { staleStore } : {}),
425
+ // PROVENANCE OF THE WHOLE PAYLOAD, including the instrument and its limit.
426
+ // Done-def 4: if any field can outpace another, the response says so rather
427
+ // than a comment saying it.
428
+ freshness: {
429
+ source,
430
+ importedAt: new Date(state.importedAt).toISOString(),
431
+ checkedAgainst: "file mtime vs store importedAt",
432
+ limit: "an edit that preserves mtime is not detected; a re-parse is triggered only when mtime is newer",
433
+ },
342
434
  // IDENTITY ONLY (Task 15.1) — id, priority, a bounded headline, and
343
435
  // blocked-by; never the full item text. Call again with `id` for one
344
436
  // row's body. This is the change that makes the tool cheap: the same
@@ -0,0 +1,110 @@
1
+ /**
2
+ * WHICH TRANSPORT THIS FLEET IS CONFIGURED TO USE (Phase 5.4 Task 3.1–3.2).
3
+ *
4
+ * Whole-fleet, read ONCE at startup, per David 2026-09-11 — no per-seat
5
+ * branching in `send_command` or `attach`. Memoised for that reason and not for
6
+ * speed: a value that can be re-read mid-process is a value that can change
7
+ * mid-process, and then two calls in one session disagree about what the fleet
8
+ * is doing.
9
+ *
10
+ * ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to `tmux-push`.
11
+ *
12
+ * The reason is not tidiness. A SILENT FALLBACK AND A CORRECT DEFAULT PRODUCE
13
+ * IDENTICAL EVIDENCE: both give you a fleet on tmux with nothing in any log, so
14
+ * a typo in the config reads exactly like a deliberate default, and the person
15
+ * who typed `heardr` spends the afternoon asking why their transport change did
16
+ * nothing. Refusing is louder than the bug it prevents.
17
+ */
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { ROOT } from "../store.js";
21
+ import { TMUX_PUSH, TRANSPORT_KINDS, type TransportKind } from "./types.js";
22
+
23
+ /** `$AGENT_COORD_DIR/config.json`, the fleet-wide file. */
24
+ export const TRANSPORT_CONFIG_FILE = path.join(ROOT, "config.json");
25
+ export const TRANSPORT_ENV_VAR = "AGENT_COORD_TRANSPORT";
26
+
27
+ /**
28
+ * PRECEDENCE, documented here because 3.2 asks for a decision and not a
29
+ * preference: **config file > env > default**.
30
+ *
31
+ * The file wins because it is the FLEET's statement and is reviewable — it sits
32
+ * on disk where every seat reads the same bytes, and a wrong value can be
33
+ * corrected in one place. An env var is per-process: it is the right tool for
34
+ * one seat to deviate deliberately (a test, a bisect), and the wrong tool for
35
+ * stating what the fleet does, because nothing can see it from outside that
36
+ * process. So the narrower, less visible source loses to the broader one, and
37
+ * `source` is reported so a surprising answer can be traced to its origin
38
+ * rather than guessed at.
39
+ */
40
+ export type ConfiguredTransport = {
41
+ kind: TransportKind;
42
+ source: "config" | "env" | "default";
43
+ /** Where the value came from, for an error message a human can act on. */
44
+ origin: string;
45
+ };
46
+
47
+ function refuse(value: string, origin: string): never {
48
+ throw new Error(
49
+ `[agent-coord-mcp] unknown transport ${JSON.stringify(value)} from ${origin}. ` +
50
+ `Valid: ${TRANSPORT_KINDS.join(", ")}. ` +
51
+ `REFUSING AT STARTUP rather than falling back to "${TMUX_PUSH}" — a silent fallback and a correct ` +
52
+ `default leave identical evidence, so a typo here would look exactly like a working default and the ` +
53
+ `transport change would appear to do nothing. Fix the value or remove it to get the default.`,
54
+ );
55
+ }
56
+
57
+ function asKind(value: unknown, origin: string): TransportKind {
58
+ if (typeof value !== "string" || value.length === 0) refuse(String(value), origin);
59
+ const match = TRANSPORT_KINDS.find((k) => k === value);
60
+ if (!match) refuse(value, origin);
61
+ return match;
62
+ }
63
+
64
+ let cached: ConfiguredTransport | undefined;
65
+
66
+ /**
67
+ * Resolve the fleet's transport. Throws on an unknown value — call it once at
68
+ * startup so the refusal lands before any agent attaches.
69
+ */
70
+ export function configuredTransport(): ConfiguredTransport {
71
+ if (cached) return cached;
72
+
73
+ if (existsSync(TRANSPORT_CONFIG_FILE)) {
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(readFileSync(TRANSPORT_CONFIG_FILE, "utf8"));
77
+ } catch (e) {
78
+ // A CONFIG FILE THAT CANNOT BE PARSED IS NOT AN ABSENT ONE. Treating it as
79
+ // absent would silently use the default while a file sits there stating
80
+ // otherwise — the same two-states-one-evidence defect as the fallback.
81
+ throw new Error(
82
+ `[agent-coord-mcp] ${TRANSPORT_CONFIG_FILE} is unreadable (${(e as Error).message}). ` +
83
+ `REFUSING rather than treating it as absent: a file that exists and cannot be read is not the ` +
84
+ `same as no file, and defaulting here would hide a stated intent behind a working fleet.`,
85
+ );
86
+ }
87
+ const raw = (parsed as { transport?: unknown } | null)?.transport;
88
+ if (raw !== undefined) {
89
+ cached = { kind: asKind(raw, `${TRANSPORT_CONFIG_FILE} ("transport")`), source: "config", origin: TRANSPORT_CONFIG_FILE };
90
+ return cached;
91
+ }
92
+ }
93
+
94
+ const env = process.env[TRANSPORT_ENV_VAR];
95
+ if (env !== undefined && env !== "") {
96
+ cached = { kind: asKind(env, `$${TRANSPORT_ENV_VAR}`), source: "env", origin: `$${TRANSPORT_ENV_VAR}` };
97
+ return cached;
98
+ }
99
+
100
+ cached = { kind: TMUX_PUSH, source: "default", origin: `built-in default (${TMUX_PUSH})` };
101
+ return cached;
102
+ }
103
+
104
+ /**
105
+ * Drop the memo. FOR TESTS ONLY — the whole point of reading once is that
106
+ * production code cannot do this.
107
+ */
108
+ export function resetConfiguredTransportForTests(): void {
109
+ cached = undefined;
110
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * THE REGISTRY — the one place a transport kind is turned into an implementation.
3
+ *
4
+ * `resolveTransport` is deliberately total over `TransportKind`: adding a kind to
5
+ * the union without registering it is a compile error here rather than a silent
6
+ * fall-through at a call site. Until Task 3, tmux is the only registered
7
+ * implementation and that is the rollback plan — the seam lands behind no config.
8
+ */
9
+ import { HERDR, TMUX_PUSH, TMUX_PUSH_REMOTE, type Transport, type TransportKind } from "./types.js";
10
+ import { TmuxTransport, type TmuxHost } from "./tmux.js";
11
+ import { configuredTransport } from "./config.js";
12
+
13
+ export * from "./types.js";
14
+ export * from "./config.js";
15
+ export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
16
+
17
+ let host: TmuxHost | undefined;
18
+
19
+ /** Wire the process-layer implementation in once, at module init. */
20
+ export function registerTmuxHost(h: TmuxHost): void {
21
+ host = h;
22
+ }
23
+
24
+ export function resolveTransport(kind: TransportKind): Transport {
25
+ if (!host) {
26
+ throw new Error(
27
+ "transport host not registered — call registerTmuxHost() before resolveTransport(); " +
28
+ "this is a wiring error, not a runtime condition",
29
+ );
30
+ }
31
+ switch (kind) {
32
+ case TMUX_PUSH:
33
+ case TMUX_PUSH_REMOTE:
34
+ return new TmuxTransport(host, kind);
35
+ case HERDR:
36
+ // Task 4. Named so the union stays total and the gap is a stated absence
37
+ // rather than a default that silently behaves like tmux.
38
+ throw new Error('transport "herdr" is not implemented yet (Phase 5.4 Task 4)');
39
+ }
40
+ }
41
+
42
+ /* ── the ACTIVE transport, and why `running` is not read from config ────────── */
43
+
44
+ /**
45
+ * The instance this process would actually use to deliver.
46
+ *
47
+ * Kept as an OBJECT rather than re-derived from the config value on each call,
48
+ * and that is the whole design. If `running` were computed by reading the config
49
+ * and resolving it, then `configured` and `running` would be two names for one
50
+ * fact and `agrees` could never be false — a check that cannot fail. The active
51
+ * instance is what startup actually installed, so the two can genuinely differ:
52
+ * a startup that refused, a process that never wired one, a test that installed
53
+ * another, a future path that falls back.
54
+ */
55
+ let active: Transport | undefined;
56
+
57
+ export function setActiveTransport(t: Transport): void {
58
+ active = t;
59
+ }
60
+
61
+ /** FOR TESTS ONLY — production wires the active transport once, at startup. */
62
+ export function clearActiveTransportForTests(): void {
63
+ active = undefined;
64
+ }
65
+
66
+ export function activeTransport(): Transport | undefined {
67
+ return active;
68
+ }
69
+
70
+ /**
71
+ * Wire the transport this fleet is configured for. Called once at startup, and
72
+ * it is where an unknown config value turns into a refusal.
73
+ */
74
+ export function initTransportFromConfig(): { kind: TransportKind; source: string } {
75
+ const conf = configuredTransport();
76
+ setActiveTransport(resolveTransport(conf.kind));
77
+ return { kind: conf.kind, source: conf.source };
78
+ }
79
+
80
+ /**
81
+ * WHAT THIS PROCESS IS ACTUALLY RUNNING, answered by CALLING the transport.
82
+ *
83
+ * *A config value is a label someone typed.* This asks the live object instead:
84
+ * it calls `available()` and puts a synthetic marker through `probe()`, and
85
+ * reports what came back as the evidence beside the answer. The probe is chosen
86
+ * to be discriminating rather than decorative — a tmux transport answers a
87
+ * bogus pane id with a reason that names the pane, and an implementation that
88
+ * does not talk to panes cannot produce that.
89
+ *
90
+ * Returns `undefined` for `kind` when nothing is wired, which is NOT the same as
91
+ * "tmux by default": a process with no transport delivers nothing, and reporting
92
+ * a default here would be the exact substitution this function exists to refuse.
93
+ */
94
+ export async function runningTransport(): Promise<{
95
+ kind: TransportKind | undefined;
96
+ evidence: string;
97
+ }> {
98
+ const t = active;
99
+ if (!t) {
100
+ return {
101
+ kind: undefined,
102
+ evidence: "no transport is wired in this process — nothing was called, and no default is assumed",
103
+ };
104
+ }
105
+ const availability = (() => {
106
+ try {
107
+ return `available()=${t.available()}`;
108
+ } catch (e) {
109
+ return `available() threw: ${(e as Error).message}`;
110
+ }
111
+ })();
112
+ let probeEvidence: string;
113
+ try {
114
+ const live = await t.probe({
115
+ agentId: "__capability_probe__",
116
+ transport: t.kind,
117
+ pid: process.pid,
118
+ target: "__no_such_target__",
119
+ since: Date.now(),
120
+ });
121
+ probeEvidence = `probe(bogus target)=${live.state}${live.state === "live" ? "" : `: ${live.reason}`}`;
122
+ } catch (e) {
123
+ probeEvidence = `probe threw: ${(e as Error).message}`;
124
+ }
125
+ return { kind: t.kind, evidence: `${availability}, ${probeEvidence}` };
126
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * THE TMUX TRANSPORT — every `tmux` shell-out in this package, in one file.
3
+ *
4
+ * Before this, `spawnSync("tmux", …)` appeared at 7 sites in `tools/transport.ts`
5
+ * and the `has-session` caveat below was written out TWICE, verbatim, at two of
6
+ * them. A rule duplicated in two comments is a rule that will be re-derived
7
+ * wrongly at the third site somebody adds.
8
+ *
9
+ * WHAT THIS FILE DOES NOT DO, on purpose: spawn or reap the pusher process,
10
+ * read receipts, or schedule reminders. Delivery is a second process for THIS
11
+ * transport and will not be for a socket one, so that machinery stays with the
12
+ * tools that own it and reaches this file through `TmuxHost` below. An interface
13
+ * that baked in a pusher could not host an implementation that has none.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import type { ControlCommand, Liveness, Transport, TransportMarker, TransportKind } from "./types.js";
17
+ import { TMUX_PUSH, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js";
18
+
19
+ /**
20
+ * The parts of delivery that belong to the PROCESS layer, not to tmux.
21
+ *
22
+ * Injected rather than imported to keep the dependency pointing one way:
23
+ * `tools/transport.ts` owns pusher spawn, receipts and marker files, and hands
24
+ * them here. Importing them back would make this module and that one mutually
25
+ * dependent, and a cycle is how "one place for the literal" quietly becomes two.
26
+ */
27
+ export type TmuxHost = {
28
+ attach(args: {
29
+ agentId: string;
30
+ target?: string;
31
+ includeRoom?: boolean;
32
+ allowlist?: string[];
33
+ debounceMs?: number;
34
+ }): Promise<TransportMarker>;
35
+ detach(agentId: string): Promise<void>;
36
+ push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string }>;
37
+ sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }>;
38
+ /** Is the pusher process behind this marker still running on this host? */
39
+ pusherAlive(marker: TransportMarker): boolean;
40
+ /** Stop a wedged pusher. Returns false if it could not be signalled. */
41
+ killPusher(marker: TransportMarker): boolean;
42
+ };
43
+
44
+ /** `tmux -V` — is tmux on this host at all? */
45
+ export function tmuxAvailable(): boolean {
46
+ return spawnSync("tmux", ["-V"]).status === 0;
47
+ }
48
+
49
+ /**
50
+ * DOES THIS TARGET EXIST? The only tmux probe with discriminating power.
51
+ *
52
+ * `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
53
+ * DOES NOT — tmux exits 0 for any target, including a pane killed a moment ago,
54
+ * so that probe had ZERO discriminating power and reported every dead pane
55
+ * alive. Pinned to the BEHAVIOUR, not a version: measured identical on tmux
56
+ * 3.6b, 3.7b. A version-pinned claim rots on the next upgrade.
57
+ *
58
+ * Positive control, both directions, re-run on tmux 3.7b while extracting this:
59
+ * bogus target `%99999` -> has-session exit 1, display-message exit 0; live pane
60
+ * `%8` -> both exit 0. The wrong probe is wrong in only one direction, which is
61
+ * why it survived: it never reports a live pane dead.
62
+ */
63
+ export function paneExists(target: string): boolean {
64
+ return spawnSync("tmux", ["has-session", "-t", target]).status === 0;
65
+ }
66
+
67
+ /**
68
+ * `has-session` with the failure text, for the one caller that reports it.
69
+ *
70
+ * `paneExists` is the boolean most sites want; attach quotes tmux's own stderr
71
+ * back to the user, and losing that text would make a refusal less useful while
72
+ * still typechecking — the exact class of silent regression this task guards.
73
+ */
74
+ export function probePane(target: string): { exists: boolean; stderr: string } {
75
+ const probe = spawnSync("tmux", ["has-session", "-t", target]);
76
+ return { exists: probe.status === 0, stderr: (probe.stderr ?? "").toString().trim() };
77
+ }
78
+
79
+ /** `tmux -V` output, or undefined when tmux is absent. */
80
+ export function tmuxVersion(): string | undefined {
81
+ const probe = spawnSync("tmux", ["-V"]);
82
+ if (probe.status !== 0) return undefined;
83
+ return (probe.stdout ?? "").toString().trim() || undefined;
84
+ }
85
+
86
+ export class TmuxTransport implements Transport {
87
+ readonly kind: TransportKind;
88
+ #host: TmuxHost;
89
+
90
+ constructor(host: TmuxHost, kind: TransportKind = TMUX_PUSH) {
91
+ this.#host = host;
92
+ this.kind = kind;
93
+ }
94
+
95
+ available(): boolean {
96
+ return tmuxAvailable();
97
+ }
98
+
99
+ attach(args: {
100
+ agentId: string;
101
+ target?: string;
102
+ includeRoom?: boolean;
103
+ allowlist?: string[];
104
+ debounceMs?: number;
105
+ }): Promise<TransportMarker> {
106
+ return this.#host.attach(args);
107
+ }
108
+
109
+ detach(agentId: string): Promise<void> {
110
+ return this.#host.detach(agentId);
111
+ }
112
+
113
+ push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string }> {
114
+ return this.#host.push(marker, text);
115
+ }
116
+
117
+ sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }> {
118
+ return this.#host.sendControl(marker, cmd);
119
+ }
120
+
121
+ /**
122
+ * THREE ANSWERS, AND THE THIRD IS LOAD-BEARING.
123
+ *
124
+ * Each `unknown` below was previously a `continue` with a comment. The states
125
+ * are preserved exactly, because "we could not look" is not evidence of death
126
+ * and a reaper that cannot tell them apart kills live sessions:
127
+ *
128
+ * · not a tmux marker -> unknown (this transport cannot speak for it)
129
+ * · remote (foreign host) -> unknown (no local pane; heartbeat decides)
130
+ * · no target recorded -> unknown (nothing to probe)
131
+ * · tmux missing on host -> unknown (the instrument is absent, not the pane)
132
+ * · target absent -> dead
133
+ * · pusher gone, pane alive -> dead, and it says which half failed
134
+ */
135
+ async probe(marker: TransportMarker): Promise<Liveness> {
136
+ if (!isTmuxKind(marker.transport)) {
137
+ return { state: "unknown", reason: `transport "${marker.transport}" is not tmux` };
138
+ }
139
+ if (!isLocallyProbeable(marker.transport)) {
140
+ return {
141
+ state: "unknown",
142
+ reason: `${marker.transport} runs on another host${marker.host ? ` (${marker.host})` : ""} — no local pane to probe; liveness is heartbeat-based`,
143
+ };
144
+ }
145
+ const target = targetOf(marker);
146
+ if (!target) return { state: "unknown", reason: "no target recorded on the marker" };
147
+ if (!tmuxAvailable()) return { state: "unknown", reason: "tmux is not available on this host" };
148
+ if (!paneExists(target)) return { state: "dead", reason: `pane ${target} does not exist` };
149
+ if (!this.#host.pusherAlive(marker)) {
150
+ return { state: "dead", reason: `pane ${target} is alive but its pusher (pid ${marker.pid}) is gone` };
151
+ }
152
+ return { state: "live" };
153
+ }
154
+
155
+ /**
156
+ * Reap pushers whose pane has gone, and say which ones could not be judged.
157
+ *
158
+ * `unprobeable` is NOT a residual list nobody reads — it is the distinction
159
+ * PRODUCTION_ROADMAP Phase 5.3 requires between "dead" and "cannot probe".
160
+ * An agent whose liveness is unknown is left alone and reported, never reaped.
161
+ */
162
+ async reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }> {
163
+ const reaped: string[] = [];
164
+ const unprobeable: string[] = [];
165
+ for (const marker of markers) {
166
+ const live = await this.probe(marker);
167
+ if (live.state === "unknown") {
168
+ unprobeable.push(marker.agentId);
169
+ continue;
170
+ }
171
+ if (live.state === "live") continue;
172
+ if (this.#host.killPusher(marker)) reaped.push(marker.agentId);
173
+ else unprobeable.push(marker.agentId);
174
+ }
175
+ return { reaped, unprobeable };
176
+ }
177
+ }