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.
- package/README.md +31 -70
- package/dist/capabilities.js +169 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +12 -1
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +60 -53
- package/dist/server-spread.js.map +1 -1
- package/dist/server.js +53 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-delivery.js +86 -26
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +243 -0
- package/dist/tools/herdr-tail.js.map +1 -0
- package/dist/tools/jsonl-offsets.js +37 -0
- package/dist/tools/jsonl-offsets.js.map +1 -0
- package/dist/tools/messaging.js +18 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +18 -2
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +56 -21
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/transport.js +17 -148
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/config.js +22 -11
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +134 -18
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +4 -3
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/tmux.js +5 -2
- package/dist/transports/tmux.js.map +1 -1
- package/dist/transports/types.js +18 -3
- package/dist/transports/types.js.map +1 -1
- package/hooks/control-bytes.mjs +69 -0
- package/hooks/submit.mjs +227 -9
- package/hooks/tier.mjs +7 -1
- package/package.json +4 -2
- package/scripts/check-global-mcp-fallback.mjs +85 -0
- package/scripts/coord-pusher.mjs +5 -1
- package/scripts/coord-seat.mjs +96 -0
- package/scripts/coord-token.mjs +79 -4
- package/scripts/stop-agent.sh +8 -4
- package/src/capabilities.ts +170 -5
- package/src/gated-head.ts +12 -1
- package/src/server-spread.ts +52 -4
- package/src/server.ts +49 -3
- package/src/tools/herdr-delivery.ts +92 -24
- package/src/tools/herdr-tail.ts +276 -0
- package/src/tools/jsonl-offsets.ts +29 -0
- package/src/tools/messaging.ts +20 -0
- package/src/tools/records.ts +18 -2
- package/src/tools/registry.ts +56 -20
- package/src/tools/transport.ts +17 -154
- package/src/transports/config.ts +25 -12
- package/src/transports/herdr.ts +193 -17
- package/src/transports/index.ts +4 -3
- package/src/transports/tmux.ts +5 -2
- package/src/transports/types.ts +24 -4
- package/hooks/tmux-pusher.mjs +0 -967
- package/scripts/spawn-agent.sh +0 -94
package/src/tools/transport.ts
CHANGED
|
@@ -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,
|
|
31
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
34
32
|
import { promises as fsp } from "node:fs";
|
|
35
|
-
import {
|
|
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:
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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:
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
};
|
package/src/transports/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
14
|
-
* a typo in the config reads exactly like a deliberate default, and the
|
|
15
|
-
* who typed `heardr` spends the afternoon asking why their transport
|
|
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 {
|
|
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"
|
|
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
|
|
52
|
-
`default leave identical evidence, so a typo here would look exactly like a working
|
|
53
|
-
`transport change would appear to do nothing. Fix the value or remove it to
|
|
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
|
-
|
|
101
|
-
|
|
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
|
/**
|
package/src/transports/herdr.ts
CHANGED
|
@@ -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 ??
|
|
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 ??
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
224
|
-
if (pending === null) return { delivered: true,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/transports/index.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
package/src/transports/tmux.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|