@bitkyc08/opencodex 2.68.0 → 2.69.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 (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-DDbgOTh6.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CuaBJkG4.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-kUE8tuAK.js +0 -86
@@ -11,7 +11,7 @@ import {
11
11
  /** Refresh only previously connected clients; a refused file never blocks its peers. */
12
12
  export async function refreshOwnedCatalogIntegrations(
13
13
  input: Omit<OwnedIntegrationRefreshInput, "clientId">,
14
- clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"],
14
+ clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo", "droid"],
15
15
  ): Promise<OwnedIntegrationRefreshOutcome[]> {
16
16
  // Client files are shared with the live proxy a sibling instance runs beside; their entries
17
17
  // point at the owner's port, and refreshing them here would re-point them at this one.
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
11
11
  import type { ConfigFormat } from "../clients/config-export";
12
+ import { canonicalizeJsonc } from "../lib/jsonc";
12
13
  import { MAX_JSON_NESTING } from "./serialize";
13
14
  import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write";
14
15
  import type { JournalEntry } from "./journal";
@@ -132,7 +133,11 @@ function jsonTextSafeToRewrite(text: string): boolean {
132
133
  }
133
134
 
134
135
  /** Parse a client config, tolerating absence. PARSE_FAILED on garbage. */
135
- export function parseConfig(text: string | null, format: ConfigFormat): unknown | typeof PARSE_FAILED {
136
+ export function parseConfig(
137
+ text: string | null,
138
+ format: ConfigFormat,
139
+ options?: { jsonc?: boolean },
140
+ ): unknown | typeof PARSE_FAILED {
136
141
  if (text === null || text.trim().length === 0) return {};
137
142
  try {
138
143
  switch (format) {
@@ -143,9 +148,13 @@ export function parseConfig(text: string | null, format: ConfigFormat): unknown
143
148
  * would otherwise cap the rewrite only after JSON.parse had already
144
149
  * built the 50k-deep object graph. The outcome is unchanged: invalid
145
150
  * JSON still returns PARSE_FAILED, from the catch below.
151
+ *
152
+ * Kilo's global file is JSONC. Comments and trailing commas are
153
+ * stripped first so the rewrite-safety scan sees JSON, not comment text.
146
154
  */
147
- if (!jsonTextSafeToRewrite(text)) return PARSE_FAILED;
148
- return JSON.parse(text);
155
+ const source = options?.jsonc ? canonicalizeJsonc(text) : text;
156
+ if (!jsonTextSafeToRewrite(source)) return PARSE_FAILED;
157
+ return JSON.parse(source);
149
158
  }
150
159
  case "json5": return Bun.JSON5.parse(text);
151
160
  case "yaml": return Bun.YAML.parse(text);
@@ -0,0 +1,65 @@
1
+ /** Read-only guard for competing Factory settings before a managed write. */
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
3
+ import { join, win32 } from "node:path";
4
+
5
+ const isWindowsRoot = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path);
6
+
7
+ function endpointKey(value: string): string | null {
8
+ try {
9
+ const url = new URL(value);
10
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
11
+ const host = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname.toLowerCase())
12
+ ? "127.0.0.1" : url.hostname.toLowerCase();
13
+ return `${url.protocol}//${host}:${url.port}${url.pathname.replace(/\/+$/, "") || "/"}`;
14
+ } catch { return null; }
15
+ }
16
+
17
+ /** Other Factory settings can take priority over the managed personal rows. */
18
+ export function assertDroidSettingsUnambiguous(root: string, baseUrl?: string, modelIds: readonly string[] = []): void {
19
+ const managedEndpoint = baseUrl === undefined ? null : endpointKey(baseUrl);
20
+ const managedModels = new Set(modelIds);
21
+ try {
22
+ const directory = lstatSync(root);
23
+ if (!directory.isDirectory()) throw new Error("Unsafe Factory settings directory");
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
26
+ }
27
+ for (const name of ["config.json", "settings.local.json"]) {
28
+ const path = isWindowsRoot(root) ? win32.join(root, name) : join(root, name);
29
+ let stat: ReturnType<typeof lstatSync>;
30
+ try { stat = lstatSync(path); }
31
+ catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
33
+ throw new Error(`Cannot inspect Factory ${name}`);
34
+ }
35
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error(`Unsafe Factory ${name}`);
36
+ let value: unknown;
37
+ let fd: number | undefined;
38
+ try {
39
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
40
+ const opened = fstatSync(fd);
41
+ if (!opened.isFile() || opened.size > 1024 * 1024 || opened.ino !== stat.ino || opened.dev !== stat.dev) {
42
+ throw new Error("file changed during inspection");
43
+ }
44
+ value = JSON.parse(readFileSync(fd, "utf8"));
45
+ } catch { throw new Error(`Cannot safely parse Factory ${name}`); }
46
+ finally { if (fd !== undefined) closeSync(fd); }
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Unsafe Factory ${name}`);
48
+ const rows = name === "config.json"
49
+ ? (value as Record<string, unknown>).custom_models
50
+ : (value as Record<string, unknown>).customModels;
51
+ if (name === "settings.local.json" && rows !== undefined) {
52
+ throw new Error("Factory settings.local.json overrides customModels; resolve its precedence before enabling Droid");
53
+ }
54
+ if (name === "config.json" && Array.isArray(rows) && rows.some(row => {
55
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
56
+ const legacy = row as Record<string, unknown>;
57
+ return (typeof legacy.display_name === "string" && legacy.display_name.startsWith("OpenCodex:"))
58
+ || (typeof legacy.model === "string" && managedModels.has(legacy.model))
59
+ || (managedEndpoint !== null && typeof legacy.base_url === "string"
60
+ && endpointKey(legacy.base_url) === managedEndpoint);
61
+ })) {
62
+ throw new Error("Factory config.json already defines OpenCodex models; resolve its precedence before enabling Droid");
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,30 @@
1
+ import { homedir } from "node:os";
2
+ import { KILO_CONFIG_CANDIDATES, kiloCandidatePath, kiloHomeDir } from "../clients/config-export";
3
+ import { loadTarget, parseConfig, PARSE_FAILED, type IntegrationIO } from "./config-io";
4
+
5
+ /** Kilo deep-merges every global candidate, so an off-target provider can override ours. */
6
+ export function inspectKiloCandidates(input: {
7
+ io: IntegrationIO;
8
+ selectedPath: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ home?: string;
11
+ }): { kind: "ok" } | { kind: "unsafe"; path: string; why: "unparseable" | "not-regular-file" }
12
+ | { kind: "conflict"; paths: string[] } {
13
+ const dir = kiloHomeDir(input.env ?? process.env, input.home ?? homedir());
14
+ const conflictPaths: string[] = [];
15
+ for (const name of KILO_CONFIG_CANDIDATES) {
16
+ const path = kiloCandidatePath(dir, name);
17
+ const loaded = loadTarget(input.io, path);
18
+ if (!loaded.ok) return { kind: "unsafe", path, why: loaded.why === "read-failed" ? "unparseable" : "not-regular-file" };
19
+ if (loaded.before === null) continue;
20
+ const parsed = parseConfig(loaded.before, "json", { jsonc: true });
21
+ if (parsed === PARSE_FAILED) return { kind: "unsafe", path, why: "unparseable" };
22
+ if (path !== input.selectedPath && typeof parsed === "object" && parsed !== null
23
+ && !Array.isArray(parsed) && Object.hasOwn(parsed, "provider")) {
24
+ const provider = (parsed as Record<string, unknown>).provider;
25
+ if (typeof provider === "object" && provider !== null && !Array.isArray(provider)
26
+ && Object.hasOwn(provider, "opencodex")) conflictPaths.push(path);
27
+ }
28
+ }
29
+ return conflictPaths.length > 0 ? { kind: "conflict", paths: conflictPaths } : { kind: "ok" };
30
+ }
@@ -26,13 +26,18 @@ import { parseClineDocument } from "./cline-document";
26
26
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
27
27
  import {
28
28
  INTEGRATION_CLIENTS,
29
+ boundIntegrationConfigPath,
30
+ assertDroidPathsUnambiguous,
31
+ assertDroidRecordedSettingsUnambiguous,
29
32
  isLoopbackOnly,
30
33
  resolveIntegrationPaths,
34
+ restoreOwnershipCollision,
31
35
  type IntegrationClientId,
32
36
  } from "./registry";
33
37
  import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
34
38
  import { shouldInjectApiAuthHeader } from "../codex/inject";
35
39
  import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
40
+ import { inspectKiloCandidates } from "./kilo-candidates";
36
41
  import { InvalidSelectorError } from "./merge";
37
42
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
38
43
  import type { OcxConfig } from "../types";
@@ -144,6 +149,8 @@ const CLIENT_MANAGED_PATHS = {
144
149
  ["settings", "providers", OPENCODE_PROVIDER_ID],
145
150
  ["catalog", "providers", OPENCODE_PROVIDER_ID],
146
151
  ],
152
+ kilo: [["provider", OPENCODE_PROVIDER_ID]],
153
+ droid: [["customModels", DYNAMIC_SEGMENT]],
147
154
  } satisfies Record<IntegrationClientId, readonly (readonly string[])[]>;
148
155
 
149
156
  /** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
@@ -339,6 +346,7 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
339
346
  function applyOutcome(input: PlanInput): PlanOutcome {
340
347
  if (input.installKind !== "dir") return deny("not_installed");
341
348
  if (input.admissionBlocked) return deny("non_loopback");
349
+ if (input.clientId === "droid" && input.contribution?.fragments.length === 0) return deny("unsafe");
342
350
  /*
343
351
  * Before any file state. The document may be perfectly writable and our block
344
352
  * may already be current in it; neither says anything about whether the
@@ -551,7 +559,9 @@ export function observeRestore(
551
559
  const clientId = input.clientId;
552
560
  let resolved: { configPath: string; detectDir: string };
553
561
  try {
554
- resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
562
+ const context = exportContextOf(input);
563
+ resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home, context);
564
+ if (clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(resolved.detectDir, context);
555
565
  } catch (error) {
556
566
  if (!(error instanceof ClientPathError)) throw error;
557
567
  return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
@@ -582,6 +592,22 @@ export function observeRestore(
582
592
  failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
583
593
  } as const;
584
594
  }
595
+ /*
596
+ * A legal historical path is not enough. Another candidate can already own
597
+ * the single record, and committing this row's prior record would orphan the
598
+ * block that candidate still holds. Direct restore asks the same question.
599
+ */
600
+ const currentOwner = store.readRecords()[clientId] ?? null;
601
+ const collision = restoreOwnershipCollision({
602
+ clientId,
603
+ journaledPath: configPath,
604
+ currentPath: currentOwner && currentOwner.clientId === clientId ? currentOwner.configPath : null,
605
+ env: input.env,
606
+ home: input.home,
607
+ });
608
+ if (collision !== null) {
609
+ return { failed: observationFailure("conflict", "conflict", collision) } as const;
610
+ }
585
611
  if (clientId === "cline") {
586
612
  try { io = createClineIO(io, configPath, store, effects.recover); }
587
613
  catch (error) {
@@ -640,7 +666,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
640
666
  // Restore never reaches the general observation, because the writer's undo path never parses
641
667
  // or classifies and a preview that did would answer a different question.
642
668
  if (request.operation === "restore") return previewRestore(input, request);
643
- const observed = observeIntegration(input, { maintenance: false, recover: false });
669
+ const observed = observeIntegration(input, { maintenance: false, recover: false }, request.operation);
644
670
  if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId);
645
671
 
646
672
  const shared = {
@@ -743,7 +769,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
743
769
  ? {}
744
770
  : observed.clientId === "cline"
745
771
  ? parseClineDocument(observed.before)
746
- : parseConfig(observed.before, observed.format),
772
+ : parseConfig(observed.before, observed.format, EXPORT_CLIENTS[observed.clientId].jsonc ? { jsonc: true } : undefined),
747
773
  restore: {
748
774
  opId: observed.entry.opId,
749
775
  entry: observed.entry,
@@ -812,7 +838,11 @@ export interface ObservationEffects {
812
838
  * classification rather than two independent reads that can disagree. The ordering of refusals is
813
839
  * load-bearing and is preserved exactly as the writer had it.
814
840
  */
815
- export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) {
841
+ export function observeIntegration(
842
+ input: IntegrationWriteInput,
843
+ effects: ObservationEffects,
844
+ operation: Exclude<IntegrationPlanOperation, "restore">,
845
+ ) {
816
846
  const store = input.store ?? createIntegrationStateStore();
817
847
  let io = input.io ?? defaultIntegrationIO(store);
818
848
  const clientId = input.clientId;
@@ -839,7 +869,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
839
869
  * an Aside account switch land between the two, so a direct apply could
840
870
  * verify account 1 was installed and then write account 0's catalog.
841
871
  */
842
- const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
872
+ const context = exportContextOf(input);
873
+ const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home, context);
874
+ if (clientId === "droid" && input.resolvedPaths) assertDroidPathsUnambiguous(resolved.detectDir, context);
843
875
  detectDir = resolved.detectDir;
844
876
  if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
845
877
  /*
@@ -850,6 +882,10 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
850
882
  * target is known, because that is the path it has to match.
851
883
  */
852
884
  stored = store.readRecords()[clientId] ?? null;
885
+ const recordedPath = boundIntegrationConfigPath({
886
+ clientId, record: stored, resolvedPath: resolved.configPath,
887
+ statKind: io.statKind, env: input.env, home: input.home,
888
+ });
853
889
  /*
854
890
  * Inside the same guard as resolution, because this resolver can refuse the
855
891
  * same way: the store is named by a client env var, and a relative one is a
@@ -857,7 +893,7 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
857
893
  * collection route.
858
894
  */
859
895
  effective = resolveIntegrationTarget({
860
- clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home,
896
+ clientId, configPath: recordedPath, io, record: stored, env: input.env, home: input.home,
861
897
  });
862
898
  configPath = effective.configPath;
863
899
  } catch (error) {
@@ -868,6 +904,13 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
868
904
  return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
869
905
  }
870
906
  // Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
907
+ if (clientId === "kilo" && operation !== "disable") {
908
+ const candidates = inspectKiloCandidates({ io, selectedPath: configPath, env: input.env, home: input.home });
909
+ if (candidates.kind !== "ok") return { failed: candidates.kind === "conflict"
910
+ ? observationFailure("conflict", "conflict", `${configPath} cannot be managed while ${candidates.paths.join(", ")} also defines provider.opencodex`)
911
+ : observationFailure("unsafe", "unsafe", `${candidates.path} cannot be inspected safely (${candidates.why})`),
912
+ } as const;
913
+ }
871
914
  if (effects.maintenance) store.retryPendingPrunes();
872
915
 
873
916
  const loaded = loadTarget(io, configPath);
@@ -880,7 +923,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
880
923
  } as const;
881
924
  }
882
925
  const before = loaded.before;
883
- const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format);
926
+ const parsed = clientId === "cline"
927
+ ? parseClineDocument(before)
928
+ : parseConfig(before, effective.format, exportSpec.jsonc ? { jsonc: true } : undefined);
884
929
  if (parsed === PARSE_FAILED) {
885
930
  return { failed: observationFailure("unsafe", "unsafe",
886
931
  `${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;
@@ -898,6 +943,9 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
898
943
  const record = stored && stored.clientId === clientId && stored.configPath === configPath
899
944
  ? stored
900
945
  : null;
946
+ if (clientId === "droid" && input.models.length > 0 && contribution.fragments.length === 0 && !record) {
947
+ return { failed: observationFailure("unsafe", "unsafe", "Factory Droid has no addressable models in the selected catalog") } as const;
948
+ }
901
949
  // `configPath`/`clientId` are load-bearing, not decoration: a record proves
902
950
  // ownership of ONE file, and the writer mutates whatever path resolves NOW.
903
951
  // Without them a record written for another home directory would grant
@@ -906,6 +954,13 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
906
954
  fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
907
955
  format: effective.format,
908
956
  });
957
+ if (clientId === "droid" && record && (classified.state === "current" || classified.state === "stale")) {
958
+ try { assertDroidRecordedSettingsUnambiguous(detectDir, parsed, record); }
959
+ catch (error) {
960
+ if (!(error instanceof ClientPathError)) throw error;
961
+ return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
962
+ }
963
+ }
909
964
  return {
910
965
  failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
911
966
  /*
@@ -18,9 +18,11 @@ import {
18
18
  import {
19
19
  currentServiceHomes,
20
20
  inspectServiceStateEvidence,
21
- serviceHomeMatches,
21
+ compareServicePathToInstall,
22
22
  type ServiceStateEvidence,
23
+ type ServicePathComparison,
23
24
  } from "../../service";
25
+ import type { CodexHomeDeps } from "../../codex/home";
24
26
  import {
25
27
  createWindowsTaskListingCache,
26
28
  inspectServiceManagerInstallation,
@@ -70,15 +72,25 @@ export interface OwnershipInspection {
70
72
  readonly reason: string;
71
73
  }
72
74
 
73
- function claimNamesDifferentHome(
75
+ function claimComparesToCurrentHomes(
74
76
  claim: ServiceManagerClaim,
75
77
  current: { codexHome: string; opencodexHome: string },
76
- ): boolean {
78
+ deps: CodexHomeDeps,
79
+ ): ServicePathComparison {
77
80
  // A definition that OMITS a home is not a definition that disagrees about it:
78
81
  // an install run without CODEX_HOME set writes no such key at all.
79
- if (claim.homes.codexHome !== null && !serviceHomeMatches(claim.homes.codexHome, current.codexHome)) return true;
80
- if (claim.homes.opencodexHome !== null && !serviceHomeMatches(claim.homes.opencodexHome, current.opencodexHome)) return true;
81
- return false;
82
+ let indeterminate = false;
83
+ if (claim.homes.codexHome !== null) {
84
+ const verdict = compareServicePathToInstall(claim.homes.codexHome, current.codexHome, deps);
85
+ if (verdict === "different") return "different";
86
+ indeterminate ||= verdict === "unknown";
87
+ }
88
+ if (claim.homes.opencodexHome !== null) {
89
+ const verdict = compareServicePathToInstall(claim.homes.opencodexHome, current.opencodexHome, deps);
90
+ if (verdict === "different") return "different";
91
+ indeterminate ||= verdict === "unknown";
92
+ }
93
+ return indeterminate ? "unknown" : "same";
82
94
  }
83
95
 
84
96
  /**
@@ -109,6 +121,8 @@ export interface OwnershipDeps extends ProbeDeps {
109
121
  */
110
122
  readonly statePaths?: readonly string[];
111
123
  readonly currentHomes?: { codexHome: string; opencodexHome: string };
124
+ /** Test seam for resolving recorded home aliases to their physical directory. */
125
+ readonly realpathSync?: (path: string) => string;
112
126
  }
113
127
 
114
128
  export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): OwnershipInspection {
@@ -130,22 +144,35 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
130
144
  // Mirrors that disagree with each other are not a majority vote.
131
145
  for (const one of valid) {
132
146
  for (const other of valid) {
133
- if (!serviceHomeMatches(one.state.codexHome, other.state.codexHome)
134
- || !serviceHomeMatches(one.state.opencodexHome, other.state.opencodexHome)) {
147
+ if (compareServicePathToInstall(one.state.codexHome, other.state.codexHome, deps) !== "same"
148
+ || compareServicePathToInstall(one.state.opencodexHome, other.state.opencodexHome, deps) !== "same") {
135
149
  return { ownership: "unknown", reason: "two service state files disagree about which homes are installed" };
136
150
  }
137
151
  }
138
152
  }
139
153
 
140
- const foreign = valid.find(e =>
141
- !serviceHomeMatches(e.state.codexHome, current.codexHome)
142
- || !serviceHomeMatches(e.state.opencodexHome, current.opencodexHome));
154
+ const evidenceComparesDifferent = (e: Extract<ServiceStateEvidence, { kind: "valid" }>): boolean =>
155
+ compareServicePathToInstall(e.state.codexHome, current.codexHome, deps) === "different"
156
+ || compareServicePathToInstall(e.state.opencodexHome, current.opencodexHome, deps) === "different";
157
+ const evidenceComparesIndeterminate = (e: Extract<ServiceStateEvidence, { kind: "valid" }>): boolean =>
158
+ compareServicePathToInstall(e.state.codexHome, current.codexHome, deps) === "unknown"
159
+ || compareServicePathToInstall(e.state.opencodexHome, current.opencodexHome, deps) === "unknown";
160
+ const foreign = valid.find(evidenceComparesDifferent);
143
161
  if (foreign) {
144
162
  return {
145
163
  ownership: "foreign",
146
164
  reason: `a service is installed for CODEX_HOME=${foreign.state.codexHome} / OPENCODEX_HOME=${foreign.state.opencodexHome}`,
147
165
  };
148
166
  }
167
+ // A resolution that could not run (EACCES, EPERM, a vanished directory, transient I/O)
168
+ // proves neither same nor different — report it as unknown, never as foreign.
169
+ const indeterminateEvidence = valid.find(evidenceComparesIndeterminate);
170
+ if (indeterminateEvidence) {
171
+ return {
172
+ ownership: "unknown",
173
+ reason: `a recorded home in ${indeterminateEvidence.path} could not be resolved for comparison`,
174
+ };
175
+ }
149
176
 
150
177
  // The manager assets live under the effective OPENCODEX_HOME. Production
151
178
  // callers do not inject ProbeDeps.configDir, so derive it from the same
@@ -162,7 +189,7 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
162
189
  return { ownership: "unknown", reason: "more than one service manager holds a registration for this proxy" };
163
190
  }
164
191
  if (manager.kind === "present") {
165
- const disagreeing = manager.claims.find(claim => claimNamesDifferentHome(claim, current));
192
+ const disagreeing = manager.claims.find(claim => claimComparesToCurrentHomes(claim, current, deps) === "different");
166
193
  if (disagreeing) {
167
194
  /*
168
195
  * The state file says this home and the definition says another. An
@@ -175,6 +202,13 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
175
202
  reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`,
176
203
  };
177
204
  }
205
+ const indeterminateClaim = manager.claims.find(claim => claimComparesToCurrentHomes(claim, current, deps) === "unknown");
206
+ if (indeterminateClaim) {
207
+ return {
208
+ ownership: "unknown",
209
+ reason: `the homes recorded in ${indeterminateClaim.definitionPath} could not be resolved for comparison`,
210
+ };
211
+ }
178
212
  // A manager backend that disagrees with the recorded state (e.g. state says
179
213
  // native/WinSW but a scheduler task is found) is an interrupted backend
180
214
  // switch: it does not prove which manager owns the installation. v1 state
@@ -9,11 +9,17 @@
9
9
  * Design of record: devlog/_fin/260802_client_toggle_api/021 §1.
10
10
  */
11
11
  import { homedir } from "node:os";
12
+ import { assertDroidSettingsUnambiguous } from "./droid-settings";
13
+ import { readPath } from "./merge";
14
+ import type { OwnershipRecord } from "./ownership";
12
15
  import { join } from "node:path";
13
16
  import {
14
17
  ClientPathError,
18
+ buildDroidContribution,
15
19
  clineConfigPath,
16
20
  clineSettingsDir,
21
+ droidConfigPath,
22
+ droidHomeDir,
17
23
  EXPORT_CLIENTS,
18
24
  asideAccountDir,
19
25
  asideConfigPath,
@@ -48,7 +54,13 @@ import {
48
54
  zcodeStoreSchemaEstablished,
49
55
  type BuildContribution,
50
56
  type ConfigFormat,
57
+ kiloConfigPath,
58
+ kiloHomeDir,
59
+ kiloCandidatePath,
60
+ KILO_CONFIG_CANDIDATES,
51
61
  type ExportClientId,
62
+ type DroidModelEntry,
63
+ type ExportContext,
52
64
  } from "../clients/config-export";
53
65
 
54
66
  /**
@@ -93,13 +105,12 @@ export interface IntegrationClientSpec {
93
105
  * Derive the config path AND the detect directory from one resolution, for a
94
106
  * client whose paths depend on mutable state rather than only env and home.
95
107
  *
96
- * Only Aside needs this. Its two paths both come from the account id in
97
- * `accounts.json`, so calling `configPath` and `detectDir` in sequence can
98
- * straddle an account switch and check one account's install while writing
99
- * another's catalog. Reading the id once and deriving both paths from it
100
- * removes the window instead of narrowing it.
108
+ * Aside needs this because both paths come from the account id in
109
+ * `accounts.json`: one read prevents an account switch between resolutions.
110
+ * Droid uses the same seam to check competing settings against the export
111
+ * context before status, preview, or mutation proceeds.
101
112
  */
102
- resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string };
113
+ resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string, exportContext?: ExportContext) => { configPath: string; detectDir: string };
103
114
  /**
104
115
  * Where the client's config WOULD live, for a client whose real path cannot
105
116
  * be resolved yet.
@@ -115,6 +126,19 @@ export interface IntegrationClientSpec {
115
126
  * catalog. `resolveIntegrationPaths` still throws for callers that mutate.
116
127
  */
117
128
  unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string;
129
+ /**
130
+ * Recognize a resolution drift that is still THIS client's own file, for a
131
+ * client whose config path depends on mutable world state rather than only
132
+ * env and home.
133
+ *
134
+ * Kilo resolves to the first EXISTING candidate, so a candidate created
135
+ * after apply moves resolution while the owned file still holds our block.
136
+ * While this predicate accepts the recorded path, reads and mutations stay
137
+ * bound to it instead of silently re-homing onto the newcomer. A client
138
+ * without this hook never binds: a record from another home stays a refusal
139
+ * ("a record for one home cannot authorize a write to another").
140
+ */
141
+ bindsDriftedRecord?: (recordPath: string, env?: NodeJS.ProcessEnv, home?: string) => boolean;
118
142
  }
119
143
 
120
144
  /**
@@ -129,12 +153,38 @@ export function resolveIntegrationPaths(
129
153
  clientId: IntegrationClientId,
130
154
  env: NodeJS.ProcessEnv = process.env,
131
155
  home: string = homedir(),
156
+ exportContext?: ExportContext,
132
157
  ): { configPath: string; detectDir: string } {
133
158
  const spec = INTEGRATION_CLIENTS[clientId];
134
- if (spec.resolvePaths) return spec.resolvePaths(env, home);
159
+ if (spec.resolvePaths) return spec.resolvePaths(env, home, exportContext);
135
160
  return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) };
136
161
  }
137
162
 
163
+ export function assertDroidPathsUnambiguous(root: string, exportContext?: ExportContext): void {
164
+ try {
165
+ const generated = exportContext ? buildDroidContribution(exportContext).fragments : [];
166
+ assertDroidSettingsUnambiguous(root, exportContext?.baseUrl, generated.map(fragment => (fragment.value as DroidModelEntry).model));
167
+ }
168
+ catch (error) { throw new ClientPathError((error as Error).message); }
169
+ }
170
+
171
+ /** Check the identities still owned on disk even after they leave the catalog. */
172
+ export function assertDroidRecordedSettingsUnambiguous(root: string, parsed: unknown, record: OwnershipRecord): void {
173
+ try {
174
+ const byEndpoint = new Map<string, Set<string>>();
175
+ for (const path of record.fragmentPaths) {
176
+ const row = readPath(parsed, path) as Partial<DroidModelEntry> | undefined;
177
+ if (typeof row?.baseUrl !== "string" || typeof row.model !== "string") {
178
+ throw new Error("Cannot verify recorded Factory Droid rows");
179
+ }
180
+ const models = byEndpoint.get(row.baseUrl) ?? new Set<string>();
181
+ models.add(row.model);
182
+ byEndpoint.set(row.baseUrl, models);
183
+ }
184
+ for (const [baseUrl, models] of byEndpoint) assertDroidSettingsUnambiguous(root, baseUrl, [...models]);
185
+ } catch (error) { throw new ClientPathError((error as Error).message); }
186
+ }
187
+
138
188
  /**
139
189
  * The location to name when resolution refused, or `""` when there is none.
140
190
  *
@@ -345,11 +395,90 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
345
395
  detectDir: (env = process.env, home = homedir()) => clineSettingsDir(env, home),
346
396
  writerLock: { suffix: ".lock" },
347
397
  },
398
+ kilo: {
399
+ id: "kilo",
400
+ configPath: (env = process.env, home = homedir()) => kiloConfigPath(env, home),
401
+ detectDir: (env = process.env, home = homedir()) => kiloHomeDir(env, home),
402
+ bindsDriftedRecord: (recordPath, env = process.env, home = homedir()) =>
403
+ KILO_CONFIG_CANDIDATES.some(name => recordPath === kiloCandidatePath(kiloHomeDir(env, home), name)),
404
+ },
405
+ droid: {
406
+ id: "droid",
407
+ configPath: (env = process.env, home = homedir()) => droidConfigPath(env, home),
408
+ detectDir: (env = process.env, home = homedir()) => droidHomeDir(env, home),
409
+ resolvePaths: (env = process.env, home = homedir(), exportContext) => {
410
+ const detectDir = droidHomeDir(env, home);
411
+ assertDroidPathsUnambiguous(detectDir, exportContext);
412
+ return { configPath: droidConfigPath(env, home), detectDir };
413
+ },
414
+ },
348
415
  };
349
416
 
350
417
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
351
418
  Object.keys(INTEGRATION_CLIENTS) as IntegrationClientId[];
352
419
 
420
+ /**
421
+ * The effective config path for a read or mutation, given the ownership record.
422
+ *
423
+ * One implementation for status AND the mutation planner: when only one side
424
+ * carried the binding, the two could disagree again and status would report a
425
+ * file the writer never touches. Binds only while the client's own
426
+ * `bindsDriftedRecord` accepts the recorded path (still one of that client's
427
+ * candidates under the CURRENT env and home) and the file still exists; a
428
+ * record from another home never binds and keeps its refusal contract.
429
+ */
430
+ export function boundIntegrationConfigPath(input: {
431
+ clientId: IntegrationClientId;
432
+ record: { clientId: IntegrationClientId; configPath: string } | null;
433
+ resolvedPath: string;
434
+ statKind: (path: string) => string;
435
+ env?: NodeJS.ProcessEnv;
436
+ home?: string;
437
+ }): string {
438
+ const record = input.record;
439
+ if (
440
+ record && record.clientId === input.clientId &&
441
+ record.configPath !== input.resolvedPath &&
442
+ input.statKind(record.configPath) === "file" &&
443
+ INTEGRATION_CLIENTS[input.clientId].bindsDriftedRecord?.(record.configPath, input.env, input.home) === true
444
+ ) {
445
+ return record.configPath;
446
+ }
447
+ return input.resolvedPath;
448
+ }
449
+
450
+ /**
451
+ * Why a historical restore must not run, or null when it may.
452
+ *
453
+ * Kilo keeps one ownership record and may legally have written more than one
454
+ * candidate. Treating every same-home journaled path as a restore target lets
455
+ * an undo of an older file commit that file's prior record over the candidate
456
+ * that owns the integration now. The managed block in the current file stays
457
+ * on disk, the record points at the old file, and a later disable drops the
458
+ * record and orphans the newcomer.
459
+ *
460
+ * A missing current record is not a collision: undoing the disable that
461
+ * dropped it still restores the journaled file. A client without
462
+ * bindsDriftedRecord is unchanged, because that seam is what made the second
463
+ * candidate a legal target. Direct restore and its preview both ask here, so
464
+ * they cannot admit different answers.
465
+ */
466
+ export function restoreOwnershipCollision(input: {
467
+ clientId: IntegrationClientId;
468
+ journaledPath: string;
469
+ currentPath: string | null;
470
+ env?: NodeJS.ProcessEnv;
471
+ home?: string;
472
+ }): string | null {
473
+ const currentPath = input.currentPath;
474
+ if (currentPath === null || currentPath === input.journaledPath) return null;
475
+ const binds = INTEGRATION_CLIENTS[input.clientId].bindsDriftedRecord;
476
+ if (!binds) return null;
477
+ if (binds(input.journaledPath, input.env, input.home) !== true) return null;
478
+ if (binds(currentPath, input.env, input.home) !== true) return null;
479
+ return `that operation was recorded for ${input.journaledPath}, but ${currentPath} currently owns this integration`;
480
+ }
481
+
353
482
  export function isIntegrationClientId(value: string): value is IntegrationClientId {
354
483
  return Object.prototype.hasOwnProperty.call(INTEGRATION_CLIENTS, value);
355
484
  }