agent-coord-mcp 0.17.0 → 0.18.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 +34 -5
- 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 +367 -33
- 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 +271 -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 +42 -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 +386 -34
- package/src/tools/work.ts +265 -0
- package/src/work.ts +329 -0
package/dist/work.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Work state as data (Phase 8 Task 5): queue items, done entries and board
|
|
2
|
+
// rows as typed records, with markdown as a first-class EXPORT rather than the
|
|
3
|
+
// datastore.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS SHAPE. `docs/QUEUE.md` / `docs/DONE.md` were parsed under a contract
|
|
6
|
+
// requiring exact `—` (U+2014) and ` · ` (U+00B7). A parser once returned ZERO
|
|
7
|
+
// items on the real file while passing every synthetic test, and the consuming
|
|
8
|
+
// UI rendered an empty panel — no error, no signal. The files themselves are
|
|
9
|
+
// good: David edits them, git diffs them, they grep. So markdown stays the
|
|
10
|
+
// INTERFACE and stops being the DATASTORE.
|
|
11
|
+
//
|
|
12
|
+
// MARKDOWN REMAINS AUTHORITATIVE. Delete the store and the documents still
|
|
13
|
+
// stand alone: every field here is recoverable from the file it came from, the
|
|
14
|
+
// exporter is byte-exact, and nothing downstream is required to read records.
|
|
15
|
+
// The store is a derived index until a consumer chooses otherwise.
|
|
16
|
+
//
|
|
17
|
+
// The round-trip guarantee is structural, not incidental: everything this
|
|
18
|
+
// module does NOT model — prose, the write-rule paragraphs, the Cross-project
|
|
19
|
+
// section, fenced examples — is captured verbatim as a `text` block and
|
|
20
|
+
// replayed in place. Only lines it genuinely understands are re-rendered, so
|
|
21
|
+
// an unmodelled construct can never be silently dropped.
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
// ---------- the glyph contract ----------
|
|
24
|
+
//
|
|
25
|
+
// Pinned in docs/DONE.md and consumed by external parsers: the ref splits on
|
|
26
|
+
// the LAST ` — ` (space, U+2014, space) and the date is a trailing ` · `
|
|
27
|
+
// (space, U+00B7, space) + ISO date. Written as explicit escapes so a source
|
|
28
|
+
// file normalization or a copy-paste through an ASCII-mangling tool can never
|
|
29
|
+
// change them silently — the constants are the contract.
|
|
30
|
+
export const EM_DASH_SEP = " — ";
|
|
31
|
+
export const MIDDOT_SEP = " · ";
|
|
32
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
33
|
+
// Deterministic ids: same content in → same id out, so import→export→import is
|
|
34
|
+
// stable and no id has to be written into the markdown (which would change the
|
|
35
|
+
// bytes the round trip is measured on).
|
|
36
|
+
function idFor(kind, seed) {
|
|
37
|
+
return `${kind}-${createHash("sha1").update(seed).digest("hex").slice(0, 8)}`;
|
|
38
|
+
}
|
|
39
|
+
// ---------- line-level recognizers ----------
|
|
40
|
+
const QUEUE_LINE = /^- \[( |x)\] \((P1|P2|P3)\) (.*)$/;
|
|
41
|
+
const DONE_LINE = /^- \[( |x)\] (.*)$/;
|
|
42
|
+
export function parseQueueLine(line, section) {
|
|
43
|
+
const m = QUEUE_LINE.exec(line);
|
|
44
|
+
if (!m)
|
|
45
|
+
return null;
|
|
46
|
+
const [, mark, priority, text] = m;
|
|
47
|
+
return {
|
|
48
|
+
id: idFor("q", text ?? ""),
|
|
49
|
+
priority: priority,
|
|
50
|
+
text: text ?? "",
|
|
51
|
+
done: mark === "x",
|
|
52
|
+
...(section ? { section } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Split on the LAST ` — `, then peel a trailing ` · YYYY-MM-DD`. Both parts are
|
|
56
|
+
// optional: an entry with neither is still a done entry, it just carries no
|
|
57
|
+
// verifiable ref — which is a fact about the entry, not a parse failure. The
|
|
58
|
+
// old parser treated the same line as nothing at all.
|
|
59
|
+
export function parseDoneLine(line, section) {
|
|
60
|
+
const m = DONE_LINE.exec(line);
|
|
61
|
+
if (!m)
|
|
62
|
+
return null;
|
|
63
|
+
let body = m[2] ?? "";
|
|
64
|
+
let ref;
|
|
65
|
+
let date;
|
|
66
|
+
const cut = body.lastIndexOf(EM_DASH_SEP);
|
|
67
|
+
if (cut !== -1) {
|
|
68
|
+
const tail = body.slice(cut + EM_DASH_SEP.length);
|
|
69
|
+
const dot = tail.lastIndexOf(MIDDOT_SEP);
|
|
70
|
+
if (dot !== -1) {
|
|
71
|
+
const maybeDate = tail.slice(dot + MIDDOT_SEP.length);
|
|
72
|
+
if (ISO_DATE.test(maybeDate)) {
|
|
73
|
+
date = maybeDate;
|
|
74
|
+
ref = tail.slice(0, dot);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// A tail with no date is still a ref, as long as it isn't prose with
|
|
78
|
+
// spaces — refs are `owner/repo#N` or `owner/repo@sha`.
|
|
79
|
+
if (date === undefined && /^\S+$/.test(tail))
|
|
80
|
+
ref = tail;
|
|
81
|
+
if (ref !== undefined)
|
|
82
|
+
body = body.slice(0, cut);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
id: idFor("d", `${body}|${ref ?? ""}|${date ?? ""}`),
|
|
86
|
+
text: body,
|
|
87
|
+
...(ref !== undefined ? { ref } : {}),
|
|
88
|
+
...(date !== undefined ? { date } : {}),
|
|
89
|
+
...(section ? { section } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function renderQueueLine(item) {
|
|
93
|
+
return `- [${item.done ? "x" : " "}] (${item.priority}) ${item.text}`;
|
|
94
|
+
}
|
|
95
|
+
export function renderDoneLine(entry) {
|
|
96
|
+
let line = `- [x] ${entry.text}`;
|
|
97
|
+
if (entry.ref !== undefined)
|
|
98
|
+
line += `${EM_DASH_SEP}${entry.ref}`;
|
|
99
|
+
if (entry.date !== undefined)
|
|
100
|
+
line += `${MIDDOT_SEP}${entry.date}`;
|
|
101
|
+
return line;
|
|
102
|
+
}
|
|
103
|
+
// ---------- board table ----------
|
|
104
|
+
const TABLE_ROW = /^\|(.*)\|\s*$/;
|
|
105
|
+
const TABLE_ALIGN = /^\|[\s:|-]+\|\s*$/;
|
|
106
|
+
function splitRow(line) {
|
|
107
|
+
const m = TABLE_ROW.exec(line);
|
|
108
|
+
if (!m)
|
|
109
|
+
return [];
|
|
110
|
+
return (m[1] ?? "").split("|").map((c) => c.trim());
|
|
111
|
+
}
|
|
112
|
+
function renderRow(cells) {
|
|
113
|
+
return `| ${cells.join(" | ")} |`;
|
|
114
|
+
}
|
|
115
|
+
function boardCells(r) {
|
|
116
|
+
return [r.lane, r.owner, r.state, r.currentSlice, r.nextGo];
|
|
117
|
+
}
|
|
118
|
+
// Replay the original line when the fields still say what it said; re-render
|
|
119
|
+
// once anything actually changed.
|
|
120
|
+
export function renderBoardRow(r) {
|
|
121
|
+
if (r.raw !== undefined) {
|
|
122
|
+
const cells = splitRow(r.raw);
|
|
123
|
+
if (cells.length === 5 && cells.every((c, i) => c === boardCells(r)[i]))
|
|
124
|
+
return r.raw;
|
|
125
|
+
}
|
|
126
|
+
return renderRow(boardCells(r));
|
|
127
|
+
}
|
|
128
|
+
// Which record kind a `## Heading` opens. `## Queue` → queue items,
|
|
129
|
+
// `## Done` → done entries; anything else closes the record region, so the
|
|
130
|
+
// Cross-project section's bullets stay verbatim prose.
|
|
131
|
+
function sectionKindOf(heading) {
|
|
132
|
+
const h = heading.replace(/^#+\s*/, "").trim().toLowerCase();
|
|
133
|
+
if (h === "queue")
|
|
134
|
+
return "queue";
|
|
135
|
+
if (h === "done")
|
|
136
|
+
return "done";
|
|
137
|
+
return "none";
|
|
138
|
+
}
|
|
139
|
+
export function parseWorkDoc(source) {
|
|
140
|
+
const trailingNewline = source.endsWith("\n");
|
|
141
|
+
const lines = source.split("\n");
|
|
142
|
+
if (trailingNewline)
|
|
143
|
+
lines.pop(); // the split's empty tail, restored on render
|
|
144
|
+
const blocks = [];
|
|
145
|
+
let text = [];
|
|
146
|
+
let kind = "none";
|
|
147
|
+
let subsection;
|
|
148
|
+
let inFence = false;
|
|
149
|
+
const flushText = () => {
|
|
150
|
+
if (text.length)
|
|
151
|
+
blocks.push({ kind: "text", lines: text });
|
|
152
|
+
text = [];
|
|
153
|
+
};
|
|
154
|
+
// Consecutive item lines coalesce into one block; anything in between (a
|
|
155
|
+
// blank line, a subsection heading) flushes as text first and therefore
|
|
156
|
+
// starts a new one, which is what keeps rendering order faithful.
|
|
157
|
+
const pushItem = (item) => {
|
|
158
|
+
flushText();
|
|
159
|
+
const last = blocks[blocks.length - 1];
|
|
160
|
+
if (last?.kind === "queue")
|
|
161
|
+
last.items.push(item);
|
|
162
|
+
else
|
|
163
|
+
blocks.push({ kind: "queue", items: [item] });
|
|
164
|
+
};
|
|
165
|
+
const pushEntry = (entry) => {
|
|
166
|
+
flushText();
|
|
167
|
+
const last = blocks[blocks.length - 1];
|
|
168
|
+
if (last?.kind === "done")
|
|
169
|
+
last.entries.push(entry);
|
|
170
|
+
else
|
|
171
|
+
blocks.push({ kind: "done", entries: [entry] });
|
|
172
|
+
};
|
|
173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
174
|
+
const line = lines[i] ?? "";
|
|
175
|
+
// Fenced blocks are never interpreted — docs/DONE.md pins the done-line
|
|
176
|
+
// FORMAT inside a fence, and parsing that example as an entry would
|
|
177
|
+
// invent a record out of documentation.
|
|
178
|
+
if (/^\s*```/.test(line)) {
|
|
179
|
+
inFence = !inFence;
|
|
180
|
+
text.push(line);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (inFence) {
|
|
184
|
+
text.push(line);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
188
|
+
const level = (line.match(/^#+/) ?? [""])[0].length;
|
|
189
|
+
if (level <= 2) {
|
|
190
|
+
kind = sectionKindOf(line);
|
|
191
|
+
subsection = undefined;
|
|
192
|
+
}
|
|
193
|
+
else if (kind !== "none") {
|
|
194
|
+
subsection = line.replace(/^#+\s*/, "").trim();
|
|
195
|
+
}
|
|
196
|
+
text.push(line);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (kind === "queue") {
|
|
200
|
+
const item = parseQueueLine(line, subsection);
|
|
201
|
+
if (item) {
|
|
202
|
+
pushItem(item);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (kind === "done") {
|
|
207
|
+
const entry = parseDoneLine(line, subsection);
|
|
208
|
+
if (entry) {
|
|
209
|
+
pushEntry(entry);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Lanes table: a header row followed by an alignment row.
|
|
214
|
+
if (TABLE_ROW.test(line) && TABLE_ALIGN.test(lines[i + 1] ?? "")) {
|
|
215
|
+
const rows = [];
|
|
216
|
+
let j = i + 2;
|
|
217
|
+
for (; j < lines.length && TABLE_ROW.test(lines[j] ?? ""); j++) {
|
|
218
|
+
const raw = lines[j] ?? "";
|
|
219
|
+
const [lane = "", owner = "", state = "", currentSlice = "", nextGo = ""] = splitRow(raw);
|
|
220
|
+
rows.push({ id: idFor("b", lane), lane, owner, state, currentSlice, nextGo, raw });
|
|
221
|
+
}
|
|
222
|
+
flushText();
|
|
223
|
+
blocks.push({ kind: "board", header: line, align: lines[i + 1] ?? "", rows });
|
|
224
|
+
i = j - 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
text.push(line);
|
|
228
|
+
}
|
|
229
|
+
flushText();
|
|
230
|
+
return { trailingNewline, blocks };
|
|
231
|
+
}
|
|
232
|
+
export function renderWorkDoc(doc) {
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const block of doc.blocks) {
|
|
235
|
+
if (block.kind === "text")
|
|
236
|
+
out.push(...block.lines);
|
|
237
|
+
else if (block.kind === "queue")
|
|
238
|
+
out.push(...block.items.map(renderQueueLine));
|
|
239
|
+
else if (block.kind === "done")
|
|
240
|
+
out.push(...block.entries.map(renderDoneLine));
|
|
241
|
+
else {
|
|
242
|
+
out.push(block.header);
|
|
243
|
+
out.push(block.align);
|
|
244
|
+
for (const r of block.rows)
|
|
245
|
+
out.push(renderBoardRow(r));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out.join("\n") + (doc.trailingNewline ? "\n" : "");
|
|
249
|
+
}
|
|
250
|
+
// ---------- record extraction ----------
|
|
251
|
+
export function queueItemsOf(doc) {
|
|
252
|
+
return doc.blocks.flatMap((b) => (b.kind === "queue" ? b.items : []));
|
|
253
|
+
}
|
|
254
|
+
export function doneEntriesOf(doc) {
|
|
255
|
+
return doc.blocks.flatMap((b) => (b.kind === "done" ? b.entries : []));
|
|
256
|
+
}
|
|
257
|
+
export function boardRowsOf(doc) {
|
|
258
|
+
return doc.blocks.flatMap((b) => (b.kind === "board" ? b.rows : []));
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=work.js.map
|
package/dist/work.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"work.js","sourceRoot":"","sources":["../src/work.ts"],"names":[],"mappings":"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"}
|
package/hooks/marker.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The transport-marker rewrite contract, extracted so it is testable.
|
|
2
|
+
//
|
|
3
|
+
// The marker has TWO writers with different authority: attach_agent CREATES
|
|
4
|
+
// it (authoritative — a fresh attach owns every field, including provenance
|
|
5
|
+
// stamps like serverBuildMtime that only the server can know), and the pusher
|
|
6
|
+
// REWRITES it at startup (partial — it owns its pid, target, and the
|
|
7
|
+
// scriptMtime of the code it actually loaded, and must not touch the rest).
|
|
8
|
+
//
|
|
9
|
+
// A rewrite that rebuilds the marker from scratch drops every field the other
|
|
10
|
+
// writer owns. That is not hypothetical: an earlier from-scratch rewrite
|
|
11
|
+
// dropped scriptMtime and silently disabled doctor's stale-pusher-script
|
|
12
|
+
// check for every local pusher — a marker field a rewrite can drop is a
|
|
13
|
+
// health check that can be switched off by accident.
|
|
14
|
+
export function mergeTransportMarker(existing, own) {
|
|
15
|
+
// Spread order IS the contract: fields this writer does not own survive;
|
|
16
|
+
// fields it does own win.
|
|
17
|
+
return { ...(existing && typeof existing === "object" ? existing : {}), ...own };
|
|
18
|
+
}
|
package/hooks/roles.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Canonical role identity (Phase 8 Task 4). Dependency-free and
|
|
2
|
+
// side-effect-free: hooks/tier.mjs imports it on the delivery hot path, and it
|
|
3
|
+
// must keep working from a bare checkout with no build step.
|
|
4
|
+
//
|
|
5
|
+
// The problem this exists for: a role's identity used to BE its display string,
|
|
6
|
+
// so renaming it (curator → liaison → aide) churned every id, skill and script
|
|
7
|
+
// that named it. Now the id is frozen and only the name moves.
|
|
8
|
+
//
|
|
9
|
+
// MIRROR: src/roles.ts carries the same logic for the server side — tsconfig's
|
|
10
|
+
// rootDir is `src`, so TypeScript cannot import this file. test/roles.test.mjs
|
|
11
|
+
// asserts the two stay in lockstep; if you change a rule or an id here, change
|
|
12
|
+
// it there too.
|
|
13
|
+
|
|
14
|
+
// Display text → role id. Lowercase, non-alphanumerics collapse to "-".
|
|
15
|
+
export function slugifyRole(role) {
|
|
16
|
+
return String(role ?? "")
|
|
17
|
+
.trim()
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A gate runner is the agent that consumes DONE: reports (QA / coordinator).
|
|
24
|
+
export const GATE_RUNNER_ROLE_IDS = new Set(["qa", "quality", "coordinator", "gate"]);
|
|
25
|
+
// Coordinators countersign work orders (`go`) and in-flight contract
|
|
26
|
+
// amendments (`scope`).
|
|
27
|
+
export const COORDINATOR_ROLE_IDS = new Set(["coordinator"]);
|
|
28
|
+
|
|
29
|
+
// Normalize any role shape into {roleId, displayName, explicit}.
|
|
30
|
+
//
|
|
31
|
+
// `explicit` records whether the id was DECLARED (registry entry carries a
|
|
32
|
+
// frozen roleId) or DERIVED from display text. It is the difference between
|
|
33
|
+
// "this agent's identity is `gate`" and "this agent's name happens to contain
|
|
34
|
+
// the word gate" — declared ids match exactly, derived ones fall back to the
|
|
35
|
+
// legacy word match below so un-migrated registries keep behaving as they did.
|
|
36
|
+
//
|
|
37
|
+
// Accepts: a plain string, {roleId, displayName}, or an AgentEntry ({role,
|
|
38
|
+
// roleId}) passed straight from the registry.
|
|
39
|
+
export function resolveRole(role) {
|
|
40
|
+
if (role === null || role === undefined) return undefined;
|
|
41
|
+
if (typeof role === "string") {
|
|
42
|
+
const roleId = slugifyRole(role);
|
|
43
|
+
return roleId ? { roleId, displayName: role, explicit: false } : undefined;
|
|
44
|
+
}
|
|
45
|
+
if (typeof role === "object") {
|
|
46
|
+
const declared = typeof role.roleId === "string" ? slugifyRole(role.roleId) : "";
|
|
47
|
+
const name =
|
|
48
|
+
typeof role.displayName === "string" && role.displayName
|
|
49
|
+
? role.displayName
|
|
50
|
+
: typeof role.role === "string" && role.role
|
|
51
|
+
? role.role
|
|
52
|
+
: undefined;
|
|
53
|
+
if (declared) return { roleId: declared, displayName: name ?? declared, explicit: true };
|
|
54
|
+
if (name) return resolveRole(name);
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Does this role carry one of `allowedIds`?
|
|
60
|
+
//
|
|
61
|
+
// Declared ids must match exactly — that is the point of freezing them. A
|
|
62
|
+
// derived id additionally matches on any of its hyphen-separated words, which
|
|
63
|
+
// reproduces the pre-Task-4 prose regex (/\b(qa|quality|coordinator|gate)\b/i)
|
|
64
|
+
// for registries that never declared a roleId.
|
|
65
|
+
export function roleMatches(role, allowedIds) {
|
|
66
|
+
const resolved = resolveRole(role);
|
|
67
|
+
if (!resolved) return false;
|
|
68
|
+
if (allowedIds.has(resolved.roleId)) return true;
|
|
69
|
+
if (resolved.explicit) return false;
|
|
70
|
+
return resolved.roleId.split("-").some((word) => allowedIds.has(word));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isGateRunner(role) {
|
|
74
|
+
return roleMatches(role, GATE_RUNNER_ROLE_IDS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isCoordinator(role) {
|
|
78
|
+
return roleMatches(role, COORDINATOR_ROLE_IDS);
|
|
79
|
+
}
|
package/hooks/submit.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
function envStr(name, fallback) {
|
|
68
|
+
const v = process.env[name];
|
|
69
|
+
return v === undefined ? fallback : v;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
export function sleep(ms) {
|
|
74
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Normalize for comparison: a TUI re-wraps and re-pads its input box, so raw
|
|
78
|
+
// substring matching on captured pane text is unreliable.
|
|
79
|
+
function squash(s) {
|
|
80
|
+
return String(s ?? "").replace(/\s+/g, " ").trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Is `payload` still sitting in the pane's INPUT LINE?
|
|
84
|
+
//
|
|
85
|
+
// It asks the input line specifically, and nothing else. The first version
|
|
86
|
+
// squashed the last 20 lines of the whole pane and searched that window — but
|
|
87
|
+
// Claude Code ECHOES a submitted command into the transcript directly above
|
|
88
|
+
// the prompt box, inside that window. So a command that ran perfectly still
|
|
89
|
+
// matched, and the verifier reported failure on success (David hit this live:
|
|
90
|
+
// the pane read "Compacting conversation… 28%" while the receipt said the
|
|
91
|
+
// command "did not run").
|
|
92
|
+
//
|
|
93
|
+
// It only ever passed in testing because a freshly spawned disposable session
|
|
94
|
+
// has an empty scrollback — the one condition that hides a scrollback bug. The
|
|
95
|
+
// lesson is narrower than "test on a real TUI": a check that reads scrollback
|
|
96
|
+
// has to be tested against a pane that HAS scrollback.
|
|
97
|
+
//
|
|
98
|
+
// Returns true = still waiting in the input, false = gone (submitted),
|
|
99
|
+
// null = UNKNOWN. Unknown is never upgraded to a confirmation: no pane text,
|
|
100
|
+
// or no line matching AGENT_COORD_PROMPT_PATTERN, means we cannot tell.
|
|
101
|
+
export function stillInInput(paneText, payload) {
|
|
102
|
+
if (paneText === null || paneText === undefined) return null;
|
|
103
|
+
const needle = squash(payload);
|
|
104
|
+
if (!needle) return false;
|
|
105
|
+
const { draft } = readPaneState(paneText);
|
|
106
|
+
if (draft === null) return null; // no recognizable input line — cannot tell
|
|
107
|
+
return squash(draft).includes(needle);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Read the pane's input state: is the TUI busy, and is there unsent text?
|
|
111
|
+
//
|
|
112
|
+
// `null` for either field means UNKNOWN — the pane could not be captured, or
|
|
113
|
+
// this TUI does not look like the one we know how to read. Unknown is never
|
|
114
|
+
// treated as "fine": callers either proceed and fall back to verify-by-absence
|
|
115
|
+
// or report the uncertainty, but they never upgrade it to a confirmation.
|
|
116
|
+
export function readPaneState(paneText) {
|
|
117
|
+
if (paneText === null || paneText === undefined) return { busy: null, draft: null };
|
|
118
|
+
const text = String(paneText);
|
|
119
|
+
const busyRe = BUSY_PATTERN();
|
|
120
|
+
const busy = busyRe ? new RegExp(busyRe).test(text) : null;
|
|
121
|
+
|
|
122
|
+
const promptRe = PROMPT_PATTERN();
|
|
123
|
+
let draft = null;
|
|
124
|
+
if (promptRe) {
|
|
125
|
+
const re = new RegExp(promptRe);
|
|
126
|
+
const lines = text.split("\n").map((l) => l.replace(/\s+$/, ""));
|
|
127
|
+
// The LAST prompt line is the live input; earlier ones are history.
|
|
128
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
129
|
+
const m = re.exec(lines[i]);
|
|
130
|
+
if (!m) continue;
|
|
131
|
+
const content = (m[1] ?? "").trim();
|
|
132
|
+
const placeholder = PLACEHOLDER_PATTERN();
|
|
133
|
+
draft = placeholder && new RegExp(placeholder).test(content) ? "" : content;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { busy, draft };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Submit a CONTROL command (/clear, /compact) — the path that must actually
|
|
141
|
+
// run, not merely arrive.
|
|
142
|
+
//
|
|
143
|
+
// Refuses rather than corrupts. Pasting into a busy pane queues the command
|
|
144
|
+
// behind a whole model turn; pasting onto a draft concatenates with it and
|
|
145
|
+
// sends the result to the model as chat text. Both were previously reported as
|
|
146
|
+
// delivery:"confirmed". Waiting briefly and then declining is slower and
|
|
147
|
+
// louder — deliberately, because a control command that silently becomes a
|
|
148
|
+
// chat message is worse than one that says it did not run.
|
|
149
|
+
export async function submitControl(deps, payload) {
|
|
150
|
+
const { run, target } = deps;
|
|
151
|
+
const capture = () => {
|
|
152
|
+
const cap = run(["capture-pane", "-p", "-t", target]);
|
|
153
|
+
return cap.status === 0 ? String(cap.stdout ?? "") : null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const idleBudget = CONTROL_IDLE_WAIT_MS();
|
|
157
|
+
const deadline = Date.now() + idleBudget;
|
|
158
|
+
let state = readPaneState(capture());
|
|
159
|
+
while (state.busy === true && idleBudget > 0 && Date.now() < deadline) {
|
|
160
|
+
await sleep(VERIFY_POLL_MS() * 5);
|
|
161
|
+
state = readPaneState(capture());
|
|
162
|
+
}
|
|
163
|
+
if (state.busy === true) {
|
|
164
|
+
return {
|
|
165
|
+
submitted: false,
|
|
166
|
+
verified: true,
|
|
167
|
+
pasted: false,
|
|
168
|
+
attempts: 0,
|
|
169
|
+
reason:
|
|
170
|
+
`pane '${target}' is still busy after ${idleBudget}ms — not pasted. A control command sent now would ` +
|
|
171
|
+
`queue behind the running turn instead of executing (raise AGENT_COORD_CONTROL_IDLE_WAIT_MS to wait longer).`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (state.draft) {
|
|
175
|
+
return {
|
|
176
|
+
submitted: false,
|
|
177
|
+
verified: true,
|
|
178
|
+
pasted: false,
|
|
179
|
+
attempts: 0,
|
|
180
|
+
reason:
|
|
181
|
+
`pane '${target}' has unsent text in its input (${JSON.stringify(state.draft.slice(0, 40))}…) — not pasted. ` +
|
|
182
|
+
`The command would have been appended to that draft and sent to the model as an ordinary message.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return pasteAndSubmit(deps, payload, { bracketed: false, verify: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Paste a payload into the target pane and submit it.
|
|
189
|
+
//
|
|
190
|
+
// deps:
|
|
191
|
+
// runStdin(args, payload) -> Promise<void> // tmux load-buffer -
|
|
192
|
+
// run(args) -> {status, stdout, stderr} // spawnSync-shaped
|
|
193
|
+
// target, buffer // tmux -t target, buffer name
|
|
194
|
+
//
|
|
195
|
+
// Returns {submitted, verified, reason?, attempts}. For bracketed peer content
|
|
196
|
+
// verification is skipped entirely (`verified:false`, `submitted:true`) — that
|
|
197
|
+
// path pastes inert data on the hot path and has never been the problem; only
|
|
198
|
+
// control commands are verified.
|
|
199
|
+
export async function pasteAndSubmit(deps, payload, { bracketed = false, verify = false } = {}) {
|
|
200
|
+
const { run, runStdin, target, buffer } = deps;
|
|
201
|
+
|
|
202
|
+
await runStdin(["load-buffer", "-b", buffer, "-"], payload);
|
|
203
|
+
const paste = run(["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", buffer, "-t", target, "-d"]);
|
|
204
|
+
if (paste.status !== 0) {
|
|
205
|
+
throw new Error(`tmux paste-buffer: ${String(paste.stderr ?? "").trim()}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await sleep(ENTER_DELAY_MS());
|
|
209
|
+
const e1 = run(["send-keys", "-t", target, "Enter"]);
|
|
210
|
+
if (e1.status !== 0) throw new Error(`tmux send-keys: ${String(e1.stderr ?? "").trim()}`);
|
|
211
|
+
await sleep(ENTER_GAP_MS());
|
|
212
|
+
run(["send-keys", "-t", target, "Enter"]);
|
|
213
|
+
|
|
214
|
+
if (!verify) return { submitted: true, verified: false, attempts: 1 };
|
|
215
|
+
|
|
216
|
+
const retries = ENTER_RETRIES();
|
|
217
|
+
for (let attempt = 1; ; attempt++) {
|
|
218
|
+
const outcome = await pollUntilGone(deps, payload);
|
|
219
|
+
if (outcome === false) return { submitted: true, verified: true, attempts: attempt };
|
|
220
|
+
if (outcome === null) {
|
|
221
|
+
return {
|
|
222
|
+
submitted: false,
|
|
223
|
+
verified: false,
|
|
224
|
+
attempts: attempt,
|
|
225
|
+
reason:
|
|
226
|
+
`could not read the input line of pane '${target}' to verify submission — the command was typed but may not have run ` +
|
|
227
|
+
`(pane capture failed, or no line matched AGENT_COORD_PROMPT_PATTERN for this TUI)`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (attempt > retries) {
|
|
231
|
+
return {
|
|
232
|
+
submitted: false,
|
|
233
|
+
verified: true, // we DID observe the pane; what we observed is failure
|
|
234
|
+
attempts: attempt,
|
|
235
|
+
reason:
|
|
236
|
+
`the command is still in the input of pane '${target}' after ${attempt} submit attempt(s) — ` +
|
|
237
|
+
`it was typed but did not run (raise AGENT_COORD_ENTER_DELAY_MS if this recurs)`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// Still sitting there: one more Enter before giving up.
|
|
241
|
+
//
|
|
242
|
+
// Every retry re-verifies FIRST (the loop head above), so an Enter is only
|
|
243
|
+
// ever sent after we have just looked at the input line and seen the
|
|
244
|
+
// command still in it. That ordering is the whole safety argument: these
|
|
245
|
+
// are keystrokes into a live session someone else may be typing in, and an
|
|
246
|
+
// Enter sent on a stale reading would submit whatever they had drafted
|
|
247
|
+
// since. Under the old tail-window check this fired on EVERY successful
|
|
248
|
+
// submit — three Enters into a pane that had already run the command.
|
|
249
|
+
await sleep(ENTER_GAP_MS());
|
|
250
|
+
run(["send-keys", "-t", target, "Enter"]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Poll capture-pane until the payload leaves the input line, the budget runs
|
|
255
|
+
// out, or we run out of ways to tell. true = still there, false = gone,
|
|
256
|
+
// null = unknown (capture failed, or no input line we can read).
|
|
257
|
+
async function pollUntilGone(deps, payload) {
|
|
258
|
+
const { run, target } = deps;
|
|
259
|
+
const deadline = Date.now() + VERIFY_TIMEOUT_MS();
|
|
260
|
+
let readInput = false;
|
|
261
|
+
for (;;) {
|
|
262
|
+
const cap = run(["capture-pane", "-p", "-t", target]);
|
|
263
|
+
if (cap.status === 0) {
|
|
264
|
+
const verdict = stillInInput(String(cap.stdout ?? ""), payload);
|
|
265
|
+
if (verdict === false) return false;
|
|
266
|
+
if (verdict === true) readInput = true; // we could read the input line
|
|
267
|
+
}
|
|
268
|
+
if (Date.now() >= deadline) return readInput ? true : null;
|
|
269
|
+
await sleep(VERIFY_POLL_MS());
|
|
270
|
+
}
|
|
271
|
+
}
|