@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,149 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { type IntegrationIO, type IntegrationTransaction, type ReadResult, type StatKind } from "./config-io";
3
+ import type { IntegrationStateStore } from "./store";
4
+ import { fingerprint, type OwnershipRecord } from "./ownership";
5
+ import type { JournalEntry } from "./journal";
6
+ import { parseClineTransaction } from "./cline-transaction";
7
+ import { decodeClinePair, encodeClinePair, type ClineRawPair } from "./cline-document";
8
+
9
+ /** Per-file atomic replacement with durable pair recovery, not simultaneous two-file visibility. */
10
+ export class ClineTransactionError extends Error {
11
+ readonly residual = true;
12
+ constructor(readonly snapshotPath: string) {
13
+ super(`Cline has an unfinished two-file operation. Stop Cline and retry the mutation. If either file was edited, recover from ${snapshotPath} before retrying.`);
14
+ }
15
+ }
16
+
17
+ function sameRecord(a: unknown, b: unknown): boolean {
18
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
19
+ }
20
+
21
+ export function clinePendingPath(store: IntegrationStateStore, configPath: string): string {
22
+ return join(store.root, `cline-pending-${fingerprint(resolve(configPath))}.json`);
23
+ }
24
+
25
+ /** Snapshot/ownership remain in the existing store. Only the filesystem projection changes. */
26
+ export function createClineIO(
27
+ base: IntegrationIO,
28
+ configPath: string,
29
+ store: IntegrationStateStore,
30
+ recover = false,
31
+ ): IntegrationIO {
32
+ const catalogPath = join(dirname(configPath), "models.json");
33
+ const markerPath = clinePendingPath(store, configPath);
34
+ const targets = { settings: configPath, catalog: catalogPath };
35
+ const keys = ["settings", "catalog"] as const;
36
+ const kind = (path: string) => (base.lstatKind ?? base.statKind)(path);
37
+ const readPair = (): ClineRawPair => {
38
+ const pair: ClineRawPair = { settings: null, catalog: null };
39
+ for (const key of keys) {
40
+ const path = targets[key];
41
+ const state = kind(path);
42
+ if (state === "missing") continue;
43
+ if (state !== "file") throw new ClineTransactionError(markerPath);
44
+ const read = base.readText(path);
45
+ if (read.kind !== "text") throw new ClineTransactionError(markerPath);
46
+ pair[key] = read.text;
47
+ }
48
+ return pair;
49
+ };
50
+ const writeMember = (key: typeof keys[number], text: string | null): void => {
51
+ if (text === null) base.removeFile(targets[key]);
52
+ else { base.mkdirp(dirname(targets[key])); base.writeText(targets[key], text); }
53
+ };
54
+ const writePair = (pair: ClineRawPair): void => {
55
+ for (const key of keys) writeMember(key, pair[key]);
56
+ };
57
+ const readPending = (): IntegrationTransaction | null => {
58
+ if (kind(markerPath) === "missing") return null;
59
+ if (kind(markerPath) !== "file") throw new ClineTransactionError(markerPath);
60
+ const read = base.readText(markerPath);
61
+ if (read.kind !== "text") throw new ClineTransactionError(markerPath);
62
+ try {
63
+ return parseClineTransaction(read.text, configPath);
64
+ } catch { throw new ClineTransactionError(markerPath); }
65
+ };
66
+ const committed = (pending: IntegrationTransaction, current: ClineRawPair): boolean => {
67
+ let operation: JournalEntry | null;
68
+ let record: OwnershipRecord | undefined;
69
+ try { operation = store.findCommittedOperation(pending.entry.opId); record = store.readRecordsStrict().cline; }
70
+ catch { throw new ClineTransactionError(markerPath); }
71
+ return operation !== null && operation.clientId === "cline" && operation.configPath === configPath
72
+ && operation.resultFingerprint === pending.entry.resultFingerprint
73
+ && encodeClinePair(current) === pending.nextText
74
+ && sameRecord(record, pending.record);
75
+ };
76
+ const cleanup = (): void => {
77
+ // The append is the commit boundary. Cleanup failure never rolls back a committed pair.
78
+ try { base.removeFile(markerPath); } catch { /* The next mutation recognizes the committed record. */ }
79
+ };
80
+ const pendingSafe = (): boolean => {
81
+ const pending = readPending();
82
+ return pending === null || committed(pending, readPair());
83
+ };
84
+
85
+ if (resolve(configPath).toLowerCase() === resolve(catalogPath).toLowerCase()) throw new ClineTransactionError(markerPath);
86
+ if (recover) {
87
+ const pending = readPending();
88
+ if (pending) {
89
+ const current = readPair();
90
+ if (committed(pending, current)) cleanup();
91
+ else {
92
+ // A journaled but mixed/inconsistent result is not an interrupted uncommitted write.
93
+ try {
94
+ if (store.findCommittedOperation(pending.entry.opId)) throw new ClineTransactionError(markerPath);
95
+ } catch { throw new ClineTransactionError(markerPath); }
96
+ const before = decodeClinePair(pending.before);
97
+ const after = decodeClinePair(pending.nextText);
98
+ if (!keys.every(key => current[key] === before[key] || current[key] === after[key])) throw new ClineTransactionError(markerPath);
99
+ let record: OwnershipRecord | null;
100
+ try { record = store.readRecordsStrict().cline ?? null; }
101
+ catch { throw new ClineTransactionError(markerPath); }
102
+ if (!sameRecord(record, pending.priorRecord) && !sameRecord(record, pending.record)) throw new ClineTransactionError(markerPath);
103
+ try {
104
+ writePair(before);
105
+ if (pending.priorRecord) base.putRecord(pending.priorRecord);
106
+ else base.dropRecord("cline");
107
+ base.removeFile(markerPath);
108
+ } catch { throw new ClineTransactionError(markerPath); }
109
+ }
110
+ }
111
+ }
112
+
113
+ return {
114
+ ...base,
115
+ statKind: (path): StatKind => {
116
+ if (path !== configPath) return base.statKind(path);
117
+ try {
118
+ if (!pendingSafe()) return "failed";
119
+ const pair = readPair();
120
+ return pair.settings === null && pair.catalog === null ? "missing" : "file";
121
+ } catch { return "failed"; }
122
+ },
123
+ readText: (path): ReadResult => {
124
+ if (path !== configPath) return base.readText(path);
125
+ try {
126
+ if (!pendingSafe()) return { kind: "failed", code: "CLINE_PENDING" };
127
+ const text = encodeClinePair(readPair());
128
+ return text === null ? { kind: "missing" } : { kind: "text", text };
129
+ } catch { return { kind: "failed", code: "CLINE_UNSAFE" }; }
130
+ },
131
+ beginTransaction: transaction => {
132
+ if (transaction.entry.clientId !== "cline" || transaction.entry.configPath !== configPath) throw new ClineTransactionError(markerPath);
133
+ if (readPending() !== null) throw new ClineTransactionError(markerPath);
134
+ if (encodeClinePair(readPair()) !== transaction.before) throw new ClineTransactionError(markerPath);
135
+ base.mkdirp(store.root);
136
+ const { entry, before, nextText, record, priorRecord } = transaction;
137
+ base.writeText(markerPath, JSON.stringify({ entry, before, nextText, record, priorRecord }));
138
+ },
139
+ writeText: (path, text) => {
140
+ if (path !== configPath) return base.writeText(path, text);
141
+ writePair(decodeClinePair(text));
142
+ },
143
+ removeFile: path => {
144
+ if (path !== configPath) return base.removeFile(path);
145
+ writePair({ settings: null, catalog: null });
146
+ },
147
+ finishTransaction: cleanup,
148
+ };
149
+ }
@@ -0,0 +1,42 @@
1
+ import type { IntegrationTransaction } from "./config-io";
2
+ import { decodeClinePair, encodeClinePair, isClineObject } from "./cline-document";
3
+ import { isJournalEntry } from "./journal";
4
+ import { fingerprint, isOwnershipRecord, type OwnershipRecord } from "./ownership";
5
+ import { validRefreshablePaths } from "./ownership-policy";
6
+
7
+ const FRAGMENTS = [["catalog", "providers", "opencodex"], ["settings", "providers", "opencodex"]];
8
+ const CONTAINERS = new Set(["catalog", "settings", "catalog\0providers", "settings\0providers"]);
9
+
10
+ function clineRecord(value: unknown, configPath: string): value is OwnershipRecord | null {
11
+ if (value === null) return true;
12
+ if (!isOwnershipRecord(value) || value.clientId !== "cline" || value.configPath !== configPath) return false;
13
+ if (value.fragmentPaths.length !== FRAGMENTS.length
14
+ || !FRAGMENTS.every(expected => value.fragmentPaths.some(path =>
15
+ path.length === expected.length && path.every((segment, index) => segment === expected[index])))) return false;
16
+ if (value.createdContainers?.some(path => !CONTAINERS.has(path))) return false;
17
+ if (value.refreshablePaths !== undefined
18
+ && !validRefreshablePaths({ clientId: "cline", fragments: [] }, value.refreshablePaths)) return false;
19
+ return value.protectedBlockFingerprint === undefined || value.refreshablePaths !== undefined;
20
+ }
21
+
22
+ /** Validate all recovery authority before a file or ownership record can be replaced. */
23
+ export function parseClineTransaction(text: string, configPath: string): IntegrationTransaction {
24
+ const value: unknown = JSON.parse(text);
25
+ if (!isClineObject(value) || !isJournalEntry(value.entry)
26
+ || value.entry.clientId !== "cline" || value.entry.configPath !== configPath
27
+ || !(typeof value.before === "string" || value.before === null)
28
+ || !(typeof value.nextText === "string" || value.nextText === null)
29
+ || !clineRecord(value.record, configPath) || !clineRecord(value.priorRecord, configPath)
30
+ || !clineRecord(value.entry.priorRecord, configPath)
31
+ || JSON.stringify(value.entry.priorRecord) !== JSON.stringify(value.priorRecord)
32
+ || (value.record !== null && (value.record.opId !== value.entry.opId || value.record.appliedAt !== value.entry.at))) {
33
+ throw new Error("invalid Cline transaction metadata");
34
+ }
35
+ if (encodeClinePair(decodeClinePair(value.before)) !== value.before
36
+ || encodeClinePair(decodeClinePair(value.nextText)) !== value.nextText
37
+ || value.entry.resultAbsent !== (value.nextText === null)
38
+ || value.entry.resultFingerprint !== (value.nextText === null ? "" : fingerprint(value.nextText))) {
39
+ throw new Error("inconsistent Cline transaction result");
40
+ }
41
+ return { entry: value.entry, before: value.before, nextText: value.nextText, record: value.record, priorRecord: value.priorRecord };
42
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Design of record: devlog/_fin/260802_client_toggle_api/021 §5-6.
9
9
  */
10
- import { mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
10
+ import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
11
11
  import type { ConfigFormat } from "../clients/config-export";
12
12
  import { MAX_JSON_NESTING } from "./serialize";
13
13
  import { atomicWriteFile } from "../config";
@@ -200,6 +200,8 @@ export interface IntegrationIO {
200
200
  readText: (path: string) => ReadResult;
201
201
  /** `failed` is distinct from `missing` for the same reason. */
202
202
  statKind: (path: string) => StatKind;
203
+ /** Optional no-follow probe for paired-file clients. */
204
+ lstatKind?: (path: string) => StatKind;
203
205
  writeText: (path: string, text: string) => void;
204
206
  removeFile: (path: string) => void;
205
207
  mkdirp: (path: string) => void;
@@ -208,6 +210,16 @@ export interface IntegrationIO {
208
210
  appendJournal: (entry: JournalEntry) => void;
209
211
  putRecord: (record: OwnershipRecord) => void;
210
212
  dropRecord: (clientId: IntegrationClientId) => void;
213
+ beginTransaction?: (transaction: IntegrationTransaction) => void;
214
+ finishTransaction?: () => void;
215
+ }
216
+
217
+ export interface IntegrationTransaction {
218
+ entry: JournalEntry;
219
+ before: string | null;
220
+ nextText: string | null;
221
+ record: OwnershipRecord | null;
222
+ priorRecord: OwnershipRecord | null;
211
223
  }
212
224
 
213
225
  export type TargetState =
@@ -240,6 +252,14 @@ export function loadTarget(io: IntegrationIO, configPath: string): TargetState {
240
252
  */
241
253
  export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "dropRecord"> {
242
254
  return {
255
+ lstatKind: path => {
256
+ try {
257
+ const stats = lstatSync(path);
258
+ return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other";
259
+ } catch (error) {
260
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
261
+ }
262
+ },
243
263
  readText: path => {
244
264
  try {
245
265
  return { kind: "text", text: readFileSync(path, "utf8") };
@@ -334,4 +354,3 @@ export function assertIntegrationWriteOwnership(
334
354
  + "of sharing the mount.",
335
355
  );
336
356
  }
337
-
@@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto";
16
16
  import { appendFileSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
17
17
  import { join, resolve, sep } from "node:path";
18
18
  import { atomicWriteFile } from "../config";
19
- import { ensureDir, fingerprint, integrationsDir, type OwnershipRecord } from "./ownership";
19
+ import { ensureDir, fingerprint, integrationsDir, isOwnershipRecord, type OwnershipRecord } from "./ownership";
20
20
  import { isIntegrationClientId, type IntegrationClientId } from "./registry";
21
21
 
22
22
  /**
@@ -231,6 +231,48 @@ export function findOperation(opId: string, dir: string = integrationsDir()): Jo
231
231
  return listOperations(undefined, Number.MAX_SAFE_INTEGER, dir).find(row => row.opId === opId) ?? null;
232
232
  }
233
233
 
234
+ export function isJournalEntry(value: unknown): value is JournalEntry {
235
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
236
+ const entry = value as Record<string, unknown>;
237
+ const snapshot = entry.snapshot;
238
+ return typeof entry.opId === "string" && /^[a-zA-Z0-9-]+$/.test(entry.opId)
239
+ && typeof entry.clientId === "string" && isIntegrationClientId(entry.clientId)
240
+ && typeof entry.configPath === "string" && entry.configPath.length > 0
241
+ && typeof entry.at === "string" && Number.isFinite(Date.parse(entry.at))
242
+ && typeof entry.kind === "string" && ["apply", "refresh", "disable", "restore", "overwrite"].includes(entry.kind)
243
+ && typeof entry.resultAbsent === "boolean"
244
+ && (entry.resultAbsent ? entry.resultFingerprint === "" : typeof entry.resultFingerprint === "string" && /^[a-f0-9]{16}$/.test(entry.resultFingerprint))
245
+ && (entry.priorRecord === null || isOwnershipRecord(entry.priorRecord))
246
+ && snapshot !== null && typeof snapshot === "object" && !Array.isArray(snapshot)
247
+ && ("kind" in snapshot && (snapshot.kind === "none" || snapshot.kind === "expired"
248
+ || (snapshot.kind === "stored" && "relPath" in snapshot && typeof snapshot.relPath === "string" && snapshot.relPath.length > 0)));
249
+ }
250
+
251
+ /** Commit evidence includes retired rows. Read/parse failures must never become "not committed". */
252
+ export function findCommittedOperation(opId: string, dir: string = integrationsDir()): JournalEntry | null {
253
+ let raw: string;
254
+ try { raw = readFileSync(journalPath(dir), "utf8"); }
255
+ catch (error) {
256
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
257
+ throw new Error("integration journal cannot be read for recovery");
258
+ }
259
+ let found: JournalEntry | null = null;
260
+ for (const line of raw.split("\n")) {
261
+ if (!line.trim()) continue;
262
+ let row: unknown;
263
+ try { row = JSON.parse(line); }
264
+ catch { throw new Error("integration journal is incomplete for recovery"); }
265
+ if (isTombstone(row)) {
266
+ if (!row.tombstone || typeof row.at !== "string" || !Number.isFinite(Date.parse(row.at))
267
+ || typeof row.by !== "string" || !row.by) throw new Error("integration journal contains invalid recovery metadata");
268
+ continue;
269
+ }
270
+ if (!isJournalEntry(row)) throw new Error("integration journal contains invalid recovery metadata");
271
+ if (row.opId === opId) found = row;
272
+ }
273
+ return found;
274
+ }
275
+
234
276
  /** Resolves the tag against what is actually on disk now. */
235
277
  export function readSnapshot(
236
278
  entry: JournalEntry,
@@ -29,7 +29,20 @@ interface MissingEntry {
29
29
  insertAt: number;
30
30
  }
31
31
 
32
- type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry };
32
+ interface ReplaceLineEntry {
33
+ lines: readonly SourceLine[];
34
+ index: number;
35
+ indent: number;
36
+ missingDepth: number;
37
+ }
38
+
39
+ type LocatedPath =
40
+ | { kind: "existing"; entry: LocatedEntry }
41
+ | { kind: "missing"; entry: MissingEntry }
42
+ // `key: {}` — an empty inline map the block-key scanner cannot see (#4260).
43
+ | { kind: "replace-line"; entry: ReplaceLineEntry }
44
+ // A populated flow container. Still refused, but nameable as its own cause.
45
+ | { kind: "unsupported-style" };
33
46
 
34
47
  export type YamlFragmentMutation =
35
48
  | { kind: "upsert"; value: unknown }
@@ -82,6 +95,61 @@ function isPlainBlockKey(line: string, indent: number, key: string): boolean {
82
95
  return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest);
83
96
  }
84
97
 
98
+ /** The inline value written after `key:` on this line, or null if the key is not here. */
99
+ function inlineValueAfterKey(line: string, indent: number, key: string): string | null {
100
+ const spaces = leadingSpaces(line);
101
+ if (spaces !== indent) return null;
102
+ const rest = line.slice(indent);
103
+ const head = `${key}:`;
104
+ // Compared as text, not as a pattern: a path segment is arbitrary user data,
105
+ // and brace escaping inside a `u`-flag regex is its own hazard.
106
+ if (!rest.startsWith(head)) return null;
107
+ return rest.slice(head.length).trim();
108
+ }
109
+
110
+ /** Exactly `key: {}` (any inner spacing) — an empty inline map, no inline comment. */
111
+ function isEmptyInlineMapKey(line: string, indent: number, key: string): boolean {
112
+ const value = inlineValueAfterKey(line, indent, key);
113
+ if (value === null) return false;
114
+ return value.startsWith("{") && value.endsWith("}") && value.slice(1, -1).trim().length === 0;
115
+ }
116
+
117
+ /** `key: { ... }` or `key: [ ... ]` on one line: content we would have to re-render. */
118
+ function isPopulatedInlineFlowKey(line: string, indent: number, key: string): boolean {
119
+ const value = inlineValueAfterKey(line, indent, key);
120
+ if (value === null) return false;
121
+ if (!value.startsWith("{") && !value.startsWith("[")) return false;
122
+ return !isEmptyInlineMapKey(line, indent, key);
123
+ }
124
+
125
+ /**
126
+ * A plain block key whose first child opens a flow collection:
127
+ *
128
+ * providers:
129
+ * { native: { ... } }
130
+ *
131
+ * DSH writes this shape itself. The walk passes straight through it — the key
132
+ * line is a plain block key and `containerEnd` does not stop at `}` — so the
133
+ * refusal used to surface only as a failed re-parse at the very end and got
134
+ * reported as a comment or formatting problem that was not there (#4260).
135
+ */
136
+ function firstChildOpensFlow(
137
+ lines: readonly SourceLine[],
138
+ start: number,
139
+ end: number,
140
+ parentIndent: number,
141
+ ): boolean {
142
+ for (let index = start + 1; index < end; index += 1) {
143
+ const body = lines[index]!.body;
144
+ if (isBlank(body) || isComment(body)) continue;
145
+ const spaces = leadingSpaces(body);
146
+ if (spaces === null || spaces <= parentIndent) continue;
147
+ const trimmed = body.trimStart();
148
+ return trimmed.startsWith("{") || trimmed.startsWith("[");
149
+ }
150
+ return false;
151
+ }
152
+
85
153
  function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
86
154
  for (let index = start + 1; index < lines.length; index += 1) {
87
155
  const body = lines[index]!.body;
@@ -193,9 +261,24 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc
193
261
  if (matches.length > 1) return null;
194
262
  prefix.push(path[depth]!);
195
263
  if (matches.length === 0) {
264
+ const seen = readPath(parsed, prefix);
265
+ // An empty inline map is the one flow shape we can adopt: rewriting that
266
+ // single line into block form adds our subtree and re-renders nothing the
267
+ // user wrote, because there is nothing in it (#4260).
268
+ const inline: number[] = [];
269
+ const populatedFlow: number[] = [];
270
+ for (let index = rangeStart; index < rangeEnd; index += 1) {
271
+ const body = lines[index]!.body;
272
+ if (isEmptyInlineMapKey(body, indent, path[depth]!)) inline.push(index);
273
+ else if (isPopulatedInlineFlowKey(body, indent, path[depth]!)) populatedFlow.push(index);
274
+ }
275
+ if (inline.length === 1 && isPlainRecord(seen) && Object.keys(seen).length === 0) {
276
+ return { kind: "replace-line", entry: { lines, index: inline[0]!, indent, missingDepth: depth } };
277
+ }
278
+ if (populatedFlow.length === 1 && seen !== undefined) return { kind: "unsupported-style" };
196
279
  // The parser saw this key through syntax we do not patch (quoted/flow,
197
280
  // merge aliases, or an ambiguous indentation shape).
198
- if (readPath(parsed, prefix) !== undefined) return null;
281
+ if (seen !== undefined) return null;
199
282
  const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length;
200
283
  return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } };
201
284
  }
@@ -209,7 +292,20 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc
209
292
  if (leafEnd === null) return null;
210
293
  return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } };
211
294
  }
212
- if (!isPlainRecord(readPath(parsed, prefix))) return null;
295
+ const container = readPath(parsed, prefix);
296
+ // `key:` with no children parses as null. The key line matched, so the
297
+ // missing-key branch above never runs, and `isPlainRecord(null)` is false —
298
+ // so an empty container used to refuse the whole document (#4260). Insert
299
+ // our subtree as its first child instead.
300
+ if (container === null) {
301
+ const insertAt = end < lines.length ? lines[end]!.start : text.length;
302
+ return {
303
+ kind: "missing",
304
+ entry: { lines, missingDepth: depth + 1, indent: indent + 2, insertAt },
305
+ };
306
+ }
307
+ if (!isPlainRecord(container)) return null;
308
+ if (firstChildOpensFlow(lines, index, end, indent)) return { kind: "unsupported-style" };
213
309
  rangeStart = index + 1;
214
310
  rangeEnd = end;
215
311
  parentIndent = indent;
@@ -241,7 +337,7 @@ function upsertSource(
241
337
  value: unknown,
242
338
  ): string | null {
243
339
  const located = locatePath(text, parsed, path);
244
- if (located === null) return null;
340
+ if (located === null || located.kind === "unsupported-style") return null;
245
341
  const eol = lineEnding(text);
246
342
  if (located.kind === "existing") {
247
343
  const { lines, index, indent, endIndex } = located.entry;
@@ -250,6 +346,13 @@ function upsertSource(
250
346
  const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`;
251
347
  return preserveFinalNewline(candidate, text, eol);
252
348
  }
349
+ if (located.kind === "replace-line") {
350
+ const { lines, index, indent, missingDepth } = located.entry;
351
+ const startOffset = lines[index]!.start;
352
+ const endOffset = index + 1 < lines.length ? lines[index + 1]!.start : text.length;
353
+ const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol);
354
+ return preserveFinalNewline(`${text.slice(0, startOffset)}${insertion}${text.slice(endOffset)}`, text, eol);
355
+ }
253
356
 
254
357
  const { missingDepth, indent, insertAt } = located.entry;
255
358
  const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
@@ -338,6 +441,22 @@ export function patchYamlFragmentSource(
338
441
  return patched !== null && semanticallyMatches(patched, expected) ? patched : null;
339
442
  }
340
443
 
444
+ /**
445
+ * True when a refusal on this path is caused by a flow-style container rather
446
+ * than by comments or formatting we would have to re-render. DSH writes that
447
+ * shape itself, so naming it is the difference between an actionable message
448
+ * and one that sends the user hunting for a comment that is not there (#4260).
449
+ */
450
+ export function yamlFragmentUnsupportedStyle(text: string, path: readonly string[]): boolean {
451
+ let parsed: unknown;
452
+ try {
453
+ parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
454
+ } catch {
455
+ return false;
456
+ }
457
+ return locatePath(text, parsed, path)?.kind === "unsupported-style";
458
+ }
459
+
341
460
  /** Backward-compatible OMP wrapper around the generic path patcher. */
342
461
  export function patchOmpYamlSource(
343
462
  text: string,
@@ -63,6 +63,10 @@ function cloneFragment(fragment: ManagedFragment): ManagedFragment {
63
63
  export function refreshablePathsOf(
64
64
  contribution: ManagedContribution,
65
65
  ): readonly (readonly string[])[] {
66
+ if (contribution.clientId === "cline") return [
67
+ ["settings", "providers", OPENCODE_PROVIDER_ID, "updatedAt"],
68
+ ["settings", "providers", OPENCODE_PROVIDER_ID, "settings", "model"],
69
+ ];
66
70
  if (contribution.clientId !== "zcode") return [];
67
71
  const fragment = contribution.fragments.find(candidate => (
68
72
  candidate.path.length === 2
@@ -90,6 +94,9 @@ export function validRefreshablePaths(
90
94
  contribution: ManagedContribution,
91
95
  value: unknown,
92
96
  ): value is readonly (readonly string[])[] {
97
+ if (contribution.clientId === "cline") {
98
+ return JSON.stringify(value) === JSON.stringify(refreshablePathsOf(contribution));
99
+ }
93
100
  if (contribution.clientId !== "zcode" || !Array.isArray(value) || value.length === 0) {
94
101
  return false;
95
102
  }
@@ -101,6 +101,42 @@ export interface OwnershipRecord {
101
101
  opId: string;
102
102
  }
103
103
 
104
+ /** Recovery needs proven metadata, unlike the tolerant status-reader fallback. */
105
+ export function isOwnershipRecord(value: unknown): value is OwnershipRecord {
106
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
107
+ const record = value as Record<string, unknown>;
108
+ const hash = (item: unknown) => typeof item === "string" && /^[a-f0-9]{16}$/.test(item);
109
+ const paths = (item: unknown) => Array.isArray(item) && item.every(path =>
110
+ Array.isArray(path) && path.length > 0 && path.every(key => typeof key === "string" && key.length > 0));
111
+ return typeof record.clientId === "string" && record.clientId.length > 0
112
+ && typeof record.configPath === "string" && record.configPath.length > 0
113
+ && typeof record.opId === "string" && record.opId.length > 0
114
+ && typeof record.appliedAt === "string" && Number.isFinite(Date.parse(record.appliedAt))
115
+ && hash(record.fileFingerprint) && hash(record.blockFingerprint)
116
+ && paths(record.fragmentPaths) && (record.fragmentPaths as unknown[]).length > 0
117
+ && ["semanticBlockFingerprint", "protectedBlockFingerprint", "semanticProtectedBlockFingerprint"]
118
+ .every(key => record[key] === undefined || hash(record[key]))
119
+ && (record.refreshablePaths === undefined || paths(record.refreshablePaths))
120
+ && (record.createdContainers === undefined || (Array.isArray(record.createdContainers)
121
+ && record.createdContainers.every(path => typeof path === "string")));
122
+ }
123
+
124
+ /** Missing is empty; corrupt/unreadable ownership is uncertainty and must abort recovery. */
125
+ export function readRecordsStrict(dir: string = integrationsDir()): Partial<Record<IntegrationClientId, OwnershipRecord>> {
126
+ let text: string;
127
+ try { text = readFileSync(recordsPath(dir), "utf8"); }
128
+ catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
130
+ throw new Error("integration ownership cannot be read for recovery");
131
+ }
132
+ try {
133
+ const parsed: unknown = JSON.parse(text);
134
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)
135
+ || !Object.entries(parsed).every(([key, value]) => isOwnershipRecord(value) && value.clientId === key)) throw new Error();
136
+ return parsed as Partial<Record<IntegrationClientId, OwnershipRecord>>;
137
+ } catch { throw new Error("integration ownership is invalid for recovery"); }
138
+ }
139
+
104
140
  /**
105
141
  * The integrations directory itself. Every primitive takes THIS path, never a
106
142
  * config root, so a caller cannot accidentally produce
@@ -12,6 +12,8 @@ import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import {
14
14
  ClientPathError,
15
+ clineConfigPath,
16
+ clineSettingsDir,
15
17
  EXPORT_CLIENTS,
16
18
  asideAccountDir,
17
19
  asideConfigPath,
@@ -26,6 +28,8 @@ import {
26
28
  kimiHomeDir,
27
29
  mcodeConfigPath,
28
30
  mcodeHomeDir,
31
+ omoAgentDir,
32
+ omoConfigPath,
29
33
  ompAgentDir,
30
34
  ompModelsConfigPath,
31
35
  opencodeGlobalConfigPath,
@@ -280,6 +284,29 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
280
284
  */
281
285
  detectDir: (env = process.env, home = homedir()) => raycastAiDir(env, home),
282
286
  },
287
+ omo: {
288
+ id: "omo",
289
+ configPath: (env = process.env, home = homedir()) => omoConfigPath(env, home),
290
+ /*
291
+ * The AGENT directory, not `~/.omo`. The v4 launcher wrapper creates
292
+ * `~/.omo` to hold `binary-runtime` without ever creating `agent/`, so
293
+ * detecting on the parent reports an omo v5 install that is not there --
294
+ * and `installed` is what stops apply from writing a catalog for an engine
295
+ * that will never read it. Prime's agent directory and Aside's account
296
+ * directory are the same shape; Pi's parent-directory check is the odd one.
297
+ *
298
+ * No `sourcePreservingYaml` (JSON), no `writerLock` (single writer), and no
299
+ * `resolvePaths` -- unlike Aside, both omo paths are a pure function of env
300
+ * and home, so reading them in sequence cannot straddle a state change.
301
+ */
302
+ detectDir: (env = process.env, home = homedir()) => omoAgentDir(env, home),
303
+ },
304
+ cline: {
305
+ id: "cline",
306
+ configPath: (env = process.env, home = homedir()) => clineConfigPath(env, home),
307
+ detectDir: (env = process.env, home = homedir()) => clineSettingsDir(env, home),
308
+ writerLock: { suffix: ".lock" },
309
+ },
283
310
  };
284
311
 
285
312
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -8,6 +8,8 @@
8
8
  *
9
9
  * Design of record: devlog/_fin/260802_client_toggle_api/021 §3.
10
10
  */
11
+ import { createClineIO } from "./cline-io";
12
+ import { parseClineDocument } from "./cline-document";
11
13
  import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export";
12
14
  import type { OcxConfig } from "../types";
13
15
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
@@ -487,7 +489,7 @@ function retentionOf(
487
489
  export function readIntegrationState(input: IntegrationStateInput): IntegrationStatus {
488
490
  const store = input.store ?? createIntegrationStateStore();
489
491
  retryPendingPrunesOnce(store);
490
- const io = input.io ?? store.io();
492
+ let io = input.io ?? store.io();
491
493
  const spec = INTEGRATION_CLIENTS[input.clientId];
492
494
  const exportSpec = EXPORT_CLIENTS[input.clientId];
493
495
  const retention = retentionOf(input.clientId, store);
@@ -529,6 +531,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
529
531
  };
530
532
  }
531
533
 
534
+ if (input.clientId === "cline") io = createClineIO(io, configPath, store);
532
535
  const target = loadTarget(io, configPath);
533
536
  if (!target.ok) {
534
537
  return {
@@ -541,7 +544,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
541
544
  };
542
545
  }
543
546
 
544
- const parsed = parseConfig(target.before, exportSpec.format);
547
+ const parsed = input.clientId === "cline" ? parseClineDocument(target.before) : parseConfig(target.before, exportSpec.format);
545
548
  const contribution = exportSpec.buildContribution(exportContextOf(input));
546
549
  const record = store.readRecords()[input.clientId] ?? null;
547
550
  const { state, reason } = classifyIntegration({