@the-open-engine/zeroshot 6.10.2 → 6.12.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 (76) hide show
  1. package/cli/commands/providers.js +13 -13
  2. package/cli/index.js +77 -35
  3. package/cli/lib/first-run.js +12 -11
  4. package/cli/lib/update-checker.js +517 -132
  5. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/adapters/opencode.js +3 -0
  7. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  8. package/lib/agent-cli-provider/types.d.ts +1 -0
  9. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  10. package/lib/agent-cli-provider/types.js.map +1 -1
  11. package/lib/cluster/client.cjs +146 -0
  12. package/lib/cluster/client.d.ts +44 -0
  13. package/lib/cluster/client.mjs +141 -0
  14. package/lib/cluster/connection.cjs +382 -0
  15. package/lib/cluster/connection.d.ts +41 -0
  16. package/lib/cluster/connection.mjs +378 -0
  17. package/lib/cluster/errors.cjs +44 -0
  18. package/lib/cluster/errors.d.ts +23 -0
  19. package/lib/cluster/errors.mjs +32 -0
  20. package/lib/cluster/frames.cjs +49 -0
  21. package/lib/cluster/frames.d.ts +12 -0
  22. package/lib/cluster/frames.mjs +45 -0
  23. package/lib/cluster/generated/protocol-schema.cjs +5 -0
  24. package/lib/cluster/generated/protocol-schema.d.ts +1 -0
  25. package/lib/cluster/generated/protocol-schema.mjs +2 -0
  26. package/lib/cluster/generated/protocol.cjs +22 -0
  27. package/lib/cluster/generated/protocol.d.ts +781 -0
  28. package/lib/cluster/generated/protocol.mjs +19 -0
  29. package/lib/cluster/index.cjs +40 -0
  30. package/lib/cluster/index.d.ts +10 -0
  31. package/lib/cluster/index.mjs +6 -0
  32. package/lib/cluster/queue.cjs +71 -0
  33. package/lib/cluster/queue.d.ts +15 -0
  34. package/lib/cluster/queue.mjs +67 -0
  35. package/lib/cluster/socket.cjs +15 -0
  36. package/lib/cluster/socket.d.ts +11 -0
  37. package/lib/cluster/socket.mjs +12 -0
  38. package/lib/cluster/subscriptions.cjs +270 -0
  39. package/lib/cluster/subscriptions.d.ts +69 -0
  40. package/lib/cluster/subscriptions.mjs +264 -0
  41. package/lib/cluster/validators.cjs +46 -0
  42. package/lib/cluster/validators.d.ts +3 -0
  43. package/lib/cluster/validators.mjs +42 -0
  44. package/lib/settings.js +195 -23
  45. package/lib/setup-apply.js +45 -32
  46. package/lib/setup-undo.js +25 -16
  47. package/package.json +25 -3
  48. package/scripts/build-cluster.js +43 -0
  49. package/scripts/generate-cluster-types.js +203 -0
  50. package/scripts/release-dry-run.js +6 -6
  51. package/scripts/rust-distribution.js +933 -0
  52. package/src/agent/agent-hook-executor.js +14 -6
  53. package/src/agent/agent-lifecycle.js +25 -8
  54. package/src/agent/agent-task-executor.js +711 -139
  55. package/src/agent/output-reformatter.js +54 -41
  56. package/src/agent/pr-verification.js +11 -3
  57. package/src/agent/task-execution-handle.js +373 -0
  58. package/src/agent-cli-provider/adapters/opencode.ts +3 -0
  59. package/src/agent-cli-provider/types.ts +1 -0
  60. package/src/agent-wrapper.js +5 -2
  61. package/src/cluster/client.ts +199 -0
  62. package/src/cluster/connection.ts +300 -0
  63. package/src/cluster/errors.ts +32 -0
  64. package/src/cluster/frames.ts +44 -0
  65. package/src/cluster/generated/protocol-schema.ts +2 -0
  66. package/src/cluster/generated/protocol.ts +183 -0
  67. package/src/cluster/index.ts +38 -0
  68. package/src/cluster/queue.ts +84 -0
  69. package/src/cluster/socket.ts +31 -0
  70. package/src/cluster/subscriptions.ts +256 -0
  71. package/src/cluster/validators.ts +56 -0
  72. package/src/cluster/ws.d.ts +7 -0
  73. package/src/isolation-manager.js +16 -0
  74. package/src/issue-providers/jira-provider.js +1 -1
  75. package/src/issue-providers/linear-provider.js +4 -4
  76. package/task-lib/runner.js +3 -0
@@ -0,0 +1,264 @@
1
+ var _a;
2
+ import { ClusterProtocolError, ClusterStateError } from './errors.mjs';
3
+ import { isRecord } from './frames.mjs';
4
+ import { assertDefinition } from './validators.mjs';
5
+ class SubscriptionStream {
6
+ connection;
7
+ registration;
8
+ parse;
9
+ #done = false;
10
+ constructor(connection, registration, parse) {
11
+ this.connection = connection;
12
+ this.registration = registration;
13
+ this.parse = parse;
14
+ }
15
+ get subscriptionId() { return this.registration.id; }
16
+ get retainedCount() { return this.registration.queue.retainedCount; }
17
+ [Symbol.asyncIterator]() { return this; }
18
+ async next() {
19
+ if (this.#done)
20
+ return { done: true, value: undefined };
21
+ const queued = await this.registration.queue.recv();
22
+ if (queued.done) {
23
+ if (this.registration.overflowed) {
24
+ this.registration.overflowed = false;
25
+ this.#done = true;
26
+ return { done: false, value: { type: 'closed', reason: 'SLOW_CONSUMER' } };
27
+ }
28
+ this.#done = true;
29
+ return { done: true, value: undefined };
30
+ }
31
+ try {
32
+ const value = this.parse(queued.value);
33
+ if (value.type === 'closed')
34
+ this.#finishWithoutCancel();
35
+ return { done: false, value };
36
+ }
37
+ catch (cause) {
38
+ await this.#terminate(true);
39
+ throw cause;
40
+ }
41
+ }
42
+ async return() {
43
+ await this.#terminate(true);
44
+ return { done: true, value: undefined };
45
+ }
46
+ async throw(error) {
47
+ await this.#terminate(true);
48
+ throw error;
49
+ }
50
+ cancel() { return this.#terminate(true); }
51
+ async #terminate(sendCancel) {
52
+ if (this.#done)
53
+ return;
54
+ this.#done = true;
55
+ this.connection.unregisterSubscription(this.registration.id, this.registration);
56
+ this.registration.queue.closeAndDiscard();
57
+ if (sendCancel) {
58
+ try {
59
+ await this.connection.cancelSubscription(this.registration);
60
+ }
61
+ catch (error) {
62
+ this.connection.recordDiagnostic(error);
63
+ }
64
+ }
65
+ }
66
+ #finishWithoutCancel() {
67
+ if (this.#done)
68
+ return;
69
+ this.#done = true;
70
+ this.connection.unregisterSubscription(this.registration.id, this.registration);
71
+ this.registration.queue.closeAndDiscard();
72
+ }
73
+ }
74
+ function parseCursorlessClosed(params, definition) {
75
+ assertDefinition(definition, params);
76
+ return { type: 'closed', reason: params.reason };
77
+ }
78
+ function parseWatchClosed(params) {
79
+ assertDefinition('SubscriptionClosedNotification', params);
80
+ const cursor = params.lastDeliveredCursor;
81
+ return {
82
+ type: 'closed',
83
+ reason: params.reason,
84
+ ...(cursor === undefined ? {} : { lastDeliveredCursor: cursor }),
85
+ };
86
+ }
87
+ function subscriptionParser(field, eventDefinition, closedDefinition) {
88
+ return (frame) => {
89
+ if (!isRecord(frame.params)) {
90
+ throw new ClusterProtocolError('notification params are missing', 'INVALID_SUBSCRIPTION_EVENT');
91
+ }
92
+ if (frame.method === 'subscription/closed') {
93
+ return parseCursorlessClosed(frame.params, closedDefinition);
94
+ }
95
+ if (frame.method !== 'event') {
96
+ throw new ClusterProtocolError('malformed subscription event', 'INVALID_SUBSCRIPTION_EVENT');
97
+ }
98
+ assertDefinition(eventDefinition, frame.params);
99
+ return { type: 'event', event: frame.params[field] };
100
+ };
101
+ }
102
+ export class LogsSubscriptionStream extends SubscriptionStream {
103
+ constructor(connection, registration) {
104
+ super(connection, registration, subscriptionParser('record', 'LogEventNotification', 'LogsClosedNotification'));
105
+ }
106
+ }
107
+ export class AgentAttachSubscriptionStream extends SubscriptionStream {
108
+ constructor(connection, registration) {
109
+ super(connection, registration, subscriptionParser('event', 'AgentAttachEventNotification', 'AgentAttachClosedNotification'));
110
+ }
111
+ }
112
+ export class WatchSubscriptionStream {
113
+ #connection;
114
+ #registration;
115
+ #lastSeenRunId;
116
+ #lastSeenCursor;
117
+ #lastDelivered;
118
+ #runId;
119
+ #done = false;
120
+ #reconnectConsumed = false;
121
+ #readBarrier = Promise.resolve();
122
+ constructor(init) {
123
+ this.#connection = init.connection;
124
+ this.#registration = init.registration;
125
+ this.#lastSeenRunId = init.lastSeenRunId;
126
+ this.#lastSeenCursor = init.lastSeenCursor;
127
+ this.#lastDelivered = init.params.fromCursor;
128
+ this.#runId = init.result.runId ?? init.params.runId;
129
+ }
130
+ get subscriptionId() { return this.#registration.id; }
131
+ get retainedCount() { return this.#registration.queue.retainedCount; }
132
+ get lastDeliveredCursor() { return this.#lastDelivered; }
133
+ [Symbol.asyncIterator]() { return this; }
134
+ next() {
135
+ const read = this.#readBarrier.then(() => this.#nextLogical());
136
+ this.#readBarrier = read.then(() => undefined, () => undefined);
137
+ return read;
138
+ }
139
+ async #nextLogical() {
140
+ while (!this.#done) {
141
+ const queued = await this.#registration.queue.recv();
142
+ if (queued.done)
143
+ return this.#finishQueue();
144
+ const item = await this.#decode(queued.value);
145
+ if (item)
146
+ return { done: false, value: item };
147
+ }
148
+ return { done: true, value: undefined };
149
+ }
150
+ #finishQueue() {
151
+ this.#done = true;
152
+ if (!this.#registration.overflowed)
153
+ return { done: true, value: undefined };
154
+ this.#registration.overflowed = false;
155
+ const cursor = this.#lastDelivered;
156
+ return {
157
+ done: false,
158
+ value: { type: 'closed', reason: 'SLOW_CONSUMER', ...(cursor === undefined ? {} : { lastDeliveredCursor: cursor }) },
159
+ };
160
+ }
161
+ async #decode(frame) {
162
+ if (!isRecord(frame.params))
163
+ return this.#invalid('notification params are missing');
164
+ if (frame.method === 'subscription/closed') {
165
+ const closed = parseWatchClosed(frame.params);
166
+ if (closed.lastDeliveredCursor !== undefined)
167
+ this.#lastDelivered = closed.lastDeliveredCursor;
168
+ this.#finishWithoutCancel();
169
+ return closed;
170
+ }
171
+ if (frame.method !== 'event')
172
+ return this.#invalid('malformed watch event');
173
+ try {
174
+ assertDefinition('EventNotification', frame.params);
175
+ }
176
+ catch {
177
+ return this.#invalid('malformed watch event');
178
+ }
179
+ const { runId, cursor, event } = frame.params;
180
+ if (this.#lastSeenRunId === runId && this.#lastSeenCursor === cursor)
181
+ return undefined;
182
+ this.#lastSeenRunId = runId;
183
+ this.#lastSeenCursor = cursor;
184
+ this.#runId = runId;
185
+ this.#lastDelivered = cursor;
186
+ return { type: 'event', runId, cursor, event };
187
+ }
188
+ async return() {
189
+ await this.#terminate(true);
190
+ return { done: true, value: undefined };
191
+ }
192
+ async throw(error) {
193
+ await this.#terminate(true);
194
+ throw error;
195
+ }
196
+ cancel() { return this.#terminate(true); }
197
+ reconnect(freshConnection) {
198
+ if (this.#reconnectConsumed) {
199
+ throw new ClusterStateError('watch stream reconnect is one-shot', 'RECONNECT_CONSUMED');
200
+ }
201
+ this.#reconnectConsumed = true;
202
+ return this.#reconnectOn(freshConnection);
203
+ }
204
+ async #reconnectOn(freshConnection) {
205
+ await this.#terminate(true);
206
+ try {
207
+ const runId = this.#runId;
208
+ const fromCursor = this.#lastDelivered;
209
+ const params = {
210
+ ...(runId === undefined ? {} : { runId }),
211
+ ...(fromCursor === undefined ? {} : { fromCursor }),
212
+ };
213
+ const established = await freshConnection.openSubscription('watch', params);
214
+ return {
215
+ result: established.result,
216
+ stream: new _a({
217
+ connection: freshConnection,
218
+ registration: established.registration,
219
+ result: established.result,
220
+ params,
221
+ ...(this.#lastSeenRunId === undefined ? {} : { lastSeenRunId: this.#lastSeenRunId }),
222
+ ...(this.#lastSeenCursor === undefined ? {} : { lastSeenCursor: this.#lastSeenCursor }),
223
+ }),
224
+ };
225
+ }
226
+ catch (error) {
227
+ await freshConnection.close();
228
+ throw error;
229
+ }
230
+ }
231
+ async #terminate(sendCancel) {
232
+ if (this.#done)
233
+ return;
234
+ this.#done = true;
235
+ this.#connection.unregisterSubscription(this.#registration.id, this.#registration);
236
+ this.#registration.queue.closeAndDiscard();
237
+ if (sendCancel) {
238
+ try {
239
+ await this.#connection.cancelSubscription(this.#registration);
240
+ }
241
+ catch (error) {
242
+ this.#connection.recordDiagnostic(error);
243
+ }
244
+ }
245
+ }
246
+ #finishWithoutCancel() {
247
+ if (this.#done)
248
+ return;
249
+ this.#done = true;
250
+ this.#connection.unregisterSubscription(this.#registration.id, this.#registration);
251
+ this.#registration.queue.closeAndDiscard();
252
+ }
253
+ async #invalid(message) {
254
+ await this.#terminate(true);
255
+ throw new ClusterProtocolError(message, 'INVALID_SUBSCRIPTION_EVENT');
256
+ }
257
+ }
258
+ _a = WatchSubscriptionStream;
259
+ Object.defineProperty(SubscriptionStream.prototype, Symbol.asyncDispose, {
260
+ configurable: true, value: SubscriptionStream.prototype.cancel,
261
+ });
262
+ Object.defineProperty(WatchSubscriptionStream.prototype, Symbol.asyncDispose, {
263
+ configurable: true, value: WatchSubscriptionStream.prototype.cancel,
264
+ });
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertDefinition = assertDefinition;
4
+ exports.assertMethodResult = assertMethodResult;
5
+ const _2020_js_1 = require("ajv/dist/2020.js");
6
+ const protocol_js_1 = require("./generated/protocol.cjs");
7
+ const protocol_schema_js_1 = require("./generated/protocol-schema.cjs");
8
+ const errors_js_1 = require("./errors.cjs");
9
+ const ajv = new _2020_js_1.default({
10
+ allErrors: true,
11
+ strict: false,
12
+ validateFormats: false,
13
+ });
14
+ ajv.addSchema(protocol_schema_js_1.CLUSTER_PROTOCOL_SCHEMA, 'openengine-cluster-v1');
15
+ const validators = new Map();
16
+ function validatorFor(definition) {
17
+ const cached = validators.get(definition);
18
+ if (cached)
19
+ return cached;
20
+ const validator = ajv.compile({
21
+ $ref: `openengine-cluster-v1#/$defs/${definition}`,
22
+ });
23
+ validators.set(definition, validator);
24
+ return validator;
25
+ }
26
+ function assertDefinition(definition, value) {
27
+ const validate = validatorFor(definition);
28
+ if (validate(value))
29
+ return;
30
+ const details = (validate.errors ?? []).map((error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`).join('; ');
31
+ throw new errors_js_1.ClusterProtocolError(`${definition} validation failed: ${details}`, 'INVALID_RESPONSE');
32
+ }
33
+ function assertMethodResult(method, value) {
34
+ try {
35
+ assertDefinition(protocol_js_1.METHOD_RESULT_DEFINITIONS[method], value);
36
+ }
37
+ catch (error) {
38
+ const receivedVersion = method === 'initialize' && value !== null && typeof value === 'object'
39
+ ? value.protocolVersion
40
+ : undefined;
41
+ if (typeof receivedVersion === 'string' && receivedVersion !== protocol_js_1.PROTOCOL_VERSION) {
42
+ throw new errors_js_1.ClusterProtocolError(`unsupported protocol version ${receivedVersion}`, 'UNSUPPORTED_PROTOCOL_VERSION');
43
+ }
44
+ throw error;
45
+ }
46
+ }
@@ -0,0 +1,3 @@
1
+ import type { ClusterMethod } from './generated/protocol.js';
2
+ export declare function assertDefinition(definition: string, value: unknown): void;
3
+ export declare function assertMethodResult(method: ClusterMethod, value: unknown): void;
@@ -0,0 +1,42 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import { METHOD_RESULT_DEFINITIONS, PROTOCOL_VERSION, } from './generated/protocol.mjs';
3
+ import { CLUSTER_PROTOCOL_SCHEMA } from './generated/protocol-schema.mjs';
4
+ import { ClusterProtocolError } from './errors.mjs';
5
+ const ajv = new Ajv2020({
6
+ allErrors: true,
7
+ strict: false,
8
+ validateFormats: false,
9
+ });
10
+ ajv.addSchema(CLUSTER_PROTOCOL_SCHEMA, 'openengine-cluster-v1');
11
+ const validators = new Map();
12
+ function validatorFor(definition) {
13
+ const cached = validators.get(definition);
14
+ if (cached)
15
+ return cached;
16
+ const validator = ajv.compile({
17
+ $ref: `openengine-cluster-v1#/$defs/${definition}`,
18
+ });
19
+ validators.set(definition, validator);
20
+ return validator;
21
+ }
22
+ export function assertDefinition(definition, value) {
23
+ const validate = validatorFor(definition);
24
+ if (validate(value))
25
+ return;
26
+ const details = (validate.errors ?? []).map((error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`).join('; ');
27
+ throw new ClusterProtocolError(`${definition} validation failed: ${details}`, 'INVALID_RESPONSE');
28
+ }
29
+ export function assertMethodResult(method, value) {
30
+ try {
31
+ assertDefinition(METHOD_RESULT_DEFINITIONS[method], value);
32
+ }
33
+ catch (error) {
34
+ const receivedVersion = method === 'initialize' && value !== null && typeof value === 'object'
35
+ ? value.protocolVersion
36
+ : undefined;
37
+ if (typeof receivedVersion === 'string' && receivedVersion !== PROTOCOL_VERSION) {
38
+ throw new ClusterProtocolError(`unsupported protocol version ${receivedVersion}`, 'UNSUPPORTED_PROTOCOL_VERSION');
39
+ }
40
+ throw error;
41
+ }
42
+ }
package/lib/settings.js CHANGED
@@ -6,6 +6,8 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
+ const crypto = require('crypto');
10
+ const lockfile = require('proper-lockfile');
9
11
  const { validateMountConfig, validateEnvPassthrough } = require('./docker-config');
10
12
  const {
11
13
  VALID_PROVIDERS,
@@ -13,6 +15,18 @@ const {
13
15
  normalizeProviderSettings,
14
16
  } = require('./provider-names');
15
17
 
18
+ const SETTINGS_LOCK_STALE_MS = 5000;
19
+ const SETTINGS_LOCK_TIMEOUT_MS = 500;
20
+ const SETTINGS_LOCK_RETRY_MS = 20;
21
+ const SETTINGS_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
22
+
23
+ class SettingsValidationError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'SettingsValidationError';
27
+ }
28
+ }
29
+
16
30
  // Lazy-loaded to avoid circular dependency (issue-providers → settings → issue-providers)
17
31
  let _issueProviderFns = null;
18
32
  function getIssueProviderFns() {
@@ -115,10 +129,11 @@ const DEFAULT_SETTINGS_BASE = {
115
129
  defaultDelivery: 'none', // 'none' | 'pr' | 'ship' - folded into resolveEffectiveRunPlan same as defaultDocker
116
130
  strictSchema: true, // true = reliable json output (default), false = live streaming (may crash - see bold-meadow-11)
117
131
  logLevel: 'normal',
118
- // Auto-update settings
132
+ // Automatic update notification cache
119
133
  autoCheckUpdates: true, // Check npm registry for newer versions
120
- lastUpdateCheckAt: null, // Unix timestamp of last check (null = never checked)
121
- lastSeenVersion: null, // Don't re-prompt for same version
134
+ lastUpdateCheckAt: null, // Unix timestamp of last automatic attempt (null = never checked)
135
+ lastSeenVersion: null, // Compatibility key containing the cached valid npm-latest version
136
+ lastUpdateCheckClaim: null, // Opaque ownership token for an in-flight automatic refresh
122
137
  // Claude command - customize how to invoke Claude CLI (default: 'claude')
123
138
  // Example: 'ccr code' for claude-code-router integration
124
139
  claudeCommand: 'claude',
@@ -260,48 +275,204 @@ function normalizeLoadedSettings(parsed) {
260
275
  if (parsed.providerSettings) {
261
276
  normalized.providerSettings = normalizeProviderSettings(parsed.providerSettings);
262
277
  }
278
+ if (
279
+ normalized.autoCheckUpdates === false ||
280
+ typeof normalized.lastUpdateCheckClaim !== 'string' ||
281
+ normalized.lastUpdateCheckClaim.trim().length === 0
282
+ ) {
283
+ normalized.lastUpdateCheckClaim = null;
284
+ }
263
285
  return normalized;
264
286
  }
265
287
 
288
+ function resolvedDefaultSettings() {
289
+ return {
290
+ ...DEFAULT_SETTINGS,
291
+ providerSettings: mergeProviderSettings(getProviderDefaults()),
292
+ };
293
+ }
294
+
295
+ function mergeLoadedSettings(parsed) {
296
+ const normalized = normalizeLoadedSettings(parsed);
297
+ const merged = { ...resolvedDefaultSettings(), ...normalized };
298
+ merged.defaultProvider =
299
+ normalizeProviderName(merged.defaultProvider) || DEFAULT_SETTINGS.defaultProvider;
300
+ merged.providerSettings = mergeProviderSettings(getProviderDefaults(), normalized.providerSettings);
301
+ if (
302
+ merged.autoCheckUpdates === false ||
303
+ typeof merged.lastUpdateCheckClaim !== 'string' ||
304
+ merged.lastUpdateCheckClaim.trim().length === 0
305
+ ) {
306
+ merged.lastUpdateCheckClaim = null;
307
+ }
308
+ return applyLegacyModelBounds(merged);
309
+ }
310
+
266
311
  /**
267
312
  * Load settings from disk, merging with defaults
268
313
  */
269
- function loadSettings() {
314
+ function loadSettings(options = {}) {
270
315
  const settingsFile = getSettingsFile();
271
316
  if (!fs.existsSync(settingsFile)) {
272
- // Return a copy with resolved providerSettings
317
+ return resolvedDefaultSettings();
318
+ }
319
+ try {
320
+ return mergeLoadedSettings(JSON.parse(fs.readFileSync(settingsFile, 'utf8')));
321
+ } catch {
322
+ if (!options.silent) console.error('Warning: Could not load settings, using defaults');
323
+ return resolvedDefaultSettings();
324
+ }
325
+ }
326
+
327
+ function readSettingsForMutation(settingsFile) {
328
+ if (!fs.existsSync(settingsFile)) {
273
329
  return {
274
- ...DEFAULT_SETTINGS,
275
- providerSettings: getProviderDefaults(),
330
+ settings: resolvedDefaultSettings(),
331
+ requiresClaimInvalidation: false,
332
+ requiresRecovery: false,
276
333
  };
277
334
  }
335
+ let parsed;
278
336
  try {
279
- const data = fs.readFileSync(settingsFile, 'utf8');
280
- const parsed = normalizeLoadedSettings(JSON.parse(data));
281
- const merged = { ...DEFAULT_SETTINGS, ...parsed };
282
- merged.defaultProvider =
283
- normalizeProviderName(merged.defaultProvider) || DEFAULT_SETTINGS.defaultProvider;
284
- merged.providerSettings = mergeProviderSettings(getProviderDefaults(), parsed.providerSettings);
285
- return applyLegacyModelBounds(merged);
286
- } catch {
287
- console.error('Warning: Could not load settings, using defaults');
337
+ parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
338
+ } catch (error) {
339
+ if (!(error instanceof SyntaxError)) throw error;
288
340
  return {
289
- ...DEFAULT_SETTINGS,
290
- providerSettings: getProviderDefaults(),
341
+ settings: resolvedDefaultSettings(),
342
+ requiresClaimInvalidation: false,
343
+ requiresRecovery: true,
291
344
  };
292
345
  }
346
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
347
+ return {
348
+ settings: resolvedDefaultSettings(),
349
+ requiresClaimInvalidation: false,
350
+ requiresRecovery: true,
351
+ };
352
+ }
353
+
354
+
355
+ const updatesDisabled = parsed.autoCheckUpdates === false || parsed.updatePolicy === 'off';
356
+ return {
357
+ settings: mergeLoadedSettings(parsed),
358
+ requiresClaimInvalidation: updatesDisabled && parsed.lastUpdateCheckClaim !== null,
359
+ requiresRecovery: false,
360
+ };
361
+ }
362
+
363
+ function getAtomicSettingsMode(settingsFile) {
364
+ try {
365
+ return fs.statSync(settingsFile).mode & 0o600;
366
+ } catch (error) {
367
+ if (error.code === 'ENOENT') return 0o600;
368
+ throw error;
369
+ }
370
+ }
371
+
372
+ function atomicWriteSettings(settingsFile, settings) {
373
+ const dir = path.dirname(settingsFile);
374
+ const temporaryFile = path.join(
375
+ dir,
376
+ `.${path.basename(settingsFile)}.${process.pid}.${crypto.randomUUID()}.tmp`
377
+ );
378
+
379
+ let operationError;
380
+ try {
381
+ fs.writeFileSync(temporaryFile, JSON.stringify(settings, null, 2), {
382
+ encoding: 'utf8',
383
+ flag: 'wx',
384
+ mode: getAtomicSettingsMode(settingsFile),
385
+ });
386
+ fs.renameSync(temporaryFile, settingsFile);
387
+ } catch (error) {
388
+ operationError = error;
389
+ }
390
+
391
+ try {
392
+ fs.unlinkSync(temporaryFile);
393
+ } catch (error) {
394
+ if (error.code !== 'ENOENT' && !operationError) operationError = error;
395
+ }
396
+ if (operationError) throw operationError;
397
+ }
398
+
399
+ function acquireSettingsLock(settingsFile, lockfilePath, timeoutMs) {
400
+ const deadline = Date.now() + timeoutMs;
401
+ while (true) {
402
+ try {
403
+ return lockfile.lockSync(settingsFile, {
404
+ lockfilePath,
405
+ realpath: false,
406
+ stale: SETTINGS_LOCK_STALE_MS,
407
+ });
408
+ } catch (error) {
409
+ if (error.code !== 'ELOCKED' || Date.now() >= deadline) throw error;
410
+ Atomics.wait(SETTINGS_LOCK_SLEEP, 0, 0, SETTINGS_LOCK_RETRY_MS);
411
+ }
412
+ }
293
413
  }
294
414
 
295
415
  /**
296
- * Save settings to disk
416
+ * Atomically mutate global settings under the process-shared settings lock.
417
+ * The mutator receives only the freshly re-read, normalized state.
418
+ *
419
+ * @param {(settings: object) => any} mutator
420
+ * @param {object} [options]
421
+ * @returns {any} the mutator's return value
297
422
  */
298
- function saveSettings(settings) {
423
+ function mutateSettings(mutator, options = {}) {
424
+ if (typeof mutator !== 'function') {
425
+ throw new TypeError('Global settings mutation requires a callback');
426
+ }
427
+
299
428
  const settingsFile = getSettingsFile();
300
429
  const dir = path.dirname(settingsFile);
301
- if (!fs.existsSync(dir)) {
430
+ const lockfilePath = `${settingsFile}.lock`;
431
+ let release;
432
+ let result;
433
+ let mutationError;
434
+
435
+ try {
302
436
  fs.mkdirSync(dir, { recursive: true });
437
+ release = acquireSettingsLock(
438
+ settingsFile,
439
+ lockfilePath,
440
+ options.lockTimeoutMs ?? SETTINGS_LOCK_TIMEOUT_MS
441
+ );
442
+
443
+ const { settings, requiresClaimInvalidation, requiresRecovery } =
444
+ readSettingsForMutation(settingsFile);
445
+ const before = JSON.stringify(settings);
446
+ result = mutator(settings);
447
+ if (result && typeof result.then === 'function') {
448
+ throw new TypeError('Global settings mutations must be synchronous');
449
+ }
450
+ if (settings.autoCheckUpdates === false || settings.updatePolicy === 'off') {
451
+ settings.lastUpdateCheckClaim = null;
452
+ }
453
+ if (requiresRecovery || requiresClaimInvalidation || JSON.stringify(settings) !== before) {
454
+ atomicWriteSettings(settingsFile, settings);
455
+ }
456
+ } catch (error) {
457
+ mutationError =
458
+ error instanceof SettingsValidationError
459
+ ? error
460
+ : new Error(`Unable to persist global settings: ${error.message}`, { cause: error });
461
+ } finally {
462
+ if (release) {
463
+ try {
464
+ release();
465
+ } catch (error) {
466
+ if (!mutationError) {
467
+ mutationError = new Error(`Unable to release global settings lock: ${error.message}`, {
468
+ cause: error,
469
+ });
470
+ }
471
+ }
472
+ }
303
473
  }
304
- fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf8');
474
+ if (mutationError) throw mutationError;
475
+ return result;
305
476
  }
306
477
 
307
478
  /**
@@ -537,9 +708,10 @@ function getClaudeCommand() {
537
708
 
538
709
  module.exports = {
539
710
  loadSettings,
540
- saveSettings,
711
+ mutateSettings,
541
712
  validateSetting,
542
713
  coerceValue,
714
+ SettingsValidationError,
543
715
  DEFAULT_SETTINGS,
544
716
  getSettingsFile,
545
717
  settingsFileExists,