agent-coord-mcp 0.26.18 → 0.26.20

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 (84) hide show
  1. package/README.md +82 -0
  2. package/dist/build.js +113 -0
  3. package/dist/build.js.map +1 -0
  4. package/dist/capabilities.js +214 -0
  5. package/dist/capabilities.js.map +1 -0
  6. package/dist/prefix.js +64 -0
  7. package/dist/prefix.js.map +1 -0
  8. package/dist/roles.js +132 -0
  9. package/dist/roles.js.map +1 -0
  10. package/dist/server-identity.js +82 -0
  11. package/dist/server-identity.js.map +1 -0
  12. package/dist/server.js +668 -0
  13. package/dist/server.js.map +1 -0
  14. package/dist/store.js +553 -0
  15. package/dist/store.js.map +1 -0
  16. package/dist/tools/admin.js +323 -0
  17. package/dist/tools/admin.js.map +1 -0
  18. package/dist/tools/attention.js +73 -0
  19. package/dist/tools/attention.js.map +1 -0
  20. package/dist/tools/away.js +285 -0
  21. package/dist/tools/away.js.map +1 -0
  22. package/dist/tools/board-ref.js +208 -0
  23. package/dist/tools/board-ref.js.map +1 -0
  24. package/dist/tools/event-kinds.js +39 -0
  25. package/dist/tools/event-kinds.js.map +1 -0
  26. package/dist/tools/events.js +234 -0
  27. package/dist/tools/events.js.map +1 -0
  28. package/dist/tools/index.js +14 -0
  29. package/dist/tools/index.js.map +1 -0
  30. package/dist/tools/logwatch.js +85 -0
  31. package/dist/tools/logwatch.js.map +1 -0
  32. package/dist/tools/messaging.js +706 -0
  33. package/dist/tools/messaging.js.map +1 -0
  34. package/dist/tools/record-events.js +380 -0
  35. package/dist/tools/record-events.js.map +1 -0
  36. package/dist/tools/records.js +1205 -0
  37. package/dist/tools/records.js.map +1 -0
  38. package/dist/tools/registry.js +529 -0
  39. package/dist/tools/registry.js.map +1 -0
  40. package/dist/tools/render.js +2 -0
  41. package/dist/tools/render.js.map +1 -0
  42. package/dist/tools/rooms.js +210 -0
  43. package/dist/tools/rooms.js.map +1 -0
  44. package/dist/tools/rotate.js +143 -0
  45. package/dist/tools/rotate.js.map +1 -0
  46. package/dist/tools/scopes.js +126 -0
  47. package/dist/tools/scopes.js.map +1 -0
  48. package/dist/tools/shared.js +104 -0
  49. package/dist/tools/shared.js.map +1 -0
  50. package/dist/tools/stall.js +415 -0
  51. package/dist/tools/stall.js.map +1 -0
  52. package/dist/tools/transport.js +1918 -0
  53. package/dist/tools/transport.js.map +1 -0
  54. package/dist/tools/work.js +465 -0
  55. package/dist/tools/work.js.map +1 -0
  56. package/dist/tools/worktrees.js +581 -0
  57. package/dist/tools/worktrees.js.map +1 -0
  58. package/dist/transports/config.js +82 -0
  59. package/dist/transports/config.js.map +1 -0
  60. package/dist/transports/index.js +113 -0
  61. package/dist/transports/index.js.map +1 -0
  62. package/dist/transports/tmux.js +140 -0
  63. package/dist/transports/tmux.js.map +1 -0
  64. package/dist/transports/types.js +86 -0
  65. package/dist/transports/types.js.map +1 -0
  66. package/dist/typed-records.js +174 -0
  67. package/dist/typed-records.js.map +1 -0
  68. package/dist/work.js +2 -0
  69. package/dist/work.js.map +1 -0
  70. package/hooks/tmux-pusher.mjs +33 -3
  71. package/package.json +1 -1
  72. package/src/capabilities.ts +104 -1
  73. package/src/server.ts +30 -1
  74. package/src/tools/records.ts +221 -34
  75. package/src/tools/registry.ts +36 -5
  76. package/src/tools/shared.ts +12 -36
  77. package/src/tools/stall.ts +2 -1
  78. package/src/tools/transport.ts +96 -43
  79. package/src/tools/work.ts +95 -3
  80. package/src/tools/worktrees.ts +234 -32
  81. package/src/transports/config.ts +110 -0
  82. package/src/transports/index.ts +126 -0
  83. package/src/transports/tmux.ts +177 -0
  84. package/src/transports/types.ts +201 -0
@@ -227,42 +227,18 @@ async function create(a: {
227
227
  atBase: true,
228
228
  behindBy: 0,
229
229
  ...(a.ephemeral
230
- ? { ephemeral: true, removeWith: `git -C ${repo} worktree remove --force ${target} && git -C ${repo} branch -D ${branch}` }
230
+ ? {
231
+ ephemeral: true,
232
+ // A VERB NAME, NOT A COMMAND LINE. This used to hand back the shell to
233
+ // paste — the tool knew the repo, path and branch and spent the agent's
234
+ // tokens on work it could do itself.
235
+ releaseWith: { verb: "release_worktree", args: { repo, path: target, apply: true } },
236
+ }
231
237
  : {}),
232
238
  };
233
239
  }
234
240
 
235
- // ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
236
-
237
- export const refreshWorktreesSchema = {
238
- repo: z.string().min(1),
239
- base: z.string().min(1),
240
- apply: z.boolean().optional(),
241
- };
242
-
243
- /**
244
- * Fast-forward IDLE trees onto `origin/<base>`. Never `--force`, never a
245
- * mid-slice tree.
246
- *
247
- * A tree is MID-SLICE when it is dirty or holds commits the base does not.
248
- * Pulling under someone's feet is worse than staleness: staleness is visible in
249
- * a diff, a clobbered work-in-progress is not. So dirty or diverged is REFUSED
250
- * per tree and named, and the run continues for the others.
251
- */
252
- export async function refreshWorktreesTool(args: { repo: string; base: string; apply?: boolean }) {
253
- const { repo, base } = args;
254
- const ref = `origin/${base}`;
255
- try {
256
- git(repo, ["fetch", "--quiet", "origin", base]);
257
- } catch {
258
- /* reported per tree below */
259
- }
260
- let tip: string;
261
- try {
262
- tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
263
- } catch {
264
- return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
265
- }
241
+ // ---------- shared: is this tree's work already in the base? ----------
266
242
 
267
243
  /**
268
244
  * Are the commits HEAD has beyond `tip` ALREADY LANDED there?
@@ -303,6 +279,39 @@ function aheadIsLanded(at: string, tip: string): boolean {
303
279
  .every((l) => l.trim().startsWith("-"));
304
280
  }
305
281
 
282
+ // ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
283
+
284
+ export const refreshWorktreesSchema = {
285
+ repo: z.string().min(1),
286
+ base: z.string().min(1),
287
+ apply: z.boolean().optional(),
288
+ };
289
+
290
+ /**
291
+ * Fast-forward IDLE trees onto `origin/<base>`. Never `--force`, never a
292
+ * mid-slice tree.
293
+ *
294
+ * A tree is MID-SLICE when it is dirty or holds commits the base does not.
295
+ * Pulling under someone's feet is worse than staleness: staleness is visible in
296
+ * a diff, a clobbered work-in-progress is not. So dirty or diverged is REFUSED
297
+ * per tree and named, and the run continues for the others.
298
+ */
299
+ export async function refreshWorktreesTool(args: { repo: string; base: string; apply?: boolean }) {
300
+ const { repo, base } = args;
301
+ const ref = `origin/${base}`;
302
+ try {
303
+ git(repo, ["fetch", "--quiet", "origin", base]);
304
+ } catch {
305
+ /* reported per tree below */
306
+ }
307
+ let tip: string;
308
+ try {
309
+ tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
310
+ } catch {
311
+ return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
312
+ }
313
+
314
+
306
315
  const results = [];
307
316
  for (const w of listWorktrees(repo)) {
308
317
  const at = w.path;
@@ -415,3 +424,196 @@ function aheadIsLanded(at: string, tip: string): boolean {
415
424
  }
416
425
  return { ok: true as const, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results };
417
426
  }
427
+
428
+ // ---------- the counterpart to ensure_worktree: give the tree back ----------
429
+
430
+ /*
431
+ * `ensure_worktree` CREATED trees and nothing RELEASED them, so teardown stayed a
432
+ * hand step in five role cards — `git worktree prune` in coord-audit, coord-ci,
433
+ * coord-qa and coord-worker, `git worktree remove` twice in coordinator, which
434
+ * says so itself: "it never removes, so `git worktree remove`/`prune` remain hand
435
+ * steps". Seven commands for one act the bus already had the inputs for.
436
+ *
437
+ * WORSE THAN ABSENT: `ensure_worktree` COMPUTED the teardown command and handed it
438
+ * back as a string for the agent to paste (`removeWith`). The tool knew the repo,
439
+ * the path and the branch, and spent the agent's tokens on shell it could have run.
440
+ * That is the shape David ruled against: "where possible we should hide the
441
+ * plumbing from the agents".
442
+ *
443
+ * THE REFUSALS ARE THE POINT, and they inherit `refresh_worktrees`' direction —
444
+ * report by default, refuse per tree with the reason named, never clobber:
445
+ *
446
+ * - THE PRIMARY CHECKOUT IS NEVER RELEASED. It is the path everyone already has.
447
+ * - A DIRTY TREE IS REFUSED. Uncommitted work is what a diff cannot show and git
448
+ * cannot give back, so this is the one refusal with no override.
449
+ * - A TREE HOLDING UNLANDED COMMITS IS REFUSED — and "unlanded" is decided by
450
+ * `aheadIsLanded` (patch ids), NOT by ancestry. Under squash merge a landed
451
+ * branch's shas never enter the base, so ancestry calls every finished tree
452
+ * mid-slice forever (q-5c40db91). Using ancestry here would refuse to release
453
+ * exactly the trees that are safe to release, which is how the hand step
454
+ * survived: the guard would have been wrong and everyone would have forced it.
455
+ *
456
+ * A NEGATIVE FROM `aheadIsLanded` IS INCONCLUSIVE, NOT A NO. Several commits squashed
457
+ * into one carry a different combined patch id, so it cannot prove landedness for
458
+ * every shape. It therefore refuses and says the check was inconclusive rather than
459
+ * claiming the work is unlanded — the caller lands it, or passes `force:true`, which
460
+ * is recorded in the result so a forced release is findable afterwards.
461
+ */
462
+ export const releaseWorktreeSchema = {
463
+ repo: z.string().min(1),
464
+ agentId: z.string().min(1).optional(),
465
+ path: z.string().min(1).optional(),
466
+ base: z.string().min(1).optional(),
467
+ apply: z.boolean().optional(),
468
+ force: z.boolean().optional(),
469
+ };
470
+
471
+ export async function releaseWorktreeTool(args: {
472
+ repo: string;
473
+ agentId?: string;
474
+ path?: string;
475
+ base?: string;
476
+ apply?: boolean;
477
+ force?: boolean;
478
+ }) {
479
+ const { repo } = args;
480
+ if (!args.agentId && !args.path) {
481
+ return { ok: false as const, error: "name the tree to release: pass `agentId` or `path`" };
482
+ }
483
+
484
+ const trees = listWorktrees(repo);
485
+ const primary = primaryOf(repo) ?? "";
486
+ const target = args.path
487
+ ? trees.find((w) => samePath(w.path, args.path as string))
488
+ : trees.find((w) => (w.branch ?? "").includes(args.agentId as string) || w.path.includes(args.agentId as string));
489
+
490
+ if (!target) {
491
+ return {
492
+ ok: false as const,
493
+ error: args.path
494
+ ? `no worktree at '${args.path}' — nothing to release`
495
+ : `no worktree found for '${args.agentId}' — nothing to release`,
496
+ // An ABSENCE is reported with what WAS found, so a misaimed lookup is
497
+ // distinguishable from an empty world (the positive-control rule).
498
+ known: trees.map((w) => ({ path: w.path, branch: w.branch })),
499
+ };
500
+ }
501
+
502
+ const at = target.path;
503
+ const branch = target.branch;
504
+
505
+ if (samePath(at, primary)) {
506
+ return {
507
+ ok: false as const,
508
+ error: `refusing to release ${at}: that is the PRIMARY checkout, the path everyone already has`,
509
+ path: at,
510
+ };
511
+ }
512
+
513
+ if (isDirty(at)) {
514
+ return {
515
+ ok: false as const,
516
+ error:
517
+ `refusing to release ${at}: the tree is DIRTY. Uncommitted work is what a diff cannot show and ` +
518
+ `git cannot give back, so this refusal has no override — commit it, stash it, or delete it by hand.`,
519
+ path: at,
520
+ branch,
521
+ dirty: true,
522
+ };
523
+ }
524
+
525
+ const base = args.base ?? "main";
526
+ const ref = `origin/${base}`;
527
+ let tip: string | null = null;
528
+ try {
529
+ git(repo, ["fetch", "--quiet", "origin", base]);
530
+ } catch {
531
+ /* a stale tip is reported below rather than guessed at */
532
+ }
533
+ try {
534
+ tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
535
+ } catch {
536
+ tip = null;
537
+ }
538
+
539
+ let ahead = "unknown";
540
+ if (tip) {
541
+ try {
542
+ ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
543
+ } catch {
544
+ /* left unknown — an unreadable count must not read as zero */
545
+ }
546
+ }
547
+
548
+ const holdsWork = ahead !== "0";
549
+ const landed = tip && holdsWork ? aheadIsLanded(at, tip) : true;
550
+
551
+ if (!tip) {
552
+ return {
553
+ ok: false as const,
554
+ error: `refusing to release ${at}: ${ref} does not resolve, so whether this tree's work landed cannot be measured`,
555
+ path: at,
556
+ branch,
557
+ };
558
+ }
559
+
560
+ if (holdsWork && !landed && !args.force) {
561
+ return {
562
+ ok: false as const,
563
+ error:
564
+ `refusing to release ${at}: it is ${ahead} commit(s) ahead of ${ref} and the patch-id check was ` +
565
+ `INCONCLUSIVE — not proof the work is unlanded, but not proof it landed either (several commits ` +
566
+ `squashed into one carry a different combined patch id). Land it, or pass force:true, which is recorded.`,
567
+ path: at,
568
+ branch,
569
+ ahead,
570
+ landedCheck: "inconclusive",
571
+ };
572
+ }
573
+
574
+ const plan = {
575
+ path: at,
576
+ branch,
577
+ ahead,
578
+ landedCheck: holdsWork ? (landed ? "landed" : "inconclusive") : "nothing-ahead",
579
+ ...(args.force && holdsWork && !landed ? { forced: true } : {}),
580
+ };
581
+
582
+ if (!args.apply) {
583
+ return {
584
+ ok: true as const,
585
+ applied: false,
586
+ would: "remove the worktree and delete its branch",
587
+ ...plan,
588
+ note: "reports by default; pass apply:true to remove",
589
+ };
590
+ }
591
+
592
+ try {
593
+ git(repo, ["worktree", "remove", "--force", at]);
594
+ } catch (e) {
595
+ return {
596
+ ok: false as const,
597
+ error: `worktree remove failed: ${String((e as Error).message).split("\n")[0]}`,
598
+ ...plan,
599
+ };
600
+ }
601
+ // PRUNE IS PART OF THE ACT, not a step the caller remembers. Every card that
602
+ // said `git worktree remove` also said `git worktree prune`, which is the
603
+ // clearest evidence they were one operation split across two lines of prose.
604
+ try {
605
+ git(repo, ["worktree", "prune"]);
606
+ } catch {
607
+ /* removal already succeeded; a failed prune is not worth failing the call */
608
+ }
609
+ let branchDeleted = false;
610
+ if (branch) {
611
+ try {
612
+ git(repo, ["branch", "-D", branch.replace(/^refs\/heads\//, "")]);
613
+ branchDeleted = true;
614
+ } catch {
615
+ /* a branch that will not delete is reported, not fatal */
616
+ }
617
+ }
618
+ return { ok: true as const, applied: true, removed: true, branchDeleted, ...plan };
619
+ }
@@ -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
+ }