agentradar-cli 0.1.1 → 0.1.3
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 +4 -2
- package/dist/index.js +11 -1
- package/dist/runner.js +17 -0
- package/dist/setup.js +61 -2
- package/dist/vendor-runner/feeder.mjs +550 -0
- package/dist/vendor-runner/pane-live.sh +27 -0
- package/dist/vendor-runner/session-frame.mjs +119 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +362 -0
- package/dist/vendor-runner/terminal-bridge.mjs +467 -0
- package/package.json +4 -2
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ===========================================================================
|
|
3
|
+
// AgentRadar I2 feeder — the INTERACTIVE-HOST variant of the vendor runner.
|
|
4
|
+
//
|
|
5
|
+
// Unlike runner.mjs (which spawns a fresh `claude` per event, out-of-context), the
|
|
6
|
+
// feeder pushes each drained client event as the NEXT TURN of a PERSISTENT, always-on
|
|
7
|
+
// `claude` running in a tmux pane (real in-session continuity — I1). It:
|
|
8
|
+
// 1. phones home to the single AgentRadar AWP MCP and long-polls `next_event`;
|
|
9
|
+
// 2. injects each event into the live pane via `tmux send-keys` (I2), pinning the
|
|
10
|
+
// EXPLICIT conversationId so the agent's `reply` never guesses the target;
|
|
11
|
+
// 3. acks an event ONLY after the submitted eventId appears in Claude's pane history
|
|
12
|
+
// (no-phantom: tmux accepting keystrokes is not enough — compaction can discard them).
|
|
13
|
+
//
|
|
14
|
+
// ISOLATION: the feeder (a trusted container-internal daemon) holds AWP_BEARER to
|
|
15
|
+
// drive next_event. It runs OUTSIDE the agent's tmux session, so the bearer is never
|
|
16
|
+
// in the agent's process env nor the vendor-streamed pane. The agent's own outbound
|
|
17
|
+
// calls (reply/report_usage) use awp-only.json (0600) — same as the runner.
|
|
18
|
+
// ===========================================================================
|
|
19
|
+
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import http from "node:http";
|
|
24
|
+
import https from "node:https";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import { formatInjectionFrame, formatProbeFrame, sendKeysCommands } from "./session-frame.mjs";
|
|
28
|
+
|
|
29
|
+
const PANE_LIVE_SH = join(dirname(fileURLToPath(import.meta.url)), "pane-live.sh");
|
|
30
|
+
|
|
31
|
+
const AWP_MCP_ENDPOINT = process.env.AWP_MCP_URL ?? "https://staging.agentradar.com/api/mcp";
|
|
32
|
+
// #152 credential hygiene: PREFER a 0600 file (AWP_BEARER_FILE) so the bearer is never in this
|
|
33
|
+
// process's environment (i.e. not `ps eww`-visible to a co-resident process). Fall back to the raw
|
|
34
|
+
// AWP_BEARER env var for back-compat (Docker `-e`, older supervisors). The file path is not a secret.
|
|
35
|
+
const AWP_BEARER = (() => {
|
|
36
|
+
const f = process.env.AWP_BEARER_FILE;
|
|
37
|
+
if (f) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.readFileSync(f, "utf8").trim();
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error(`[feeder] FATAL: AWP_BEARER_FILE set but unreadable (${f}): ${e.message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return process.env.AWP_BEARER ?? "";
|
|
46
|
+
})();
|
|
47
|
+
const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
|
|
48
|
+
// The claude pid marker (same predicate entrypoint.sh uses) — proves the pane is live.
|
|
49
|
+
const AGENT_PID_PATTERN = process.env.AGENT_PID_PATTERN ?? "mcp-config.*awp-only";
|
|
50
|
+
|
|
51
|
+
const MCP_CALL_TIMEOUT_MS = Number(process.env.AWP_MCP_CALL_TIMEOUT_MS ?? "30000");
|
|
52
|
+
// #171: mirror the SERVER long-poll hold (NEXT_EVENT_HOLD_MS, default 200s), held UNDER the ~240s ALB
|
|
53
|
+
// idle-timeout so the receive long-poll returns cleanly instead of the ALB killing its socket every
|
|
54
|
+
// cycle ("fetch failed — re-establishing"). SEPARATE from the server's liveness cadence
|
|
55
|
+
// (NEXT_EVENT_LONGPOLL_MS, pinned 240s) — see #171. Leave NEXT_EVENT_HOLD_MS unset for the 200s default.
|
|
56
|
+
const NEXT_EVENT_HOLD_MS = Number(process.env.NEXT_EVENT_HOLD_MS ?? "200000");
|
|
57
|
+
// Watchdog past the server hold so a dead socket aborts → self-heal (matches runner.mjs). At 200s the
|
|
58
|
+
// watchdog is 230s, still under the ~240s ALB idle-timeout.
|
|
59
|
+
const NEXT_EVENT_TIMEOUT_MS = NEXT_EVENT_HOLD_MS + 30000;
|
|
60
|
+
const IDLE_DELAY_MS = Number(process.env.IDLE_DELAY_MS ?? "1000");
|
|
61
|
+
const ERROR_DELAY_MS = Number(process.env.ERROR_DELAY_MS ?? "3000");
|
|
62
|
+
// How long to wait for the pane to come back (mid-revive) before giving up this round.
|
|
63
|
+
const PANE_WAIT_MS = Number(process.env.PANE_WAIT_MS ?? "15000");
|
|
64
|
+
const PANE_POLL_MS = Number(process.env.PANE_POLL_MS ?? "1000");
|
|
65
|
+
// Small settle between literal-text and Enter, and between events, so the TUI keeps up.
|
|
66
|
+
const KEY_SETTLE_MS = Number(process.env.KEY_SETTLE_MS ?? "150");
|
|
67
|
+
// #156 ACT-liveness: how often the feeder samples the pane fingerprint + reports it (L2 stagnation
|
|
68
|
+
// detection). 0 disables. Independent of the long-poll so stagnation is sampled at a useful cadence.
|
|
69
|
+
const REPORT_PANE_INTERVAL_MS = Number(process.env.REPORT_PANE_INTERVAL_MS ?? "45000");
|
|
70
|
+
// #156 cadence fix: report_pane runs on its OWN short-lived connection (mcpPostIsolated), NOT the
|
|
71
|
+
// long-poll's pooled keep-alive socket — that socket is killed by the ALB idle-timeout at ~240s mid-hold,
|
|
72
|
+
// so a report_pane sharing it hangs to MCP_CALL_TIMEOUT and pane sampling stalls. A short timeout bounds
|
|
73
|
+
// any single failure well under the 45s interval.
|
|
74
|
+
const PANE_MCP_TIMEOUT_MS = Number(process.env.PANE_MCP_TIMEOUT_MS ?? "10000");
|
|
75
|
+
|
|
76
|
+
function sleep(ms) {
|
|
77
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --------------------------------------------------------------------------
|
|
81
|
+
// Minimal MCP client over Streamable-HTTP (initialize + tools/call) — the proven
|
|
82
|
+
// shape from runner.mjs, incl. the dead-socket watchdog + session-id handling.
|
|
83
|
+
// --------------------------------------------------------------------------
|
|
84
|
+
let _sessionId = null;
|
|
85
|
+
function parseMcpBody(text) {
|
|
86
|
+
const trimmed = text.trim();
|
|
87
|
+
if (trimmed.startsWith("{")) return JSON.parse(trimmed);
|
|
88
|
+
for (const line of trimmed.split("\n")) {
|
|
89
|
+
const m = line.match(/^data:\s*(\{.*\})\s*$/);
|
|
90
|
+
if (m) return JSON.parse(m[1]);
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`unparseable MCP response: ${trimmed.slice(0, 200)}`);
|
|
93
|
+
}
|
|
94
|
+
async function mcpPost(method, params, { notification = false, timeoutMs = MCP_CALL_TIMEOUT_MS } = {}) {
|
|
95
|
+
const headers = {
|
|
96
|
+
"content-type": "application/json",
|
|
97
|
+
accept: "application/json, text/event-stream",
|
|
98
|
+
authorization: `Bearer ${AWP_BEARER}`,
|
|
99
|
+
};
|
|
100
|
+
if (_sessionId) headers["mcp-session-id"] = _sessionId;
|
|
101
|
+
const body = { jsonrpc: "2.0", method, params: params ?? {} };
|
|
102
|
+
if (!notification) body.id = Date.now();
|
|
103
|
+
const ac = new AbortController();
|
|
104
|
+
let timedOut = false;
|
|
105
|
+
const timer = timeoutMs > 0 ? setTimeout(() => { timedOut = true; ac.abort(); }, timeoutMs) : null;
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(AWP_MCP_ENDPOINT, { method: "POST", headers, body: JSON.stringify(body), signal: ac.signal });
|
|
108
|
+
const sid = res.headers.get("mcp-session-id");
|
|
109
|
+
if (sid) _sessionId = sid;
|
|
110
|
+
if (notification) return null;
|
|
111
|
+
const text = await res.text();
|
|
112
|
+
if (!res.ok) throw new Error(`MCP ${method} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
113
|
+
const parsed = parseMcpBody(text);
|
|
114
|
+
if (parsed.error) throw new Error(`MCP ${method} error: ${JSON.stringify(parsed.error)}`);
|
|
115
|
+
return parsed.result;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (timedOut) throw new Error(`MCP ${method} client-side timeout after ${timeoutMs}ms (dead poll/socket)`);
|
|
118
|
+
throw err;
|
|
119
|
+
} finally {
|
|
120
|
+
if (timer) clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// #156: issue ONE MCP call over a FRESH, keep-alive-OFF connection, independent of the pooled socket
|
|
124
|
+
// the next_event long-poll holds. The AWP server is STATELESS (no session; fresh transport per request),
|
|
125
|
+
// so a bare tools/call over a brand-new short-lived connection is valid and cheap — and it can never
|
|
126
|
+
// inherit the long-poll socket that the ALB idle-timeout kills at ~240s mid-hold. Used by report_pane so
|
|
127
|
+
// pane sampling stays on its 45s cadence regardless of the receive long-poll's socket lifecycle.
|
|
128
|
+
function mcpPostIsolated(method, params, { timeoutMs = PANE_MCP_TIMEOUT_MS } = {}) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
let url;
|
|
131
|
+
try {
|
|
132
|
+
url = new URL(AWP_MCP_ENDPOINT);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
reject(e);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
138
|
+
const AgentCtor = url.protocol === "https:" ? https.Agent : http.Agent;
|
|
139
|
+
const agent = new AgentCtor({ keepAlive: false });
|
|
140
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params: params ?? {} });
|
|
141
|
+
const req = lib.request(
|
|
142
|
+
url,
|
|
143
|
+
{
|
|
144
|
+
method: "POST",
|
|
145
|
+
agent,
|
|
146
|
+
headers: {
|
|
147
|
+
"content-type": "application/json",
|
|
148
|
+
accept: "application/json, text/event-stream",
|
|
149
|
+
authorization: `Bearer ${AWP_BEARER}`,
|
|
150
|
+
"content-length": Buffer.byteLength(payload),
|
|
151
|
+
},
|
|
152
|
+
timeout: timeoutMs,
|
|
153
|
+
},
|
|
154
|
+
(res) => {
|
|
155
|
+
let bodyText = "";
|
|
156
|
+
res.setEncoding("utf8");
|
|
157
|
+
res.on("data", (c) => { bodyText += c; });
|
|
158
|
+
res.on("end", () => {
|
|
159
|
+
agent.destroy();
|
|
160
|
+
const status = res.statusCode ?? 0;
|
|
161
|
+
if (status >= 400) {
|
|
162
|
+
reject(new Error(`MCP ${method} → HTTP ${status}: ${bodyText.slice(0, 200)}`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const parsed = parseMcpBody(bodyText);
|
|
167
|
+
if (parsed.error) {
|
|
168
|
+
reject(new Error(`MCP ${method} error: ${JSON.stringify(parsed.error)}`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
resolve(parsed.result);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
reject(e);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
req.on("timeout", () => { req.destroy(new Error(`MCP ${method} isolated timeout after ${timeoutMs}ms`)); });
|
|
179
|
+
req.on("error", (err) => { agent.destroy(); reject(err); });
|
|
180
|
+
req.end(payload);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function mcpInitialize() {
|
|
184
|
+
_sessionId = null;
|
|
185
|
+
await mcpPost("initialize", {
|
|
186
|
+
protocolVersion: "2024-11-05",
|
|
187
|
+
capabilities: {},
|
|
188
|
+
clientInfo: { name: "agentradar-i2-feeder", version: "1.0.0" },
|
|
189
|
+
});
|
|
190
|
+
await mcpPost("notifications/initialized", {}, { notification: true });
|
|
191
|
+
}
|
|
192
|
+
function toolJson(result) {
|
|
193
|
+
const text = result?.content?.find((c) => c.type === "text")?.text;
|
|
194
|
+
return text ? JSON.parse(text) : {};
|
|
195
|
+
}
|
|
196
|
+
async function nextEvent(ackEventIds) {
|
|
197
|
+
const args = ackEventIds && ackEventIds.length ? { ackEventIds } : {};
|
|
198
|
+
const result = await mcpPost("tools/call", { name: "next_event", arguments: args }, { timeoutMs: NEXT_EVENT_TIMEOUT_MS });
|
|
199
|
+
return toolJson(result); // { events: [...] }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --------------------------------------------------------------------------
|
|
203
|
+
// tmux injection — push one event into the live pane as the agent's next turn.
|
|
204
|
+
// --------------------------------------------------------------------------
|
|
205
|
+
/** True only when this runner's named tmux pane owns a matching agent process. */
|
|
206
|
+
export function paneIsLive(run = spawnSync) {
|
|
207
|
+
const r = run("/bin/sh", [PANE_LIVE_SH], {
|
|
208
|
+
encoding: "utf8",
|
|
209
|
+
env: {
|
|
210
|
+
PATH: process.env.PATH,
|
|
211
|
+
TMUX_BIN: process.env.TMUX_BIN ?? "tmux",
|
|
212
|
+
TMUX_SESSION,
|
|
213
|
+
AGENT_PID_PATTERN,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return r.status === 0;
|
|
217
|
+
}
|
|
218
|
+
/** Wait up to PANE_WAIT_MS for the pane to come back (e.g. mid --continue revive). */
|
|
219
|
+
async function waitForLivePane() {
|
|
220
|
+
const deadline = Date.now() + PANE_WAIT_MS;
|
|
221
|
+
while (Date.now() < deadline) {
|
|
222
|
+
if (paneIsLive()) return true;
|
|
223
|
+
await sleep(PANE_POLL_MS);
|
|
224
|
+
}
|
|
225
|
+
return paneIsLive();
|
|
226
|
+
}
|
|
227
|
+
function tmux(argv) {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const child = spawn("tmux", argv, { stdio: ["ignore", "ignore", "inherit"] });
|
|
230
|
+
child.on("close", (code) => resolve(code === 0));
|
|
231
|
+
child.on("error", () => resolve(false));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/** Push one already-formatted frame into the live pane (literal text + Enter). Returns true only if it
|
|
235
|
+
* reached a live pane. Shared by real events (injectEvent) and the #156 liveness probe (injectProbe). */
|
|
236
|
+
async function injectFrame(frame, label) {
|
|
237
|
+
if (!paneIsLive() && !(await waitForLivePane())) {
|
|
238
|
+
console.warn(`[feeder] pane ${TMUX_SESSION} not live — NOT injecting ${label} (will re-deliver / probe times out)`);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const [literalCmd, enterCmd] = sendKeysCommands(TMUX_SESSION, frame);
|
|
242
|
+
const okText = await tmux(literalCmd);
|
|
243
|
+
await sleep(KEY_SETTLE_MS);
|
|
244
|
+
const okEnter = okText && (await tmux(enterCmd));
|
|
245
|
+
if (okEnter) console.log(`[feeder] injected ${label}`);
|
|
246
|
+
else console.warn(`[feeder] send-keys failed for ${label}`);
|
|
247
|
+
return Boolean(okEnter);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 🔴 DELIVERED ≠ ACTED. `injectFrame` only proves the KEYSTROKES landed (`tmux send-keys` exit 0) — and the
|
|
251
|
+
* ack is keyed off that. Measured 2026-08-03: a real Telegram event was logged `injected … → delivered 1/1 →
|
|
252
|
+
* will ack` while the pane was mid auto-`/compact`; the TUI discarded the text, no turn ever ran, and the
|
|
253
|
+
* event was acked and lost permanently. A retry 20 min later answered in 15s, so the path was healthy
|
|
254
|
+
* throughout — the defect is that "typed at" was treated as "received by".
|
|
255
|
+
*
|
|
256
|
+
* The first gate is the pane's OWN in-progress hint, which `capturePane` already computes. Returning false
|
|
257
|
+
* here means NOT delivered, so the eventId never reaches `_pendingAck` and the server re-delivers on the next
|
|
258
|
+
* poll. A second gate after injection confirms the exact eventId appears in pane history before acking, which
|
|
259
|
+
* covers compaction states whose rendering has no in-progress hint.
|
|
260
|
+
*
|
|
261
|
+
* 🔴 IT LIVES ON injectEvent, NOT injectFrame — deliberately. A liveness PROBE skipped because the pane is
|
|
262
|
+
* BUSY would time out server-side and be read as act-DEAD, which is precisely backwards: a busy pane is the
|
|
263
|
+
* strongest evidence the agent is alive. Probes must keep going in unconditionally.
|
|
264
|
+
*
|
|
265
|
+
* ponytail: receipt means Claude rendered the exact eventId as a submitted turn, not that it completed the
|
|
266
|
+
* work. Completion remains tool-specific (`reply`, `report_usage`, etc.); this gate only prevents permanent
|
|
267
|
+
* loss when tmux accepts keystrokes that Claude discards.
|
|
268
|
+
*/
|
|
269
|
+
const INJECT_BUSY_WAIT_MS = Number(process.env.INJECT_BUSY_WAIT_MS ?? "120000");
|
|
270
|
+
const INJECT_BUSY_POLL_MS = Number(process.env.INJECT_BUSY_POLL_MS ?? "2000");
|
|
271
|
+
|
|
272
|
+
/** True once the pane is not mid-turn. Unsamplable pane ⇒ true: never block delivery on a broken instrument.
|
|
273
|
+
* Sampler/sleep/budget are injected so the gate is drivable with no real pane and no real time. */
|
|
274
|
+
export async function waitForIdlePane(sample = capturePane, sleepFn = sleep, budgetMs = INJECT_BUSY_WAIT_MS) {
|
|
275
|
+
const deadline = Date.now() + budgetMs;
|
|
276
|
+
for (;;) {
|
|
277
|
+
const p = sample();
|
|
278
|
+
if (!p || !p.inProgress) return true;
|
|
279
|
+
if (Date.now() >= deadline) return false;
|
|
280
|
+
await sleepFn(INJECT_BUSY_POLL_MS);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** True only when the submitted event appears in Claude's pane history. `send-keys` success alone merely
|
|
285
|
+
* proves tmux accepted keystrokes; Claude can discard them during compaction. */
|
|
286
|
+
function paneHasEvent(eventId) {
|
|
287
|
+
if (!eventId) return false;
|
|
288
|
+
const r = spawnSync("tmux", ["capture-pane", "-p", "-S", "-1000", "-t", TMUX_SESSION], { encoding: "utf8" });
|
|
289
|
+
return r.status === 0 && typeof r.stdout === "string" && r.stdout.includes(eventId);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Wait for Claude to accept the injected frame as a turn. Unobservable means unacknowledged (fail closed). */
|
|
293
|
+
export async function waitForPaneReceipt(eventId, sample = paneHasEvent, sleepFn = sleep, budgetMs = PANE_WAIT_MS) {
|
|
294
|
+
const deadline = Date.now() + budgetMs;
|
|
295
|
+
for (;;) {
|
|
296
|
+
if (sample(eventId)) return true;
|
|
297
|
+
if (Date.now() >= deadline) return false;
|
|
298
|
+
await sleepFn(PANE_POLL_MS);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Inject one client event into the live pane. Returns true only after Claude's pane shows the submitted turn. */
|
|
303
|
+
async function injectEvent(event) {
|
|
304
|
+
if (!(await waitForIdlePane())) {
|
|
305
|
+
console.warn(
|
|
306
|
+
`[feeder] pane ${TMUX_SESSION} still mid-turn after ${INJECT_BUSY_WAIT_MS}ms — NOT injecting eventId=${event.eventId}; leaving it UNACKED for re-delivery`,
|
|
307
|
+
);
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const injected = await injectFrame(
|
|
311
|
+
formatInjectionFrame(event),
|
|
312
|
+
`eventId=${event.eventId} (source=${event.source}, conversationId=${event.conversationId ?? "-"})`,
|
|
313
|
+
);
|
|
314
|
+
if (!injected) return false;
|
|
315
|
+
if (await waitForPaneReceipt(event.eventId)) return true;
|
|
316
|
+
console.warn(
|
|
317
|
+
`[feeder] pane ${TMUX_SESSION} did not show eventId=${event.eventId} after injection — leaving it UNACKED for re-delivery`,
|
|
318
|
+
);
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
/** #156/#173b/#176: inject a liveness probe through the SAME trusted control-plane frame as real events
|
|
322
|
+
* (formatProbeFrame → `[AgentRadar inbound · source=agentradar · eventId=<uuid>]`) so a security-hardened
|
|
323
|
+
* agent recognizes it as legitimate platform traffic and honors it, rather than refusing a raw in-chat
|
|
324
|
+
* instruction as prompt-injection (#173b root cause). #176: the server mints a real UUID eventId
|
|
325
|
+
* (shape-matching a real event) so the frame no longer trips an agent's "genuine events have a UUID
|
|
326
|
+
* eventId" trust heuristic; the NONCE remains the sole anti-spoof boundary.
|
|
327
|
+
* FENCE: the probe is NEVER acked. Its eventId is NOT added to _pendingAck (this path is separate from
|
|
328
|
+
* injectEvent's delivered[] → ack flow; the fence is by code-path, independent of the eventId format) — a
|
|
329
|
+
* probe is answered by the agent calling liveness_ack(nonce), not by an ackEventIds echo, so it can never
|
|
330
|
+
* double-count or mis-ack a real event.
|
|
331
|
+
* Best-effort: if the pane is dead we DON'T inject (the probe then times out server-side → the
|
|
332
|
+
* act-signal treats genuine silence as act-dead, the correct verdict). */
|
|
333
|
+
async function injectProbe(probe) {
|
|
334
|
+
if (!probe || !probe.nonce || !probe.instruction) return;
|
|
335
|
+
await injectFrame(formatProbeFrame(probe), `liveness-probe nonce=${probe.nonce}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --------------------------------------------------------------------------
|
|
339
|
+
// #156 L2 pane-scan — sample the tmux pane fingerprint + an in-progress hint and report it (report_pane).
|
|
340
|
+
// TOKEN-FREE (no LLM): just tmux capture + one MCP call on a timer. A STAGNANT pane while in-progress is
|
|
341
|
+
// a probe TRIGGER server-side — never a decision (a changing pane may be a silent loop).
|
|
342
|
+
// --------------------------------------------------------------------------
|
|
343
|
+
/**
|
|
344
|
+
* #173b/§4 — strip claude's CLIENT-SIDE-VOLATILE working line(s) before fingerprinting the pane.
|
|
345
|
+
*
|
|
346
|
+
* THE corpse-safety fix. claude's TUI animates a spinner + an elapsed-seconds counter mid-turn, e.g.
|
|
347
|
+
* "✻ Brewed for 11s" / "✻ Mustering… (19s · still thinking)" + a "… esc to interrupt" hint.
|
|
348
|
+
* That counter ticks in the CLIENT, independent of the LLM. On a realistic mid-turn API hang (process
|
|
349
|
+
* alive, LLM dead) the raw capture keeps CHANGING → a raw hash never stagnates → the agent reads ALIVE
|
|
350
|
+
* forever → we bill a corpse. Hashing only the transcript body (spinner/counter removed) makes a hung
|
|
351
|
+
* pane produce a STABLE hash → stagnation is detectable. (in_progress detection still uses the RAW text
|
|
352
|
+
* — the hint is the trigger signal, not part of the fingerprint.)
|
|
353
|
+
*/
|
|
354
|
+
export function stripVolatilePaneLines(text) {
|
|
355
|
+
return String(text ?? "")
|
|
356
|
+
.split("\n")
|
|
357
|
+
.filter((line) => {
|
|
358
|
+
// the mid-turn interrupt hint (often carries the ticking counter)
|
|
359
|
+
if (/\besc to (?:interrupt|cancel)\b/i.test(line)) return false;
|
|
360
|
+
// claude's spinner line, TWO observed forms — a leading animated glyph + a SINGLE gerund word +
|
|
361
|
+
// the elapsed counter. Tightly anchored (one word before the counter) so real transcript prose
|
|
362
|
+
// that merely contains "for 5s"/"(5s)" mid-sentence is NOT stripped. The counter grows through
|
|
363
|
+
// seconds → minutes → hours ("19s" → "1m 44s" → "1h 2m 3s"): past 60s claude switches to the
|
|
364
|
+
// "Xm Ys" form, so a seconds-ONLY matcher misses a >60s hang and the strip fails to stabilize the
|
|
365
|
+
// fingerprint (a corpse-safety hole the #176 live C-HANG caught). `\d+s`(-only) → the h/m/s fragment:
|
|
366
|
+
// "✻ Brewed for 11s" / "✻ Brewed for 1m 5s"
|
|
367
|
+
if (/^\s*[^\w\s]\s+\S+\s+for\s+(?:\d+h\s*)?(?:\d+m\s*)?\d+s\b/.test(line)) return false;
|
|
368
|
+
// "✻ Mustering… (19s · still thinking)" / "✶ Twisting… (1m 44s)" / "… (1h 2m 3s)"
|
|
369
|
+
if (/^\s*[^\w\s]\s+\S+…?\s*\(\s*(?:\d+h\s*)?(?:\d+m\s*)?\d+s\b/.test(line)) return false;
|
|
370
|
+
// #179 claude's CONNECTION-HANG retry loop. On a connection-LEVEL failure (refused / reset / connect-
|
|
371
|
+
// timeout — distinct from the stream-alive stall the two spinner matchers above cover), claude retries
|
|
372
|
+
// ~10× on a status line whose countdown AND attempt counter BOTH tick:
|
|
373
|
+
// "✻ Unable to connect to API (ConnectionRefused) · Retrying in 3s · attempt 4/10"
|
|
374
|
+
// Neither matcher above catches it (no "for Ns"/"(Ns"), and the error text truncates on a narrow pane
|
|
375
|
+
// — "(ConnectionRefuse…" — so the leading text is unstable too. Result: on a connection-hang the
|
|
376
|
+
// stripped fingerprint DRIFTS as `attempt X/10` increments (and the "Retrying in Ns" counts down) →
|
|
377
|
+
// pane_stagnant is transiently defeated → a corpse can read alive (the >60s bug's connection-mode
|
|
378
|
+
// sibling). Anchor on the stable DUAL signature — glyph + "Retrying in <N>s" + "attempt X/Y" — so
|
|
379
|
+
// real transcript prose (which won't carry BOTH markers on one line) is never over-stripped; `.*`
|
|
380
|
+
// between them tolerates the truncating/variable error text.
|
|
381
|
+
if (/^\s*[^\w\s]\s+.*\bRetrying in\s+(?:\d+h\s*)?(?:\d+m\s*)?\d+s\b.*\battempt\s+\d+\/\d+/i.test(line)) return false;
|
|
382
|
+
return true;
|
|
383
|
+
})
|
|
384
|
+
.join("\n");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function capturePane() {
|
|
388
|
+
const r = spawnSync("tmux", ["capture-pane", "-p", "-t", TMUX_SESSION], { encoding: "utf8" });
|
|
389
|
+
if (r.status !== 0 || typeof r.stdout !== "string") {
|
|
390
|
+
// #156 diagnostics: surface WHY capture failed (session mismatch / tmux error) instead of a silent
|
|
391
|
+
// null return — otherwise report_pane skips with no trace and pane_observed_at silently stalls.
|
|
392
|
+
if (process.env.DEBUG_PANE) {
|
|
393
|
+
const why = r.error ? r.error.message : `status=${r.status} stderr=${(r.stderr ?? "").toString().trim().slice(0, 160)}`;
|
|
394
|
+
console.warn(`[feeder] capture-pane FAILED for session "${TMUX_SESSION}": ${why}`);
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
const text = r.stdout;
|
|
399
|
+
// Fingerprint the TRANSCRIPT BODY only — strip the client-side-ticking spinner/counter so a mid-turn
|
|
400
|
+
// API hang (LLM dead, counter still ticking) yields a STABLE hash → stagnation is detectable (§4).
|
|
401
|
+
const hash = createHash("sha256").update(stripVolatilePaneLines(text)).digest("hex").slice(0, 32);
|
|
402
|
+
// claude's TUI shows a working line ("… esc to interrupt") while mid-turn. This is only a HINT that
|
|
403
|
+
// triggers a probe on stagnation — it never decides liveness, so a missed/extra hint is harmless.
|
|
404
|
+
const inProgress = /esc to interrupt|\besc to cancel\b/i.test(text);
|
|
405
|
+
return { hash, inProgress };
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
|
|
409
|
+
* still reads in_progress across consecutive samples, that is the signature of a claude working-line
|
|
410
|
+
* variant the strip FAILED to catch (an unseen glyph/counter syntax ticking through). It also matches
|
|
411
|
+
* a genuinely-working agent — so this is a DEBUG-only observability hint, NOT a decision: correlated
|
|
412
|
+
* with "no genuine acts" (the #4 idle-backstop) it flags a strip that needs a new anchor, instead of
|
|
413
|
+
* silently corpse-billing. Pure so it's unit-testable.
|
|
414
|
+
*/
|
|
415
|
+
export function looksLikeStripMiss(prev, cur) {
|
|
416
|
+
return Boolean(prev && cur && prev.inProgress && cur.inProgress && prev.hash !== cur.hash);
|
|
417
|
+
}
|
|
418
|
+
let _lastPaneSample = null;
|
|
419
|
+
let _paneReportCount = 0;
|
|
420
|
+
async function reportPane() {
|
|
421
|
+
// #156 diagnostics: log EVERY outcome (skip / ok+latency / error) under DEBUG_PANE with a monotonic
|
|
422
|
+
// counter, so a single capture shows whether report_pane is firing at cadence and, if not, exactly
|
|
423
|
+
// where it fails (tmux capture vs the MCP call). The previous version logged only the catch, so a
|
|
424
|
+
// null-capture skip left no trace — the silent-stall blind spot.
|
|
425
|
+
const n = ++_paneReportCount;
|
|
426
|
+
const t0 = Date.now();
|
|
427
|
+
try {
|
|
428
|
+
const p = capturePane();
|
|
429
|
+
if (!p) {
|
|
430
|
+
if (process.env.DEBUG_PANE) console.warn(`[feeder] report_pane #${n}: SKIP (no pane capture)`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// #173b (disc obs): flag a possible strip-MISS (stripped hash still ticking while in_progress) so a
|
|
434
|
+
// future claude working-line format that defeats the strip is OBSERVABLE, not silently corpse-billing.
|
|
435
|
+
if (process.env.DEBUG_PANE && looksLikeStripMiss(_lastPaneSample, p)) {
|
|
436
|
+
console.warn(
|
|
437
|
+
`[feeder] report_pane #${n}: STRIP-MISS? stripped hash changed while in_progress (${_lastPaneSample.hash.slice(0, 8)}→${p.hash.slice(0, 8)}) — real progress OR an uncaught working-line format; if it persists with NO genuine acts, the strip needs a new anchor.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
_lastPaneSample = p;
|
|
441
|
+
// #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
|
|
442
|
+
// the receive long-poll's ALB-killed socket.
|
|
443
|
+
await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress } });
|
|
444
|
+
if (process.env.DEBUG_PANE) {
|
|
445
|
+
console.log(`[feeder] report_pane #${n}: OK hash=${p.hash.slice(0, 8)} inProgress=${p.inProgress} (${Date.now() - t0}ms)`);
|
|
446
|
+
}
|
|
447
|
+
} catch (err) {
|
|
448
|
+
// Best-effort — an older server without report_pane, or a transient error, must never crash the feeder.
|
|
449
|
+
if (process.env.DEBUG_PANE) {
|
|
450
|
+
console.warn(`[feeder] report_pane #${n}: ERROR after ${Date.now() - t0}ms — ${err instanceof Error ? err.message : String(err)}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --------------------------------------------------------------------------
|
|
456
|
+
// The loop: initialize → poll next_event → inject each into the live pane → ack the
|
|
457
|
+
// delivered ones on the NEXT poll → self-heal on transient MCP errors (runner shape).
|
|
458
|
+
// --------------------------------------------------------------------------
|
|
459
|
+
let _stopping = false;
|
|
460
|
+
let _pendingAck = [];
|
|
461
|
+
let _paneTimer = null;
|
|
462
|
+
|
|
463
|
+
/** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
|
|
464
|
+
* fence is directly testable). Sets `_pendingAck` = the eventIds to ack on the NEXT poll. */
|
|
465
|
+
export async function pollOnce(paneLive = paneIsLive) {
|
|
466
|
+
// next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
|
|
467
|
+
// process without its owned tmux session cannot receive work, so polling would paint a false Live state
|
|
468
|
+
// while every event stays unacknowledged. The supervisor recreates the session; until then the durable
|
|
469
|
+
// queue remains untouched and liveness ages out honestly.
|
|
470
|
+
if (!paneLive()) {
|
|
471
|
+
console.warn(`[feeder] pane ${TMUX_SESSION} not live — not polling next_event`);
|
|
472
|
+
await sleep(IDLE_DELAY_MS);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const { events = [], livenessProbe } = await nextEvent(_pendingAck.length ? _pendingAck : undefined);
|
|
476
|
+
_pendingAck = [];
|
|
477
|
+
// #156/#173b: forward a liveness probe (if any) into the pane so the agent proves it can act.
|
|
478
|
+
// Framed like an event for TRUST (synthetic `probe-<nonce>` eventId, control-plane source) but
|
|
479
|
+
// NEVER acked — its synthetic eventId is display-only provenance, NOT pushed to _pendingAck; the
|
|
480
|
+
// agent answers via liveness_ack(nonce), not an ackEventIds echo. THIS IS THE FENCE: injectProbe is
|
|
481
|
+
// a separate path from the events→delivered→_pendingAck flow below, so a probe can never be acked
|
|
482
|
+
// as a delivered event (no double-count / no mis-ack of a real event).
|
|
483
|
+
if (livenessProbe) await injectProbe(livenessProbe);
|
|
484
|
+
if (events.length === 0) {
|
|
485
|
+
await sleep(IDLE_DELAY_MS);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
console.log(`[feeder] received ${events.length} event(s) → injecting into live session`);
|
|
489
|
+
const delivered = [];
|
|
490
|
+
for (const e of events) {
|
|
491
|
+
// Inject in order; ack ONLY the ones that reached a live pane (no-phantom).
|
|
492
|
+
// eslint-disable-next-line no-await-in-loop
|
|
493
|
+
if (await injectEvent(e)) delivered.push(e.eventId);
|
|
494
|
+
// eslint-disable-next-line no-await-in-loop
|
|
495
|
+
else break; // pane went away mid-batch → stop; unacked events re-deliver in order.
|
|
496
|
+
// eslint-disable-next-line no-await-in-loop
|
|
497
|
+
await sleep(KEY_SETTLE_MS);
|
|
498
|
+
}
|
|
499
|
+
_pendingAck = delivered;
|
|
500
|
+
if (delivered.length) console.log(`[feeder] delivered ${delivered.length}/${events.length} → will ack`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function loop() {
|
|
504
|
+
await mcpInitialize();
|
|
505
|
+
console.log(`[feeder] connected to ${AWP_MCP_ENDPOINT} — pushing into tmux session "${TMUX_SESSION}"`);
|
|
506
|
+
// #156: start the L2 pane-scan on its own cadence (independent of the long-poll). Best-effort.
|
|
507
|
+
if (REPORT_PANE_INTERVAL_MS > 0 && !_paneTimer) {
|
|
508
|
+
_paneTimer = setInterval(() => { void reportPane(); }, REPORT_PANE_INTERVAL_MS);
|
|
509
|
+
_paneTimer.unref?.();
|
|
510
|
+
}
|
|
511
|
+
while (!_stopping) {
|
|
512
|
+
try {
|
|
513
|
+
await pollOnce();
|
|
514
|
+
} catch (err) {
|
|
515
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
516
|
+
console.error(`[feeder] iteration error: ${msg} — re-establishing MCP session`);
|
|
517
|
+
_sessionId = null;
|
|
518
|
+
let reconnected = false;
|
|
519
|
+
for (let attempt = 1; attempt <= 5 && !_stopping && !reconnected; attempt++) {
|
|
520
|
+
try {
|
|
521
|
+
await mcpInitialize();
|
|
522
|
+
reconnected = true;
|
|
523
|
+
console.log(`[feeder] reconnected (attempt ${attempt}) — resuming poll`);
|
|
524
|
+
} catch (e) {
|
|
525
|
+
console.error(`[feeder] reconnect attempt ${attempt} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
526
|
+
await sleep(ERROR_DELAY_MS * attempt);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (!reconnected) await sleep(ERROR_DELAY_MS);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Run the feeder only when executed directly (not when imported for tests).
|
|
535
|
+
const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
|
|
536
|
+
if (isMain) {
|
|
537
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
538
|
+
process.on(sig, () => { console.log(`[feeder] ${sig} — stopping`); _stopping = true; if (_paneTimer) clearInterval(_paneTimer); setTimeout(() => process.exit(0), 500); });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!AWP_BEARER) {
|
|
542
|
+
console.error("[feeder] FATAL: no AWP bearer — set AWP_BEARER_FILE (0600 file, preferred) or AWP_BEARER; cannot poll next_event");
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
console.log("[feeder] starting — interactive-host push feeder; bearer held here, NOT in the agent pane.");
|
|
546
|
+
loop().catch((err) => { console.error(`[feeder] fatal: ${err?.message ?? err}`); process.exit(1); });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Exported for tests (#156). Importing this module does NOT start the feeder (see isMain guard).
|
|
550
|
+
export { mcpPost, mcpPostIsolated, reportPane, capturePane, parseMcpBody };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# A runner is live only when a matching agent process belongs to a pane in its named tmux session.
|
|
3
|
+
# A machine-global pgrep can borrow another agent (or a stale orphan) and paint this runner Live.
|
|
4
|
+
set -u
|
|
5
|
+
|
|
6
|
+
: "${TMUX_SESSION:?TMUX_SESSION required}"
|
|
7
|
+
: "${AGENT_PID_PATTERN:?AGENT_PID_PATTERN required}"
|
|
8
|
+
TMUX_BIN="${TMUX_BIN:-tmux}"
|
|
9
|
+
|
|
10
|
+
frontier="$("${TMUX_BIN}" list-panes -t "${TMUX_SESSION}" -F '#{pane_pid}' 2>/dev/null)" || exit 1
|
|
11
|
+
[ -n "${frontier}" ] || exit 1
|
|
12
|
+
|
|
13
|
+
depth=0
|
|
14
|
+
while [ -n "${frontier}" ] && [ "${depth}" -lt 8 ]; do
|
|
15
|
+
next=""
|
|
16
|
+
for pid in ${frontier}; do
|
|
17
|
+
if ps -p "${pid}" -o command= 2>/dev/null | grep -Eq -- "${AGENT_PID_PATTERN}"; then
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
children="$(pgrep -P "${pid}" 2>/dev/null || true)"
|
|
21
|
+
next="${next} ${children}"
|
|
22
|
+
done
|
|
23
|
+
frontier="${next}"
|
|
24
|
+
depth=$((depth + 1))
|
|
25
|
+
done
|
|
26
|
+
|
|
27
|
+
exit 1
|