@miadi/connector 0.1.8 → 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/dist/tools.js CHANGED
@@ -16,8 +16,11 @@ import { reviewIdOf, videoIdOf } from "./reviews.js";
16
16
  import { SCOPE_APPROVE, SCOPE_CONTRIBUTE } from "./oauth.js";
17
17
  import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current, newRecordId, } from "./records.js";
18
18
  import { VERSION } from "./version.js";
19
- import { AlreadyDecided, DECISIONS, KIND_LABELS, consequenceOf, decide, isApprovable, readQueueState } from "./approve.js";
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";
@@ -461,11 +464,79 @@ export function registerTools(server, ctx) {
461
464
  record: r,
462
465
  approval_url: r.approval_state === "awaiting_approval" ? approvalUrl(r.id) : undefined,
463
466
  queue: r.kind === "run_request" || r.kind === "work_proposal" ? readQueueState(r, ctx.factoryDir) : undefined,
467
+ // Where the request stands, in the words every surface uses (jgwill/Miadi#889).
468
+ projection: requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
464
469
  ...(r.approval_state === "revision_requested"
465
470
  ? { revision: { instructions: r.completion_receipt?.instructions, next: `Submit a revised request that cites ${r.id} in refs.` } }
466
471
  : {}),
467
472
  };
468
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
+ }));
469
540
  // search / fetch: the pair ChatGPT uses for connectors in search and deep research.
470
541
  server.registerTool("search", {
471
542
  title: "Search Miadi",
@@ -813,12 +884,31 @@ export function registerTools(server, ctx) {
813
884
  refs,
814
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."),
815
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."),
816
899
  ...base,
817
900
  },
818
901
  annotations: WRITE,
819
902
  }, wrap(async (a, extra) => {
820
903
  const { person, auth } = who(extra);
821
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
+ }
822
912
  const r = append("run_request", person, auth, {
823
913
  circle: a.circle,
824
914
  refs: a.refs,
@@ -827,9 +917,100 @@ export function registerTools(server, ctx) {
827
917
  idempotency_key: a.idempotency_key,
828
918
  approval_state: "awaiting_approval",
829
919
  state: "pending",
830
- body: { task: a.task, session: a.session, cwd: a.cwd },
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
+ },
831
928
  });
832
- return { ...r, approval_url: approvalUrl(r.id) };
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;
833
1014
  }));
834
1015
  server.registerTool("request_review_operation", {
835
1016
  title: "Request a review operation",
@@ -1083,9 +1264,9 @@ export function registerTools(server, ctx) {
1083
1264
  const { person, auth } = who(extra);
1084
1265
  const writer = person.api_tier === "write";
1085
1266
  const waiting = all()
1086
- .filter((r) => r.approval_state === "awaiting_approval" && isApprovable(r))
1267
+ .filter((r) => isApprovable(r))
1087
1268
  .filter((r) => writer || r.actor.person_id === person.id)
1088
- .filter((r) => !record_id || r.id === record_id)
1269
+ .filter((r) => record_id ? r.id === record_id : r.approval_state === "awaiting_approval")
1089
1270
  .slice(-20)
1090
1271
  .reverse();
1091
1272
  const canDecide = writer && auth.scopes.includes(SCOPE_APPROVE) && Boolean(ctx.decisionTokens);
@@ -1107,17 +1288,20 @@ export function registerTools(server, ctx) {
1107
1288
  consequence: consequenceOf(r),
1108
1289
  detail: detailOf(r),
1109
1290
  approval_url: approvalUrl(r.id),
1291
+ projection: requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
1110
1292
  }));
1111
- const tokens = canDecide ? Object.fromEntries(waiting.map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId)])) : {};
1293
+ const actionable = decisions.filter((d) => d.projection?.stage === "awaiting_decision");
1294
+ const tokens = canDecide ? Object.fromEntries(actionable.map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId)])) : {};
1112
1295
  const structured = {
1113
1296
  decisions,
1114
1297
  count: decisions.length,
1298
+ awaiting_count: actionable.length,
1115
1299
  can_decide_here: canDecide,
1116
1300
  ...(reason ? { reason } : {}),
1117
1301
  ...(decisions.length ? {} : { message: record_id ? "That request is not awaiting a decision." : "No request is waiting for a decision." }),
1118
1302
  };
1119
1303
  const text = decisions.length
1120
- ? `${decisions.length} request(s) await a decision and are shown as cards. ${canDecide ? "The person decides each one on its card; do not decide or claim a decision for them." : reason}`
1304
+ ? `${decisions.length} request(s) shown as cards; ${actionable.length} await a decision. ${canDecide ? "The person decides an awaiting request on its card; do not decide or claim a decision for them." : reason}`
1121
1305
  : structured.message;
1122
1306
  return { structuredContent: structured, content: [{ type: "text", text }], _meta: { [DECISION_TOKENS_META]: tokens } };
1123
1307
  }
@@ -1153,28 +1337,43 @@ export function registerTools(server, ctx) {
1153
1337
  const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId);
1154
1338
  if (check !== "ok")
1155
1339
  throw new ToolRefusal(check === "expired" ? "token_expired" : "invalid_token", check === "expired" ? "This card is too old; show the requests again." : "This decision did not come from the person's card.");
1156
- if (r.approval_state !== "awaiting_approval")
1157
- throw new ToolRefusal("already_decided", `This request is already ${r.approval_state}.`);
1340
+ // A stale card or a second client gets where the request stands, not a bare refusal (jgwill/Miadi#889).
1341
+ const projectionNow = () => requestProjection(all().find((x) => x.id === r.id) ?? r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir });
1342
+ if (r.approval_state !== "awaiting_approval") {
1343
+ throw new ToolRefusal("already_decided", `This request is already ${r.approval_state}.`, { projection: projectionNow() });
1344
+ }
1158
1345
  const instructions = a.instructions?.trim() || undefined;
1159
1346
  if ((a.decision === "approve_with_changes" || a.decision === "request_revision") && !instructions) {
1160
1347
  throw new ToolRefusal("instructions_required", a.decision === "request_revision" ? "Say what to revise." : "Write the changes to apply.");
1161
1348
  }
1162
1349
  const client = ctx.oauthState.read().clients[auth.clientId];
1163
- const { decision, state, receipt } = await decide(r, person, a.decision, ctx.approval, {
1164
- instructions,
1165
- via: `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`,
1166
- });
1350
+ let decided;
1351
+ try {
1352
+ decided = await decide(r, person, a.decision, ctx.approval, {
1353
+ instructions,
1354
+ via: `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`,
1355
+ });
1356
+ }
1357
+ catch (error) {
1358
+ // A decision recorded a moment earlier, by another client, is reported with where it stands.
1359
+ if (error instanceof AlreadyDecided)
1360
+ throw new ToolRefusal("already_decided", error.message, { projection: projectionNow() });
1361
+ throw error;
1362
+ }
1363
+ const { decision, state, receipt } = decided;
1364
+ const projection = projectionNow();
1167
1365
  const summary = decision === "approved"
1168
1366
  ? `Approved by ${person.name}. ${receipt.outcome === "failed" ? "Running it failed; see the receipt." : `Outcome: ${String(receipt.outcome ?? "recorded").replace(/_/g, " ")}.`}`
1169
1367
  : decision === "revision_requested"
1170
1368
  ? `Sent back for revision by ${person.name}.`
1171
1369
  : `Declined by ${person.name}.`;
1172
- const out = { record_id: r.id, decision, state, summary, receipt };
1370
+ const out = { record_id: r.id, decision, state, summary, receipt, projection };
1173
1371
  return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1174
1372
  }
1175
1373
  catch (error) {
1176
1374
  if (error instanceof ToolRefusal || error instanceof AlreadyDecided) {
1177
- const out = error instanceof AlreadyDecided ? { error: "already_decided", message: error.message } : { error: error.code, message: error.message };
1375
+ const detail = error instanceof ToolRefusal ? error.detail : undefined;
1376
+ const out = error instanceof AlreadyDecided ? { error: "already_decided", message: error.message } : { error: error.code, message: error.message, ...detail };
1178
1377
  return { isError: true, structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1179
1378
  }
1180
1379
  return fail({ error: "failed", message: error instanceof Error ? error.message : String(error) });
@@ -1242,6 +1441,8 @@ export const TOOL_STATUS = {
1242
1441
  handoff_status: "live",
1243
1442
  list_records: "live",
1244
1443
  get_run_result: "live",
1444
+ mission_status: "live (operator details need the Miadi write tier)",
1445
+ provider_capabilities: "live, Miadi write tier",
1245
1446
  search: "live",
1246
1447
  fetch: "live",
1247
1448
  append_observation: "live, contribute",
@@ -1255,7 +1456,8 @@ export const TOOL_STATUS = {
1255
1456
  propose_work: "live, contribute, needs_approval (approving starts the work as a factory run)",
1256
1457
  prepare_calendar_event: "live, contribute (draft and .ics only)",
1257
1458
  propose_session_completion: "live, contribute, needs_approval (decision recorded, nothing closed)",
1258
- 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)",
1259
1461
  request_review_operation: "live, contribute, needs_approval",
1260
1462
  request_deep_diver: "live, contribute, needs_approval",
1261
1463
  link_review_to_episode: "live, contribute, needs_approval",
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.7";
1
+ export declare const VERSION = "0.1.10";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.7";
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.8",
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",
@@ -26,7 +26,7 @@
26
26
  "README.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@miadi/inquiry-weave": "0.18.4",
29
+ "@miadi/inquiry-weave": "0.18.5",
30
30
  "@miadi/node-service-kit": "0.1.2",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "express": "^5.2.1",