agent-coord-mcp 0.26.24 → 0.26.26

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 (60) hide show
  1. package/README.md +31 -70
  2. package/dist/capabilities.js +169 -4
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/gated-head.js +12 -1
  5. package/dist/gated-head.js.map +1 -1
  6. package/dist/server-spread.js +60 -53
  7. package/dist/server-spread.js.map +1 -1
  8. package/dist/server.js +53 -4
  9. package/dist/server.js.map +1 -1
  10. package/dist/tools/herdr-delivery.js +86 -26
  11. package/dist/tools/herdr-delivery.js.map +1 -1
  12. package/dist/tools/herdr-tail.js +243 -0
  13. package/dist/tools/herdr-tail.js.map +1 -0
  14. package/dist/tools/jsonl-offsets.js +37 -0
  15. package/dist/tools/jsonl-offsets.js.map +1 -0
  16. package/dist/tools/messaging.js +18 -0
  17. package/dist/tools/messaging.js.map +1 -1
  18. package/dist/tools/records.js +18 -2
  19. package/dist/tools/records.js.map +1 -1
  20. package/dist/tools/registry.js +56 -21
  21. package/dist/tools/registry.js.map +1 -1
  22. package/dist/tools/transport.js +17 -148
  23. package/dist/tools/transport.js.map +1 -1
  24. package/dist/transports/config.js +22 -11
  25. package/dist/transports/config.js.map +1 -1
  26. package/dist/transports/herdr.js +134 -18
  27. package/dist/transports/herdr.js.map +1 -1
  28. package/dist/transports/index.js +4 -3
  29. package/dist/transports/index.js.map +1 -1
  30. package/dist/transports/tmux.js +5 -2
  31. package/dist/transports/tmux.js.map +1 -1
  32. package/dist/transports/types.js +18 -3
  33. package/dist/transports/types.js.map +1 -1
  34. package/hooks/control-bytes.mjs +69 -0
  35. package/hooks/submit.mjs +227 -9
  36. package/hooks/tier.mjs +7 -1
  37. package/package.json +4 -2
  38. package/scripts/check-global-mcp-fallback.mjs +85 -0
  39. package/scripts/coord-pusher.mjs +5 -1
  40. package/scripts/coord-seat.mjs +96 -0
  41. package/scripts/coord-token.mjs +79 -4
  42. package/scripts/stop-agent.sh +8 -4
  43. package/src/capabilities.ts +170 -5
  44. package/src/gated-head.ts +12 -1
  45. package/src/server-spread.ts +52 -4
  46. package/src/server.ts +49 -3
  47. package/src/tools/herdr-delivery.ts +92 -24
  48. package/src/tools/herdr-tail.ts +276 -0
  49. package/src/tools/jsonl-offsets.ts +29 -0
  50. package/src/tools/messaging.ts +20 -0
  51. package/src/tools/records.ts +18 -2
  52. package/src/tools/registry.ts +56 -20
  53. package/src/tools/transport.ts +17 -154
  54. package/src/transports/config.ts +25 -12
  55. package/src/transports/herdr.ts +193 -17
  56. package/src/transports/index.ts +4 -3
  57. package/src/transports/tmux.ts +5 -2
  58. package/src/transports/types.ts +24 -4
  59. package/hooks/tmux-pusher.mjs +0 -967
  60. package/scripts/spawn-agent.sh +0 -94
@@ -1,6 +1,5 @@
1
- import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
1
+ import { loadLiveTransports, isMarkerLive, isPidAlive, markerHoldsLiveProcess } from "./registry.js";
2
2
  import {
3
- TMUX_PUSH,
4
3
  registerTmuxHost,
5
4
  activeTransport,
6
5
  HERDR,
@@ -8,7 +7,6 @@ import {
8
7
  isLocallyProbeable,
9
8
  isTmuxKind,
10
9
  paneExists,
11
- probePane,
12
10
  tmuxVersion,
13
11
  targetOf,
14
12
  tmuxAvailable,
@@ -30,9 +28,9 @@ import { resolveServerIdentity } from "../server-identity.js";
30
28
  import { capabilitiesTool } from "../capabilities.js";
31
29
  import { sendMessageTool, readMessagesTool } from "./messaging.js";
32
30
  import { randomUUID } from "node:crypto";
33
- import { existsSync, openSync, readFileSync, watch } from "node:fs";
31
+ import { existsSync, readFileSync, watch } from "node:fs";
34
32
  import { promises as fsp } from "node:fs";
35
- import { spawn, spawnSync } from "node:child_process";
33
+ import { spawnSync } from "node:child_process";
36
34
  import { fileURLToPath } from "node:url";
37
35
  import { z } from "zod";
38
36
  import { seatBuildOf, installedFrom, psReader } from "./seat-build.js";
@@ -685,163 +683,28 @@ export async function attachAgentTool(args: {
685
683
  agentId: args.agentId,
686
684
  transport: HERDR,
687
685
  target: marker.target,
688
- pid: 0,
686
+ pid: marker.pid,
687
+ pidWhy: marker.pidWhy,
689
688
  rooms: marker.rooms,
690
689
  note: "herdr socket transport: no pusher process — delivery is made in-process by this server through herdr's socket API; liveness is herdr's own pane status",
691
690
  };
692
691
  }
693
- const target = args.tmuxTarget ?? process.env.TMUX_PANE;
694
- if (!target) {
695
- return {
696
- ok: false,
697
- error:
698
- "tmuxTarget not provided and the MCP server is not running inside tmux (no $TMUX_PANE). Pass tmuxTarget explicitly (e.g. '%42' or 'session:window.pane').",
699
- };
700
- }
701
-
702
- // Validate target exists. The probe's discriminating power, and the control
703
- // that proves it, are documented once on `paneExists`.
704
- const targetProbe = probePane(target);
705
- if (!targetProbe.exists) {
706
- return {
707
- ok: false,
708
- error: `tmux target '${target}' not found: ${targetProbe.stderr}`,
709
- };
710
- }
711
-
712
- // If something's already attached, refuse rather than spawn a second pusher.
713
- const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
714
- if (existing && isPidAlive(existing.pid)) {
715
- return {
716
- ok: false,
717
- error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
718
- existing,
719
- };
720
- }
721
- // Clean up dead marker, if any.
722
- if (existing) await deleteFile(transportFile(args.agentId));
723
-
724
- const pusher = resolvePusherPath();
725
- if (!existsSync(pusher)) {
726
- return { ok: false, error: `tmux-pusher not found at ${pusher}` };
727
- }
728
-
729
- // Detached spawn so the pusher outlives this MCP request/process.
730
- const log = logFile(args.agentId, "pusher");
731
- await fsp.mkdir(path.dirname(log), { recursive: true });
732
- await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
733
- await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
734
- const logFd = openSync(log, "a");
735
- // Default: deliver room broadcasts too. The bus is chat-first — silence on
736
- // a room post is a worse failure mode than a slightly noisier pane. Callers
737
- // who want DM-only can pass includeRoom:false explicitly.
738
- const includeRoom = args.includeRoom !== false;
739
- // Use the exact node binary running this server, not bare "node" — the MCP
740
- // server is often launched via an absolute path (nvm/Homebrew/bundled
741
- // runtime) that isn't on the spawned child's PATH, which would silently fail
742
- // the pusher launch ("attached but nothing arrives").
743
- // `--agent <id>` is inert to the pusher (env stays authoritative) but puts
744
- // the agentId in argv, so a pattern kill can be scoped to ONE pusher
745
- // (`pkill -f "tmux-pusher.mjs --agent <id>"`). Without it the only matchable
746
- // pattern was the script path, and a `pkill -f tmux-pusher.mjs` during one
747
- // agent's cleanup silently detached every live agent on the bus (2026-07-28).
748
- const child = spawn(process.execPath, [pusher, "--agent", args.agentId], {
749
- detached: true,
750
- stdio: ["ignore", logFd, logFd],
751
- env: {
752
- ...process.env,
753
- AGENT_COORD_ID: args.agentId,
754
- AGENT_COORD_TMUX_TARGET: target,
755
- ...(includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
756
- ...(args.allowlist && args.allowlist.length > 0
757
- ? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
758
- : {}),
759
- ...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
760
- },
761
- });
762
- child.unref();
763
- const pid = child.pid;
764
- if (!pid) return { ok: false, error: "spawn returned no pid" };
765
-
766
- // Write pid file (for scripts) and transport marker (for list_agents).
767
- await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
768
- // Stamp the pusher source's freshness so doctor() can flag a stale daemon if
769
- // it outlives a later upgrade of the on-disk code (see v0.8.1 → v0.8.2 bug
770
- // report: control commands silently dropped by pre-v0.8 in-memory code).
771
- const scriptMtime = newestPusherSourceMtime();
772
- const marker: TransportMarker = {
773
- agentId: args.agentId,
774
- transport: TMUX_PUSH,
775
- pid,
776
- // DUAL-WRITTEN, and the duplication is the point. `target` is what every
777
- // consumer now reads (`targetOf`); `tmuxTarget` is what the code a merge
778
- // revert restores reads. Writing only the new field would leave markers the
779
- // old server cannot parse, and that failure does not degrade gracefully —
780
- // it silences every attached lane at once.
781
- target,
782
- tmuxTarget: target,
783
- since: Date.now(),
784
- scriptMtime,
785
- // Provenance: the build identity THIS server loaded at startup — not a
786
- // fresh stat of dist/, because the code doing the stamping is the loaded
787
- // code, and after an in-place rebuild the two differ (that difference is
788
- // exactly what doctor's provenance check exists to surface).
789
- serverBuildMtime: SERVER_BUILD_MTIME,
790
- // WHAT this transport carries, recorded by the code that decides it.
791
- // `includeRoom` is what the pusher is actually spawned with a few lines
792
- // above, so the marker cannot claim a capability the process was not
793
- // given — the marker and the spawn come from one value, not two.
794
- rooms: includeRoom,
795
- };
796
- // Use updateJson so it lockfile-protects and creates the file atomically.
797
- await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
798
-
799
- // Best-effort scan for a peek-coord.mjs hook wired to the same agentId —
800
- // both consumers share the cursor file and would race / double-deliver.
801
- const conflictingHook = await detectPeekCoordHook(args.agentId);
802
-
692
+ // ⟨q-ec020f6a⟩ slice C — local tmux-push DELETED outright: 0 of 13 live seats used it,
693
+ // and keeping it meant a second delivery mechanism (hooks/tmux-pusher.mjs, now removed)
694
+ // plus a role-card precondition that halted every live herdr seat on a false requirement.
695
+ // attach_agent handles exactly one kind now. A remote seat never came through here in
696
+ // the first place — it registers via report_transport (scripts/coord-pusher.mjs) — so
697
+ // this refuses rather than silently trying (and failing) to spawn a script that no
698
+ // longer exists.
803
699
  return {
804
- ok: true,
805
- agentId: args.agentId,
806
- transport: TMUX_PUSH,
807
- tmuxTarget: target,
808
- pid,
809
- log,
810
- ...(conflictingHook
811
- ? {
812
- warnings: [
813
- `peek-coord.mjs hook for agentId='${args.agentId}' detected in ${conflictingHook}. ` +
814
- `Running both transports causes double-delivery — disable one. ` +
815
- `Recommend removing the peek-coord hook entry since tmux-push supersedes it.`,
816
- ],
817
- }
818
- : {}),
700
+ ok: false,
701
+ error:
702
+ `attach_agent only attaches the herdr transport now (this fleet's active transport is ` +
703
+ `${activeT ? `'${activeT.kind}'` : "unconfigured"}) — local tmux-push was removed (⟨q-ec020f6a⟩). ` +
704
+ `A remote seat registers via report_transport, not attach_agent.`,
819
705
  };
820
706
  }
821
707
 
822
- async function detectPeekCoordHook(agentId: string): Promise<string | undefined> {
823
- const home = process.env.HOME ?? "";
824
- const cwd = process.cwd();
825
- const candidates = [
826
- path.join(home, ".claude", "settings.json"),
827
- path.join(home, ".claude", "settings.local.json"),
828
- path.join(cwd, ".claude", "settings.json"),
829
- path.join(cwd, ".claude", "settings.local.json"),
830
- ];
831
- for (const file of candidates) {
832
- if (!existsSync(file)) continue;
833
- try {
834
- const raw = await fsp.readFile(file, "utf8");
835
- if (raw.includes("peek-coord.mjs") && raw.includes(`AGENT_COORD_ID=${agentId}`)) {
836
- return file;
837
- }
838
- } catch {
839
- // unreadable, skip
840
- }
841
- }
842
- return undefined;
843
- }
844
-
845
708
  export const detachAgentSchema = {
846
709
  agentId: z.string().min(1),
847
710
  };
@@ -7,18 +7,27 @@
7
7
  * mid-process, and then two calls in one session disagree about what the fleet
8
8
  * is doing.
9
9
  *
10
- * ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to `tmux-push`.
10
+ * ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to anything.
11
11
  *
12
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.
13
+ * IDENTICAL EVIDENCE: both give you a fleet on some transport with nothing in any
14
+ * log, so a typo in the config reads exactly like a deliberate default, and the
15
+ * person who typed `heardr` spends the afternoon asking why their transport
16
+ * change did nothing. Refusing is louder than the bug it prevents.
17
+ *
18
+ * ⟨q-ec020f6a⟩ THERE IS NO BUILT-IN DEFAULT ANY MORE, for the identical reason.
19
+ * `tmux-push` (0 of 13 live seats) used to be it; deleting that kind without
20
+ * removing the fallback would have meant every unconfigured session refused
21
+ * with "unknown transport 'tmux-push'" — a config-file bug wearing a runtime
22
+ * bug's clothes. Picking a new implicit default (herdr, say) would be correct
23
+ * on THIS fleet today and wrong on the next machine that doesn't run herdr —
24
+ * the exact silent-guess failure ⟨q-e439e4ad⟩ spent a day fixing, moved one
25
+ * layer up. So: zero configuration REFUSES, naming the kinds that exist.
17
26
  */
18
27
  import { existsSync, readFileSync } from "node:fs";
19
28
  import path from "node:path";
20
29
  import { ROOT } from "../store.js";
21
- import { TMUX_PUSH, TRANSPORT_KINDS, type TransportKind } from "./types.js";
30
+ import { TRANSPORT_KINDS, type TransportKind } from "./types.js";
22
31
 
23
32
  /** `$AGENT_COORD_DIR/config.json`, the fleet-wide file. */
24
33
  export const TRANSPORT_CONFIG_FILE = path.join(ROOT, "config.json");
@@ -39,7 +48,7 @@ export const TRANSPORT_ENV_VAR = "AGENT_COORD_TRANSPORT";
39
48
  */
40
49
  export type ConfiguredTransport = {
41
50
  kind: TransportKind;
42
- source: "config" | "env" | "default";
51
+ source: "config" | "env";
43
52
  /** Where the value came from, for an error message a human can act on. */
44
53
  origin: string;
45
54
  };
@@ -48,9 +57,9 @@ function refuse(value: string, origin: string): never {
48
57
  throw new Error(
49
58
  `[agent-coord-mcp] unknown transport ${JSON.stringify(value)} from ${origin}. ` +
50
59
  `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.`,
60
+ `REFUSING AT STARTUP rather than falling back to anything — a silent fallback and a correct ` +
61
+ `default leave identical evidence, so a typo here would look exactly like a working value and the ` +
62
+ `transport change would appear to do nothing. Fix the value or remove it (⟨q-ec020f6a⟩: there is no default to fall back to).`,
54
63
  );
55
64
  }
56
65
 
@@ -97,8 +106,12 @@ export function configuredTransport(): ConfiguredTransport {
97
106
  return cached;
98
107
  }
99
108
 
100
- cached = { kind: TMUX_PUSH, source: "default", origin: `built-in default (${TMUX_PUSH})` };
101
- return cached;
109
+ throw new Error(
110
+ `[agent-coord-mcp] no transport configured — set $${TRANSPORT_ENV_VAR} or ${TRANSPORT_CONFIG_FILE} ` +
111
+ `("transport") to one of: ${TRANSPORT_KINDS.join(", ")}. ` +
112
+ `REFUSING rather than picking one: a default that is right for this fleet today is wrong for the ` +
113
+ `next machine that doesn't have it, and the two failures leave identical evidence.`,
114
+ );
102
115
  }
103
116
 
104
117
  /**
@@ -36,6 +36,16 @@
36
36
  import { spawnSync } from "node:child_process";
37
37
  import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
38
38
  import { HERDR, TICK_READS_AS, TICK_STORED_AS, targetOf } from "./types.js";
39
+ // ⟨q-dc83023a⟩ The chip grammar and the pane-unsafe byte class are single-sourced in hooks/, shared
40
+ // with both pushers. `hooks/` ships beside `dist/`, so these resolve the same when installed.
41
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
42
+ import { PASTE_CHIP_RE, readReadyBox, readyProfile, refusalBeforeKeys, boxHoldsPayload, transcriptGained } from "../../hooks/submit.mjs";
43
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
44
+ import { neutralizeControls } from "../../hooks/control-bytes.mjs";
45
+
46
+ /** Bracketed-paste markers (xterm mode 2004), which Claude Code and tmux `paste-buffer -p` both speak. */
47
+ export const PASTE_START = "\x1b[200~";
48
+ export const PASTE_END = "\x1b[201~";
39
49
 
40
50
  export type HerdrError = { code: string; message: string };
41
51
  export type HerdrResult = {
@@ -118,6 +128,20 @@ function processInfoOf(r: HerdrResult): ProcessInfo | undefined {
118
128
  }
119
129
  const LIVE_STATUSES = new Set(["idle", "working", "blocked", "done"]);
120
130
 
131
+ /**
132
+ * ⟨q-15d763dc⟩ What a push did, so a caller can tell a message that is safe to try again from one
133
+ * that may already be sitting in the pane:
134
+ * delivered — verified: the ready box took it and the transcript shows it. The ONLY
135
+ * outcome a cursor may advance on.
136
+ * held — NO key was sent (no ready box, a draft, a menu, or the pane unreadable).
137
+ * Safe to retry.
138
+ * typed-unconfirmed — the text was typed but did not show in the box; Enter was NOT sent.
139
+ * pending — the text is still in the box after every Enter.
140
+ * unverified — Enter was sent and the screen after it does not prove submission.
141
+ */
142
+ export type PushOutcome = "delivered" | "held" | "typed-unconfirmed" | "pending" | "unverified";
143
+ export type HerdrPushResult = { delivered: boolean; verified?: boolean; outcome?: PushOutcome; safeToRetry?: boolean; error?: string; enters?: number; unguarded?: boolean; busy?: boolean };
144
+
121
145
  export type HerdrTransportOptions = {
122
146
  run?: HerdrRunner;
123
147
  /** Milliseconds to wait before reading a pane back after typing. */
@@ -126,14 +150,75 @@ export type HerdrTransportOptions = {
126
150
  sleep?: (ms: number) => void;
127
151
  /** Lines of pane to read back when verifying a delivery. */
128
152
  readLines?: number;
153
+ /** Injectable marker-pid decision (tests); defaults to asking the kernel about pid 1. */
154
+ markerPid?: () => HerdrMarkerPid;
155
+ /**
156
+ * The environment `attach` reads its default pane from. Injectable because it is AMBIENT
157
+ * PROCESS STATE, and ambient state is the one input an injected runner does not cover.
158
+ *
159
+ * ⛔ MEASURED 2026-09-16, the day seats moved onto herdr: the attach test injected a scripted
160
+ * runner and still read the REAL `process.env.HERDR_PANE_ID`. Outside herdr the variable was
161
+ * absent and the test passed; inside a herdr pane it resolved to the gater's own pane
162
+ * (`wA6:p1`), the scripted runner had never heard of it, and the suite went red on every
163
+ * herdr-hosted tree for a reason unrelated to any diff. Stripping `HERDR_PANE_ID` alone — one
164
+ * variable at a time, six others left in place — was what turned it green.
165
+ */
166
+ env?: Record<string, string | undefined>;
129
167
  };
130
168
 
169
+ /**
170
+ * ⟨q-abd88dd4⟩ — THE PID A HERDR MARKER CARRIES, chosen for the readers that CANNOT be patched.
171
+ *
172
+ * A herdr seat has no pusher, so its marker used to carry pid 0. Every server build from before
173
+ * the herdr transport (0.19.1, kit 0.26.19, published 0.26.22 — reproduced in a throwaway bus
174
+ * against each) decides marker liveness as `isPidAlive(marker.pid)` for everything but
175
+ * `tmux-push-remote`, and `isPidAlive(0)` is false, so ONE ordinary read (`list_agents`,
176
+ * `status`, `stall_check`, `send_command`) DELETES the marker. A mixed-build fleet deafens its
177
+ * herdr seats as a side effect of looking at the roster.
178
+ *
179
+ * pid 1 is kept by all three: it always exists, so `isPidAlive(1)` is true. Their signal paths,
180
+ * enumerated by qa across all three on its gate: kit 0.26.19 and published 0.26.22 gate
181
+ * `detach_agent`'s SIGTERM on `isPusherProcess(marker.pid)`, which reads the process COMMAND, so they
182
+ * never signal launchd whatever uid they run as. Only 0.19.1's detach is unguarded (`isPidAlive`
183
+ * then `process.kill(marker.pid, "SIGTERM")`, reachable from `detach_agent`, `unregister` and
184
+ * `rename_agent`), and it enforces identity, so its caller must be bound as that herdr seat. As a
185
+ * non-root process that kill gets EPERM — measured `killed:false`, launchd untouched. The seat's own
186
+ * server pid would have been kept by all three as well, and 0.19.1's unguarded detach would have
187
+ * SIGTERMed that seat's MCP server; that shape is rejected.
188
+ *
189
+ * ⛔ SO pid 1 IS WRITTEN ONLY WHEN THIS PROCESS COULD NOT SIGNAL IT, asked of the kernel with
190
+ * signal 0 rather than inferred from a uid: as root, or in a container where pid 1 is our own
191
+ * user's process (often the server itself), `kill(1, 0)` succeeds and an old reader running
192
+ * alongside could SIGTERM init. There the marker falls back to pid 0 and says why — an old
193
+ * reader then deletes it, which is deafness, and deafness is recoverable where a signal to init
194
+ * is not. RESIDUAL, stated as narrowly as it is: a 0.19.1-era server, running as ROOT, bound as
195
+ * the herdr seat's OWN identity, calling detach, unregister or rename, reaches init. Nothing a
196
+ * marker says can remove a privilege its reader holds.
197
+ *
198
+ * The CURRENT build never reads this pid as liveness: a herdr marker is live exactly when herdr
199
+ * says its pane exists (`isMarkerLive`), and nothing signals it (`detach_agent` removes the
200
+ * marker, `markerHoldsLiveProcess` answers false).
201
+ */
202
+ export type HerdrMarkerPid = { pid: 0 | 1; why: string };
203
+ export function herdrMarkerPid(kill: (pid: number, signal: 0) => unknown = (p, s) => process.kill(p, s)): HerdrMarkerPid {
204
+ try {
205
+ kill(1, 0);
206
+ return { pid: 0, why: "this process may signal pid 1 (root, or a container whose pid 1 is ours), so pid 1 is not safe to advertise; pre-herdr readers will reap this marker" };
207
+ } catch (e) {
208
+ const code = (e as NodeJS.ErrnoException).code;
209
+ if (code === "EPERM") return { pid: 1, why: "pid 1 exists and this process may not signal it, so pre-herdr readers keep the marker and their detach cannot signal it" };
210
+ return { pid: 0, why: `kill(1, 0) answered ${code ?? "an unexpected error"}, not EPERM — pid 1 not advertised` };
211
+ }
212
+ }
213
+
131
214
  export class HerdrTransport implements Transport {
132
215
  readonly kind: TransportKind = HERDR;
133
216
  #run: HerdrRunner;
134
217
  #settleMs: number;
135
218
  #sleep: (ms: number) => void;
136
219
  #readLines: number;
220
+ #markerPid: () => HerdrMarkerPid;
221
+ #env: Record<string, string | undefined>;
137
222
 
138
223
  constructor(opts: HerdrTransportOptions = {}) {
139
224
  this.#run = opts.run ?? defaultHerdrRunner;
@@ -143,7 +228,9 @@ export class HerdrTransport implements Transport {
143
228
  // the spike measured is Claude's input box; the retry exists for that, bounded to one.
144
229
  this.#settleMs = opts.settleMs ?? 400;
145
230
  this.#sleep = opts.sleep ?? ((ms) => { const end = Date.now() + ms; while (Date.now() < end) { /* spin: tiny and rare */ } });
146
- this.#readLines = opts.readLines ?? 12;
231
+ this.#readLines = opts.readLines ?? 40;
232
+ this.#markerPid = opts.markerPid ?? (() => herdrMarkerPid());
233
+ this.#env = opts.env ?? process.env;
147
234
  }
148
235
 
149
236
  /** Is herdr on this host AND is its server running? Both, or the reason. */
@@ -168,8 +255,26 @@ export class HerdrTransport implements Transport {
168
255
  async attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number }): Promise<TransportMarker> {
169
256
  const avail = this.availability();
170
257
  if (!avail.available) throw new Error(`herdr transport cannot attach '${args.agentId}': ${avail.reason}`);
171
- let target = args.target ?? process.env.HERDR_PANE_ID;
258
+ let target = args.target ?? this.#env.HERDR_PANE_ID;
172
259
  if (!target) {
260
+ // ⟨q-e439e4ad⟩ `herdr pane current` asks HERDR FOR WHATEVER PANE IS CURRENTLY
261
+ // FOCUSED ON THIS HOST — under stdio that is correct, because the server process
262
+ // itself lives inside the caller's own pane (the same relationship $TMUX_PANE has
263
+ // to a tmux server). AGENT_COORD_HTTP_PORT set means this process is instead the
264
+ // shared bus DAEMON: one long-lived process with no pane of its own, answering
265
+ // every seat on the host. "Current" there names whichever terminal a human last
266
+ // focused — measured attaching two different seats onto ANOTHER FLEET's panes,
267
+ // both calls returning `ok`. Same rule as transports/config.ts's unknown-transport
268
+ // refusal: a silent fallback and a correct default produce identical evidence, so
269
+ // this refuses instead of guessing.
270
+ if (this.#env.AGENT_COORD_HTTP_PORT) {
271
+ throw new Error(
272
+ `herdr target not provided for '${args.agentId}': this server is the shared HTTP daemon ` +
273
+ `(AGENT_COORD_HTTP_PORT set) and has no pane of its own, so \`herdr pane current\` would name ` +
274
+ `whichever pane last had focus on this host, not '${args.agentId}''s. Pass target explicitly ` +
275
+ `(e.g. 'w2:p1').`,
276
+ );
277
+ }
173
278
  const cur = this.#run(["pane", "current"]);
174
279
  target = paneOf(cur)?.pane_id;
175
280
  }
@@ -178,10 +283,12 @@ export class HerdrTransport implements Transport {
178
283
  }
179
284
  const got = this.#run(["pane", "get", target]);
180
285
  if (!got.ok) throw new Error(`herdr pane '${target}' not found: ${got.error?.message ?? got.stderr}`);
286
+ const markerPid = this.#markerPid();
181
287
  return {
182
288
  agentId: args.agentId,
183
289
  transport: HERDR,
184
- pid: 0,
290
+ pid: markerPid.pid,
291
+ pidWhy: markerPid.why,
185
292
  target,
186
293
  tmuxTarget: target,
187
294
  since: Date.now(),
@@ -203,40 +310,108 @@ export class HerdrTransport implements Transport {
203
310
  * Type `text` into the pane and press enter; VERIFY by reading the pane back, and if
204
311
  * the line is still sitting unsubmitted (the spike's race), press enter once more.
205
312
  * Reports the enters it took so the race is measured on every delivery.
313
+ *
314
+ * ⟨q-dc83023a⟩ A DELIVERY IS A BRACKETED PASTE BY DEFAULT. `send-text` types keystrokes and
315
+ * herdr writes them in 1022-byte chunks; Claude Code v2.1.273 folds each large chunk into a
316
+ * `[Pasted text #N]` chip and, on submit, KEPT ONLY THE LAST CHUNK — a 2668-byte message
317
+ * arrived as its final 624 bytes, the PR, sha and gate gone. Wrapped in paste markers, the
318
+ * same bytes arrived whole (measured at 2668 B, 5 KB multi-line and 20 KB). The body is
319
+ * neutralised first so it can never carry the closing marker itself (⟨q-e5cb3538⟩).
320
+ * `paste: false` is for control commands only: a slash command must arrive as typing.
206
321
  */
207
- async push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string; enters?: number; verified?: boolean }> {
322
+ async push(marker: TransportMarker, text: string, opts: { paste?: boolean } = {}): Promise<HerdrPushResult> {
208
323
  const avail = this.availability();
209
- if (!avail.available) return { delivered: false, error: avail.reason };
324
+ if (!avail.available) return { delivered: false, outcome: "held", safeToRetry: true, error: avail.reason };
210
325
  const target = targetOf(marker);
211
- if (!target) return { delivered: false, error: "no target recorded on the marker" };
212
- const typed = this.#run(["pane", "send-text", target, text]);
213
- if (!typed.ok) return { delivered: false, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
326
+ if (!target) return { delivered: false, outcome: "held", safeToRetry: true, error: "no target recorded on the marker" };
327
+ const payload = opts.paste === false ? text : `${PASTE_START}${neutralizeControls(text)}${PASTE_END}`;
214
328
  const enterKey = herdrKeyName("enter");
215
- if (!enterKey.ok) return { delivered: false, error: enterKey.error };
329
+ if (!enterKey.ok) return { delivered: false, outcome: "held", safeToRetry: true, error: enterKey.error };
330
+ if (readyProfile().profile === "none") return this.#pushUnguarded(target, text, payload, enterKey.key);
331
+
332
+ // ⟨q-15d763dc⟩ BEFORE ANY KEY: the screen must be Claude Code's ready, empty input box. The text
333
+ // is held as firmly as the Enter — a digit typed into an open dialog selects an option.
334
+ const before = readReadyBox(this.#screen(target));
335
+ const refusal = refusalBeforeKeys(before);
336
+ if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` };
337
+
338
+ const typed = this.#run(["pane", "send-text", target, payload]);
339
+ if (!typed.ok) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
340
+ this.#sleep(this.#settleMs);
341
+ let box = readReadyBox(this.#screen(target));
342
+ if (!boxHoldsPayload(box, text)) {
343
+ return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, error: `typed into ${target}, but the text did not show in the input box (${box.ready ? "box holds something else" : box.reason}) — Enter NOT sent` };
344
+ }
216
345
  let enters = 0;
217
346
  for (let attempt = 0; attempt < 2; attempt++) {
347
+ // Every Enter follows a read that saw THIS payload in the ready box, never a stale one.
218
348
  const pressed = this.#run(["pane", "send-keys", target, enterKey.key]);
219
- if (!pressed.ok) return { delivered: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
349
+ if (!pressed.ok) return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
350
+ enters++;
351
+ this.#sleep(this.#settleMs);
352
+ box = readReadyBox(this.#screen(target));
353
+ if (transcriptGained(before, box, text)) return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined };
354
+ if (boxHoldsPayload(box, text)) continue;
355
+ return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, enters, error: `enter sent to ${target}, but the screen after it does not show the message submitted (${box.ready ? (box.draft ? "the box holds other text" : "the box is empty and the transcript gained no line carrying it") : box.reason})` };
356
+ }
357
+ return { delivered: false, verified: true, outcome: "pending", safeToRetry: false, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
358
+ }
359
+
360
+ /** The screen, styled, so the ready-box reader can tell a ghost suggestion from a draft. null = unreadable. */
361
+ #screen(target: string): string | null {
362
+ const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "ansi"]);
363
+ return read.ok ? read.stdout : null;
364
+ }
365
+
366
+ /**
367
+ * AGENT_COORD_READY_PROFILE=none, chosen by whoever started this process: #357's push, with no
368
+ * readiness check. Reported `unguarded`, and an unreadable screen still never counts as verified.
369
+ */
370
+ #pushUnguarded(target: string, text: string, payload: string, enter: string): HerdrPushResult {
371
+ const typed = this.#run(["pane", "send-text", target, payload]);
372
+ if (!typed.ok) return { delivered: false, unguarded: true, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
373
+ let enters = 0;
374
+ for (let attempt = 0; attempt < 2; attempt++) {
375
+ const pressed = this.#run(["pane", "send-keys", target, enter]);
376
+ if (!pressed.ok) return { delivered: false, unguarded: true, outcome: "typed-unconfirmed", safeToRetry: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
220
377
  enters++;
221
378
  this.#sleep(this.#settleMs);
222
379
  const pending = this.#stillPending(target, text);
223
- if (pending === false) return { delivered: true, enters, verified: true };
224
- if (pending === null) return { delivered: true, enters, verified: false };
380
+ if (pending === false) return { delivered: true, verified: true, unguarded: true, outcome: "delivered", safeToRetry: false, enters };
381
+ if (pending === null) return { delivered: false, verified: false, unguarded: true, outcome: "unverified", safeToRetry: false, enters, error: `enter sent to ${target}, but the pane could not be read back to verify it` };
225
382
  }
226
- return { delivered: false, enters, verified: true, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
383
+ return { delivered: false, verified: true, unguarded: true, outcome: "pending", safeToRetry: false, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
227
384
  }
228
385
 
229
386
  /**
230
- * Read the pane back: is the typed text still the LAST non-empty line (unsubmitted)?
387
+ * Read the pane back: is the typed text still sitting in the INPUT (unsubmitted)?
231
388
  * true = pending · false = submitted · null = could not read (unverified, not failed).
389
+ *
390
+ * ⟨q-dc83023a⟩ THE INPUT IS THE PROMPT LINE, NOT THE LAST LINE. Claude Code draws a border,
391
+ * a footer and hints ("paste again to expand") BELOW its `❯` input, so the last non-empty
392
+ * line is never the input and the old last-line check answered "submitted" for a message
393
+ * still sitting there. The prompt line is pending when it shows a paste chip or the start
394
+ * of what was typed (a long first line wraps, so either may be a prefix of the other).
395
+ *
396
+ * NO PROMPT LINE → null (UNVERIFIED), never the last-line reading: that reading was measured
397
+ * to answer "submitted" for text still sitting in Claude's input, so falling back to it would
398
+ * let a changed Claude version, an open dialog or a read mid-render verify silently again
399
+ * (the coordinator's condition on ⟨q-dc83023a⟩, from worker-2). What a caller does with an
400
+ * unverified delivery is ⟨q-15d763dc⟩.
232
401
  */
233
402
  #stillPending(target: string, text: string): boolean | null {
234
403
  const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "text"]);
235
404
  if (!read.ok) return null;
236
405
  const lines = read.stdout.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length > 0);
237
- const last = lines.at(-1) ?? "";
238
406
  const firstLine = text.split("\n")[0];
239
- return last.endsWith(firstLine) && !/^[⏺✔✖]/.test(last);
407
+ const prompt = [...lines].reverse().find((l) => /^\s*❯/.test(l));
408
+ if (prompt !== undefined) {
409
+ const content = prompt.replace(/^\s*❯\s?/, "").trim();
410
+ if (!content) return false;
411
+ if (PASTE_CHIP_RE.test(content)) return true;
412
+ return firstLine.startsWith(content) || content.startsWith(firstLine);
413
+ }
414
+ return null;
240
415
  }
241
416
 
242
417
  /**
@@ -276,7 +451,8 @@ export class HerdrTransport implements Transport {
276
451
  * not measured) and are said so in the result.
277
452
  */
278
453
  async sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string; enters?: number; note?: string }> {
279
- const r = await this.push(marker, `/${cmd}`);
454
+ const r = await this.push(marker, `/${cmd}`, { paste: false });
455
+ // push reports delivered only when verified (⟨q-15d763dc⟩), so `delivered` is the whole test here.
280
456
  if (!r.delivered) return { ok: false, error: r.error ?? "control not delivered", enters: r.enters };
281
457
  return {
282
458
  ok: true,
@@ -6,7 +6,7 @@
6
6
  * fall-through at a call site. Until Task 3, tmux is the only registered
7
7
  * implementation and that is the rollback plan — the seam lands behind no config.
8
8
  */
9
- import { HERDR, TMUX_PUSH, TMUX_PUSH_REMOTE, TRANSPORT_KINDS, type Transport, type TransportKind } from "./types.js";
9
+ import { HERDR, TMUX_PUSH_REMOTE, TRANSPORT_KINDS, type Transport, type TransportKind } from "./types.js";
10
10
  import { TmuxTransport, type TmuxHost } from "./tmux.js";
11
11
  import { HerdrTransport } from "./herdr.js";
12
12
  import { configuredTransport } from "./config.js";
@@ -14,7 +14,7 @@ import { configuredTransport } from "./config.js";
14
14
  export * from "./types.js";
15
15
  export * from "./config.js";
16
16
  export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
17
- export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult } from "./herdr.js";
17
+ export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid } from "./herdr.js";
18
18
 
19
19
  let host: TmuxHost | undefined;
20
20
 
@@ -31,7 +31,8 @@ export function resolveTransport(kind: TransportKind): Transport {
31
31
  );
32
32
  }
33
33
  switch (kind) {
34
- case TMUX_PUSH:
34
+ // ⟨q-ec020f6a⟩ local tmux-push deleted outright — TmuxTransport now only ever
35
+ // backs the remote kind. Kept fully intact: only this construction site changed.
35
36
  case TMUX_PUSH_REMOTE:
36
37
  return new TmuxTransport(host, kind);
37
38
  case HERDR:
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { spawnSync } from "node:child_process";
16
16
  import type { ControlCommand, Liveness, Transport, TransportMarker, TransportKind } from "./types.js";
17
- import { TMUX_PUSH, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js";
17
+ import { TMUX_PUSH_REMOTE, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js";
18
18
 
19
19
  /**
20
20
  * The parts of delivery that belong to the PROCESS layer, not to tmux.
@@ -87,7 +87,10 @@ export class TmuxTransport implements Transport {
87
87
  readonly kind: TransportKind;
88
88
  #host: TmuxHost;
89
89
 
90
- constructor(host: TmuxHost, kind: TransportKind = TMUX_PUSH) {
90
+ // ⟨q-ec020f6a⟩ local tmux-push deleted: this class now only ever backs the remote kind,
91
+ // so the default (used only by tests that omit `kind`) follows that — everything else
92
+ // in this file is unchanged, per the slice's "keep TmuxTransport fully intact" scope.
93
+ constructor(host: TmuxHost, kind: TransportKind = TMUX_PUSH_REMOTE) {
91
94
  this.#host = host;
92
95
  this.kind = kind;
93
96
  }