@gajae-code/coding-agent 0.16.7 → 0.17.1

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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -1,3 +1,4 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as nodeFs from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
@@ -44,6 +45,9 @@ import type {
44
45
  GjcUninstallPreview,
45
46
  GjcUpdateApplyResult,
46
47
  GjcUpdatePreview,
48
+ LocalRestorePlanV1,
49
+ NormalizedGjcPluginBundle,
50
+ ReviewedRestoreTokenV1,
47
51
  } from "./types";
48
52
  import { GJC_PLUGIN_MANIFEST_FILENAME, GjcPluginLoadError } from "./types";
49
53
 
@@ -824,6 +828,161 @@ export async function applyGjcBundleUpdate(
824
828
  });
825
829
  }
826
830
 
831
+ async function restoreArtifactObservation(entry: GjcPluginRegistryEntry): Promise<LocalRestorePlanV1["artifact"]> {
832
+ const hash = crypto.createHash("sha256");
833
+ try {
834
+ for (const file of [...entry.copiedFiles].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
835
+ const target = path.join(entry.pluginRoot, file.relativePath);
836
+ const stat = await fs.lstat(target);
837
+ if (!stat.isFile() || stat.isSymbolicLink()) return { status: "unreadable" };
838
+ const bytes = await fs.readFile(target);
839
+ if (crypto.createHash("sha256").update(bytes).digest("hex") !== file.sha256) return { status: "unreadable" };
840
+ hash.update(file.relativePath);
841
+ hash.update("\0");
842
+ hash.update(file.sha256);
843
+ hash.update("\0");
844
+ }
845
+ return { status: "present", digest: hash.digest("hex") };
846
+ } catch (error) {
847
+ hash.digest();
848
+ return { status: isEnoent(error) ? "absent" : "unreadable" };
849
+ }
850
+ }
851
+
852
+ function bundleArtifactFingerprint(bundle: NormalizedGjcPluginBundle): string {
853
+ const hash = crypto.createHash("sha256");
854
+ for (const file of [...bundle.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
855
+ hash.update(file.relativePath);
856
+ hash.update("\0");
857
+ hash.update(file.sha256);
858
+ hash.update("\0");
859
+ }
860
+ return hash.digest("hex");
861
+ }
862
+
863
+ export async function previewGjcBundleRestore(
864
+ ctx: GjcLifecycleContext,
865
+ identity: GjcBundleIdentity,
866
+ ): Promise<GjcLifecycleResult<LocalRestorePlanV1>> {
867
+ const registry = await readRegistry(identity.scope, ctx.cwd, { migrate: false });
868
+ const entry = registry.plugins.find(plugin => plugin.name === identity.name);
869
+ if (!entry) return { ok: false, error: notInstalled(identity) };
870
+ const effective = await readEffective(ctx.cwd);
871
+ return {
872
+ ok: true,
873
+ value: {
874
+ schemaVersion: 1,
875
+ kind: "gjc-plugin.restore-artifact",
876
+ identity,
877
+ baselineFingerprint: baselineFingerprint(entry),
878
+ decisionContextFingerprint: decisionContextFingerprint(identity, effective),
879
+ artifact: await restoreArtifactObservation(entry),
880
+ writes: false,
881
+ fetch: false,
882
+ locks: false,
883
+ },
884
+ };
885
+ }
886
+
887
+ export async function authorizeGjcBundleRestore(
888
+ ctx: GjcLifecycleContext,
889
+ plan: LocalRestorePlanV1,
890
+ authorizations: readonly string[],
891
+ ): Promise<GjcLifecycleResult<ReviewedRestoreTokenV1>> {
892
+ if (plan.kind !== "gjc-plugin.restore-artifact" || plan.schemaVersion !== 1)
893
+ return { ok: false, error: fail("invalid_target", "Restore plan is invalid") };
894
+ if (!authorizations.includes("plugin-change") || !authorizations.includes("install-replace"))
895
+ return { ok: false, error: fail("invalid_target", "Restore authorization is missing") };
896
+ const registry = await readRegistry(plan.identity.scope, ctx.cwd, { migrate: false });
897
+ const entry = registry.plugins.find(plugin => plugin.name === plan.identity.name);
898
+ if (!entry) return { ok: false, error: notInstalled(plan.identity) };
899
+ if (baselineFingerprint(entry) !== plan.baselineFingerprint)
900
+ return { ok: false, error: fail("stale_baseline", "The installed bundle changed since it was reviewed") };
901
+ const effective = await readEffective(ctx.cwd);
902
+ if (decisionContextFingerprint(plan.identity, effective) !== plan.decisionContextFingerprint)
903
+ return {
904
+ ok: false,
905
+ error: fail("stale_decision_context", "Installed bundles changed since restore was reviewed"),
906
+ };
907
+ return await withSourceAvailability(plan.identity, async () =>
908
+ resolveGjcBundleCandidate(storedSourceLocator(entry.source), async ({ bundle }) => {
909
+ if (bundle.name !== plan.identity.name)
910
+ return { ok: false, error: fail("identity_mismatch", "Stored source identity changed") };
911
+ const fingerprint = candidateFingerprint(plan.identity.scope, bundle);
912
+ return {
913
+ ok: true,
914
+ value: {
915
+ schemaVersion: 1,
916
+ kind: "gjc-plugin.restore-artifact",
917
+ purpose: "restore-artifact",
918
+ identity: plan.identity,
919
+ candidateFingerprint: fingerprint,
920
+ baselineFingerprint: plan.baselineFingerprint,
921
+ decisionContextFingerprint: plan.decisionContextFingerprint,
922
+ artifactFingerprint: bundleArtifactFingerprint(bundle),
923
+ reviewedAt: new Date().toISOString(),
924
+ },
925
+ };
926
+ }),
927
+ );
928
+ }
929
+
930
+ export async function applyGjcBundleRestore(
931
+ ctx: GjcLifecycleContext,
932
+ token: ReviewedRestoreTokenV1,
933
+ ): Promise<GjcLifecycleResult<GjcUpdateApplyResult>> {
934
+ if (
935
+ token.kind !== "gjc-plugin.restore-artifact" ||
936
+ token.purpose !== "restore-artifact" ||
937
+ token.schemaVersion !== 1
938
+ )
939
+ return { ok: false, error: fail("invalid_target", "Restore token is invalid") };
940
+ const identity = token.identity;
941
+ const registry = await readRegistry(identity.scope, ctx.cwd, { migrate: false });
942
+ const stored = registry.plugins.find(plugin => plugin.name === identity.name);
943
+ if (!stored) return { ok: false, error: notInstalled(identity) };
944
+ return await withSourceAvailability(identity, async () => {
945
+ const result = await runGjcBundleTransaction(storedSourceLocator(stored.source), {
946
+ scope: identity.scope,
947
+ cwd: ctx.cwd,
948
+ decide: async ({ existing, effective, bundle, candidate }) => {
949
+ if (!existing) return { kind: "abort", error: notInstalled(identity) };
950
+ if (candidateFingerprint(identity.scope, bundle) !== token.candidateFingerprint)
951
+ return { kind: "abort", error: fail("stale_candidate", "The restore candidate changed since review") };
952
+ if (baselineFingerprint(existing) !== token.baselineFingerprint)
953
+ return { kind: "abort", error: fail("stale_baseline", "The installed bundle changed since review") };
954
+ if (decisionContextFingerprint(identity, effective) !== token.decisionContextFingerprint)
955
+ return {
956
+ kind: "abort",
957
+ error: fail("stale_decision_context", "Installed bundles changed since restore was reviewed"),
958
+ };
959
+ const observed = await restoreArtifactObservation(existing);
960
+ if (observed.status === "present" && observed.digest === bundleArtifactFingerprint(bundle))
961
+ return { kind: "noop", entry: existing };
962
+ const reconciled = reconcileEnablement(existing.disabledSurfaceIds, surfaceIdsOf(bundle.surfaces));
963
+ const next: GjcPluginRegistryEntry = {
964
+ ...candidate,
965
+ enabled: existing.enabled,
966
+ installedAt: existing.installedAt,
967
+ disabledSurfaceIds: reconciled.disabledSurfaceIds,
968
+ };
969
+ if (reconciled.quarantine.length) next.quarantine = reconciled.quarantine;
970
+ else delete next.quarantine;
971
+ return { kind: "commit", entry: next };
972
+ },
973
+ });
974
+ if (result.status === "aborted") return { ok: false, error: result.error };
975
+ return {
976
+ ok: true,
977
+ value: {
978
+ status: result.status === "noop" ? "unchanged" : "updated",
979
+ summary: toBundleSummary(result.entry),
980
+ remnantCount: result.remnants.length,
981
+ },
982
+ };
983
+ });
984
+ }
985
+
827
986
  async function mutateEntry(
828
987
  ctx: GjcLifecycleContext,
829
988
  identity: GjcBundleIdentity,
@@ -842,6 +1001,36 @@ async function mutateEntry(
842
1001
  });
843
1002
  }
844
1003
 
1004
+ /**
1005
+ * Doctor-only quarantine primitive. The expected baseline is compared while
1006
+ * holding the ordinary registry writer lock, so concurrent enable/disable or
1007
+ * update cannot be clobbered.
1008
+ */
1009
+ export async function setGjcBundleQuarantineExpectedBaseline(
1010
+ ctx: GjcLifecycleContext,
1011
+ identity: GjcBundleIdentity,
1012
+ enabled: boolean,
1013
+ expectedBaseline: string,
1014
+ ): Promise<GjcLifecycleResult<GjcToggleResult>> {
1015
+ return await mutateEntry(ctx, identity, entry => {
1016
+ if (baselineFingerprint(entry) !== expectedBaseline) {
1017
+ return { ok: false, error: fail("stale_baseline", "The installed bundle changed since it was reviewed") };
1018
+ }
1019
+ if (entry.enabled === enabled) return { ok: true, value: null };
1020
+ return { ok: true, value: { ...entry, enabled, updatedAt: new Date().toISOString() } };
1021
+ });
1022
+ }
1023
+
1024
+ export async function getGjcBundleEnablementState(
1025
+ ctx: GjcLifecycleContext,
1026
+ identity: GjcBundleIdentity,
1027
+ ): Promise<GjcLifecycleResult<{ enabled: boolean; baseline: string }>> {
1028
+ const registry = await readRegistry(identity.scope, ctx.cwd, { migrate: false });
1029
+ const entry = registry.plugins.find(p => p.name === identity.name);
1030
+ if (!entry) return { ok: false, error: notInstalled(identity) };
1031
+ return { ok: true, value: { enabled: entry.enabled, baseline: baselineFingerprint(entry) } };
1032
+ }
1033
+
845
1034
  /**
846
1035
  * Enable or disable a whole bundle. Deterministic quarantine blocks enabling;
847
1036
  * disabling is always allowed so operators can always de-escalate.
@@ -1,6 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getAgentDir, pathIsWithin } from "@gajae-code/utils";
4
+ import type { ReadFileOptions } from "../../capability/fs";
5
+ import { readFile } from "../../capability/fs";
4
6
  import { GJC_PLUGIN_MANIFEST_FILENAME, GjcPluginLoadError } from "./types";
5
7
 
6
8
  export function gjcPluginUserRoot(): string {
@@ -28,7 +30,8 @@ function isEnoent(error: unknown): boolean {
28
30
  return (error as NodeJS.ErrnoException).code === "ENOENT";
29
31
  }
30
32
 
31
- export async function rootContainsGjcManifest(dir: string): Promise<boolean> {
33
+ export async function rootContainsGjcManifest(dir: string, options?: ReadFileOptions): Promise<boolean> {
34
+ if (options?.isolatedHome) return (await readFile(path.join(dir, GJC_PLUGIN_MANIFEST_FILENAME), options)) !== null;
32
35
  try {
33
36
  await fs.access(path.join(dir, GJC_PLUGIN_MANIFEST_FILENAME));
34
37
  return true;
@@ -459,6 +459,33 @@ export interface GjcReviewedUpdateToken {
459
459
  reviewedAt: string;
460
460
  }
461
461
 
462
+ export interface LocalRestorePlanV1 {
463
+ schemaVersion: 1;
464
+ kind: "gjc-plugin.restore-artifact";
465
+ identity: GjcBundleIdentity;
466
+ baselineFingerprint: string;
467
+ decisionContextFingerprint: string;
468
+ artifact: {
469
+ status: "present" | "absent" | "unreadable";
470
+ digest?: string;
471
+ };
472
+ writes: false;
473
+ fetch: false;
474
+ locks: false;
475
+ }
476
+
477
+ export interface ReviewedRestoreTokenV1 {
478
+ schemaVersion: 1;
479
+ kind: "gjc-plugin.restore-artifact";
480
+ purpose: "restore-artifact";
481
+ identity: GjcBundleIdentity;
482
+ candidateFingerprint: string;
483
+ baselineFingerprint: string;
484
+ decisionContextFingerprint: string;
485
+ artifactFingerprint: string;
486
+ reviewedAt: string;
487
+ }
488
+
462
489
  export type GjcLifecycleErrorCode =
463
490
  | "already_installed_use_upgrade"
464
491
  | "not_installed"
@@ -10,6 +10,7 @@ import {
10
10
  isEnoent,
11
11
  logger,
12
12
  } from "@gajae-code/utils";
13
+ import { withFileLock } from "../../config/file-lock";
13
14
  import { extractPackageName, parsePluginSpec } from "./parser";
14
15
  import type {
15
16
  DoctorCheck,
@@ -48,6 +49,11 @@ function validatePackageName(name: string): void {
48
49
  // Plugin Manager
49
50
  // =============================================================================
50
51
 
52
+ export type PluginEnablementState =
53
+ | { status: "ok"; enabled: boolean; baseline: string }
54
+ | { status: "not_installed" }
55
+ | { status: "malformed" };
56
+
51
57
  export class PluginManager {
52
58
  #runtimeConfig: PluginRuntimeConfig | null = null;
53
59
  #cwd: string;
@@ -56,19 +62,179 @@ export class PluginManager {
56
62
  this.#cwd = cwd;
57
63
  }
58
64
 
65
+ /**
66
+ * Read-only durable-enablement snapshot for one npm plugin, scope-qualified.
67
+ * `"user"` reads the shared runtime lock file (~/.gjc/plugins/gjc-plugins.lock.json);
68
+ * `"project"` reads this project's `.gjc/plugin-overrides.json` disabled list.
69
+ * A malformed underlying file is reported distinctly from "not installed" —
70
+ * neither is silently treated as an empty/enabled state.
71
+ */
72
+ async getEnablementState(name: string, scope: "user" | "project"): Promise<PluginEnablementState> {
73
+ if (scope === "user") {
74
+ let config: PluginRuntimeConfig;
75
+ try {
76
+ config = await this.#loadRuntimeConfigStrict();
77
+ } catch {
78
+ return { status: "malformed" };
79
+ }
80
+ const current = config.plugins[name];
81
+ if (!current) return { status: "not_installed" };
82
+ return { status: "ok", enabled: current.enabled !== false, baseline: JSON.stringify({ scope, current }) };
83
+ }
84
+ let overrides: ProjectPluginOverrides;
85
+ try {
86
+ overrides = await this.#loadProjectOverridesStrict();
87
+ } catch {
88
+ return { status: "malformed" };
89
+ }
90
+ // Project scope has no independent "installed" concept (it modifies the
91
+ // same npm-managed package the user scope tracks); absence of a user
92
+ // entry means there is nothing this project scope could be disabling.
93
+ let config: PluginRuntimeConfig;
94
+ try {
95
+ config = await this.#loadRuntimeConfigStrict();
96
+ } catch {
97
+ return { status: "malformed" };
98
+ }
99
+ if (!config.plugins[name]) return { status: "not_installed" };
100
+ const disabled = [...(overrides.disabled ?? [])].sort();
101
+ return { status: "ok", enabled: !disabled.includes(name), baseline: JSON.stringify({ scope, disabled }) };
102
+ }
103
+
104
+ /**
105
+ * Durable enablement toggle used by doctor quarantine and the plugin CLI.
106
+ * No install/fetch/reinstall occurs — this only flips the durable enabled
107
+ * flag a future session-startup load (`getEnabledPlugins`) consults: the
108
+ * user-scope runtime lock file, or the project's disabled-list override.
109
+ * Serialized under the canonical native-identity-bound file lock shared
110
+ * with every other writer of the same file (bounded retry/timeout,
111
+ * owner-safe release — never an unbounded wait, never a blind `rm`).
112
+ * `expectedBaseline` is required: a caller with no prior
113
+ * {@link getEnablementState} read has no CAS authority to mutate.
114
+ */
115
+ async setEnabled(
116
+ name: string,
117
+ enabled: boolean,
118
+ scope: "user" | "project",
119
+ expectedBaseline: string,
120
+ ): Promise<{ status: "updated" | "not_needed"; enabled: boolean; version?: string }> {
121
+ if (scope === "project") return await this.#setProjectEnabled(name, enabled, expectedBaseline);
122
+ const lockfilePath = getPluginsLockfile();
123
+ return await withFileLock(lockfilePath, async () => {
124
+ this.#runtimeConfig = null;
125
+ let config: PluginRuntimeConfig;
126
+ try {
127
+ config = await this.#loadRuntimeConfigStrict();
128
+ } catch (error) {
129
+ throw Object.assign(new Error(`Plugin runtime config is malformed at ${lockfilePath}`), {
130
+ code: "malformed_registry",
131
+ cause: error,
132
+ });
133
+ }
134
+ const current = config.plugins[name];
135
+ if (!current) throw Object.assign(new Error(`Plugin "${name}" is not installed`), { code: "not_installed" });
136
+ const baseline = JSON.stringify({ scope: "user" as const, current });
137
+ if (baseline !== expectedBaseline)
138
+ throw Object.assign(new Error("The installed plugin entry changed since it was reviewed"), {
139
+ code: "stale_baseline",
140
+ });
141
+ if (current.enabled === enabled) return { status: "not_needed" as const, enabled, version: current.version };
142
+ config.plugins[name] = { ...current, enabled };
143
+ this.#runtimeConfig = config;
144
+ await this.#saveRuntimeConfig();
145
+ return { status: "updated" as const, enabled, version: current.version };
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Project-scoped disable: adds/removes `name` from the project's
151
+ * `.gjc/plugin-overrides.json` disabled list. Serialized under that exact
152
+ * file's canonical file lock so an ordinary override edit and this doctor
153
+ * toggle cannot race and clobber each other's sibling entries.
154
+ */
155
+ async #setProjectEnabled(
156
+ name: string,
157
+ enabled: boolean,
158
+ expectedBaseline: string,
159
+ ): Promise<{ status: "updated" | "not_needed"; enabled: boolean }> {
160
+ const overridesPath = getProjectPluginOverridesPath(this.#cwd);
161
+ return await withFileLock(overridesPath, async () => {
162
+ let overrides: ProjectPluginOverrides;
163
+ try {
164
+ overrides = await this.#loadProjectOverridesStrict();
165
+ } catch (error) {
166
+ throw Object.assign(new Error(`Project plugin overrides are malformed at ${overridesPath}`), {
167
+ code: "malformed_registry",
168
+ cause: error,
169
+ });
170
+ }
171
+ const disabled = [...(overrides.disabled ?? [])].sort();
172
+ const baseline = JSON.stringify({ scope: "project" as const, disabled });
173
+ if (baseline !== expectedBaseline)
174
+ throw Object.assign(new Error("The project plugin overrides changed since they were reviewed"), {
175
+ code: "stale_baseline",
176
+ });
177
+ const wasDisabled = disabled.includes(name);
178
+ if (wasDisabled === !enabled) return { status: "not_needed" as const, enabled };
179
+ const nextDisabled = enabled ? disabled.filter(n => n !== name) : [...new Set([...disabled, name])].sort();
180
+ const next: ProjectPluginOverrides = { ...overrides, disabled: nextDisabled };
181
+ await fs.promises.mkdir(path.dirname(overridesPath), { recursive: true });
182
+ const tmpPath = `${overridesPath}.tmp-${process.pid}-${Date.now()}`;
183
+ await fs.promises.writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`);
184
+ await fs.promises.rename(tmpPath, overridesPath);
185
+ return { status: "updated" as const, enabled };
186
+ });
187
+ }
188
+
59
189
  // ==========================================================================
60
190
  // Runtime Config Management
61
191
  // ==========================================================================
62
192
 
63
193
  async #loadRuntimeConfig(): Promise<PluginRuntimeConfig> {
194
+ try {
195
+ return await this.#loadRuntimeConfigStrict();
196
+ } catch (err) {
197
+ logger.warn("Failed to load plugin runtime config", { path: getPluginsLockfile(), error: String(err) });
198
+ return { plugins: {}, settings: {} };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Strict variant: a missing lock file is legitimately empty (no plugins
204
+ * installed yet), but malformed JSON must never be silently coerced into
205
+ * an empty config — a mutating writer built on that would overwrite (and
206
+ * lose) an unreadable but non-empty runtime config.
207
+ */
208
+ async #loadRuntimeConfigStrict(): Promise<PluginRuntimeConfig> {
64
209
  const lockPath = getPluginsLockfile();
210
+ let text: string;
65
211
  try {
66
- return await Bun.file(lockPath).json();
212
+ text = await Bun.file(lockPath).text();
67
213
  } catch (err) {
68
214
  if (isEnoent(err)) return { plugins: {}, settings: {} };
69
- logger.warn("Failed to load plugin runtime config", { path: lockPath, error: String(err) });
70
- return { plugins: {}, settings: {} };
215
+ throw err;
71
216
  }
217
+ let parsed: unknown;
218
+ try {
219
+ parsed = JSON.parse(text);
220
+ } catch (error) {
221
+ throw Object.assign(new Error(`Plugin runtime config is malformed at ${lockPath}`), {
222
+ code: "malformed_registry",
223
+ cause: error,
224
+ });
225
+ }
226
+ if (
227
+ typeof parsed !== "object" ||
228
+ parsed === null ||
229
+ typeof (parsed as PluginRuntimeConfig).plugins !== "object" ||
230
+ (parsed as PluginRuntimeConfig).plugins === null
231
+ ) {
232
+ throw Object.assign(new Error(`Plugin runtime config has an unsupported shape at ${lockPath}`), {
233
+ code: "malformed_registry",
234
+ });
235
+ }
236
+ const config = parsed as PluginRuntimeConfig;
237
+ return { plugins: config.plugins ?? {}, settings: config.settings ?? {} };
72
238
  }
73
239
 
74
240
  async #ensureConfigLoaded(): Promise<PluginRuntimeConfig> {
@@ -84,14 +250,42 @@ export class PluginManager {
84
250
  }
85
251
 
86
252
  async #loadProjectOverrides(): Promise<ProjectPluginOverrides> {
253
+ try {
254
+ return await this.#loadProjectOverridesStrict();
255
+ } catch (err) {
256
+ logger.warn("Failed to load project plugin overrides", {
257
+ path: getProjectPluginOverridesPath(this.#cwd),
258
+ error: String(err),
259
+ });
260
+ return {};
261
+ }
262
+ }
263
+
264
+ /** Strict variant: missing is empty; malformed JSON/shape throws. */
265
+ async #loadProjectOverridesStrict(): Promise<ProjectPluginOverrides> {
87
266
  const overridesPath = getProjectPluginOverridesPath(this.#cwd);
267
+ let text: string;
88
268
  try {
89
- return await Bun.file(overridesPath).json();
269
+ text = await Bun.file(overridesPath).text();
90
270
  } catch (err) {
91
271
  if (isEnoent(err)) return {};
92
- logger.warn("Failed to load project plugin overrides", { path: overridesPath, error: String(err) });
93
- return {};
272
+ throw err;
94
273
  }
274
+ let parsed: unknown;
275
+ try {
276
+ parsed = JSON.parse(text);
277
+ } catch (error) {
278
+ throw Object.assign(new Error(`Project plugin overrides are malformed at ${overridesPath}`), {
279
+ code: "malformed_registry",
280
+ cause: error,
281
+ });
282
+ }
283
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
284
+ throw Object.assign(new Error(`Project plugin overrides have an unsupported shape at ${overridesPath}`), {
285
+ code: "malformed_registry",
286
+ });
287
+ }
288
+ return parsed as ProjectPluginOverrides;
95
289
  }
96
290
 
97
291
  // ==========================================================================
@@ -383,22 +577,6 @@ export class PluginManager {
383
577
  };
384
578
  }
385
579
 
386
- // ==========================================================================
387
- // Enable / Disable
388
- // ==========================================================================
389
-
390
- /**
391
- * Enable or disable a plugin globally.
392
- */
393
- async setEnabled(name: string, enabled: boolean): Promise<void> {
394
- const config = await this.#ensureConfigLoaded();
395
- if (!config.plugins[name]) {
396
- throw new Error(`Plugin ${name} not found in runtime config`);
397
- }
398
- config.plugins[name].enabled = enabled;
399
- await this.#saveRuntimeConfig();
400
- }
401
-
402
580
  // ==========================================================================
403
581
  // Features
404
582
  // ==========================================================================
@@ -10,12 +10,11 @@
10
10
  * This ensures cache paths cannot be crafted to escape the cache directory.
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto";
13
14
  import * as nodeFs from "node:fs";
14
15
  import * as fs from "node:fs/promises";
15
16
  import * as path from "node:path";
16
-
17
17
  import { isEnoent } from "@gajae-code/utils";
18
-
19
18
  import { isValidNameSegment } from "./types";
20
19
 
21
20
  // Reject anything that could be used for path traversal or shell injection in
@@ -88,6 +87,60 @@ export async function cachePlugin(
88
87
  return targetPath;
89
88
  }
90
89
 
90
+ export async function stageCachedPlugin(sourcePath: string, stagingPath: string): Promise<string> {
91
+ await fs.rm(stagingPath, { recursive: true, force: true });
92
+ await fs.cp(sourcePath, stagingPath, { recursive: true, errorOnExist: true, verbatimSymlinks: true });
93
+ return stagingPath;
94
+ }
95
+
96
+ export async function digestCachedPlugin(targetPath: string): Promise<string | undefined> {
97
+ const hash = createHash("sha256");
98
+ const walk = async (root: string, rel = ""): Promise<void> => {
99
+ const entries = (await fs.readdir(root, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
100
+ for (const entry of entries) {
101
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
102
+ const child = path.join(root, entry.name);
103
+ const stat = await fs.lstat(child);
104
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error("unsafe cache artifact");
105
+ // Reject a regular file with more than one hard link: its bytes could be
106
+ // mutated through a second pathname we never inspected, so a digest taken
107
+ // here would not be provably bound to the artifact this walk actually saw.
108
+ // Directory entries never carry meaningful hard-link counts on POSIX
109
+ // (nlink for a directory reflects '.'/subdir back-references), so this
110
+ // check applies only to regular files.
111
+ if (stat.isFile() && stat.nlink > 1) throw new Error("unsafe cache artifact: multiply-linked file");
112
+ if (stat.isDirectory()) await walk(child, childRel);
113
+ else {
114
+ hash.update(childRel);
115
+ hash.update("\0");
116
+ hash.update(await fs.readFile(child));
117
+ hash.update("\0");
118
+ }
119
+ }
120
+ };
121
+ try {
122
+ await walk(targetPath);
123
+ return hash.digest("hex");
124
+ } catch (error) {
125
+ hash.digest();
126
+ if (isEnoent(error)) return undefined;
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ export async function inspectCachedPlugin(
132
+ targetPath: string,
133
+ ): Promise<{ status: "present" | "absent" | "unreadable"; digest?: string }> {
134
+ try {
135
+ const stat = await fs.lstat(targetPath);
136
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { status: "unreadable" };
137
+ const digest = await digestCachedPlugin(targetPath);
138
+ return digest ? { status: "present", digest } : { status: "unreadable" };
139
+ } catch (error) {
140
+ return isEnoent(error) ? { status: "absent" } : { status: "unreadable" };
141
+ }
142
+ }
143
+
91
144
  /**
92
145
  * Synchronous check — true when the cache directory exists on disk.
93
146
  * Uses `existsSync` because callers may need to run this check inline without async.