@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.
- package/README.md +40 -5
- package/dist/approve.d.ts +16 -2
- package/dist/approve.js +185 -16
- package/dist/bin.js +3 -0
- package/dist/mission-status.d.ts +13 -1
- package/dist/mission-status.js +13 -5
- package/dist/model-preflight.d.ts +9 -2
- package/dist/model-preflight.js +23 -1
- package/dist/provider-policy.d.ts +94 -0
- package/dist/provider-policy.js +150 -0
- package/dist/providers.d.ts +7 -3
- package/dist/providers.js +13 -7
- package/dist/queue-state.js +12 -1
- package/dist/reading/adapters.d.ts +18 -0
- package/dist/reading/adapters.js +90 -0
- package/dist/reading/contract.d.ts +153 -0
- package/dist/reading/contract.js +79 -0
- package/dist/reading/markdown.d.ts +47 -0
- package/dist/reading/markdown.js +52 -0
- package/dist/reading/privacy.d.ts +7 -0
- package/dist/reading/privacy.js +14 -0
- package/dist/reading/workflow.d.ts +93 -0
- package/dist/reading/workflow.js +325 -0
- package/dist/records.d.ts +1 -1
- package/dist/records.js +6 -0
- package/dist/request-state.js +9 -1
- package/dist/research-digest.d.ts +30 -0
- package/dist/research-digest.js +104 -0
- package/dist/research-shelf.d.ts +173 -0
- package/dist/research-shelf.js +297 -0
- package/dist/runtime-steer.d.ts +182 -0
- package/dist/runtime-steer.js +543 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +4 -12
- package/dist/steer.d.ts +21 -7
- package/dist/steer.js +115 -10
- package/dist/store.d.ts +2 -0
- package/dist/store.js +18 -1
- package/dist/tools.d.ts +29 -1
- package/dist/tools.js +435 -50
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
...(
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
822
|
-
|
|
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}`);
|
package/dist/mission-status.d.ts
CHANGED
|
@@ -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). */
|
package/dist/mission-status.js
CHANGED
|
@@ -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
|
-
:
|
|
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;
|