@bitkyc08/opencodex 2.83.0 → 2.84.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 (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -0,0 +1,30 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
+
3
+ // CRLF normalization can halve the byte count. Every body within 64 KiB
4
+ // therefore fits this raw ceiling; title bytes stay outside the body budget.
5
+ const MAX_IMPORT_SOURCE_BYTES = 2 * 64 * 1024;
6
+
7
+ /** The prompt-text-probe descriptor pattern: nonblocking, regular, bounded. */
8
+ export function readBaseImportSource(path: string): string {
9
+ const nonBlocking = (constants as { O_NONBLOCK?: number }).O_NONBLOCK ?? 0;
10
+ const descriptor = openSync(path, constants.O_RDONLY | nonBlocking);
11
+ try {
12
+ if (!fstatSync(descriptor).isFile()) throw new Error("prompt source is not a regular file");
13
+ const parts: Buffer[] = [];
14
+ const view = Buffer.allocUnsafe(8192);
15
+ let total = 0;
16
+ for (;;) {
17
+ const count = readSync(descriptor, view, 0, Math.min(view.length, MAX_IMPORT_SOURCE_BYTES + 1 - total), total);
18
+ if (count === 0) return Buffer.concat(parts).toString("utf8");
19
+ total += count;
20
+ if (total > MAX_IMPORT_SOURCE_BYTES) {
21
+ const error = new Error("prompt source exceeds the import byte ceiling") as NodeJS.ErrnoException;
22
+ error.code = "EFBIG";
23
+ throw error;
24
+ }
25
+ parts.push(Buffer.from(view.subarray(0, count)));
26
+ }
27
+ } finally {
28
+ closeSync(descriptor);
29
+ }
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { OCX_SECTION_MARKER } from "../injected-marker";
2
- import { encodeBasicString } from "./encoding";
2
+ import { encodeBasicString, lexicalLineStarts, matchKeyHead } from "./encoding";
3
3
  import { TABLE_HEADER, ANY_DEV_INSTRUCTIONS, DEV_INSTRUCTIONS_KEY } from "./toml-read";
4
4
 
5
5
  /** Line editing, not re-serialization: the user's comments and layout survive. */
@@ -37,28 +37,156 @@ function joinLines(lines: string[], eol: "\r\n" | "\n"): string {
37
37
  return eol === "\n" ? text : text.replace(/\n/g, "\r\n");
38
38
  }
39
39
 
40
- function firstTableIndex(lines: string[]): number {
41
- const idx = lines.findIndex(l => TABLE_HEADER.test(l));
40
+ function firstTableIndex(lines: string[], starts?: readonly boolean[]): number {
41
+ const idx = lines.findIndex((l, i) => (starts === undefined || starts[i] === true) && TABLE_HEADER.test(l));
42
42
  return idx === -1 ? lines.length : idx;
43
43
  }
44
44
 
45
- /** Set a root-scope boolean, inserting above the first table when absent. */
46
- export function setRootBool(content: string, key: string, value: boolean): string {
45
+ /**
46
+ * Raised when a recognized `key =` line carries a value a line editor cannot
47
+ * replace safely — a multi-line `"""…"""`/`'''…'''` span, an unterminated
48
+ * quote, or trailing junk that is neither value nor comment. Callers refuse
49
+ * BEFORE writing rather than splice over a span they cannot see the end of.
50
+ */
51
+ export class UnsupportedTomlForm extends Error {
52
+ constructor(readonly line: string) {
53
+ super(`unsupported TOML value form: ${line.trim()}`);
54
+ this.name = "UnsupportedTomlForm";
55
+ }
56
+ }
57
+
58
+ /** Where `<key> =` ends and the value begins. `prefix` keeps indent and key quoting. */
59
+ interface AssignmentHead {
60
+ prefix: string;
61
+ rest: string;
62
+ }
63
+
64
+ /**
65
+ * Lexical line scope for the editors below (see `lexicalLineStarts`). A span
66
+ * that never closes means the scope of every later line is unknown, so the
67
+ * whole edit refuses instead of guessing.
68
+ */
69
+ function scopedLineStarts(lines: readonly string[]): boolean[] {
70
+ const starts = lexicalLineStarts(lines);
71
+ if (starts === null) throw new UnsupportedTomlForm("configuration contains an unterminated string or composite value");
72
+ return starts;
73
+ }
74
+
75
+ /**
76
+ * Split `rest` (everything after `key =`) into the value token and a trailing
77
+ * `#…` comment — adjacent or whitespace-separated, both preserved.
78
+ *
79
+ * The scan is string-aware so a `#` inside a quoted value is never mistaken
80
+ * for a comment opener: `"a#b" # note` parses as value `"a#b"` + comment
81
+ * ` # note`, while `false# note` parses as `false` + `# note`. Anything the
82
+ * token scan cannot classify — a multi-line string opener, an unterminated
83
+ * quote, text after the value that is not a comment — throws
84
+ * UnsupportedTomlForm so the caller refuses rather than guesses a span.
85
+ */
86
+ function splitValueComment(head: AssignmentHead, line: string): { value: string; comment: string } {
87
+ const { rest } = head;
88
+ if (rest.startsWith('"""') || rest.startsWith("'''")) throw new UnsupportedTomlForm(line);
89
+ let value: string;
90
+ const first = rest[0];
91
+ if (first === '"') {
92
+ // Basic string: an escaped quote does not close, an unterminated one refuses.
93
+ let i = 1;
94
+ while (i < rest.length && rest[i] !== '"') i += rest[i] === "\\" ? 2 : 1;
95
+ if (i >= rest.length) throw new UnsupportedTomlForm(line);
96
+ value = rest.slice(0, i + 1);
97
+ } else if (first === "'") {
98
+ // Literal string: no escapes, the next ' closes it.
99
+ const end = rest.indexOf("'", 1);
100
+ if (end === -1) throw new UnsupportedTomlForm(line);
101
+ value = rest.slice(0, end + 1);
102
+ } else {
103
+ // Composite values may continue onto another line. This scalar editor
104
+ // refuses them instead of replacing only their opener and orphaning the tail.
105
+ if (first === "[" || first === "{") throw new UnsupportedTomlForm(line);
106
+ const m = /^[^\s#]+/.exec(rest);
107
+ if (!m) throw new UnsupportedTomlForm(line);
108
+ value = m[0];
109
+ }
110
+ const tail = rest.slice(value.length);
111
+ if (tail !== "" && !/^\s*(?:#|$)/.test(tail)) throw new UnsupportedTomlForm(line);
112
+ return { value, comment: tail };
113
+ }
114
+
115
+ /**
116
+ * The shape of `key`'s root-scope assignment, or "absent". "unsupported" means
117
+ * a `key =` line was found but its value is a form the line editor refuses —
118
+ * multiline strings and the like — so a caller can fail BEFORE touching disk
119
+ * instead of inside the transaction.
120
+ */
121
+ export function rootKeyValueForm(content: string, key: string): "absent" | "simple" | "unsupported" {
122
+ const { body } = splitBom(content);
123
+ const lines = splitLines(body);
124
+ let starts: boolean[];
125
+ try { starts = scopedLineStarts(lines); } catch { return "unsupported"; }
126
+ const limit = firstTableIndex(lines, starts);
127
+ for (let i = 0; i < limit; i += 1) {
128
+ if (!starts[i]) continue;
129
+ const head = matchKeyHead(lines[i]!, key);
130
+ if (!head) continue;
131
+ try {
132
+ splitValueComment(head, lines[i]!);
133
+ return "simple";
134
+ } catch {
135
+ return "unsupported";
136
+ }
137
+ }
138
+ return "absent";
139
+ }
140
+
141
+ /**
142
+ * Replace or remove one matched line. The line's own indentation, key quoting,
143
+ * and trailing comment are preserved; `null` removes the assignment but keeps
144
+ * a comment behind so a `# note` written beside the key survives a reset.
145
+ */
146
+ function applyLineEdit(lines: string[], i: number, key: string, replacement: string | null): void {
147
+ const head = matchKeyHead(lines[i]!, key)!;
148
+ const { comment } = splitValueComment(head, lines[i]!);
149
+ if (replacement === null) {
150
+ if (comment.trim()) lines[i] = comment.trim();
151
+ else lines.splice(i, 1);
152
+ } else {
153
+ lines[i] = `${head.prefix}${replacement}${comment}`;
154
+ }
155
+ }
156
+
157
+ type EditMode = "inplace" | "appended" | "noop";
158
+ interface LineEdit { text: string; mode: EditMode }
159
+
160
+ /**
161
+ * Set a root-scope boolean, inserting above the first table when absent.
162
+ *
163
+ * `null` REMOVES the key rather than writing a value, which is what restoring a
164
+ * documented default means: `include_permissions_instructions = true` and an
165
+ * absent key are different facts about the same file, and only the absent one
166
+ * lets a changed upstream default ever take effect again.
167
+ *
168
+ * The value slot is classified, not just skipped over: any value form the line
169
+ * editor cannot see the end of — a multi-line string span, an unterminated
170
+ * quote — throws UnsupportedTomlForm so the write is refused instead of
171
+ * appending a second assignment TOML would reject as a duplicate key. A value
172
+ * that is not a boolean is a bad fact about the same key, so it is REPLACED,
173
+ * and a trailing comment survives a write or a removal whether or not a space
174
+ * precedes the `#`.
175
+ */
176
+ function setRootBoolLines(content: string, key: string, value: boolean | null): LineEdit {
47
177
  const eol = dominantEol(content);
48
178
  const { bom, body } = splitBom(content);
49
179
  const lines = splitLines(body);
50
- const limit = firstTableIndex(lines);
51
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
- const pattern = new RegExp(`^(\\s*${escaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
180
+ const starts = scopedLineStarts(lines);
181
+ const limit = firstTableIndex(lines, starts);
53
182
  for (let i = 0; i < limit; i += 1) {
54
- const m = pattern.exec(lines[i]!);
55
- if (m) {
56
- lines[i] = `${m[1]}${value}${m[2]}`;
57
- return bom + joinLines(lines, eol);
58
- }
183
+ if (!starts[i] || !matchKeyHead(lines[i]!, key)) continue;
184
+ applyLineEdit(lines, i, key, value === null ? null : String(value));
185
+ return { text: bom + joinLines(lines, eol), mode: "inplace" };
59
186
  }
187
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
60
188
  lines.splice(limit, 0, `${key} = ${value}`);
61
- return bom + joinLines(lines, eol);
189
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
62
190
  }
63
191
 
64
192
  /**
@@ -68,51 +196,56 @@ export function setRootBool(content: string, key: string, value: boolean): strin
68
196
  * rather than an empty string: `model_instructions_file = ""` is a path Codex would try
69
197
  * to read, not an absent setting.
70
198
  */
71
- export function setRootString(content: string, key: string, value: string | null): string {
199
+ function setRootStringLines(content: string, key: string, value: string | null): LineEdit {
72
200
  const eol = dominantEol(content);
73
201
  const { bom, body } = splitBom(content);
74
202
  const lines = splitLines(body);
75
- const limit = firstTableIndex(lines);
76
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
77
- const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*"[^"]*"\\s*(?:#.*)?$`);
203
+ const starts = scopedLineStarts(lines);
204
+ const limit = firstTableIndex(lines, starts);
78
205
  for (let i = 0; i < limit; i += 1) {
79
- if (!pattern.test(lines[i]!)) continue;
80
- if (value === null) lines.splice(i, 1);
81
- else lines[i] = `${key} = ${encodeBasicString(value)}`;
82
- return bom + joinLines(lines, eol);
206
+ if (!starts[i] || !matchKeyHead(lines[i]!, key)) continue;
207
+ applyLineEdit(lines, i, key, value === null ? null : encodeBasicString(value));
208
+ return { text: bom + joinLines(lines, eol), mode: "inplace" };
83
209
  }
84
- if (value === null) return bom + joinLines(lines, eol);
210
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
85
211
  lines.splice(limit, 0, `${key} = ${encodeBasicString(value)}`);
86
- return bom + joinLines(lines, eol);
212
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
87
213
  }
88
214
 
89
- /** Set a boolean inside `[table]`, appending the table when absent. */
90
- export function setTableBool(content: string, table: string, key: string, value: boolean): string {
215
+ /**
216
+ * Set a boolean inside `[table]`, appending the table when absent.
217
+ *
218
+ * `null` removes the key line but leaves the table header: an empty `[skills]`
219
+ * is valid TOML, and deleting the header would also orphan any comments the
220
+ * user wrote inside the table.
221
+ */
222
+ function setTableBoolLines(content: string, table: string, key: string, value: boolean | null): LineEdit {
91
223
  const eol = dominantEol(content);
92
224
  const { bom, body } = splitBom(content);
93
225
  const lines = splitLines(body);
226
+ const starts = scopedLineStarts(lines);
94
227
  const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
- const start = lines.findIndex(l => new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`).test(l));
228
+ const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`);
229
+ const start = lines.findIndex((l, i) => starts[i] === true && header.test(l));
96
230
  if (start === -1) {
231
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
97
232
  const tail = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
98
233
  lines.splice(tail, 0, `[${table}]`, `${key} = ${value}`);
99
- return bom + joinLines(lines, eol);
234
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
100
235
  }
101
- const keyEscaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
- const pattern = new RegExp(`^(\\s*${keyEscaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
103
236
  let end = start + 1;
104
- while (end < lines.length && !TABLE_HEADER.test(lines[end]!)) end += 1;
237
+ while (end < lines.length && !(starts[end] && TABLE_HEADER.test(lines[end]!))) end += 1;
105
238
  for (let i = start + 1; i < end; i += 1) {
106
- const m = pattern.exec(lines[i]!);
107
- if (m) {
108
- lines[i] = `${m[1]}${value}${m[2]}`;
109
- return bom + joinLines(lines, eol);
110
- }
239
+ if (!starts[i] || !matchKeyHead(lines[i]!, key)) continue;
240
+ applyLineEdit(lines, i, key, value === null ? null : String(value));
241
+ return { text: bom + joinLines(lines, eol), mode: "inplace" };
111
242
  }
243
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
112
244
  lines.splice(end, 0, `${key} = ${value}`);
113
- return bom + joinLines(lines, eol);
245
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
114
246
  }
115
247
 
248
+
116
249
  /**
117
250
  * Replace, insert, or remove the generated two-line block. Canonical form is
118
251
  * marker + assignment at the top of the document; replacement is "find the
@@ -161,3 +294,102 @@ export function removeUnownedProjection(content: string): string {
161
294
  }
162
295
  return joinLines(lines, eol);
163
296
  }
297
+
298
+ /**
299
+ * Refuse, before any write, an edit that does not do exactly what it was asked.
300
+ *
301
+ * The line editors above address bare table headers and bare or quoted keys. A
302
+ * quoted, spaced, inline or dotted spelling of the same table makes them append a
303
+ * duplicate header (a file Codex cannot load) or, for restore-default, leave the
304
+ * override in place while the caller reports success. Both are caught by parsing
305
+ * the before and after images: the parsed result must equal the parsed input with
306
+ * only the intended path set or removed. When Bun cannot parse the input (its
307
+ * parser has documented gaps a valid Codex config can hit, such as i64 extremes),
308
+ * only an in-place replacement of a key the editor located is trusted; an append
309
+ * or a no-op restore cannot be verified and is refused. A target value with
310
+ * control characters is checked for presence and type only, for the same reason.
311
+ */
312
+ function checkedEdit(before: string, edit: LineEdit, path: readonly string[], value: string | boolean | null): string {
313
+ const after = edit.text;
314
+ let parsedBefore: Record<string, unknown>;
315
+ try { parsedBefore = Bun.TOML.parse(before.replace(/^\ufeff/, "")) as Record<string, unknown>; } catch {
316
+ if (edit.mode === "inplace") return after;
317
+ throw new UnsupportedTomlForm("config.toml uses syntax this editor cannot verify; edit it by hand");
318
+ }
319
+ let parsedAfter: Record<string, unknown>;
320
+ try { parsedAfter = Bun.TOML.parse(after.replace(/^\ufeff/, "")) as Record<string, unknown>; } catch {
321
+ throw new UnsupportedTomlForm("edit would leave config.toml unparseable");
322
+ }
323
+ try {
324
+ const expected = tomlClone(parsedBefore) as Record<string, unknown>;
325
+ let parent: Record<string, unknown> | null = expected;
326
+ let afterParent: unknown = parsedAfter;
327
+ for (const segment of path.slice(0, -1)) {
328
+ const next: unknown = parent?.[segment];
329
+ afterParent = isPlainTable(afterParent) ? afterParent[segment] : undefined;
330
+ if (next === undefined) {
331
+ if (value === null) { parent = null; break; }
332
+ parent![segment] = {};
333
+ parent = parent![segment] as Record<string, unknown>;
334
+ continue;
335
+ }
336
+ if (!isPlainTable(next)) throw new UnsupportedTomlForm("edit target is not a plain table");
337
+ parent = next;
338
+ }
339
+ const key = path[path.length - 1]!;
340
+ if (parent !== null) {
341
+ if (value === null) delete parent[key];
342
+ else if (typeof value === "string" && /[\u0000-\u001f\u007f]/.test(value)) {
343
+ const landed = isPlainTable(afterParent) ? afterParent[key] : undefined;
344
+ if (typeof landed !== "string") throw new UnsupportedTomlForm("edit did not land on the intended key");
345
+ parent[key] = landed;
346
+ } else parent[key] = value;
347
+ }
348
+ if (!tomlEqual(parsedAfter, expected)) throw new UnsupportedTomlForm("edit did not land on the intended key");
349
+ } catch (error) {
350
+ if (error instanceof UnsupportedTomlForm) throw error;
351
+ throw new UnsupportedTomlForm("edit could not be verified");
352
+ }
353
+ return after;
354
+ }
355
+
356
+ function isPlainTable(value: unknown): value is Record<string, unknown> {
357
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
358
+ const proto = Object.getPrototypeOf(value);
359
+ return proto === Object.prototype || proto === null;
360
+ }
361
+
362
+ /** Arrays and plain tables are copied; TOML date/time values are immutable and shared. */
363
+ function tomlClone(value: unknown): unknown {
364
+ if (Array.isArray(value)) return value.map(tomlClone);
365
+ if (isPlainTable(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, tomlClone(v)]));
366
+ return value;
367
+ }
368
+
369
+ function tomlEqual(a: unknown, b: unknown): boolean {
370
+ if (Object.is(a, b)) return true;
371
+ if (Array.isArray(a) || Array.isArray(b)) {
372
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => tomlEqual(v, b[i]));
373
+ }
374
+ if (isPlainTable(a) || isPlainTable(b)) {
375
+ if (!isPlainTable(a) || !isPlainTable(b)) return false;
376
+ const ka = Object.keys(a), kb = Object.keys(b);
377
+ return ka.length === kb.length && ka.every(k => Object.hasOwn(b, k) && tomlEqual(a[k], b[k]));
378
+ }
379
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") {
380
+ return Object.getPrototypeOf(a) === Object.getPrototypeOf(b) && String(a) === String(b);
381
+ }
382
+ return false;
383
+ }
384
+
385
+ export function setRootBool(content: string, key: string, value: boolean | null): string {
386
+ return checkedEdit(content, setRootBoolLines(content, key, value), [key], value);
387
+ }
388
+
389
+ export function setRootString(content: string, key: string, value: string | null): string {
390
+ return checkedEdit(content, setRootStringLines(content, key, value), [key], value);
391
+ }
392
+
393
+ export function setTableBool(content: string, table: string, key: string, value: boolean | null): string {
394
+ return checkedEdit(content, setTableBoolLines(content, table, key, value), [...table.split("."), key], value);
395
+ }
@@ -1,5 +1,5 @@
1
1
  import { OCX_SECTION_MARKER } from "../injected-marker";
2
- import { decodeBasicString } from "./encoding";
2
+ import { decodeBasicString, lexicalLineStarts, matchKeyHead } from "./encoding";
3
3
 
4
4
  /**
5
5
  * Decoded string entries of a root-scope TOML array.
@@ -118,6 +118,25 @@ export function rootLines(content: string): string[] {
118
118
  return first === -1 ? lines : lines.slice(0, first);
119
119
  }
120
120
 
121
+ /**
122
+ * Root-scope lines that start outside every string and composite span. Used by
123
+ * fallbacks that scan lines because the parser refused the file (for example a
124
+ * Codex-valid i64 Bun cannot represent): prose inside a multiline value must not
125
+ * be read as a setting. A file whose spans never close keeps the plain scan.
126
+ */
127
+ export function scopedRootLines(content: string): string[] {
128
+ const lines = content.split("\n");
129
+ const starts = lexicalLineStarts(lines);
130
+ if (starts === null) return rootLines(content);
131
+ const out: string[] = [];
132
+ for (let i = 0; i < lines.length; i += 1) {
133
+ if (!starts[i]) continue;
134
+ if (TABLE_HEADER.test(lines[i]!)) break;
135
+ out.push(lines[i]!);
136
+ }
137
+ return out;
138
+ }
139
+
121
140
  /** Lines of `[header]`'s body, up to the next table header. */
122
141
  export function tableLines(content: string, header: string): string[] | null {
123
142
  const lines = content.split("\n");
@@ -129,11 +148,40 @@ export function tableLines(content: string, header: string): string[] | null {
129
148
  return end === -1 ? rest : rest.slice(0, end);
130
149
  }
131
150
 
151
+ /**
152
+ * A root-scope (`table === null`) or `[table]` boolean, reading only lines that
153
+ * start outside every string and composite span, so assignment-shaped prose in a
154
+ * multiline value is never reported as the setting. A file whose spans never
155
+ * close falls back to the plain line scan; Codex cannot load it either way.
156
+ */
157
+ export function scopedBool(content: string, table: string | null, key: string): boolean | null {
158
+ const lines = content.split("\n");
159
+ const starts = lexicalLineStarts(lines);
160
+ if (starts === null) {
161
+ const scope = table === null ? rootLines(content) : tableLines(content, table);
162
+ return scope === null ? null : boolInLines(scope, key);
163
+ }
164
+ const top = (i: number) => starts[i] === true;
165
+ let from = 0;
166
+ if (table !== null) {
167
+ const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
168
+ const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`);
169
+ from = lines.findIndex((l, i) => top(i) && header.test(l)) + 1;
170
+ if (from === 0) return null;
171
+ }
172
+ for (let i = from; i < lines.length; i += 1) {
173
+ if (!top(i)) continue;
174
+ if (TABLE_HEADER.test(lines[i]!)) break;
175
+ const value = boolInLines([lines[i]!], key);
176
+ if (value !== null) return value;
177
+ }
178
+ return null;
179
+ }
180
+
132
181
  export function boolInLines(lines: string[], key: string): boolean | null {
133
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
134
- const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*(true|false)\\s*(?:#.*)?$`);
135
182
  for (const line of lines) {
136
- const m = pattern.exec(line);
183
+ const head = matchKeyHead(line, key);
184
+ const m = head === null ? null : /^(true|false)\s*(?:#.*)?$/.exec(head.rest);
137
185
  if (m) return m[1] === "true";
138
186
  }
139
187
  return null;