@miadi/connector 0.1.9 → 0.1.10
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 +10 -2
- package/dist/approve.d.ts +5 -0
- package/dist/approve.js +30 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mission-status.d.ts +87 -0
- package/dist/mission-status.js +144 -0
- package/dist/providers.d.ts +61 -0
- package/dist/providers.js +120 -0
- package/dist/records.d.ts +1 -1
- package/dist/records.js +1 -0
- package/dist/request-state.d.ts +1 -1
- package/dist/request-state.js +8 -1
- package/dist/server.d.ts +8 -0
- package/dist/server.js +16 -1
- package/dist/steer.d.ts +71 -0
- package/dist/steer.js +229 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +185 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -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` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `search` `fetch` |
|
|
23
|
+
| reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `search` `fetch` |
|
|
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_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` `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.
|
|
@@ -40,6 +40,14 @@ The model that asked cannot approve its own request.
|
|
|
40
40
|
|
|
41
41
|
`get_run_result` returns one `projection` of the decision, dispatch/completion state, receipt link and outstanding instructions. The in-chat card, approval page and Miadi's research page use that same read model. Asking `show_decisions` for an already-decided `record_id` shows its receipt without issuing another decision token. An in-flight card can refresh status without repeating the operation. A completed basic review with unapplied approval instructions remains open as **changes outstanding**, not falsely finished.
|
|
42
42
|
|
|
43
|
+
### Following and steering a mission
|
|
44
|
+
|
|
45
|
+
`mission_status` joins one mission read-only: its source request and decision, the sessions bound to it (each with how it is bound — a signed `session:` ref, the delivered brief's target, or the requested name), its issues and PRs, its steering grant, and where it stands stage by stage — approved, queued, delivered, running, merged, released, closed — each by its evidence, so an idle pane or a merge is never read as completion. Live panes and Miadi's session view are joined only for the write tier.
|
|
46
|
+
|
|
47
|
+
`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. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
|
|
48
|
+
|
|
49
|
+
`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 routing preflight (`unconfigured` without `MIADI_JEV_URL`). A provider name inside a task's text selects nothing.
|
|
50
|
+
|
|
43
51
|
### Deciding inside the conversation
|
|
44
52
|
|
|
45
53
|
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.
|
package/dist/approve.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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 TmuxLike } from "./steer.js";
|
|
19
20
|
export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
20
21
|
export interface ApprovalDeps {
|
|
21
22
|
config: ConnectorConfig;
|
|
@@ -26,6 +27,10 @@ export interface ApprovalDeps {
|
|
|
26
27
|
/** The factory directory holding queue/ (default ~/.miadi/factory). */
|
|
27
28
|
factoryDir?: string;
|
|
28
29
|
tmuxSessions?: () => string[];
|
|
30
|
+
/** The steering surface (jgwill/Miadi#893); tests inject a fake, production uses tmux. */
|
|
31
|
+
tmux?: TmuxLike;
|
|
32
|
+
/** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
|
|
33
|
+
steerSettleMs?: number;
|
|
29
34
|
}
|
|
30
35
|
/**
|
|
31
36
|
* What a person may decide on a request. Approving with changes runs it with the
|
package/dist/approve.js
CHANGED
|
@@ -19,6 +19,7 @@ import { MiadiUnavailable } from "./people.js";
|
|
|
19
19
|
import { current, newRecordId } from "./records.js";
|
|
20
20
|
import { APPROVABLE, projectRequest } from "./request-state.js";
|
|
21
21
|
import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
22
|
+
import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
|
|
22
23
|
export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
23
24
|
/**
|
|
24
25
|
* What a person may decide on a request. Approving with changes runs it with the
|
|
@@ -67,7 +68,15 @@ export function runBrief(record, approver, sessions, instructions) {
|
|
|
67
68
|
const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
|
|
68
69
|
const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
|
|
69
70
|
const cwd = body.cwd || "/a/src/Miadi-18";
|
|
70
|
-
const open = [
|
|
71
|
+
const open = [
|
|
72
|
+
`to="${attr(to)}"`,
|
|
73
|
+
'status="send"',
|
|
74
|
+
'type="connector-run"',
|
|
75
|
+
`record="${attr(record.id)}"`,
|
|
76
|
+
// The provider the connector verified at request time (jgwill/Miadi#893); the intake may honour it or record why not.
|
|
77
|
+
...(body.resolved_provider ? [`provider="${attr(body.resolved_provider)}"`] : []),
|
|
78
|
+
...(to.startsWith("new:") ? [`cwd="${attr(cwd)}"`] : []),
|
|
79
|
+
];
|
|
71
80
|
const lines = [
|
|
72
81
|
`<input ${open.join(" ")}>`,
|
|
73
82
|
`A bounded task requested through the Miadi connector (jgwill/Miadi#815, #816).`,
|
|
@@ -202,6 +211,23 @@ export async function executeApproved(record, approver, deps, instructions) {
|
|
|
202
211
|
ending: b.ending,
|
|
203
212
|
note: "Recorded only. The witness seat writes it into the inventory record and closes the session by its own lifecycle.",
|
|
204
213
|
};
|
|
214
|
+
case "session_steer_request": {
|
|
215
|
+
// The person's approval of this exact steer is its authorization; the
|
|
216
|
+
// grant record it cites still binds the session (jgwill/Miadi#893).
|
|
217
|
+
const grantId = record.refs.find((r) => r.startsWith("run_request:") || r.startsWith("work_proposal:"));
|
|
218
|
+
const grantRecord = grantId ? current(deps.ledger.all()).find((x) => x.id === grantId) : undefined;
|
|
219
|
+
if (!grantRecord)
|
|
220
|
+
return { outcome: "failed", message: "The run whose grant this steer cites is gone." };
|
|
221
|
+
const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, { record, grantRecord, priorAttempts: steerAttempts(current(deps.ledger.all()), grantRecord.id), authorized: true });
|
|
222
|
+
const delivered = result.status === "accepted" || result.status === "submitted";
|
|
223
|
+
return {
|
|
224
|
+
outcome: delivered ? result.status : "failed",
|
|
225
|
+
steer_status: result.status,
|
|
226
|
+
message: result.message,
|
|
227
|
+
...(result.target ? { target: result.target } : {}),
|
|
228
|
+
...(result.artifact ? { artifact: result.artifact } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
205
231
|
default:
|
|
206
232
|
return { outcome: "nothing_to_run" };
|
|
207
233
|
}
|
|
@@ -398,6 +424,8 @@ export function consequenceOf(r) {
|
|
|
398
424
|
return b.new_episode ? "Approving mints a new episode with the inquiry woven in." : `Approving weaves the inquiry into episode ${b.episode ?? "?"}.`;
|
|
399
425
|
case "session_completion_proposal":
|
|
400
426
|
return `Approving records the ending (${b.ending ?? "?"}) for the session. No pane is closed.`;
|
|
427
|
+
case "session_steer_request":
|
|
428
|
+
return `Approving types exactly this instruction into the bound agent session (${b.session ?? "the run's session"}), once, and records whether the agent accepted it.`;
|
|
401
429
|
default:
|
|
402
430
|
return "Approving runs the request once, as you.";
|
|
403
431
|
}
|
|
@@ -594,4 +622,5 @@ export const KIND_LABELS = {
|
|
|
594
622
|
review_episode_link_request: "Review into an episode",
|
|
595
623
|
inquiry_episode_request: "Inquiry into an episode",
|
|
596
624
|
session_completion_proposal: "Session ending",
|
|
625
|
+
session_steer_request: "Session steer",
|
|
597
626
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,7 @@ export { MiadiOAuthProvider, SCOPES, SCOPE_READ, SCOPE_CONTRIBUTE, type Person }
|
|
|
5
5
|
export { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, RECORD_KINDS, current, type ConnectorRecord, type CircleId, type ClaimCategory, type RecordKind, } from "./records.js";
|
|
6
6
|
export { TOOL_STATUS } from "./tools.js";
|
|
7
7
|
export { runBrief, readQueueState } from "./approve.js";
|
|
8
|
+
export { PROVIDERS, discoverProviders, resolveProvider, routerPreflight, type ProviderAvailability, type ProviderId, type ProviderResolution, type RouterPreflight, } from "./providers.js";
|
|
9
|
+
export { missionStatus, resolveMissionSubject, type MissionStatus, type MissionStatusDeps } from "./mission-status.js";
|
|
10
|
+
export { AGENT_COMMANDS, BUSY_PATTERNS, STEER_STATUSES, checkGrant, isBusy, readSteerArtifact, steerAttempts, steerSession, steeringGrantOf, tmuxAdapter, type SteerDeps, type SteerResult, type SteerStatus, type SteeringGrant, type TmuxLike, } from "./steer.js";
|
|
8
11
|
export { VERSION } from "./version.js";
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,7 @@ export { MiadiOAuthProvider, SCOPES, SCOPE_READ, SCOPE_CONTRIBUTE } from "./oaut
|
|
|
5
5
|
export { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, RECORD_KINDS, current, } from "./records.js";
|
|
6
6
|
export { TOOL_STATUS } from "./tools.js";
|
|
7
7
|
export { runBrief, readQueueState } from "./approve.js";
|
|
8
|
+
export { PROVIDERS, discoverProviders, resolveProvider, routerPreflight, } from "./providers.js";
|
|
9
|
+
export { missionStatus, resolveMissionSubject } from "./mission-status.js";
|
|
10
|
+
export { AGENT_COMMANDS, BUSY_PATTERNS, STEER_STATUSES, checkGrant, isBusy, readSteerArtifact, steerAttempts, steerSession, steeringGrantOf, tmuxAdapter, } from "./steer.js";
|
|
8
11
|
export { VERSION } from "./version.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only mission status (jgwill/Miadi#893, Phase 1A): where one mission
|
|
3
|
+
* stands, joined from the connector ledger, the factory queue, tmux, Miadi's
|
|
4
|
+
* session view and GitHub — with every stage named by its evidence, so an idle
|
|
5
|
+
* pane or a merge is never read as completion.
|
|
6
|
+
*
|
|
7
|
+
* The caller (tools.ts) decides the viewer's tier first: without the write
|
|
8
|
+
* tier there are no operator details (live panes, session internals); the
|
|
9
|
+
* record's own standing still answers.
|
|
10
|
+
*/
|
|
11
|
+
import type { ConnectorRecord } from "./records.js";
|
|
12
|
+
import type { RequestProjection } from "./request-state.js";
|
|
13
|
+
export interface MissionSession {
|
|
14
|
+
/** How this session is bound to the mission: signed_ref (a session: ref on the record), brief_target (the delivered brief's `to`), named (the requested name). */
|
|
15
|
+
association: "signed_ref" | "brief_target" | "named";
|
|
16
|
+
session: string;
|
|
17
|
+
live: "running" | "absent" | "unknown";
|
|
18
|
+
activity?: "busy" | "idle" | "unknown";
|
|
19
|
+
/** Present when Miadi's session view was joined (write tier). */
|
|
20
|
+
miadi?: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface MissionStageTruth {
|
|
23
|
+
approved: boolean;
|
|
24
|
+
queued: boolean;
|
|
25
|
+
delivered: boolean;
|
|
26
|
+
/** "evidence" only while a bound session is live; an idle pane proves nothing. */
|
|
27
|
+
running: "evidence" | "no_evidence";
|
|
28
|
+
pr_merged: "merged" | "open_or_absent" | "no_pr";
|
|
29
|
+
released: "evidence" | "no_evidence";
|
|
30
|
+
closed: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface MissionStatus {
|
|
33
|
+
ref: string;
|
|
34
|
+
found: boolean;
|
|
35
|
+
via?: string;
|
|
36
|
+
subject?: {
|
|
37
|
+
id: string;
|
|
38
|
+
kind: string;
|
|
39
|
+
created_at: string;
|
|
40
|
+
by: string;
|
|
41
|
+
circle?: string;
|
|
42
|
+
intention?: string;
|
|
43
|
+
};
|
|
44
|
+
standing?: RequestProjection;
|
|
45
|
+
queue?: {
|
|
46
|
+
state: string;
|
|
47
|
+
};
|
|
48
|
+
sessions?: MissionSession[];
|
|
49
|
+
issues?: {
|
|
50
|
+
ref: string;
|
|
51
|
+
state: string;
|
|
52
|
+
title?: string;
|
|
53
|
+
merged?: boolean;
|
|
54
|
+
}[];
|
|
55
|
+
steering?: {
|
|
56
|
+
grant: "none" | "active" | "expired_grant" | "exhausted";
|
|
57
|
+
attempts_used: number;
|
|
58
|
+
max_attempts?: number;
|
|
59
|
+
};
|
|
60
|
+
stage_truth?: MissionStageTruth;
|
|
61
|
+
next_authorized_action?: string;
|
|
62
|
+
notes: string[];
|
|
63
|
+
}
|
|
64
|
+
export interface MissionStatusDeps {
|
|
65
|
+
queueOf?: (record: ConnectorRecord) => {
|
|
66
|
+
state: string;
|
|
67
|
+
} | undefined;
|
|
68
|
+
projectOf: (record: ConnectorRecord) => RequestProjection | null;
|
|
69
|
+
/** Live tmux session names, when the host can list them (write tier only). */
|
|
70
|
+
tmuxSessions?: () => string[];
|
|
71
|
+
/** Read-only peek: is the named session's first pane mid-turn? (write tier only.) */
|
|
72
|
+
paneBusy?: (session: string) => "busy" | "idle" | "unknown";
|
|
73
|
+
/** Miadi's session view by session:<uuid> (write tier only). */
|
|
74
|
+
sessionOf?: (id: string) => Promise<unknown>;
|
|
75
|
+
issueOf?: (owner: string, repo: string, n: string) => Promise<{
|
|
76
|
+
state: string;
|
|
77
|
+
title?: string;
|
|
78
|
+
merged?: boolean;
|
|
79
|
+
}>;
|
|
80
|
+
now?: () => Date;
|
|
81
|
+
}
|
|
82
|
+
/** The record a mission reference resolves to, and how it was found. */
|
|
83
|
+
export declare function resolveMissionSubject(ref: string, records: ConnectorRecord[]): {
|
|
84
|
+
record: ConnectorRecord;
|
|
85
|
+
via: string;
|
|
86
|
+
} | null;
|
|
87
|
+
export declare function missionStatus(ref: string, records: ConnectorRecord[], deps: MissionStatusDeps): Promise<MissionStatus>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { steerAttempts, steeringGrantOf, checkGrant } from "./steer.js";
|
|
2
|
+
const ISSUE_REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
|
|
3
|
+
const SESSION_REF = /^session:([0-9a-f-]{36})$/;
|
|
4
|
+
/** The record a mission reference resolves to, and how it was found. */
|
|
5
|
+
export function resolveMissionSubject(ref, records) {
|
|
6
|
+
const exact = records.find((r) => r.id === ref);
|
|
7
|
+
if (exact)
|
|
8
|
+
return { record: exact, via: "record_id" };
|
|
9
|
+
const session = SESSION_REF.exec(ref);
|
|
10
|
+
if (session) {
|
|
11
|
+
const citing = records
|
|
12
|
+
.filter((r) => (r.kind === "run_request" || r.kind === "work_proposal") && r.refs.includes(ref))
|
|
13
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
14
|
+
if (citing.length)
|
|
15
|
+
return { record: citing[0], via: "session_ref" };
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (ISSUE_REF.test(ref)) {
|
|
19
|
+
const citing = records
|
|
20
|
+
.filter((r) => (r.kind === "run_request" || r.kind === "work_proposal") && (r.refs.includes(ref) || r.evidence_refs.includes(ref)))
|
|
21
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
22
|
+
if (citing.length)
|
|
23
|
+
return { record: citing[0], via: "issue_ref" };
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function nextAction(record, standing, queue) {
|
|
29
|
+
if (!standing)
|
|
30
|
+
return "Read the record itself; it is not an approvable request.";
|
|
31
|
+
switch (standing.stage) {
|
|
32
|
+
case "awaiting_decision":
|
|
33
|
+
return "A person with Miadi's write tier decides on its approval page or a decisions card.";
|
|
34
|
+
case "recording":
|
|
35
|
+
return "A decision is being recorded; read it again shortly.";
|
|
36
|
+
case "declined":
|
|
37
|
+
case "sent_back":
|
|
38
|
+
return standing.stage === "declined" ? "Nothing runs; a new request is needed." : "Submit a revised request citing the old id.";
|
|
39
|
+
case "dispatching":
|
|
40
|
+
return queue?.state === "delivered"
|
|
41
|
+
? "The brief is with its session. Follow it, or steer it with request_session_steer inside the approved grant."
|
|
42
|
+
: "Wait for the factory intake to deliver the brief.";
|
|
43
|
+
case "failed":
|
|
44
|
+
return standing.rerun.available ? "A write-tier person may run it again with the same approval." : "Read the failure and decide.";
|
|
45
|
+
case "completed":
|
|
46
|
+
return standing.outstanding.length
|
|
47
|
+
? "The approved changes are still outstanding; they need their own authorized step."
|
|
48
|
+
: "The request is done. A mission closes when its session's ending is decided, not when this request completes.";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function missionStatus(ref, records, deps) {
|
|
52
|
+
const notes = [];
|
|
53
|
+
const found = resolveMissionSubject(ref, records);
|
|
54
|
+
if (!found)
|
|
55
|
+
return { ref, found: false, notes: ["No mission record resolves from this reference."] };
|
|
56
|
+
const { record, via } = found;
|
|
57
|
+
const standing = deps.projectOf(record) ?? undefined;
|
|
58
|
+
const queue = deps.queueOf?.(record);
|
|
59
|
+
const now = deps.now?.() ?? new Date();
|
|
60
|
+
// The sessions this mission names, each with how it is bound.
|
|
61
|
+
const sessions = [];
|
|
62
|
+
const live = deps.tmuxSessions ? new Set(deps.tmuxSessions()) : undefined;
|
|
63
|
+
const briefTo = (record.completion_receipt ?? {}).to;
|
|
64
|
+
const named = String(record.body.session ?? "") || undefined;
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
const addSession = (session, association) => {
|
|
67
|
+
const key = `${association}:${session}`;
|
|
68
|
+
if (seen.has(session))
|
|
69
|
+
return;
|
|
70
|
+
seen.add(session);
|
|
71
|
+
sessions.push({
|
|
72
|
+
association,
|
|
73
|
+
session,
|
|
74
|
+
live: live ? (live.has(session) ? "running" : "absent") : "unknown",
|
|
75
|
+
...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session) } : {}),
|
|
76
|
+
});
|
|
77
|
+
void key;
|
|
78
|
+
};
|
|
79
|
+
if (typeof briefTo === "string" && briefTo)
|
|
80
|
+
addSession(briefTo.replace(/^new:/, ""), "brief_target");
|
|
81
|
+
if (named)
|
|
82
|
+
addSession(named, "named");
|
|
83
|
+
for (const r of record.refs) {
|
|
84
|
+
const m = SESSION_REF.exec(r);
|
|
85
|
+
if (m) {
|
|
86
|
+
const miadi = deps.sessionOf ? await deps.sessionOf(m[1]).catch(() => ({ state: "unavailable" })) : undefined;
|
|
87
|
+
sessions.push({ association: "signed_ref", session: r, live: "unknown", ...(miadi ? { miadi } : {}) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!live)
|
|
91
|
+
notes.push("Live session state was not joined (the viewer's tier or the host does not allow it).");
|
|
92
|
+
const issues = [];
|
|
93
|
+
if (deps.issueOf) {
|
|
94
|
+
for (const r of [...record.refs, ...record.evidence_refs]) {
|
|
95
|
+
const m = ISSUE_REF.exec(r);
|
|
96
|
+
if (!m)
|
|
97
|
+
continue;
|
|
98
|
+
const issue = await deps
|
|
99
|
+
.issueOf(m[1], m[2], m[3])
|
|
100
|
+
.catch(() => ({ state: "unavailable" }));
|
|
101
|
+
issues.push({ ref: r, state: issue.state, ...(issue.title ? { title: issue.title } : {}), ...(issue.merged !== undefined ? { merged: issue.merged } : {}) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const grant = steeringGrantOf(record);
|
|
105
|
+
const attempts = steerAttempts(records, record.id);
|
|
106
|
+
const grantCheck = grant ? checkGrant(grant, attempts, now) : null;
|
|
107
|
+
const grantState = grantCheck === null ? "none" : grantCheck === "ok" ? "active" : grantCheck === "expired_grant" ? "expired_grant" : "exhausted";
|
|
108
|
+
const receipt = (record.completion_receipt ?? {});
|
|
109
|
+
const anyLive = sessions.some((s) => s.live === "running");
|
|
110
|
+
const prs = issues.filter((i) => i.merged !== undefined);
|
|
111
|
+
const stageTruth = {
|
|
112
|
+
approved: record.approval_state === "approved",
|
|
113
|
+
queued: queue?.state === "queued" || queue?.state === "delivered",
|
|
114
|
+
delivered: queue?.state === "delivered",
|
|
115
|
+
running: anyLive ? "evidence" : "no_evidence",
|
|
116
|
+
pr_merged: prs.length ? (prs.every((p) => p.merged) ? "merged" : "open_or_absent") : "no_pr",
|
|
117
|
+
released: receipt.published || receipt.version ? "evidence" : "no_evidence",
|
|
118
|
+
closed: standing?.stage === "completed" && standing?.standing === "done",
|
|
119
|
+
};
|
|
120
|
+
if (stageTruth.running === "no_evidence" && record.approval_state === "approved") {
|
|
121
|
+
notes.push("No bound session shows live; an idle or absent pane is not proof of completion or failure.");
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ref,
|
|
125
|
+
found: true,
|
|
126
|
+
via,
|
|
127
|
+
subject: {
|
|
128
|
+
id: record.id,
|
|
129
|
+
kind: record.kind,
|
|
130
|
+
created_at: record.created_at,
|
|
131
|
+
by: record.actor.name,
|
|
132
|
+
...(record.circle ? { circle: record.circle } : {}),
|
|
133
|
+
...(record.intention ? { intention: record.intention } : {}),
|
|
134
|
+
},
|
|
135
|
+
...(standing ? { standing } : {}),
|
|
136
|
+
...(queue ? { queue } : {}),
|
|
137
|
+
sessions,
|
|
138
|
+
issues,
|
|
139
|
+
steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
|
|
140
|
+
stage_truth: stageTruth,
|
|
141
|
+
next_authorized_action: nextAction(record, standing ?? null, queue),
|
|
142
|
+
notes,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type ProviderId = "codex" | "pi" | "copilot" | "claude";
|
|
2
|
+
export interface ProviderSpec {
|
|
3
|
+
id: ProviderId;
|
|
4
|
+
cli: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** False when a provider may run only on an explicit, authorized request. */
|
|
7
|
+
selectable_by_default: boolean;
|
|
8
|
+
/** Config paths whose existence suggests a sign-in; contents are never read. */
|
|
9
|
+
auth_evidence: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Preference order when a run request names no provider (jgwill/Miadi#893). */
|
|
12
|
+
export declare const PROVIDERS: ProviderSpec[];
|
|
13
|
+
export interface ProviderAvailability {
|
|
14
|
+
id: ProviderId;
|
|
15
|
+
name: string;
|
|
16
|
+
cli: string;
|
|
17
|
+
installed: boolean;
|
|
18
|
+
version?: string;
|
|
19
|
+
auth_evidence: "found" | "none";
|
|
20
|
+
selectable_by_default: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface DiscoveryProbe {
|
|
23
|
+
which?: (cli: string) => string | null;
|
|
24
|
+
versionOf?: (cli: string) => string | undefined;
|
|
25
|
+
exists?: (path: string) => boolean;
|
|
26
|
+
home?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function discoverProviders(probe?: DiscoveryProbe, specs?: ProviderSpec[]): ProviderAvailability[];
|
|
29
|
+
export type ProviderResolution = {
|
|
30
|
+
state: "ok";
|
|
31
|
+
provider: ProviderAvailability;
|
|
32
|
+
explicit: boolean;
|
|
33
|
+
} | {
|
|
34
|
+
state: "provider_unknown" | "provider_unavailable" | "provider_needs_explicit_selection";
|
|
35
|
+
message: string;
|
|
36
|
+
available: ProviderAvailability[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the provider a run will ask the intake for. A requested provider must
|
|
40
|
+
* be known and installed; with no request the first installed provider in the
|
|
41
|
+
* preference order that may be selected by default wins — Claude Code only ever
|
|
42
|
+
* by an explicit request.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveProvider(requested: string | undefined, found: ProviderAvailability[]): ProviderResolution;
|
|
45
|
+
export type RouterPreflight = {
|
|
46
|
+
state: "unconfigured";
|
|
47
|
+
note: string;
|
|
48
|
+
} | {
|
|
49
|
+
state: "reachable";
|
|
50
|
+
url: string;
|
|
51
|
+
} | {
|
|
52
|
+
state: "router_unavailable";
|
|
53
|
+
url: string;
|
|
54
|
+
message: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The read-only Jev routing preflight (jgwill/Miadi#893, section E): is a Jev
|
|
58
|
+
* decision-aide endpoint configured and reachable from this host? Nothing is
|
|
59
|
+
* sent but a health probe; no key is read here and no source text ever leaves.
|
|
60
|
+
*/
|
|
61
|
+
export declare function routerPreflight(env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Promise<RouterPreflight>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider capability discovery and selection policy (jgwill/Miadi#893, Phase 1C).
|
|
3
|
+
*
|
|
4
|
+
* The connector never assumes a provider works because a task text names one:
|
|
5
|
+
* it reports what is installed on this host, what sign-in evidence exists (the
|
|
6
|
+
* presence of the provider's own config directory — never its contents), and
|
|
7
|
+
* applies an explicit preference order. Claude Code is never selected silently:
|
|
8
|
+
* it requires an explicit request. When nothing safe is configured the answer
|
|
9
|
+
* is `provider_unavailable` and the mission is left unstarted.
|
|
10
|
+
*
|
|
11
|
+
* No bearer token is read, logged or returned here. Version probes run the
|
|
12
|
+
* provider's own `--version` with a short timeout.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
/** Preference order when a run request names no provider (jgwill/Miadi#893). */
|
|
19
|
+
export const PROVIDERS = [
|
|
20
|
+
{ id: "codex", cli: "codex", name: "Codex CLI", selectable_by_default: true, auth_evidence: [".codex"] },
|
|
21
|
+
{ id: "pi", cli: "pi", name: "Pi coding agent", selectable_by_default: true, auth_evidence: [".pi", ".config/pi"] },
|
|
22
|
+
{ id: "copilot", cli: "copilot", name: "GitHub Copilot CLI", selectable_by_default: true, auth_evidence: [".copilot"] },
|
|
23
|
+
{ id: "claude", cli: "claude", name: "Claude Code", selectable_by_default: false, auth_evidence: [".claude"] },
|
|
24
|
+
];
|
|
25
|
+
const defaultWhich = (cli) => {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("sh", ["-c", `command -v "$1"`, "sh", cli], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const defaultVersionOf = (cli) => {
|
|
34
|
+
try {
|
|
35
|
+
return execFileSync(cli, ["--version"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).split("\n")[0]?.trim() || undefined;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
export function discoverProviders(probe = {}, specs = PROVIDERS) {
|
|
42
|
+
const which = probe.which ?? defaultWhich;
|
|
43
|
+
const versionOf = probe.versionOf ?? defaultVersionOf;
|
|
44
|
+
const exists = probe.exists ?? existsSync;
|
|
45
|
+
const home = probe.home ?? homedir();
|
|
46
|
+
return specs.map((s) => {
|
|
47
|
+
const installed = which(s.cli) !== null;
|
|
48
|
+
return {
|
|
49
|
+
id: s.id,
|
|
50
|
+
name: s.name,
|
|
51
|
+
cli: s.cli,
|
|
52
|
+
installed,
|
|
53
|
+
...(installed ? { version: versionOf(s.cli) } : {}),
|
|
54
|
+
auth_evidence: s.auth_evidence.some((p) => exists(join(home, p))) ? "found" : "none",
|
|
55
|
+
selectable_by_default: s.selectable_by_default,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the provider a run will ask the intake for. A requested provider must
|
|
61
|
+
* be known and installed; with no request the first installed provider in the
|
|
62
|
+
* preference order that may be selected by default wins — Claude Code only ever
|
|
63
|
+
* by an explicit request.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveProvider(requested, found) {
|
|
66
|
+
const installed = found.filter((p) => p.installed);
|
|
67
|
+
if (requested) {
|
|
68
|
+
const spec = PROVIDERS.find((p) => p.id === requested || p.cli === requested);
|
|
69
|
+
if (!spec) {
|
|
70
|
+
return {
|
|
71
|
+
state: "provider_unknown",
|
|
72
|
+
message: `Unknown provider "${requested}". Known: ${PROVIDERS.map((p) => p.id).join(", ")}.`,
|
|
73
|
+
available: installed,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const avail = found.find((p) => p.id === spec.id);
|
|
77
|
+
if (!avail.installed) {
|
|
78
|
+
return {
|
|
79
|
+
state: "provider_unavailable",
|
|
80
|
+
message: `${avail.name} is not installed on the connector host. The mission is left unstarted; name an available provider or install this one.`,
|
|
81
|
+
available: installed,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { state: "ok", provider: avail, explicit: true };
|
|
85
|
+
}
|
|
86
|
+
const first = PROVIDERS.map((s) => found.find((p) => p.id === s.id)).find((p) => p.installed && p.selectable_by_default);
|
|
87
|
+
if (first)
|
|
88
|
+
return { state: "ok", provider: first, explicit: false };
|
|
89
|
+
if (installed.length) {
|
|
90
|
+
return {
|
|
91
|
+
state: "provider_needs_explicit_selection",
|
|
92
|
+
message: `Only providers that need an explicit request are installed (${installed.map((p) => p.id).join(", ")}). Name one in the request; none is chosen silently.`,
|
|
93
|
+
available: installed,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { state: "provider_unavailable", message: "No agent provider is installed on the connector host.", available: [] };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The read-only Jev routing preflight (jgwill/Miadi#893, section E): is a Jev
|
|
100
|
+
* decision-aide endpoint configured and reachable from this host? Nothing is
|
|
101
|
+
* sent but a health probe; no key is read here and no source text ever leaves.
|
|
102
|
+
*/
|
|
103
|
+
export async function routerPreflight(env = process.env, fetchImpl = fetch) {
|
|
104
|
+
const url = (env.MIADI_JEV_URL ?? "").replace(/\/$/, "");
|
|
105
|
+
if (!url) {
|
|
106
|
+
return {
|
|
107
|
+
state: "unconfigured",
|
|
108
|
+
note: "No MIADI_JEV_URL on the connector host: model/tool routing falls back to the explicit provider policy and the person's choice, never to a silent default.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetchImpl(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
|
113
|
+
if (res.ok)
|
|
114
|
+
return { state: "reachable", url };
|
|
115
|
+
return { state: "router_unavailable", url, message: `Jev answered ${res.status}; route by the explicit provider policy instead.` };
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return { state: "router_unavailable", url, message: `Jev is not reachable (${error instanceof Error ? error.message : String(error)}); route by the explicit provider policy instead.` };
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/records.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export declare const DOMAINS: readonly ["creative", "ceremonial", "engineering"]
|
|
|
35
35
|
/** #815 "claim category": what kind of statement a record makes. */
|
|
36
36
|
export declare const CLAIM_CATEGORIES: readonly ["observed", "hypothesis", "desired_future", "fiction", "verified_implementation"];
|
|
37
37
|
export type ClaimCategory = (typeof CLAIM_CATEGORIES)[number];
|
|
38
|
-
export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal", "research_artifact", "inquiry_episode_request"];
|
|
38
|
+
export declare const RECORD_KINDS: readonly ["observation", "inquiry", "link", "work_proposal", "run_request", "handoff", "source_intent", "review_operation_request", "deep_diver_request", "review_episode_link_request", "calendar_event_draft", "session_completion_proposal", "research_artifact", "inquiry_episode_request", "session_steer_request"];
|
|
39
39
|
export type RecordKind = (typeof RECORD_KINDS)[number];
|
|
40
40
|
/** #816 handoff states. */
|
|
41
41
|
export declare const HANDOFF_STATES: readonly ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
|
package/dist/records.js
CHANGED
|
@@ -60,6 +60,7 @@ export const RECORD_KINDS = [
|
|
|
60
60
|
"session_completion_proposal",
|
|
61
61
|
"research_artifact",
|
|
62
62
|
"inquiry_episode_request",
|
|
63
|
+
"session_steer_request",
|
|
63
64
|
];
|
|
64
65
|
/** #816 handoff states. */
|
|
65
66
|
export const HANDOFF_STATES = ["pending", "blocked", "ready", "in_progress", "completed", "withdrawn"];
|
package/dist/request-state.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* work proposals) lists them as outstanding. The projection never says they ran.
|
|
18
18
|
*/
|
|
19
19
|
import type { ConnectorRecord } from "./records.js";
|
|
20
|
-
export declare const APPROVABLE_KINDS: readonly ["run_request", "review_operation_request", "deep_diver_request", "review_episode_link_request", "work_proposal", "session_completion_proposal", "inquiry_episode_request"];
|
|
20
|
+
export declare const APPROVABLE_KINDS: readonly ["run_request", "review_operation_request", "deep_diver_request", "review_episode_link_request", "work_proposal", "session_completion_proposal", "inquiry_episode_request", "session_steer_request"];
|
|
21
21
|
export declare const APPROVABLE: ReadonlySet<string>;
|
|
22
22
|
/** `recording`: a decision was taken a moment ago and its run may still be going; it is not the ledger's state. */
|
|
23
23
|
export type RequestStage = "awaiting_decision" | "recording" | "declined" | "sent_back" | "dispatching" | "completed" | "failed";
|
package/dist/request-state.js
CHANGED
|
@@ -6,6 +6,7 @@ export const APPROVABLE_KINDS = [
|
|
|
6
6
|
"work_proposal",
|
|
7
7
|
"session_completion_proposal",
|
|
8
8
|
"inquiry_episode_request",
|
|
9
|
+
"session_steer_request",
|
|
9
10
|
];
|
|
10
11
|
export const APPROVABLE = new Set(APPROVABLE_KINDS);
|
|
11
12
|
/** The kinds whose approved run carries the approver's instructions into what it does. */
|
|
@@ -15,6 +16,7 @@ const CHOICE_WORDS = {
|
|
|
15
16
|
approve_with_changes: "Approved with changes",
|
|
16
17
|
request_revision: "Sent back for revision",
|
|
17
18
|
decline: "Declined",
|
|
19
|
+
covered_by_grant: "Covered by the approved run's steering grant",
|
|
18
20
|
};
|
|
19
21
|
const OUTCOME_WORDS = {
|
|
20
22
|
queued: "queued for the factory intake",
|
|
@@ -25,6 +27,8 @@ const OUTCOME_WORDS = {
|
|
|
25
27
|
marked_ready: "marked ready for Deep Diver",
|
|
26
28
|
decided: "recorded",
|
|
27
29
|
nothing_to_run: "nothing to run",
|
|
30
|
+
accepted: "accepted by the session",
|
|
31
|
+
submitted: "typed into the session, acceptance unconfirmed",
|
|
28
32
|
failed: "failed",
|
|
29
33
|
};
|
|
30
34
|
const NOT_CARRIED = "The changes you approved are recorded and did not run: this kind of request does not carry instructions into its run. They are still to do.";
|
|
@@ -71,7 +75,10 @@ export function projectRequest(record, ctx = {}) {
|
|
|
71
75
|
lines.push(`Instructions for whoever asked: ${instructions}`);
|
|
72
76
|
return finish({ ...base, stage: "sent_back", standing: "waiting", decision, label: "Sent back for revision" }, lines);
|
|
73
77
|
}
|
|
74
|
-
|
|
78
|
+
// A grant-covered steer runs without its own decision: its authorization is
|
|
79
|
+
// the approved run's steering grant, which the receipt names.
|
|
80
|
+
const approved = record.approval_state === "approved" || (record.approval_state === "not_required" && record.kind === "session_steer_request");
|
|
81
|
+
if (!approved)
|
|
75
82
|
return null;
|
|
76
83
|
const changes = choice === "approve_with_changes";
|
|
77
84
|
const outcome = text(receipt.outcome) || undefined;
|
package/dist/server.d.ts
CHANGED
|
@@ -3,12 +3,20 @@ import type { MiadiPerson } from "./people.js";
|
|
|
3
3
|
import { Ledger } from "./store.js";
|
|
4
4
|
import type { ConnectorRecord } from "./records.js";
|
|
5
5
|
import type { ConnectorConfig } from "./config.js";
|
|
6
|
+
import type { TmuxLike } from "./steer.js";
|
|
7
|
+
import type { DiscoveryProbe } from "./providers.js";
|
|
6
8
|
export interface ConnectorDeps {
|
|
7
9
|
identify: (token: string) => Promise<MiadiPerson | null>;
|
|
8
10
|
fetchImpl?: typeof fetch;
|
|
9
11
|
/** The factory directory holding queue/ (default ~/.miadi/factory). */
|
|
10
12
|
factoryDir?: string;
|
|
11
13
|
tmuxSessions?: () => string[];
|
|
14
|
+
/** The steering surface (jgwill/Miadi#893); tests inject a fake, production uses tmux. */
|
|
15
|
+
tmux?: TmuxLike;
|
|
16
|
+
/** Provider discovery probes (jgwill/Miadi#893); tests inject fakes. */
|
|
17
|
+
providersProbe?: DiscoveryProbe;
|
|
18
|
+
/** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
|
|
19
|
+
steerSettleMs?: number;
|
|
12
20
|
/** Person-token attempts allowed per address in 15 minutes on each sign-in form (default 30). */
|
|
13
21
|
tokenAttemptsPerWindow?: number;
|
|
14
22
|
}
|
package/dist/server.js
CHANGED
|
@@ -85,6 +85,8 @@ export function createConnectorApp(config, deps) {
|
|
|
85
85
|
fetchImpl: deps.fetchImpl,
|
|
86
86
|
factoryDir: deps.factoryDir,
|
|
87
87
|
tmuxSessions: deps.tmuxSessions,
|
|
88
|
+
tmux: deps.tmux,
|
|
89
|
+
steerSettleMs: deps.steerSettleMs,
|
|
88
90
|
};
|
|
89
91
|
registerApprovalRoutes(app, approvalDeps);
|
|
90
92
|
const decisionTokens = new DecisionTokens(config.stateDir);
|
|
@@ -94,7 +96,20 @@ export function createConnectorApp(config, deps) {
|
|
|
94
96
|
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
|
95
97
|
});
|
|
96
98
|
const handle = async (req, res) => {
|
|
97
|
-
const ctx = {
|
|
99
|
+
const ctx = {
|
|
100
|
+
config,
|
|
101
|
+
miadi,
|
|
102
|
+
reviews,
|
|
103
|
+
ledger,
|
|
104
|
+
oauthState,
|
|
105
|
+
factoryDir: deps.factoryDir,
|
|
106
|
+
approval: approvalDeps,
|
|
107
|
+
decisionTokens,
|
|
108
|
+
tmux: deps.tmux,
|
|
109
|
+
tmuxSessions: deps.tmuxSessions,
|
|
110
|
+
providersProbe: deps.providersProbe,
|
|
111
|
+
steerSettleMs: deps.steerSettleMs,
|
|
112
|
+
};
|
|
98
113
|
const server = new McpServer({ name: "miadi", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
99
114
|
registerTools(server, ctx);
|
|
100
115
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
package/dist/steer.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import type { ConnectorRecord } from "./records.js";
|
|
3
|
+
export declare const STEER_STATUSES: readonly ["prepared", "submitted", "accepted", "busy", "blocked", "wrong_pane", "stale_binding", "expired_grant", "duplicate"];
|
|
4
|
+
export type SteerStatus = (typeof STEER_STATUSES)[number];
|
|
5
|
+
/** The steering grant an approved run carries: `body.steering` on the run_request or work proposal. */
|
|
6
|
+
export interface SteeringGrant {
|
|
7
|
+
/** How many steers the approval covers. */
|
|
8
|
+
max_attempts?: number;
|
|
9
|
+
/** ISO time after which no steer runs under this grant. */
|
|
10
|
+
expires_at?: string;
|
|
11
|
+
/** The pane the seat lives in, `window.pane` (default the session's first pane). */
|
|
12
|
+
pane?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface PaneInfo {
|
|
15
|
+
/** tmux pane id, e.g. %67 — evidence only, never the address. */
|
|
16
|
+
id: string;
|
|
17
|
+
window: number;
|
|
18
|
+
pane: number;
|
|
19
|
+
command: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** The tmux surface the steer needs, injectable so tests never touch a live pane. */
|
|
23
|
+
export interface TmuxLike {
|
|
24
|
+
listPanes(session: string): PaneInfo[];
|
|
25
|
+
capture(target: string): string;
|
|
26
|
+
sendLiteral(target: string, text: string): void;
|
|
27
|
+
sendEnter(target: string): void;
|
|
28
|
+
}
|
|
29
|
+
/** Commands that mark a pane as an agent seat a steer may land in. */
|
|
30
|
+
export declare const AGENT_COMMANDS: string[];
|
|
31
|
+
/** Signs a seat is mid-turn; typed text would queue behind its work. */
|
|
32
|
+
export declare const BUSY_PATTERNS: RegExp[];
|
|
33
|
+
/** The production adapter. All tmux addressing is by exact session and window.pane. */
|
|
34
|
+
export declare function tmuxAdapter(exec?: typeof execFileSync): TmuxLike;
|
|
35
|
+
export declare function steeringGrantOf(run: ConnectorRecord): SteeringGrant | null;
|
|
36
|
+
export type GrantCheck = "ok" | "no_grant" | "expired_grant" | "duplicate";
|
|
37
|
+
/** Whether a grant covers one more steer. `priorAttempts` counts the steers already run under it. */
|
|
38
|
+
export declare function checkGrant(grant: SteeringGrant | null, priorAttempts: number, now?: Date): GrantCheck;
|
|
39
|
+
/** Steers already run under a grant: decided (or grant-covered) steer records that cite it. */
|
|
40
|
+
export declare function steerAttempts(records: ConnectorRecord[], grantId: string): number;
|
|
41
|
+
export declare function isBusy(capture: string, patterns?: RegExp[]): boolean;
|
|
42
|
+
export interface SteerDeps {
|
|
43
|
+
tmux: TmuxLike;
|
|
44
|
+
stateDir: string;
|
|
45
|
+
now?: () => Date;
|
|
46
|
+
settleMs?: number;
|
|
47
|
+
sleep?: (ms: number) => Promise<void>;
|
|
48
|
+
agentCommands?: string[];
|
|
49
|
+
}
|
|
50
|
+
export interface SteerResult {
|
|
51
|
+
status: SteerStatus;
|
|
52
|
+
message: string;
|
|
53
|
+
target?: string;
|
|
54
|
+
artifact?: {
|
|
55
|
+
file: string;
|
|
56
|
+
sha256: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The artifact as last written, when a caller asks again with the same idempotency key. */
|
|
60
|
+
export declare function readSteerArtifact(stateDir: string, recordId: string): Record<string, unknown> | null;
|
|
61
|
+
/**
|
|
62
|
+
* Run one steer under its grant. The record is the session_steer_request; the
|
|
63
|
+
* grant record is the approved run the steer belongs to. A returned status
|
|
64
|
+
* other than `accepted`/`submitted` means nothing was typed.
|
|
65
|
+
*/
|
|
66
|
+
export declare function steerSession(deps: SteerDeps, args: {
|
|
67
|
+
record: ConnectorRecord;
|
|
68
|
+
grantRecord: ConnectorRecord;
|
|
69
|
+
priorAttempts: number;
|
|
70
|
+
authorized?: boolean;
|
|
71
|
+
}): Promise<SteerResult>;
|
package/dist/steer.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-aware session steering (jgwill/Miadi#893, Phase 1B): one authorized
|
|
3
|
+
* follow-up to one existing live agent, never raw tmux handed to a model.
|
|
4
|
+
*
|
|
5
|
+
* The rules this file keeps:
|
|
6
|
+
* - The target is the pane the mission is bound to — the pane the grant names,
|
|
7
|
+
* else the session's first pane (lowest window, lowest pane, jgwill/Miadi#725).
|
|
8
|
+
* The active pane plays no part.
|
|
9
|
+
* - peek → scoped act → re-peek (jgwill/Miadi#345): visible pasted text is not
|
|
10
|
+
* acceptance; acceptance is the pane recaptured and the agent seen
|
|
11
|
+
* processing, with the instruction scrolled out of the input line.
|
|
12
|
+
* - Every attempt leaves a durable artifact: the instruction, its sha256, the
|
|
13
|
+
* grant it ran under, the target, the timestamps and both captures.
|
|
14
|
+
* - Pane content is evidence, never instruction: nothing an agent printed is
|
|
15
|
+
* parsed as a directive.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
export const STEER_STATUSES = [
|
|
22
|
+
"prepared",
|
|
23
|
+
"submitted",
|
|
24
|
+
"accepted",
|
|
25
|
+
"busy",
|
|
26
|
+
"blocked",
|
|
27
|
+
"wrong_pane",
|
|
28
|
+
"stale_binding",
|
|
29
|
+
"expired_grant",
|
|
30
|
+
"duplicate",
|
|
31
|
+
];
|
|
32
|
+
/** Commands that mark a pane as an agent seat a steer may land in. */
|
|
33
|
+
export const AGENT_COMMANDS = ["claude", "codex", "copilot", "pi", "gemini", "node"];
|
|
34
|
+
/** Signs a seat is mid-turn; typed text would queue behind its work. */
|
|
35
|
+
export const BUSY_PATTERNS = [/esc (to )?interrupt/i, /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/, /\bWorking\b/, /\bThinking\b/];
|
|
36
|
+
/** The production adapter. All tmux addressing is by exact session and window.pane. */
|
|
37
|
+
export function tmuxAdapter(exec = execFileSync) {
|
|
38
|
+
const tmux = (args) => exec("tmux", args, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
|
|
39
|
+
return {
|
|
40
|
+
listPanes(session) {
|
|
41
|
+
const out = tmux(["list-panes", "-s", "-t", session, "-F", "#{pane_id}\t#{window_index}\t#{pane_index}\t#{pane_current_command}\t#{pane_active}"]);
|
|
42
|
+
return out
|
|
43
|
+
.split("\n")
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((line) => {
|
|
46
|
+
const [id, w, p, command, active] = line.split("\t");
|
|
47
|
+
return { id, window: Number(w), pane: Number(p), command, active: active === "1" };
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => a.window - b.window || a.pane - b.pane);
|
|
50
|
+
},
|
|
51
|
+
capture(target) {
|
|
52
|
+
return tmux(["capture-pane", "-p", "-t", target]);
|
|
53
|
+
},
|
|
54
|
+
// -l: the text is literal; no tmux key names in an instruction are interpreted.
|
|
55
|
+
sendLiteral(target, text) {
|
|
56
|
+
tmux(["send-keys", "-t", target, "-l", "--", text]);
|
|
57
|
+
},
|
|
58
|
+
sendEnter(target) {
|
|
59
|
+
tmux(["send-keys", "-t", target, "Enter"]);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function steeringGrantOf(run) {
|
|
64
|
+
const g = run.body.steering;
|
|
65
|
+
if (!g || typeof g !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
const grant = g;
|
|
68
|
+
return {
|
|
69
|
+
...(typeof grant.max_attempts === "number" ? { max_attempts: grant.max_attempts } : {}),
|
|
70
|
+
...(typeof grant.expires_at === "string" ? { expires_at: grant.expires_at } : {}),
|
|
71
|
+
...(typeof grant.pane === "string" ? { pane: grant.pane } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Whether a grant covers one more steer. `priorAttempts` counts the steers already run under it. */
|
|
75
|
+
export function checkGrant(grant, priorAttempts, now = new Date()) {
|
|
76
|
+
if (!grant)
|
|
77
|
+
return "no_grant";
|
|
78
|
+
if (grant.expires_at && !Number.isNaN(Date.parse(grant.expires_at)) && Date.parse(grant.expires_at) <= now.getTime())
|
|
79
|
+
return "expired_grant";
|
|
80
|
+
if ((grant.max_attempts ?? 0) <= priorAttempts)
|
|
81
|
+
return "duplicate";
|
|
82
|
+
return "ok";
|
|
83
|
+
}
|
|
84
|
+
/** Steers already run under a grant: decided (or grant-covered) steer records that cite it. */
|
|
85
|
+
export function steerAttempts(records, grantId) {
|
|
86
|
+
return records.filter((r) => r.kind === "session_steer_request" &&
|
|
87
|
+
r.refs.includes(grantId) &&
|
|
88
|
+
(r.approval_state === "approved" || r.approval_state === "not_required") &&
|
|
89
|
+
r.completion_receipt !== undefined).length;
|
|
90
|
+
}
|
|
91
|
+
const targetName = (session, pane) => `${session}:${pane.window}.${pane.pane}`;
|
|
92
|
+
export function isBusy(capture, patterns = BUSY_PATTERNS) {
|
|
93
|
+
const tail = capture.split("\n").slice(-25).join("\n");
|
|
94
|
+
return patterns.some((p) => p.test(tail));
|
|
95
|
+
}
|
|
96
|
+
function artifactPath(stateDir, recordId) {
|
|
97
|
+
return join(stateDir, "steers", `${recordId.replace(/[^\w-]/g, "-")}.json`);
|
|
98
|
+
}
|
|
99
|
+
function persistArtifact(stateDir, recordId, artifact) {
|
|
100
|
+
const file = artifactPath(stateDir, recordId);
|
|
101
|
+
mkdirSync(join(stateDir, "steers"), { recursive: true, mode: 0o700 });
|
|
102
|
+
const sha256 = createHash("sha256").update(JSON.stringify(artifact.instruction)).digest("hex");
|
|
103
|
+
writeFileSync(file, JSON.stringify({ ...artifact, instruction_sha256: sha256 }, null, 2) + "\n", { mode: 0o600 });
|
|
104
|
+
return { file, sha256 };
|
|
105
|
+
}
|
|
106
|
+
/** The artifact as last written, when a caller asks again with the same idempotency key. */
|
|
107
|
+
export function readSteerArtifact(stateDir, recordId) {
|
|
108
|
+
const file = artifactPath(stateDir, recordId);
|
|
109
|
+
if (!existsSync(file))
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const CAPTURE_LINES = 40;
|
|
119
|
+
/**
|
|
120
|
+
* Run one steer under its grant. The record is the session_steer_request; the
|
|
121
|
+
* grant record is the approved run the steer belongs to. A returned status
|
|
122
|
+
* other than `accepted`/`submitted` means nothing was typed.
|
|
123
|
+
*/
|
|
124
|
+
export async function steerSession(deps, args) {
|
|
125
|
+
const now = deps.now ?? (() => new Date());
|
|
126
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
127
|
+
const agentCommands = deps.agentCommands ?? AGENT_COMMANDS;
|
|
128
|
+
const { record, grantRecord } = args;
|
|
129
|
+
const body = record.body;
|
|
130
|
+
const grant = steeringGrantOf(grantRecord);
|
|
131
|
+
const instruction = String(body.instruction ?? "");
|
|
132
|
+
const grantSession = String(grantRecord.body.session ?? "") || undefined;
|
|
133
|
+
const session = body.session ?? grantSession;
|
|
134
|
+
const finish = (status, message, extra = {}, target) => {
|
|
135
|
+
const artifact = persistArtifact(deps.stateDir, record.id, {
|
|
136
|
+
record: record.id,
|
|
137
|
+
grant: grantRecord.id,
|
|
138
|
+
instruction,
|
|
139
|
+
session,
|
|
140
|
+
status,
|
|
141
|
+
message,
|
|
142
|
+
at: now().toISOString(),
|
|
143
|
+
...extra,
|
|
144
|
+
});
|
|
145
|
+
return { status, message, ...(target ? { target } : {}), artifact };
|
|
146
|
+
};
|
|
147
|
+
if (!instruction.trim())
|
|
148
|
+
return { status: "blocked", message: "The steer carries no instruction." };
|
|
149
|
+
if (!session)
|
|
150
|
+
return finish("stale_binding", "Neither the steer nor its grant names a session; a steer never targets whichever pane is active.");
|
|
151
|
+
if (body.session && grantSession && body.session !== grantSession) {
|
|
152
|
+
return finish("stale_binding", `The steer names session "${body.session}" but its grant covers "${grantSession}".`);
|
|
153
|
+
}
|
|
154
|
+
// A person's approval of this exact steer covers one attempt even when the
|
|
155
|
+
// run's grant is exhausted; without it, the grant's own limits decide.
|
|
156
|
+
if (!args.authorized) {
|
|
157
|
+
const check = checkGrant(grant, args.priorAttempts, now());
|
|
158
|
+
if (check === "expired_grant")
|
|
159
|
+
return finish("expired_grant", `The steering grant expired at ${grant?.expires_at}.`, {}, session);
|
|
160
|
+
if (check === "duplicate") {
|
|
161
|
+
return finish("duplicate", `The grant's ${grant?.max_attempts ?? 0} steer(s) are used; a new scoped human decision is needed for more.`, {}, session);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let panes;
|
|
165
|
+
try {
|
|
166
|
+
panes = deps.tmux.listPanes(session);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return finish("stale_binding", `No tmux session named "${session}"; the binding is stale.`, {}, session);
|
|
170
|
+
}
|
|
171
|
+
if (!panes.length)
|
|
172
|
+
return finish("stale_binding", `Session "${session}" has no panes; the binding is stale.`, {}, session);
|
|
173
|
+
const wanted = body.pane ?? grant?.pane;
|
|
174
|
+
let pane;
|
|
175
|
+
if (wanted) {
|
|
176
|
+
const [w, p] = wanted.split(".").map(Number);
|
|
177
|
+
pane = panes.find((x) => x.window === w && x.pane === p);
|
|
178
|
+
if (!pane)
|
|
179
|
+
return finish("stale_binding", `Pane ${wanted} of "${session}" is gone (stale or renumbered); nothing was typed anywhere else.`, {}, session);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
pane = panes[0]; // listPanes is sorted: lowest window, lowest pane (jgwill/Miadi#725).
|
|
183
|
+
}
|
|
184
|
+
const target = targetName(session, pane);
|
|
185
|
+
if (!agentCommands.includes(pane.command)) {
|
|
186
|
+
return finish("wrong_pane", `Pane ${target} runs "${pane.command}", not an agent; nothing was typed.`, { pane: { ...pane } }, target);
|
|
187
|
+
}
|
|
188
|
+
let before;
|
|
189
|
+
try {
|
|
190
|
+
before = deps.tmux.capture(target);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
return finish("blocked", `The pane could not be read (${error instanceof Error ? error.message : String(error)}); nothing was typed.`, {}, target);
|
|
194
|
+
}
|
|
195
|
+
if (isBusy(before)) {
|
|
196
|
+
return finish("busy", `The agent in ${target} is mid-turn; the steer was not typed. Ask again when it is idle.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
197
|
+
}
|
|
198
|
+
deps.tmux.sendLiteral(target, instruction);
|
|
199
|
+
deps.tmux.sendEnter(target);
|
|
200
|
+
await sleep(deps.settleMs ?? 4000);
|
|
201
|
+
let after;
|
|
202
|
+
try {
|
|
203
|
+
after = deps.tmux.capture(target);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return finish("submitted", `Typed into ${target}, but the re-peek failed (${error instanceof Error ? error.message : String(error)}); acceptance is unconfirmed.`, { peek: before.split("\n").slice(-CAPTURE_LINES) }, target);
|
|
207
|
+
}
|
|
208
|
+
let current;
|
|
209
|
+
try {
|
|
210
|
+
current = deps.tmux.listPanes(session).find((x) => x.window === pane.window && x.pane === pane.pane);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
current = undefined;
|
|
214
|
+
}
|
|
215
|
+
const tail = after.split("\n").slice(-CAPTURE_LINES);
|
|
216
|
+
const evidence = { peek: before.split("\n").slice(-CAPTURE_LINES), re_peek: tail };
|
|
217
|
+
if (!current || current.command !== pane.command) {
|
|
218
|
+
return finish("stale_binding", `Typed into ${target}, but the pane's command changed while sending; the seat may have exited. Acceptance is unconfirmed.`, evidence, target);
|
|
219
|
+
}
|
|
220
|
+
const firstLine = instruction.split("\n")[0].slice(0, 80);
|
|
221
|
+
const echoed = firstLine.length > 0 && after.includes(firstLine);
|
|
222
|
+
if (isBusy(after) && echoed) {
|
|
223
|
+
return finish("accepted", `The agent in ${target} is processing the steer.`, evidence, target);
|
|
224
|
+
}
|
|
225
|
+
const stillAtPrompt = tail.some((l) => firstLine.length > 0 && l.includes(firstLine)) && !isBusy(after);
|
|
226
|
+
return finish("submitted", stillAtPrompt
|
|
227
|
+
? `The instruction sits at the prompt in ${target}; it was typed but the agent has not accepted it.`
|
|
228
|
+
: `Typed into ${target}; the pane shows no processing sign, so acceptance is unconfirmed.`, evidence, target);
|
|
229
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { OAuthState } from "./oauth.js";
|
|
|
7
7
|
import { type ConnectorRecord } from "./records.js";
|
|
8
8
|
import { type ApprovalDeps } from "./approve.js";
|
|
9
9
|
import { type DecisionTokens } from "./decisions.js";
|
|
10
|
+
import { type DiscoveryProbe } from "./providers.js";
|
|
11
|
+
import { type TmuxLike } from "./steer.js";
|
|
10
12
|
export interface ToolContext {
|
|
11
13
|
config: ConnectorConfig;
|
|
12
14
|
miadi: MiadiClient;
|
|
@@ -18,6 +20,13 @@ export interface ToolContext {
|
|
|
18
20
|
/** What the in-chat approval card needs to run a decided request. */
|
|
19
21
|
approval?: ApprovalDeps;
|
|
20
22
|
decisionTokens?: DecisionTokens;
|
|
23
|
+
/** The steering surface (jgwill/Miadi#893); absent, steering answers unavailable. */
|
|
24
|
+
tmux?: TmuxLike;
|
|
25
|
+
tmuxSessions?: () => string[];
|
|
26
|
+
/** Provider discovery probes; tests inject fakes (jgwill/Miadi#893). */
|
|
27
|
+
providersProbe?: DiscoveryProbe;
|
|
28
|
+
/** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
|
|
29
|
+
steerSettleMs?: number;
|
|
21
30
|
}
|
|
22
31
|
export declare function registerTools(server: McpServer, ctx: ToolContext): void;
|
|
23
32
|
/** What get_capabilities reports for each tool. */
|
package/dist/tools.js
CHANGED
|
@@ -18,6 +18,9 @@ import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current
|
|
|
18
18
|
import { VERSION } from "./version.js";
|
|
19
19
|
import { AlreadyDecided, DECISIONS, KIND_LABELS, consequenceOf, decide, isApprovable, readQueueState, requestProjection } from "./approve.js";
|
|
20
20
|
import { DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
|
|
21
|
+
import { discoverProviders, resolveProvider, routerPreflight, PROVIDERS } from "./providers.js";
|
|
22
|
+
import { missionStatus } from "./mission-status.js";
|
|
23
|
+
import { checkGrant, isBusy, steerAttempts, steeringGrantOf, steerSession } from "./steer.js";
|
|
21
24
|
import { join } from "node:path";
|
|
22
25
|
import { artefactVisibility, readArtefactWeave, saveResearchArtefact } from "@miadi/inquiry-weave";
|
|
23
26
|
import { ResearchUploads, UploadRefusal } from "./uploads.js";
|
|
@@ -468,6 +471,72 @@ export function registerTools(server, ctx) {
|
|
|
468
471
|
: {}),
|
|
469
472
|
};
|
|
470
473
|
}));
|
|
474
|
+
server.registerTool("mission_status", {
|
|
475
|
+
title: "Where a mission stands",
|
|
476
|
+
description: "One mission, joined and verified: its source request and decision, the sessions bound to it (live or not), its issues and PRs, what is approved, queued, delivered, running, merged, released or closed — each by its evidence, never inferred from an idle pane — and the next authorized action. Operator details need Miadi's write tier. jgwill/Miadi#893.",
|
|
477
|
+
inputSchema: {
|
|
478
|
+
ref: z
|
|
479
|
+
.string()
|
|
480
|
+
.min(1)
|
|
481
|
+
.describe("run_request:<id>, work_proposal:<id>, another record id, session:<uuid>, or owner/repo#n the mission cites."),
|
|
482
|
+
},
|
|
483
|
+
annotations: READ,
|
|
484
|
+
}, wrap(async ({ ref }, extra) => {
|
|
485
|
+
const { person } = who(extra);
|
|
486
|
+
const writeTier = person.api_tier === "write";
|
|
487
|
+
return missionStatus(ref.trim(), records(), {
|
|
488
|
+
projectOf: (r) => requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
489
|
+
queueOf: (r) => (r.kind === "run_request" || r.kind === "work_proposal" ? readQueueState(r, ctx.factoryDir) : undefined),
|
|
490
|
+
tmuxSessions: writeTier ? (ctx.tmuxSessions ?? (() => [])) : undefined,
|
|
491
|
+
paneBusy: writeTier && ctx.tmux
|
|
492
|
+
? (session) => {
|
|
493
|
+
try {
|
|
494
|
+
const pane = ctx.tmux.listPanes(session)[0];
|
|
495
|
+
if (!pane)
|
|
496
|
+
return "unknown";
|
|
497
|
+
return isBusy(ctx.tmux.capture(`${session}:${pane.window}.${pane.pane}`)) ? "busy" : "idle";
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return "unknown";
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
: undefined,
|
|
504
|
+
sessionOf: writeTier ? (id) => miadi.get(person.token, `/api/session/${encodeURIComponent(id)}`) : undefined,
|
|
505
|
+
issueOf: async (owner, repo, n) => {
|
|
506
|
+
const f = ctx.approval?.fetchImpl ?? fetch;
|
|
507
|
+
const headers = {
|
|
508
|
+
accept: "application/vnd.github+json",
|
|
509
|
+
...(process.env.GITHUB_TOKEN ? { authorization: "Bearer " + process.env.GITHUB_TOKEN } : {}),
|
|
510
|
+
};
|
|
511
|
+
const pr = await f(`https://api.github.com/repos/${owner}/${repo}/pulls/${n}`, { headers, signal: AbortSignal.timeout(15_000) });
|
|
512
|
+
if (pr.ok) {
|
|
513
|
+
const p = await pr.json();
|
|
514
|
+
return { state: p.state, title: p.title, merged: Boolean(p.merged_at) };
|
|
515
|
+
}
|
|
516
|
+
const res = await f(`https://api.github.com/repos/${owner}/${repo}/issues/${n}`, { headers, signal: AbortSignal.timeout(15_000) });
|
|
517
|
+
if (!res.ok)
|
|
518
|
+
return { state: res.status === 404 ? "not_found_or_private" : `github_${res.status}` };
|
|
519
|
+
const i = await res.json();
|
|
520
|
+
return { state: i.state, title: i.title };
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
}));
|
|
524
|
+
server.registerTool("provider_capabilities", {
|
|
525
|
+
title: "Which agent providers this host can launch",
|
|
526
|
+
description: "The provider reality of the connector host (jgwill/Miadi#893): which agent CLIs are installed, their versions, whether sign-in evidence exists, the preference order (Codex, Pi, Copilot; Claude Code only on explicit request), and the read-only Jev routing preflight. No token is read or shown. Needs Miadi's write tier: this is host truth.",
|
|
527
|
+
inputSchema: {},
|
|
528
|
+
annotations: READ,
|
|
529
|
+
}, wrap(async (_args, extra) => {
|
|
530
|
+
const { person } = who(extra);
|
|
531
|
+
requireTier(person, "write");
|
|
532
|
+
return {
|
|
533
|
+
preference_order: PROVIDERS.map((p) => p.id),
|
|
534
|
+
explicit_only: PROVIDERS.filter((p) => !p.selectable_by_default).map((p) => p.id),
|
|
535
|
+
providers: discoverProviders(ctx.providersProbe),
|
|
536
|
+
router: await routerPreflight(process.env, ctx.approval?.fetchImpl ?? fetch),
|
|
537
|
+
note: "A provider name in a task's text selects nothing; request_authorized_run's provider field is verified against this list, and an unavailable provider leaves the mission unstarted.",
|
|
538
|
+
};
|
|
539
|
+
}));
|
|
471
540
|
// search / fetch: the pair ChatGPT uses for connectors in search and deep research.
|
|
472
541
|
server.registerTool("search", {
|
|
473
542
|
title: "Search Miadi",
|
|
@@ -815,12 +884,31 @@ export function registerTools(server, ctx) {
|
|
|
815
884
|
refs,
|
|
816
885
|
session: z.string().regex(/^[A-Za-z0-9._-]{3,80}$/).optional().describe("An existing tmux session to hand it to, or a name for a new one."),
|
|
817
886
|
cwd: z.string().max(300).optional().describe("Working folder for a new session (default the Miadi checkout)."),
|
|
887
|
+
provider: z
|
|
888
|
+
.enum(["codex", "pi", "copilot", "claude"])
|
|
889
|
+
.optional()
|
|
890
|
+
.describe("The provider the run should launch with, verified installed on the connector host. Claude Code runs only when named explicitly. Unknown or unavailable leaves the mission unstarted."),
|
|
891
|
+
steering: z
|
|
892
|
+
.object({
|
|
893
|
+
max_attempts: z.number().int().min(1).max(20).describe("How many authorized follow-up steers this approval covers."),
|
|
894
|
+
expires_at: z.string().optional().describe("ISO time after which no steer runs under this grant."),
|
|
895
|
+
pane: z.string().regex(/^\d+\.\d+$/).optional().describe("The pane the seat lives in (window.pane); default the session's first pane."),
|
|
896
|
+
})
|
|
897
|
+
.optional()
|
|
898
|
+
.describe("The steering grant the approver covers with this run: bounded follow-ups to the same session through request_session_steer."),
|
|
818
899
|
...base,
|
|
819
900
|
},
|
|
820
901
|
annotations: WRITE,
|
|
821
902
|
}, wrap(async (a, extra) => {
|
|
822
903
|
const { person, auth } = who(extra);
|
|
823
904
|
requireContribute(auth);
|
|
905
|
+
const found = discoverProviders(ctx.providersProbe);
|
|
906
|
+
const resolution = resolveProvider(a.provider, found);
|
|
907
|
+
if (resolution.state === "provider_unknown")
|
|
908
|
+
throw new ToolRefusal("invalid_request", resolution.message, { available: resolution.available });
|
|
909
|
+
if (resolution.state !== "ok") {
|
|
910
|
+
return { state: resolution.state, message: resolution.message, available: resolution.available, note: "Nothing was recorded or queued; the mission is unstarted." };
|
|
911
|
+
}
|
|
824
912
|
const r = append("run_request", person, auth, {
|
|
825
913
|
circle: a.circle,
|
|
826
914
|
refs: a.refs,
|
|
@@ -829,9 +917,100 @@ export function registerTools(server, ctx) {
|
|
|
829
917
|
idempotency_key: a.idempotency_key,
|
|
830
918
|
approval_state: "awaiting_approval",
|
|
831
919
|
state: "pending",
|
|
832
|
-
body: {
|
|
920
|
+
body: {
|
|
921
|
+
task: a.task,
|
|
922
|
+
session: a.session,
|
|
923
|
+
cwd: a.cwd,
|
|
924
|
+
provider: a.provider,
|
|
925
|
+
resolved_provider: resolution.provider.id,
|
|
926
|
+
...(a.steering ? { steering: a.steering } : {}),
|
|
927
|
+
},
|
|
833
928
|
});
|
|
834
|
-
return {
|
|
929
|
+
return {
|
|
930
|
+
...r,
|
|
931
|
+
provider: { requested: a.provider ?? null, resolved: resolution.provider.id, explicit: resolution.explicit, version: resolution.provider.version },
|
|
932
|
+
approval_url: approvalUrl(r.id),
|
|
933
|
+
};
|
|
934
|
+
}));
|
|
935
|
+
server.registerTool("request_session_steer", {
|
|
936
|
+
title: "Send one authorized follow-up to a running agent",
|
|
937
|
+
description: "Steer one existing live agent session within an approved run's steering grant (jgwill/Miadi#893): the exact instruction is typed into the mission's bound pane — never whichever pane is active — after a peek, and a re-peek verifies the agent accepted it. When the approved run carries no steering grant covering this, the steer waits for one explicit scoped human decision on the returned approval_url. Every attempt leaves a durable artifact with its hash and both captures.",
|
|
938
|
+
inputSchema: {
|
|
939
|
+
run_request: z.string().min(1).describe("The approved run_request or work_proposal whose steering grant covers this steer."),
|
|
940
|
+
instruction: z.string().min(1).max(4000).describe("The exact instruction typed into the session. It is material within the approved mission's scope, not new authority."),
|
|
941
|
+
session: z.string().regex(/^[A-Za-z0-9._-]{3,80}$/).optional().describe("The session to steer; must be the one the grant binds."),
|
|
942
|
+
pane: z.string().regex(/^\d+\.\d+$/).optional().describe("The pane (window.pane); default the grant's pane, else the session's first pane. The active pane is never used."),
|
|
943
|
+
refs,
|
|
944
|
+
...base,
|
|
945
|
+
},
|
|
946
|
+
annotations: WRITE,
|
|
947
|
+
}, wrap(async (a, extra) => {
|
|
948
|
+
const { person, auth } = who(extra);
|
|
949
|
+
requireContribute(auth);
|
|
950
|
+
requireTier(person, "write");
|
|
951
|
+
if (!ctx.tmux)
|
|
952
|
+
throw new ToolRefusal("unavailable", "The connector host has no steering surface; the steer is unstarted.");
|
|
953
|
+
const grantRecord = all().find((r) => r.id === a.run_request);
|
|
954
|
+
if (!grantRecord || (grantRecord.kind !== "run_request" && grantRecord.kind !== "work_proposal")) {
|
|
955
|
+
throw new ToolRefusal("invalid_ref", "Give the id of the approved run_request or work_proposal this steer belongs to.");
|
|
956
|
+
}
|
|
957
|
+
if (grantRecord.approval_state !== "approved") {
|
|
958
|
+
throw new ToolRefusal("not_approved", `${grantRecord.id} is ${grantRecord.approval_state ?? "not approved"}; a steer needs an approved run's grant or its own human decision.`);
|
|
959
|
+
}
|
|
960
|
+
const grant = steeringGrantOf(grantRecord);
|
|
961
|
+
const priorAttempts = steerAttempts(all(), grantRecord.id);
|
|
962
|
+
const covered = checkGrant(grant, priorAttempts) === "ok";
|
|
963
|
+
const body = {
|
|
964
|
+
instruction: a.instruction,
|
|
965
|
+
session: a.session ?? grantRecord.body.session,
|
|
966
|
+
pane: a.pane ?? grant?.pane,
|
|
967
|
+
};
|
|
968
|
+
const fields = {
|
|
969
|
+
circle: grantRecord.circle,
|
|
970
|
+
refs: [grantRecord.id, ...a.refs],
|
|
971
|
+
intention: a.intention,
|
|
972
|
+
role: a.role,
|
|
973
|
+
idempotency_key: a.idempotency_key,
|
|
974
|
+
body,
|
|
975
|
+
};
|
|
976
|
+
if (!covered) {
|
|
977
|
+
// One explicit scoped human decision (jgwill/Miadi#893): the record waits
|
|
978
|
+
// on the approval page; approving it runs exactly this instruction once.
|
|
979
|
+
const r = append("session_steer_request", person, auth, { ...fields, approval_state: "awaiting_approval", state: "pending" });
|
|
980
|
+
return {
|
|
981
|
+
...r,
|
|
982
|
+
approval_url: approvalUrl(r.id),
|
|
983
|
+
state: "awaiting_approval",
|
|
984
|
+
reason: !grant ? "no_grant" : checkGrant(grant, priorAttempts),
|
|
985
|
+
message: "The approved run's steering grant does not cover this steer; a person's decision is requested, once, for exactly this instruction.",
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const r = append("session_steer_request", person, auth, { ...fields, approval_state: "not_required" });
|
|
989
|
+
const result = await steerSession({ tmux: ctx.tmux, stateDir: config.stateDir, ...(ctx.steerSettleMs !== undefined ? { settleMs: ctx.steerSettleMs } : {}) }, { record: r, grantRecord, priorAttempts });
|
|
990
|
+
const delivered = result.status === "accepted" || result.status === "submitted";
|
|
991
|
+
ledger.append({
|
|
992
|
+
id: newRecordId(r.kind),
|
|
993
|
+
kind: r.kind,
|
|
994
|
+
created_at: new Date().toISOString(),
|
|
995
|
+
actor: r.actor,
|
|
996
|
+
refs: r.refs,
|
|
997
|
+
evidence_refs: [],
|
|
998
|
+
supersedes: r.id,
|
|
999
|
+
approval_state: "not_required",
|
|
1000
|
+
state: delivered ? "completed" : "blocked",
|
|
1001
|
+
completion_receipt: {
|
|
1002
|
+
decision: "covered_by_grant",
|
|
1003
|
+
decided_by: { person_id: person.id, name: person.name },
|
|
1004
|
+
decided_at: new Date().toISOString(),
|
|
1005
|
+
outcome: delivered ? result.status : "failed",
|
|
1006
|
+
steer_status: result.status,
|
|
1007
|
+
message: result.message,
|
|
1008
|
+
...(result.target ? { target: result.target } : {}),
|
|
1009
|
+
...(result.artifact ? { artifact: result.artifact } : {}),
|
|
1010
|
+
},
|
|
1011
|
+
body: r.body,
|
|
1012
|
+
});
|
|
1013
|
+
return records().find((x) => x.id === r.id) ?? r;
|
|
835
1014
|
}));
|
|
836
1015
|
server.registerTool("request_review_operation", {
|
|
837
1016
|
title: "Request a review operation",
|
|
@@ -1262,6 +1441,8 @@ export const TOOL_STATUS = {
|
|
|
1262
1441
|
handoff_status: "live",
|
|
1263
1442
|
list_records: "live",
|
|
1264
1443
|
get_run_result: "live",
|
|
1444
|
+
mission_status: "live (operator details need the Miadi write tier)",
|
|
1445
|
+
provider_capabilities: "live, Miadi write tier",
|
|
1265
1446
|
search: "live",
|
|
1266
1447
|
fetch: "live",
|
|
1267
1448
|
append_observation: "live, contribute",
|
|
@@ -1275,7 +1456,8 @@ export const TOOL_STATUS = {
|
|
|
1275
1456
|
propose_work: "live, contribute, needs_approval (approving starts the work as a factory run)",
|
|
1276
1457
|
prepare_calendar_event: "live, contribute (draft and .ics only)",
|
|
1277
1458
|
propose_session_completion: "live, contribute, needs_approval (decision recorded, nothing closed)",
|
|
1278
|
-
request_authorized_run: "live, contribute, needs_approval (queues a factory brief once approved)",
|
|
1459
|
+
request_authorized_run: "live, contribute, needs_approval (queues a factory brief once approved; may name a verified provider and a bounded steering grant)",
|
|
1460
|
+
request_session_steer: "live, contribute, write tier (runs inside an approved run's steering grant, else one scoped human decision)",
|
|
1279
1461
|
request_review_operation: "live, contribute, needs_approval",
|
|
1280
1462
|
request_deep_diver: "live, contribute, needs_approval",
|
|
1281
1463
|
link_review_to_episode: "live, contribute, needs_approval",
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.10";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.10";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miadi/connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "The Miadi connector: one remote MCP service through which a person signed in with their Miadi person token, from ChatGPT or any MCP client, reads sessions, reviews, episodes and circles and records observations, inquiries, handoffs and proposals with their name on each record.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Miadi",
|