@miadi/connector 0.1.18 → 0.1.20

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.
package/README.md CHANGED
@@ -62,7 +62,9 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
62
62
 
63
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.
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
+ `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.
66
+
67
+ **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
68
 
67
69
  `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
70
 
@@ -70,9 +72,11 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
70
72
 
71
73
  In a client that renders MCP Apps (ChatGPT among them), `show_decisions` draws one card per request awaiting a decision: what is asked, by whom, and what approving does. The person decides on the card with their own click: Approve, Approve with my changes, Send back for revision, or Decline. The card then shows the receipt.
72
74
 
75
+ A decision is recorded once. An approved run that calls another service (a review operation, Deep Diver, an episode door, a steer) is written to the ledger as approved and running before the call, so a restart can never offer the request for a second approval; a run the connector was recording when it stopped reads as failed and may be run again. A decision whose run takes longer than `MIADI_CONNECTOR_DECISION_WAIT_MS` is answered as recorded and running, and the card reads `get_run_result` every 3 seconds until the outcome is in. The same decision sent again (a host retrying a call, a second click, the page form sent twice) answers with the first one's receipt, marked `replayed`, and runs nothing. A different decision is refused as `decision_conflict`, with where the request stands. When the host gives up on a call, the card reads where the request stands before it offers the buttons again.
76
+
73
77
  The model that asked cannot decide for the person:
74
78
  - `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`), so the host does not offer it to the model.
75
- - It requires a decision token that travels only in the `_meta` of the `show_decisions` result. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request at the version its card shows, one person and one OAuth client, and expires after 30 minutes. A decided request no longer awaits a decision, so a token works once.
79
+ - It requires a decision token that travels only in the `_meta` of the `show_decisions` result. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request at the version its card shows, one person and one OAuth client, and expires after 7 days (`MIADI_CONNECTOR_DECISION_TOKEN_TTL`). A decided request no longer awaits a decision, so a token works once. The request itself does not expire: a card whose token expired names the request's approval page, and `show_decisions` gives a fresh card for the same request.
76
80
  - The sign-in must carry `miadi:approve`. A person grants it by ticking "Let me approve requests on cards inside this client" on the sign-in page. Only a person with Miadi's write tier receives it, only for a client that sends people back to chatgpt.com, and it ends when Miadi no longer gives them the write tier. A person token used directly as Bearer never carries it.
77
81
 
78
82
  The approval page itself (`/approve/<record>`) names only the kind of request until someone signs in. A write-tier person then reads and decides it, and whoever asked can read it.
@@ -99,6 +103,8 @@ MIADI_CONNECTOR_PUBLIC_URL=https://mcp.sanctuaireagentique.com node packages/con
99
103
  | `MIADI_FACTORY_DIR` | `~/.miadi/factory` | the queue approved runs are written to |
100
104
  | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes |
101
105
  | `MIADI_INQUIRY_GITHUB_REPO` | `miadisabelle/Etuaptmumk-RSM` | where a saved report's inquiry issue is opened, with the host's `gh` login |
106
+ | `MIADI_CONNECTOR_DECISION_TOKEN_TTL` | `604800` (7 days) | seconds a card's decision token stays usable, held between 300 and 2592000 |
107
+ | `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 |
102
108
 
103
109
  On gaia it runs as the user service `miadi-connector.service`, and the Cloudflare tunnel `gaia` routes `mcp.sanctuaireagentique.com` to it.
104
110
 
@@ -109,6 +115,8 @@ On gaia it runs as the user service `miadi-connector.service`, and the Cloudflar
109
115
  - Miadi decides who a token belongs to (`GET /api/identity/me`), and is asked again at most every 5 minutes while a sign-in is used. A token Miadi no longer recognises ends the sign-in, and the tier Miadi returns now is the one applied. When Miadi cannot be reached, sign-in says so (503) and an existing sign-in keeps the person as last known.
110
116
  - What a requester wrote is placed in an approved run's brief with every tag-opening `<` neutralised, so the brief holds one block with the connector's own attributes.
111
117
  - Only the loopback hop (cloudflared) is trusted for the client address. The OAuth endpoints, the forms that take a person token, and failed `/mcp` authentications are rate limited per address.
118
+ - A card's decision token lasts 7 days. Its lifetime is not what protects a request: using it also needs the same person's bearer, for the same client, carrying `miadi:approve`, and the write tier Miadi gives that person now. That bearer can already ask `show_decisions` for a fresh token, so a longer lifetime gives a stolen token nothing a stolen bearer does not.
119
+ - Each step of a decision is one JSON line on stdout (`"evt":"connector.decision"`, read with `journalctl --user -u miadi-connector`): the request, a correlation id, the door, timings, the outcome and the HTTP status the run met. No token and no instructions are logged.
112
120
  - Outbound calls time out: Miadi 30 s (episode doors 120 s), the review service 15 s (approved review operations 290 s), GitHub 15 s.
113
121
 
114
122
  ## Test
package/dist/approve.d.ts CHANGED
@@ -10,13 +10,14 @@
10
10
  * is appended as a record that supersedes the request (state, approval, receipt).
11
11
  */
12
12
  import { type Express } from "express";
13
- import type { ConnectorConfig } from "./config.js";
13
+ import { type ConnectorConfig } from "./config.js";
14
14
  import type { Ledger } from "./store.js";
15
15
  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
19
  import { type TmuxLike } from "./steer.js";
20
+ import { type ProviderAuthorization } from "./provider-policy.js";
20
21
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
21
22
  export interface ApprovalDeps {
22
23
  config: ConnectorConfig;
@@ -31,7 +32,21 @@ export interface ApprovalDeps {
31
32
  tmux?: TmuxLike;
32
33
  /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
33
34
  steerSettleMs?: number;
35
+ /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
36
+ log?: (event: Record<string, unknown>) => void;
34
37
  }
38
+ /**
39
+ * One line per step of a decision, for the journal: the request, a correlation id,
40
+ * the door it came through, timings, the outcome and the HTTP status the run met.
41
+ * Never a token, never instructions (jgwill/Miadi#924).
42
+ */
43
+ export declare function logDecision(deps: Pick<ApprovalDeps, "log">, event: Record<string, unknown>): void;
44
+ /** How long a decision answer waits for its run before saying it is recorded and still running. */
45
+ export declare function decisionWaitOf(deps: Pick<ApprovalDeps, "config">): number;
46
+ /** What `settleWithin` answers when the promise has not settled in time; the promise goes on. */
47
+ export declare const STILL_RUNNING: unique symbol;
48
+ /** The promise's value if it settles within `ms`, else STILL_RUNNING. A rejection after the bound is caught, never unhandled. */
49
+ export declare function settleWithin<T>(p: Promise<T>, ms: number): Promise<T | typeof STILL_RUNNING>;
35
50
  /**
36
51
  * What a person may decide on a request. Approving with changes runs it with the
37
52
  * approver's instructions carried into what runs; a revision sends it back to
@@ -41,7 +56,7 @@ export declare const DECISIONS: readonly ["approve", "approve_with_changes", "re
41
56
  export type Decision = (typeof DECISIONS)[number];
42
57
  /** The task a work proposal asks for, written from the proposal's own fields. */
43
58
  export declare function proposalTask(record: ConnectorRecord): string;
44
- export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string): {
59
+ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string, providerAttrs?: Record<string, string>): {
45
60
  to: string;
46
61
  brief: string;
47
62
  };
@@ -54,27 +69,63 @@ export declare function executeApproved(record: ConnectorRecord, approver: Miadi
54
69
  * caller has already checked who the person is, their write tier, and that the
55
70
  * request still awaits approval.
56
71
  */
57
- /** A request is decided once: a second decision while one runs, or after it, is refused. */
72
+ /** A request is decided once: a second, different decision while one runs, or after it, is refused. */
58
73
  export declare class AlreadyDecided extends Error {
59
74
  }
75
+ /**
76
+ * A decision that differs from the one recorded or being recorded (another choice, or other
77
+ * instructions). The same decision sent again is not refused: it answers with the first one's receipt (#924).
78
+ */
79
+ export declare class DecisionConflict extends AlreadyDecided {
80
+ }
60
81
  /** A decision on a version the request no longer holds: it was revised after the page or card was read (#919). */
61
82
  export declare class StaleVersion extends AlreadyDecided {
62
83
  readonly currentVersion: number;
63
84
  readonly decidedVersion: number;
64
85
  constructor(currentVersion: number, decidedVersion: number);
65
86
  }
87
+ /**
88
+ * An approval of a run whose provider needs the requester's own choice for this mission,
89
+ * and has none (jgwill/Miadi#926). Declining or sending it back stays possible.
90
+ */
91
+ export declare class ProviderChoiceRequired extends Error {
92
+ readonly authorization: ProviderAuthorization;
93
+ constructor(authorization: ProviderAuthorization);
94
+ }
95
+ /** What a decision answers: the state it left, its receipt, and `replayed` when it repeats a decision already taken. */
96
+ export interface Decided {
97
+ decision: NonNullable<ConnectorRecord["approval_state"]>;
98
+ state: ConnectorRecord["state"];
99
+ receipt: Record<string, unknown>;
100
+ /** The same decision was sent again (a retried call, a second click): this is the first one's answer, nothing ran twice. */
101
+ replayed?: boolean;
102
+ }
66
103
  /** Whether a decision on this request is being recorded now, its run may still be going. */
67
104
  export declare function isBeingDecided(recordId: string): boolean;
105
+ /** The approval state a choice leaves a request in. */
106
+ export declare function approvalStateOf(choice: Decision): NonNullable<ConnectorRecord["approval_state"]>;
107
+ /**
108
+ * Record a person's decision once. The page and the card (or two cards, or a host
109
+ * that retries a call it gave up waiting on) may send it more than once:
110
+ * - the same decision while the first is being recorded waits for that one and
111
+ * answers with its receipt, marked `replayed`; nothing runs twice;
112
+ * - the same decision after it was recorded answers with the recorded receipt;
113
+ * - a different decision is refused as a DecisionConflict (jgwill/Miadi#924).
114
+ */
68
115
  export declare function decide(r: ConnectorRecord, person: MiadiPerson, choice: Decision, deps: ApprovalDeps, opts: {
69
116
  instructions?: string;
70
117
  note?: string;
71
118
  via: string;
72
119
  expectedVersion?: number;
73
- }): Promise<{
74
- decision: NonNullable<ConnectorRecord["approval_state"]>;
75
- state: ConnectorRecord["state"];
76
- receipt: Record<string, unknown>;
77
- }>;
120
+ }): Promise<Decided>;
121
+ /** What the receipt of a run the connector lost to a restart says (jgwill/Miadi#924). */
122
+ export declare const INTERRUPTED_MESSAGE = "The connector stopped while this ran, so whether it finished is not known. Read what it was to change before running it again. A review create is safe to run again: the review service answers with the review it already holds for that video.";
123
+ /**
124
+ * A run recorded as running whose process is gone (the connector restarted mid-run)
125
+ * becomes a failed run that a person may run again. Called once when the connector
126
+ * starts; a run this process is still recording is left alone.
127
+ */
128
+ export declare function reconcileInterrupted(deps: Pick<ApprovalDeps, "ledger" | "log">): string[];
78
129
  /** Whether a request was approved and its run failed, so a person may run it again. */
79
130
  export declare function isRerunnable(r: ConnectorRecord | undefined): boolean;
80
131
  /**
@@ -93,7 +144,7 @@ export declare function rerun(r: ConnectorRecord, person: MiadiPerson, deps: App
93
144
  }>;
94
145
  export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
95
146
  /** What approving a request does, in one sentence, for the card a person decides on. */
96
- export declare function consequenceOf(r: ConnectorRecord): string;
147
+ export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization): string;
97
148
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
98
149
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
99
150
  /**
@@ -104,5 +155,7 @@ export declare function requestProjection(record: ConnectorRecord, opts?: {
104
155
  reviewBase?: string;
105
156
  factoryDir?: string;
106
157
  }): RequestProjection | null;
158
+ /** The person's own provider policy page: read and change it, signed in with their person token (jgwill/Miadi#926). */
159
+ export declare function registerProviderPolicyRoutes(app: Express, deps: Pick<ApprovalDeps, "ledger" | "identify" | "log">): void;
107
160
  /** How a request is named to a person. */
108
161
  export declare const KIND_LABELS: Record<string, string>;