@miadi/connector 0.1.15 → 0.1.17

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.
@@ -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,101 @@ 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
- const live = deps.tmuxSessions ? new Set(deps.tmuxSessions()) : undefined;
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
+ // Miadi's door answers { session: {...}, read_at }; the session itself holds live (jgwill/Miadi#921 F1).
254
+ const seat = miadi && typeof miadi === "object" ? (miadi.session && typeof miadi.session === "object" ? miadi.session : miadi) : undefined;
255
+ const reg = seat && seat.live && typeof seat.live === "object" ? seat.live : undefined;
256
+ let binding;
257
+ let binding_reason;
258
+ let binding_compared;
259
+ if (!rc.session_id) {
260
+ binding = "receipt_only";
261
+ binding_reason = "The runner recorded no session id for this delivery.";
262
+ }
263
+ else if (!miadi || miadi.state === "unavailable" || miadi.error) {
264
+ binding = "receipt_only";
265
+ binding_reason = "Miadi's session registry could not be read; only the runner's receipt binds this session.";
266
+ }
267
+ else if (!reg) {
268
+ binding = "receipt_only";
269
+ binding_reason = "Miadi's session registry knows the session but shows no live seat for it now.";
270
+ }
271
+ else if (str(reg.tmux_session) === rc.tmux_session && (!rc.pane_id || !str(reg.pane_id) || str(reg.pane_id) === rc.pane_id)) {
272
+ binding = "verified";
273
+ binding_compared = ["session_id", "tmux_session", ...(rc.pane_id && str(reg.pane_id) ? ["pane_id"] : [])];
274
+ binding_reason = `The factory receipt and Miadi's session registry agree on ${binding_compared.join(", ")}.`;
275
+ }
276
+ else {
277
+ binding = "unverified_association";
278
+ 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, " ");
279
+ }
280
+ const inTmux = live ? live.has(rc.tmux_session) : undefined;
281
+ const paneThere = inTmux && rc.pane_id && deps.panePresent ? deps.panePresent(rc.tmux_session, rc.pane_id) : undefined;
282
+ const liveState = live ? (inTmux && paneThere !== false ? "running" : "absent") : reg ? "running" : "unknown";
283
+ if (inTmux && paneThere === false) {
284
+ binding_reason += ` Pane ${rc.pane_id} is no longer in ${rc.tmux_session}; the seat was replaced or restored.`;
285
+ if (binding === "verified")
286
+ binding = "unverified_association";
287
+ }
288
+ const regStatus = str(reg?.status);
289
+ const activity = binding === "verified" && (regStatus === "busy" || regStatus === "idle")
290
+ ? regStatus
291
+ : liveState === "running" && deps.paneBusy
292
+ ? deps.paneBusy(rc.tmux_session, rc.pane_id)
293
+ : undefined;
294
+ sessions.push({
295
+ association: "factory_receipt",
296
+ session: rc.tmux_session,
297
+ ...(rc.session_id ? { session_id: rc.session_id } : {}),
298
+ ...(rc.pane_id ? { pane_id: rc.pane_id } : {}),
299
+ delivered_at: rc.at,
300
+ live: liveState,
301
+ ...(activity ? { activity, activity_source: binding === "verified" && regStatus ? "miadi_registry" : "pane_peek" } : {}),
302
+ ...(activity && binding === "verified" && str(reg?.updated_at) ? { activity_at: str(reg.updated_at) } : {}),
303
+ binding,
304
+ binding_reason,
305
+ ...(binding_compared ? { binding_compared } : {}),
306
+ ...(miadi ? { miadi } : {}),
307
+ });
308
+ if (!provider) {
309
+ const b = record.body;
310
+ const requested = str(b.provider) || null;
311
+ const resolved = str(b.resolved_provider) || null;
312
+ const actual = rc.provider || str(seat?.agent) || null;
313
+ provider = {
314
+ requested,
315
+ resolved,
316
+ actual,
317
+ ...(actual ? { actual_source: rc.provider ? "factory_receipt" : "miadi_registry" } : {}),
318
+ match: !actual || !(resolved ?? requested) ? "unknown" : actual === (resolved ?? requested),
319
+ };
320
+ if (provider.match === false) {
321
+ notes.push(`The mission asked for ${requested ?? "(none)"}${resolved && resolved !== requested ? ` (resolved ${resolved})` : ""}; the factory ran ${actual}. Nothing was re-run because of it.`);
322
+ }
323
+ }
324
+ }
205
325
  const addSession = (session, association) => {
206
326
  if (seen.has(session))
207
327
  return;
@@ -210,7 +330,7 @@ export async function missionStatus(ref, records, deps) {
210
330
  association,
211
331
  session,
212
332
  live: live ? (live.has(session) ? "running" : "absent") : "unknown",
213
- ...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session) } : {}),
333
+ ...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session), activity_source: "pane_peek" } : {}),
214
334
  });
215
335
  };
216
336
  if (typeof briefTo === "string" && briefTo)
@@ -220,12 +340,31 @@ export async function missionStatus(ref, records, deps) {
220
340
  for (const r of record.refs) {
221
341
  const m = SESSION_REF.exec(r);
222
342
  if (m) {
223
- const miadi = deps.sessionOf ? await deps.sessionOf(m[1]).catch(() => ({ state: "unavailable" })) : undefined;
224
- sessions.push({ association: "signed_ref", session: r, live: "unknown", ...(miadi ? { miadi } : {}) });
343
+ if (seenIds.has(m[1]))
344
+ continue; // the receipt already binds it
345
+ const miadi = await view(m[1]);
346
+ sessions.push({ association: "signed_ref", session: r, session_id: m[1], live: "unknown", ...(miadi ? { miadi } : {}) });
225
347
  }
226
348
  }
227
349
  if (!live)
228
350
  notes.push("Live session state was not joined (the viewer's tier or the host does not allow it).");
351
+ if (deps.factoryReceiptsOf && !receipts.length && record.approval_state === "approved") {
352
+ notes.push("The factory has no delivery receipt for this mission; its session is known only by name.");
353
+ }
354
+ // The sessions a result must come from: the runner's receipts (for every tier), else the session refs the record carries.
355
+ const boundIds = new Set([...seenIds, ...(deps.boundSessionIdsOf?.(record.id) ?? [])]);
356
+ if (!boundIds.size)
357
+ for (const r of record.refs) {
358
+ const m = SESSION_REF.exec(r);
359
+ if (m)
360
+ boundIds.add(m[1]);
361
+ }
362
+ const evidence = deps.trustedRecords ?? records;
363
+ const operator = deps.operator ?? true;
364
+ // The closure set is built from the record as stored, not as this viewer is shown it (#922 R15).
365
+ const stored = evidence.find((r) => r.id === record.id) ?? record;
366
+ const storedTo = str((stored.completion_receipt ?? {}).to).replace(/^new:/, "");
367
+ const storedNamed = str(stored.body.session);
229
368
  const issues = [];
230
369
  if (deps.issueOf) {
231
370
  for (const r of [...new Set([...record.refs, ...record.evidence_refs])]) {
@@ -255,8 +394,16 @@ export async function missionStatus(ref, records, deps) {
255
394
  const prs = issues.filter((i) => i.merged !== undefined);
256
395
  const unverifiedRefs = issues.some((i) => i.state === "unverified");
257
396
  const release = releaseTruth(record, records, deps, now);
258
- const missionSessions = new Set(sessions.map((x) => x.session.replace(/^session:/, "")));
259
- const closure = closureTruth(record, records, missionSessions);
397
+ const missionSessions = new Set([
398
+ ...sessions.map((x) => x.session.replace(/^session:/, "")),
399
+ ...boundIds,
400
+ ...(storedTo ? [storedTo] : []),
401
+ ...(storedNamed ? [storedNamed] : []),
402
+ ]);
403
+ const closureFound = closureTruth(record, evidence, missionSessions);
404
+ const closure = closureFound && !operator ? (({ session_id: _s, ...rest }) => rest)(closureFound) : closureFound;
405
+ const checked = await missionResult(record, evidence, boundIds, { githubGet: deps.githubGet, approvalUrl: deps.approvalUrl, now: () => now });
406
+ const result = operator ? checked : redactResult(checked);
260
407
  const choice = standing?.decision?.choice ?? (str(receipt.decision) || undefined);
261
408
  const instructions = standing?.decision?.instructions ?? (str(receipt.instructions) || undefined);
262
409
  const stageTruth = {
@@ -274,9 +421,18 @@ export async function missionStatus(ref, records, deps) {
274
421
  pr_merged: unverifiedRefs ? "unverified" : prs.length ? (prs.every((p) => p.merged) ? "merged" : "open_or_absent") : "no_pr",
275
422
  released: release.released,
276
423
  ...(release.release_evidence ? { release_evidence: release.release_evidence } : {}),
424
+ result_submitted: result.state === "submitted",
425
+ result_verified: result.verified,
426
+ completion: result.completion,
277
427
  closed: Boolean(closure),
278
428
  ...(closure ? { closure_evidence: closure } : {}),
279
429
  };
430
+ if (result.state === "submitted" && result.verified && result.completion === "pending_approval") {
431
+ notes.push("The agent submitted its result; its ending waits for a person's decision. Submitted is not accepted, and accepted is not closed.");
432
+ }
433
+ if (stageTruth.running === "evidence" && result.state === "no_result_submitted") {
434
+ notes.push("A live seat, busy or idle, is not a result: nothing names this mission as its submitted result yet.");
435
+ }
280
436
  if (stageTruth.running === "no_evidence" && record.approval_state === "approved") {
281
437
  notes.push("No bound session shows live; an idle or absent pane is not proof of completion or failure.");
282
438
  }
@@ -307,10 +463,57 @@ export async function missionStatus(ref, records, deps) {
307
463
  sessions,
308
464
  issues,
309
465
  steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
466
+ ...(provider ? { provider } : {}),
310
467
  stage_truth: stageTruth,
468
+ mission_stage: missionStage(standing ?? null, stageTruth),
469
+ pending_human_gates: pendingGates(record, records, result, deps.approvalUrl),
470
+ result,
311
471
  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)}`,
472
+ ? nextAction(record, standing ?? null, queue, result)
473
+ : `Not authorizing (${found.contested ? "contested" : "inferred"}): call mission_status with ${record.id} before acting. ${nextAction(record, standing ?? null, queue, result)}`,
314
474
  notes,
315
475
  };
316
476
  }
477
+ /** The furthest stage the evidence reaches. Each stage keeps its own truth in stage_truth. */
478
+ export function missionStage(standing, t) {
479
+ if (standing && ["awaiting_decision", "recording", "sent_back", "declined"].includes(standing.stage))
480
+ return standing.stage;
481
+ if (t.closed)
482
+ return "closed";
483
+ if (t.released === "verified" || t.released === "receipt_recorded")
484
+ return "released";
485
+ if (t.pr_merged === "merged")
486
+ return "merged";
487
+ if (t.completion === "accepted")
488
+ return "completion_accepted";
489
+ if (t.result_submitted && !t.result_verified)
490
+ return "result_submitted_unverified";
491
+ if (t.result_submitted) {
492
+ if (t.completion === "declined")
493
+ return "result_submitted_completion_declined";
494
+ if (t.completion === "revision_requested")
495
+ return "result_submitted_revision_requested";
496
+ return "result_submitted_awaiting_completion_approval";
497
+ }
498
+ if (t.running === "evidence")
499
+ return "running";
500
+ if (t.delivered)
501
+ return "delivered";
502
+ if (t.queued)
503
+ return "queued";
504
+ return t.approved ? "approved" : (standing?.stage ?? "recorded");
505
+ }
506
+ function pendingGates(record, records, result, approvalUrl) {
507
+ const gates = [];
508
+ const url = (id) => (approvalUrl ? { approval_url: approvalUrl(id) } : {});
509
+ if (record.approval_state === "awaiting_approval")
510
+ gates.push({ gate: "run_approval", record: record.id, ...url(record.id) });
511
+ if (result.current && result.current.decision === "pending_approval") {
512
+ gates.push({ gate: result.verified ? "completion_approval" : "unverified_completion_approval", record: result.current.record, ...url(result.current.record) });
513
+ }
514
+ for (const r of records) {
515
+ if (r.kind === "session_steer_request" && r.approval_state === "awaiting_approval" && r.refs.includes(record.id))
516
+ gates.push({ gate: "steer_approval", record: r.id, ...url(r.id) });
517
+ }
518
+ return gates;
519
+ }
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
- /** Steers already run under a grant: decided (or grant-covered) steer records that cite it. */
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
- export const BUSY_PATTERNS = [/esc (to )?interrupt/i, /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/, /\bWorking\b/, /\bThinking\b/];
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
- /** Steers already run under a grant: decided (or grant-covered) steer records that cite it. */
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) => r.kind === "session_steer_request" &&
87
- r.refs.includes(grantId) &&
88
- (r.approval_state === "approved" || r.approval_state === "not_required") &&
89
- r.completion_receipt !== undefined).length;
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
- deps.tmux.sendLiteral(target, instruction);
199
- deps.tmux.sendEnter(target);
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>;