agent-coord-mcp 0.17.1 → 0.19.0
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 +8 -1
- package/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/roles.js +92 -0
- package/dist/roles.js.map +1 -0
- package/dist/server.js +30 -14
- package/dist/server.js.map +1 -1
- package/dist/store.js +54 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +5 -3
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/messaging.js +187 -8
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/registry.js +54 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +84 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +18 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +393 -40
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +209 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/work.js +260 -0
- package/dist/work.js.map +1 -0
- package/hooks/marker.mjs +18 -0
- package/hooks/roles.mjs +79 -0
- package/hooks/submit.mjs +393 -0
- package/hooks/tier.mjs +104 -11
- package/hooks/tmux-pusher.mjs +137 -51
- package/package.json +5 -4
- package/scripts/check-self-dependency.mjs +54 -0
- package/scripts/check-test-count.mjs +83 -0
- package/scripts/coord-pusher.mjs +150 -33
- package/src/build.ts +111 -0
- package/src/roles.ts +111 -0
- package/src/server.ts +79 -14
- package/src/store.ts +60 -1
- package/src/tools/admin.ts +10 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/messaging.ts +206 -7
- package/src/tools/registry.ts +63 -4
- package/src/tools/render.ts +80 -0
- package/src/tools/scopes.ts +177 -0
- package/src/tools/shared.ts +120 -0
- package/src/tools/transport.ts +413 -40
- package/src/tools/work.ts +265 -0
- package/src/work.ts +329 -0
package/hooks/submit.mjs
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Paste + submit, shared by BOTH pushers (hooks/tmux-pusher.mjs and
|
|
2
|
+
// scripts/coord-pusher.mjs). One implementation on purpose: the two carried
|
|
3
|
+
// copies of this pipeline and they had already drifted — the local one sent two
|
|
4
|
+
// Enters with 100ms/50ms delays, the remote one sent a single Enter with no
|
|
5
|
+
// delay at all. Same class of defect as three copies of the retention
|
|
6
|
+
// predicate. Import it; do not re-implement it.
|
|
7
|
+
//
|
|
8
|
+
// tmux is injected (`run`, `runStdin`) so this module is pure logic and can be
|
|
9
|
+
// tested without a terminal. It never imports node:child_process itself.
|
|
10
|
+
|
|
11
|
+
function envInt(name, fallback) {
|
|
12
|
+
const n = Number(process.env[name]);
|
|
13
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// WHY THESE NUMBERS.
|
|
17
|
+
//
|
|
18
|
+
// The old defaults (100ms then 50ms) were picked for a multi-line PASTE
|
|
19
|
+
// settling, not for a slash command. A real agent TUI does more on "/" than
|
|
20
|
+
// accept text: it opens an autocomplete menu, which is rendered on a later
|
|
21
|
+
// frame, and the first Enter is consumed selecting from that menu rather than
|
|
22
|
+
// submitting. The command then sits in the input looking delivered — which is
|
|
23
|
+
// exactly what David hit.
|
|
24
|
+
//
|
|
25
|
+
// 400ms is a frame-budget argument, not a measurement of one machine: a TUI
|
|
26
|
+
// that re-renders at 60fps needs a handful of frames to open a menu, and
|
|
27
|
+
// terminal apps commonly debounce input by 100-250ms. 400ms clears that with
|
|
28
|
+
// margin while staying under the ~500ms a human reads as instant. 150ms
|
|
29
|
+
// between Enters is the same reasoning at smaller scale — enough for the menu
|
|
30
|
+
// to close and the input to settle before the submitting Enter.
|
|
31
|
+
//
|
|
32
|
+
// These are DEFAULTS, not guarantees. That is why verification exists below:
|
|
33
|
+
// no delay can be proven sufficient on a machine we have not seen, so the
|
|
34
|
+
// pipeline observes the result instead of trusting the clock.
|
|
35
|
+
export const ENTER_DELAY_MS = () => envInt("AGENT_COORD_ENTER_DELAY_MS", 400);
|
|
36
|
+
export const ENTER_GAP_MS = () => envInt("AGENT_COORD_ENTER_GAP_MS", 150);
|
|
37
|
+
// How long to keep checking that the command actually left the input, and how
|
|
38
|
+
// often. Bounded and finite — this must not spin, and it must not stall the
|
|
39
|
+
// delivery loop behind a pane that will never change.
|
|
40
|
+
export const VERIFY_TIMEOUT_MS = () => envInt("AGENT_COORD_SUBMIT_VERIFY_MS", 1500);
|
|
41
|
+
export const VERIFY_POLL_MS = () => envInt("AGENT_COORD_SUBMIT_POLL_MS", 100);
|
|
42
|
+
// Extra Enters to try when the first pair didn't submit. 0 disables retrying.
|
|
43
|
+
export const ENTER_RETRIES = () => envInt("AGENT_COORD_ENTER_RETRIES", 2);
|
|
44
|
+
// How long to wait for a BUSY pane to go idle before giving up on a control
|
|
45
|
+
// command. 15s covers a short tool call or a model turn's tail; beyond that the
|
|
46
|
+
// honest answer is "not now" rather than queueing a slash command behind a
|
|
47
|
+
// turn that may run for minutes. 0 disables waiting (paste immediately).
|
|
48
|
+
export const CONTROL_IDLE_WAIT_MS = () => envInt("AGENT_COORD_CONTROL_IDLE_WAIT_MS", 15000);
|
|
49
|
+
|
|
50
|
+
// Pane-state patterns. Defaults match the Claude Code TUI, which is what the
|
|
51
|
+
// bus drives; override for another TUI. Empty string disables that check.
|
|
52
|
+
//
|
|
53
|
+
// These exist because control commands fail in ways a delay cannot fix:
|
|
54
|
+
// BUSY — the command lands in the TUI's queue and runs only when the
|
|
55
|
+
// current turn ends (observed: minutes later), while the receipt
|
|
56
|
+
// already said "confirmed".
|
|
57
|
+
// DRAFT — unsent text in the input; the paste APPENDS to it, so
|
|
58
|
+
// "draft I was typing" + "/compact" submits as an ordinary MESSAGE
|
|
59
|
+
// to the model. The command never runs at all and a model turn is
|
|
60
|
+
// burned on nonsense. This is the worst observed outcome.
|
|
61
|
+
export const BUSY_PATTERN = () => envStr("AGENT_COORD_BUSY_PATTERN", "esc to interrupt");
|
|
62
|
+
export const PROMPT_PATTERN = () => envStr("AGENT_COORD_PROMPT_PATTERN", "^\\s*[❯>]\\s?(.*)$");
|
|
63
|
+
// Input-line content that means "empty" — placeholder hints, not real text.
|
|
64
|
+
export const PLACEHOLDER_PATTERN = () =>
|
|
65
|
+
envStr("AGENT_COORD_PLACEHOLDER_PATTERN", '^(Try ".*"|Press up to edit queued messages|)$');
|
|
66
|
+
|
|
67
|
+
// GHOST TEXT — the ONE place the rendering assumption lives.
|
|
68
|
+
//
|
|
69
|
+
// Claude Code renders a session-derived SUGGESTED next prompt ("ghost text")
|
|
70
|
+
// inside the input box while idle: `❯ ` + ESC[2m + suggestion + ESC[0m. It is
|
|
71
|
+
// chrome, not content: nobody typed it, the first keystroke replaces it, and
|
|
72
|
+
// it never reaches scrollback. To a plain `capture-pane` it is byte-for-byte
|
|
73
|
+
// indistinguishable from a real draft — which made this guard refuse control
|
|
74
|
+
// commands against EMPTY inputs fleet-wide (2026-07-29: three live refusals,
|
|
75
|
+
// every quoted "draft" was the suggestion; /clear and /compact were
|
|
76
|
+
// undeliverable to exactly the agents they were built for). The placeholder
|
|
77
|
+
// regex above cannot help: the suggestion is derived from session state, so
|
|
78
|
+
// its space of values is unbounded — no content pattern can enumerate it.
|
|
79
|
+
// Styling is the only channel that carries the distinction, hence
|
|
80
|
+
// `capture-pane -e` below.
|
|
81
|
+
//
|
|
82
|
+
// SGR 2 (dim/faint) is Claude Code's CURRENT rendering of the suggestion — a
|
|
83
|
+
// per-harness rendering detail, not a protocol guarantee. If a harness styles
|
|
84
|
+
// suggestions differently, or a release changes it, override or edit HERE and
|
|
85
|
+
// nowhere else.
|
|
86
|
+
export const GHOST_TEXT_SGR = () => envStr("AGENT_COORD_GHOST_TEXT_SGR", "2");
|
|
87
|
+
|
|
88
|
+
function envStr(name, fallback) {
|
|
89
|
+
const v = process.env[name];
|
|
90
|
+
return v === undefined ? fallback : v;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
export function sleep(ms) {
|
|
95
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Normalize for comparison: a TUI re-wraps and re-pads its input box, so raw
|
|
99
|
+
// substring matching on captured pane text is unreliable.
|
|
100
|
+
function squash(s) {
|
|
101
|
+
return String(s ?? "").replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Terminal escape sequences as they appear in `capture-pane -e` output:
|
|
105
|
+
// CSI (colors/attributes, cursor) and OSC (hyperlinks, titles). Anything not
|
|
106
|
+
// matched stays in the text — unrecognized bytes read as content, and content
|
|
107
|
+
// fails toward refusing, never toward delivering.
|
|
108
|
+
const CSI_RE = /\x1b\[[0-9;:?]*[A-Za-z]/g;
|
|
109
|
+
const OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
110
|
+
|
|
111
|
+
export function stripAnsi(s) {
|
|
112
|
+
return String(s ?? "").replace(OSC_RE, "").replace(CSI_RE, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Split ONE styled line into real (typed) and ghost (suggestion) text by
|
|
116
|
+
// walking its SGR state. Dim-attributed spans are ghost; everything else —
|
|
117
|
+
// including any styling we do not recognize — is real. See GHOST_TEXT_SGR for
|
|
118
|
+
// why dim, and for the asymmetry that makes "unrecognized = real" the safe
|
|
119
|
+
// default.
|
|
120
|
+
export function partitionStyledLine(styledLine) {
|
|
121
|
+
const ghostAttr = GHOST_TEXT_SGR();
|
|
122
|
+
const src = String(styledLine ?? "");
|
|
123
|
+
let real = "";
|
|
124
|
+
let ghost = "";
|
|
125
|
+
let dim = false;
|
|
126
|
+
let i = 0;
|
|
127
|
+
while (i < src.length) {
|
|
128
|
+
if (src[i] === "\x1b") {
|
|
129
|
+
const rest = src.slice(i);
|
|
130
|
+
const om = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.exec(rest);
|
|
131
|
+
if (om) {
|
|
132
|
+
i += om[0].length;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const cm = /^\x1b\[([0-9;:?]*)([A-Za-z])/.exec(rest);
|
|
136
|
+
if (cm) {
|
|
137
|
+
if (cm[2] === "m") {
|
|
138
|
+
// SGR: parameters separated by ; or :. Empty list means reset.
|
|
139
|
+
const params = cm[1] === "" ? ["0"] : cm[1].split(/[;:]/);
|
|
140
|
+
for (const p of params) {
|
|
141
|
+
if (p === "0" || p === "") dim = false; // full reset
|
|
142
|
+
else if (p === "22") dim = false; // "normal intensity" clears dim
|
|
143
|
+
else if (p === ghostAttr) dim = true;
|
|
144
|
+
// 38;5;N color runs share the list; a bare "5"/"2" inside a color
|
|
145
|
+
// spec could false-trigger — handle the extended-color form:
|
|
146
|
+
if (p === "38" || p === "48") break; // rest of list is a color spec
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
i += cm[0].length;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Lone ESC we do not understand: drop the ESC byte, keep going.
|
|
153
|
+
i += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (dim) ghost += src[i];
|
|
157
|
+
else real += src[i];
|
|
158
|
+
i += 1;
|
|
159
|
+
}
|
|
160
|
+
return { real, ghost };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Is `payload` still sitting in the pane's INPUT LINE?
|
|
164
|
+
//
|
|
165
|
+
// It asks the input line specifically, and nothing else. The first version
|
|
166
|
+
// squashed the last 20 lines of the whole pane and searched that window — but
|
|
167
|
+
// Claude Code ECHOES a submitted command into the transcript directly above
|
|
168
|
+
// the prompt box, inside that window. So a command that ran perfectly still
|
|
169
|
+
// matched, and the verifier reported failure on success (David hit this live:
|
|
170
|
+
// the pane read "Compacting conversation… 28%" while the receipt said the
|
|
171
|
+
// command "did not run").
|
|
172
|
+
//
|
|
173
|
+
// It only ever passed in testing because a freshly spawned disposable session
|
|
174
|
+
// has an empty scrollback — the one condition that hides a scrollback bug. The
|
|
175
|
+
// lesson is narrower than "test on a real TUI": a check that reads scrollback
|
|
176
|
+
// has to be tested against a pane that HAS scrollback.
|
|
177
|
+
//
|
|
178
|
+
// Returns true = still waiting in the input, false = gone (submitted),
|
|
179
|
+
// null = UNKNOWN. Unknown is never upgraded to a confirmation: no pane text,
|
|
180
|
+
// or no line matching AGENT_COORD_PROMPT_PATTERN, means we cannot tell.
|
|
181
|
+
export function stillInInput(paneText, payload) {
|
|
182
|
+
if (paneText === null || paneText === undefined) return null;
|
|
183
|
+
const needle = squash(payload);
|
|
184
|
+
if (!needle) return false;
|
|
185
|
+
const { draft } = readPaneState(paneText);
|
|
186
|
+
if (draft === null) return null; // no recognizable input line — cannot tell
|
|
187
|
+
return squash(draft).includes(needle);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Read the pane's input state: is the TUI busy, and is there unsent text?
|
|
191
|
+
//
|
|
192
|
+
// Accepts BOTH plain and styled (`capture-pane -e`) text — on styled input the
|
|
193
|
+
// ghost suggestion is partitioned out of `draft` (see GHOST_TEXT_SGR); plain
|
|
194
|
+
// text has no style layer, so everything on the input line reads as content,
|
|
195
|
+
// which is the conservative side. `ghost` and `styledInputLine` are null on
|
|
196
|
+
// plain input.
|
|
197
|
+
//
|
|
198
|
+
// `null` for busy/draft means UNKNOWN — the pane could not be captured, or
|
|
199
|
+
// this TUI does not look like the one we know how to read. Unknown is never
|
|
200
|
+
// treated as "fine": callers either proceed and fall back to verify-by-absence
|
|
201
|
+
// or report the uncertainty, but they never upgrade it to a confirmation.
|
|
202
|
+
export function readPaneState(paneText) {
|
|
203
|
+
if (paneText === null || paneText === undefined) {
|
|
204
|
+
return { busy: null, draft: null, ghost: null, styledInputLine: null };
|
|
205
|
+
}
|
|
206
|
+
const text = String(paneText);
|
|
207
|
+
const busyRe = BUSY_PATTERN();
|
|
208
|
+
// Busy markers live in the status chrome; match on the de-styled text.
|
|
209
|
+
const busy = busyRe ? new RegExp(busyRe).test(stripAnsi(text)) : null;
|
|
210
|
+
|
|
211
|
+
const promptRe = PROMPT_PATTERN();
|
|
212
|
+
let draft = null;
|
|
213
|
+
let ghost = null;
|
|
214
|
+
let styledInputLine = null;
|
|
215
|
+
if (promptRe) {
|
|
216
|
+
const re = new RegExp(promptRe);
|
|
217
|
+
const lines = text.split("\n");
|
|
218
|
+
// The LAST prompt line is the live input; earlier ones are history.
|
|
219
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
220
|
+
// Partition FIRST: the prompt char renders undimmed, so it stays in
|
|
221
|
+
// `real` and the prompt regex matches the de-styled real text. Ghost
|
|
222
|
+
// spans never reach the draft no matter what they contain.
|
|
223
|
+
const { real, ghost: ghostText } = partitionStyledLine(lines[i]);
|
|
224
|
+
const m = re.exec(real.replace(/\s+$/, ""));
|
|
225
|
+
if (!m) continue;
|
|
226
|
+
const content = (m[1] ?? "").trim();
|
|
227
|
+
const placeholder = PLACEHOLDER_PATTERN();
|
|
228
|
+
draft = placeholder && new RegExp(placeholder).test(content) ? "" : content;
|
|
229
|
+
ghost = squash(ghostText) || null;
|
|
230
|
+
styledInputLine = lines[i];
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { busy, draft, ghost, styledInputLine };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Submit a CONTROL command (/clear, /compact) — the path that must actually
|
|
238
|
+
// run, not merely arrive.
|
|
239
|
+
//
|
|
240
|
+
// Refuses rather than corrupts. Pasting into a busy pane queues the command
|
|
241
|
+
// behind a whole model turn; pasting onto a draft concatenates with it and
|
|
242
|
+
// sends the result to the model as chat text. Both were previously reported as
|
|
243
|
+
// delivery:"confirmed". Waiting briefly and then declining is slower and
|
|
244
|
+
// louder — deliberately, because a control command that silently becomes a
|
|
245
|
+
// chat message is worse than one that says it did not run.
|
|
246
|
+
// The ONE place a pane is captured for guard/verify decisions. `-e` is
|
|
247
|
+
// load-bearing: it keeps the style layer, the only channel that distinguishes
|
|
248
|
+
// a real draft from the TUI's ghost suggestion (see GHOST_TEXT_SGR). Without
|
|
249
|
+
// it every ghost reads as a draft again — and the SUITE CANNOT NOTICE,
|
|
250
|
+
// because the parser tests inject styled text below this call. That is the
|
|
251
|
+
// scriptMtime family of failure: the subject of a check silently disabling
|
|
252
|
+
// it. A source lock in control-submit.test.mjs pins the flag here and counts
|
|
253
|
+
// capture sites, so a new capture call added without `-e` trips it too.
|
|
254
|
+
function captureStyled(run, target) {
|
|
255
|
+
const cap = run(["capture-pane", "-e", "-p", "-t", target]);
|
|
256
|
+
return cap.status === 0 ? String(cap.stdout ?? "") : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function submitControl(deps, payload) {
|
|
260
|
+
const { run, target } = deps;
|
|
261
|
+
const capture = () => captureStyled(run, target);
|
|
262
|
+
|
|
263
|
+
const idleBudget = CONTROL_IDLE_WAIT_MS();
|
|
264
|
+
const deadline = Date.now() + idleBudget;
|
|
265
|
+
let state = readPaneState(capture());
|
|
266
|
+
while (state.busy === true && idleBudget > 0 && Date.now() < deadline) {
|
|
267
|
+
await sleep(VERIFY_POLL_MS() * 5);
|
|
268
|
+
state = readPaneState(capture());
|
|
269
|
+
}
|
|
270
|
+
if (state.busy === true) {
|
|
271
|
+
return {
|
|
272
|
+
submitted: false,
|
|
273
|
+
verified: true,
|
|
274
|
+
pasted: false,
|
|
275
|
+
attempts: 0,
|
|
276
|
+
reason:
|
|
277
|
+
`pane '${target}' is still busy after ${idleBudget}ms — not pasted. A control command sent now would ` +
|
|
278
|
+
`queue behind the running turn instead of executing (raise AGENT_COORD_CONTROL_IDLE_WAIT_MS to wait longer).`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (state.draft) {
|
|
282
|
+
// ASYMMETRY — do not "fix" this branch toward delivering. A false REFUSAL
|
|
283
|
+
// costs one control command: retryable, visible, and self-diagnosing via
|
|
284
|
+
// the styled quote below. A false DELIVERY types keystrokes into someone's
|
|
285
|
+
// real unsent text and submits the concatenation to their model — the
|
|
286
|
+
// exact harm this guard exists to prevent. A harness whose ghost text is
|
|
287
|
+
// NOT dim-styled therefore lands here and refuses; that is the intended
|
|
288
|
+
// conservative direction, and the remedy is teaching GHOST_TEXT_SGR its
|
|
289
|
+
// rendering, never loosening this check.
|
|
290
|
+
return {
|
|
291
|
+
submitted: false,
|
|
292
|
+
verified: true,
|
|
293
|
+
pasted: false,
|
|
294
|
+
attempts: 0,
|
|
295
|
+
reason:
|
|
296
|
+
`pane '${target}' has unsent text in its input (${JSON.stringify(state.draft.slice(0, 40))}…) — not pasted. ` +
|
|
297
|
+
`The command would have been appended to that draft and sent to the model as an ordinary message. ` +
|
|
298
|
+
`Rule: non-dim input-line content is a draft; dim (SGR ${GHOST_TEXT_SGR()}) spans are the TUI's ghost ` +
|
|
299
|
+
`suggestion and are ignored (AGENT_COORD_GHOST_TEXT_SGR overrides). ` +
|
|
300
|
+
`Styled input line: ${JSON.stringify(String(state.styledInputLine ?? "").slice(0, 160))}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return pasteAndSubmit(deps, payload, { bracketed: false, verify: true });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Paste a payload into the target pane and submit it.
|
|
307
|
+
//
|
|
308
|
+
// deps:
|
|
309
|
+
// runStdin(args, payload) -> Promise<void> // tmux load-buffer -
|
|
310
|
+
// run(args) -> {status, stdout, stderr} // spawnSync-shaped
|
|
311
|
+
// target, buffer // tmux -t target, buffer name
|
|
312
|
+
//
|
|
313
|
+
// Returns {submitted, verified, reason?, attempts}. For bracketed peer content
|
|
314
|
+
// verification is skipped entirely (`verified:false`, `submitted:true`) — that
|
|
315
|
+
// path pastes inert data on the hot path and has never been the problem; only
|
|
316
|
+
// control commands are verified.
|
|
317
|
+
export async function pasteAndSubmit(deps, payload, { bracketed = false, verify = false } = {}) {
|
|
318
|
+
const { run, runStdin, target, buffer } = deps;
|
|
319
|
+
|
|
320
|
+
await runStdin(["load-buffer", "-b", buffer, "-"], payload);
|
|
321
|
+
const paste = run(["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", buffer, "-t", target, "-d"]);
|
|
322
|
+
if (paste.status !== 0) {
|
|
323
|
+
throw new Error(`tmux paste-buffer: ${String(paste.stderr ?? "").trim()}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await sleep(ENTER_DELAY_MS());
|
|
327
|
+
const e1 = run(["send-keys", "-t", target, "Enter"]);
|
|
328
|
+
if (e1.status !== 0) throw new Error(`tmux send-keys: ${String(e1.stderr ?? "").trim()}`);
|
|
329
|
+
await sleep(ENTER_GAP_MS());
|
|
330
|
+
run(["send-keys", "-t", target, "Enter"]);
|
|
331
|
+
|
|
332
|
+
if (!verify) return { submitted: true, verified: false, attempts: 1 };
|
|
333
|
+
|
|
334
|
+
const retries = ENTER_RETRIES();
|
|
335
|
+
for (let attempt = 1; ; attempt++) {
|
|
336
|
+
const outcome = await pollUntilGone(deps, payload);
|
|
337
|
+
if (outcome === false) return { submitted: true, verified: true, attempts: attempt };
|
|
338
|
+
if (outcome === null) {
|
|
339
|
+
return {
|
|
340
|
+
submitted: false,
|
|
341
|
+
verified: false,
|
|
342
|
+
attempts: attempt,
|
|
343
|
+
reason:
|
|
344
|
+
`could not read the input line of pane '${target}' to verify submission — the command was typed but may not have run ` +
|
|
345
|
+
`(pane capture failed, or no line matched AGENT_COORD_PROMPT_PATTERN for this TUI)`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (attempt > retries) {
|
|
349
|
+
return {
|
|
350
|
+
submitted: false,
|
|
351
|
+
verified: true, // we DID observe the pane; what we observed is failure
|
|
352
|
+
attempts: attempt,
|
|
353
|
+
reason:
|
|
354
|
+
`the command is still in the input of pane '${target}' after ${attempt} submit attempt(s) — ` +
|
|
355
|
+
`it was typed but did not run (raise AGENT_COORD_ENTER_DELAY_MS if this recurs)`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// Still sitting there: one more Enter before giving up.
|
|
359
|
+
//
|
|
360
|
+
// Every retry re-verifies FIRST (the loop head above), so an Enter is only
|
|
361
|
+
// ever sent after we have just looked at the input line and seen the
|
|
362
|
+
// command still in it. That ordering is the whole safety argument: these
|
|
363
|
+
// are keystrokes into a live session someone else may be typing in, and an
|
|
364
|
+
// Enter sent on a stale reading would submit whatever they had drafted
|
|
365
|
+
// since. Under the old tail-window check this fired on EVERY successful
|
|
366
|
+
// submit — three Enters into a pane that had already run the command.
|
|
367
|
+
await sleep(ENTER_GAP_MS());
|
|
368
|
+
run(["send-keys", "-t", target, "Enter"]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Poll capture-pane until the payload leaves the input line, the budget runs
|
|
373
|
+
// out, or we run out of ways to tell. true = still there, false = gone,
|
|
374
|
+
// null = unknown (capture failed, or no input line we can read).
|
|
375
|
+
async function pollUntilGone(deps, payload) {
|
|
376
|
+
const { run, target } = deps;
|
|
377
|
+
const deadline = Date.now() + VERIFY_TIMEOUT_MS();
|
|
378
|
+
let readInput = false;
|
|
379
|
+
for (;;) {
|
|
380
|
+
// Styled capture here too: a ghost suggestion CONTAINING the payload
|
|
381
|
+
// (e.g. the TUI suggesting "/compact" right after it ran) would otherwise
|
|
382
|
+
// read as "still in the input" — a false non-submit whose retry path
|
|
383
|
+
// sends extra Enters into a live pane on the strength of chrome.
|
|
384
|
+
const pane = captureStyled(run, target);
|
|
385
|
+
if (pane !== null) {
|
|
386
|
+
const verdict = stillInInput(pane, payload);
|
|
387
|
+
if (verdict === false) return false;
|
|
388
|
+
if (verdict === true) readInput = true; // we could read the input line
|
|
389
|
+
}
|
|
390
|
+
if (Date.now() >= deadline) return readInput ? true : null;
|
|
391
|
+
await sleep(VERIFY_POLL_MS());
|
|
392
|
+
}
|
|
393
|
+
}
|
package/hooks/tier.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Pure delivery-tier logic for the pusher.
|
|
2
|
-
//
|
|
3
|
-
// delivery hot path.
|
|
1
|
+
// Pure delivery-tier logic for the pusher. Side-effect-free, and its only
|
|
2
|
+
// import (./roles.mjs) is equally pure — so it can be unit-tested directly and
|
|
3
|
+
// adds no I/O to the delivery hot path.
|
|
4
4
|
//
|
|
5
5
|
// "urgent" = push now (wakes the target agent's model).
|
|
6
6
|
// "routine" = queue silently; it rides along as a coalesced digest on the
|
|
@@ -9,6 +9,41 @@
|
|
|
9
9
|
// All prefix checks are case-sensitive: the protocol prefixes are uppercase
|
|
10
10
|
// by convention, and a lowercase lookalike is chatter, not a work order.
|
|
11
11
|
|
|
12
|
+
import { isGateRunner } from "./roles.mjs";
|
|
13
|
+
|
|
14
|
+
// Typed protocol record → tier (Phase 8). The prefix table below is the same
|
|
15
|
+
// vocabulary parsed out of text; reading the field instead removes the parse
|
|
16
|
+
// entirely, so a body that leads with a greeting no longer downgrades a
|
|
17
|
+
// blocker to routine.
|
|
18
|
+
//
|
|
19
|
+
// TRUST: `record` is caller-supplied, exactly like `from`. A typed record may
|
|
20
|
+
// therefore assert what a text prefix could already assert, and NOTHING more —
|
|
21
|
+
// `scope` resolves the sender against trustedSenders and `done` depends on the
|
|
22
|
+
// RECIPIENT being a gate runner, exactly as the prefix path does. `go` is
|
|
23
|
+
// unconditionally urgent here because literal "GO:" is unconditionally urgent
|
|
24
|
+
// in the prefix path too; do NOT "fix" that asymmetry by gating go, and do not
|
|
25
|
+
// ungate scope to match go — each mirrors its v1 prefix, which is the whole
|
|
26
|
+
// invariant. Typed is not authenticated: anything that would let a peer
|
|
27
|
+
// self-declare urgency belongs in the server-set `urgent` flag, not here.
|
|
28
|
+
function tierFromRecord(rec, m, opts) {
|
|
29
|
+
switch (rec.type) {
|
|
30
|
+
case "blocker":
|
|
31
|
+
case "decision":
|
|
32
|
+
return "urgent";
|
|
33
|
+
case "go":
|
|
34
|
+
return "urgent";
|
|
35
|
+
case "scope":
|
|
36
|
+
return opts.trustedSenders?.has?.(m.from) ? "urgent" : "routine";
|
|
37
|
+
case "done":
|
|
38
|
+
return opts.gateRunner ? "urgent" : "routine";
|
|
39
|
+
// risk / fyi / action / verdict: real semantics, but not a reason to
|
|
40
|
+
// interrupt someone else's turn. A risk that genuinely needs an immediate
|
|
41
|
+
// turn is a blocker — that judgement stays with the sender.
|
|
42
|
+
default:
|
|
43
|
+
return "routine";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
export function classifyTier(m, opts = {}) {
|
|
13
48
|
if (!m) return "routine";
|
|
14
49
|
// Server-set push-now override (post-/clear reminder). send_message builds
|
|
@@ -18,7 +53,39 @@ export function classifyTier(m, opts = {}) {
|
|
|
18
53
|
// wants it specifically, and DM volume is tiny next to room traffic. The
|
|
19
54
|
// tiers exist to absorb broadcast noise, not point-to-point asks (the
|
|
20
55
|
// liaison relaying a David question must not sit in a digest queue).
|
|
21
|
-
|
|
56
|
+
//
|
|
57
|
+
// `tag`, not `kind`: this is the pushers' synthetic channel tag ("DM" /
|
|
58
|
+
// "room #general"), set on their own copy of the message. It used to be
|
|
59
|
+
// called `kind`, which collided with the stored Message.kind (retention
|
|
60
|
+
// weight, "decision"/"status"/"chatter") — a room post tagged
|
|
61
|
+
// kind:"decision" overwrote the channel tag and rendered as `[decision …]`.
|
|
62
|
+
// The tag is process-local and never persisted, so it was the safe half of
|
|
63
|
+
// the collision to rename; Message.kind is on disk in every JSONL file.
|
|
64
|
+
// NOTE: this returns before the record is read, so a DM can never be
|
|
65
|
+
// downgraded by one. The floor rule below makes that moot, but the ordering
|
|
66
|
+
// is load-bearing if anyone reintroduces a record-first branch.
|
|
67
|
+
if (m.tag === "DM") return "urgent";
|
|
68
|
+
// The record is a FLOOR, not an override: it can raise the tier, never lower
|
|
69
|
+
// it. An earlier version let the record win outright, on the argument that
|
|
70
|
+
// Task 3 renders text from the record so the two agree by construction. That
|
|
71
|
+
// is false when BOTH are supplied — contract 3.3 makes the author's `text`
|
|
72
|
+
// win for rendering, so `{text:"BLOCKER: prod down", record:{type:"fyi"}}`
|
|
73
|
+
// displayed a BLOCKER in the pane and delivered it routine, with the reader
|
|
74
|
+
// unable to see why (`record` is never rendered). That is a REGRESSION IN A
|
|
75
|
+
// SAFETY PROPERTY: in v1, "BLOCKER:" at byte 0 always woke the pane. The
|
|
76
|
+
// trigger is ordinary relay, not malice — an aide forwarding a worker's body
|
|
77
|
+
// under its own `fyi` would silently bury that worker's blocker.
|
|
78
|
+
//
|
|
79
|
+
// max() is not the two-overridable-sources design this phase exists to
|
|
80
|
+
// delete: it is monotone, so there is nothing to disagree about, only a
|
|
81
|
+
// higher claim winning. It also fails safe on an UNKNOWN future record type,
|
|
82
|
+
// which hits the routine default — an old pusher meeting a new vocabulary
|
|
83
|
+
// must not bury a "BLOCKER:" body. The one legitimate downgrade, quoting a
|
|
84
|
+
// blocker mid-body, is already handled by the byte-0 rule.
|
|
85
|
+
// Found by ai-workflow-worker-1 gating 721882a.
|
|
86
|
+
if (m.record && typeof m.record.type === "string") {
|
|
87
|
+
if (tierFromRecord(m.record, m, opts) === "urgent") return "urgent";
|
|
88
|
+
}
|
|
22
89
|
if (typeof m.text !== "string") return "routine";
|
|
23
90
|
const text = m.text.trimStart();
|
|
24
91
|
// Control/slash commands are injected raw and must fire immediately.
|
|
@@ -46,10 +113,16 @@ export function effectiveTier(m, opts = {}) {
|
|
|
46
113
|
}
|
|
47
114
|
|
|
48
115
|
// A gate runner is the agent that consumes DONE: reports (QA / coordinator).
|
|
49
|
-
// Resolved from the registry role
|
|
50
|
-
//
|
|
116
|
+
// Resolved from the registry role's frozen `roleId` (Phase 8 Task 4) rather
|
|
117
|
+
// than by regex-matching display prose, so renaming the role does not change
|
|
118
|
+
// who runs the gate. Registry entries with no declared roleId fall back to the
|
|
119
|
+
// legacy word match — see roleMatches in roles.mjs. Env override in both
|
|
120
|
+
// directions (AGENT_COORD_GATE_RUNNER=1|0) is applied by the caller.
|
|
121
|
+
//
|
|
122
|
+
// Accepts a plain string or a whole registry entry ({role, roleId}); pass the
|
|
123
|
+
// entry when you have it, or the id is lost.
|
|
51
124
|
export function isGateRunnerRole(role) {
|
|
52
|
-
return
|
|
125
|
+
return isGateRunner(role);
|
|
53
126
|
}
|
|
54
127
|
|
|
55
128
|
// In-memory routine queue with the push decision. Ingest classified messages;
|
|
@@ -96,15 +169,35 @@ export class TierQueue {
|
|
|
96
169
|
// The per-message PARSE CONTRACT line. Agent harnesses read from/room/text
|
|
97
170
|
// back out of this, so its shape is load-bearing and MUST stay byte-identical
|
|
98
171
|
// to coord-pusher.mjs's `injectLine`. Compact form (v0.14.0, salvaged from
|
|
99
|
-
// v0.8.10): ` [<
|
|
172
|
+
// v0.8.10): ` [<tag> <HH:MM> <from>] <text>` where tag drops the leading
|
|
100
173
|
// "room " ("room #general" → "#general"), the timestamp is HH:MM UTC, and the
|
|
101
|
-
// "from=" label is dropped (bare id).
|
|
174
|
+
// "from=" label is dropped (bare id). tag/time/from never contain spaces
|
|
102
175
|
// (ids are sanitized), so a parser splits on the first "] " unambiguously.
|
|
103
176
|
export function injectLine(m) {
|
|
104
|
-
const tag = String(m.
|
|
177
|
+
const tag = String(m.tag ?? "").replace(/^room /, "");
|
|
105
178
|
const d = new Date(m.ts ?? 0);
|
|
106
179
|
const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
|
107
|
-
|
|
180
|
+
let text = m.text ?? "";
|
|
181
|
+
// Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
|
|
182
|
+
// ONE attributed line — first line, a count of what was withheld, and the
|
|
183
|
+
// message id as the retrieval handle. Continuation lines used to arrive bare,
|
|
184
|
+
// with no `[tag HH:MM from]` header, so a parser could not attribute them.
|
|
185
|
+
//
|
|
186
|
+
// The handle is the message id, NOT a stashed copy: the full record is
|
|
187
|
+
// already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
|
|
188
|
+
// retrieve_message reads it back by id (falling through to the append-only
|
|
189
|
+
// archive if compaction moved it). A cache would have had a TTL and lost the
|
|
190
|
+
// record permanently on expiry.
|
|
191
|
+
//
|
|
192
|
+
// Gated on `m.record`: a record-LESS multi-line message is untouched and
|
|
193
|
+
// still arrives unattributed past line 1, exactly as today. Task 6 does not
|
|
194
|
+
// fix hand-typed multi-line messages, and must not change their bytes.
|
|
195
|
+
const nl = text.indexOf("\n");
|
|
196
|
+
if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
|
|
197
|
+
const held = text.split("\n").length - 1;
|
|
198
|
+
text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
|
|
199
|
+
}
|
|
200
|
+
return ` [${tag} ${hhmm} ${m.from}] ${text}`;
|
|
108
201
|
}
|
|
109
202
|
|
|
110
203
|
// Render one delivery: urgent verbatim under the banner, then at most ONE
|