@miadi/connector 0.1.15 → 0.1.16
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 +5 -1
- package/dist/approve.js +24 -4
- package/dist/bin.js +12 -1
- package/dist/factory-receipt.d.ts +28 -0
- package/dist/factory-receipt.js +69 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/mission-result.d.ts +142 -0
- package/dist/mission-result.js +273 -0
- package/dist/mission-status.d.ts +92 -12
- package/dist/mission-status.js +214 -13
- package/dist/steer.d.ts +34 -2
- package/dist/steer.js +111 -15
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +176 -19
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/mission-status.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { steerAttempts, steeringGrantOf, checkGrant } from "./steer.js";
|
|
2
|
+
import { missionResult, redactResult, submittedWithWriteTier } from "./mission-result.js";
|
|
2
3
|
const ISSUE_REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
|
|
3
4
|
const SESSION_REF = /^session:([0-9a-f-]{36})$/;
|
|
4
5
|
const MISSION_KINDS = new Set(["run_request", "work_proposal"]);
|
|
@@ -20,9 +21,9 @@ function candidate(r, relation, provenance) {
|
|
|
20
21
|
const primary = primaryIssueOf(r);
|
|
21
22
|
return { id: r.id, kind: r.kind, created_at: r.created_at, relation, provenance, ...(primary ? { primary_issue: primary.ref } : {}) };
|
|
22
23
|
}
|
|
23
|
-
/** Whether a resolved reference may be acted on: an exact id, or a declared primary nobody else holds. */
|
|
24
|
+
/** Whether a resolved reference may be acted on: an exact id, the one record the factory delivered to a session, or a declared primary nobody else holds. */
|
|
24
25
|
export function authorizesActions(found) {
|
|
25
|
-
return found.resolution === "exact" || (found.resolution === "declared_primary" && !found.contested);
|
|
26
|
+
return found.resolution === "exact" || found.resolution === "factory_bound" || (found.resolution === "declared_primary" && !found.contested);
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
29
|
* The record a mission reference resolves to, how it was found, and every other
|
|
@@ -30,12 +31,22 @@ export function authorizesActions(found) {
|
|
|
30
31
|
* Expects the folded ledger (`current()`), so a superseding record never stands
|
|
31
32
|
* as a mission of its own.
|
|
32
33
|
*/
|
|
33
|
-
export function resolveMissionSubject(ref, records) {
|
|
34
|
+
export function resolveMissionSubject(ref, records, opts = {}) {
|
|
34
35
|
const exact = records.find((r) => r.id === ref);
|
|
35
36
|
if (exact)
|
|
36
37
|
return { record: exact, via: "record_id", resolution: "exact", candidates: [] };
|
|
37
38
|
// A request retired as an earlier version of another (jgwill/Miadi#919) is not a mission of its own.
|
|
38
39
|
const missions = records.filter((r) => MISSION_KINDS.has(r.kind) && r.completion_receipt?.decision !== "superseded").sort(newestFirst);
|
|
40
|
+
// A session the factory delivered a mission to is bound by the runner's receipt, not by a citation (jgwill/Miadi#909).
|
|
41
|
+
const sessionMatch = SESSION_REF.exec(ref);
|
|
42
|
+
if (sessionMatch && opts.recordsBoundTo) {
|
|
43
|
+
const ids = new Set(opts.recordsBoundTo(sessionMatch[1]));
|
|
44
|
+
const bound = missions.filter((r) => ids.has(r.id));
|
|
45
|
+
if (bound.length === 1)
|
|
46
|
+
return { record: bound[0], via: "factory_receipt", resolution: "factory_bound", candidates: [candidate(bound[0], "bound", "factory_receipt")] };
|
|
47
|
+
if (bound.length > 1)
|
|
48
|
+
return { via: "factory_receipt", resolution: "ambiguous_mission", candidates: bound.map((r) => candidate(r, "bound", "factory_receipt")) };
|
|
49
|
+
}
|
|
39
50
|
if (SESSION_REF.test(ref)) {
|
|
40
51
|
const citing = missions.filter((r) => r.refs.includes(ref));
|
|
41
52
|
if (!citing.length)
|
|
@@ -68,9 +79,24 @@ export function resolveMissionSubject(ref, records) {
|
|
|
68
79
|
}
|
|
69
80
|
return null;
|
|
70
81
|
}
|
|
71
|
-
function nextAction(record, standing, queue) {
|
|
82
|
+
function nextAction(record, standing, queue, result) {
|
|
72
83
|
if (!standing)
|
|
73
84
|
return "Read the record itself; it is not an approvable request.";
|
|
85
|
+
if (result?.current && !result.verified && (standing.stage === "dispatching" || standing.stage === "completed")) {
|
|
86
|
+
return `A result names this mission but is not verified (${result.current.association_reason}) Read it as a claim; its decision does not complete the mission.`;
|
|
87
|
+
}
|
|
88
|
+
if (result?.current && (standing.stage === "dispatching" || standing.stage === "completed")) {
|
|
89
|
+
switch (result.completion) {
|
|
90
|
+
case "pending_approval":
|
|
91
|
+
return `The agent submitted its result (${result.current.record}). Read result.current; a person with Miadi's write tier decides its ending on its approval page. Nothing closes before that.`;
|
|
92
|
+
case "accepted":
|
|
93
|
+
return "The person accepted the ending. What remains is any merge or release this mission needs, each by its own receipt.";
|
|
94
|
+
case "declined":
|
|
95
|
+
return "The person declined the ending; the session stays open. Follow it, or steer it inside the approved grant.";
|
|
96
|
+
case "revision_requested":
|
|
97
|
+
return "The person sent the ending back; the agent revises its result and submits again.";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
74
100
|
switch (standing.stage) {
|
|
75
101
|
case "awaiting_decision":
|
|
76
102
|
return "A person with Miadi's write tier decides on its approval page or a decisions card.";
|
|
@@ -144,6 +170,9 @@ function closureTruth(record, records, missionSessions) {
|
|
|
144
170
|
return false;
|
|
145
171
|
if (!CLOSING_ENDINGS.has(str(b.ending)))
|
|
146
172
|
return false;
|
|
173
|
+
// The same trust as a verified result: an ending submitted below the write tier closes nothing (#922 R3, R4).
|
|
174
|
+
if (!submittedWithWriteTier(r))
|
|
175
|
+
return false;
|
|
147
176
|
return missionSessions.size === 0 || missionSessions.has(str(b.session_id));
|
|
148
177
|
})
|
|
149
178
|
.sort(newestFirst);
|
|
@@ -163,7 +192,7 @@ function closureTruth(record, records, missionSessions) {
|
|
|
163
192
|
}
|
|
164
193
|
export async function missionStatus(ref, records, deps) {
|
|
165
194
|
const notes = [];
|
|
166
|
-
const found = resolveMissionSubject(ref, records);
|
|
195
|
+
const found = resolveMissionSubject(ref, records, { recordsBoundTo: deps.recordsBoundTo });
|
|
167
196
|
if (!found)
|
|
168
197
|
return { ref, found: false, notes: ["No mission record resolves from this reference."] };
|
|
169
198
|
if (!found.record) {
|
|
@@ -198,10 +227,99 @@ export async function missionStatus(ref, records, deps) {
|
|
|
198
227
|
const now = deps.now?.() ?? new Date();
|
|
199
228
|
// The sessions this mission names, each with how it is bound.
|
|
200
229
|
const sessions = [];
|
|
201
|
-
|
|
230
|
+
// A lister that fails says nothing: unknown, never absent (#922 R10).
|
|
231
|
+
let live;
|
|
232
|
+
try {
|
|
233
|
+
live = deps.tmuxSessions ? new Set(deps.tmuxSessions()) : undefined;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
live = undefined;
|
|
237
|
+
}
|
|
202
238
|
const briefTo = (record.completion_receipt ?? {}).to;
|
|
203
239
|
const named = String(record.body.session ?? "") || undefined;
|
|
204
240
|
const seen = new Set();
|
|
241
|
+
const seenIds = new Set();
|
|
242
|
+
const view = async (id) => (deps.sessionOf ? deps.sessionOf(id).catch(() => ({ state: "unavailable" })) : undefined);
|
|
243
|
+
// 1. The factory runner's receipt: the record, the tmux session, the pane and the session id that took the brief (jgwill/Miadi#909).
|
|
244
|
+
const receipts = deps.factoryReceiptsOf?.(record.id) ?? [];
|
|
245
|
+
let provider;
|
|
246
|
+
for (const rc of receipts) {
|
|
247
|
+
if (seen.has(rc.tmux_session) && (!rc.session_id || seenIds.has(rc.session_id)))
|
|
248
|
+
continue;
|
|
249
|
+
seen.add(rc.tmux_session);
|
|
250
|
+
if (rc.session_id)
|
|
251
|
+
seenIds.add(rc.session_id);
|
|
252
|
+
const miadi = rc.session_id ? (await view(rc.session_id)) : undefined;
|
|
253
|
+
const reg = miadi && typeof miadi === "object" && miadi.live && typeof miadi.live === "object" ? miadi.live : undefined;
|
|
254
|
+
let binding;
|
|
255
|
+
let binding_reason;
|
|
256
|
+
let binding_compared;
|
|
257
|
+
if (!rc.session_id) {
|
|
258
|
+
binding = "receipt_only";
|
|
259
|
+
binding_reason = "The runner recorded no session id for this delivery.";
|
|
260
|
+
}
|
|
261
|
+
else if (!miadi || miadi.state === "unavailable" || miadi.error) {
|
|
262
|
+
binding = "receipt_only";
|
|
263
|
+
binding_reason = "Miadi's session registry could not be read; only the runner's receipt binds this session.";
|
|
264
|
+
}
|
|
265
|
+
else if (!reg) {
|
|
266
|
+
binding = "receipt_only";
|
|
267
|
+
binding_reason = "Miadi's session registry knows the session but shows no live seat for it now.";
|
|
268
|
+
}
|
|
269
|
+
else if (str(reg.tmux_session) === rc.tmux_session && (!rc.pane_id || !str(reg.pane_id) || str(reg.pane_id) === rc.pane_id)) {
|
|
270
|
+
binding = "verified";
|
|
271
|
+
binding_compared = ["session_id", "tmux_session", ...(rc.pane_id && str(reg.pane_id) ? ["pane_id"] : [])];
|
|
272
|
+
binding_reason = `The factory receipt and Miadi's session registry agree on ${binding_compared.join(", ")}.`;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
binding = "unverified_association";
|
|
276
|
+
binding_reason = `The receipt names ${rc.tmux_session} ${rc.pane_id ?? ""}, but the registry places session ${rc.session_id} in ${str(reg.tmux_session) || "(none)"} ${str(reg.pane_id)}.`.replace(/\s+/g, " ");
|
|
277
|
+
}
|
|
278
|
+
const inTmux = live ? live.has(rc.tmux_session) : undefined;
|
|
279
|
+
const paneThere = inTmux && rc.pane_id && deps.panePresent ? deps.panePresent(rc.tmux_session, rc.pane_id) : undefined;
|
|
280
|
+
const liveState = live ? (inTmux && paneThere !== false ? "running" : "absent") : reg ? "running" : "unknown";
|
|
281
|
+
if (inTmux && paneThere === false) {
|
|
282
|
+
binding_reason += ` Pane ${rc.pane_id} is no longer in ${rc.tmux_session}; the seat was replaced or restored.`;
|
|
283
|
+
if (binding === "verified")
|
|
284
|
+
binding = "unverified_association";
|
|
285
|
+
}
|
|
286
|
+
const regStatus = str(reg?.status);
|
|
287
|
+
const activity = binding === "verified" && (regStatus === "busy" || regStatus === "idle")
|
|
288
|
+
? regStatus
|
|
289
|
+
: liveState === "running" && deps.paneBusy
|
|
290
|
+
? deps.paneBusy(rc.tmux_session, rc.pane_id)
|
|
291
|
+
: undefined;
|
|
292
|
+
sessions.push({
|
|
293
|
+
association: "factory_receipt",
|
|
294
|
+
session: rc.tmux_session,
|
|
295
|
+
...(rc.session_id ? { session_id: rc.session_id } : {}),
|
|
296
|
+
...(rc.pane_id ? { pane_id: rc.pane_id } : {}),
|
|
297
|
+
delivered_at: rc.at,
|
|
298
|
+
live: liveState,
|
|
299
|
+
...(activity ? { activity, activity_source: binding === "verified" && regStatus ? "miadi_registry" : "pane_peek" } : {}),
|
|
300
|
+
...(activity && binding === "verified" && str(reg?.updated_at) ? { activity_at: str(reg.updated_at) } : {}),
|
|
301
|
+
binding,
|
|
302
|
+
binding_reason,
|
|
303
|
+
...(binding_compared ? { binding_compared } : {}),
|
|
304
|
+
...(miadi ? { miadi } : {}),
|
|
305
|
+
});
|
|
306
|
+
if (!provider) {
|
|
307
|
+
const b = record.body;
|
|
308
|
+
const requested = str(b.provider) || null;
|
|
309
|
+
const resolved = str(b.resolved_provider) || null;
|
|
310
|
+
const actual = rc.provider || str(miadi?.agent) || null;
|
|
311
|
+
provider = {
|
|
312
|
+
requested,
|
|
313
|
+
resolved,
|
|
314
|
+
actual,
|
|
315
|
+
...(actual ? { actual_source: rc.provider ? "factory_receipt" : "miadi_registry" } : {}),
|
|
316
|
+
match: !actual || !(resolved ?? requested) ? "unknown" : actual === (resolved ?? requested),
|
|
317
|
+
};
|
|
318
|
+
if (provider.match === false) {
|
|
319
|
+
notes.push(`The mission asked for ${requested ?? "(none)"}${resolved && resolved !== requested ? ` (resolved ${resolved})` : ""}; the factory ran ${actual}. Nothing was re-run because of it.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
205
323
|
const addSession = (session, association) => {
|
|
206
324
|
if (seen.has(session))
|
|
207
325
|
return;
|
|
@@ -210,7 +328,7 @@ export async function missionStatus(ref, records, deps) {
|
|
|
210
328
|
association,
|
|
211
329
|
session,
|
|
212
330
|
live: live ? (live.has(session) ? "running" : "absent") : "unknown",
|
|
213
|
-
...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session) } : {}),
|
|
331
|
+
...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session), activity_source: "pane_peek" } : {}),
|
|
214
332
|
});
|
|
215
333
|
};
|
|
216
334
|
if (typeof briefTo === "string" && briefTo)
|
|
@@ -220,12 +338,31 @@ export async function missionStatus(ref, records, deps) {
|
|
|
220
338
|
for (const r of record.refs) {
|
|
221
339
|
const m = SESSION_REF.exec(r);
|
|
222
340
|
if (m) {
|
|
223
|
-
|
|
224
|
-
|
|
341
|
+
if (seenIds.has(m[1]))
|
|
342
|
+
continue; // the receipt already binds it
|
|
343
|
+
const miadi = await view(m[1]);
|
|
344
|
+
sessions.push({ association: "signed_ref", session: r, session_id: m[1], live: "unknown", ...(miadi ? { miadi } : {}) });
|
|
225
345
|
}
|
|
226
346
|
}
|
|
227
347
|
if (!live)
|
|
228
348
|
notes.push("Live session state was not joined (the viewer's tier or the host does not allow it).");
|
|
349
|
+
if (deps.factoryReceiptsOf && !receipts.length && record.approval_state === "approved") {
|
|
350
|
+
notes.push("The factory has no delivery receipt for this mission; its session is known only by name.");
|
|
351
|
+
}
|
|
352
|
+
// The sessions a result must come from: the runner's receipts (for every tier), else the session refs the record carries.
|
|
353
|
+
const boundIds = new Set([...seenIds, ...(deps.boundSessionIdsOf?.(record.id) ?? [])]);
|
|
354
|
+
if (!boundIds.size)
|
|
355
|
+
for (const r of record.refs) {
|
|
356
|
+
const m = SESSION_REF.exec(r);
|
|
357
|
+
if (m)
|
|
358
|
+
boundIds.add(m[1]);
|
|
359
|
+
}
|
|
360
|
+
const evidence = deps.trustedRecords ?? records;
|
|
361
|
+
const operator = deps.operator ?? true;
|
|
362
|
+
// The closure set is built from the record as stored, not as this viewer is shown it (#922 R15).
|
|
363
|
+
const stored = evidence.find((r) => r.id === record.id) ?? record;
|
|
364
|
+
const storedTo = str((stored.completion_receipt ?? {}).to).replace(/^new:/, "");
|
|
365
|
+
const storedNamed = str(stored.body.session);
|
|
229
366
|
const issues = [];
|
|
230
367
|
if (deps.issueOf) {
|
|
231
368
|
for (const r of [...new Set([...record.refs, ...record.evidence_refs])]) {
|
|
@@ -255,8 +392,16 @@ export async function missionStatus(ref, records, deps) {
|
|
|
255
392
|
const prs = issues.filter((i) => i.merged !== undefined);
|
|
256
393
|
const unverifiedRefs = issues.some((i) => i.state === "unverified");
|
|
257
394
|
const release = releaseTruth(record, records, deps, now);
|
|
258
|
-
const missionSessions = new Set(
|
|
259
|
-
|
|
395
|
+
const missionSessions = new Set([
|
|
396
|
+
...sessions.map((x) => x.session.replace(/^session:/, "")),
|
|
397
|
+
...boundIds,
|
|
398
|
+
...(storedTo ? [storedTo] : []),
|
|
399
|
+
...(storedNamed ? [storedNamed] : []),
|
|
400
|
+
]);
|
|
401
|
+
const closureFound = closureTruth(record, evidence, missionSessions);
|
|
402
|
+
const closure = closureFound && !operator ? (({ session_id: _s, ...rest }) => rest)(closureFound) : closureFound;
|
|
403
|
+
const checked = await missionResult(record, evidence, boundIds, { githubGet: deps.githubGet, approvalUrl: deps.approvalUrl, now: () => now });
|
|
404
|
+
const result = operator ? checked : redactResult(checked);
|
|
260
405
|
const choice = standing?.decision?.choice ?? (str(receipt.decision) || undefined);
|
|
261
406
|
const instructions = standing?.decision?.instructions ?? (str(receipt.instructions) || undefined);
|
|
262
407
|
const stageTruth = {
|
|
@@ -274,9 +419,18 @@ export async function missionStatus(ref, records, deps) {
|
|
|
274
419
|
pr_merged: unverifiedRefs ? "unverified" : prs.length ? (prs.every((p) => p.merged) ? "merged" : "open_or_absent") : "no_pr",
|
|
275
420
|
released: release.released,
|
|
276
421
|
...(release.release_evidence ? { release_evidence: release.release_evidence } : {}),
|
|
422
|
+
result_submitted: result.state === "submitted",
|
|
423
|
+
result_verified: result.verified,
|
|
424
|
+
completion: result.completion,
|
|
277
425
|
closed: Boolean(closure),
|
|
278
426
|
...(closure ? { closure_evidence: closure } : {}),
|
|
279
427
|
};
|
|
428
|
+
if (result.state === "submitted" && result.verified && result.completion === "pending_approval") {
|
|
429
|
+
notes.push("The agent submitted its result; its ending waits for a person's decision. Submitted is not accepted, and accepted is not closed.");
|
|
430
|
+
}
|
|
431
|
+
if (stageTruth.running === "evidence" && result.state === "no_result_submitted") {
|
|
432
|
+
notes.push("A live seat, busy or idle, is not a result: nothing names this mission as its submitted result yet.");
|
|
433
|
+
}
|
|
280
434
|
if (stageTruth.running === "no_evidence" && record.approval_state === "approved") {
|
|
281
435
|
notes.push("No bound session shows live; an idle or absent pane is not proof of completion or failure.");
|
|
282
436
|
}
|
|
@@ -307,10 +461,57 @@ export async function missionStatus(ref, records, deps) {
|
|
|
307
461
|
sessions,
|
|
308
462
|
issues,
|
|
309
463
|
steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
|
|
464
|
+
...(provider ? { provider } : {}),
|
|
310
465
|
stage_truth: stageTruth,
|
|
466
|
+
mission_stage: missionStage(standing ?? null, stageTruth),
|
|
467
|
+
pending_human_gates: pendingGates(record, records, result, deps.approvalUrl),
|
|
468
|
+
result,
|
|
311
469
|
next_authorized_action: identity.authorizes_actions
|
|
312
|
-
? nextAction(record, standing ?? null, queue)
|
|
313
|
-
: `Not authorizing (${found.contested ? "contested" : "inferred"}): call mission_status with ${record.id} before acting. ${nextAction(record, standing ?? null, queue)}`,
|
|
470
|
+
? nextAction(record, standing ?? null, queue, result)
|
|
471
|
+
: `Not authorizing (${found.contested ? "contested" : "inferred"}): call mission_status with ${record.id} before acting. ${nextAction(record, standing ?? null, queue, result)}`,
|
|
314
472
|
notes,
|
|
315
473
|
};
|
|
316
474
|
}
|
|
475
|
+
/** The furthest stage the evidence reaches. Each stage keeps its own truth in stage_truth. */
|
|
476
|
+
export function missionStage(standing, t) {
|
|
477
|
+
if (standing && ["awaiting_decision", "recording", "sent_back", "declined"].includes(standing.stage))
|
|
478
|
+
return standing.stage;
|
|
479
|
+
if (t.closed)
|
|
480
|
+
return "closed";
|
|
481
|
+
if (t.released === "verified" || t.released === "receipt_recorded")
|
|
482
|
+
return "released";
|
|
483
|
+
if (t.pr_merged === "merged")
|
|
484
|
+
return "merged";
|
|
485
|
+
if (t.completion === "accepted")
|
|
486
|
+
return "completion_accepted";
|
|
487
|
+
if (t.result_submitted && !t.result_verified)
|
|
488
|
+
return "result_submitted_unverified";
|
|
489
|
+
if (t.result_submitted) {
|
|
490
|
+
if (t.completion === "declined")
|
|
491
|
+
return "result_submitted_completion_declined";
|
|
492
|
+
if (t.completion === "revision_requested")
|
|
493
|
+
return "result_submitted_revision_requested";
|
|
494
|
+
return "result_submitted_awaiting_completion_approval";
|
|
495
|
+
}
|
|
496
|
+
if (t.running === "evidence")
|
|
497
|
+
return "running";
|
|
498
|
+
if (t.delivered)
|
|
499
|
+
return "delivered";
|
|
500
|
+
if (t.queued)
|
|
501
|
+
return "queued";
|
|
502
|
+
return t.approved ? "approved" : (standing?.stage ?? "recorded");
|
|
503
|
+
}
|
|
504
|
+
function pendingGates(record, records, result, approvalUrl) {
|
|
505
|
+
const gates = [];
|
|
506
|
+
const url = (id) => (approvalUrl ? { approval_url: approvalUrl(id) } : {});
|
|
507
|
+
if (record.approval_state === "awaiting_approval")
|
|
508
|
+
gates.push({ gate: "run_approval", record: record.id, ...url(record.id) });
|
|
509
|
+
if (result.current && result.current.decision === "pending_approval") {
|
|
510
|
+
gates.push({ gate: result.verified ? "completion_approval" : "unverified_completion_approval", record: result.current.record, ...url(result.current.record) });
|
|
511
|
+
}
|
|
512
|
+
for (const r of records) {
|
|
513
|
+
if (r.kind === "session_steer_request" && r.approval_state === "awaiting_approval" && r.refs.includes(record.id))
|
|
514
|
+
gates.push({ gate: "steer_approval", record: r.id, ...url(r.id) });
|
|
515
|
+
}
|
|
516
|
+
return gates;
|
|
517
|
+
}
|
package/dist/steer.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import type { ConnectorRecord } from "./records.js";
|
|
3
|
-
export declare const STEER_STATUSES: readonly ["prepared", "submitted", "accepted", "busy", "blocked", "wrong_pane", "stale_binding", "expired_grant", "duplicate"];
|
|
3
|
+
export declare const STEER_STATUSES: readonly ["prepared", "submitted", "accepted", "busy", "blocked", "wrong_pane", "stale_binding", "expired_grant", "duplicate", "composing"];
|
|
4
4
|
export type SteerStatus = (typeof STEER_STATUSES)[number];
|
|
5
5
|
/** The steering grant an approved run carries: `body.steering` on the run_request or work proposal. */
|
|
6
6
|
export interface SteeringGrant {
|
|
@@ -25,18 +25,39 @@ export interface TmuxLike {
|
|
|
25
25
|
capture(target: string): string;
|
|
26
26
|
sendLiteral(target: string, text: string): void;
|
|
27
27
|
sendEnter(target: string): void;
|
|
28
|
+
/** Whether the tmux server answers at all. False means the transport is down: no steer is recorded or counted. */
|
|
29
|
+
available?(): boolean;
|
|
30
|
+
/** Every live tmux session name. */
|
|
31
|
+
listSessions?(): string[];
|
|
28
32
|
}
|
|
29
33
|
/** Commands that mark a pane as an agent seat a steer may land in. */
|
|
30
34
|
export declare const AGENT_COMMANDS: string[];
|
|
31
35
|
/** Signs a seat is mid-turn; typed text would queue behind its work. */
|
|
32
36
|
export declare const BUSY_PATTERNS: RegExp[];
|
|
37
|
+
/**
|
|
38
|
+
* An exact tmux target. Without the `=` prefix tmux matches a session name by its start, so
|
|
39
|
+
* `mission-x` reaches `mission-x-miette-insight` once `mission-x` has gone (jgwill/Miadi#922 R1).
|
|
40
|
+
* `=name:` is a session, `=name:w.p` a pane; tmux 3.7 still prefix-matches a bare `=name` for list-panes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function exactTarget(target: string): string;
|
|
33
43
|
/** The production adapter. All tmux addressing is by exact session and window.pane. */
|
|
34
44
|
export declare function tmuxAdapter(exec?: typeof execFileSync): TmuxLike;
|
|
45
|
+
/**
|
|
46
|
+
* The text a person (or anyone) left at the agent's prompt: the region between the last two
|
|
47
|
+
* horizontal rules of the Claude TUI, without its furniture. The same reading as the factory's
|
|
48
|
+
* pane-write-guard.sh. Empty when the prompt is clear or the pane has no such box.
|
|
49
|
+
*/
|
|
50
|
+
export declare function composingText(capture: string): string | null;
|
|
35
51
|
export declare function steeringGrantOf(run: ConnectorRecord): SteeringGrant | null;
|
|
36
52
|
export type GrantCheck = "ok" | "no_grant" | "expired_grant" | "duplicate";
|
|
37
53
|
/** Whether a grant covers one more steer. `priorAttempts` counts the steers already run under it. */
|
|
38
54
|
export declare function checkGrant(grant: SteeringGrant | null, priorAttempts: number, now?: Date): GrantCheck;
|
|
39
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Steers already run under a grant: decided (or grant-covered) steer records that cite it and
|
|
57
|
+
* typed their instruction (jgwill/Miadi#909). A steer stopped before typing (busy, composing,
|
|
58
|
+
* wrong or stale pane, transport down) uses none of the grant's attempts. Receipts made before
|
|
59
|
+
* `typed` was recorded count when their outcome says the text was delivered.
|
|
60
|
+
*/
|
|
40
61
|
export declare function steerAttempts(records: ConnectorRecord[], grantId: string): number;
|
|
41
62
|
export declare function isBusy(capture: string, patterns?: RegExp[]): boolean;
|
|
42
63
|
export interface SteerDeps {
|
|
@@ -51,6 +72,8 @@ export interface SteerResult {
|
|
|
51
72
|
status: SteerStatus;
|
|
52
73
|
message: string;
|
|
53
74
|
target?: string;
|
|
75
|
+
/** Whether the instruction was typed into the pane. Only a typed steer uses one of the grant's attempts. */
|
|
76
|
+
typed: boolean;
|
|
54
77
|
artifact?: {
|
|
55
78
|
file: string;
|
|
56
79
|
sha256: string;
|
|
@@ -68,4 +91,13 @@ export declare function steerSession(deps: SteerDeps, args: {
|
|
|
68
91
|
grantRecord: ConnectorRecord;
|
|
69
92
|
priorAttempts: number;
|
|
70
93
|
authorized?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The binding the factory runner recorded for this mission (jgwill/Miadi#909): the tmux
|
|
96
|
+
* session and the pane id the brief was typed into. With it, the steer goes to that pane and
|
|
97
|
+
* nowhere else; a session or pane that does not match is refused before anything is typed.
|
|
98
|
+
*/
|
|
99
|
+
binding?: {
|
|
100
|
+
session: string;
|
|
101
|
+
pane_id?: string;
|
|
102
|
+
};
|
|
71
103
|
}): Promise<SteerResult>;
|
package/dist/steer.js
CHANGED
|
@@ -28,17 +28,32 @@ export const STEER_STATUSES = [
|
|
|
28
28
|
"stale_binding",
|
|
29
29
|
"expired_grant",
|
|
30
30
|
"duplicate",
|
|
31
|
+
"composing",
|
|
31
32
|
];
|
|
32
33
|
/** Commands that mark a pane as an agent seat a steer may land in. */
|
|
33
34
|
export const AGENT_COMMANDS = ["claude", "codex", "copilot", "pi", "gemini", "node"];
|
|
34
35
|
/** Signs a seat is mid-turn; typed text would queue behind its work. */
|
|
35
|
-
|
|
36
|
+
// The last pattern is Claude Code's spinner on a narrow pane that cuts "esc to interrupt": a glyph,
|
|
37
|
+
// then one verb ending in "…" ("✽ Cerebrating… (3s)"); an answered turn reads "✻ Cooked for 18s".
|
|
38
|
+
// The same reading as the factory's pane-write-guard.sh (etc-claude-code dispatch-discipline).
|
|
39
|
+
export const BUSY_PATTERNS = [/esc (to )?interrupt/i, /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/, /\bWorking\b/, /\bThinking\b/, /^\s*(·|✢|✳|✶|✻|✽|\*) [^ …]+…/m];
|
|
40
|
+
/**
|
|
41
|
+
* An exact tmux target. Without the `=` prefix tmux matches a session name by its start, so
|
|
42
|
+
* `mission-x` reaches `mission-x-miette-insight` once `mission-x` has gone (jgwill/Miadi#922 R1).
|
|
43
|
+
* `=name:` is a session, `=name:w.p` a pane; tmux 3.7 still prefix-matches a bare `=name` for list-panes.
|
|
44
|
+
*/
|
|
45
|
+
export function exactTarget(target) {
|
|
46
|
+
if (target.startsWith("=") || target.startsWith("%"))
|
|
47
|
+
return target;
|
|
48
|
+
const colon = target.indexOf(":");
|
|
49
|
+
return colon < 0 ? `=${target}:` : `=${target}`;
|
|
50
|
+
}
|
|
36
51
|
/** The production adapter. All tmux addressing is by exact session and window.pane. */
|
|
37
52
|
export function tmuxAdapter(exec = execFileSync) {
|
|
38
53
|
const tmux = (args) => exec("tmux", args, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
|
|
39
54
|
return {
|
|
40
55
|
listPanes(session) {
|
|
41
|
-
const out = tmux(["list-panes", "-s", "-t", session, "-F", "#{pane_id}\t#{window_index}\t#{pane_index}\t#{pane_current_command}\t#{pane_active}"]);
|
|
56
|
+
const out = tmux(["list-panes", "-s", "-t", exactTarget(session), "-F", "#{pane_id}\t#{window_index}\t#{pane_index}\t#{pane_current_command}\t#{pane_active}"]);
|
|
42
57
|
return out
|
|
43
58
|
.split("\n")
|
|
44
59
|
.filter(Boolean)
|
|
@@ -49,17 +64,51 @@ export function tmuxAdapter(exec = execFileSync) {
|
|
|
49
64
|
.sort((a, b) => a.window - b.window || a.pane - b.pane);
|
|
50
65
|
},
|
|
51
66
|
capture(target) {
|
|
52
|
-
return tmux(["capture-pane", "-p", "-t", target]);
|
|
67
|
+
return tmux(["capture-pane", "-p", "-t", exactTarget(target)]);
|
|
53
68
|
},
|
|
54
69
|
// -l: the text is literal; no tmux key names in an instruction are interpreted.
|
|
55
70
|
sendLiteral(target, text) {
|
|
56
|
-
tmux(["send-keys", "-t", target, "-l", "--", text]);
|
|
71
|
+
tmux(["send-keys", "-t", exactTarget(target), "-l", "--", text]);
|
|
57
72
|
},
|
|
58
73
|
sendEnter(target) {
|
|
59
|
-
tmux(["send-keys", "-t", target, "Enter"]);
|
|
74
|
+
tmux(["send-keys", "-t", exactTarget(target), "Enter"]);
|
|
75
|
+
},
|
|
76
|
+
// tmux exits when its last session closes, so "no server" and "no session" are one answer: nothing to steer.
|
|
77
|
+
available() {
|
|
78
|
+
try {
|
|
79
|
+
tmux(["list-sessions", "-F", "#{session_name}"]);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
// Throws when the server cannot be listed: the caller reports unknown, never absent.
|
|
87
|
+
listSessions() {
|
|
88
|
+
return tmux(["list-sessions", "-F", "#{session_name}"]).split("\n").filter(Boolean);
|
|
60
89
|
},
|
|
61
90
|
};
|
|
62
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The text a person (or anyone) left at the agent's prompt: the region between the last two
|
|
94
|
+
* horizontal rules of the Claude TUI, without its furniture. The same reading as the factory's
|
|
95
|
+
* pane-write-guard.sh. Empty when the prompt is clear or the pane has no such box.
|
|
96
|
+
*/
|
|
97
|
+
export function composingText(capture) {
|
|
98
|
+
const lines = capture.split("\n");
|
|
99
|
+
const rules = lines.map((l, i) => (/[─━]{10,}/.test(l) ? i : -1)).filter((i) => i >= 0);
|
|
100
|
+
// No input box: whether someone is writing cannot be told, so the caller types nothing (#922 R7).
|
|
101
|
+
if (rules.length < 2)
|
|
102
|
+
return null;
|
|
103
|
+
const region = lines.slice(rules[rules.length - 2] + 1, rules[rules.length - 1]);
|
|
104
|
+
return region
|
|
105
|
+
.map((l) => l.replace(/\u00a0/g, " ").trim().replace(/^[❯>]\s*/, "").trim())
|
|
106
|
+
// Only whole status lines are furniture; words a person typed are never dropped for containing them (#922 R6).
|
|
107
|
+
.filter((l) => l && !STATUS_LINE.test(l))
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
/** A whole TUI status line, never a phrase inside someone's text: the mode line, or the bare /rc echo. */
|
|
111
|
+
const STATUS_LINE = /^(?:(?:⏵⏵|⏸)\s.*|\/rc|\(?esc to interrupt\)?)$/i;
|
|
63
112
|
export function steeringGrantOf(run) {
|
|
64
113
|
const g = run.body.steering;
|
|
65
114
|
if (!g || typeof g !== "object")
|
|
@@ -81,12 +130,26 @@ export function checkGrant(grant, priorAttempts, now = new Date()) {
|
|
|
81
130
|
return "duplicate";
|
|
82
131
|
return "ok";
|
|
83
132
|
}
|
|
84
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Steers already run under a grant: decided (or grant-covered) steer records that cite it and
|
|
135
|
+
* typed their instruction (jgwill/Miadi#909). A steer stopped before typing (busy, composing,
|
|
136
|
+
* wrong or stale pane, transport down) uses none of the grant's attempts. Receipts made before
|
|
137
|
+
* `typed` was recorded count when their outcome says the text was delivered.
|
|
138
|
+
*/
|
|
85
139
|
export function steerAttempts(records, grantId) {
|
|
86
|
-
return records.filter((r) =>
|
|
87
|
-
r.refs.includes(grantId)
|
|
88
|
-
|
|
89
|
-
r.
|
|
140
|
+
return records.filter((r) => {
|
|
141
|
+
if (r.kind !== "session_steer_request" || !r.refs.includes(grantId))
|
|
142
|
+
return false;
|
|
143
|
+
if (r.approval_state !== "approved" && r.approval_state !== "not_required")
|
|
144
|
+
return false;
|
|
145
|
+
const receipt = r.completion_receipt;
|
|
146
|
+
if (!receipt)
|
|
147
|
+
return false;
|
|
148
|
+
if (typeof receipt.typed === "boolean")
|
|
149
|
+
return receipt.typed;
|
|
150
|
+
const outcome = String(receipt.steer_status ?? receipt.outcome ?? "");
|
|
151
|
+
return outcome === "accepted" || outcome === "submitted";
|
|
152
|
+
}).length;
|
|
90
153
|
}
|
|
91
154
|
const targetName = (session, pane) => `${session}:${pane.window}.${pane.pane}`;
|
|
92
155
|
export function isBusy(capture, patterns = BUSY_PATTERNS) {
|
|
@@ -130,7 +193,8 @@ export async function steerSession(deps, args) {
|
|
|
130
193
|
const grant = steeringGrantOf(grantRecord);
|
|
131
194
|
const instruction = String(body.instruction ?? "");
|
|
132
195
|
const grantSession = String(grantRecord.body.session ?? "") || undefined;
|
|
133
|
-
const session = body.session ?? grantSession;
|
|
196
|
+
const session = body.session ?? args.binding?.session ?? grantSession;
|
|
197
|
+
let typed = false;
|
|
134
198
|
const finish = (status, message, extra = {}, target) => {
|
|
135
199
|
const artifact = persistArtifact(deps.stateDir, record.id, {
|
|
136
200
|
record: record.id,
|
|
@@ -139,18 +203,23 @@ export async function steerSession(deps, args) {
|
|
|
139
203
|
session,
|
|
140
204
|
status,
|
|
141
205
|
message,
|
|
206
|
+
typed,
|
|
207
|
+
...(args.binding ? { binding: args.binding } : {}),
|
|
142
208
|
at: now().toISOString(),
|
|
143
209
|
...extra,
|
|
144
210
|
});
|
|
145
|
-
return { status, message, ...(target ? { target } : {}), artifact };
|
|
211
|
+
return { status, message, typed, ...(target ? { target } : {}), artifact };
|
|
146
212
|
};
|
|
147
213
|
if (!instruction.trim())
|
|
148
|
-
return { status: "blocked", message: "The steer carries no instruction." };
|
|
214
|
+
return { status: "blocked", message: "The steer carries no instruction.", typed: false };
|
|
149
215
|
if (!session)
|
|
150
216
|
return finish("stale_binding", "Neither the steer nor its grant names a session; a steer never targets whichever pane is active.");
|
|
151
217
|
if (body.session && grantSession && body.session !== grantSession) {
|
|
152
218
|
return finish("stale_binding", `The steer names session "${body.session}" but its grant covers "${grantSession}".`);
|
|
153
219
|
}
|
|
220
|
+
if (args.binding && session !== args.binding.session) {
|
|
221
|
+
return finish("stale_binding", `The steer names session "${session}" but the factory delivered this mission to "${args.binding.session}"; nothing was typed.`);
|
|
222
|
+
}
|
|
154
223
|
// A person's approval of this exact steer covers one attempt even when the
|
|
155
224
|
// run's grant is exhausted; without it, the grant's own limits decide.
|
|
156
225
|
if (!args.authorized) {
|
|
@@ -171,12 +240,21 @@ export async function steerSession(deps, args) {
|
|
|
171
240
|
if (!panes.length)
|
|
172
241
|
return finish("stale_binding", `Session "${session}" has no panes; the binding is stale.`, {}, session);
|
|
173
242
|
const wanted = body.pane ?? grant?.pane;
|
|
243
|
+
const boundId = args.binding?.pane_id;
|
|
174
244
|
let pane;
|
|
175
245
|
if (wanted) {
|
|
176
246
|
const [w, p] = wanted.split(".").map(Number);
|
|
177
247
|
pane = panes.find((x) => x.window === w && x.pane === p);
|
|
178
248
|
if (!pane)
|
|
179
249
|
return finish("stale_binding", `Pane ${wanted} of "${session}" is gone (stale or renumbered); nothing was typed anywhere else.`, {}, session);
|
|
250
|
+
if (boundId && pane.id !== boundId) {
|
|
251
|
+
return finish("wrong_pane", `Pane ${wanted} of "${session}" is ${pane.id}, not ${boundId}, the pane the factory bound to this mission; nothing was typed.`, { pane: { ...pane } }, targetName(session, pane));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else if (boundId) {
|
|
255
|
+
pane = panes.find((x) => x.id === boundId);
|
|
256
|
+
if (!pane)
|
|
257
|
+
return finish("stale_binding", `The pane the factory bound to this mission, ${boundId}, is no longer in "${session}" (the seat was replaced or restored); nothing was typed.`, {}, session);
|
|
180
258
|
}
|
|
181
259
|
else {
|
|
182
260
|
pane = panes[0]; // listPanes is sorted: lowest window, lowest pane (jgwill/Miadi#725).
|
|
@@ -195,8 +273,26 @@ export async function steerSession(deps, args) {
|
|
|
195
273
|
if (isBusy(before)) {
|
|
196
274
|
return finish("busy", `The agent in ${target} is mid-turn; the steer was not typed. Ask again when it is idle.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
197
275
|
}
|
|
198
|
-
|
|
199
|
-
|
|
276
|
+
const composing = composingText(before);
|
|
277
|
+
if (composing === null) {
|
|
278
|
+
return finish("blocked", `The prompt box of ${target} (${pane.command}) cannot be read, so whether someone is writing there cannot be told. Nothing was typed.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
279
|
+
}
|
|
280
|
+
if (composing) {
|
|
281
|
+
return finish("composing", `Text already sits at the prompt in ${target}; someone may be writing. Nothing was typed and Enter was not pressed.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
deps.tmux.sendLiteral(target, instruction);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
return finish("blocked", `tmux refused the text for ${target} (${error instanceof Error ? error.message : String(error)}); nothing was typed.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
288
|
+
}
|
|
289
|
+
typed = true;
|
|
290
|
+
try {
|
|
291
|
+
deps.tmux.sendEnter(target);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
return finish("submitted", `Typed into ${target}, but Enter failed (${error instanceof Error ? error.message : String(error)}); the text may sit at the prompt.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
295
|
+
}
|
|
200
296
|
await sleep(deps.settleMs ?? 4000);
|
|
201
297
|
let after;
|
|
202
298
|
try {
|
package/dist/tools.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type DecisionTokens } from "./decisions.js";
|
|
|
10
10
|
import { type DiscoveryProbe, type ProviderId } from "./providers.js";
|
|
11
11
|
import { type JevAdapter } from "./model-preflight.js";
|
|
12
12
|
import { type TmuxLike } from "./steer.js";
|
|
13
|
+
import { type GithubGet } from "./mission-result.js";
|
|
13
14
|
export interface ToolContext {
|
|
14
15
|
config: ConnectorConfig;
|
|
15
16
|
miadi: MiadiClient;
|
|
@@ -39,6 +40,14 @@ export interface ToolContext {
|
|
|
39
40
|
* ever reported as not found.
|
|
40
41
|
*/
|
|
41
42
|
export declare function githubAccess(status: number, hasToken: boolean): string;
|
|
43
|
+
/** A GitHub REST read as the connector can make it: GITHUB_TOKEN when the host has one, else anonymous. */
|
|
44
|
+
export declare function githubGetter(fetchImpl?: typeof fetch, token?: string | undefined): GithubGet;
|
|
45
|
+
/** Whether the steering transport works now: registered is not operational (jgwill/Miadi#909). */
|
|
46
|
+
export declare function steeringTransport(tmux: TmuxLike | undefined): {
|
|
47
|
+
tool: "registered";
|
|
48
|
+
transport: "operational" | "unavailable";
|
|
49
|
+
reason?: string;
|
|
50
|
+
};
|
|
42
51
|
export declare function registerTools(server: McpServer, ctx: ToolContext): void;
|
|
43
52
|
/** What get_capabilities reports for each tool. */
|
|
44
53
|
export declare const TOOL_STATUS: Record<string, string>;
|