failproofai 0.0.10-beta.9 → 0.0.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.
Files changed (116) 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 +7 -7
  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 +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/index.html +1 -1
  29. package/.next/standalone/.next/server/app/index.rsc +15 -15
  30. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  32. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  33. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  34. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  36. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  39. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  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/chunks/{[root-of-the-server]__0fjhqi9._.js → [root-of-the-server]__044xt9.._.js} +2 -2
  55. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d_ob4n._.js +1 -1
  56. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +1 -1
  57. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07_-mkc._.js +3 -0
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e9o9ri._.js +2 -2
  61. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0mnba92._.js → [root-of-the-server]__0l6swv1._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0logebz._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mi5ejy._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0podumr._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rkxer-._.js +3 -0
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rl2kwi._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0vg0uey._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ye1w50._.js +4 -0
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +32 -7
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__10._f0s._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +1 -1
  76. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  77. package/.next/standalone/.next/server/pages/404.html +1 -1
  78. package/.next/standalone/.next/server/pages/500.html +1 -1
  79. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  81. package/.next/standalone/.next/static/chunks/{0.a7kxwvbre7q.js → 0j171xiqge4rv.js} +1 -1
  82. package/.next/standalone/.next/static/chunks/0kqar56yl~41o.js +6 -0
  83. package/.next/standalone/.next/static/chunks/{0bndjr3n70vjn.js → 0lt8ko3lw.5yt.js} +1 -1
  84. package/.next/standalone/.next/static/chunks/{0el2m08z2u5jm.js → 0ml1.ck_5t36i.js} +1 -1
  85. package/.next/standalone/.next/static/chunks/{11zd_~~dqyu39.js → 0pkl..xgo-qox.js} +1 -1
  86. package/.next/standalone/.next/static/chunks/12l2t63hkyo2q.js +1 -0
  87. package/.next/standalone/.next/static/chunks/{0-17za-4x~4gj.js → 14lii11wmo450.js} +1 -1
  88. package/.next/standalone/.next/static/chunks/{05i2pvqx75mg1.js → 179yytvmam0ug.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/17rm86uz2nd5a.css +2 -0
  90. package/.next/standalone/.opencode/plugins/failproofai.mjs +75 -15
  91. package/.next/standalone/app/actions/get-hooks-config.ts +25 -1
  92. package/.next/standalone/app/policies/hooks-client.tsx +228 -44
  93. package/.next/standalone/lib/gemini-projects.ts +64 -24
  94. package/.next/standalone/lib/opencode-projects.ts +9 -7
  95. package/.next/standalone/package.json +2 -2
  96. package/.next/standalone/pi-extension/index.ts +113 -12
  97. package/.next/standalone/server.js +1 -1
  98. package/dist/cli.mjs +193 -68
  99. package/lib/gemini-projects.ts +64 -24
  100. package/lib/opencode-projects.ts +9 -7
  101. package/package.json +2 -2
  102. package/pi-extension/index.ts +113 -12
  103. package/scripts/launch.ts +5 -29
  104. package/src/hooks/handler.ts +63 -6
  105. package/src/hooks/integrations.ts +31 -6
  106. package/src/hooks/policy-evaluator.ts +34 -2
  107. package/src/hooks/types.ts +52 -0
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.2-_y.._.js +0 -3
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okpm5k._.js +0 -3
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0zwce~o._.js +0 -4
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0~tyclf._.js +0 -3
  112. package/.next/standalone/.next/static/chunks/0xd2k83e47~cx.js +0 -6
  113. package/.next/standalone/.next/static/chunks/0ywdtmk13p9_i.css +0 -2
  114. /package/.next/standalone/.next/static/{rOMtBtMm3y5vK0uC-Nzvi → dAuQps6jUwCz9X1Q5FFOO}/_buildManifest.js +0 -0
  115. /package/.next/standalone/.next/static/{rOMtBtMm3y5vK0uC-Nzvi → dAuQps6jUwCz9X1Q5FFOO}/_clientMiddlewareManifest.js +0 -0
  116. /package/.next/standalone/.next/static/{rOMtBtMm3y5vK0uC-Nzvi → dAuQps6jUwCz9X1Q5FFOO}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useEffect, useCallback, useRef, useTransition } from "react";
3
+ import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import Link from "next/link";
6
6
  import {
@@ -21,6 +21,7 @@ import { getHookActivityAction, searchHookActivityAction } from "@/app/actions/g
21
21
  import type { HookActivityPayload } from "@/app/actions/get-hook-activity";
22
22
  import { getHooksConfigAction } from "@/app/actions/get-hooks-config";
23
23
  import type { HooksConfigPayload, PolicyInfo, CustomPolicyInfo } from "@/app/actions/get-hooks-config";
24
+ import type { IntegrationType } from "@/src/hooks/types";
24
25
  import { togglePolicyAction } from "@/app/actions/update-hooks-config";
25
26
  import { installHooksWebAction, removeHooksWebAction } from "@/app/actions/install-hooks-web";
26
27
  import { updatePolicyParamsAction } from "@/app/actions/update-policy-params";
@@ -950,23 +951,66 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
950
951
  const [actionError, setActionError] = useState<string | null>(null);
951
952
  const [hooksWarning, setHooksWarning] = useState<string | null>(null);
952
953
  const [configuringPolicy, setConfiguringPolicy] = useState<PolicyInfo | null>(null);
954
+ const [checkedClis, setCheckedClis] = useState<Set<IntegrationType>>(() => new Set());
955
+ const cliCheckboxesInitializedRef = useRef(false);
953
956
 
954
957
  const reload = useCallback(async () => {
955
958
  try {
956
959
  const data = await getHooksConfigAction();
957
960
  setConfig(data);
958
- onHooksInstallChange?.(data.installedScopes.length > 0);
961
+ onHooksInstallChange?.(data.clis.some((c) => c.installed));
959
962
  } catch {
960
963
  // Non-critical
961
964
  }
962
965
  }, [onHooksInstallChange]);
963
966
 
964
- // eslint-disable-next-line react-hooks/set-state-in-effect
965
967
  useEffect(() => { reload(); }, [reload]);
966
968
 
969
+ // Sync the checkbox set with payload. On first load only, pre-check
970
+ // detected-but-not-installed CLIs so a fresh user lands ready for one-click
971
+ // install. After that, sync strictly to `installed` so unchecking a still-
972
+ // detected CLI and clicking Apply doesn't re-tick the box on reload.
973
+ useEffect(() => {
974
+ if (!config) return;
975
+ if (!cliCheckboxesInitializedRef.current) {
976
+ cliCheckboxesInitializedRef.current = true;
977
+ setCheckedClis(new Set(config.clis.filter((c) => c.installed || c.detected).map((c) => c.id)));
978
+ } else {
979
+ setCheckedClis(new Set(config.clis.filter((c) => c.installed).map((c) => c.id)));
980
+ }
981
+ // eslint-disable-next-line react-hooks/exhaustive-deps
982
+ }, [config?.clis]);
983
+
984
+ const installedCliSet = useMemo(
985
+ () => new Set((config?.clis ?? []).filter((c) => c.installed).map((c) => c.id)),
986
+ [config?.clis],
987
+ );
988
+
989
+ const pendingChanges = useMemo(() => {
990
+ const toInstall: IntegrationType[] = [];
991
+ const toRemove: IntegrationType[] = [];
992
+ for (const cli of config?.clis ?? []) {
993
+ const isChecked = checkedClis.has(cli.id);
994
+ if (isChecked && !installedCliSet.has(cli.id)) toInstall.push(cli.id);
995
+ if (!isChecked && installedCliSet.has(cli.id)) toRemove.push(cli.id);
996
+ }
997
+ return { toInstall, toRemove };
998
+ }, [config?.clis, checkedClis, installedCliSet]);
999
+
1000
+ const hasPendingChanges = pendingChanges.toInstall.length > 0 || pendingChanges.toRemove.length > 0;
1001
+
1002
+ const toggleCli = (id: IntegrationType) => {
1003
+ setCheckedClis((prev) => {
1004
+ const next = new Set(prev);
1005
+ if (next.has(id)) next.delete(id);
1006
+ else next.add(id);
1007
+ return next;
1008
+ });
1009
+ };
1010
+
967
1011
  const handleToggle = (name: string, currentlyEnabled: boolean) => {
968
1012
  if (!config) return;
969
- const installed = config.installedScopes.length > 0;
1013
+ const installed = config.clis.some((c) => c.installed);
970
1014
  if (!installed) {
971
1015
  setHooksWarning("Policies are not installed. Install policies to continue.");
972
1016
  return;
@@ -995,26 +1039,39 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
995
1039
  });
996
1040
  };
997
1041
 
998
- const handleInstall = () => {
1042
+ const handleApply = () => {
1043
+ const { toInstall, toRemove } = pendingChanges;
1044
+ if (toInstall.length === 0 && toRemove.length === 0) return;
999
1045
  startTransition(async () => {
1000
1046
  try {
1001
1047
  setActionError(null);
1002
- await installHooksWebAction("user");
1003
- await reload();
1048
+ if (toInstall.length > 0) await installHooksWebAction("user", toInstall);
1049
+ if (toRemove.length > 0) await removeHooksWebAction("user", toRemove);
1004
1050
  } catch (e) {
1005
- setActionError(e instanceof Error ? e.message : "Failed to install hooks.");
1051
+ setActionError(e instanceof Error ? e.message : "Failed to apply changes.");
1052
+ } finally {
1053
+ // Always resync so a partial-success batch (install OK, remove failed)
1054
+ // doesn't leave the UI showing stale install state on the next click.
1055
+ await reload();
1006
1056
  }
1007
1057
  });
1008
1058
  };
1009
1059
 
1010
- const handleRemove = () => {
1060
+ const handleReinstall = () => {
1061
+ // Reinstall acts on the intersection of checked × installed. Detected-but-
1062
+ // not-installed CLIs are pre-checked as a one-click install hint, so a raw
1063
+ // Array.from(checkedClis) would silently install brand-new CLIs from the
1064
+ // Reinstall button. Use Apply for first-time installs.
1065
+ const targets = Array.from(installedCliSet).filter((id) => checkedClis.has(id));
1066
+ if (targets.length === 0) return;
1011
1067
  startTransition(async () => {
1012
1068
  try {
1013
1069
  setActionError(null);
1014
- await removeHooksWebAction("user");
1015
- await reload();
1070
+ await installHooksWebAction("user", targets);
1016
1071
  } catch (e) {
1017
- setActionError(e instanceof Error ? e.message : "Failed to remove hooks.");
1072
+ setActionError(e instanceof Error ? e.message : "Failed to reinstall.");
1073
+ } finally {
1074
+ await reload();
1018
1075
  }
1019
1076
  });
1020
1077
  };
@@ -1042,7 +1099,9 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
1042
1099
  );
1043
1100
  }
1044
1101
 
1045
- const installed = config.installedScopes.length > 0;
1102
+ const installed = config.clis.some((c) => c.installed);
1103
+ const installedCount = installedCliSet.size;
1104
+ const checkedCount = checkedClis.size;
1046
1105
 
1047
1106
  // Group policies by category
1048
1107
  const categories = Array.from(new Set(config.policies.map((p) => p.category)));
@@ -1057,45 +1116,170 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
1057
1116
  />
1058
1117
  )}
1059
1118
  <div className="bg-card border border-border rounded-lg overflow-hidden">
1060
- {/* Install status banner */}
1061
- <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/10">
1062
- <div className="flex items-center gap-2.5">
1063
- <span
1064
- className={`h-2 w-2 rounded-full shrink-0 ${installed ? "bg-emerald-500" : "bg-muted-foreground/50"}`}
1065
- />
1066
- <span className="text-sm text-foreground">
1067
- {installed ? "Policies installed" : "Policies not installed"}
1068
- </span>
1069
- {installed && (
1070
- <span className="text-xs text-muted-foreground font-mono hidden sm:inline">
1071
- · {config.installedScopes.join(", ")} scope · {config.settingsPath}
1119
+ {/* CLI control panel — header */}
1120
+ <div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-border/60 bg-muted/10">
1121
+ <div className="flex items-center gap-3 min-w-0 flex-wrap">
1122
+ <div className="flex items-center gap-2">
1123
+ <span
1124
+ className={`h-2 w-2 rounded-full transition-shadow ${
1125
+ installed
1126
+ ? "bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.6)]"
1127
+ : "bg-muted-foreground/40"
1128
+ }`}
1129
+ aria-hidden
1130
+ />
1131
+ <span className="text-[0.65rem] uppercase tracking-[0.2em] font-mono text-foreground/90">
1132
+ Integrations
1133
+ </span>
1134
+ </div>
1135
+ <span className="text-[0.65rem] font-mono text-muted-foreground/40">·</span>
1136
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] font-mono text-muted-foreground">
1137
+ <span className="text-foreground tabular-nums">
1138
+ {installedCount.toString().padStart(2, "0")}
1072
1139
  </span>
1140
+ <span className="opacity-50"> / 0{config.clis.length} active</span>
1141
+ </div>
1142
+ {hasPendingChanges && (
1143
+ <>
1144
+ <span className="text-[0.65rem] font-mono text-muted-foreground/40">·</span>
1145
+ <span className="inline-flex items-center gap-1.5 text-[0.65rem] uppercase tracking-[0.2em] font-mono text-pink-400">
1146
+ <span className="h-1 w-1 rounded-full bg-pink-400 animate-pulse" />
1147
+ {pendingChanges.toInstall.length + pendingChanges.toRemove.length} pending
1148
+ </span>
1149
+ </>
1073
1150
  )}
1074
1151
  </div>
1075
1152
  <div className="flex items-center gap-2 shrink-0">
1076
- {installed && (
1077
- <Button
1078
- variant="outline"
1079
- size="sm"
1080
- onClick={handleRemove}
1081
- disabled={isPending}
1082
- className="text-xs h-7 px-3"
1083
- >
1084
- Remove
1085
- </Button>
1086
- )}
1087
1153
  <Button
1088
- variant={installed ? "outline" : "default"}
1154
+ variant="outline"
1089
1155
  size="sm"
1090
- onClick={handleInstall}
1091
- disabled={isPending}
1092
- className="text-xs h-7 px-3"
1156
+ onClick={handleReinstall}
1157
+ disabled={isPending || checkedCount === 0}
1158
+ className="text-xs h-7 px-3 font-mono"
1093
1159
  >
1094
- {installed ? "Reinstall" : "Install policies"}
1160
+ Reinstall
1161
+ </Button>
1162
+ <Button
1163
+ variant="default"
1164
+ size="sm"
1165
+ onClick={handleApply}
1166
+ disabled={isPending || !hasPendingChanges}
1167
+ className="text-xs h-7 px-3 font-mono"
1168
+ >
1169
+ Apply changes
1095
1170
  </Button>
1096
1171
  </div>
1097
1172
  </div>
1098
1173
 
1174
+ {/* CLI rows */}
1175
+ <div className="divide-y divide-border/30 border-b border-border/60">
1176
+ {config.clis.map((cli, i) => {
1177
+ const isChecked = checkedClis.has(cli.id);
1178
+ const isInstalled = installedCliSet.has(cli.id);
1179
+ const willChange = isChecked !== isInstalled;
1180
+ const badge = getCliBadgeClasses(cli.id);
1181
+ const accentClass =
1182
+ badge.split(" ").find((c) => c.startsWith("text-")) ?? "text-foreground";
1183
+
1184
+ return (
1185
+ <label
1186
+ key={cli.id}
1187
+ className={`group relative flex items-center gap-3 pl-6 pr-5 py-3 cursor-pointer transition-colors hover:bg-muted/15 ${
1188
+ willChange ? "bg-pink-500/[0.04]" : ""
1189
+ }`}
1190
+ >
1191
+ {/* Brand-colored accent rail */}
1192
+ <div
1193
+ className={`absolute left-0 top-0 bottom-0 w-[3px] ${accentClass} bg-current transition-opacity ${
1194
+ isChecked ? "opacity-100" : "opacity-25 group-hover:opacity-60"
1195
+ }`}
1196
+ aria-hidden
1197
+ />
1198
+
1199
+ {/* Slot index — 01..07 */}
1200
+ <div className="font-mono text-[0.65rem] text-muted-foreground/40 tabular-nums w-6 select-none">
1201
+ {(i + 1).toString().padStart(2, "0")}
1202
+ </div>
1203
+
1204
+ {/* Custom checkbox */}
1205
+ <div className="relative shrink-0">
1206
+ <input
1207
+ type="checkbox"
1208
+ checked={isChecked}
1209
+ onChange={() => toggleCli(cli.id)}
1210
+ disabled={isPending}
1211
+ className="sr-only peer"
1212
+ aria-label={`Toggle ${cli.label}`}
1213
+ />
1214
+ <div
1215
+ className={`h-[18px] w-[18px] rounded-sm border transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40 ${
1216
+ isChecked
1217
+ ? "bg-primary border-primary"
1218
+ : "bg-background/50 border-border group-hover:border-foreground/40"
1219
+ }`}
1220
+ >
1221
+ {isChecked && (
1222
+ <svg
1223
+ viewBox="0 0 18 18"
1224
+ className="h-full w-full text-primary-foreground"
1225
+ fill="none"
1226
+ aria-hidden
1227
+ >
1228
+ <path
1229
+ d="M4 9.5L7.5 12.5L14 6"
1230
+ stroke="currentColor"
1231
+ strokeWidth="2"
1232
+ strokeLinecap="round"
1233
+ strokeLinejoin="round"
1234
+ />
1235
+ </svg>
1236
+ )}
1237
+ </div>
1238
+ </div>
1239
+
1240
+ {/* CLI label */}
1241
+ <div className="min-w-[150px] shrink-0">
1242
+ <div className="text-sm font-medium text-foreground">{cli.label}</div>
1243
+ </div>
1244
+
1245
+ {/* Status + diff pills */}
1246
+ <div className="flex items-center gap-1.5 shrink-0">
1247
+ {isInstalled ? (
1248
+ <span
1249
+ className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border ${badge}`}
1250
+ >
1251
+ <span className="h-1 w-1 rounded-full bg-current shadow-[0_0_4px_currentColor]" />
1252
+ Active
1253
+ </span>
1254
+ ) : cli.detected ? (
1255
+ <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border border-border/60 text-muted-foreground/80 bg-muted/20">
1256
+ <span className="h-1 w-1 rounded-full bg-current opacity-60" />
1257
+ Detected
1258
+ </span>
1259
+ ) : (
1260
+ <span className="inline-flex items-center px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border border-border/30 text-muted-foreground/40">
1261
+ Inactive
1262
+ </span>
1263
+ )}
1264
+ {willChange && (
1265
+ <span className="inline-flex items-center px-2 py-0.5 rounded-sm text-[0.6rem] uppercase tracking-[0.15em] font-mono border border-pink-500/40 text-pink-400 bg-pink-500/10">
1266
+ {isChecked ? "+ install" : "− remove"}
1267
+ </span>
1268
+ )}
1269
+ </div>
1270
+
1271
+ {/* Settings path — right-aligned, monospace */}
1272
+ <div
1273
+ className="ml-auto hidden lg:block text-[0.7rem] font-mono text-muted-foreground/50 truncate max-w-[420px] group-hover:text-muted-foreground/80 transition-colors"
1274
+ title={cli.settingsPath}
1275
+ >
1276
+ {cli.settingsPath}
1277
+ </div>
1278
+ </label>
1279
+ );
1280
+ })}
1281
+ </div>
1282
+
1099
1283
  {/* Policy summary */}
1100
1284
  <div className="flex items-center gap-2 px-4 py-2 border-b border-border/40 bg-muted/5">
1101
1285
  <span className="text-xs text-muted-foreground">
@@ -1106,7 +1290,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
1106
1290
  </span>
1107
1291
  {installed && (
1108
1292
  <span className="text-[0.65rem] text-muted-foreground/60">
1109
- · active in {config.installedScopes.join(", ")} scope
1293
+ · shared across {installedCount} active CLI{installedCount === 1 ? "" : "s"}
1110
1294
  </span>
1111
1295
  )}
1112
1296
  </div>
@@ -1120,7 +1304,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install
1120
1304
  <ErrorToast
1121
1305
  message={hooksWarning}
1122
1306
  onDismiss={() => setHooksWarning(null)}
1123
- onInstall={handleInstall}
1307
+ onInstall={handleApply}
1124
1308
  isPending={isPending}
1125
1309
  />
1126
1310
  )}
@@ -1306,7 +1490,7 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?:
1306
1490
  useEffect(() => {
1307
1491
  getHooksConfigAction()
1308
1492
  .then((cfg) => {
1309
- setHooksInstalled(cfg.installedScopes.length > 0);
1493
+ setHooksInstalled(cfg.clis.some((c) => c.installed));
1310
1494
  setPolicyCounts({
1311
1495
  enabled: cfg.enabledPolicies.length,
1312
1496
  total: cfg.policies.length + (cfg.customPolicies?.length ?? 0),
@@ -21,7 +21,7 @@
21
21
  * missing `~/.gemini/` returns `[]`, malformed JSONL falls open without
22
22
  * surfacing the session.
23
23
  */
24
- import { readdir, readFile, stat } from "node:fs/promises";
24
+ import { open, readdir, readFile, stat } from "node:fs/promises";
25
25
  import { homedir } from "node:os";
26
26
  import { join } from "node:path";
27
27
  import type { ProjectFolder, SessionFile } from "./projects";
@@ -34,6 +34,13 @@ import { logWarn } from "./logger";
34
34
  /** Filename pattern for a Gemini session JSONL:
35
35
  * `session-<ISO-timestamp-with-dashes>-<8-hex-uuid-prefix>.jsonl`. */
36
36
  const SESSION_FILE_RE = /^session-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})-([0-9a-f]{8})\.jsonl$/i;
37
+ /** Full UUID — the filename only embeds the first 8 hex chars; the rest is on
38
+ * the JSONL metadata header line. The session detail route requires a full
39
+ * UUID, so links built from the truncated filename prefix 404. */
40
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
41
+ /** Metadata header sits on line 1 and is well under 1 KB; 4 KB covers it
42
+ * comfortably without slurping a multi-MB transcript. */
43
+ const FIRST_LINE_CHUNK_BYTES = 4 * 1024;
37
44
 
38
45
  /** Override for tests. Defaults to the live Gemini session-state root. */
39
46
  function getGeminiTmpRoot(): string {
@@ -46,6 +53,10 @@ interface GeminiSessionMeta {
46
53
  sessionFilename: string;
47
54
  cwd: string;
48
55
  fileMtime: Date;
56
+ /** Full UUID parsed from the JSONL metadata header (line 1). Undefined when
57
+ * the header is missing, malformed, or carries a non-UUID `sessionId`;
58
+ * callers fall through to rendering an un-linked row. */
59
+ sessionId?: string;
49
60
  }
50
61
 
51
62
  async function safeReaddir(dir: string) {
@@ -64,6 +75,40 @@ async function statMtime(path: string): Promise<Date | null> {
64
75
  }
65
76
  }
66
77
 
78
+ /** Read the first newline-delimited line of `filePath` without slurping the
79
+ * rest. Mirrors `lib/codex-projects.ts`'s readFirstLine; the Gemini metadata
80
+ * header is always on line 1. */
81
+ async function readFirstLine(filePath: string): Promise<string | null> {
82
+ let fh: Awaited<ReturnType<typeof open>> | null = null;
83
+ try {
84
+ fh = await open(filePath, "r");
85
+ const buf = Buffer.alloc(FIRST_LINE_CHUNK_BYTES);
86
+ const { bytesRead } = await fh.read(buf, 0, FIRST_LINE_CHUNK_BYTES, 0);
87
+ if (bytesRead === 0) return null;
88
+ const slice = buf.subarray(0, bytesRead);
89
+ const nl = slice.indexOf(0x0a); // '\n'
90
+ const end = nl === -1 ? bytesRead : nl;
91
+ return slice.subarray(0, end).toString("utf-8");
92
+ } catch {
93
+ return null;
94
+ } finally {
95
+ if (fh) await fh.close().catch(() => {});
96
+ }
97
+ }
98
+
99
+ /** Extract a full-UUID `sessionId` from a JSONL metadata header line. Returns
100
+ * undefined on parse failure, missing field, or a non-UUID value. */
101
+ function extractFullSessionId(line: string | null): string | undefined {
102
+ if (!line) return undefined;
103
+ try {
104
+ const meta = JSON.parse(line) as { sessionId?: unknown };
105
+ if (typeof meta.sessionId !== "string") return undefined;
106
+ return UUID_RE.test(meta.sessionId) ? meta.sessionId : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
67
112
  /** Read `.project_root` to recover the absolute cwd for a basename folder.
68
113
  * Returns null if missing or empty (caller treats the folder as un-mappable). */
69
114
  async function readProjectRoot(projectDir: string): Promise<string | null> {
@@ -101,7 +146,8 @@ async function scanGeminiSessions(): Promise<GeminiSessionMeta[]> {
101
146
  const filePath = join(chatsDir, f.name);
102
147
  const mtime = await statMtime(filePath);
103
148
  if (!mtime) continue;
104
- out.push({ filePath, sessionFilename: f.name, cwd, fileMtime: mtime });
149
+ const sessionId = extractFullSessionId(await readFirstLine(filePath));
150
+ out.push({ filePath, sessionFilename: f.name, cwd, fileMtime: mtime, sessionId });
105
151
  }
106
152
  }),
107
153
  16,
@@ -140,17 +186,14 @@ export async function getGeminiProjects(): Promise<ProjectFolder[]> {
140
186
  export async function getGeminiSessionsForCwd(cwd: string): Promise<SessionFile[]> {
141
187
  const sessions = await scanGeminiSessions();
142
188
  const matches = sessions.filter((s) => s.cwd === cwd);
143
- const files: SessionFile[] = matches.map((s) => {
144
- const m = s.sessionFilename.match(SESSION_FILE_RE);
145
- return {
146
- name: s.sessionFilename,
147
- path: s.filePath,
148
- lastModified: s.fileMtime,
149
- lastModifiedFormatted: formatDate(s.fileMtime),
150
- sessionId: m ? m[2] : undefined,
151
- cli: "gemini",
152
- };
153
- });
189
+ const files: SessionFile[] = matches.map((s) => ({
190
+ name: s.sessionFilename,
191
+ path: s.filePath,
192
+ lastModified: s.fileMtime,
193
+ lastModifiedFormatted: formatDate(s.fileMtime),
194
+ sessionId: s.sessionId,
195
+ cli: "gemini",
196
+ }));
154
197
  files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
155
198
  return files;
156
199
  }
@@ -180,17 +223,14 @@ export async function getGeminiSessionsByEncodedName(name: string): Promise<Gemi
180
223
  if (uniqueCwds.length !== 1) {
181
224
  return { cwd: null, sessions: [] };
182
225
  }
183
- const sessions = matches.map((s) => {
184
- const m = s.sessionFilename.match(SESSION_FILE_RE);
185
- return {
186
- name: s.sessionFilename,
187
- path: s.filePath,
188
- lastModified: s.fileMtime,
189
- lastModifiedFormatted: formatDate(s.fileMtime),
190
- sessionId: m ? m[2] : undefined,
191
- cli: "gemini" as const,
192
- };
193
- });
226
+ const sessions = matches.map((s) => ({
227
+ name: s.sessionFilename,
228
+ path: s.filePath,
229
+ lastModified: s.fileMtime,
230
+ lastModifiedFormatted: formatDate(s.fileMtime),
231
+ sessionId: s.sessionId,
232
+ cli: "gemini" as const,
233
+ }));
194
234
  sessions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
195
235
  return { cwd: uniqueCwds[0], sessions };
196
236
  }
@@ -22,7 +22,6 @@
22
22
  * https://opencode.ai/docs/plugins/ (plugin model context)
23
23
  */
24
24
  import { execFileSync } from "node:child_process";
25
- import { basename } from "node:path";
26
25
  import { encodeFolderName } from "./paths";
27
26
  import type { ProjectFolder, SessionFile } from "./projects";
28
27
  import { runtimeCache } from "./runtime-cache";
@@ -91,10 +90,11 @@ function readProjectRows(): OpenCodeProjectRow[] | null {
91
90
 
92
91
  /**
93
92
  * Group sessions by `project_id` and produce one ProjectFolder per project.
94
- * The folder name comes from `project.name` when set, else `basename(worktree)`,
95
- * else the project_id (last-resort). `lastModified` is the max session
96
- * `time_updated` for that project (or the project's own time_updated if no
97
- * sessions exist yet).
93
+ * The folder name is `encodeFolderName(worktree)` (matches every other CLI's
94
+ * URL-slug encoding so the dashboard's `/project/[name]` route resolves), or
95
+ * the project_id when no worktree is recorded. `lastModified` is the max
96
+ * session `time_updated` for that project (or the project's own time_updated
97
+ * if no sessions exist yet).
98
98
  */
99
99
  export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
100
100
  const sessions = readSessionRows();
@@ -122,13 +122,15 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
122
122
 
123
123
  // Emit one ProjectFolder per project that has at least one session OR a
124
124
  // project row (covers projects opencode knows about but hasn't run yet).
125
+ // `name` is the dashboard's URL slug — must be `encodeFolderName(cwd)` to
126
+ // match every other CLI (and the resolver in `getOpenCodeSessionsByEncodedName`).
125
127
  const seen = new Set<string>();
126
128
  const out: ProjectFolder[] = [];
127
129
  for (const [projectId, group] of groups) {
128
130
  seen.add(projectId);
129
131
  const proj = projectMap.get(projectId);
130
132
  const worktree = proj?.worktree ?? group.rows[0]?.directory ?? null;
131
- const name = proj?.name?.trim() || (worktree ? basename(worktree) : projectId);
133
+ const name = worktree ? encodeFolderName(worktree) : projectId;
132
134
  const path = worktree ?? "";
133
135
  const lastModified = new Date(Math.max(group.latest, proj?.time_updated ?? 0));
134
136
  out.push({
@@ -143,7 +145,7 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
143
145
  for (const p of projects ?? []) {
144
146
  if (seen.has(p.id)) continue;
145
147
  const worktree = p.worktree ?? "";
146
- const name = p.name?.trim() || (worktree ? basename(worktree) : p.id);
148
+ const name = worktree ? encodeFolderName(worktree) : p.id;
147
149
  const lastModified = new Date(p.time_updated);
148
150
  out.push({
149
151
  name,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.10-beta.9",
3
+ "version": "0.0.10",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -71,6 +71,7 @@
71
71
  "access": "public"
72
72
  },
73
73
  "devDependencies": {
74
+ "@anthropic-ai/sdk": "^0.93.0",
74
75
  "@tailwindcss/postcss": "^4.1.18",
75
76
  "@tanstack/react-virtual": "^3.13.18",
76
77
  "@testing-library/jest-dom": "^6.9.1",
@@ -91,7 +92,6 @@
91
92
  "tailwind-merge": "^3.4.0",
92
93
  "tailwindcss": "^4.1.18",
93
94
  "typescript": "^6.0.2",
94
- "@anthropic-ai/sdk": "^0.93.0",
95
95
  "vitest": "^4.0.18"
96
96
  },
97
97
  "dependencies": {