@bitkyc08/opencodex 2.58.0 → 2.60.0

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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -0,0 +1,815 @@
1
+ /**
2
+ * Value-free planning for integration mutations.
3
+ *
4
+ * An operator confirming "apply", "overwrite", "disable" or "undo" is agreeing to consequences
5
+ * nobody has shown them. This module computes those consequences as bounded managed schema paths
6
+ * and closed change kinds, and binds them to a fingerprint over every input the decision rested
7
+ * on, so a confirmation can be refused when the state it described has moved.
8
+ *
9
+ * Two rules give the output its safety. Nothing here is a value: paths are structural, and a
10
+ * segment that is not representable in the managed grammar is dropped rather than echoed, because
11
+ * an ownership record on disk accepts arbitrary strings and is not a validation authority. And
12
+ * nothing here writes: this module owns no IO, takes no lock, and must never import the writer.
13
+ * The dependency direction is state/ownership/merge into here, and here into the writer and the
14
+ * preview route.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
18
+ import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
19
+ import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
20
+ import { createClineIO, ClineTransactionError } from "./cline-io";
21
+ import { parseClineDocument } from "./cline-document";
22
+ import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
23
+ import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
24
+ import { shouldInjectApiAuthHeader } from "../codex/inject";
25
+ import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
26
+ import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
27
+ import type { OcxConfig } from "../types";
28
+ import { matchesOperationResult, type JournalEntry } from "./journal";
29
+
30
+ /**
31
+ * Why a mutation refused. Declared here rather than in the writer so the planner can report a
32
+ * refusal without depending on the module that performs writes; the writer re-exports it, so this
33
+ * is a move rather than a second vocabulary.
34
+ */
35
+ export type RefusalReason =
36
+ | "not_installed"
37
+ | "conflict"
38
+ | "unsafe"
39
+ | "non_loopback"
40
+ | "drift_requires_confirm"
41
+ | "snapshot_expired"
42
+ | "write_failed";
43
+
44
+ export type IntegrationPlanOperation = "apply" | "overwrite" | "disable" | "restore";
45
+ export type IntegrationPlanChangeKind = "add" | "replace" | "remove" | "snapshot" | "ownership" | "journal";
46
+ export type IntegrationPlanForeignEdit = "none" | "unowned" | "foreign-edit" | "drift";
47
+
48
+ /**
49
+ * Effects that are not places in the client's document. They carry no disk location and no value,
50
+ * so an operator learns that history will be written without learning where it lives.
51
+ */
52
+ export const PLAN_SNAPSHOT_PATH = "$snapshot";
53
+ export const PLAN_OWNERSHIP_PATH = "$ownership";
54
+ export const PLAN_JOURNAL_PATH = "$journal";
55
+
56
+ /**
57
+ * Upper bound on reported changes. Above the largest contribution any registered client builds and
58
+ * well below a response worth truncating, so the cap is a guard rather than a routine limit.
59
+ */
60
+ export const PLAN_CHANGE_LIMIT = 256;
61
+
62
+ export interface IntegrationPlanChange {
63
+ readonly kind: IntegrationPlanChangeKind;
64
+ readonly path: string;
65
+ }
66
+
67
+ export interface IntegrationMutationPlan {
68
+ readonly version: 1;
69
+ readonly clientId: IntegrationClientId;
70
+ readonly operation: IntegrationPlanOperation;
71
+ readonly state: IntegrationState;
72
+ readonly foreignEdit: IntegrationPlanForeignEdit;
73
+ readonly changes: readonly IntegrationPlanChange[];
74
+ readonly fingerprint: string;
75
+ readonly canApply: boolean;
76
+ /**
77
+ * Whether confirming would write at all.
78
+ *
79
+ * An apply against an already-current file and a disable against an absent one both succeed
80
+ * while writing nothing, so reporting a snapshot and a journal row for them would describe
81
+ * consequences that never happen.
82
+ */
83
+ readonly willChange: boolean;
84
+ readonly refusalReason?: RefusalReason;
85
+ readonly profileId?: number;
86
+ }
87
+
88
+ /** A position whose observed value is never published, only its presence. */
89
+ export const DYNAMIC_SEGMENT = "*";
90
+
91
+ /**
92
+ * Where each client's managed fragments live, declared rather than inferred.
93
+ *
94
+ * A general "looks like a plain key" rule is not good enough, and Kimi is the proof: it writes one
95
+ * fragment per model at `models.<alias>`, so an alphanumeric allowlist would publish a user's
96
+ * model identifier verbatim. The same rule would accept any plain path sitting in an ownership
97
+ * record, and a record on disk is not a validation authority.
98
+ *
99
+ * So a path is published only when it matches one of these templates exactly. Static segments must
100
+ * match literally, a DYNAMIC_SEGMENT position accepts any observed segment, and the string that
101
+ * leaves this module is the TEMPLATE rather than the observed path. That is what makes publishing
102
+ * a value structurally impossible instead of merely unlikely.
103
+ *
104
+ * The satisfies clause makes a new client a type error here, so nobody can add one whose managed
105
+ * paths silently have no declaration.
106
+ */
107
+ const CLIENT_MANAGED_PATHS = {
108
+ opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]],
109
+ pi: [["providers", OPENCODE_PROVIDER_ID]],
110
+ omp: [["providers", OPENCODE_PROVIDER_ID]],
111
+ hermes: [["providers", OPENCODE_PROVIDER_ID]],
112
+ openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]],
113
+ kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]],
114
+ gajae: [["providers", OPENCODE_PROVIDER_ID]],
115
+ dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
116
+ mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
117
+ zcode: [["provider", OPENCODE_PROVIDER_ID]],
118
+ prime: [["providers", OPENCODE_PROVIDER_ID]],
119
+ aside: [["providers", OPENCODE_PROVIDER_ID]],
120
+ raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]],
121
+ omo: [["providers", OPENCODE_PROVIDER_ID]],
122
+ cline: [
123
+ ["settings", "providers", OPENCODE_PROVIDER_ID],
124
+ ["catalog", "providers", OPENCODE_PROVIDER_ID],
125
+ ],
126
+ } satisfies Record<IntegrationClientId, readonly (readonly string[])[]>;
127
+
128
+ /** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
129
+ export const MANAGED_PATH_TEMPLATES: Readonly<Record<IntegrationClientId, readonly (readonly string[])[]>> = CLIENT_MANAGED_PATHS;
130
+
131
+ function matchesTemplate(template: readonly string[], path: readonly string[]): boolean {
132
+ if (template.length !== path.length) return false;
133
+ return template.every((segment, index) => {
134
+ const observed = path[index];
135
+ if (observed === undefined || observed.length === 0) return false;
136
+ return segment === DYNAMIC_SEGMENT || segment === observed;
137
+ });
138
+ }
139
+
140
+ /**
141
+ * The managed schema path this change touches, or null when the path is outside the client's
142
+ * declared grammar.
143
+ *
144
+ * Null is not an error to work around. A path nobody declared is either a record written by a
145
+ * different version or something arbitrary, and neither is safe to name, so the caller reports the
146
+ * fixed ownership pseudo-path or a refusal instead of inventing a description.
147
+ */
148
+ export function canonicalSchemaPath(clientId: IntegrationClientId, path: readonly string[]): string | null {
149
+ if (path.length === 0) return null;
150
+ for (const template of CLIENT_MANAGED_PATHS[clientId]) {
151
+ if (matchesTemplate(template, path)) return template.join(".");
152
+ }
153
+ return null;
154
+ }
155
+
156
+ const KIND_ORDER: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"];
157
+
158
+ /**
159
+ * Deterministic, deduplicated and capped. Deterministic because the fingerprint is taken over this
160
+ * projection, so an unstable order would stale a plan that did not change.
161
+ */
162
+ export function orderPlanChanges(changes: readonly IntegrationPlanChange[]): readonly IntegrationPlanChange[] {
163
+ const seen = new Set<string>();
164
+ const unique: IntegrationPlanChange[] = [];
165
+ for (const change of changes) {
166
+ const key = `${change.kind}\u0000${change.path}`;
167
+ if (seen.has(key)) continue;
168
+ seen.add(key);
169
+ unique.push(change);
170
+ }
171
+ unique.sort((left, right) => {
172
+ const byKind = KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind);
173
+ if (byKind !== 0) return byKind;
174
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
175
+ });
176
+ return Object.freeze(unique.slice(0, PLAN_CHANGE_LIMIT));
177
+ }
178
+
179
+ /**
180
+ * Every input the plan's authority rests on.
181
+ *
182
+ * `models` is here because the desired contribution is derived from it, so a model roster that
183
+ * changed between preview and commit changes what would be written. `snapshot` carries a digest of
184
+ * the bytes a restore would actually publish rather than only the operation id, because the id
185
+ * names the row and the bytes are what lands in the user's file.
186
+ */
187
+ export interface PlanFingerprintInput {
188
+ readonly operation: IntegrationPlanOperation;
189
+ readonly clientId: IntegrationClientId;
190
+ readonly profileId?: number;
191
+ readonly configPath: string;
192
+ readonly detectDir: string;
193
+ /**
194
+ * What the detect directory actually was when observed, not merely where it is.
195
+ *
196
+ * Binding only the path leaves a confirmation valid across an uninstall: the contribution is
197
+ * unchanged, so every other component matches, while the answer to "is this client installed"
198
+ * has flipped. The observed kind is the input the not_installed refusal is derived from.
199
+ */
200
+ readonly installKind: string;
201
+ /**
202
+ * Whether admission policy blocks this integration, which is the non_loopback refusal's input.
203
+ *
204
+ * Config eligibility can change without touching the file, the record or the contribution, so a
205
+ * plan that did not bind it could be confirmed after the proxy stopped being a legal target.
206
+ */
207
+ readonly admissionBlocked: boolean;
208
+ /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
209
+ readonly before: string | null;
210
+ readonly contribution: ManagedContribution | null;
211
+ readonly record: OwnershipRecord | null;
212
+ readonly models: readonly ExportModel[];
213
+ readonly restore?: {
214
+ readonly opId: string;
215
+ readonly entry: JournalEntry;
216
+ readonly snapshotKind: string;
217
+ /** Digest of the snapshot's exact text, or null when it holds none. */
218
+ readonly snapshotText: string | null;
219
+ readonly confirmDrift: boolean;
220
+ /**
221
+ * Whether the target has changed since the operation being undone, which is the
222
+ * drift_requires_confirm predicate. Passed in rather than recomputed so the plan and the
223
+ * mutation read drift from the same comparison.
224
+ */
225
+ readonly driftsFromResult: boolean;
226
+ };
227
+ }
228
+
229
+ const PLAN_FINGERPRINT_VERSION = "p1";
230
+
231
+ function digest(value: string): string {
232
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
233
+ }
234
+
235
+ /**
236
+ * An opaque optimistic-concurrency token, not authorization.
237
+ *
238
+ * Longer than the 16-hex ownership fingerprint because this one is supplied by a caller and
239
+ * compared for equality, so accidental collision matters more than it does for a stored digest.
240
+ * The version prefix means a future input set invalidates old tokens instead of silently
241
+ * comparing two different meanings.
242
+ */
243
+ export function planFingerprint(input: PlanFingerprintInput): string {
244
+ const restore = input.restore;
245
+ const components = [
246
+ PLAN_FINGERPRINT_VERSION,
247
+ input.operation,
248
+ input.clientId,
249
+ input.profileId === undefined ? null : input.profileId,
250
+ input.configPath,
251
+ input.detectDir,
252
+ input.installKind,
253
+ input.admissionBlocked,
254
+ input.before === null ? "\u0000absent" : fingerprint(input.before),
255
+ input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)),
256
+ input.record === null ? null : fingerprint(JSON.stringify(input.record)),
257
+ fingerprint(JSON.stringify(input.models)),
258
+ restore === undefined ? null : [
259
+ restore.opId,
260
+ fingerprint(JSON.stringify(restore.entry)),
261
+ restore.snapshotKind,
262
+ restore.snapshotText === null ? "\u0000none" : fingerprint(restore.snapshotText),
263
+ restore.confirmDrift,
264
+ restore.driftsFromResult,
265
+ ],
266
+ ];
267
+ return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`;
268
+ }
269
+
270
+ /** The observation facts a plan is derived from, beside the fingerprint inputs. */
271
+ export interface PlanInput extends PlanFingerprintInput {
272
+ readonly classified: { readonly state: IntegrationState; readonly reason?: StateReason };
273
+ /**
274
+ * The parsed target document. Whether a managed place is occupied is a fact about the file, not
275
+ * about our record: an overwrite of a key somebody else wrote replaces a value even though no
276
+ * record of ours mentions it.
277
+ */
278
+ readonly parsed: unknown;
279
+ }
280
+
281
+ type PlanOutcome =
282
+ | { readonly kind: "refuse"; readonly reason: RefusalReason }
283
+ | { readonly kind: "noop" }
284
+ | { readonly kind: "change" };
285
+
286
+ const CHANGE: PlanOutcome = { kind: "change" };
287
+ const NOOP: PlanOutcome = { kind: "noop" };
288
+ const deny = (reason: RefusalReason): PlanOutcome => ({ kind: "refuse", reason });
289
+
290
+ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
291
+ if (input.restore?.driftsFromResult) return "drift";
292
+ if (input.classified.reason === "unowned-key") return "unowned";
293
+ if (input.classified.reason === "foreign-edit") return "foreign-edit";
294
+ return "none";
295
+ }
296
+
297
+ /**
298
+ * Why this operation would refuse, in the writer's own order.
299
+ *
300
+ * The order is not cosmetic. An uninstalled client is reported as not installed rather than as
301
+ * whatever its leftover file happens to classify as, and an unreadable or unparseable file is
302
+ * reported before either, because that is the sequence the writer itself refuses in. A plan that
303
+ * named a different reason than the mutation would name is worse than no plan.
304
+ */
305
+ function applyOutcome(input: PlanInput): PlanOutcome {
306
+ if (input.installKind !== "dir") return deny("not_installed");
307
+ if (input.admissionBlocked) return deny("non_loopback");
308
+ // Overwrite exists precisely to proceed through a conflict the operator has been shown.
309
+ if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict");
310
+ if (input.classified.state === "unsafe") return deny("unsafe");
311
+ if (input.classified.state === "current") return NOOP;
312
+ return CHANGE;
313
+ }
314
+
315
+ /**
316
+ * Disable answers a different question, so it asks different ones.
317
+ *
318
+ * It never checks installation or admission: removing what we wrote from a file that still exists
319
+ * is meaningful whether or not the client is installed now, and it emits nothing that admission
320
+ * policy could object to. An absent block is a success that writes nothing rather than a refusal.
321
+ */
322
+ function disableOutcome(input: PlanInput): PlanOutcome {
323
+ if (input.classified.state === "absent") return NOOP;
324
+ if (input.classified.state === "conflict") return deny("conflict");
325
+ if (input.classified.state === "unsafe") return deny("unsafe");
326
+ return CHANGE;
327
+ }
328
+
329
+ function restoreOutcome(input: PlanInput): PlanOutcome {
330
+ if (input.restore === undefined) return deny("unsafe");
331
+ if (input.restore.snapshotKind === "expired") return deny("snapshot_expired");
332
+ if (input.restore.driftsFromResult && !input.restore.confirmDrift) return deny("drift_requires_confirm");
333
+ return CHANGE;
334
+ }
335
+
336
+ /**
337
+ * What this operation would do, decided the way the operation itself decides it.
338
+ *
339
+ * Applying one global sequence to all four was wrong: it reported disable as refused on an
340
+ * uninstalled client the writer would have accepted, and it ranked the classifier's unsafe ahead
341
+ * of a conflict that apply reports first. A plan is only useful if it reaches the same verdict,
342
+ * for the same reason, as the mutation it describes.
343
+ */
344
+ function outcomeOf(input: PlanInput): PlanOutcome {
345
+ if (input.operation === "restore") return restoreOutcome(input);
346
+ if (input.operation === "disable") return disableOutcome(input);
347
+ return applyOutcome(input);
348
+ }
349
+
350
+
351
+ /**
352
+ * The managed places this operation would touch, plus the history it would write.
353
+ *
354
+ * A path that does not canonicalize is omitted rather than guessed at. For a shipped client that
355
+ * cannot happen, and the parity case proves it; what it does cover is a record written by another
356
+ * version, where declining to describe a path is the honest answer and the fixed ownership entry
357
+ * still tells the operator that ownership changes.
358
+ */
359
+ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] {
360
+ const changes: IntegrationPlanChange[] = [];
361
+ if (input.operation === "apply" || input.operation === "overwrite") {
362
+ for (const fragment of input.contribution?.fragments ?? []) {
363
+ const path = canonicalSchemaPath(input.clientId, fragment.path);
364
+ if (path === null) continue;
365
+ // Occupied is a fact about the document. Deciding from our own record instead would call an
366
+ // overwrite of somebody else's key an addition, which is the one case overwrite exists for.
367
+ const occupied = readPath(input.parsed, fragment.path) !== undefined;
368
+ changes.push({ kind: occupied ? "replace" : "add", path });
369
+ }
370
+ }
371
+ if (input.operation === "disable") {
372
+ for (const owned of input.record?.fragmentPaths ?? []) {
373
+ const path = canonicalSchemaPath(input.clientId, owned);
374
+ if (path === null) continue;
375
+ changes.push({ kind: "remove", path });
376
+ }
377
+ }
378
+ if (input.operation === "restore") {
379
+ /*
380
+ * An undo replaces the whole document, so what it changes is the difference between the
381
+ * places that are ours now and the places the row recorded as ours before that operation ran.
382
+ *
383
+ * Reading only the prior record got both common undos wrong. Undoing an initial apply has no
384
+ * prior record, so the plan described a change to nothing at all while the undo removed the
385
+ * managed block. Undoing a disable has a prior record and an empty document, so the plan said
386
+ * it would replace paths the file does not currently have.
387
+ *
388
+ * Provenance is still restored rather than re-derived: the prior record is what says which
389
+ * places are ours afterwards. The document only answers whether each of them is there now.
390
+ */
391
+ const prior = new Map<string, readonly string[]>();
392
+ for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) {
393
+ const path = canonicalSchemaPath(input.clientId, fragment);
394
+ if (path !== null) prior.set(path, fragment);
395
+ }
396
+ /*
397
+ * A document we could not read says nothing about whether a place is there, and restore does
398
+ * not need it read: eligibility is a question about bytes. Where it cannot be read, each place
399
+ * is reported as a replacement, which is what this list said before any of it was derived.
400
+ */
401
+ const documentKnown = input.parsed !== PARSE_FAILED;
402
+ for (const [path, fragment] of prior) {
403
+ const absent = documentKnown && readPath(input.parsed, fragment) === undefined;
404
+ changes.push({ kind: absent ? "add" : "replace", path });
405
+ }
406
+ for (const fragment of input.record?.fragmentPaths ?? []) {
407
+ const path = canonicalSchemaPath(input.clientId, fragment);
408
+ if (path === null || prior.has(path)) continue;
409
+ changes.push({ kind: "remove", path });
410
+ }
411
+ }
412
+ changes.push({ kind: "snapshot", path: PLAN_SNAPSHOT_PATH });
413
+ changes.push({ kind: "ownership", path: PLAN_OWNERSHIP_PATH });
414
+ changes.push({ kind: "journal", path: PLAN_JOURNAL_PATH });
415
+ return orderPlanChanges(changes);
416
+ }
417
+
418
+ /**
419
+ * The whole plan, value-free.
420
+ *
421
+ * A refused plan is still worth returning: knowing that undo is blocked because the backup expired
422
+ * is the answer an operator needs, and it carries no more detail than an allowed one.
423
+ */
424
+ export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan {
425
+ const outcome = outcomeOf(input);
426
+ return Object.freeze({
427
+ version: 1 as const,
428
+ clientId: input.clientId,
429
+ operation: input.operation,
430
+ state: input.classified.state,
431
+ foreignEdit: foreignEditOf(input),
432
+ // Only a plan that would actually write describes places to write.
433
+ changes: outcome.kind === "change" ? changesOf(input) : Object.freeze([]),
434
+ fingerprint: planFingerprint(input),
435
+ canApply: outcome.kind !== "refuse",
436
+ willChange: outcome.kind === "change",
437
+ ...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}),
438
+ ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
439
+ });
440
+ }
441
+
442
+ /**
443
+ * The token a plan carries when observation itself refused.
444
+ *
445
+ * Such a plan never read the state it would have bound, so there is nothing to bind. It is safe
446
+ * for every one of them to share this value because `canApply` is false, and a mutation may only
447
+ * be bound to a plan that could apply.
448
+ */
449
+ export const PLAN_UNBOUND_FINGERPRINT = `${PLAN_FINGERPRINT_VERSION}:unbound`;
450
+
451
+ function unboundPlan(
452
+ clientId: IntegrationClientId,
453
+ operation: IntegrationPlanOperation,
454
+ failure: IntegrationObservationFailure,
455
+ profileId?: number,
456
+ ): IntegrationMutationPlan {
457
+ return Object.freeze({
458
+ version: 1 as const,
459
+ clientId,
460
+ operation,
461
+ state: failure.state,
462
+ foreignEdit: "none" as const,
463
+ changes: Object.freeze([]),
464
+ fingerprint: PLAN_UNBOUND_FINGERPRINT,
465
+ canApply: false,
466
+ willChange: false,
467
+ refusalReason: failure.reason,
468
+ ...(profileId === undefined ? {} : { profileId }),
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Restore reads a different specification, so it gets its own observation.
474
+ *
475
+ * The writer's undo path never parses and never classifies: it compares the resolved config path
476
+ * against the one the journal row was recorded for, reads the snapshot, and reads the target's
477
+ * BYTES. Routing a preview through the general observation therefore refused an undo of a file
478
+ * that was readable but unparseable, which is the state that most needs restoring, and accepted a
479
+ * row recorded against a previous home, which is the single case path equality exists to refuse.
480
+ */
481
+ export function observeRestore(
482
+ input: IntegrationWriteInput,
483
+ opId: string,
484
+ effects: ObservationEffects,
485
+ /**
486
+ * The row a caller already selected, with the store it came from.
487
+ *
488
+ * Aside can hold more than one valid copy of the same operation, so re-resolving here could
489
+ * legitimately pick a different row than the mutation will. The plan would then describe an
490
+ * operation the confirmation was never about. A caller that has resolved one passes it in, and
491
+ * neither side resolves again.
492
+ */
493
+ selectedOperation?: { entry: JournalEntry; store: IntegrationStateStore },
494
+ ) {
495
+ const store = selectedOperation?.store ?? input.store ?? createIntegrationStateStore();
496
+ let io = input.io ?? defaultIntegrationIO(store);
497
+ const clientId = input.clientId;
498
+ let resolved: { configPath: string; detectDir: string };
499
+ try {
500
+ resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
501
+ } catch (error) {
502
+ if (!(error instanceof ClientPathError)) throw error;
503
+ return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
504
+ }
505
+ // Coordinated restore refuses this before it looks at the row at all: an undo will not create
506
+ // the client's home, so a writer-lock client without one has nothing to restore into.
507
+ if (INTEGRATION_CLIENTS[clientId].writerLock && io.statKind(resolved.detectDir) !== "dir") {
508
+ return {
509
+ failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"),
510
+ } as const;
511
+ }
512
+ const entry = selectedOperation?.entry ?? store.findOperation(opId);
513
+ if (!entry || entry.clientId !== clientId) {
514
+ return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const;
515
+ }
516
+ const configPath = entry.configPath;
517
+ // An undo acts on the path the operation was journaled against. A row recorded for one home must
518
+ // never be allowed to rewrite a file in another.
519
+ if (resolved.configPath !== configPath) {
520
+ return {
521
+ failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
522
+ } as const;
523
+ }
524
+ if (clientId === "cline") {
525
+ try { io = createClineIO(io, configPath, store, effects.recover); }
526
+ catch (error) {
527
+ if (!(error instanceof ClineTransactionError)) throw error;
528
+ return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
529
+ }
530
+ }
531
+ const snapshot = store.readSnapshot(entry);
532
+ /*
533
+ * Expiry is decided before the target is read, exactly as the writer decides it. Reading first
534
+ * let an expired backup over an unreadable file report the file as the problem, when the answer
535
+ * the operator needs is that the backup is gone.
536
+ */
537
+ if (snapshot.kind === "expired") {
538
+ return { failed: observationFailure("snapshot_expired", "absent", "that backup has expired") } as const;
539
+ }
540
+ const target = loadTarget(io, configPath);
541
+ if (!target.ok) {
542
+ return { failed: observationFailure("unsafe", "unsafe", "the target cannot be read safely") } as const;
543
+ }
544
+ const before = target.before;
545
+ return {
546
+ failed: undefined,
547
+ clientId,
548
+ configPath,
549
+ detectDir: resolved.detectDir,
550
+ installKind: io.statKind(resolved.detectDir),
551
+ entry,
552
+ snapshotKind: snapshot.kind,
553
+ snapshotText: snapshot.kind === "stored" ? snapshot.text : null,
554
+ before,
555
+ // Bytes only. Parsing here is exactly what must not happen.
556
+ driftsFromResult: !matchesOperationResult(entry, before),
557
+ } as const;
558
+ }
559
+
560
+ export interface PreviewRequest {
561
+ readonly operation: IntegrationPlanOperation;
562
+ /** Required for restore; names the journalled operation being undone. */
563
+ readonly opId?: string;
564
+ readonly confirmDrift?: boolean;
565
+ readonly profileId?: number;
566
+ /** A row and store the caller already selected, so neither side resolves it twice. */
567
+ readonly resolved?: { entry: JournalEntry; store: IntegrationStateStore };
568
+ }
569
+
570
+ /**
571
+ * Plan an operation without performing it.
572
+ *
573
+ * Observation runs with both write-capable effects off, so this path prunes nothing, recovers
574
+ * nothing, takes no lock and enters no mutation flight. Everything it reads is a read: the target
575
+ * file, the ownership records, and for restore the journal row and its snapshot.
576
+ */
577
+ export function previewIntegration(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan {
578
+ // Restore never reaches the general observation, because the writer's undo path never parses
579
+ // or classifies and a preview that did would answer a different question.
580
+ if (request.operation === "restore") return previewRestore(input, request);
581
+ const observed = observeIntegration(input, { maintenance: false, recover: false });
582
+ if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId);
583
+
584
+ const shared = {
585
+ operation: request.operation,
586
+ clientId: observed.clientId,
587
+ configPath: observed.configPath,
588
+ detectDir: observed.detectDir,
589
+ installKind: observed.io.statKind(observed.detectDir),
590
+ // Loopback-only clients cannot carry the admission header a non-loopback bind requires.
591
+ admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
592
+ before: observed.before,
593
+ contribution: observed.contribution,
594
+ record: observed.record,
595
+ models: input.models,
596
+ classified: observed.classified,
597
+ parsed: observed.parsed,
598
+ ...(request.profileId === undefined ? {} : { profileId: request.profileId }),
599
+ };
600
+
601
+ return buildMutationPlan(shared);
602
+ }
603
+
604
+ /**
605
+ * Which places are ours in the file this undo would rewrite, read the same way the general
606
+ * observation reads them.
607
+ *
608
+ * Read from the store bound to the target being rewritten, never from the store a historical row
609
+ * was selected out of. Aside keeps a copy of an operation in the root store while the profile's
610
+ * own store holds the ownership for its file, so asking the selected row's store would have
611
+ * described the wrong file's ownership. The selected store stays what it is for: the row and its
612
+ * snapshot. A record written for another location grants nothing here either, which is the same
613
+ * rule the writer applies.
614
+ */
615
+ function currentRecordFor(
616
+ input: IntegrationWriteInput,
617
+ clientId: IntegrationClientId,
618
+ configPath: string,
619
+ ): OwnershipRecord | null {
620
+ const store = input.store ?? createIntegrationStateStore();
621
+ const stored = store.readRecords()[clientId] ?? null;
622
+ return stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null;
623
+ }
624
+
625
+ /**
626
+ * Plan an undo the way the writer performs one.
627
+ *
628
+ * State is derived from bytes alone: a missing target is absent, a target that no longer matches
629
+ * the row's recorded result is a conflict, and anything else is current. Admission is not asked
630
+ * about, because restore emits nothing an admission policy could object to and the writer does not
631
+ * ask either.
632
+ */
633
+ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan {
634
+ const refusal = (message: string): IntegrationMutationPlan =>
635
+ unboundPlan(input.clientId, "restore", { reason: "unsafe", state: "unsafe", message }, request.profileId);
636
+ if (request.opId === undefined) return refusal("that operation cannot be undone");
637
+
638
+ const observed = observeRestore(
639
+ input,
640
+ request.opId,
641
+ { maintenance: false, recover: false },
642
+ request.resolved,
643
+ );
644
+ if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId);
645
+
646
+ /*
647
+ * Drift decides first. A row that recorded a file and now finds none has drifted, and calling
648
+ * that absent would report a missing file as an ordinary undo while the writer refuses it
649
+ * pending confirmation. Absent is only honest when the recorded result was absence too.
650
+ */
651
+ const state: IntegrationState = observed.driftsFromResult
652
+ ? "conflict"
653
+ : observed.before === null ? "absent" : "current";
654
+
655
+ return buildMutationPlan({
656
+ operation: "restore",
657
+ clientId: observed.clientId,
658
+ configPath: observed.configPath,
659
+ detectDir: observed.detectDir,
660
+ installKind: observed.installKind,
661
+ admissionBlocked: false,
662
+ before: observed.before,
663
+ contribution: null,
664
+ // Descriptive, never decisive. The record says which places are ours now and the document
665
+ // says which of them the file holds, so the change list can distinguish a place this undo
666
+ // adds back from one it replaces and one it takes away. Neither is allowed to refuse: an
667
+ // unreadable document is reported as PARSE_FAILED and leaves every place a replacement, and
668
+ // restore eligibility stays the byte comparison it was.
669
+ record: currentRecordFor(input, observed.clientId, observed.configPath),
670
+ models: input.models,
671
+ classified: { state },
672
+ parsed: observed.before === null
673
+ ? {}
674
+ : observed.clientId === "cline"
675
+ ? parseClineDocument(observed.before)
676
+ : parseConfig(observed.before, EXPORT_CLIENTS[observed.clientId].format),
677
+ restore: {
678
+ opId: observed.entry.opId,
679
+ entry: observed.entry,
680
+ snapshotKind: observed.snapshotKind,
681
+ snapshotText: observed.snapshotText,
682
+ confirmDrift: request.confirmDrift === true,
683
+ driftsFromResult: observed.driftsFromResult,
684
+ },
685
+ ...(request.profileId === undefined ? {} : { profileId: request.profileId }),
686
+ });
687
+ }
688
+
689
+ /**
690
+ * What a mutation is asked to do. Declared here because the observation below consumes it and the
691
+ * planner must not depend on the writer; the writer re-exports it, so callers are unaffected.
692
+ */
693
+ export interface IntegrationWriteInput {
694
+ clientId: IntegrationClientId;
695
+ models: readonly ExportModel[];
696
+ config: OcxConfig;
697
+ port: number;
698
+ env?: NodeJS.ProcessEnv;
699
+ home?: string;
700
+ store?: IntegrationStateStore;
701
+ io?: IntegrationIO;
702
+ /** Frozen once by the async coordinator; synchronous callers may omit it. */
703
+ resolvedPaths?: { configPath: string; detectDir: string };
704
+ }
705
+
706
+ /** A refusal in the planner's own vocabulary, so observation does not depend on the writer's result type. */
707
+ export interface IntegrationObservationFailure {
708
+ readonly reason: RefusalReason;
709
+ readonly state: IntegrationState;
710
+ readonly message: string;
711
+ readonly snapshotPath?: string;
712
+ /** A Cline transaction left residue that only a mutation may clear. */
713
+ readonly residual?: boolean;
714
+ }
715
+
716
+ function observationFailure(
717
+ reason: RefusalReason,
718
+ state: IntegrationState,
719
+ message: string,
720
+ snapshotPath?: string,
721
+ ): IntegrationObservationFailure {
722
+ return { reason, state, message, ...(snapshotPath ? { snapshotPath } : {}) };
723
+ }
724
+
725
+ /**
726
+ * What preview and mutation are allowed to touch while looking.
727
+ *
728
+ * Both are false for a preview and both are true for a mutation, and neither defaults, because the
729
+ * difference is the whole safety argument. `maintenance` runs pending snapshot pruning, which
730
+ * writes; `recover` lets the Cline adapter repair a pending transaction, which also writes. A
731
+ * preview that quietly inherited either would be a mutation wearing a read's name.
732
+ */
733
+ export interface ObservationEffects {
734
+ readonly maintenance: boolean;
735
+ readonly recover: boolean;
736
+ }
737
+
738
+ /**
739
+ * Detect, gate, read, parse and classify, once, for both preview and mutation.
740
+ *
741
+ * Extracted from the writer so a plan and the mutation it authorizes rest on the same
742
+ * classification rather than two independent reads that can disagree. The ordering of refusals is
743
+ * load-bearing and is preserved exactly as the writer had it.
744
+ */
745
+ export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) {
746
+ const store = input.store ?? createIntegrationStateStore();
747
+ let io = input.io ?? defaultIntegrationIO(store);
748
+ const clientId = input.clientId;
749
+ const spec = INTEGRATION_CLIENTS[clientId];
750
+ const exportSpec = EXPORT_CLIENTS[clientId];
751
+ /*
752
+ * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected
753
+ * because we cannot know the gateway's working directory. That is a refusal
754
+ * about the user's configuration, not an internal fault, so it must not
755
+ * escape as an exception — the collection route would answer 500 for the
756
+ * whole Integrations page because one client is misconfigured.
757
+ */
758
+ let configPath: string;
759
+ let detectDir: string;
760
+ try {
761
+ /*
762
+ * Resolve the PAIR, never one half.
763
+ *
764
+ * The coordinated path hands us a frozen pair, but applyIntegration,
765
+ * refreshIntegration and disableIntegration are public and may be called
766
+ * without one. Resolving configPath here and detectDir separately later let
767
+ * an Aside account switch land between the two, so a direct apply could
768
+ * verify account 1 was installed and then write account 0's catalog.
769
+ */
770
+ const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
771
+ configPath = resolved.configPath;
772
+ detectDir = resolved.detectDir;
773
+ if (clientId === "cline") io = createClineIO(io, configPath, store, effects.recover);
774
+ } catch (error) {
775
+ if (error instanceof ClineTransactionError) {
776
+ return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
777
+ }
778
+ if (!(error instanceof ClientPathError)) throw error;
779
+ return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
780
+ }
781
+ // Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
782
+ if (effects.maintenance) store.retryPendingPrunes();
783
+
784
+ const target = loadTarget(io, configPath);
785
+ if (!target.ok) {
786
+ return {
787
+ failed: observationFailure("unsafe", "unsafe",
788
+ target.why === "read-failed"
789
+ ? `${configPath} exists but could not be read`
790
+ : `${configPath} is not a regular file`),
791
+ } as const;
792
+ }
793
+ const before = target.before;
794
+ const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format);
795
+ if (parsed === PARSE_FAILED) {
796
+ return { failed: observationFailure("unsafe", "unsafe",
797
+ `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
798
+ }
799
+ const contribution = exportSpec.buildContribution(exportContextOf(input));
800
+ // A record proves ownership of the file it was written FOR. Matching only by
801
+ // client id let a record for one home authorize a write to another whose
802
+ // bytes happened to hash the same — which deleted a config we never touched.
803
+ const stored = store.readRecords()[clientId] ?? null;
804
+ const record = stored && stored.clientId === clientId && stored.configPath === configPath
805
+ ? stored
806
+ : null;
807
+ // `configPath`/`clientId` are load-bearing, not decoration: a record proves
808
+ // ownership of ONE file, and the writer mutates whatever path resolves NOW.
809
+ // Without them a record written for another home directory would grant
810
+ // ownership here and disable would delete fragments it never wrote.
811
+ const classified = classifyIntegration({
812
+ fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
813
+ });
814
+ return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
815
+ }