@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/README.md CHANGED
@@ -20,9 +20,9 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
20
20
 
21
21
  | kind | tools |
22
22
  |---|---|
23
- | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `mission_model_preflight` `search` `fetch` |
23
+ | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `mission_model_preflight` `search` `fetch` `list_research_artifacts` `read_research_artifact` |
24
24
  | contributions | `append_observation` `open_inquiry` `link_records` `propose_narrative_beat` `record_evaluation` `register_source_intent` `record_handoff` `update_handoff` `prepare_calendar_event` `save_research` |
25
- | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_session_steer` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
25
+ | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_session_steer` `revoke_session_steering` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
26
26
  | decisions in the conversation | `show_decisions` (cards), `decide_request` (called by the card only) |
27
27
 
28
28
  - **Reads** need Miadi's read tier and stay within it. A person Miadi gives no API tier reaches only `get_capabilities`. Sessions need the write tier, because a session's name and pane are operator data. For the same reason, below the write tier a record someone else made shows no session, working folder or seat, and no long bodies.
@@ -60,9 +60,44 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
60
60
 
61
61
  **The agent's result (jgwill/Miadi#921).** An agent returns its work with `propose_session_completion` naming the mission in `mission`, from its bound session, with its evidence refs and, optionally, a structured `result`: `summary`, `outputs` (`ref`, `label`, `git_sha`), `tests` (`command`, `passed`, `failed`, `skipped`) and `blockers`. `mission_status` and `get_run_result` read it back by the mission id as `result`: the standing submission (the newest `verified` one: it names the mission, ends a bound session and was submitted with Miadi's write tier or the factory's writer token, which the connector records on the ending as `submitted_with_tier`; anything else naming the mission is `unverified_association` with an `association_code`, a mere citation `cited_only` and never a result), its summary, tests and blockers as the agent's claim, and each output typed and checked when read. A commit or file on GitHub is read through GitHub (`GITHUB_TOKEN` when the host has one): `verified` with its sha, `changed_since_submission` when its declared `git_sha` no longer matches or the file changed on its branch after the submission, `unverified` with the reason when GitHub cannot be read, never `not_found`. A connector record id the ledger does not hold is `not_found`. Bound sessions are taken from the factory's receipts for every reader, so the verdict is the same at every tier; below the write tier the session ids are left out. The completion decision stays the person's, and only a verified submission sets it: `stage_truth.completion` is `pending_approval`, `accepted`, `declined` or `revision_requested` (`none` for an unverified one, whose stage is `result_submitted_unverified`; an approved exit submitted below the write tier closes nothing), `pending_human_gates` lists what waits on a person with its approval page, and `mission_stage` names the furthest stage with evidence (`result_submitted_awaiting_completion_approval`, `completion_accepted`, `merged`, `released`, `closed`…). A duplicate submission (same session, ending and mission, still pending) is handed back with `replayed: true`, and `not_recorded` names what the second call carried that was not kept.
62
62
 
63
- `request_session_steer` sends one authorized follow-up to one existing live agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). It peeks at the bound pane — the grant's pane or the session's first pane, never whichever pane is active — refuses a busy agent or a pane that is not an agent, types the exact instruction literally, and re-peeks to verify the agent accepted it (`accepted`) or only holds it at the prompt (`submitted`). Every attempt leaves a durable artifact with the instruction's sha256, its grant, its target and both captures. When the factory recorded the mission's pane, the steer goes to that pane id and nowhere else: another session is `stale_binding`, another pane `wrong_pane`, a replaced pane `stale_binding`, and text already at the prompt `composing` (nothing typed, Enter not pressed). A pane whose prompt box cannot be read (any TUI but Claude Code's today) is `blocked`. Every tmux address is exact (`=name:w.p`), because tmux otherwise matches a session by the start of its name. Only a steer that typed its instruction uses one of the grant's attempts (`typed`, and `attempts` in the answer). The connector's service steers through the tmux server of the user it runs as; `get_capabilities.steering` says whether that transport answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
63
+ `request_session_steer` sends one person's correction to a running agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). Busy is the normal case (jgwill/Miadi#930).
64
64
 
65
- `request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order is resolved and named in the brief; Claude Code is never chosen silently — it runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
65
+ | who | owns | never does |
66
+ |---|---|---|
67
+ | The person, through ChatGPT or any client | one correction, in scope | watch tools, wait for idle, send it again |
68
+ | The connector | who may send it, the grant, mission → session → runtime, idempotency, handing it to that session's inbox, reporting what happened | decide when the model reads it, type into a working agent's pane, call a forward a delivery |
69
+ | The runtime (Claude Code hooks: `miadi-session-observability` 0.1.34, `hooks/steering_inbox.mjs`) | the inbox, its order, the checks again at consumption, when its model reads it | deliver anything to a subagent or to another session |
70
+ | The model | reading it and changing what it does next | |
71
+
72
+ The route comes from what each part signed: the factory's receipt names the session, Claude Code's own registry says it runs and in which pane, and the adapter's `adapter.json` says this session loaded its hooks. A wrong session is `stale_binding`, a wrong pane `wrong_pane`. A provider with no adapter, or a Claude session started before the adapter (hooks load at session start), answers `unsupported` with the reason and uses no attempt. No Claude Code behaviour is assumed of Codex, Copilot or Pi, and `get_capabilities.steering.contract` names the adapters that exist.
73
+
74
+ The inbox is `$MIADI_STEERING_DIR` (default `<factory>/steering`), `<session_id>/pending/` and its siblings, one Unix account's, `0700`. The connector writes one envelope with fsync and rename. The adapter takes it at the next `PostToolUse` or `PostToolUseFailure` as `additionalContext` in the same turn, or at `Stop` with `decision: block` when no further tool follows. A correction landing while the turn ends, after `Stop` has looked, is caught by a background watcher that runs for 30 seconds after each turn (`asyncRewake`): exit 2 wakes the model with it, so a working agent never waits for idle or a timer. A seat already idle is typed at once (below). The factory runner's next pass is the last net for anything left (`wakeIdleSteers`, once per envelope, only if the prompt box is clear), for example a session that resumed with a correction waiting. `delivery.continuation` says which of these is coming: `next_tool_or_turn_end`, `runner_wakes_idle_seat` or `session_not_running`. One rename into `claimed/` decides who delivers, so it is delivered once and never replayed. The adapter checks the session, the hash, the expiry (the grant's, or one day) and revocation again when it consumes. The hook does not poll: it runs in the agent's own lifecycle, and nothing outside watches it. A hook on an interrupted tool does not consume, because Claude Code drops hook messages there, and a subagent's tool does not consume either.
75
+
76
+ Why hooks: Claude Code 2.1.318 has a native mid-turn queue, and `claude --help` offers no outside door to it. Typing reaches it through the keyboard, which is the terminal heuristic this replaces. The cross-session socket (`messagingSocketPath`) belongs to Claude sessions speaking to each other: its protocol is not published, and a connector speaking it would pose as a session. `PostToolUse.additionalContext` and `Stop` are its documented interface, so that is the adapter. Pi, Codex and Copilot have their own in-turn mechanisms, and none has an adapter here yet.
77
+
78
+ `delivery` in the answer, in `get_run_result` on the steer's id, and in `mission_status.steering.steers` (write tier) says where it stands, each state with its source and time:
79
+
80
+ | state | means | source |
81
+ |---|---|---|
82
+ | `authorized` | the grant or a person's decision covered it | connector |
83
+ | `forwarded_to_runtime`, `queued_at_runtime` | it is in the session's inbox; the model has not necessarily read it | connector, runtime inbox |
84
+ | `delivered_to_agent_context` | Claude Code recorded it entering the context | the transcript's attachment record |
85
+ | `acknowledged` | the agent's own next text names `steer <id> received` | the transcript |
86
+ | `behavior_adjusted` | a later tool input holds what the steer named in `expect_next_action`; without that, `behavior: not_judged` and the next actions are shown for a person to judge | the transcript |
87
+ | `unsupported`, `expired`, `blocked` | it never reached the model, with the reason | connector or runtime hook |
88
+ | `unknown` | claimed with no transcript record: ambiguous, and it is not sent again | |
89
+
90
+ The record itself moves with its evidence: `completed` once the model has it, `blocked` once it never will, on the next read by anyone. So the ledger, `mission_status` and the decision surfaces say the same thing.
91
+
92
+ `revoke_session_steering` stops a run's steering or one steer. What is still pending moves out at once, a marker stops anything later, and the answer says exactly what was revoked and what the agent had already consumed. The same idempotency key gets the first answer back, and the same words under a new key answer `duplicate`. Only a typed or forwarded steer uses one of the grant's attempts.
93
+
94
+ An idle seat has no continuation coming, so it is the one case still typed: into the mission's bound pane, never whichever pane is active, with an exact tmux address (`=name:w.p`). It peeks first and re-peeks after. Text already at the prompt, or a prompt box that cannot be read, goes to the inbox when the seat has one and otherwise is `composing` or `blocked`, with nothing typed. A pane echo is not delivery: only a user record in the transcript moves it to `delivered_to_agent_context`. Every attempt leaves a durable artifact with the instruction's sha256, its grant and its target. The connector's service reaches the tmux server of the user it runs as; `get_capabilities.steering` says whether that answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
95
+
96
+ Rollback: run the connector version before this one (`git checkout` of its `dist`, then `systemctl --user restart miadi-connector`). A working agent's steer then answers `busy` again and nothing else changes. To stop the runtime half, remove the plugin's `steering_inbox.sh` entries from `hooks/hooks.json`; a new session then has no `adapter.json` and answers `unsupported`. Envelopes already queued stay on disk, unread.
97
+
98
+ `request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order that may be picked by default (Pi, then Copilot) is resolved and named in the brief; Codex and Claude Code are never chosen silently, each runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
99
+
100
+ **Codex runs only on its requester's own choice (jgwill/Miadi#926).** The provider field is the caller's: the connector cannot read the chat, so `provider: "codex"` chooses nothing for the person. A run that names Codex is recorded, and its answer says it `needs_person_choice`. The approval page then shows no Approve button. Only the requester, signed in there with their own Miadi person token, can tick and send "Choose codex for this mission". That `provider_choice` record is bound to the request id, its version and its content hash, must be under 24 hours old when the request is approved, and covers that one mission. A revision makes it stale. A generic Approve, on the page or on a card, is refused as `provider_choice_required`, and so is a choice by any other person or by a service token. The requester can instead set their own policy at `/providers` so their requests may name Codex without a per-mission choice; that policy is theirs only and revisable. The check runs at request, revision, approval, and where the brief is written (approval and every rerun). The brief carries `provider_authorization` and `provider_choice` (or `provider_policy`), and the factory runner refuses to launch Codex for a connector brief without them. `request_authorized_run`, `revise_pending_request`, `mission_status`, `show_decisions` and `mission_model_preflight` show `provider_authorization`; after approval it is also in the request's `completion_receipt`, which `get_run_result` returns. A Codex launch without it is named on the mission and fails the preflight closed as `provider_not_authorized`. A brief the runner refuses goes to `queue/refused/` with its reason, and the request shows it. Other providers keep their regular policy, and nothing falls back to Codex. What this does not cover: anyone who can write `/srv/miadi/factory/queue` or the connector's state directory can forge a brief or a record (the folder's permissions are the boundary); a configured launcher that runs Codex behind a wrapper whose name does not say codex is trusted as configured; a brief to an existing session reaches only an idle Claude Code agent today, so it is not checked; and an agent that starts `codex` itself from inside its session is outside the connector's reach.
66
101
 
67
102
  `mission_model_preflight` (write tier) answers, for one mission, which provider and model fit its task type and criticality, and how that is known: contract `miadi.mission_model_preflight` v1, candidates with their configured default model, sign-in evidence (config directory only), quota `unknown`, policy eligibility, a review floor, and a recommendation or an abstention with reasons. Its `decider.source` is `static_fallback`, `jev` or `synthetic_fixture`. Jev is asked only with `consult_jev: true` and a key on the host; it is sent task type, criticality and candidates, never task text, mission id or repository, and its answer is advisory: uncalibrated, below 0.8 or outside the contract, the preflight abstains. A reported `launch` on another provider than the approved one answers `provider_mismatch` before anything is asked. It authorizes nothing. `bench/model-preflight-bench.mjs` compares the 0.1.12 baseline, the static preflight and Jev on synthetic scenarios; `bench/results/` keeps the runs.
68
103
 
@@ -99,7 +134,7 @@ MIADI_CONNECTOR_PUBLIC_URL=https://mcp.sanctuaireagentique.com node packages/con
99
134
  | `MIADI_REVIEW_BASE_URL` | `https://miadi-review-service.vercel.app` | the review service |
100
135
  | `MIADI_REVIEW_TOKEN` | read from `~/.env` | review search and approved review operations |
101
136
  | `MIADI_FACTORY_DIR` | `~/.miadi/factory` | the queue approved runs are written to |
102
- | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes |
137
+ | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes, and `list_research_artifacts` and `read_research_artifact` read back |
103
138
  | `MIADI_INQUIRY_GITHUB_REPO` | `miadisabelle/Etuaptmumk-RSM` | where a saved report's inquiry issue is opened, with the host's `gh` login |
104
139
  | `MIADI_CONNECTOR_DECISION_TOKEN_TTL` | `604800` (7 days) | seconds a card's decision token stays usable, held between 300 and 2592000 |
105
140
  | `MIADI_CONNECTOR_DECISION_WAIT_MS` | `20000` | how long a decision answer waits for its run before answering it as recorded and running, at most 50000 |
package/dist/approve.d.ts CHANGED
@@ -16,7 +16,9 @@ import type { MiadiClient } from "./miadi.js";
16
16
  import { type MiadiPerson } from "./people.js";
17
17
  import { type ConnectorRecord } from "./records.js";
18
18
  import { type RequestProjection } from "./request-state.js";
19
+ import type { RuntimeDeps } from "./runtime-steer.js";
19
20
  import { type TmuxLike } from "./steer.js";
21
+ import { type ProviderAuthorization } from "./provider-policy.js";
20
22
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
21
23
  export interface ApprovalDeps {
22
24
  config: ConnectorConfig;
@@ -31,6 +33,8 @@ export interface ApprovalDeps {
31
33
  tmux?: TmuxLike;
32
34
  /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
33
35
  steerSettleMs?: number;
36
+ /** Where sessions and their steering inboxes live (jgwill/Miadi#930). Absent, no runtime route is known. */
37
+ steeringRuntime?: RuntimeDeps;
34
38
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
35
39
  log?: (event: Record<string, unknown>) => void;
36
40
  }
@@ -55,7 +59,7 @@ export declare const DECISIONS: readonly ["approve", "approve_with_changes", "re
55
59
  export type Decision = (typeof DECISIONS)[number];
56
60
  /** The task a work proposal asks for, written from the proposal's own fields. */
57
61
  export declare function proposalTask(record: ConnectorRecord): string;
58
- export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string): {
62
+ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string, providerAttrs?: Record<string, string>): {
59
63
  to: string;
60
64
  brief: string;
61
65
  };
@@ -83,6 +87,14 @@ export declare class StaleVersion extends AlreadyDecided {
83
87
  readonly decidedVersion: number;
84
88
  constructor(currentVersion: number, decidedVersion: number);
85
89
  }
90
+ /**
91
+ * An approval of a run whose provider needs the requester's own choice for this mission,
92
+ * and has none (jgwill/Miadi#926). Declining or sending it back stays possible.
93
+ */
94
+ export declare class ProviderChoiceRequired extends Error {
95
+ readonly authorization: ProviderAuthorization;
96
+ constructor(authorization: ProviderAuthorization);
97
+ }
86
98
  /** What a decision answers: the state it left, its receipt, and `replayed` when it repeats a decision already taken. */
87
99
  export interface Decided {
88
100
  decision: NonNullable<ConnectorRecord["approval_state"]>;
@@ -135,7 +147,7 @@ export declare function rerun(r: ConnectorRecord, person: MiadiPerson, deps: App
135
147
  }>;
136
148
  export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
137
149
  /** What approving a request does, in one sentence, for the card a person decides on. */
138
- export declare function consequenceOf(r: ConnectorRecord): string;
150
+ export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization): string;
139
151
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
140
152
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
141
153
  /**
@@ -146,5 +158,7 @@ export declare function requestProjection(record: ConnectorRecord, opts?: {
146
158
  reviewBase?: string;
147
159
  factoryDir?: string;
148
160
  }): RequestProjection | null;
161
+ /** The person's own provider policy page: read and change it, signed in with their person token (jgwill/Miadi#926). */
162
+ export declare function registerProviderPolicyRoutes(app: Express, deps: Pick<ApprovalDeps, "ledger" | "identify" | "log">): void;
149
163
  /** How a request is named to a person. */
150
164
  export declare const KIND_LABELS: Record<string, string>;
package/dist/approve.js CHANGED
@@ -24,6 +24,8 @@ import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
24
24
  import { factoryLedgerPath, readFactoryReceipts, receiptsForRecord } from "./factory-receipt.js";
25
25
  import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
26
26
  import { contentHash, versionOf } from "./revisions.js";
27
+ import { canReadReading } from "./reading/privacy.js";
28
+ import { authorizationReceipt, authorizeProvider, briefAttributes, CHOICE_TTL_MS, CODEX_POLICIES, isPersonId, needsPersonChoice, providerPolicyOf, } from "./provider-policy.js";
27
29
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
28
30
  /**
29
31
  * One line per step of a decision, for the journal: the request, a correlation id,
@@ -103,8 +105,10 @@ export function proposalTask(record) {
103
105
  }
104
106
  return lines.join("\n");
105
107
  }
106
- export function runBrief(record, approver, sessions, instructions) {
108
+ export function runBrief(record, approver, sessions, instructions, providerAttrs = {}) {
107
109
  const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
110
+ // A request recorded before providers were resolved (jgwill/Miadi#893) names only what was asked (#926 review RR7).
111
+ const provider = body.resolved_provider ?? body.provider;
108
112
  const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
109
113
  const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
110
114
  // A new session opens where the request says, else where the factory runner's config says (defaultCwd): no host path here (#911).
@@ -116,7 +120,9 @@ export function runBrief(record, approver, sessions, instructions) {
116
120
  `record="${attr(record.id)}"`,
117
121
  `version="${versionOf(record)}"`,
118
122
  // The provider the connector verified at request time (jgwill/Miadi#893); the intake may honour it or record why not.
119
- ...(body.resolved_provider ? [`provider="${attr(body.resolved_provider)}"`] : []),
123
+ ...(provider ? [`provider="${attr(provider)}"`] : []),
124
+ // What authorizes a provider that needs the requester's own choice; the runner refuses Codex without it (jgwill/Miadi#926).
125
+ ...Object.entries(providerAttrs).map(([k, v]) => `${k}="${attr(v)}"`),
120
126
  ...(to.startsWith("new:") && cwd ? [`cwd="${attr(cwd)}"`] : []),
121
127
  ];
122
128
  const lines = [
@@ -189,6 +195,11 @@ export async function executeApproved(record, approver, deps, instructions) {
189
195
  return { status: res.status, ok: res.ok, response: parsed };
190
196
  };
191
197
  const queueBrief = () => {
198
+ // Checked again where the brief is written: an approval, and every rerun of it (jgwill/Miadi#926).
199
+ const authorization = record.kind === "run_request" ? authorizeProvider(record, deps.ledger.all()) : undefined;
200
+ if (authorization && !authorization.authorized) {
201
+ return { outcome: "failed", provider_authorization: authorizationReceipt(authorization), message: `No brief was written. ${authorization.explanation}` };
202
+ }
192
203
  const queue = join(deps.factoryDir ?? factoryDir(), "queue");
193
204
  mkdirSync(queue, { recursive: true });
194
205
  // A lister that cannot answer means no session is known live: the brief opens a new one (#922 R16).
@@ -199,10 +210,16 @@ export async function executeApproved(record, approver, deps, instructions) {
199
210
  catch {
200
211
  sessions = [];
201
212
  }
202
- const { to, brief } = runBrief(record, approver, sessions, instructions);
213
+ const { to, brief } = runBrief(record, approver, sessions, instructions, authorization ? briefAttributes(authorization) : {});
203
214
  const file = runBriefFile(record);
204
215
  writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
205
- return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
216
+ return {
217
+ outcome: "queued",
218
+ to,
219
+ file,
220
+ ...(authorization ? { provider_authorization: authorizationReceipt(authorization) } : {}),
221
+ note: "The factory intake delivers queued briefs every 5 minutes.",
222
+ };
206
223
  };
207
224
  switch (record.kind) {
208
225
  case "run_request":
@@ -272,18 +289,21 @@ export async function executeApproved(record, approver, deps, instructions) {
272
289
  return { outcome: "failed", message: "The run whose grant this steer cites is gone." };
273
290
  // The pane the factory delivered the mission to, when it recorded one (jgwill/Miadi#909).
274
291
  const bound = receiptsForRecord(readFactoryReceipts(factoryLedgerPath(deps.factoryDir ?? factoryDir())), grantRecord.id)[0];
275
- const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, {
292
+ const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steeringRuntime ? { runtime: deps.steeringRuntime } : {}), ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, {
276
293
  record,
277
294
  grantRecord,
278
295
  priorAttempts: steerAttempts(current(deps.ledger.all()), grantRecord.id),
279
296
  authorized: true,
280
- ...(bound ? { binding: { session: bound.tmux_session, ...(bound.pane_id ? { pane_id: bound.pane_id } : {}) } } : {}),
297
+ ...(bound ? { binding: { session: bound.tmux_session, ...(bound.pane_id ? { pane_id: bound.pane_id } : {}), ...(bound.session_id ? { session_id: bound.session_id } : {}), ...(bound.provider ? { provider: bound.provider } : {}) } } : {}),
281
298
  });
282
- // A typed steer is never "failed": failed is re-runnable, and that would type it twice (#922 R8).
299
+ // A typed or forwarded steer is never "failed": failed is re-runnable, and that would send it twice (#922 R8).
283
300
  return {
284
- outcome: result.typed ? result.status : "failed",
301
+ outcome: result.typed || result.forwarded ? result.status : "failed",
285
302
  steer_status: result.status,
286
303
  typed: result.typed,
304
+ ...(result.forwarded ? { forwarded: true } : {}),
305
+ ...(result.route ? { route: result.route } : {}),
306
+ ...(result.transcript_offset !== undefined ? { transcript_offset: result.transcript_offset } : {}),
287
307
  message: result.message,
288
308
  ...(result.target ? { target: result.target } : {}),
289
309
  ...(result.artifact ? { artifact: result.artifact } : {}),
@@ -357,6 +377,17 @@ export class StaleVersion extends AlreadyDecided {
357
377
  this.decidedVersion = decidedVersion;
358
378
  }
359
379
  }
380
+ /**
381
+ * An approval of a run whose provider needs the requester's own choice for this mission,
382
+ * and has none (jgwill/Miadi#926). Declining or sending it back stays possible.
383
+ */
384
+ export class ProviderChoiceRequired extends Error {
385
+ authorization;
386
+ constructor(authorization) {
387
+ super(authorization.explanation);
388
+ this.authorization = authorization;
389
+ }
390
+ }
360
391
  /** The decisions being recorded now, by request id: one at a time per request, in this process. */
361
392
  const deciding = new Map();
362
393
  /** Whether a decision on this request is being recorded now, its run may still be going. */
@@ -414,6 +445,14 @@ export async function decide(r, person, choice, deps, opts) {
414
445
  }
415
446
  if (versionOf(live) !== decidedVersion)
416
447
  throw new StaleVersion(versionOf(live), decidedVersion);
448
+ // A generic approval never chooses Codex: the requester's own fresh choice or policy must already stand (jgwill/Miadi#926).
449
+ if (live.kind === "run_request" && approvalStateOf(choice) === "approved") {
450
+ const authorization = authorizeProvider(live, deps.ledger.all(), { requireFresh: true });
451
+ if (!authorization.authorized) {
452
+ logDecision(deps, { phase: "provider_choice_required", record: r.id, via: opts.via, choice, provider: authorization.provider });
453
+ throw new ProviderChoiceRequired(authorization);
454
+ }
455
+ }
417
456
  const entry = { ...asked, correlation: randomUUID(), started: Date.now() };
418
457
  deciding.set(r.id, entry);
419
458
  entry.promise = decideOnce(live, person, choice, deps, { ...opts, correlation: entry.correlation, started: entry.started });
@@ -497,7 +536,7 @@ export async function rerun(r, person, deps, opts) {
497
536
  receipt = { ...receipt, ...outcome };
498
537
  if (outcome.outcome === "failed")
499
538
  state = "blocked";
500
- if (outcome.outcome === "queued")
539
+ if (outcome.outcome === "queued" || outcome.outcome === "queued_at_runtime")
501
540
  state = "in_progress";
502
541
  }
503
542
  catch (error) {
@@ -564,7 +603,7 @@ async function decideOnce(r, person, choice, deps, opts) {
564
603
  receipt = { ...receipt, ...outcome };
565
604
  if (outcome.outcome === "failed")
566
605
  state = "blocked";
567
- if (outcome.outcome === "queued")
606
+ if (outcome.outcome === "queued" || outcome.outcome === "queued_at_runtime")
568
607
  state = "in_progress";
569
608
  }
570
609
  catch (error) {
@@ -602,11 +641,13 @@ export function isApprovable(r) {
602
641
  return Boolean(r && APPROVABLE.has(r.kind));
603
642
  }
604
643
  /** What approving a request does, in one sentence, for the card a person decides on. */
605
- export function consequenceOf(r) {
644
+ export function consequenceOf(r, authorization) {
606
645
  const b = r.body;
607
646
  switch (r.kind) {
608
647
  case "run_request":
609
- return "Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes.";
648
+ return authorization && !authorization.authorized
649
+ ? `Approving is refused until ${r.actor.name} chooses ${authorization.provider} for this mission on its approval page; approving does not choose it. Declining or sending it back stays possible.`
650
+ : `Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes${authorization?.provider ? ` with ${authorization.provider}` : ""}.`;
610
651
  case "work_proposal":
611
652
  return "Approving starts the work: a brief written from the proposal and your instructions is queued for an agent session.";
612
653
  case "review_operation_request":
@@ -658,8 +699,43 @@ export function registerApprovalRoutes(app, deps) {
658
699
  if (!writer && r.actor.person_id !== person.id) {
659
700
  return void res.status(403).type("html").send(signInToRead(r, `Reading and deciding this request needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.`));
660
701
  }
702
+ const requester = r.actor.person_id === person.id && isPersonId(person.id);
661
703
  if (!body.decision)
662
- return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined }));
704
+ return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined, chooserToken: requester ? token : undefined }));
705
+ if (body.decision === "choose_provider") {
706
+ // The requester's own, separate choice of a provider that needs one, for this version only (jgwill/Miadi#926).
707
+ const live = find(r.id);
708
+ const again = (status, error) => void res.status(status).type("html").send(approvePage(deps, live, { token: writer ? token : undefined, chooserToken: requester ? token : undefined, error }));
709
+ if (!requester) {
710
+ return again(403, isPersonId(r.actor.person_id)
711
+ ? `Only ${r.actor.name}, who asked, can choose a provider for their own mission.`
712
+ : `${r.actor.name} is a service token: only a person chooses a provider, for a request of their own. Name another provider for this request.`);
713
+ }
714
+ if (live.approval_state !== "awaiting_approval" || isBeingDecided(live.id))
715
+ return again(409, "A provider is chosen only while the request awaits a decision.");
716
+ const provider = String(body.provider ?? "");
717
+ const named = authorizeProvider(live, deps.ledger.all(), { requireFresh: true });
718
+ if (!needsPersonChoice(provider) || provider !== named.provider)
719
+ return again(400, `This request names ${named.provider ?? "no provider"}; only that provider can be chosen here.`);
720
+ if (Number(body.version) !== versionOf(live))
721
+ return again(409, `This request is now at version ${versionOf(live)}. Read it and choose again.`);
722
+ if (body.confirm !== "yes")
723
+ return again(400, `Tick the box to choose ${provider} for this mission.`);
724
+ if (!named.authorized || named.state !== "person_choice") {
725
+ deps.ledger.append({
726
+ id: newRecordId("provider_choice"),
727
+ kind: "provider_choice",
728
+ created_at: new Date().toISOString(),
729
+ actor: { person_id: person.id, name: person.name, client_id: "approval-page" },
730
+ refs: [live.id],
731
+ evidence_refs: [],
732
+ claim_category: "observed",
733
+ body: { request_id: live.id, provider, version: versionOf(live), content_hash: contentHash(live) },
734
+ });
735
+ logDecision(deps, { phase: "provider_chosen", record: live.id, provider, version: versionOf(live) });
736
+ }
737
+ return void res.type("html").send(approvePage(deps, find(r.id), { token: writer ? token : undefined, chooserToken: token }));
738
+ }
663
739
  if (!writer)
664
740
  return void res.status(403).type("html").send(approvePage(deps, r, { error: `Deciding needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.` }));
665
741
  if (body.decision === "rerun") {
@@ -705,6 +781,9 @@ export function registerApprovalRoutes(app, deps) {
705
781
  decided = settled;
706
782
  }
707
783
  catch (error) {
784
+ if (error instanceof ProviderChoiceRequired) {
785
+ return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, chooserToken: requester ? token : undefined, error: error.message }));
786
+ }
708
787
  if (error instanceof StaleVersion)
709
788
  return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, error: error.message }));
710
789
  if (error instanceof AlreadyDecided)
@@ -746,6 +825,9 @@ export function registerApprovalRoutes(app, deps) {
746
825
  const r = find(String(req.params.id));
747
826
  if (!r)
748
827
  return void res.status(404).type("html").send(page("Not found", "<h1>No record with that id</h1>"));
828
+ if (!canReadReading(r, current(deps.ledger.all()), person)) {
829
+ return void res.status(403).type("html").send(page("Miadi record", recordForm(String(req.params.id), "This source and its reading are private to the source owner and factory operators.")));
830
+ }
749
831
  if (!person.api_tier && r.actor.person_id !== person.id) {
750
832
  return void res.status(403).type("html").send(page("Miadi record", recordForm(String(req.params.id), `Reading records needs Miadi's read tier; ${person.name} has none.`)));
751
833
  }
@@ -806,8 +888,12 @@ function approvePage(deps, r, o = {}) {
806
888
  const open = r.approval_state === "awaiting_approval";
807
889
  // A decision being recorded offers no buttons: a second click would decide the same request again.
808
890
  const actionable = open && !isBeingDecided(r.id);
891
+ // Freshness binds a decision still to come; a decided run shows the authorization it holds (#926 review R1).
892
+ const authorization = r.kind === "run_request" ? authorizeProvider(r, deps.ledger.all(), { requireFresh: open }) : undefined;
893
+ const blocked = Boolean(authorization && !authorization.authorized);
809
894
  return page("Approve a Miadi request", `<h1>${open ? "Approve a request" : "A decided request"}</h1>
810
- ${actionable ? `<p>${escapeHtml(consequenceOf(r))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
895
+ ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
896
+ ${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
811
897
  ${summary(r)}
812
898
  ${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}
813
899
  ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
@@ -818,8 +904,10 @@ ${actionable && o.token
818
904
  <label for="i">Instructions (needed to approve with changes or to send back)</label>
819
905
  <textarea id="i" name="instructions" rows="4"></textarea>
820
906
  <div class="row">
821
- <button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
822
- <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>
907
+ ${blocked
908
+ ? ""
909
+ : `<button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
910
+ <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>`}
823
911
  <button type="submit" name="decision" value="request_revision">Send back for revision</button>
824
912
  <button type="submit" name="decision" value="decline">Decline</button>
825
913
  </div></form>`
@@ -834,6 +922,87 @@ ${actionable && o.token
834
922
  <div class="row"><button class="primary" type="submit" name="decision" value="rerun">Run it again</button></div></form>`
835
923
  : ""}`);
836
924
  }
925
+ /**
926
+ * Which provider a run launches with, who chose it, and, when the requester's own choice is
927
+ * missing, the separate control only the requester sees (jgwill/Miadi#926).
928
+ */
929
+ function providerSection(r, a, chooserToken) {
930
+ const lines = [
931
+ `<dt>Provider</dt><dd>${escapeHtml(a.provider ?? "the factory runner's default")}</dd>`,
932
+ `<dt>Asked for</dt><dd>${escapeHtml(a.requested ?? "none named")}${a.requester.client ? ` (through ${escapeHtml(a.requester.client)})` : ""}</dd>`,
933
+ `<dt>Authorized by</dt><dd>${escapeHtml(a.state.replace(/_/g, " "))}</dd>`,
934
+ ].join("");
935
+ const choose = a.state === "needs_person_choice" && chooserToken && a.provider
936
+ ? `<form method="post">
937
+ <input type="hidden" name="person_token" value="${escapeHtml(chooserToken)}">
938
+ <input type="hidden" name="version" value="${versionOf(r)}">
939
+ <input type="hidden" name="provider" value="${escapeHtml(a.provider)}">
940
+ <label><input type="checkbox" name="confirm" value="yes" required> I choose ${escapeHtml(a.provider)} for this mission (version ${versionOf(r)}) myself. It does not cover any other mission.</label>
941
+ <div class="row"><button type="submit" name="decision" value="choose_provider">Choose ${escapeHtml(a.provider)} for this mission</button></div>
942
+ </form>`
943
+ : a.state === "needs_person_choice" && !isPersonId(r.actor.person_id)
944
+ ? ""
945
+ : a.state === "needs_person_choice"
946
+ ? `<p>Only ${escapeHtml(r.actor.name)}, who asked, can choose ${escapeHtml(a.provider ?? "")} for this mission, signed in here. Within ${CHOICE_TTL_MS / 3_600_000} hours of that choice it can be approved.</p>`
947
+ : "";
948
+ return `<section aria-label="Provider"><dl>${lines}</dl><p>${escapeHtml(a.explanation)}</p>${choose}</section>`;
949
+ }
950
+ /** The person's own provider policy page: read and change it, signed in with their person token (jgwill/Miadi#926). */
951
+ export function registerProviderPolicyRoutes(app, deps) {
952
+ const form = express.urlencoded({ extended: false, limit: "16kb" });
953
+ const signIn = (error) => page("Your provider policy", `<h1>Your provider policy</h1><p>Which agent providers a request made in your name may name. Sign in to read or change yours; it applies to your requests only.</p>
954
+ ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
955
+ <form method="post"><label for="t">Your Miadi person token</label><input id="t" name="person_token" type="password" autocomplete="off" required>
956
+ <div class="row"><button class="primary" type="submit">Read my policy</button></div></form>`);
957
+ app.get("/providers", (_req, res) => void res.type("html").send(signIn()));
958
+ app.post("/providers", form, async (req, res) => {
959
+ const body = (req.body ?? {});
960
+ const token = String(body.person_token ?? "").trim();
961
+ let person;
962
+ try {
963
+ person = await deps.identify(token);
964
+ }
965
+ catch (error) {
966
+ if (!(error instanceof MiadiUnavailable))
967
+ throw error;
968
+ return void res.status(503).type("html").send(signIn("Miadi could not be reached. Try again in a minute."));
969
+ }
970
+ if (!person)
971
+ return void res.status(401).type("html").send(signIn("Miadi did not recognise that person token."));
972
+ if (!isPersonId(person.id))
973
+ return void res.status(403).type("html").send(signIn("A service token has no provider policy; a person sets their own."));
974
+ let saved = "";
975
+ if (body.codex !== undefined) {
976
+ if (!CODEX_POLICIES.includes(body.codex))
977
+ return void res.status(400).type("html").send(signIn("Unknown Codex policy."));
978
+ if (providerPolicyOf(deps.ledger.all(), person.id).codex !== body.codex) {
979
+ deps.ledger.append({
980
+ id: newRecordId("provider_policy"),
981
+ kind: "provider_policy",
982
+ created_at: new Date().toISOString(),
983
+ actor: { person_id: person.id, name: person.name, client_id: "providers-page" },
984
+ refs: [],
985
+ evidence_refs: [],
986
+ claim_category: "observed",
987
+ body: { codex: body.codex },
988
+ });
989
+ logDecision(deps, { phase: "provider_policy_set", person: person.id, codex: body.codex });
990
+ }
991
+ saved = "<p>Saved. It applies to your requests from now on, including a failed run of yours that is run again. A per-mission choice you already made still covers its mission.</p>";
992
+ }
993
+ const policy = providerPolicyOf(deps.ledger.all(), person.id);
994
+ const option = (value, label) => `<label><input type="radio" name="codex" value="${value}"${policy.codex === value ? " checked" : ""}> ${escapeHtml(label)}</label>`;
995
+ res.type("html").send(page("Your provider policy", `<h1>${escapeHtml(person.name)}'s provider policy</h1>${saved}
996
+ <dl><dt>Codex</dt><dd>${escapeHtml(policy.codex.replace(/_/g, " "))}${policy.source === "default" ? " (the default)" : `, set ${escapeHtml(policy.set_at ?? "")}`}</dd>
997
+ <dt>Other providers</dt><dd>Pi, Copilot and Claude Code run when a request names them and they are installed; Pi and Copilot may also be picked by the host's order.</dd></dl>
998
+ <form method="post"><input type="hidden" name="person_token" value="${escapeHtml(token)}">
999
+ <fieldset><legend>When a request in my name names Codex</legend>
1000
+ ${option("per_mission_choice", "Codex runs only when I choose it for that mission on its approval page (default).")}
1001
+ ${option("selectable", "Codex may be named for my requests without a per-mission choice.")}
1002
+ </fieldset>
1003
+ <div class="row"><button class="primary" type="submit">Save my policy</button></div></form>`));
1004
+ });
1005
+ }
837
1006
  /** How a request is named to a person. */
838
1007
  export const KIND_LABELS = {
839
1008
  run_request: "Agent run",
package/dist/bin.js CHANGED
@@ -4,6 +4,7 @@ import { createConnectorApp } from "./server.js";
4
4
  import { identifyThroughMiadi } from "./people.js";
5
5
  import { steeringTransport } from "./tools.js";
6
6
  import { tmuxAdapter } from "./steer.js";
7
+ import { defaultRuntime } from "./runtime-steer.js";
7
8
  import { VERSION } from "./version.js";
8
9
  const config = configFromEnv();
9
10
  // The host's steering surface (jgwill/Miadi#909): the tmux server of the user the service runs as,
@@ -13,6 +14,8 @@ const { app } = createConnectorApp(config, {
13
14
  identify: identifyThroughMiadi(config.miadiUrl),
14
15
  tmux,
15
16
  tmuxSessions: () => tmux.listSessions(),
17
+ // Each session's own steering inbox and Claude Code's registry of running sessions (jgwill/Miadi#930).
18
+ steeringRuntime: defaultRuntime(),
16
19
  });
17
20
  app.listen(config.port, config.host, () => {
18
21
  console.log(`@miadi/connector ${VERSION} on http://${config.host}:${config.port}`);
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import type { ConnectorRecord } from "./records.js";
23
23
  import type { RequestProjection } from "./request-state.js";
24
+ import type { SteerDelivery } from "./runtime-steer.js";
24
25
  import type { FactoryReceipt } from "./factory-receipt.js";
25
26
  import { type GithubGet, type MissionResult } from "./mission-result.js";
26
27
  export interface MissionSession {
@@ -184,9 +185,18 @@ export interface MissionStatus {
184
185
  access?: string;
185
186
  }[];
186
187
  steering?: {
187
- grant: "none" | "active" | "expired_grant" | "exhausted";
188
+ grant: "none" | "active" | "expired_grant" | "exhausted" | "revoked";
188
189
  attempts_used: number;
189
190
  max_attempts?: number;
191
+ /** Each correction sent under this mission and where it stands, by its own evidence (jgwill/Miadi#930; write tier). */
192
+ steers?: {
193
+ id: string;
194
+ at: string;
195
+ by: string;
196
+ state: string;
197
+ route: string;
198
+ delivery: SteerDelivery;
199
+ }[];
190
200
  };
191
201
  stage_truth?: MissionStageTruth;
192
202
  /**
@@ -216,6 +226,8 @@ export interface MissionStatusDeps {
216
226
  tmuxSessions?: () => string[];
217
227
  /** Read-only peek: is the named session's pane mid-turn? The pane id when the binding names one, else the first pane. (write tier only.) */
218
228
  paneBusy?: (session: string, paneId?: string) => "busy" | "idle" | "unknown";
229
+ /** Where one steer stands, read from the runtime's inbox and the session's transcript (write tier only). */
230
+ steerDeliveryOf?: (steer: ConnectorRecord) => SteerDelivery | null;
219
231
  /** Whether the tmux session still holds the pane id (write tier only); undefined when it cannot be listed. */
220
232
  panePresent?: (session: string, paneId: string) => boolean | undefined;
221
233
  /** The factory runner's delivery receipts for one record, newest first (write tier only). */
@@ -1,4 +1,4 @@
1
- import { steerAttempts, steeringGrantOf, checkGrant } from "./steer.js";
1
+ import { steerAttempts, steeringGrantOf, steeringRevoked, checkGrant } from "./steer.js";
2
2
  import { missionResult, redactResult, submittedWithWriteTier } from "./mission-result.js";
3
3
  const ISSUE_REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
4
4
  const SESSION_REF = /^session:([0-9a-f-]{36})$/;
@@ -108,7 +108,9 @@ function nextAction(record, standing, queue, result) {
108
108
  case "dispatching":
109
109
  return queue?.state === "delivered"
110
110
  ? "The brief is with its session. Follow it, or steer it with request_session_steer inside the approved grant."
111
- : "Wait for the factory intake to deliver the brief.";
111
+ : queue?.state === "refused"
112
+ ? "The factory runner refused to launch its provider; nothing runs. The requester decides on a new request with a provider they chose."
113
+ : "Wait for the factory intake to deliver the brief.";
112
114
  case "failed":
113
115
  return standing.rerun.available ? "A write-tier person may run it again with the same approval." : "Read the failure and decide.";
114
116
  case "completed":
@@ -387,8 +389,14 @@ export async function missionStatus(ref, records, deps) {
387
389
  }
388
390
  const grant = steeringGrantOf(record);
389
391
  const attempts = steerAttempts(records, record.id);
390
- const grantCheck = grant ? checkGrant(grant, attempts, now) : null;
391
- const grantState = grantCheck === null ? "none" : grantCheck === "ok" ? "active" : grantCheck === "expired_grant" ? "expired_grant" : "exhausted";
392
+ const grantCheck = grant ? checkGrant(grant, attempts, now, steeringRevoked(records, record.id)) : null;
393
+ const grantState = grantCheck === null ? "none" : grantCheck === "ok" ? "active" : grantCheck === "expired_grant" ? "expired_grant" : grantCheck === "revoked" ? "revoked" : "exhausted";
394
+ const steers = deps.steerDeliveryOf
395
+ ? records.filter((r) => r.kind === "session_steer_request" && r.refs.includes(record.id)).flatMap((r) => {
396
+ const delivery = deps.steerDeliveryOf(r);
397
+ return delivery ? [{ id: r.id, at: r.created_at, by: r.actor.name, state: delivery.state, route: delivery.route, delivery }] : [];
398
+ })
399
+ : [];
392
400
  const receipt = (record.completion_receipt ?? {});
393
401
  const anyLive = sessions.some((s) => s.live === "running");
394
402
  const prs = issues.filter((i) => i.merged !== undefined);
@@ -462,7 +470,7 @@ export async function missionStatus(ref, records, deps) {
462
470
  ...(queue ? { queue } : {}),
463
471
  sessions,
464
472
  issues,
465
- steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
473
+ steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}), ...(steers.length ? { steers } : {}) },
466
474
  ...(provider ? { provider } : {}),
467
475
  stage_truth: stageTruth,
468
476
  mission_stage: missionStage(standing ?? null, stageTruth),
@@ -1,4 +1,5 @@
1
1
  import { type JevConfig, type ProviderAvailability, type ProviderId } from "./providers.js";
2
+ import { type ProviderAuthorization } from "./provider-policy.js";
2
3
  export declare const PREFLIGHT_CONTRACT = "miadi.mission_model_preflight";
3
4
  export declare const PREFLIGHT_VERSION = 1;
4
5
  /** Below this, a Jev choice is not acted on (the ≥0.8 practice of jgwill/Miadi#770). */
@@ -34,7 +35,7 @@ export interface LaunchEvidence {
34
35
  /** What shows it (a process line, a pane header); reported by the caller, not checked by the connector. */
35
36
  evidence: string;
36
37
  }
37
- export type PreflightState = "recommended" | "abstained" | "provider_mismatch";
38
+ export type PreflightState = "recommended" | "abstained" | "provider_mismatch" | "provider_not_authorized";
38
39
  export type DeciderSource = "jev" | "static_fallback" | "synthetic_fixture";
39
40
  export type RouterState = "answered" | "not_requested" | "router_unconfigured" | "router_unavailable" | "invalid_answer";
40
41
  export interface ModelPreflight {
@@ -57,6 +58,8 @@ export interface ModelPreflight {
57
58
  /** approved: the request was approved with this provider. requested: it is not approved (yet); the provider is only asked for. */
58
59
  provider_basis: "approved" | "requested";
59
60
  candidates: ModelCandidate[];
61
+ /** Whether the mission's provider is authorized and by whom (jgwill/Miadi#926). */
62
+ provider_authorization?: Record<string, unknown>;
60
63
  decider: {
61
64
  source: DeciderSource;
62
65
  router: RouterState;
@@ -117,7 +120,7 @@ export interface JevRequest {
117
120
  }
118
121
  /** The model a provider's own config names, when it names one. Only that key is read. */
119
122
  export declare function configuredModel(provider: ProviderId, home?: string): string | null;
120
- export declare function candidatesFor(found: ProviderAvailability[], approved: ProviderId | null, modelOf?: (p: ProviderId) => string | null, basis?: "approved" | "requested"): ModelCandidate[];
123
+ export declare function candidatesFor(found: ProviderAvailability[], approved: ProviderId | null, modelOf?: (p: ProviderId) => string | null, basis?: "approved" | "requested", authorization?: Pick<ProviderAuthorization, "authorized" | "provider" | "requester">): ModelCandidate[];
121
124
  /** The least review a task gets, whatever any decider says. Jev may raise it, never lower it. */
122
125
  export declare function reviewFloor(task: TaskType, criticality: Criticality): ReviewTier;
123
126
  export declare function decisionState(task: TaskType, criticality: Criticality, candidates: ModelCandidate[]): Record<string, unknown>;
@@ -134,6 +137,10 @@ export interface PreflightInput {
134
137
  task: TaskType;
135
138
  criticality: Criticality;
136
139
  candidates: ModelCandidate[];
140
+ /** Whether the mission's provider is authorized, and by whom (jgwill/Miadi#926); shown as given. */
141
+ provider_authorization?: Record<string, unknown> & {
142
+ authorized?: boolean;
143
+ };
137
144
  launch?: LaunchEvidence;
138
145
  /** The decider to consult, when the caller asked for one; absent means static fallback only. */
139
146
  adapter?: JevAdapter;