@miadi/connector 0.1.19 → 0.1.21

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.
Files changed (43) hide show
  1. package/README.md +40 -5
  2. package/dist/approve.d.ts +16 -2
  3. package/dist/approve.js +185 -16
  4. package/dist/bin.js +3 -0
  5. package/dist/mission-status.d.ts +13 -1
  6. package/dist/mission-status.js +13 -5
  7. package/dist/model-preflight.d.ts +9 -2
  8. package/dist/model-preflight.js +23 -1
  9. package/dist/provider-policy.d.ts +94 -0
  10. package/dist/provider-policy.js +150 -0
  11. package/dist/providers.d.ts +7 -3
  12. package/dist/providers.js +13 -7
  13. package/dist/queue-state.js +12 -1
  14. package/dist/reading/adapters.d.ts +18 -0
  15. package/dist/reading/adapters.js +90 -0
  16. package/dist/reading/contract.d.ts +153 -0
  17. package/dist/reading/contract.js +79 -0
  18. package/dist/reading/markdown.d.ts +47 -0
  19. package/dist/reading/markdown.js +52 -0
  20. package/dist/reading/privacy.d.ts +7 -0
  21. package/dist/reading/privacy.js +14 -0
  22. package/dist/reading/workflow.d.ts +93 -0
  23. package/dist/reading/workflow.js +325 -0
  24. package/dist/records.d.ts +1 -1
  25. package/dist/records.js +6 -0
  26. package/dist/request-state.js +9 -1
  27. package/dist/research-digest.d.ts +30 -0
  28. package/dist/research-digest.js +104 -0
  29. package/dist/research-shelf.d.ts +173 -0
  30. package/dist/research-shelf.js +297 -0
  31. package/dist/runtime-steer.d.ts +182 -0
  32. package/dist/runtime-steer.js +543 -0
  33. package/dist/server.d.ts +14 -0
  34. package/dist/server.js +4 -12
  35. package/dist/steer.d.ts +21 -7
  36. package/dist/steer.js +115 -10
  37. package/dist/store.d.ts +2 -0
  38. package/dist/store.js +18 -1
  39. package/dist/tools.d.ts +29 -1
  40. package/dist/tools.js +435 -50
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +13 -1
@@ -0,0 +1,543 @@
1
+ /**
2
+ * The connector's half of the terminal-agent steering contract (jgwill/Miadi#930).
3
+ *
4
+ * The connector admits a correction and hands it to the bound session's own inbox. It never
5
+ * watches tools, never waits for idle and never decides when the model reads it: that is the
6
+ * runtime's. For Claude Code the runtime half is the hook adapter in the kit plugin
7
+ * miadi-session-observability (hooks/steering_inbox.mjs), which holds the file contract:
8
+ * <root>/<session_id>/{adapter.json,pending,claimed,expired,withdrawn,rejected,revoked,receipts.jsonl}.
9
+ *
10
+ * Other providers have no adapter here, so they answer unsupported with the exact reason. Claude
11
+ * Code's hook semantics are never assumed of Copilot, Pi or Codex.
12
+ *
13
+ * Delivery is read from its own evidence: the connector's write, the runtime hook's receipt and
14
+ * the transcript record Claude Code itself wrote. A pane echo or a spinner is none of these.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { appendFileSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { factoryDir } from "./queue-state.js";
21
+ export const STEER_PROTOCOL = 1;
22
+ /** A correction no continuation consumed in a day is stale: the work has moved on. A grant's own expiry comes sooner when it has one. */
23
+ export const ENVELOPE_TTL_MS = 24 * 60 * 60 * 1000;
24
+ export const CLAUDE_ADAPTER = "miadi-session-observability 0.1.34 or later";
25
+ const INBOX_DIRS = ["pending", "claimed", "expired", "withdrawn", "rejected"];
26
+ /** How much transcript one read scans after the steer was queued. */
27
+ const MAX_TRANSCRIPT_SCAN = 32 * 1024 * 1024;
28
+ const MAX_NEXT_ACTIONS = 5;
29
+ export const DELIVERY_STATES = [
30
+ "authorized",
31
+ "forwarded_to_runtime",
32
+ "queued_at_runtime",
33
+ "delivered_to_agent_context",
34
+ "acknowledged",
35
+ "behavior_adjusted",
36
+ "unsupported",
37
+ "expired",
38
+ "blocked",
39
+ "unknown",
40
+ ];
41
+ export function steeringRoot(env = process.env) {
42
+ return env.MIADI_STEERING_DIR || join(factoryDir(env), "steering");
43
+ }
44
+ export function defaultRuntime(env = process.env) {
45
+ return { root: steeringRoot(env), claudeDir: env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), ".claude") };
46
+ }
47
+ const safe = (id) => id.replace(/[^\w-]/g, "-");
48
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
49
+ const statOrNull = (path) => {
50
+ try {
51
+ return lstatSync(path);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ };
57
+ const readJson = (path) => {
58
+ try {
59
+ return JSON.parse(readFileSync(path, "utf8"));
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ };
65
+ /** The token the agent names back in its own text: the same rule as the adapter's ackToken. */
66
+ export const ackToken = (steerId) => `steer ${safe(steerId).replace(/^session_steer_request-/, "").slice(0, 8)}`;
67
+ /** Claude Code's own record of a running session, `<claudeDir>/sessions/<pid>.json`, if its process lives. */
68
+ export function readClaudeSession(deps, sessionId) {
69
+ const dir = join(deps.claudeDir, "sessions");
70
+ const alive = deps.isAlive ??
71
+ ((pid) => {
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ });
80
+ let names;
81
+ try {
82
+ names = readdirSync(dir).filter((n) => /^\d+\.json$/.test(n));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ for (const name of names) {
88
+ const r = readJson(join(dir, name));
89
+ if (r?.sessionId === sessionId && typeof r.pid === "number" && alive(r.pid))
90
+ return r;
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Mission → session → runtime, from what each one signed: the runner's receipt names the session,
96
+ * Claude Code's registry says it runs and in which pane, and the adapter's file says this session
97
+ * loaded its hooks. The inbox belongs to this Unix account alone or it is not used.
98
+ */
99
+ export function resolveRuntime(deps, binding) {
100
+ const { session_id, pane_id, provider } = binding;
101
+ if (provider && provider !== "claude") {
102
+ return { route: "none", code: "unsupported", reason: `No steering adapter is installed for provider "${provider}". Claude Code's hooks are not assumed of it.` };
103
+ }
104
+ if (!session_id)
105
+ return { route: "none", code: "unsupported", reason: "The mission's binding names no session id, so no exact session's inbox can be addressed." };
106
+ const record = readClaudeSession(deps, session_id);
107
+ if (!record)
108
+ return { route: "none", code: "stale_binding", reason: `Claude Code session ${session_id} is not running on this host.` };
109
+ const seatPane = record.tmux?.match(/%\d+$/)?.[0];
110
+ if (pane_id && seatPane && seatPane !== pane_id) {
111
+ return { route: "none", code: "wrong_pane", reason: `Claude Code session ${session_id} is in pane ${seatPane}, not ${pane_id}, the pane its mission is bound to.` };
112
+ }
113
+ const dir = join(deps.root, safe(session_id));
114
+ const st = statOrNull(dir);
115
+ const adapter = st?.isDirectory() && st.uid === process.getuid?.() && (st.mode & 0o022) === 0 ? readJson(join(dir, "adapter.json")) : null;
116
+ if (!adapter || adapter.protocol !== STEER_PROTOCOL || adapter.session_id !== session_id) {
117
+ return {
118
+ route: "none",
119
+ code: "unsupported",
120
+ reason: `Claude Code session ${session_id} has no steering adapter loaded. Hooks load at session start, so a session started before ${CLAUDE_ADAPTER} cannot receive a correction mid-turn.`,
121
+ };
122
+ }
123
+ return { route: "claude_hook_inbox", session_id, seat: record.status ?? "unknown", ...(typeof adapter.transcript_path === "string" ? { transcript_path: adapter.transcript_path } : {}) };
124
+ }
125
+ function findEnvelope(root, sessionId, steer) {
126
+ const dir = join(root, safe(sessionId));
127
+ const suffix = `-${safe(steer)}.json`;
128
+ for (const state of INBOX_DIRS) {
129
+ let names;
130
+ try {
131
+ names = readdirSync(join(dir, state));
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ const name = names.find((n) => n.endsWith(suffix));
137
+ if (name)
138
+ return { state, file: join(dir, state, name), envelope: readJson(join(dir, state, name)) };
139
+ }
140
+ return null;
141
+ }
142
+ /** The steer already on its way with these same words under this grant: one correction, one delivery. */
143
+ export function sameInstructionInFlight(deps, sessionId, grant, instruction) {
144
+ const dir = join(deps.root, safe(sessionId));
145
+ const hash = sha256(instruction);
146
+ for (const state of ["pending", "claimed"]) {
147
+ let names;
148
+ try {
149
+ names = readdirSync(join(dir, state));
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ for (const name of names) {
155
+ const e = readJson(join(dir, state, name));
156
+ if (e?.grant === grant && e.instruction_sha256 === hash)
157
+ return e.steer;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ /**
163
+ * Put one correction in the session's inbox, durably: fsync, then one rename into pending/, so the
164
+ * adapter never reads half a file. The steer's id keeps it single across a retry or a connector restart.
165
+ */
166
+ export function enqueueSteer(deps, args) {
167
+ const known = findEnvelope(deps.root, args.session_id, args.steer);
168
+ if (known?.envelope)
169
+ return { already: true, file: known.file, envelope: known.envelope };
170
+ const now = args.now ?? new Date();
171
+ const dir = join(deps.root, safe(args.session_id));
172
+ const pending = join(dir, "pending");
173
+ mkdirSync(pending, { recursive: true, mode: 0o700 });
174
+ const ttl = now.getTime() + ENVELOPE_TTL_MS;
175
+ const grantEnd = args.grant_expires_at ? Date.parse(args.grant_expires_at) : NaN;
176
+ const envelope = {
177
+ protocol: STEER_PROTOCOL,
178
+ steer: args.steer,
179
+ grant: args.grant,
180
+ session_id: args.session_id,
181
+ instruction: args.instruction,
182
+ instruction_sha256: sha256(args.instruction),
183
+ from: args.from,
184
+ queued_at: now.toISOString(),
185
+ expires_at: new Date(Number.isNaN(grantEnd) ? ttl : Math.min(ttl, grantEnd)).toISOString(),
186
+ ...(args.decided ? { decided: true } : {}),
187
+ ...(args.transcript_path ? { transcript_path: args.transcript_path, transcript_offset: transcriptSize(args.transcript_path) } : {}),
188
+ };
189
+ // Ordered by name: never before an envelope already waiting.
190
+ const last = readdirSync(pending).filter((n) => n.endsWith(".json")).sort().at(-1);
191
+ const order = Math.max(now.getTime(), last ? Number(last.split("-")[0]) + 1 : 0);
192
+ const name = `${String(order).padStart(15, "0")}-${safe(args.steer)}.json`;
193
+ const tmp = join(dir, `.tmp-${safe(args.steer)}`);
194
+ const fd = openSync(tmp, "w", 0o600);
195
+ try {
196
+ writeSync(fd, JSON.stringify(envelope));
197
+ fsyncSync(fd);
198
+ }
199
+ finally {
200
+ closeSync(fd);
201
+ }
202
+ renameSync(tmp, join(pending, name));
203
+ return { already: false, file: join(pending, name), envelope };
204
+ }
205
+ function transcriptSize(path) {
206
+ try {
207
+ return statSync(path).size;
208
+ }
209
+ catch {
210
+ return 0;
211
+ }
212
+ }
213
+ /**
214
+ * Revoke a grant or one steer for this session. The marker stops whatever the adapter would consume
215
+ * next, and what is still pending moves out at once. A rename decides any race with the adapter,
216
+ * so the answer is exact: revoked here, or already consumed.
217
+ */
218
+ export function revokeInInbox(deps, sessionId, target, reason) {
219
+ const dir = join(deps.root, safe(sessionId));
220
+ const out = { revoked: [], already_consumed: [] };
221
+ if (!statOrNull(dir))
222
+ return out;
223
+ mkdirSync(join(dir, "revoked"), { recursive: true, mode: 0o700 });
224
+ writeFileSync(join(dir, "revoked", safe(target.steer ?? target.grant)), `${JSON.stringify({ reason, at: new Date().toISOString() })}\n`, { mode: 0o600 });
225
+ const pending = join(dir, "pending");
226
+ mkdirSync(join(dir, "withdrawn"), { recursive: true, mode: 0o700 });
227
+ let names = [];
228
+ try {
229
+ names = readdirSync(pending);
230
+ }
231
+ catch {
232
+ // Nothing queued.
233
+ }
234
+ for (const name of names) {
235
+ const e = readJson(join(pending, name));
236
+ if (!e || e.grant !== target.grant || (target.steer && e.steer !== target.steer))
237
+ continue;
238
+ try {
239
+ renameSync(join(pending, name), join(dir, "withdrawn", name));
240
+ appendFileSync(join(dir, "receipts.jsonl"), `${JSON.stringify({ at: new Date().toISOString(), source: "connector", steer: e.steer, state: "revoked", reason })}\n`, { mode: 0o600 });
241
+ out.revoked.push(e.steer);
242
+ }
243
+ catch {
244
+ out.already_consumed.push(e.steer);
245
+ }
246
+ }
247
+ // What the agent's runtime already took cannot be stopped, and the answer says which.
248
+ let taken = [];
249
+ try {
250
+ taken = readdirSync(join(dir, "claimed"));
251
+ }
252
+ catch {
253
+ // Nothing consumed yet.
254
+ }
255
+ for (const name of taken) {
256
+ const e = readJson(join(dir, "claimed", name));
257
+ if (!e || e.grant !== target.grant || (target.steer && e.steer !== target.steer) || out.already_consumed.includes(e.steer))
258
+ continue;
259
+ out.already_consumed.push(e.steer);
260
+ }
261
+ return out;
262
+ }
263
+ function readTail(path, offset) {
264
+ let fd;
265
+ try {
266
+ fd = openSync(path, "r");
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ try {
272
+ const size = statSync(path).size;
273
+ const start = Math.max(Math.min(offset, size), size - MAX_TRANSCRIPT_SCAN);
274
+ const buffer = Buffer.alloc(size - start);
275
+ readSync(fd, buffer, 0, buffer.length, start);
276
+ return buffer.toString("utf8");
277
+ }
278
+ finally {
279
+ closeSync(fd);
280
+ }
281
+ }
282
+ const textOf = (content) => {
283
+ if (typeof content === "string")
284
+ return content;
285
+ if (!Array.isArray(content))
286
+ return "";
287
+ return content.map((b) => (b && typeof b === "object" && b.type === "text" ? String(b.text ?? "") : "")).join("\n");
288
+ };
289
+ /**
290
+ * What Claude Code itself recorded about this steer. Only an attachment naming the steer's id shows
291
+ * it entered the model's context, and only the agent's own later text can acknowledge it. Tool
292
+ * results and the agent's quotes of the id do not count as delivery.
293
+ */
294
+ export function readTranscriptEvidence(path, offset, steer) {
295
+ const out = { next_actions: [], actions_seen: 0, raw_inputs: [] };
296
+ const text = readTail(path, offset);
297
+ if (text === null)
298
+ return out;
299
+ const token = `${ackToken(steer)} received`.toLowerCase();
300
+ for (const line of text.split("\n")) {
301
+ if (!line.trim())
302
+ continue;
303
+ if (!out.delivered && !line.includes(steer))
304
+ continue;
305
+ let d;
306
+ try {
307
+ d = JSON.parse(line);
308
+ }
309
+ catch {
310
+ continue;
311
+ }
312
+ if (!out.delivered) {
313
+ // Claude Code writes hook_success with the hook's stdout first: that shows the hook exited. These show it entered the context.
314
+ const admitted = d.type === "attachment" && ["hook_additional_context", "hook_blocking_error", "queued_command"].includes(d.attachment?.type);
315
+ // A woken model reads it as a meta user message, never a tool result that quotes the id.
316
+ const wakeMessage = d.type === "user" && d.isMeta === true && textOf(d.message?.content).includes(steer);
317
+ if (!admitted && !wakeMessage)
318
+ continue;
319
+ out.delivered = {
320
+ at: d.timestamp,
321
+ attachment: admitted ? d.attachment.type : "meta_user_message",
322
+ hook_event: admitted ? (d.attachment.hookEvent ?? "Stop") : "Stop",
323
+ ...(admitted && d.attachment.toolUseID ? { tool_use_id: d.attachment.toolUseID } : {}),
324
+ };
325
+ continue;
326
+ }
327
+ if (d.type !== "assistant")
328
+ continue;
329
+ const content = d.message?.content;
330
+ const said = textOf(content);
331
+ if (!out.acknowledged && said.toLowerCase().includes(token))
332
+ out.acknowledged = { at: d.timestamp, text: said.slice(0, 500) };
333
+ if (!Array.isArray(content))
334
+ continue;
335
+ for (const block of content) {
336
+ if (block?.type !== "tool_use")
337
+ continue;
338
+ out.actions_seen++;
339
+ const input = JSON.stringify(block.input ?? {});
340
+ if (out.next_actions.length < MAX_NEXT_ACTIONS) {
341
+ out.next_actions.push({ at: d.timestamp, tool: String(block.name), input: input.slice(0, 200) });
342
+ out.raw_inputs.push(input);
343
+ }
344
+ }
345
+ }
346
+ return out;
347
+ }
348
+ /** Whether a prompt typed into the bound pane reached the session: Claude Code's own user or queued-prompt record. A pane echo proves nothing. */
349
+ export function typedLineInTranscript(path, offset, instruction) {
350
+ const text = readTail(path, offset);
351
+ if (text === null)
352
+ return null;
353
+ const firstLine = instruction.split("\n")[0].slice(0, 80);
354
+ if (!firstLine.trim())
355
+ return null;
356
+ const needle = JSON.stringify(firstLine).slice(1, -1);
357
+ for (const line of text.split("\n")) {
358
+ if (!line.includes(needle))
359
+ continue;
360
+ try {
361
+ const d = JSON.parse(line);
362
+ if (d.type === "attachment" && d.attachment?.type === "queued_command")
363
+ return { at: d.timestamp, record: "queued_command" };
364
+ // The person's prompt, never a tool's output that quotes the words.
365
+ if (d.type === "user" && textOf(d.message?.content).includes(firstLine))
366
+ return { at: d.timestamp, record: "user" };
367
+ }
368
+ catch {
369
+ // A partial last line: the next read has it whole.
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+ /** Find a session's transcript when its adapter did not name it. */
375
+ export function transcriptPathOf(deps, sessionId) {
376
+ const adapter = readJson(join(deps.root, safe(sessionId), "adapter.json"));
377
+ if (typeof adapter?.transcript_path === "string")
378
+ return adapter.transcript_path;
379
+ const projects = join(deps.claudeDir, "projects");
380
+ try {
381
+ for (const project of readdirSync(projects)) {
382
+ const file = join(projects, project, `${sessionId}.jsonl`);
383
+ if (existsSync(file))
384
+ return file;
385
+ }
386
+ }
387
+ catch {
388
+ // No projects folder: no transcript to read.
389
+ }
390
+ return undefined;
391
+ }
392
+ /**
393
+ * Where one steer stands, each state with its source and time, for request_session_steer,
394
+ * mission_status and get_run_result. It only reads, and it can be read at any time: ChatGPT
395
+ * disconnecting or the connector restarting changes nothing here.
396
+ */
397
+ export function readSteerDelivery(deps, record, now = new Date()) {
398
+ const receipt = record.completion_receipt;
399
+ if (!receipt)
400
+ return null;
401
+ const binding = record.body.binding;
402
+ const route = String(receipt.route ?? (receipt.typed ? "tmux_exact_pane" : "none"));
403
+ const session_id = binding?.session_id;
404
+ const receipts = [];
405
+ const authorizedAt = receipt.decided_at;
406
+ receipts.push({ state: "authorized", at: authorizedAt, source: "connector", detail: { by: receipt.decided_by, decision: receipt.decision } });
407
+ const base = { route, ...(session_id ? { session_id } : {}) };
408
+ if (route === "tmux_exact_pane") {
409
+ receipts.push({ state: "seat_was_idle", at: authorizedAt, source: "connector", detail: { target: receipt.target } });
410
+ receipts.push({ state: "forwarded_to_runtime", at: authorizedAt, source: "connector", detail: { target: receipt.target, typed: receipt.typed } });
411
+ const path = session_id ? transcriptPathOf(deps, session_id) : undefined;
412
+ const landed = path ? typedLineInTranscript(path, Number(receipt.transcript_offset ?? 0), String(record.body.instruction ?? "")) : null;
413
+ if (landed) {
414
+ receipts.push({ state: "delivered_to_agent_context", at: landed.at, source: "claude_transcript", detail: { record: landed.record } });
415
+ return { ...base, state: "delivered_to_agent_context", receipts, note: "Claude Code recorded the prompt in the session's transcript." };
416
+ }
417
+ return { ...base, state: "unknown", receipts, note: "It was typed into the bound pane of an idle seat. No transcript record of it has been read, and a pane echo is not proof the model read it." };
418
+ }
419
+ if (route !== "claude_hook_inbox" || !session_id) {
420
+ const status = String(receipt.steer_status ?? "");
421
+ const state = status === "unsupported" ? "unsupported" : status === "expired_grant" ? "expired" : "blocked";
422
+ return { ...base, state, receipts, note: String(receipt.message ?? "Nothing was forwarded.") };
423
+ }
424
+ const found = findEnvelope(deps.root, session_id, record.id);
425
+ const forwardedAt = found?.envelope?.queued_at ?? authorizedAt;
426
+ receipts.push({ state: "forwarded_to_runtime", at: forwardedAt, source: "connector", detail: { route } });
427
+ if (!found)
428
+ return { ...base, state: "unknown", receipts, note: "The session's inbox no longer holds this steer, so where it went cannot be read." };
429
+ receipts.push({ state: "queued_at_runtime", at: forwardedAt, source: "runtime_inbox", detail: { file: found.file, expires_at: found.envelope?.expires_at } });
430
+ const hook = hookReceipts(deps.root, session_id, record.id);
431
+ for (const r of hook) {
432
+ if (r.state === "emitted") {
433
+ const { at, state: _state, source: _source, steer: _steer, ...detail } = r;
434
+ receipts.push({ state: "runtime_hook_emitted", at, source: "runtime_hook", detail });
435
+ }
436
+ }
437
+ const refused = hook.find((r) => r.state === "expired" || r.state === "revoked" || r.state === "blocked");
438
+ if (found.state === "pending") {
439
+ const expiresAt = found.envelope?.expires_at;
440
+ if (expiresAt && Date.parse(expiresAt) <= now.getTime()) {
441
+ receipts.push({ state: "expired", at: expiresAt, source: "connector_clock" });
442
+ return { ...base, state: "expired", receipts, note: "It expired in the inbox before any continuation consumed it. It will not reach the model." };
443
+ }
444
+ const seat = readClaudeSession(deps, session_id);
445
+ for (const r of hook.filter((x) => x.state === "woken"))
446
+ receipts.push({ state: "seat_woken", at: r.at, source: "factory_runner", detail: { pane: r.pane } });
447
+ if (!seat)
448
+ return { ...base, state: "queued_at_runtime", continuation: "session_not_running", receipts, note: `It waits in the inbox of a session that is not running. It reaches the model if that session resumes before ${expiresAt}, and otherwise expires.` };
449
+ if (seat.status === "idle")
450
+ return { ...base, state: "queued_at_runtime", continuation: "runner_wakes_idle_seat", receipts, note: "The seat went idle before a continuation carried it. The factory runner's next pass, within minutes, prompts the seat, and the correction arrives with that prompt. Nobody has to send it again." };
451
+ return { ...base, state: "queued_at_runtime", continuation: "next_tool_or_turn_end", receipts, note: "It waits in the session's inbox. The agent's next tool end, or the end of its turn, presents it. This does not yet show the model read it." };
452
+ }
453
+ if (found.state === "expired") {
454
+ receipts.push({ state: "expired", at: refused?.at, source: "runtime_hook", detail: { reason: refused?.reason } });
455
+ return { ...base, state: "expired", receipts, note: "It expired before a continuation consumed it. It never reached the model." };
456
+ }
457
+ if (found.state === "withdrawn" || found.state === "rejected") {
458
+ receipts.push({ state: "blocked", at: refused?.at, source: refused?.source === "connector" ? "connector" : "runtime_hook", detail: { reason: refused?.reason, moved_to: found.state } });
459
+ return { ...base, state: "blocked", receipts, note: `It never reached the model: ${refused?.reason ?? found.state}.` };
460
+ }
461
+ // Claimed: taken for delivery, exactly once, never again whatever happens next.
462
+ const path = found.envelope?.transcript_path;
463
+ const evidence = path ? readTranscriptEvidence(path, found.envelope?.transcript_offset ?? 0, record.id) : null;
464
+ if (!evidence?.delivered) {
465
+ const emitted = hook.some((r) => r.state === "emitted");
466
+ return {
467
+ ...base,
468
+ state: "unknown",
469
+ receipts,
470
+ note: emitted
471
+ ? "The runtime hook printed it, and the transcript does not show it entered the model's context yet."
472
+ : "The hook claimed it and left no receipt, so delivery is ambiguous. It is not sent again.",
473
+ };
474
+ }
475
+ receipts.push({ state: "delivered_to_agent_context", at: evidence.delivered.at, source: "claude_transcript", detail: { attachment: evidence.delivered.attachment, hook_event: evidence.delivered.hook_event, tool_use_id: evidence.delivered.tool_use_id } });
476
+ let state = "delivered_to_agent_context";
477
+ if (evidence.acknowledged) {
478
+ state = "acknowledged";
479
+ receipts.push({ state: "acknowledged", at: evidence.acknowledged.at, source: "claude_transcript", detail: { text: evidence.acknowledged.text } });
480
+ }
481
+ const expect = typeof record.body.expect_next_action === "string" ? record.body.expect_next_action : undefined;
482
+ let behavior = evidence.actions_seen ? "not_judged" : "not_observed_yet";
483
+ if (expect && evidence.actions_seen) {
484
+ const seen = evidence.raw_inputs.findIndex((i) => i.includes(JSON.stringify(expect).slice(1, -1)));
485
+ behavior = seen >= 0 ? "expected_action_seen" : "expected_action_not_seen";
486
+ if (seen >= 0) {
487
+ state = "behavior_adjusted";
488
+ receipts.push({ state: "behavior_adjusted", at: evidence.next_actions[seen]?.at, source: "claude_transcript", detail: { expected: expect, action: evidence.next_actions[seen] } });
489
+ }
490
+ }
491
+ return {
492
+ ...base,
493
+ state,
494
+ receipts,
495
+ next_actions: evidence.next_actions,
496
+ behavior,
497
+ note: state === "behavior_adjusted"
498
+ ? "The transcript shows the action this steer named, after it entered the model's context."
499
+ : "The transcript shows it entered the model's context. Whether the next actions follow it is a person's judgment unless the steer named expect_next_action.",
500
+ };
501
+ }
502
+ function hookReceipts(root, sessionId, steer) {
503
+ let text;
504
+ try {
505
+ text = readFileSync(join(root, safe(sessionId), "receipts.jsonl"), "utf8");
506
+ }
507
+ catch {
508
+ return [];
509
+ }
510
+ const out = [];
511
+ for (const line of text.split("\n")) {
512
+ if (!line.trim())
513
+ continue;
514
+ try {
515
+ const r = JSON.parse(line);
516
+ if (r.steer === steer)
517
+ out.push(r);
518
+ }
519
+ catch {
520
+ // A torn line is not a receipt.
521
+ }
522
+ }
523
+ return out;
524
+ }
525
+ /**
526
+ * What a forwarded steer's ledger record should now say, from its delivery: done once the model has
527
+ * it, stopped once it never will. Null while it is still on its way, or if it has already settled.
528
+ */
529
+ export function settlementOf(deps, record, now = new Date()) {
530
+ if (record.state !== "in_progress" || record.completion_receipt?.forwarded !== true)
531
+ return null;
532
+ const delivery = readSteerDelivery(deps, record, now);
533
+ if (!delivery)
534
+ return null;
535
+ const at = delivery.receipts.filter((r) => r.state === delivery.state).at(-1)?.at;
536
+ if (delivery.state === "delivered_to_agent_context" || delivery.state === "acknowledged" || delivery.state === "behavior_adjusted") {
537
+ return { state: "completed", outcome: delivery.state, message: delivery.note, ...(at ? { at } : {}) };
538
+ }
539
+ if (delivery.state === "expired" || delivery.state === "blocked") {
540
+ return { state: "blocked", outcome: delivery.state, message: delivery.note, ...(at ? { at } : {}) };
541
+ }
542
+ return null;
543
+ }
package/dist/server.d.ts CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The connector's HTTP surface:
3
+ *
4
+ * /.well-known/oauth-* OAuth discovery (MCP authorization spec)
5
+ * /authorize /token /register /revoke, /authorize/confirm sign-in
6
+ * /mcp the MCP endpoint (streamable HTTP, stateless)
7
+ * /health liveness, no data
8
+ *
9
+ * Every MCP request carries the signed-in person, and each tool call runs
10
+ * as that person (see tools.ts).
11
+ */
12
+ import type { RuntimeDeps } from "./runtime-steer.js";
1
13
  import { MiadiOAuthProvider } from "./oauth.js";
2
14
  import type { MiadiPerson } from "./people.js";
3
15
  import { Ledger } from "./store.js";
@@ -21,6 +33,8 @@ export interface ConnectorDeps {
21
33
  jevAdapter?: JevAdapter;
22
34
  /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
23
35
  steerSettleMs?: number;
36
+ /** Where sessions and their steering inboxes live (jgwill/Miadi#930). */
37
+ steeringRuntime?: RuntimeDeps;
24
38
  /** Person-token attempts allowed per address in 15 minutes on each sign-in form (default 30). */
25
39
  tokenAttemptsPerWindow?: number;
26
40
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them (jgwill/Miadi#924). */
package/dist/server.js CHANGED
@@ -1,14 +1,3 @@
1
- /**
2
- * The connector's HTTP surface:
3
- *
4
- * /.well-known/oauth-* OAuth discovery (MCP authorization spec)
5
- * /authorize /token /register /revoke, /authorize/confirm sign-in
6
- * /mcp the MCP endpoint (streamable HTTP, stateless)
7
- * /health liveness, no data
8
- *
9
- * Every MCP request carries the signed-in person, and each tool call runs
10
- * as that person (see tools.ts).
11
- */
12
1
  import express from "express";
13
2
  import { rateLimit } from "express-rate-limit";
14
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -16,7 +5,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
16
5
  import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
17
6
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
18
7
  import { MiadiOAuthProvider, SCOPES, SCOPE_CONTRIBUTE, SCOPE_READ, emptyOAuthState } from "./oauth.js";
19
- import { reconcileInterrupted, registerApprovalRoutes } from "./approve.js";
8
+ import { reconcileInterrupted, registerApprovalRoutes, registerProviderPolicyRoutes } from "./approve.js";
20
9
  import { DecisionTokens } from "./decisions.js";
21
10
  import { JsonFile, Ledger, ensureStateDir, statePaths } from "./store.js";
22
11
  import { registerTools } from "./tools.js";
@@ -87,11 +76,13 @@ export function createConnectorApp(config, deps) {
87
76
  tmuxSessions: deps.tmuxSessions,
88
77
  tmux: deps.tmux,
89
78
  steerSettleMs: deps.steerSettleMs,
79
+ ...(deps.steeringRuntime ? { steeringRuntime: deps.steeringRuntime } : {}),
90
80
  log: deps.log,
91
81
  };
92
82
  // A run the connector was recording when it last stopped is failed now, so a person may run it again (#924).
93
83
  reconcileInterrupted(approvalDeps);
94
84
  registerApprovalRoutes(app, approvalDeps);
85
+ registerProviderPolicyRoutes(app, approvalDeps);
95
86
  const decisionTokens = new DecisionTokens(config.stateDir, config.decisionTokenTtl);
96
87
  const bearer = requireBearerAuth({
97
88
  verifier: provider,
@@ -126,6 +117,7 @@ export function createConnectorApp(config, deps) {
126
117
  modelOf: deps.modelOf,
127
118
  jevAdapter: deps.jevAdapter,
128
119
  steerSettleMs: deps.steerSettleMs,
120
+ ...(deps.steeringRuntime ? { steeringRuntime: deps.steeringRuntime } : {}),
129
121
  };
130
122
  const server = new McpServer({ name: "miadi", version: VERSION }, { instructions: INSTRUCTIONS });
131
123
  registerTools(server, ctx);
package/dist/steer.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { type RuntimeDeps } from "./runtime-steer.js";
2
3
  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", "composing"];
4
+ export declare const STEER_STATUSES: readonly ["prepared", "submitted", "accepted", "busy", "blocked", "wrong_pane", "stale_binding", "expired_grant", "duplicate", "composing", "queued_at_runtime", "unsupported"];
4
5
  export type SteerStatus = (typeof STEER_STATUSES)[number];
5
6
  /** The steering grant an approved run carries: `body.steering` on the run_request or work proposal. */
6
7
  export interface SteeringGrant {
@@ -49,14 +50,16 @@ export declare function tmuxAdapter(exec?: typeof execFileSync): TmuxLike;
49
50
  */
50
51
  export declare function composingText(capture: string): string | null;
51
52
  export declare function steeringGrantOf(run: ConnectorRecord): SteeringGrant | null;
52
- export type GrantCheck = "ok" | "no_grant" | "expired_grant" | "duplicate";
53
+ export type GrantCheck = "ok" | "no_grant" | "expired_grant" | "duplicate" | "revoked";
53
54
  /** Whether a grant covers one more steer. `priorAttempts` counts the steers already run under it. */
54
- export declare function checkGrant(grant: SteeringGrant | null, priorAttempts: number, now?: Date): GrantCheck;
55
+ export declare function checkGrant(grant: SteeringGrant | null, priorAttempts: number, now?: Date, revoked?: boolean): GrantCheck;
56
+ /** Whether a person revoked this run's steering, or just this one steer when `steer` is given (jgwill/Miadi#930). */
57
+ export declare function steeringRevoked(records: ConnectorRecord[], grantId: string): boolean;
55
58
  /**
56
59
  * 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
+ * typed or forwarded their instruction (jgwill/Miadi#909, #930). A steer stopped before that
61
+ * (composing, unsupported, wrong or stale pane, transport down) uses none of the grant's attempts.
62
+ * Receipts made before `typed` was recorded count when their outcome says the text was delivered.
60
63
  */
61
64
  export declare function steerAttempts(records: ConnectorRecord[], grantId: string): number;
62
65
  export declare function isBusy(capture: string, patterns?: RegExp[]): boolean;
@@ -67,13 +70,20 @@ export interface SteerDeps {
67
70
  settleMs?: number;
68
71
  sleep?: (ms: number) => Promise<void>;
69
72
  agentCommands?: string[];
73
+ /** Where sessions and their inboxes live (jgwill/Miadi#930). Absent, no runtime route is known. */
74
+ runtime?: RuntimeDeps;
70
75
  }
71
76
  export interface SteerResult {
72
77
  status: SteerStatus;
73
78
  message: string;
74
79
  target?: string;
75
- /** Whether the instruction was typed into the pane. Only a typed steer uses one of the grant's attempts. */
80
+ /** Whether the instruction was typed into the pane. A typed or forwarded steer uses one of the grant's attempts. */
76
81
  typed: boolean;
82
+ /** Whether it was handed to the runtime's inbox. That is not proof the model read it. */
83
+ forwarded?: boolean;
84
+ route?: "claude_hook_inbox" | "tmux_exact_pane";
85
+ transcript_path?: string;
86
+ transcript_offset?: number;
77
87
  artifact?: {
78
88
  file: string;
79
89
  sha256: string;
@@ -99,5 +109,9 @@ export declare function steerSession(deps: SteerDeps, args: {
99
109
  binding?: {
100
110
  session: string;
101
111
  pane_id?: string;
112
+ session_id?: string;
113
+ provider?: string;
102
114
  };
115
+ /** A steer already sent with these same words under this grant. One correction is sent once, unless a person approves it again. */
116
+ sameInstructionSent?: string;
103
117
  }): Promise<SteerResult>;