failproofai 1.0.12-beta.1 → 1.0.12-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +3 -3
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00l2u6j86cb8y._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0b78b0j8iyv6l._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_1a3tubds7s7ow._.js → _0ouu886szh6yj._.js} +4 -4
  78. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_1fw0_nzkae5ao._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_1i8oid1gc7s1f.js → node_modules_html-to-image_es_index_1fv03uigni8s5.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
  96. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  97. package/.next/standalone/.next/server/pages/404.html +1 -1
  98. package/.next/standalone/.next/server/pages/500.html +1 -1
  99. package/.next/standalone/.next/server/preview-props.json +3 -3
  100. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  101. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  102. package/.next/standalone/.next/static/chunks/{2-tf6_7u623kd.js → 0ay__-d2h1xb5.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{18433qz-8njv3.js → 0tbm3vjm62h-2.js} +1 -1
  104. package/.next/standalone/.next/static/chunks/{1b32-afqacaen.js → 1c_jf6g5wnslk.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{0_zfad4co-t2z.js → 1my3-5p47t7r-.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{0jutamwqz8pg5.js → 20py8k1gd_i1m.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
  108. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
  109. package/.next/standalone/.next/static/chunks/{42e8zab314654.js → 2o6d37j425uci.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
  111. package/.next/standalone/.next/static/chunks/{24w3x2da9sk49.js → 42xptz88mtq3x.js} +1 -1
  112. package/.next/standalone/app/actions/update-jev-config.ts +5 -3
  113. package/.next/standalone/app/settings/jev-panel.tsx +1 -1
  114. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +9 -18
  115. package/.next/standalone/fp-cloud-cli/README.md +7 -9
  116. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1 -27
  117. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +12 -216
  118. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +7 -53
  119. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +2 -71
  120. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +1 -1
  121. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +6 -7
  122. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +32 -197
  123. package/.next/standalone/hermes-plugin/__init__.py +11 -0
  124. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  125. package/.next/standalone/package.json +11 -10
  126. package/.next/standalone/sdk/python/uv.lock +31 -8
  127. package/.next/standalone/server.js +1 -1
  128. package/dist/cli.mjs +1686 -612
  129. package/dist/worker.mjs +354 -49
  130. package/hermes-plugin/__init__.py +11 -0
  131. package/hermes-plugin/plugin.yaml +1 -1
  132. package/package.json +11 -10
  133. package/src/audit/open-browser.ts +24 -7
  134. package/src/hooks/agent-roster.ts +9 -1
  135. package/src/hooks/agent-skill.ts +203 -0
  136. package/src/hooks/cloud-connection.ts +27 -51
  137. package/src/hooks/cloud-introspect.ts +5 -2
  138. package/src/hooks/cloud-login.ts +248 -0
  139. package/src/hooks/cloud-managed-policies.ts +54 -4
  140. package/src/hooks/codex-system-hooks.ts +10 -4
  141. package/src/hooks/config-status.ts +9 -3
  142. package/src/hooks/configure-wizard.ts +356 -32
  143. package/src/hooks/delivery-health.ts +47 -2
  144. package/src/hooks/effective-reviewers.ts +14 -0
  145. package/src/hooks/fp-config.ts +19 -0
  146. package/src/hooks/handler.ts +21 -2
  147. package/src/hooks/hook-activity-store.ts +7 -1
  148. package/src/hooks/integrations.ts +18 -3
  149. package/src/hooks/jev-activity.ts +117 -1
  150. package/src/hooks/jev-cli.ts +7 -9
  151. package/src/hooks/manager.ts +80 -46
  152. package/src/hooks/policy-evaluator.ts +17 -0
  153. package/src/hooks/policy-reviewability.ts +4 -2
  154. package/src/hooks/semantic/cloud-jev.ts +219 -28
  155. package/src/hooks/semantic/combine.ts +26 -1
  156. package/src/hooks/semantic/jev-client.ts +1 -1
  157. package/src/hooks/semantic/jev-config.ts +6 -27
  158. package/src/hooks/semantic/jev-review.ts +90 -2
  159. package/src/hooks/tui.ts +280 -0
  160. package/src/hooks/uninstall-cli.ts +12 -1
  161. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0588st-tpiuia._.js +0 -3
  162. package/.next/standalone/.next/server/chunks/ssr/_128ebxr83nk6g._.js +0 -3
  163. package/.next/standalone/.next/static/chunks/114hbolvrv94e.js +0 -1
  164. package/.next/standalone/.next/static/chunks/1p-hsi58i-6h0.js +0 -6
  165. package/.next/standalone/.next/static/chunks/3-pku9qwv1yfu.js +0 -1
  166. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
  167. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
  168. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
@@ -30,20 +30,26 @@ import { dirname, resolve, sep } from "node:path";
30
30
  import { CORE_SOURCE } from "./pack-store";
31
31
 
32
32
  import {
33
+ BACK,
33
34
  collapseAfter,
34
35
  colorsEnabled,
35
36
  multiSelect,
36
37
  OPEN_SOURCE,
37
38
  optsFor,
38
39
  paint,
40
+ promptBrowserLogin,
39
41
  promptCloudKey,
40
42
  screenKit,
43
+ selectOne,
41
44
  type CloudKeyAnswer,
42
45
  type MultiChoice,
43
46
  type OpenSource,
47
+ type SelectChoice,
44
48
  type TTYIn,
45
49
  type TTYOut,
46
50
  } from "./tui";
51
+ import { claimLogin, machineOs, pollLogin, startLogin, type LoginOrg } from "./cloud-login";
52
+ import { canOpenBrowser, openUrl } from "../audit/open-browser";
47
53
  import {
48
54
  DEFAULT_INGEST_URL,
49
55
  readIngestCredential,
@@ -56,6 +62,7 @@ import {
56
62
  } from "./integrations";
57
63
  import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
58
64
  import { enforcingPolicyCount, installHooks, notEnforcingReason } from "./manager";
65
+ import { installAgentSkill } from "./agent-skill";
59
66
  import {
60
67
  CODEX_UNTRUSTED_NOTICE,
61
68
  codexHookTrust,
@@ -998,6 +1005,195 @@ export async function runConfigureWizard(
998
1005
  stdout,
999
1006
  });
1000
1007
 
1008
+ // A key typed after picking "Paste an API key" from the menu. Same field as
1009
+ // above minus the saved key, which the menu already offered as "keep"; Tab is
1010
+ // the way back to the menu, so it never means open source here.
1011
+ const askKey = (): Promise<CloudKeyAnswer | OpenSource | null> =>
1012
+ promptCloudKey({
1013
+ message: "Connect to cloud",
1014
+ meta: source ? `${host} · from ${source}` : host,
1015
+ tabHint: "go back",
1016
+ emptyError: "Paste a key, or press tab to go back.",
1017
+ check: async (key) => {
1018
+ if (!url) return { ok: false, line: badUrl ?? "No usable endpoint." };
1019
+ const r = await checkCloudKey(url, key);
1020
+ if (r.ok) {
1021
+ typedOrg = r.org;
1022
+ return { ok: true, line: r.note };
1023
+ }
1024
+ return { ok: false, line: r.refused ? `That key was refused: ${r.reason}.` : `Couldn't check the key: ${r.reason}.` };
1025
+ },
1026
+ collapsed: (answer) => (answer === OPEN_SOURCE ? [] : [...cloudLines(planFor(answer)), ""]),
1027
+ stdin,
1028
+ stdout,
1029
+ });
1030
+
1031
+ // The browser login. Resolves the approval, BACK for the menu of other
1032
+ // options (Tab / Esc, or a cloud that cannot do this), or null when the person
1033
+ // cancelled the whole run.
1034
+ type Approval = Extract<CloudPlan, { kind: "login" }>;
1035
+ // Set once the cloud says it has no browser login; the menu then drops it.
1036
+ let loginUnsupported = false;
1037
+ const askLogin = (endpoint: string): Promise<{ value: Approval } | typeof BACK | null> => {
1038
+ let pollSecret = "";
1039
+ return promptBrowserLogin<Approval>({
1040
+ message: "Connect to cloud",
1041
+ meta: source ? `${host} · from ${source}` : host,
1042
+ machineLabel,
1043
+ start: async () => {
1044
+ const r = await startLogin(endpoint, { label: machineLabel, os: machineOs(), cliVersion });
1045
+ if (r.kind === "ok") {
1046
+ pollSecret = r.pollSecret;
1047
+ void emit("configure_cloud_login", { step: "started" });
1048
+ return { ok: true, url: r.verificationUrl, expiresIn: r.expiresIn, interval: r.interval };
1049
+ }
1050
+ if (r.kind === "unsupported") {
1051
+ void emit("configure_cloud_login", { step: "unsupported" });
1052
+ loginUnsupported = true;
1053
+ return { ok: false, retryable: false, line: `${host} doesn't support logging in from here yet.` };
1054
+ }
1055
+ return {
1056
+ ok: false,
1057
+ retryable: true,
1058
+ line: r.kind === "refused" ? `Couldn't start the login: ${r.reason}.` : `Couldn't reach ${host}: ${r.reason}.`,
1059
+ };
1060
+ },
1061
+ poll: async () => {
1062
+ const p = await pollLogin(endpoint, pollSecret);
1063
+ switch (p.kind) {
1064
+ case "approved":
1065
+ void emit("configure_cloud_login", { step: "approved" });
1066
+ return {
1067
+ state: "approved",
1068
+ value: {
1069
+ kind: "login",
1070
+ pollSecret,
1071
+ org: p.org,
1072
+ approvedBy: p.approvedBy,
1073
+ keyName: p.keyName,
1074
+ permissions: p.permissions,
1075
+ },
1076
+ };
1077
+ case "pending":
1078
+ return { state: "pending", interval: p.interval };
1079
+ case "slow_down":
1080
+ return { state: "slow_down" };
1081
+ case "denied":
1082
+ void emit("configure_cloud_login", { step: "denied" });
1083
+ return { state: "denied" };
1084
+ case "unreachable":
1085
+ return { state: "unreachable", line: p.reason };
1086
+ // Expired, already claimed, or swept: in every case this link is done.
1087
+ default:
1088
+ return { state: "expired" };
1089
+ }
1090
+ },
1091
+ open: (link) => {
1092
+ if (!canOpenBrowser()) return false;
1093
+ openUrl(link);
1094
+ return true;
1095
+ },
1096
+ collapsed: (plan) => [...cloudLines(plan), ""],
1097
+ stdin,
1098
+ stdout,
1099
+ });
1100
+ };
1101
+
1102
+ // Whether the saved key was refused when the CONNECT step checked it — still
1103
+ // true at the end of the run if a login there created no key.
1104
+ let savedRefused = false;
1105
+
1106
+ // The interactive CONNECT step. A machine with no connection, or whose saved
1107
+ // key is refused, starts the browser login straight away — Tab or Esc there
1108
+ // opens the other options. A machine that is connected with a working key gets
1109
+ // the menu, "keep" first, so re-running config never opens a browser by itself.
1110
+ const chooseConnection = async (): Promise<CloudPlan | null> => {
1111
+ const verdict =
1112
+ saved && !saved.splitKeys
1113
+ ? await transient("Checking your saved key…", () => checkCloudKey(saved.url, saved.token))
1114
+ : null;
1115
+ const savedBroken = verdict !== null && !verdict.ok && verdict.refused;
1116
+ savedRefused = savedBroken;
1117
+ const keptPlan: CloudPlan =
1118
+ verdict && verdict.ok
1119
+ ? { kind: "kept", how: "saved", verified: true }
1120
+ : verdict && !verdict.refused
1121
+ ? { kind: "kept", how: "saved", verified: false }
1122
+ : { kind: "kept", how: "skip", verified: false };
1123
+ const keepHint = !saved
1124
+ ? ""
1125
+ : saved.splitKeys
1126
+ ? "leave both keys as they are"
1127
+ : verdict?.ok
1128
+ ? `${verdict.org ?? saved.org ?? hostOf(saved.url)} · key ${maskToken(saved.token)}`
1129
+ : savedBroken
1130
+ ? "its key no longer works"
1131
+ : "its key couldn't be checked just now";
1132
+
1133
+ type Pick = "login" | "key" | "oss" | "keep";
1134
+ const login: SelectChoice<Pick> = {
1135
+ label: saved ? "Log in again" : "Log in to FailproofAI Cloud",
1136
+ value: "login",
1137
+ hint: savedBroken
1138
+ ? "recommended · your saved key no longer works"
1139
+ : saved
1140
+ ? "opens your browser and makes a new key"
1141
+ : "recommended · opens your browser",
1142
+ };
1143
+ const key: SelectChoice<Pick> = { label: "Paste an API key", value: "key", hint: "one you already have" };
1144
+ const oss: SelectChoice<Pick> = { label: "Use open source", value: "oss", hint: "skip the cloud" };
1145
+ const keep: SelectChoice<Pick> = { label: "Keep current connection", value: "keep", hint: keepHint };
1146
+ // No initial-cursor option on selectOne: the default is whatever is first.
1147
+ const choices = !saved ? [login, key, oss] : savedBroken ? [login, key, keep] : [keep, login, key];
1148
+ const leftLogin = (result: Awaited<ReturnType<typeof askLogin>>): CloudPlan | null | typeof BACK => {
1149
+ if (result === null || result === BACK) {
1150
+ if (result === BACK && loginUnsupported) {
1151
+ write([kit.caution(`${host} doesn't support logging in from here yet. Paste an API key instead.`), ""]);
1152
+ }
1153
+ return result;
1154
+ }
1155
+ return result.value;
1156
+ };
1157
+
1158
+ if (url && (!saved || savedBroken)) {
1159
+ if (savedBroken && verdict && !verdict.ok) {
1160
+ write([kit.caution(`Your saved key no longer works: ${verdict.reason}. Logging in again.`)]);
1161
+ }
1162
+ void emit("configure_cloud_choice", { choice: "login", connected: Boolean(saved), auto: true });
1163
+ const first = leftLogin(await askLogin(url));
1164
+ if (first !== BACK) return first;
1165
+ }
1166
+
1167
+ for (;;) {
1168
+ const pick = await selectOne<Pick>({
1169
+ message: "Connect to cloud",
1170
+ meta: source ? `${host} · from ${source}` : host,
1171
+ choices: loginUnsupported ? choices.filter((c) => c.value !== "login") : choices,
1172
+ collapsed: (p) =>
1173
+ p === "keep" ? [...cloudLines(keptPlan), ""] : p === "oss" ? [...cloudLines({ kind: "oss" }), ""] : [],
1174
+ stdin,
1175
+ stdout,
1176
+ });
1177
+ if (pick === null) return null;
1178
+ void emit("configure_cloud_choice", { choice: pick, connected: Boolean(saved) });
1179
+ if (pick === "keep") return keptPlan;
1180
+ if (pick === "oss") return { kind: "oss" };
1181
+ if (pick === "key") {
1182
+ const answer = await askKey();
1183
+ if (answer === null) return null;
1184
+ if (answer === OPEN_SOURCE) continue;
1185
+ return planFor(answer);
1186
+ }
1187
+ if (!url) {
1188
+ write([kit.fail(badUrl ?? "No usable endpoint."), ""]);
1189
+ continue;
1190
+ }
1191
+ const result = leftLogin(await askLogin(url));
1192
+ if (result === BACK) continue;
1193
+ return result;
1194
+ }
1195
+ };
1196
+
1001
1197
  let cloud: CloudPlan;
1002
1198
  if (answers.oss) {
1003
1199
  cloud = saved ? { kind: "kept", how: "skip", verified: false } : { kind: "oss" };
@@ -1032,11 +1228,12 @@ export async function runConfigureWizard(
1032
1228
  cloud = saved ? { kind: "kept", how: "skip", verified: false } : { kind: "oss" };
1033
1229
  write([...cloudLines(cloud), ""]);
1034
1230
  } else {
1035
- // Said above the field, which stays usable for Tab and a saved key.
1231
+ // Said above the menu, which stays usable for keeping a saved key and for
1232
+ // open source, neither of which sends anything anywhere.
1036
1233
  if (badUrl) write([kit.fail(badUrl)]);
1037
- const answer = await askCloud();
1038
- if (answer === null) return cancel();
1039
- cloud = planFor(answer);
1234
+ const chosen = await chooseConnection();
1235
+ if (chosen === null) return cancel();
1236
+ cloud = chosen;
1040
1237
  }
1041
1238
 
1042
1239
  // ── AGENTS ──────────────────────────────────────────────────────────────────
@@ -1049,8 +1246,10 @@ export async function runConfigureWizard(
1049
1246
  // Whether the daemon will be collecting once this run ends: a key is already
1050
1247
  // on disk, or this run writes one. Removal copy and the backfill request both
1051
1248
  // depend on it.
1052
- const collectingAfter = cloud.kind === "connect" || readIngestCredential() !== null;
1053
- const sessionsOn = cloud.kind === "connect" ? answers.noTranscripts !== true : fp.collector.sessions === true;
1249
+ // A browser login connects exactly like a pasted key, one step later.
1250
+ const connecting = cloud.kind === "connect" || cloud.kind === "login";
1251
+ const collectingAfter = connecting || readIngestCredential() !== null;
1252
+ const sessionsOn = connecting ? answers.noTranscripts !== true : fp.collector.sessions === true;
1054
1253
 
1055
1254
  let selected: IntegrationType[];
1056
1255
  if (answers.agents && answers.agents.length > 0) {
@@ -1105,6 +1304,37 @@ export async function runConfigureWizard(
1105
1304
  // 5. The connection last.
1106
1305
  const done: string[] = [];
1107
1306
  let daemonInstalled = daemonAlreadyRunning;
1307
+ // The `sudo -v` at the top cached root for sudo's timeout — five minutes by
1308
+ // default on macOS, fifteen on most Linux. A browser login, or simply a slow
1309
+ // agents screen, can outlast it, and the install below runs `sudo -n`, which
1310
+ // would then fail AFTER the person did everything else. Ask once more instead.
1311
+ // The stale-unit refresh runs `sudo -n` too, so it is asked for the same way —
1312
+ // but, as at the top, losing root there costs the scheduled audit, not setup.
1313
+ // Unattended runs never primed anything, so there is nothing to have expired.
1314
+ if ((daemonWanted || daemonUnitStale) && !unattended) {
1315
+ let stillRoot = false;
1316
+ try {
1317
+ stillRoot = canElevate();
1318
+ } catch {
1319
+ stillRoot = false;
1320
+ }
1321
+ if (!stillRoot && daemonWanted) {
1322
+ if (!elevate("Your sudo session expired while you were away. Installing failproofaid needs root again.")) {
1323
+ write([
1324
+ kit.fail("Couldn't get root, so setup stopped before changing anything."),
1325
+ ` Re-run once you can use sudo: ${kit.cmd("failproofai config")}`,
1326
+ ]);
1327
+ void emit("configure_aborted", { reason: "needs_root" });
1328
+ return { applied: false, abort: "needs_root" };
1329
+ }
1330
+ } else if (
1331
+ !stillRoot &&
1332
+ !elevate("Your sudo session expired while you were away. Refreshing failproofaid's service definition needs root again.")
1333
+ ) {
1334
+ done.push(kit.caution("No root, so its service definition stays as it is. Scheduled audits stay off until it is refreshed."));
1335
+ daemonUnitStale = false;
1336
+ }
1337
+ }
1108
1338
  if (daemonWanted) {
1109
1339
  // A unit that is running but cannot answer is torn down before it is
1110
1340
  // rebuilt: it holds the singleton flock the replacement needs, so
@@ -1214,24 +1444,6 @@ export async function runConfigureWizard(
1214
1444
  pruneOldDaemonBinaries();
1215
1445
  }
1216
1446
 
1217
- const connectedAfterPlan = cloud.kind === "connect" || cloud.kind === "kept";
1218
- // Telemetry runs concurrently with the rest of apply (never rejects,
1219
- // 5s-bounded) and is awaited before returning. `cli` carries the selected
1220
- // agent ids, as it always has (and as hooks_installed does): a fixed list of
1221
- // product ids, never user content. The agents_* fields add counts only.
1222
- const applied = emit("configure_applied", {
1223
- target: "user",
1224
- scopes: ["user"],
1225
- cli: selected,
1226
- cli_count: selected.length,
1227
- policy_count: (readScopedHooksConfig("user", cwd).enabledPolicies ?? []).length,
1228
- connected: cloud.kind === "connect",
1229
- mode: connectedAfterPlan ? "cloud" : "oss",
1230
- agents_selected: selected.length,
1231
- agents_detected: plan.detected.size,
1232
- agents_changed: added.length + removed.length > 0,
1233
- });
1234
-
1235
1447
  // Hooks, at user scope, for the chosen agents. Whatever policies are enabled
1236
1448
  // there are carried through untouched: `replace: true` would otherwise switch
1237
1449
  // off policies the user had turned on, and setup must never reduce
@@ -1259,6 +1471,11 @@ export async function runConfigureWizard(
1259
1471
  cwd,
1260
1472
  );
1261
1473
 
1474
+ // The failproofai skill, in every agent this machine has and any ticked
1475
+ // without being detected — traced or not, an agent asked about FailproofAI
1476
+ // should know it. A pointer to the skill online (see agent-skill.ts).
1477
+ installAgentSkill(INTEGRATION_TYPES.filter((id) => plan.detected.has(id) || selected.includes(id)));
1478
+
1262
1479
  // Agents traced again start fresh (see `requestFreshStart`). Only on a
1263
1480
  // rerun: its baseline is the selection the daemon actually filtered on. With
1264
1481
  // no saved list (upgrade, first) the daemon was collecting every agent, so an
@@ -1273,8 +1490,38 @@ export async function runConfigureWizard(
1273
1490
  // a key on disk doing nothing. `connectToCloud` re-verifies each capability
1274
1491
  // and writes only what works.
1275
1492
  let connected = false;
1276
- if (cloud.kind === "connect" && url) {
1277
- const token = cloud.token;
1493
+ // A browser login has an approval, not a key: claim it NOW, with the daemon
1494
+ // in, so a run that stopped earlier left nothing minted anywhere. The claim
1495
+ // is single-use — the server mints the key and hands it over exactly once.
1496
+ let token: string | null = cloud.kind === "connect" ? cloud.token : null;
1497
+ let orgLabel: string | undefined = cloud.kind === "connect" ? cloud.org : undefined;
1498
+ let mintedName: string | undefined;
1499
+ let claimedOrg: LoginOrg | undefined;
1500
+ if (cloud.kind === "login" && url) {
1501
+ const claimed = await transient("Creating this machine's key…", () => claimLogin(url, cloud.pollSecret));
1502
+ void emit("configure_cloud_login", { step: "claimed", ok: claimed.kind === "ok", reason: claimed.kind });
1503
+ if (claimed.kind === "ok") {
1504
+ token = claimed.key;
1505
+ orgLabel = loginOrgLabel(claimed.org);
1506
+ mintedName = claimed.keyName;
1507
+ claimedOrg = claimed.org;
1508
+ } else {
1509
+ hookLogWarn(`cloud login could not be completed: ${claimed.kind}`);
1510
+ done.push(
1511
+ kit.caution(
1512
+ claimed.kind === "expired"
1513
+ ? "The login took too long to finish, so no key was created. Everything else was applied."
1514
+ : claimed.kind === "revoked"
1515
+ ? "Whoever approved the login can no longer create keys in that org, so no key was created."
1516
+ : claimed.kind === "unreachable"
1517
+ ? `Couldn't reach ${host} to create the key (${claimed.reason}); everything else was applied.`
1518
+ : `The key couldn't be created: ${claimed.reason}. Everything else was applied.`,
1519
+ "failproofai config",
1520
+ ),
1521
+ );
1522
+ }
1523
+ }
1524
+ if (token && url) {
1278
1525
  try {
1279
1526
  const outcome = await transient("Connecting…", () =>
1280
1527
  connectToCloud({
@@ -1284,11 +1531,18 @@ export async function runConfigureWizard(
1284
1531
  machineLabel,
1285
1532
  // Both streams unless the run said `--no-transcripts`.
1286
1533
  sessions: answers.noTranscripts !== true,
1534
+ // The org the login was approved into, for when introspect can't say.
1535
+ org: claimedOrg,
1287
1536
  }),
1288
1537
  );
1289
1538
  connected = outcome.anyConfigured;
1290
1539
  done.push(...(outcome.changes ?? []));
1291
- done.push(...connectDoneLines(kit, outcome, host, cloud.org));
1540
+ done.push(...connectDoneLines(kit, outcome, host, orgLabel));
1541
+ // Logging in again makes a NEW key; the one this machine held keeps
1542
+ // working until someone revokes it. Said here, where it is still useful.
1543
+ if (cloud.kind === "login" && saved && connected) {
1544
+ done.push(` Your previous key for this machine still works until you revoke it on ${host}'s keys page.`);
1545
+ }
1292
1546
  if (outcome.ingest.ok && answers.noTranscripts === true) {
1293
1547
  done.push(" Decisions only: session transcripts are not sent (--no-transcripts).");
1294
1548
  }
@@ -1298,13 +1552,49 @@ export async function runConfigureWizard(
1298
1552
  hookLogWarn(`cloud connection was not written: ${err instanceof Error ? err.message : String(err)}`);
1299
1553
  done.push(kit.caution("Couldn't connect; everything else was applied.", "failproofai config"));
1300
1554
  }
1555
+ // The claim spent the approval and the key exists, but nothing here uses
1556
+ // it: say which one, so it can be revoked rather than left live.
1557
+ if (cloud.kind === "login" && !connected) {
1558
+ done.push(` The key ${mintedName ? `"${mintedName}" ` : ""}was created but isn't in use here; revoke it on ${host}'s keys page.`);
1559
+ }
1301
1560
  } else if (cloud.kind === "kept" && cloud.how === "saved" && fp.mode !== "cloud") {
1302
1561
  // A home migrated from layout 1 carries keys but no mode, and would read
1303
1562
  // as open source everywhere that asks.
1304
1563
  updateConfig({ mode: "cloud" });
1305
1564
  }
1306
1565
 
1307
- await applied;
1566
+ // Logging in again on a connected machine, with nothing connected at the end
1567
+ // of it — no key, or a key `connectToCloud` could not use, which writes
1568
+ // nothing — changed nothing about the connection: the previous one is still
1569
+ // there (never removed from this step, decision D4) — but not as if it worked
1570
+ // when its key was refused.
1571
+ const keptOld = cloud.kind === "login" && !connected && Boolean(saved);
1572
+ if (keptOld) {
1573
+ done.push(
1574
+ savedRefused
1575
+ ? " This machine keeps its previous connection, but its key no longer works. Run failproofai config again to log in."
1576
+ : " This machine keeps its previous connection.",
1577
+ );
1578
+ }
1579
+ const cloudAfter = connected || cloud.kind === "kept" || keptOld;
1580
+
1581
+ // Sent once the connection's outcome is known — a login whose claim then
1582
+ // failed must not count as connected. Never rejects, 5s-bounded. `cli`
1583
+ // carries the selected agent ids, as it always has (and as hooks_installed
1584
+ // does): a fixed list of product ids, never user content. The agents_*
1585
+ // fields add counts only.
1586
+ await emit("configure_applied", {
1587
+ target: "user",
1588
+ scopes: ["user"],
1589
+ cli: selected,
1590
+ cli_count: selected.length,
1591
+ policy_count: policies.length,
1592
+ connected,
1593
+ mode: cloudAfter ? "cloud" : "oss",
1594
+ agents_selected: selected.length,
1595
+ agents_detected: plan.detected.size,
1596
+ agents_changed: added.length + removed.length > 0,
1597
+ });
1308
1598
  // Only now — a completed apply — is the launcher considered "seen", so
1309
1599
  // first-run onboarding stops offering itself on every command.
1310
1600
  markLauncherSeen();
@@ -1321,7 +1611,7 @@ export async function runConfigureWizard(
1321
1611
  done,
1322
1612
  cwd,
1323
1613
  agents: selected.length,
1324
- connected: connected || cloud.kind === "kept",
1614
+ connected: cloudAfter,
1325
1615
  codexTrust,
1326
1616
  }),
1327
1617
  );
@@ -1334,7 +1624,7 @@ export async function runConfigureWizard(
1334
1624
  policies,
1335
1625
  daemonInstalled,
1336
1626
  connected,
1337
- mode: connected || cloud.kind === "kept" ? "cloud" : "oss",
1627
+ mode: cloudAfter ? "cloud" : "oss",
1338
1628
  agents: { selected, added, removed },
1339
1629
  };
1340
1630
  }
@@ -1345,7 +1635,19 @@ export async function runConfigureWizard(
1345
1635
  type CloudPlan =
1346
1636
  | { kind: "oss" }
1347
1637
  | { kind: "kept"; how: "skip" | "saved"; verified: boolean }
1348
- | { kind: "connect"; token: string; org?: string; note?: string };
1638
+ | { kind: "connect"; token: string; org?: string; note?: string }
1639
+ /**
1640
+ * Approved in the browser, not yet claimed. The key does not exist until
1641
+ * apply claims it — after the daemon is in — so a run that stops before then
1642
+ * leaves no key behind anywhere.
1643
+ */
1644
+ | { kind: "login"; pollSecret: string; org: LoginOrg; approvedBy?: string; keyName?: string; permissions: string[] };
1645
+
1646
+ /** "Acme (acme)", or whichever half the server sent. */
1647
+ function loginOrgLabel(org: LoginOrg): string {
1648
+ if (org.name && org.slug) return `${org.name} (${org.slug})`;
1649
+ return org.name ?? org.slug ?? "your org";
1650
+ }
1349
1651
 
1350
1652
  /**
1351
1653
  * The connection already on this machine, from either credential: the policy
@@ -1394,7 +1696,15 @@ export function resolveCloudUrl(
1394
1696
  const env = process.env.FAILPROOFAI_CLOUD_URL?.trim();
1395
1697
  const fromFlag = flag?.trim();
1396
1698
  const override = fromFlag || env;
1397
- if (!override) return { ok: true, url: existing ?? cloudBaseFor(DEFAULT_INGEST_URL) };
1699
+ if (!override) {
1700
+ if (!existing) return { ok: true, url: cloudBaseFor(DEFAULT_INGEST_URL) };
1701
+ // Checked like a typed one: login sends its secret and receives a key here
1702
+ // before anything else would, and the file it came from can be edited.
1703
+ const saved = validateCloudUrl(existing);
1704
+ return saved.ok
1705
+ ? { ok: true, url: existing }
1706
+ : { ok: false, message: `the saved cloud address "${existing}" cannot be used: ${saved.reason}` };
1707
+ }
1398
1708
  const source = fromFlag && fromFlag !== env ? "--url" : "FAILPROOFAI_CLOUD_URL";
1399
1709
  const validated = validateCloudUrl(cloudBaseFor(override));
1400
1710
  if (!validated.ok) {
@@ -1430,6 +1740,11 @@ export async function checkCloudKey(
1430
1740
  url: string,
1431
1741
  key: string,
1432
1742
  ): Promise<{ ok: true; org?: string; note?: string } | { ok: false; refused: boolean; reason: string }> {
1743
+ // Checked here rather than trusted: a saved address comes from a file that
1744
+ // can be edited, and an http one to another host would carry the key in
1745
+ // the clear. Not a verdict on the key, so not `refused`.
1746
+ const target = validateCloudUrl(url);
1747
+ if (!target.ok) return { ok: false, refused: false, reason: `the address ${url} can't be used: ${target.reason}` };
1433
1748
  const identity = await introspectKey(url, key);
1434
1749
  if (identity.kind === "rejected") return { ok: false, refused: true, reason: "the server did not accept it" };
1435
1750
  if (identity.kind === "unreachable") return { ok: false, refused: false, reason: identity.reason };
@@ -1477,6 +1792,15 @@ export function cloudCollapsedLines(
1477
1792
  kit.ok(plan.org ? `Key accepted for ${plan.org} on ${at.host}` : `Key accepted by ${at.host}`),
1478
1793
  ...(plan.note ? [kit.caution(plan.note)] : []),
1479
1794
  ];
1795
+ case "login":
1796
+ // Who approved it is said out loud: a link someone else approved into
1797
+ // THEIR org would otherwise connect this machine there unnoticed.
1798
+ return [
1799
+ head,
1800
+ kit.ok(
1801
+ `Logged in${plan.approvedBy ? ` as ${plan.approvedBy}` : ""} · ${loginOrgLabel(plan.org)} on ${at.host}`,
1802
+ ),
1803
+ ];
1480
1804
  }
1481
1805
  }
1482
1806
 
@@ -23,11 +23,20 @@
23
23
  * `is_auto_retryable()` excludes it from the retry pass — that is correct, and
24
24
  * re-litigating it here would burn the budget of batches that could succeed.
25
25
  * The gap was never the detection. It was that the detection was mute.
26
+ *
27
+ * A credential refusal is only evidence about the CURRENT key while nothing
28
+ * has been accepted since. A key regenerated on the dashboard leaves the
29
+ * running collector on the old secret until the daemon's next config tick, so
30
+ * a batch or two is refused while the new key already works. Those parked
31
+ * before the collector's last accepted delivery (`delivery.last_ok_ts`) are
32
+ * counted as `resending`, not refused: the daemon re-sends them with the key
33
+ * that works (`Delivery::redrive_refused`) and drops one only if that key
34
+ * refuses it too.
26
35
  */
27
- import { existsSync, readdirSync, statSync } from "node:fs";
36
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
28
37
  import { join } from "node:path";
29
38
 
30
- import { failedDir } from "./fp-home";
39
+ import { collectorHealthFile, failedDir } from "./fp-home";
31
40
  import { readCredentials } from "./fp-config";
32
41
 
33
42
  /**
@@ -106,6 +115,13 @@ export interface DeliveryHealth {
106
115
  credentialRejected: number;
107
116
  /** Count per client status, e.g. `{ 401: 26 }`. */
108
117
  byStatus: Record<number, number>;
118
+ /**
119
+ * Credential refusals parked BEFORE the last delivery the server accepted.
120
+ * The key in use now works, so these are not counted as refused: the daemon
121
+ * re-sends them with that key (`Delivery::redrive_refused`, fpai-collect)
122
+ * and drops one only if the current key refuses it too.
123
+ */
124
+ resending: number;
109
125
  /** Age of the oldest parked batch, in ms. Undefined when none are parked. */
110
126
  oldestAgeMs?: number;
111
127
  }
@@ -115,8 +131,23 @@ const EMPTY: DeliveryHealth = {
115
131
  rejected: 0,
116
132
  credentialRejected: 0,
117
133
  byStatus: {},
134
+ resending: 0,
118
135
  };
119
136
 
137
+ /**
138
+ * `delivery.last_ok_ts` from the collector's health file, in ms, or undefined.
139
+ * The last upload the server ACCEPTED (`UploadMetrics::last_ok_ts`).
140
+ */
141
+ export function readLastDeliveredAt(home?: string): number | undefined {
142
+ try {
143
+ const raw = JSON.parse(readFileSync(collectorHealthFile(home), "utf8")) as { delivery?: { last_ok_ts?: unknown } };
144
+ const ts = raw?.delivery?.last_ok_ts;
145
+ return typeof ts === "number" && Number.isFinite(ts) && ts > 0 ? ts * 1000 : undefined;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
120
151
  /**
121
152
  * Summarise `failed/` without touching it.
122
153
  *
@@ -127,6 +158,7 @@ export function deliveryHealth(
127
158
  home?: string,
128
159
  now: number = Date.now(),
129
160
  connectedAt: number | undefined = home ? undefined : readCredentials().connectedAt,
161
+ lastDeliveredAt: number | undefined = home ? undefined : readLastDeliveredAt(),
130
162
  ): DeliveryHealth {
131
163
  const dir = failedDir(home);
132
164
  if (!existsSync(dir)) return EMPTY;
@@ -144,6 +176,7 @@ export function deliveryHealth(
144
176
  let total = 0;
145
177
  let rejected = 0;
146
178
  let credentialRejected = 0;
179
+ let resending = 0;
147
180
  let oldestMtime: number | undefined;
148
181
 
149
182
  for (const name of entries) {
@@ -162,6 +195,17 @@ export function deliveryHealth(
162
195
  CREDENTIAL_STATUSES.has(parsed.clientStatus) &&
163
196
  mtime < connectedAt
164
197
  ) continue;
198
+ // Refused with a key that has worked since: the latest delivery attempt
199
+ // was accepted, so the CURRENT key is not the one being refused.
200
+ if (
201
+ lastDeliveredAt !== undefined &&
202
+ parsed.clientStatus !== undefined &&
203
+ CREDENTIAL_STATUSES.has(parsed.clientStatus) &&
204
+ mtime <= lastDeliveredAt
205
+ ) {
206
+ resending += 1;
207
+ continue;
208
+ }
165
209
  total += 1;
166
210
  if (parsed.clientStatus !== undefined) {
167
211
  rejected += 1;
@@ -177,6 +221,7 @@ export function deliveryHealth(
177
221
  rejected,
178
222
  credentialRejected,
179
223
  byStatus,
224
+ resending,
180
225
  ...(oldestMtime !== undefined ? { oldestAgeMs: Math.max(0, now - oldestMtime) } : {}),
181
226
  };
182
227
  }
@@ -49,6 +49,8 @@ import { cloudReviewerNames, readCloudAuthorityInputs, readCloudJevMode } from "
49
49
  import type { AgentIdentity } from "./agent-targets";
50
50
 
51
51
  let cached: ReadonlySet<string> | null = null;
52
+ /** Whether the installed packs alone (not Cloud) declared any check, for the cached answer. */
53
+ let cachedPackChecks = false;
52
54
  let cachedContested: ReadonlyMap<string, string[]> = new Map();
53
55
  /** The agent the current registration pass is for; see {@link forgetEffectiveReviewerNames}. */
54
56
  let reviewerCli: string | undefined;
@@ -185,9 +187,11 @@ export function effectiveReviewerNames(): ReadonlySet<string> {
185
187
  if (cached) return cached;
186
188
  let names: ReadonlySet<string> = NO_REVIEWERS;
187
189
  cachedContested = new Map();
190
+ cachedPackChecks = false;
188
191
  try {
189
192
  const packs = jevPacks(readInstalledPacks().packs, reviewerCli);
190
193
  names = reviewerNamesFor(packs);
194
+ cachedPackChecks = names.size > 0;
191
195
  cachedContested = contestedSemanticNames(packs);
192
196
  } catch {
193
197
  // See above: an unreadable manifest declares no checks.
@@ -224,6 +228,16 @@ export function jevChecksInstalled(): boolean {
224
228
  return effectiveReviewerNames().size > 0;
225
229
  }
226
230
 
231
+ /**
232
+ * Whether an installed pack (not FailproofAI Cloud) gives Jev a check for this
233
+ * agent: the part of {@link jevChecksInstalled} a Cloud review needs to know
234
+ * when deciding whether a call has anything of the machine's own to ask.
235
+ */
236
+ export function packJevChecksInstalled(): boolean {
237
+ effectiveReviewerNames();
238
+ return cachedPackChecks;
239
+ }
240
+
227
241
  /**
228
242
  * The same question for a CLI command, read fresh rather than from the
229
243
  * registration cache (which only the hook path fills): any agent, packs as