@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
package/dist/steer.js CHANGED
@@ -6,9 +6,13 @@
6
6
  * - The target is the pane the mission is bound to — the pane the grant names,
7
7
  * else the session's first pane (lowest window, lowest pane, jgwill/Miadi#725).
8
8
  * The active pane plays no part.
9
- * - peek → scoped act → re-peek (jgwill/Miadi#345): visible pasted text is not
10
- * acceptance; acceptance is the pane recaptured and the agent seen
11
- * processing, with the instruction scrolled out of the input line.
9
+ * - A working agent is the normal case (jgwill/Miadi#930). With a runtime route, the correction
10
+ * goes to that session's own inbox and the runtime decides when its model reads it
11
+ * (runtime-steer.ts). Without one, a busy seat answers unsupported with the reason; nothing
12
+ * is typed into a working agent's pane.
13
+ * - An idle seat has no continuation coming, so it is the one case still typed, into its bound
14
+ * pane: peek → scoped act → re-peek (jgwill/Miadi#345). A pane echo is not acceptance; what
15
+ * reached the model is read from the transcript (readSteerDelivery).
12
16
  * - Every attempt leaves a durable artifact: the instruction, its sha256, the
13
17
  * grant it ran under, the target, the timestamps and both captures.
14
18
  * - Pane content is evidence, never instruction: nothing an agent printed is
@@ -16,8 +20,9 @@
16
20
  */
17
21
  import { createHash } from "node:crypto";
18
22
  import { execFileSync } from "node:child_process";
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
20
24
  import { join } from "node:path";
25
+ import { enqueueSteer, resolveRuntime, sameInstructionInFlight, transcriptPathOf } from "./runtime-steer.js";
21
26
  export const STEER_STATUSES = [
22
27
  "prepared",
23
28
  "submitted",
@@ -29,6 +34,9 @@ export const STEER_STATUSES = [
29
34
  "expired_grant",
30
35
  "duplicate",
31
36
  "composing",
37
+ // jgwill/Miadi#930. "busy" stays readable on old records; a new steer never answers it.
38
+ "queued_at_runtime",
39
+ "unsupported",
32
40
  ];
33
41
  /** Commands that mark a pane as an agent seat a steer may land in. */
34
42
  export const AGENT_COMMANDS = ["claude", "codex", "copilot", "pi", "gemini", "node"];
@@ -121,20 +129,26 @@ export function steeringGrantOf(run) {
121
129
  };
122
130
  }
123
131
  /** Whether a grant covers one more steer. `priorAttempts` counts the steers already run under it. */
124
- export function checkGrant(grant, priorAttempts, now = new Date()) {
132
+ export function checkGrant(grant, priorAttempts, now = new Date(), revoked = false) {
125
133
  if (!grant)
126
134
  return "no_grant";
135
+ if (revoked)
136
+ return "revoked";
127
137
  if (grant.expires_at && !Number.isNaN(Date.parse(grant.expires_at)) && Date.parse(grant.expires_at) <= now.getTime())
128
138
  return "expired_grant";
129
139
  if ((grant.max_attempts ?? 0) <= priorAttempts)
130
140
  return "duplicate";
131
141
  return "ok";
132
142
  }
143
+ /** Whether a person revoked this run's steering, or just this one steer when `steer` is given (jgwill/Miadi#930). */
144
+ export function steeringRevoked(records, grantId) {
145
+ return records.some((r) => r.kind === "steering_revocation" && r.refs.includes(grantId) && !r.body.steer);
146
+ }
133
147
  /**
134
148
  * 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.
149
+ * typed or forwarded their instruction (jgwill/Miadi#909, #930). A steer stopped before that
150
+ * (composing, unsupported, wrong or stale pane, transport down) uses none of the grant's attempts.
151
+ * Receipts made before `typed` was recorded count when their outcome says the text was delivered.
138
152
  */
139
153
  export function steerAttempts(records, grantId) {
140
154
  return records.filter((r) => {
@@ -145,6 +159,8 @@ export function steerAttempts(records, grantId) {
145
159
  const receipt = r.completion_receipt;
146
160
  if (!receipt)
147
161
  return false;
162
+ if (receipt.forwarded === true)
163
+ return true;
148
164
  if (typeof receipt.typed === "boolean")
149
165
  return receipt.typed;
150
166
  const outcome = String(receipt.steer_status ?? receipt.outcome ?? "");
@@ -179,6 +195,15 @@ export function readSteerArtifact(stateDir, recordId) {
179
195
  }
180
196
  }
181
197
  const CAPTURE_LINES = 40;
198
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
199
+ const fileSize = (path) => {
200
+ try {
201
+ return statSync(path).size;
202
+ }
203
+ catch {
204
+ return 0;
205
+ }
206
+ };
182
207
  /**
183
208
  * Run one steer under its grant. The record is the session_steer_request; the
184
209
  * grant record is the approved run the steer belongs to. A returned status
@@ -195,6 +220,9 @@ export async function steerSession(deps, args) {
195
220
  const grantSession = String(grantRecord.body.session ?? "") || undefined;
196
221
  const session = body.session ?? args.binding?.session ?? grantSession;
197
222
  let typed = false;
223
+ let forwarded = false;
224
+ let route;
225
+ let transcript = {};
198
226
  const finish = (status, message, extra = {}, target) => {
199
227
  const artifact = persistArtifact(deps.stateDir, record.id, {
200
228
  record: record.id,
@@ -204,14 +232,33 @@ export async function steerSession(deps, args) {
204
232
  status,
205
233
  message,
206
234
  typed,
235
+ ...(forwarded ? { forwarded } : {}),
236
+ ...(route ? { route } : {}),
237
+ ...transcript,
238
+ ...(target ? { target } : {}),
207
239
  ...(args.binding ? { binding: args.binding } : {}),
208
240
  at: now().toISOString(),
209
241
  ...extra,
210
242
  });
211
- return { status, message, typed, ...(target ? { target } : {}), artifact };
243
+ return { status, message, typed, ...(forwarded ? { forwarded } : {}), ...(route ? { route } : {}), ...transcript, ...(target ? { target } : {}), artifact };
212
244
  };
213
245
  if (!instruction.trim())
214
246
  return { status: "blocked", message: "The steer carries no instruction.", typed: false };
247
+ // Sent before, or about to be when the connector stopped: this answers from that, and nothing is sent again.
248
+ const sent = readSteerArtifact(deps.stateDir, record.id);
249
+ if (sent?.typed === true || sent?.forwarded === true) {
250
+ const earlierRoute = sent.route;
251
+ return {
252
+ status: sent.status,
253
+ message: String(sent.message ?? ""),
254
+ typed: sent.typed === true,
255
+ ...(sent.forwarded === true ? { forwarded: true } : {}),
256
+ ...(earlierRoute ? { route: earlierRoute } : {}),
257
+ ...(typeof sent.transcript_offset === "number" ? { transcript_offset: sent.transcript_offset } : {}),
258
+ ...(typeof sent.target === "string" ? { target: sent.target } : {}),
259
+ artifact: { file: artifactPath(deps.stateDir, record.id), sha256: String(sent.instruction_sha256 ?? "") },
260
+ };
261
+ }
215
262
  if (!session)
216
263
  return finish("stale_binding", "Neither the steer nor its grant names a session; a steer never targets whichever pane is active.");
217
264
  if (body.session && grantSession && body.session !== grantSession) {
@@ -263,6 +310,48 @@ export async function steerSession(deps, args) {
263
310
  if (!agentCommands.includes(pane.command)) {
264
311
  return finish("wrong_pane", `Pane ${target} runs "${pane.command}", not an agent; nothing was typed.`, { pane: { ...pane } }, target);
265
312
  }
313
+ if (args.sameInstructionSent && !args.authorized) {
314
+ return finish("duplicate", `These same words were already sent under this grant as ${args.sameInstructionSent}; they are not sent twice. Reword it if you mean something new.`, { earlier: args.sameInstructionSent }, target);
315
+ }
316
+ // A mission bound to a session is answered about that session: the mission's own binding, Claude
317
+ // Code's registry and the adapter's file, never a reading of the pane (jgwill/Miadi#930).
318
+ const provider = args.binding?.provider ?? str(grantRecord.body.resolved_provider) ?? str(grantRecord.body.provider);
319
+ const runtime = deps.runtime
320
+ ? resolveRuntime(deps.runtime, { session_id: args.binding?.session_id, pane_id: pane.id, provider })
321
+ : { route: "none", code: "unsupported", reason: "This host has no runtime steering route configured." };
322
+ if (runtime.route === "none" && runtime.code !== "unsupported") {
323
+ return finish(runtime.code, `${runtime.reason} Nothing was sent.`, { pane: { ...pane } }, target);
324
+ }
325
+ // The runtime owns when its model reads this. The connector hands it over and is done.
326
+ const forward = (rt) => {
327
+ const earlier = sameInstructionInFlight(deps.runtime, rt.session_id, grantRecord.id, instruction);
328
+ if (earlier && earlier !== record.id) {
329
+ return finish("duplicate", `These same words were already forwarded to this session as ${earlier}; they are not sent twice.`, { earlier }, target);
330
+ }
331
+ try {
332
+ const actor = record.actor;
333
+ const { file, already, envelope } = enqueueSteer(deps.runtime, {
334
+ steer: record.id,
335
+ grant: grantRecord.id,
336
+ session_id: rt.session_id,
337
+ instruction,
338
+ from: { person_id: actor.person_id, name: actor.name, ...(actor.client_name ? { via: actor.client_name } : {}) },
339
+ // A person's decision on this exact steer stands apart from the grant it cites: the grant's expiry and revocation do not stop it.
340
+ ...(args.authorized ? { decided: true } : grant?.expires_at ? { grant_expires_at: grant.expires_at } : {}),
341
+ ...(rt.transcript_path ? { transcript_path: rt.transcript_path } : {}),
342
+ now: now(),
343
+ });
344
+ forwarded = true;
345
+ route = "claude_hook_inbox";
346
+ transcript = envelope.transcript_offset !== undefined ? { transcript_offset: envelope.transcript_offset } : {};
347
+ return finish("queued_at_runtime", `Handed to the inbox of Claude Code session ${rt.session_id}. Its own hooks present it to its model at its next continuation, in the same turn. This is not yet proof the model read it.`, { inbox_file: file, already_queued: already, queued_at: envelope.queued_at, expires_at: envelope.expires_at }, target);
348
+ }
349
+ catch (error) {
350
+ return finish("blocked", `The inbox of session ${rt.session_id} refused the steer (${error instanceof Error ? error.message : String(error)}); nothing was forwarded.`, {}, target);
351
+ }
352
+ };
353
+ if (runtime.route === "claude_hook_inbox" && runtime.seat !== "idle")
354
+ return forward(runtime);
266
355
  let before;
267
356
  try {
268
357
  before = deps.tmux.capture(target);
@@ -271,22 +360,38 @@ export async function steerSession(deps, args) {
271
360
  return finish("blocked", `The pane could not be read (${error instanceof Error ? error.message : String(error)}); nothing was typed.`, {}, target);
272
361
  }
273
362
  if (isBusy(before)) {
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);
363
+ if (runtime.route === "claude_hook_inbox")
364
+ return forward(runtime);
365
+ return finish("unsupported", `The agent in ${target} is working and has no runtime steering route: ${runtime.route === "none" ? runtime.reason : ""} Nothing was typed.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
275
366
  }
276
367
  const composing = composingText(before);
277
368
  if (composing === null) {
369
+ if (runtime.route === "claude_hook_inbox")
370
+ return forward(runtime);
278
371
  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
372
  }
280
373
  if (composing) {
374
+ // Someone's words are at the prompt: the inbox waits for their prompt and never touches them.
375
+ if (runtime.route === "claude_hook_inbox")
376
+ return forward(runtime);
281
377
  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
378
  }
379
+ if (deps.runtime && args.binding?.session_id) {
380
+ const path = transcriptPathOf(deps.runtime, args.binding.session_id);
381
+ if (path)
382
+ transcript = { transcript_offset: fileSize(path) };
383
+ }
384
+ // Written before the keys: a connector stopped between here and the receipt never types it twice.
385
+ persistArtifact(deps.stateDir, record.id, { record: record.id, grant: grantRecord.id, instruction, session, status: "submitted", message: `Typed into ${target}; the connector stopped before it could say more.`, typed: true, route: "tmux_exact_pane", ...transcript, target, at: now().toISOString() });
283
386
  try {
284
387
  deps.tmux.sendLiteral(target, instruction);
285
388
  }
286
389
  catch (error) {
390
+ // finish() overwrites the artifact written above: nothing was typed, so a later call may try again.
287
391
  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
392
  }
289
393
  typed = true;
394
+ route = "tmux_exact_pane";
290
395
  try {
291
396
  deps.tmux.sendEnter(target);
292
397
  }
package/dist/store.d.ts CHANGED
@@ -17,6 +17,8 @@ export declare class Ledger<R extends {
17
17
  private cache;
18
18
  constructor(path: string);
19
19
  append(record: R): R;
20
+ /** A processing receipt is committed to disk before its dependent stage starts. */
21
+ appendDurable(record: R): R;
20
22
  all(): R[];
21
23
  }
22
24
  export declare function statePaths(dir: string): {
package/dist/store.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * (observations, handoffs, proposals). Append-only: a record
11
11
  * is never edited, a later record supersedes it.
12
12
  */
13
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
13
+ import { appendFileSync, chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, statSync, writeSync } from "node:fs";
14
14
  import { createHash, randomBytes } from "node:crypto";
15
15
  import { join } from "node:path";
16
16
  import { readJsonSafe, writeJsonAtomic } from "@miadi/node-service-kit/json";
@@ -66,6 +66,23 @@ export class Ledger {
66
66
  appendFileSync(this.path, JSON.stringify(record) + "\n", { mode: 0o600 });
67
67
  return record;
68
68
  }
69
+ /** A processing receipt is committed to disk before its dependent stage starts. */
70
+ appendDurable(record) {
71
+ const fd = openSync(this.path, "a", 0o600);
72
+ try {
73
+ // Keep a torn crash tail separate from this record, as all() skips invalid lines.
74
+ const old = readFileSync(this.path);
75
+ const line = Buffer.from((old.length && old[old.length - 1] !== 10 ? "\n" : "") + JSON.stringify(record) + "\n");
76
+ let written = 0;
77
+ while (written < line.length)
78
+ written += writeSync(fd, line, written, line.length - written);
79
+ fsyncSync(fd);
80
+ }
81
+ finally {
82
+ closeSync(fd);
83
+ }
84
+ return record;
85
+ }
69
86
  all() {
70
87
  if (!existsSync(this.path))
71
88
  return [];
package/dist/tools.d.ts CHANGED
@@ -9,6 +9,7 @@ import { type ApprovalDeps } from "./approve.js";
9
9
  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
+ import { type RuntimeDeps } from "./runtime-steer.js";
12
13
  import { type TmuxLike } from "./steer.js";
13
14
  import { type GithubGet } from "./mission-result.js";
14
15
  export interface ToolContext {
@@ -24,6 +25,8 @@ export interface ToolContext {
24
25
  decisionTokens?: DecisionTokens;
25
26
  /** The steering surface (jgwill/Miadi#893); absent, steering answers unavailable. */
26
27
  tmux?: TmuxLike;
28
+ /** Where sessions and their steering inboxes live (jgwill/Miadi#930). Absent, no runtime route is known. */
29
+ steeringRuntime?: RuntimeDeps;
27
30
  tmuxSessions?: () => string[];
28
31
  /** Provider discovery probes; tests inject fakes (jgwill/Miadi#893). */
29
32
  providersProbe?: DiscoveryProbe;
@@ -42,7 +45,32 @@ export interface ToolContext {
42
45
  export declare function githubAccess(status: number, hasToken: boolean): string;
43
46
  /** A GitHub REST read as the connector can make it: GITHUB_TOKEN when the host has one, else anonymous. */
44
47
  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). */
48
+ export declare const STEERING_CONTRACT: {
49
+ readonly busy_is_normal: true;
50
+ readonly connector: "Admits the steer, resolves mission → session → runtime, hands it to that session's inbox and reports what the runtime accepted. It does not watch tools or wait for idle.";
51
+ readonly runtime: "The agent's own lifecycle presents it to its model at the next supported continuation in the same turn.";
52
+ readonly delivery_states: readonly ["authorized", "forwarded_to_runtime", "queued_at_runtime", "delivered_to_agent_context", "acknowledged", "behavior_adjusted", "unsupported", "expired", "blocked", "unknown"];
53
+ readonly provider_adapters: {
54
+ readonly claude: {
55
+ readonly route: "claude_hook_inbox";
56
+ readonly continuation: "PostToolUse, PostToolUseFailure, then Stop if no tool follows";
57
+ readonly needs: "miadi-session-observability 0.1.34 or later, loaded when the session started";
58
+ };
59
+ readonly codex: {
60
+ readonly route: "unsupported";
61
+ };
62
+ readonly copilot: {
63
+ readonly route: "unsupported";
64
+ };
65
+ readonly pi: {
66
+ readonly route: "unsupported";
67
+ };
68
+ readonly gemini: {
69
+ readonly route: "unsupported";
70
+ };
71
+ };
72
+ readonly idle_seat: "An idle seat has no continuation coming, so its steer is typed into its bound pane once the prompt reads empty.";
73
+ };
46
74
  export declare function steeringTransport(tmux: TmuxLike | undefined): {
47
75
  tool: "registered";
48
76
  transport: "operational" | "unavailable";