@miadi/connector 0.1.20 → 0.1.22

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.
@@ -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";
@@ -6,6 +18,7 @@ import type { ConnectorConfig } from "./config.js";
6
18
  import type { TmuxLike } from "./steer.js";
7
19
  import type { DiscoveryProbe, ProviderId } from "./providers.js";
8
20
  import type { JevAdapter } from "./model-preflight.js";
21
+ import type { StandardSource } from "./mission-standard.js";
9
22
  export interface ConnectorDeps {
10
23
  identify: (token: string) => Promise<MiadiPerson | null>;
11
24
  fetchImpl?: typeof fetch;
@@ -19,8 +32,14 @@ export interface ConnectorDeps {
19
32
  /** A provider's configured default model and a Jev decider (jgwill/Miadi#913); tests inject fakes. */
20
33
  modelOf?: (provider: ProviderId) => string | null;
21
34
  jevAdapter?: JevAdapter;
35
+ /** The environment the draft review reads its keys from (default process.env); tests pass their own (jgwill/Miadi#939). */
36
+ draftReviewEnv?: NodeJS.ProcessEnv;
37
+ /** Where the mission standard comes from (default the prompt store, then the built-in copy); tests pass their own. */
38
+ standardSource?: StandardSource;
22
39
  /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
23
40
  steerSettleMs?: number;
41
+ /** Where sessions and their steering inboxes live (jgwill/Miadi#930). */
42
+ steeringRuntime?: RuntimeDeps;
24
43
  /** Person-token attempts allowed per address in 15 minutes on each sign-in form (default 30). */
25
44
  tokenAttemptsPerWindow?: number;
26
45
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them (jgwill/Miadi#924). */
@@ -31,4 +50,4 @@ export declare function createConnectorApp(config: ConnectorConfig, deps: Connec
31
50
  provider: MiadiOAuthProvider;
32
51
  ledger: Ledger<ConnectorRecord>;
33
52
  };
34
- export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nEvery tool is offered on every connection. A client that loads tools on demand shows only those its last tool search matched: a tool you do not see is not missing. Search for it by its exact name, get_capabilities first, before telling the person a tool is absent. The main reads are get_capabilities, get_context, search, fetch, list_records, list_sessions, get_session, review_lookup, read_episode_file and show_decisions.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.\n- A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.\n- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
53
+ export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nEvery tool is offered on every connection. A client that loads tools on demand shows only those its last tool search matched: a tool you do not see is not missing. Search for it by its exact name, get_capabilities first, before telling the person a tool is absent. The main reads are get_capabilities, get_context, search, fetch, list_records, list_sessions, get_session, review_lookup, read_episode_file and show_decisions.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.\n- A new or revised run or work proposal answers with draft_review: the aspects of the mission standard to revise, each with why, the question to settle and who can settle it. Read it before show_decisions. It carries no verdict, it advises and blocks nothing, and next_action says what to do. Answer what your conversation holds, ask the person the rest, then revise. preferences lists the person's standing preferences for you to apply. Fill delivery with the one exact place the result lands, and person_words with the person's own words, verbatim, for anything credited to them.\n- A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.\n- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
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";
@@ -22,6 +11,7 @@ import { JsonFile, Ledger, ensureStateDir, statePaths } from "./store.js";
22
11
  import { registerTools } from "./tools.js";
23
12
  import { MiadiClient } from "./miadi.js";
24
13
  import { ReviewClient } from "./reviews.js";
14
+ import { draftReviewService } from "./draft-review.js";
25
15
  import { VERSION } from "./version.js";
26
16
  export function createConnectorApp(config, deps) {
27
17
  ensureStateDir(config.stateDir);
@@ -77,8 +67,10 @@ export function createConnectorApp(config, deps) {
77
67
  app.post("/authorize/confirm", confirmLimit, express.urlencoded({ extended: false, limit: "16kb" }), (req, res, next) => {
78
68
  provider.confirm(req, res).catch(next);
79
69
  });
70
+ const draftReview = draftReviewService({ stateDir: config.stateDir, fetchImpl: deps.fetchImpl, adapter: deps.jevAdapter, env: deps.draftReviewEnv, source: deps.standardSource });
80
71
  const approvalDeps = {
81
72
  config,
73
+ draftReview,
82
74
  ledger,
83
75
  miadi,
84
76
  identify: deps.identify,
@@ -87,6 +79,7 @@ export function createConnectorApp(config, deps) {
87
79
  tmuxSessions: deps.tmuxSessions,
88
80
  tmux: deps.tmux,
89
81
  steerSettleMs: deps.steerSettleMs,
82
+ ...(deps.steeringRuntime ? { steeringRuntime: deps.steeringRuntime } : {}),
90
83
  log: deps.log,
91
84
  };
92
85
  // A run the connector was recording when it last stopped is failed now, so a person may run it again (#924).
@@ -126,7 +119,9 @@ export function createConnectorApp(config, deps) {
126
119
  providersProbe: deps.providersProbe,
127
120
  modelOf: deps.modelOf,
128
121
  jevAdapter: deps.jevAdapter,
122
+ draftReview,
129
123
  steerSettleMs: deps.steerSettleMs,
124
+ ...(deps.steeringRuntime ? { steeringRuntime: deps.steeringRuntime } : {}),
130
125
  };
131
126
  const server = new McpServer({ name: "miadi", version: VERSION }, { instructions: INSTRUCTIONS });
132
127
  registerTools(server, ctx);
@@ -164,6 +159,7 @@ Every tool is offered on every connection. A client that loads tools on demand s
164
159
  Rules every tool keeps:
165
160
  - Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.
166
161
  - Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.
162
+ - A new or revised run or work proposal answers with draft_review: the aspects of the mission standard to revise, each with why, the question to settle and who can settle it. Read it before show_decisions. It carries no verdict, it advises and blocks nothing, and next_action says what to do. Answer what your conversation holds, ask the person the rest, then revise. preferences lists the person's standing preferences for you to apply. Fill delivery with the one exact place the result lands, and person_words with the person's own words, verbatim, for anything credited to them.
167
163
  - A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.
168
164
  - A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.
169
165
  - An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.