@kitkat-vn/kitcode 0.2.0-staging.3

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/bin/kitcode.mjs +1097 -0
  4. package/package.json +41 -0
  5. package/src/api-access.mjs +119 -0
  6. package/src/api.mjs +165 -0
  7. package/src/atomic-file.mjs +85 -0
  8. package/src/break-progress.mjs +87 -0
  9. package/src/claim-bundle.mjs +37 -0
  10. package/src/companion-controls.mjs +40 -0
  11. package/src/companion-electron.mjs +87 -0
  12. package/src/companion-preload.cjs +10 -0
  13. package/src/companion-window.mjs +57 -0
  14. package/src/cors.mjs +51 -0
  15. package/src/equals-ledger.mjs +166 -0
  16. package/src/focus-clock.mjs +86 -0
  17. package/src/fonts/fira-code/OFL.txt +93 -0
  18. package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  19. package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
  20. package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  21. package/src/fonts/vt323/OFL.txt +93 -0
  22. package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
  23. package/src/git.mjs +448 -0
  24. package/src/hook-installers.mjs +397 -0
  25. package/src/hook-prompt.mjs +201 -0
  26. package/src/install-id.mjs +94 -0
  27. package/src/integration-installers.mjs +28 -0
  28. package/src/integration-spec.mjs +398 -0
  29. package/src/log-file.mjs +60 -0
  30. package/src/milestones-reached.mjs +130 -0
  31. package/src/next-break-reminder.mjs +78 -0
  32. package/src/onboarding-electron.mjs +240 -0
  33. package/src/onboarding-platform.mjs +50 -0
  34. package/src/onboarding-preload.cjs +19 -0
  35. package/src/onboarding-window.mjs +874 -0
  36. package/src/open-dashboard.mjs +30 -0
  37. package/src/reward.mjs +311 -0
  38. package/src/runner-installer.mjs +153 -0
  39. package/src/runtime.mjs +1323 -0
  40. package/src/scan-worker.mjs +23 -0
  41. package/src/scanner.mjs +84 -0
  42. package/src/seen-lines.mjs +148 -0
  43. package/src/skill-installer.mjs +76 -0
  44. package/src/source-snapshot.mjs +725 -0
  45. package/src/store.mjs +595 -0
  46. package/src/tracker-instance.mjs +166 -0
  47. package/src/tracker-problems.mjs +30 -0
  48. package/src/tracker-stop.mjs +73 -0
  49. package/src/uninstall.mjs +80 -0
  50. package/src/vibe.mjs +4 -0
  51. package/src/window-guards.mjs +7 -0
  52. package/src/window-page.mjs +49 -0
package/src/store.mjs ADDED
@@ -0,0 +1,595 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {leftoverTempFiles, writeFileAtomically} from './atomic-file.mjs';
6
+ import {addSeenLines, hasSeenLine, NO_SEEN_LINES, readSeenLinesFile, SeenLinesFormatError, writeSeenLinesFile} from './seen-lines.mjs';
7
+ import {lineMapsWrittenOnOneLine} from './source-snapshot.mjs';
8
+
9
+ export const STORE_DIR = path.join(os.homedir(), '.kitcode');
10
+ export const STORE_PATH = path.join(STORE_DIR, 'state.json');
11
+ export const STATE_LOCK_PATH = `${STORE_PATH}.lock`;
12
+ // Every line KitCode has seen (seen-lines.mjs; docs/security.md, "The seen-lines file"). Written only
13
+ // under the state lock, and before state.json in the same update.
14
+ export const SEEN_LINES_PATH = path.join(STORE_DIR, 'seen-lines');
15
+
16
+ // state.json is the user's only copy of their progress, and it names their projects: only they
17
+ // may read it.
18
+ const STATE_FILE_MODE = 0o600;
19
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
20
+ const LOCK_RETRY_MS = 25;
21
+ // A lock whose holder is a process on this machine that no longer exists is stale at once. One
22
+ // whose holder cannot be checked (another machine, or a pid reused since) is stale after this long;
23
+ // no update holds the lock for more than a few seconds.
24
+ const STALE_LOCK_MS = 10 * 60 * 1000;
25
+ // A lock file is written right after it is created. One that is still unreadable after this long
26
+ // was left by a process that died in between.
27
+ const UNREADABLE_LOCK_MS = 5000;
28
+ const STATE_LOCK_BREAK_PATH = `${STATE_LOCK_PATH}.break`;
29
+ const STALE_BREAK_MS = 5000;
30
+
31
+ const KEEP_PROGRESS_ADVICE = 'KitCode has left the file as it is, because it holds your progress. ' +
32
+ 'Repair it or restore it from a backup; to start again with no progress, move it aside.';
33
+
34
+ // A file of the user's progress that KitCode cannot read or count against: state.json unless
35
+ // `filePath` names another. Every command that meets one stops, and nothing is written.
36
+ export class StateFileError extends Error {
37
+ constructor(problem, cause, {filePath = STORE_PATH, advice = KEEP_PROGRESS_ADVICE} = {}) {
38
+ super(`KitCode cannot read ${filePath}: ${problem}. ${advice}`, {cause});
39
+ this.name = 'StateFileError';
40
+ this.filePath = filePath;
41
+ }
42
+ }
43
+
44
+ export class StateLockError extends Error {
45
+ constructor(message, cause) {
46
+ super(message, {cause});
47
+ this.name = 'StateLockError';
48
+ }
49
+ }
50
+
51
+ function createEmptyState() {
52
+ return {
53
+ version: 4,
54
+ projects: {},
55
+ };
56
+ }
57
+
58
+ function isObject(value) {
59
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
60
+ }
61
+
62
+ function normalizeCountedEntries(entries) {
63
+ const normalized = {};
64
+
65
+ if (!entries || typeof entries !== 'object') {
66
+ return normalized;
67
+ }
68
+
69
+ for (const [id, entry] of Object.entries(entries)) {
70
+ normalized[id] = {
71
+ equals: Number(entry?.equals) || 0,
72
+ counted_at: entry?.counted_at ?? null,
73
+ };
74
+ }
75
+
76
+ return normalized;
77
+ }
78
+
79
+ function normalizeEqualsLedger(ledger) {
80
+ if (!ledger || typeof ledger !== 'object') {
81
+ return null;
82
+ }
83
+
84
+ const projects = {};
85
+
86
+ if (ledger.projects && typeof ledger.projects === 'object') {
87
+ for (const [projectId, projectLedger] of Object.entries(ledger.projects)) {
88
+ if (!projectLedger || typeof projectLedger !== 'object') {
89
+ continue;
90
+ }
91
+
92
+ const normalizedProjectId = String(projectLedger.project_id || projectId);
93
+ const countedCommits = normalizeCountedEntries(projectLedger.counted_commits);
94
+ const countedBatches = normalizeCountedEntries(projectLedger.counted_batches);
95
+ const totalEquals = Number(projectLedger.total_equals)
96
+ || Object.values(countedCommits).reduce((sum, entry) => sum + entry.equals, 0)
97
+ + Object.values(countedBatches).reduce((sum, entry) => sum + entry.equals, 0);
98
+
99
+ projects[normalizedProjectId] = {
100
+ project_id: normalizedProjectId,
101
+ repo_root: typeof projectLedger.repo_root === 'string' ? projectLedger.repo_root : null,
102
+ source_type: projectLedger.source_type === 'vibe' ? 'vibe' : 'git',
103
+ total_equals: totalEquals,
104
+ counted_commits: countedCommits,
105
+ counted_batches: countedBatches,
106
+ first_counted_at: projectLedger.first_counted_at ?? null,
107
+ last_updated_at: projectLedger.last_updated_at ?? null,
108
+ };
109
+ }
110
+ }
111
+
112
+ return {
113
+ total_equals: Object.values(projects).reduce((sum, project) => sum + project.total_equals, 0),
114
+ projects,
115
+ earned_tiers: Array.isArray(ledger.earned_tiers) ? ledger.earned_tiers : [],
116
+ first_counted_at: ledger.first_counted_at ?? null,
117
+ last_updated_at: ledger.last_updated_at ?? null,
118
+ };
119
+ }
120
+
121
+ function normalizeOnboarding(onboarding) {
122
+ if (!onboarding || typeof onboarding !== 'object') {
123
+ return null;
124
+ }
125
+
126
+ return {
127
+ completed: onboarding.completed === true,
128
+ autoTrack: onboarding.autoTrack === true,
129
+ companionView: 'mini',
130
+ };
131
+ }
132
+
133
+ // What callers read. Writes never go through this view: they change the file's own object (see
134
+ // updateState), so fields this version does not know are kept. Since 2026-09-25 that includes
135
+ // rewardSettings: an older KitCode's time target, which nothing reads (reward.mjs REWARD_SECONDS).
136
+ function normalizeState(state) {
137
+ const ledger = normalizeEqualsLedger(state.equalsLedger);
138
+ const onboarding = normalizeOnboarding(state.onboarding);
139
+
140
+ const nextState = {...state};
141
+
142
+ if (ledger) {
143
+ nextState.equalsLedger = ledger;
144
+ } else {
145
+ delete nextState.equalsLedger;
146
+ }
147
+
148
+ if (onboarding) {
149
+ nextState.onboarding = onboarding;
150
+ } else {
151
+ delete nextState.onboarding;
152
+ }
153
+
154
+ return nextState;
155
+ }
156
+
157
+ function describeReadError(error) {
158
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
159
+ return `permission denied (${error.code})`;
160
+ }
161
+
162
+ if (error.code === 'EISDIR') {
163
+ return 'it is a folder, not a file (EISDIR)';
164
+ }
165
+
166
+ return error.code ? `${error.message}` : String(error);
167
+ }
168
+
169
+ // The file's text, or null when there is no file yet. Any other failure is an error: an
170
+ // unreadable file must never be mistaken for an empty one.
171
+ function readStateText() {
172
+ try {
173
+ return fs.readFileSync(STORE_PATH, 'utf8');
174
+ } catch (error) {
175
+ if (error.code === 'ENOENT') {
176
+ return null;
177
+ }
178
+
179
+ throw new StateFileError(describeReadError(error), error);
180
+ }
181
+ }
182
+
183
+ function parseState(text) {
184
+ if (text.trim() === '') {
185
+ throw new StateFileError('it is empty');
186
+ }
187
+
188
+ let state;
189
+
190
+ try {
191
+ state = JSON.parse(text);
192
+ } catch (error) {
193
+ throw new StateFileError(`it is not valid JSON (${error.message})`, error);
194
+ }
195
+
196
+ if (!isObject(state)) {
197
+ throw new StateFileError('it does not hold a KitCode state object');
198
+ }
199
+
200
+ return state;
201
+ }
202
+
203
+ // JSON as JSON.stringify(value, null, 2) writes it, two spaces a level, except that each object in
204
+ // `oneLine` is written on one line.
205
+ function stringify(value, indent, oneLine, key) {
206
+ if (typeof value?.toJSON === 'function') {
207
+ value = value.toJSON(key);
208
+ }
209
+
210
+ if (value === null || typeof value !== 'object' || oneLine.has(value)
211
+ || value instanceof Number || value instanceof String || value instanceof Boolean) {
212
+ return JSON.stringify(value);
213
+ }
214
+
215
+ const inner = `${indent} `;
216
+
217
+ if (Array.isArray(value)) {
218
+ const items = value.map((item, index) => `${inner}${stringify(item, inner, oneLine, String(index)) ?? 'null'}`);
219
+ return items.length === 0 ? '[]' : `[\n${items.join(',\n')}\n${indent}]`;
220
+ }
221
+
222
+ const members = [];
223
+
224
+ for (const name of Object.keys(value)) {
225
+ const text = stringify(value[name], inner, oneLine, name);
226
+
227
+ if (text !== undefined) {
228
+ members.push(`${inner}${JSON.stringify(name)}: ${text}`);
229
+ }
230
+ }
231
+
232
+ return members.length === 0 ? '{}' : `{\n${members.join(',\n')}\n${indent}}`;
233
+ }
234
+
235
+ // Two spaces a level, as it has always been written, except that the line map of each file of a
236
+ // snapshot in this version's format is written on one line: a line map is thousands of keys, and on
237
+ // lines of their own they made up most of the file (docs/lessons-learned.md F4).
238
+ function serializeState(state) {
239
+ const oneLine = lineMapsWrittenOnOneLine(state);
240
+ return `${oneLine.size === 0 ? JSON.stringify(state, null, 2) : stringify(state, '', oneLine, '')}\n`;
241
+ }
242
+
243
+ // The update in progress in this process, if any: the state as read once under the lock, whether
244
+ // anything has changed it, the lines it has seen, and seen-lines as read once under the lock.
245
+ let transaction = null;
246
+
247
+ function transactionState() {
248
+ if (!transaction.loaded) {
249
+ transaction.text = readStateText();
250
+ transaction.state = transaction.text === null ? createEmptyState() : parseState(transaction.text);
251
+ transaction.loaded = true;
252
+ }
253
+
254
+ return transaction.state;
255
+ }
256
+
257
+ // Reads state.json. It never writes: a missing file reads as empty progress, and a file that
258
+ // cannot be read or parsed throws StateFileError. Inside withStateLock it returns the state that
259
+ // update is working on, read once. Treat the result as read-only.
260
+ export function loadState() {
261
+ if (transaction) {
262
+ return normalizeState(transactionState());
263
+ }
264
+
265
+ const text = readStateText();
266
+ return normalizeState(text === null ? createEmptyState() : parseState(text));
267
+ }
268
+
269
+ // Runs `run` holding the state lock. The first loadState or updateState inside reads the file,
270
+ // once. If an updateState changed it, the file is written once at the end, atomically and 0600;
271
+ // if `run` throws, nothing is written. Calls nest: an inner one joins the outer.
272
+ //
273
+ // Lines recorded with recordSeenLines are written to seen-lines first, so that it never lacks a line
274
+ // a snapshot in state.json holds: a crash between the two writes leaves it holding more, never less.
275
+ // If state.json then cannot be written, seen-lines is put back as it was.
276
+ export function withStateLock(run, {lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS} = {}) {
277
+ if (transaction) {
278
+ return run();
279
+ }
280
+
281
+ const release = acquireStateLock(lockTimeoutMs);
282
+ transaction = {loaded: false, text: null, state: null, changed: false, seen: new Set(), seeded: false, seenFile: undefined};
283
+
284
+ try {
285
+ const result = run();
286
+ const seenBefore = transaction.seen.size > 0 ? writeSeenLines(transaction.seen) : undefined;
287
+
288
+ try {
289
+ if (transaction.changed) {
290
+ const next = serializeState(transaction.state);
291
+
292
+ if (next !== (transaction.text ?? serializeState(createEmptyState()))) {
293
+ writeState(next);
294
+ }
295
+ }
296
+ } catch (error) {
297
+ if (seenBefore !== undefined) {
298
+ restoreSeenLines(seenBefore);
299
+ }
300
+
301
+ throw error;
302
+ }
303
+
304
+ return result;
305
+ } finally {
306
+ transaction = null;
307
+ release();
308
+ }
309
+ }
310
+
311
+ // Changes state.json under the lock. `mutate` gets the file's own object, not the normalized view
312
+ // loadState returns: change only what you mean to, and everything else, including fields this
313
+ // version does not know, is written back as it was.
314
+ export function updateState(mutate, options) {
315
+ if (!transaction) {
316
+ return withStateLock(() => updateState(mutate), options);
317
+ }
318
+
319
+ const result = mutate(transactionState());
320
+ transaction.changed = true;
321
+ return result;
322
+ }
323
+
324
+ function writeState(text) {
325
+ // Under the lock no other process is writing, so these belong to writers that died mid-write.
326
+ for (const tempPath of leftoverTempFiles(STORE_PATH)) {
327
+ fs.rmSync(tempPath, {force: true});
328
+ }
329
+
330
+ writeFileAtomically(STORE_PATH, text, {mode: STATE_FILE_MODE});
331
+ }
332
+
333
+ // Records lines as seen, by their snapshot keys: in the update in progress, or in one of their own.
334
+ // They reach seen-lines when the update ends, before state.json. `seeding` says they are the lines of
335
+ // every snapshot, from which seen-lines is made when there is none (runtime.mjs migrateState).
336
+ export function recordSeenLines(keys, {seeding = false} = {}) {
337
+ if (!transaction) {
338
+ return withStateLock(() => recordSeenLines(keys, {seeding}));
339
+ }
340
+
341
+ for (const key of keys) {
342
+ transaction.seen.add(key);
343
+ }
344
+
345
+ transaction.seeded ||= seeding;
346
+ return undefined;
347
+ }
348
+
349
+ // Whether seen-lines has been made: it exists, or the update in progress is making it.
350
+ export function seenLinesExist() {
351
+ return Boolean(transaction?.seeded) || fs.existsSync(SEEN_LINES_PATH);
352
+ }
353
+
354
+ // The lines in seen-lines, or null when there is no file yet. One that cannot be read, or is not a
355
+ // seen-lines file, throws a StateFileError naming it, and is left as it is.
356
+ export function loadSeenLines() {
357
+ try {
358
+ return readSeenLinesFile(SEEN_LINES_PATH);
359
+ } catch (error) {
360
+ const problem = error instanceof SeenLinesFormatError ? error.message : describeReadError(error);
361
+
362
+ throw new StateFileError(problem, error, {
363
+ filePath: SEEN_LINES_PATH,
364
+ advice: 'KitCode has left the file as it is. Restore it from a backup, or move it aside: KitCode then makes it again from the snapshots in state.json.',
365
+ });
366
+ }
367
+ }
368
+
369
+ // seen-lines as the update in progress read it, once: null when there is no file.
370
+ function transactionSeenLines() {
371
+ if (transaction.seenFile === undefined) {
372
+ transaction.seenFile = loadSeenLines();
373
+ }
374
+
375
+ return transaction.seenFile;
376
+ }
377
+
378
+ // Whether a line, by its snapshot key, has been seen before: it is in seen-lines, or this update has
379
+ // recorded it. Counting rules that credit a line once per user, ever, ask it (source-snapshot.mjs
380
+ // applySourceScan). Only inside withStateLock, where nothing else writes seen-lines.
381
+ export function isLineSeen(key) {
382
+ if (!transaction) {
383
+ throw new Error('isLineSeen needs the state lock');
384
+ }
385
+
386
+ return transaction.seen.has(key) || hasSeenLine(transactionSeenLines() ?? NO_SEEN_LINES, key);
387
+ }
388
+
389
+ // Adds `keys` to seen-lines. Returns what the file held before, null when there was none, or
390
+ // undefined when every line was there already and nothing was written.
391
+ function writeSeenLines(keys) {
392
+ const before = transactionSeenLines();
393
+ const lines = addSeenLines(before ?? NO_SEEN_LINES, keys);
394
+
395
+ if (before !== null && lines === before) {
396
+ return undefined;
397
+ }
398
+
399
+ writeSeenLinesFile(SEEN_LINES_PATH, lines);
400
+ return before;
401
+ }
402
+
403
+ // Puts seen-lines back as it was before an update that could not finish. If that fails too, it keeps
404
+ // the lines the update added: more than state.json needs, never less.
405
+ function restoreSeenLines(before) {
406
+ try {
407
+ if (before === null) {
408
+ fs.rmSync(SEEN_LINES_PATH, {force: true});
409
+ } else {
410
+ writeSeenLinesFile(SEEN_LINES_PATH, before);
411
+ }
412
+ } catch {
413
+ // The error that stopped the update is the one to report.
414
+ }
415
+ }
416
+
417
+ function sleepSync(ms) {
418
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
419
+ }
420
+
421
+ function isProcessAlive(pid) {
422
+ try {
423
+ process.kill(pid, 0);
424
+ return true;
425
+ } catch (error) {
426
+ return error.code === 'EPERM';
427
+ }
428
+ }
429
+
430
+ function inspectLock(lockPath) {
431
+ let stat;
432
+
433
+ try {
434
+ stat = fs.statSync(lockPath);
435
+ } catch (error) {
436
+ if (error.code === 'ENOENT') {
437
+ return null;
438
+ }
439
+
440
+ throw error;
441
+ }
442
+
443
+ let holder = null;
444
+
445
+ try {
446
+ holder = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
447
+ } catch {
448
+ holder = null;
449
+ }
450
+
451
+ return {holder: isObject(holder) ? holder : null, ino: stat.ino, mtimeMs: stat.mtimeMs};
452
+ }
453
+
454
+ function isStale(lock) {
455
+ const age = Date.now() - lock.mtimeMs;
456
+
457
+ if (!lock.holder) {
458
+ return age > UNREADABLE_LOCK_MS;
459
+ }
460
+
461
+ const {pid, host} = lock.holder;
462
+
463
+ if (host === os.hostname() && Number.isInteger(pid) && pid > 0 && !isProcessAlive(pid)) {
464
+ return true;
465
+ }
466
+
467
+ return age > STALE_LOCK_MS;
468
+ }
469
+
470
+ function tryCreate(lockPath, content) {
471
+ let fd;
472
+
473
+ try {
474
+ fd = fs.openSync(lockPath, 'wx', STATE_FILE_MODE);
475
+ } catch (error) {
476
+ if (error.code === 'EEXIST') {
477
+ return false;
478
+ }
479
+
480
+ throw error;
481
+ }
482
+
483
+ try {
484
+ fs.writeFileSync(fd, content);
485
+ } catch (error) {
486
+ fs.closeSync(fd);
487
+ fs.rmSync(lockPath, {force: true});
488
+ throw error;
489
+ }
490
+
491
+ fs.closeSync(fd);
492
+ return true;
493
+ }
494
+
495
+ // Removes a stale lock. Breakers take turns through a second lock, so two of them cannot both
496
+ // remove the stale lock and then one remove the lock the other has just taken.
497
+ function breakStaleLock(examined) {
498
+ if (!tryCreate(STATE_LOCK_BREAK_PATH, String(process.pid))) {
499
+ const breaker = inspectLock(STATE_LOCK_BREAK_PATH);
500
+
501
+ // It is held for microseconds; one this old was left by a breaker that died.
502
+ if (breaker && Date.now() - breaker.mtimeMs > STALE_BREAK_MS) {
503
+ fs.rmSync(STATE_LOCK_BREAK_PATH, {force: true});
504
+ }
505
+
506
+ return false;
507
+ }
508
+
509
+ try {
510
+ const current = inspectLock(STATE_LOCK_PATH);
511
+
512
+ if (current && current.ino === examined.ino && current.mtimeMs === examined.mtimeMs && isStale(current)) {
513
+ fs.rmSync(STATE_LOCK_PATH, {force: true});
514
+ return true;
515
+ }
516
+
517
+ return false;
518
+ } finally {
519
+ fs.rmSync(STATE_LOCK_BREAK_PATH, {force: true});
520
+ }
521
+ }
522
+
523
+ function describeHolder(lock) {
524
+ if (!lock?.holder) {
525
+ return 'another KitCode process';
526
+ }
527
+
528
+ const since = lock.holder.since ? ` since ${lock.holder.since}` : '';
529
+ return `KitCode process ${lock.holder.pid} on ${lock.holder.host}${since}`;
530
+ }
531
+
532
+ function acquireStateLock(timeoutMs) {
533
+ const token = crypto.randomUUID();
534
+ const content = JSON.stringify({pid: process.pid, host: os.hostname(), since: new Date().toISOString(), token});
535
+ const deadline = Date.now() + timeoutMs;
536
+
537
+ try {
538
+ fs.mkdirSync(STORE_DIR, {recursive: true});
539
+
540
+ for (;;) {
541
+ if (tryCreate(STATE_LOCK_PATH, content)) {
542
+ return () => releaseStateLock(token);
543
+ }
544
+
545
+ const lock = inspectLock(STATE_LOCK_PATH);
546
+
547
+ if (lock && isStale(lock) && breakStaleLock(lock)) {
548
+ continue;
549
+ }
550
+
551
+ if (Date.now() >= deadline) {
552
+ throw new StateLockError(
553
+ `${STORE_PATH} is busy: ${describeHolder(lock)} is changing it. Try again in a moment. ` +
554
+ `If no KitCode process is running, delete ${STATE_LOCK_PATH}.`,
555
+ );
556
+ }
557
+
558
+ sleepSync(LOCK_RETRY_MS);
559
+ }
560
+ } catch (error) {
561
+ if (error instanceof StateLockError) {
562
+ throw error;
563
+ }
564
+
565
+ throw new StateLockError(`KitCode could not lock ${STORE_PATH}: ${error.message}`, error);
566
+ }
567
+ }
568
+
569
+ function releaseStateLock(token) {
570
+ try {
571
+ if (inspectLock(STATE_LOCK_PATH)?.holder?.token === token) {
572
+ fs.rmSync(STATE_LOCK_PATH, {force: true});
573
+ }
574
+ } catch {
575
+ // A lock that cannot be removed is broken by the next writer once this process has exited.
576
+ }
577
+ }
578
+
579
+ export function onboardingPreferences() {
580
+ return normalizeOnboarding(loadState().onboarding) ?? {
581
+ completed: false,
582
+ autoTrack: true,
583
+ companionView: 'mini',
584
+ };
585
+ }
586
+
587
+ export function saveOnboardingPreferences(preferences) {
588
+ const next = normalizeOnboarding({...preferences, completed: preferences?.completed !== false});
589
+
590
+ updateState((state) => {
591
+ state.onboarding = {...(isObject(state.onboarding) ? state.onboarding : {}), ...next};
592
+ });
593
+
594
+ return next;
595
+ }