@kitkat-vn/kitcode 0.2.0-staging.10

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/bin/kitcode.mjs +1266 -0
  4. package/package.json +41 -0
  5. package/src/api-access.mjs +119 -0
  6. package/src/api.mjs +175 -0
  7. package/src/app-identity.mjs +29 -0
  8. package/src/assets/kitcode-icon.png +0 -0
  9. package/src/atomic-file.mjs +85 -0
  10. package/src/break-progress.mjs +87 -0
  11. package/src/claim-bundle.mjs +38 -0
  12. package/src/companion-controls.mjs +40 -0
  13. package/src/companion-electron.mjs +85 -0
  14. package/src/companion-preload.cjs +10 -0
  15. package/src/companion-window-options.mjs +16 -0
  16. package/src/companion-window.mjs +59 -0
  17. package/src/cors.mjs +51 -0
  18. package/src/diagnostics.mjs +872 -0
  19. package/src/equals-ledger.mjs +166 -0
  20. package/src/focus-clock.mjs +86 -0
  21. package/src/fonts/fira-code/OFL.txt +93 -0
  22. package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  23. package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
  24. package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  25. package/src/fonts/vt323/OFL.txt +93 -0
  26. package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
  27. package/src/git.mjs +488 -0
  28. package/src/hook-installers.mjs +397 -0
  29. package/src/hook-prompt.mjs +217 -0
  30. package/src/install-id.mjs +94 -0
  31. package/src/integration-installers.mjs +28 -0
  32. package/src/integration-spec.mjs +410 -0
  33. package/src/log-file.mjs +60 -0
  34. package/src/milestones-reached.mjs +133 -0
  35. package/src/next-break-reminder.mjs +78 -0
  36. package/src/onboarding-electron.mjs +252 -0
  37. package/src/onboarding-platform.mjs +50 -0
  38. package/src/onboarding-preload.cjs +19 -0
  39. package/src/onboarding-window.mjs +914 -0
  40. package/src/open-dashboard.mjs +30 -0
  41. package/src/reward.mjs +274 -0
  42. package/src/runner-installer.mjs +153 -0
  43. package/src/runtime.mjs +1340 -0
  44. package/src/scan-worker.mjs +23 -0
  45. package/src/scanner.mjs +84 -0
  46. package/src/seen-lines.mjs +148 -0
  47. package/src/skill-installer.mjs +76 -0
  48. package/src/source-snapshot.mjs +733 -0
  49. package/src/store.mjs +595 -0
  50. package/src/tracker-instance.mjs +166 -0
  51. package/src/tracker-problems.mjs +31 -0
  52. package/src/tracker-stop.mjs +73 -0
  53. package/src/uninstall.mjs +80 -0
  54. package/src/vibe.mjs +4 -0
  55. package/src/window-guards.mjs +7 -0
  56. package/src/window-page.mjs +49 -0
@@ -0,0 +1,1266 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import {spawn} from 'node:child_process';
5
+ import {createRequire} from 'node:module';
6
+ import readline from 'node:readline/promises';
7
+ import {DASHBOARD_URL, openDashboard} from '../src/open-dashboard.mjs';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import {fileURLToPath} from 'node:url';
11
+ import {createServer} from '../src/api.mjs';
12
+ import {isLoopbackAddress} from '../src/api-access.mjs';
13
+ import {
14
+ configProblemCode,
15
+ describeOutcome,
16
+ diagnosticsEnabled,
17
+ diagnosticsStatus,
18
+ errorCodeOf,
19
+ errorNameOf,
20
+ projectRef,
21
+ recordEvent,
22
+ sendQueued,
23
+ startDiagnosticsLoop,
24
+ sweepHookMarkers,
25
+ } from '../src/diagnostics.mjs';
26
+ import {manualHookSnippet} from '../src/hook-installers.mjs';
27
+ import {runPromptHook} from '../src/hook-prompt.mjs';
28
+ import {installIntegration, integrationStatus, uninstallIntegration} from '../src/integration-installers.mjs';
29
+ import {ensureInstallId} from '../src/install-id.mjs';
30
+ import {milestoneProgressPercent} from '../src/break-progress.mjs';
31
+ import {safeProblemCode} from '../src/tracker-problems.mjs';
32
+ import {getDiskRewardSummary} from '../src/reward.mjs';
33
+ import {
34
+ createRuntime,
35
+ DEFAULT_HOST,
36
+ DEFAULT_PORT,
37
+ listProjects,
38
+ prepareState,
39
+ registerProject,
40
+ removeProject,
41
+ startWatchers,
42
+ } from '../src/runtime.mjs';
43
+ import {loadState, onboardingPreferences, StateFileError, StateLockError} from '../src/store.mjs';
44
+ import {createLog} from '../src/log-file.mjs';
45
+ import {
46
+ claimTracker,
47
+ findRunningTracker,
48
+ isProcessAlive,
49
+ readTrackerRecord,
50
+ releaseTracker,
51
+ TRACKER_LOG_PATH,
52
+ trackerHealth,
53
+ updateTrackerRecord,
54
+ } from '../src/tracker-instance.mjs';
55
+ import {askTrackerToStop, watchForStopRequest} from '../src/tracker-stop.mjs';
56
+ import {normalizeSetupPlatform} from '../src/onboarding-platform.mjs';
57
+ import {uninstallKitCode} from '../src/uninstall.mjs';
58
+
59
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
60
+ const require = createRequire(import.meta.url);
61
+ const VERSION = require(path.join(PACKAGE_ROOT, 'package.json')).version;
62
+ const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
63
+ const COLOR = {
64
+ reset: '\x1b[0m',
65
+ bold: '\x1b[1m',
66
+ dim: '\x1b[2m',
67
+ primary: '\x1b[31m',
68
+ cyan: '\x1b[36m',
69
+ yellow: '\x1b[33m',
70
+ underline: '\x1b[4m',
71
+ };
72
+
73
+ function parseBooleanEnv(value) {
74
+ return value === '1' || value === 'true' || value === 'yes';
75
+ }
76
+
77
+ function colorize(value, ...styles) {
78
+ if (!USE_COLOR) {
79
+ return value;
80
+ }
81
+
82
+ return `${styles.join('')}${value}${COLOR.reset}`;
83
+ }
84
+
85
+ function trackerAllowedOrigins() {
86
+ const origins = new Set(
87
+ String(process.env.KITCODE_ALLOWED_ORIGINS ?? '')
88
+ .split(',')
89
+ .map((origin) => origin.trim())
90
+ .filter(Boolean),
91
+ );
92
+
93
+ try {
94
+ origins.add(new URL(DASHBOARD_URL).origin);
95
+ } catch {
96
+ }
97
+
98
+ return [...origins].join(',');
99
+ }
100
+
101
+ function trackerChildEnvironment(overrides = {}) {
102
+ return {
103
+ ...process.env,
104
+ KITCODE_ALLOWED_ORIGINS: trackerAllowedOrigins(),
105
+ KITCODE_DAEMON: '1',
106
+ KITCODE_NO_OPEN: '1',
107
+ NO_COLOR: process.env.NO_COLOR ?? '1',
108
+ ...overrides,
109
+ };
110
+ }
111
+
112
+ function createStatusReporter() {
113
+ const frames = ['-', '\\', '|', '/'];
114
+ const useSpinner = process.stdout.isTTY && !process.env.CI;
115
+ let frameIndex = 0;
116
+ let message = '';
117
+ let timer = null;
118
+ let lastPlainMessage = '';
119
+
120
+ const clear = () => {
121
+ if (useSpinner && message) {
122
+ process.stdout.write('\r\x1b[2K');
123
+ }
124
+ };
125
+
126
+ const render = () => {
127
+ if (!useSpinner || !message) {
128
+ return;
129
+ }
130
+
131
+ const frame = colorize(frames[frameIndex % frames.length], COLOR.cyan);
132
+ frameIndex += 1;
133
+ process.stdout.write(`\r${frame} ${message}`);
134
+ };
135
+
136
+ return {
137
+ set(nextMessage) {
138
+ if (!nextMessage || nextMessage === message) {
139
+ return;
140
+ }
141
+
142
+ message = nextMessage;
143
+
144
+ if (!useSpinner) {
145
+ if (lastPlainMessage !== nextMessage) {
146
+ console.log(nextMessage);
147
+ lastPlainMessage = nextMessage;
148
+ }
149
+
150
+ return;
151
+ }
152
+
153
+ render();
154
+ timer ??= setInterval(render, 120);
155
+ },
156
+ stop() {
157
+ if (timer) {
158
+ clearInterval(timer);
159
+ timer = null;
160
+ }
161
+
162
+ clear();
163
+ message = '';
164
+ },
165
+ };
166
+ }
167
+
168
+ function parseArgs(argv) {
169
+ const firstArg = argv[2];
170
+ const command = !firstArg || (
171
+ firstArg.startsWith('-') &&
172
+ firstArg !== '--help' &&
173
+ firstArg !== '-h' &&
174
+ firstArg !== '--version' &&
175
+ firstArg !== '-v'
176
+ ) ? 'help' : firstArg;
177
+ const options = {
178
+ command,
179
+ subcommand: argv[3],
180
+ host: DEFAULT_HOST,
181
+ port: DEFAULT_PORT,
182
+ source: undefined,
183
+ tier: undefined,
184
+ openDashboard: !parseBooleanEnv(process.env.KITCODE_NO_OPEN),
185
+ yes: false,
186
+ setupPlatform: undefined,
187
+ allowNonLoopback: false,
188
+ };
189
+
190
+ if (options.command === '--help' || options.command === '-h') {
191
+ options.command = 'help';
192
+ } else if (options.command === '--version' || options.command === '-v') {
193
+ options.command = 'version';
194
+ }
195
+
196
+ for (let index = command === firstArg ? 3 : 2; index < argv.length; index += 1) {
197
+ const arg = argv[index];
198
+ const next = argv[index + 1];
199
+
200
+ if (arg === '--host' && next) {
201
+ options.host = next;
202
+ index += 1;
203
+ } else if (arg === '--port' && next) {
204
+ options.port = Number(next);
205
+ index += 1;
206
+ } else if (arg === '--source' && next) {
207
+ options.source = next;
208
+ index += 1;
209
+ } else if (arg === '--tier' && next) {
210
+ options.tier = Number(next);
211
+ index += 1;
212
+ } else if (arg === '--platform' && next) {
213
+ options.setupPlatform = next;
214
+ index += 1;
215
+ } else if (arg === '--no-open') {
216
+ options.openDashboard = false;
217
+ } else if (arg === '--allow-non-loopback') {
218
+ options.allowNonLoopback = true;
219
+ } else if (arg === '--yes') {
220
+ options.yes = true;
221
+ } else if (arg === '--version' || arg === '-v') {
222
+ options.command = 'version';
223
+ } else if (arg === '--help' || arg === '-h') {
224
+ options.command = 'help';
225
+ }
226
+ }
227
+
228
+ // --reward-seconds and KITCODE_REWARD_SECONDS are gone, as --reward-equals is: the time target is
229
+ // fixed (operator decision Q5, 2026-09-25; reward.mjs REWARD_SECONDS). Either is ignored if given.
230
+ return options;
231
+ }
232
+
233
+ function printHelp() {
234
+ console.log(`Usage: kitcode [command] [options]
235
+
236
+ Commands:
237
+ add [path] Add a project to KitCode, default current directory
238
+ remove [path] Remove a project and its contribution data
239
+ track Start the KitCode tracker in the background
240
+ untrack Stop the KitCode tracker
241
+ list Show added project totals
242
+ status Show tracker, project, and reward progress
243
+ summary Show compact =, active time, and milestone progress
244
+ awards Show reward and milestone readiness
245
+ dashboard Open the dashboard for the running tracker
246
+ setup Open KitCode preferences and onboarding
247
+ uninstall Remove KitCode hooks, skills, tracker, and local state
248
+ hook prompt --source codex|claude
249
+ Internal prompt hook used by Codex and Claude
250
+ codex on|off|status Install, remove, or inspect the Codex hook and skill
251
+ claude on|off|status Install, remove, or inspect the Claude hook and skill
252
+ diagnostics status|send
253
+ A tester build's diagnostics: what waits, and send it now
254
+
255
+ Options:
256
+ --host <host> Host to bind, default ${DEFAULT_HOST}
257
+ --allow-non-loopback Let track bind a --host other than 127.0.0.1 or localhost
258
+ --port <port> Port to bind, default ${DEFAULT_PORT}
259
+ --no-open Do not open the hosted dashboard automatically
260
+ --platform <os> Preview Welcome chrome: macos|windows|linux (default: host OS)
261
+ --yes Confirm destructive commands without prompting
262
+ -v, --version Print version
263
+ -h, --help Print help
264
+ `);
265
+ }
266
+
267
+ function formatDuration(seconds) {
268
+ const totalSeconds = Math.max(0, Math.floor(Number(seconds) || 0));
269
+
270
+ if (totalSeconds < 60) {
271
+ return `${totalSeconds}s`;
272
+ }
273
+
274
+ const hours = Math.floor(totalSeconds / 3600);
275
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
276
+
277
+ if (hours > 0) {
278
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
279
+ }
280
+
281
+ return `${minutes}m`;
282
+ }
283
+
284
+ // The numbers come from the summary: the same ones the Mini and the dashboard show
285
+ // (docs/reward-rules.md §10). The next milestone follows the ready ones: until 2026-10-01 a ready milestone hid it, and
286
+ // showing a 10–30 % code was what cleared one; with the codes gone nothing does (docs/reward-rules.md §7).
287
+ function printRewardSummary() {
288
+ const reward = getDiskRewardSummary();
289
+ const next = reward.nextBreak;
290
+ const ready = reward.milestones.filter((milestone) => milestone.status === 'ready' && !milestone.redeemed);
291
+
292
+ console.log('KitCode summary');
293
+ console.log(` = counted: ${reward.totalEquals}`);
294
+ console.log(` active time: ${formatDuration(reward.earnedSeconds)}`);
295
+ console.log(` campaign progress: ${reward.progressPercent}%`);
296
+
297
+ if (ready.length > 0) {
298
+ console.log(` ready: ${ready.map((milestone) => `${milestone.percent}%`).join(', ')}`);
299
+ }
300
+
301
+ if (next) {
302
+ console.log(` next milestone: ${next.percent}% (${next.progressPercent}% there)`);
303
+ console.log(` to break: ${next.equalsCopy}, ${next.timeCopy}`);
304
+ }
305
+ }
306
+
307
+ // Each milestone, locked or ready. Since 2026-10-01 none has a code (the operator's decision 6): a tier whose code an
308
+ // earlier version recorded as shown keeps its `redeemed` status in the API, and reads as ready here, a milestone reached;
309
+ // until then it read "code shown", and the 10–30 % tiers "reward".
310
+ function printAwards() {
311
+ const reward = getDiskRewardSummary();
312
+
313
+ console.log('KitCode awards');
314
+ for (const milestone of reward.milestones) {
315
+ const status = milestone.status === 'redeemed' ? 'ready' : milestone.status;
316
+ console.log(` ${milestone.percent}% milestone: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
317
+ }
318
+ }
319
+
320
+ async function printStatus(options) {
321
+ const totals = listProjects();
322
+ const found = await findRunningTracker();
323
+ const running = found?.state === 'running' || await isServerRunning(options);
324
+ const problems = found?.health?.tracking === 'degraded' && Array.isArray(found.health.problems) ? found.health.problems : [];
325
+
326
+ console.log('KitCode status');
327
+ console.log(` tracker: ${running ? 'running' : found?.state === 'unresponsive' ? 'not responding' : 'stopped'}`);
328
+
329
+ if (problems.length > 0) {
330
+ // /api/health may be answered by a process squatting the port, so its codes are sanitized and
331
+ // its counts coerced to a number before they are printed (docs/security.md).
332
+ console.log(` tracker problems: ${problems.map((problem) => {
333
+ const count = Number.isInteger(problem.projects) ? problem.projects : 0;
334
+ return `${safeProblemCode(problem.code)} (${count} project${count === 1 ? '' : 's'})`;
335
+ }).join(', ')}; see ${TRACKER_LOG_PATH}`);
336
+ }
337
+ console.log(` added projects: ${totals.trackingProjects}`);
338
+ printRewardSummary();
339
+ }
340
+
341
+
342
+ // Electron is an optional dependency, and it downloads its binary the first time it is required
343
+ // (node_modules/electron/index.js). Three different failures, each reported as what it is: the
344
+ // package is not installed; the download failed; or the binary is not where Electron says.
345
+ function resolveElectronPath() {
346
+ try {
347
+ require.resolve('electron');
348
+ } catch {
349
+ return {ok: false, reason: 'missing-electron'};
350
+ }
351
+
352
+ let electronPath;
353
+
354
+ try {
355
+ electronPath = require('electron');
356
+ } catch (error) {
357
+ return {ok: false, reason: 'electron-download-failed', detail: error?.message ?? String(error)};
358
+ }
359
+
360
+ if (typeof electronPath !== 'string' || !fs.existsSync(electronPath)) {
361
+ return {ok: false, reason: 'electron-binary-missing', detail: String(electronPath)};
362
+ }
363
+
364
+ return {ok: true, path: electronPath};
365
+ }
366
+
367
+ const ELECTRON_READY_MS = 1500;
368
+
369
+ function openElectronEntry(entryName, environment = {}) {
370
+ return new Promise((resolve) => {
371
+ if (process.env.KITCODE_DRY_ELECTRON === 'fail') {
372
+ resolve({
373
+ ok: false,
374
+ reason: 'missing-electron',
375
+ detail: 'Forced Electron launch failure for checks.',
376
+ });
377
+ return;
378
+ }
379
+
380
+ if (parseBooleanEnv(process.env.KITCODE_DRY_ELECTRON)) {
381
+ resolve({ok: true, reason: 'dry-run'});
382
+ return;
383
+ }
384
+
385
+ const electron = resolveElectronPath();
386
+ if (!electron.ok) {
387
+ resolve(electron);
388
+ return;
389
+ }
390
+
391
+ const electronPath = electron.path;
392
+
393
+ const entryPath = path.resolve(fileURLToPath(new URL(`../src/${entryName}`, import.meta.url)));
394
+ if (!fs.existsSync(entryPath)) {
395
+ resolve({ok: false, reason: 'missing-entry', detail: entryPath});
396
+ return;
397
+ }
398
+
399
+ let settled = false;
400
+ let stderr = '';
401
+ const child = spawn(electronPath, [entryPath], {
402
+ detached: true,
403
+ env: {
404
+ ...process.env,
405
+ KITCODE_NODE_PATH: process.execPath,
406
+ KITCODE_CLI_ENTRY: fileURLToPath(import.meta.url),
407
+ KITCODE_NO_OPEN: '1',
408
+ ...environment,
409
+ },
410
+ stdio: ['ignore', 'ignore', 'pipe'],
411
+ // GUI Electron must remain visible on Windows; windowsHide can suppress the desktop window.
412
+ windowsHide: false,
413
+ });
414
+
415
+ const finish = (result) => {
416
+ if (settled) {
417
+ return;
418
+ }
419
+
420
+ settled = true;
421
+ clearTimeout(timer);
422
+ child.removeAllListeners('exit');
423
+ child.removeAllListeners('error');
424
+ child.stderr?.removeAllListeners('data');
425
+ child.stderr?.resume();
426
+
427
+ if (result.ok) {
428
+ child.unref();
429
+ }
430
+
431
+ resolve(result);
432
+ };
433
+
434
+ child.stderr?.on('data', (chunk) => {
435
+ stderr += String(chunk);
436
+ if (stderr.length > 4000) {
437
+ stderr = stderr.slice(-4000);
438
+ }
439
+ });
440
+
441
+ child.once('error', (error) => {
442
+ finish({ok: false, reason: 'spawn-error', detail: error.message});
443
+ });
444
+
445
+ child.once('exit', (code, signal) => {
446
+ const detail = stderr.trim()
447
+ || `Electron exited before the window opened (code ${code ?? 'null'}, signal ${signal ?? 'null'}).`;
448
+ finish({ok: false, reason: 'exited', detail, code, signal});
449
+ });
450
+
451
+ const timer = setTimeout(() => {
452
+ if (child.exitCode === null && !child.killed) {
453
+ finish({ok: true});
454
+ }
455
+ }, ELECTRON_READY_MS);
456
+ });
457
+ }
458
+
459
+ function printElectronSetupHelp() {
460
+ console.error('KitCode setup needs Electron for the desktop folder picker.');
461
+ console.error("Reinstall the tester build with: npm install -g @kitkat-vn/kitcode@staging");
462
+ console.error('If install succeeded but Electron is still missing, fix npm cache permissions and reinstall.');
463
+ }
464
+
465
+ function printElectronLaunchFailure(result, label) {
466
+ console.error(`${label} could not open.`);
467
+
468
+ if (result?.reason === 'electron-download-failed') {
469
+ console.error(`Electron's binary could not be downloaded: ${result.detail}`);
470
+ console.error('Electron downloads it the first time KitCode opens a window. Check the network connection, and any proxy, then run `kitcode setup` again.');
471
+ return;
472
+ }
473
+
474
+ if (result?.reason === 'electron-binary-missing') {
475
+ console.error(`Electron's binary is missing: Electron points to ${result.detail}, and nothing is there.`);
476
+ console.error("Reinstall the tester build with: npm install -g @kitkat-vn/kitcode@staging");
477
+ return;
478
+ }
479
+
480
+ if (result?.detail) {
481
+ console.error(result.detail);
482
+ }
483
+ printElectronSetupHelp();
484
+ }
485
+
486
+ async function openOnboardingWindow(options = {}) {
487
+ console.log('Opening KitCode Welcome...');
488
+ const environment = {
489
+ KITCODE_HOST: options.host,
490
+ KITCODE_PORT: String(options.port),
491
+ KITCODE_INITIAL_PROJECT: path.resolve(options.initialProject ?? process.cwd()),
492
+ };
493
+
494
+ if (options.setupPlatform) {
495
+ const platform = normalizeSetupPlatform(options.setupPlatform);
496
+ if (!platform) {
497
+ console.error('Invalid --platform. Use macos, windows, or linux (aliases: darwin, win32).');
498
+ recordWelcomeFailed(options.welcomeCommand ?? 'setup', {reason: 'invalid-platform'});
499
+ await sendOnce();
500
+ return false;
501
+ }
502
+ environment.KITCODE_SETUP_PLATFORM = platform;
503
+ }
504
+
505
+ const result = await openElectronEntry('onboarding-electron.mjs', environment);
506
+ if (result.ok) {
507
+ return true;
508
+ }
509
+
510
+ printElectronLaunchFailure(result, 'KitCode Welcome');
511
+ recordWelcomeFailed(options.welcomeCommand ?? 'setup', result);
512
+ await sendOnce();
513
+ return false;
514
+ }
515
+
516
+ function printDashboardHint(options) {
517
+ console.log(`${colorize('Dashboard', COLOR.bold)}: ${colorize(DASHBOARD_URL, COLOR.cyan, COLOR.underline)}`);
518
+
519
+ if (!options.openDashboard) {
520
+ console.log(colorize('Open dashboard manually when you are ready.', COLOR.dim));
521
+ return;
522
+ }
523
+
524
+ console.log(colorize('Opening dashboard in your browser...', COLOR.primary));
525
+
526
+ if (!openDashboard(DASHBOARD_URL)) {
527
+ console.log(colorize('Could not open the browser automatically. Paste the dashboard URL above.', COLOR.yellow));
528
+ }
529
+ }
530
+
531
+ // What happened to the hook, from `on` and `off` (`action`), or what `status` found.
532
+ function describeHook(hook) {
533
+ if (hook.action === 'refused') {
534
+ return hook.installed === false ? 'not installed (see below)' : 'not removed (see below)';
535
+ }
536
+
537
+ if (hook.action) {
538
+ return {installed: 'installed', unchanged: 'already installed', removed: 'removed', 'not-found': 'not found'}[hook.action];
539
+ }
540
+
541
+ if (hook.installed === null) {
542
+ return `unknown (${hook.error.message})`;
543
+ }
544
+
545
+ return hook.installed ? 'installed' : 'not installed';
546
+ }
547
+
548
+ function printIntegrationStatus(source, status) {
549
+ console.log(`${source}:`);
550
+ if (status.runner) {
551
+ console.log(` runner: ${status.runner.installed ? 'installed' : 'not installed'}`);
552
+ console.log(` runner file: ${status.runner.path}`);
553
+ }
554
+
555
+ console.log(` hook: ${describeHook(status.hook)}`);
556
+ console.log(` config: ${status.hook.path}`);
557
+ if (status.hook.backupPath) {
558
+ console.log(` config backup: ${status.hook.backupPath}`);
559
+ }
560
+ if (status.hook.command) {
561
+ console.log(` hook command: ${status.hook.command}`);
562
+ }
563
+
564
+ if (status.skill.removed !== undefined) {
565
+ console.log(` skill: ${status.skill.removed ? 'removed' : 'not found'}`);
566
+ } else {
567
+ console.log(` skill: ${status.skill.installed ? 'installed' : 'not installed'}`);
568
+ }
569
+ console.log(` skill file: ${status.skill.path}`);
570
+
571
+ if (source === 'codex' && status.hook.installed) {
572
+ console.log('Open /hooks in Codex to review and trust the KitCode hook.');
573
+ }
574
+ }
575
+
576
+ // The config was left as it is: say why, and what to change by hand. A read-only file could be read,
577
+ // so its KitCode entries (`entries`) are printed exactly as they are in it.
578
+ function printRefusedHook(source, hook) {
579
+ console.error(`KitCode left ${hook.path} as it is: it ${hook.error.problem}.`);
580
+ const entries = hook.entries ?? [];
581
+
582
+ if (hook.installed === false) {
583
+ console.error(`To add the KitCode hook yourself, merge this into ${hook.path}:`);
584
+ console.error(JSON.stringify(manualHookSnippet(source), null, 2));
585
+
586
+ if (entries.length > 0) {
587
+ console.error(`and delete ${entries.length === 1 ? 'the KitCode entry' : `the ${entries.length} KitCode entries`} already in it, so that the hook runs once:`);
588
+ for (const entry of entries) {
589
+ console.error(JSON.stringify(entry, null, 2));
590
+ }
591
+ }
592
+ return;
593
+ }
594
+
595
+ if (entries.length > 0) {
596
+ console.error(`To turn the hook off yourself, delete ${entries.length === 1 ? 'this entry' : 'these entries'} from "hooks" > "UserPromptSubmit" in that file, with its group if nothing else is left in it:`);
597
+ for (const entry of entries) {
598
+ console.error(JSON.stringify(entry, null, 2));
599
+ }
600
+ return;
601
+ }
602
+
603
+ console.error(`To turn the hook off yourself, delete the entry whose "command" is "${hook.command}" from "hooks" > "UserPromptSubmit" in that file.`);
604
+ }
605
+
606
+ async function confirmUninstall() {
607
+ const rl = readline.createInterface({
608
+ input: process.stdin,
609
+ output: process.stdout,
610
+ });
611
+
612
+ try {
613
+ const answer = await rl.question('Remove all KitCode hooks, skills, tracker, and ~/.kitcode data? [y/N] ');
614
+ return /^y(es)?$/i.test(answer.trim());
615
+ } finally {
616
+ rl.close();
617
+ }
618
+ }
619
+
620
+ function describeRemovedHook(hook) {
621
+ if (hook.action === 'removed') {
622
+ return 'removed';
623
+ }
624
+
625
+ return hook.action === 'not-found' ? 'not found' : `not removed: ${hook.error.message}`;
626
+ }
627
+
628
+ function printUninstallReport(report) {
629
+ console.log('KitCode uninstall complete.');
630
+ console.log(` tracker: ${report.tracker.stopped === true ? 'stopped' : report.tracker.stopped === 'requested' ? 'stop requested' : 'not running'}`);
631
+ console.log(` codex hook: ${describeRemovedHook(report.integrations.codex.hook)} (${report.integrations.codex.hook.path})`);
632
+ console.log(` claude hook: ${describeRemovedHook(report.integrations.claude.hook)} (${report.integrations.claude.hook.path})`);
633
+ console.log(` codex skill: ${report.skills.codex.removed ? 'removed' : 'not found'} (${report.skills.codex.path})`);
634
+ console.log(` claude skill: ${report.skills.claude.removed ? 'removed' : 'not found'} (${report.skills.claude.path})`);
635
+ console.log(` local state: ${report.store.removed ? 'removed' : 'not found'} (${report.store.path})`);
636
+ }
637
+
638
+ async function handleUninstall(options) {
639
+ if (!options.yes) {
640
+ if (!process.stdin.isTTY) {
641
+ console.error('Refusing to uninstall without confirmation. Re-run with: kitcode uninstall --yes');
642
+ process.exit(1);
643
+ }
644
+
645
+ if (!(await confirmUninstall())) {
646
+ console.log('KitCode uninstall canceled.');
647
+ return;
648
+ }
649
+ }
650
+
651
+ console.log('Removing KitCode...');
652
+ const report = await uninstallKitCode();
653
+
654
+ if (report.refused) {
655
+ console.error('KitCode uninstall stopped before changing anything:');
656
+ for (const status of report.refused) {
657
+ if (status.error.readOnly) {
658
+ printRefusedHook(status.source, status);
659
+ } else {
660
+ console.error(` ${status.error.message}. The KitCode hook there, if any, runs: ${status.command}`);
661
+ }
662
+ }
663
+ console.error(report.refused.every((status) => status.error.readOnly)
664
+ ? 'Then run `kitcode uninstall --yes` again.'
665
+ : 'Repair that file, or delete the KitCode hook from it by hand, then run `kitcode uninstall --yes` again.');
666
+ process.exit(1);
667
+ }
668
+
669
+ printUninstallReport(report);
670
+ }
671
+
672
+ // What became of the hook in the agent's settings, for a tester build's diagnostics: by outcome, and a refusal by a fixed code,
673
+ // never the file's path or its words.
674
+ function recordAgentHook(source, action, hook) {
675
+ recordEvent('agent-hook', {agent: source, action, outcome: hook.action, problem: hook.action === 'refused' ? configProblemCode(hook.error) : 'none'}, {source: 'command'});
676
+ }
677
+
678
+ async function handleHookInstaller(source, action) {
679
+ if (action === 'on') {
680
+ const status = installIntegration(source);
681
+ printIntegrationStatus(source, status);
682
+ recordAgentHook(source, 'on', status.hook);
683
+ let failed = false;
684
+ if (status.hook.action === 'refused') {
685
+ printRefusedHook(source, status.hook);
686
+ process.exitCode = 1;
687
+ failed = true;
688
+ }
689
+ if (!onboardingPreferences().completed) {
690
+ if (!(await openOnboardingWindow({host: DEFAULT_HOST, port: DEFAULT_PORT, initialProject: process.cwd(), welcomeCommand: `${source}-on`}))) {
691
+ console.error('KitCode Welcome could not open. Fix Electron install, then re-run `kitcode setup`.');
692
+ process.exitCode = 1;
693
+ failed = true;
694
+ }
695
+ }
696
+ if (failed) {
697
+ await sendOnce();
698
+ }
699
+ return;
700
+ }
701
+
702
+ if (action === 'off') {
703
+ const status = uninstallIntegration(source);
704
+ printIntegrationStatus(source, status);
705
+ recordAgentHook(source, 'off', status.hook);
706
+ if (status.hook.action === 'refused') {
707
+ printRefusedHook(source, status.hook);
708
+ process.exitCode = 1;
709
+ await sendOnce();
710
+ }
711
+ return;
712
+ }
713
+
714
+ if (action === 'status') {
715
+ printIntegrationStatus(source, integrationStatus(source));
716
+ return;
717
+ }
718
+
719
+ printHelp();
720
+ process.exit(1);
721
+ }
722
+
723
+ async function isServerRunning(options) {
724
+ return Boolean(await trackerHealth(options));
725
+ }
726
+
727
+ // `kitcode diagnostics status` and `kitcode diagnostics send` (docs/way-of-working.md, "A full staging round"): what a tester
728
+ // build's diagnostics hold, and Send logs now from the terminal, for when the dashboard cannot reach the tracker. Any other
729
+ // build says it sends nothing, and sends nothing.
730
+ async function handleDiagnostics(action) {
731
+ if (action === 'status' || action === undefined) {
732
+ const status = diagnosticsStatus();
733
+
734
+ if (!status.enabled) {
735
+ console.log(describeOutcome({outcome: 'disabled'}));
736
+ return;
737
+ }
738
+
739
+ sweepHookMarkers({source: 'command'});
740
+ console.log('KitCode diagnostics: this tester build sends KitCode\'s own events and errors to its staging server, never your code, files, prompts or project names.');
741
+ console.log(` staging server: ${status.destination}`);
742
+ console.log(` install id: ${status.installId ?? 'none yet'}`);
743
+ console.log(` invited email: ${status.invitedEmail ?? 'none given'}`);
744
+ console.log(` waiting to be sent: ${status.queued} event${status.queued === 1 ? '' : 's'}`);
745
+
746
+ if (status.closedUntil) {
747
+ console.log(` paused: the staging server is not taking diagnostics; KitCode tries again after ${status.closedUntil}`);
748
+ }
749
+
750
+ console.log(` last send: ${status.lastSend ? `${status.lastSend.at}, ${describeOutcome(status.lastSend)}` : 'none yet'}`);
751
+ return;
752
+ }
753
+
754
+ if (action === 'send') {
755
+ if (!diagnosticsEnabled()) {
756
+ console.log(describeOutcome({outcome: 'disabled'}));
757
+ return;
758
+ }
759
+
760
+ sweepHookMarkers({source: 'command'});
761
+ const result = await sendQueued({timeoutMs: 10_000, maxBatches: 5, manual: true, source: 'command'});
762
+ console.log(describeOutcome(result));
763
+
764
+ if (result.outcome !== 'stored' && result.outcome !== 'nothing') {
765
+ process.exitCode = 1;
766
+ }
767
+
768
+ return;
769
+ }
770
+
771
+ printHelp();
772
+ process.exit(1);
773
+ }
774
+
775
+ // The local API has no login. Bound to anything but loopback, it is on every network that can reach
776
+ // that address, so that takes --allow-non-loopback, and a warning.
777
+ function bindRefusal(options) {
778
+ if (isLoopbackAddress(options.host) || options.allowNonLoopback) {
779
+ return null;
780
+ }
781
+
782
+ return `Not started: ${options.host} is not a loopback address, so KitCode's local API, which has no authentication, would be reachable from other machines. Use --host 127.0.0.1, or add --allow-non-loopback to bind ${options.host} anyway.`;
783
+ }
784
+
785
+ function nonLoopbackWarning(options) {
786
+ return `Warning: KitCode's local API will listen on ${options.host}:${options.port}, where other machines may reach it. It has no authentication. It answers only requests addressed to 127.0.0.1 or localhost: that stops web pages, not other programs, and any program that can connect to it can read your progress.`;
787
+ }
788
+
789
+ function sleep(ms) {
790
+ return new Promise((resolve) => setTimeout(resolve, ms));
791
+ }
792
+
793
+ function printTrackerNotRunning() {
794
+ console.error('KitCode tracker is not running. Run `kitcode track` first.');
795
+ }
796
+
797
+ // A tester build's diagnostics (src/diagnostics.mjs): a command that saw something fail records it and sends what waits,
798
+ // once, with a short timeout, and quietly, since no tracker may be running to send it later. In any other build each
799
+ // returns at once, and nothing is written or sent.
800
+ const COMMAND_NAMES = new Set(['add', 'remove', 'track', 'untrack', 'list', 'status', 'summary', 'awards', 'dashboard', 'setup', 'uninstall', 'claude', 'codex', 'diagnostics']);
801
+
802
+ function commandName(command) {
803
+ const name = command === 'award' || command === 'rewards' ? 'awards' : command;
804
+ return COMMAND_NAMES.has(name) ? name : 'other';
805
+ }
806
+
807
+ // Once a process: the first failure sends what waits, then, and what a command records after it waits for the next send.
808
+ let sentOnce = false;
809
+
810
+ async function sendOnce() {
811
+ if (!diagnosticsEnabled() || sentOnce) {
812
+ return;
813
+ }
814
+
815
+ sentOnce = true;
816
+
817
+ try {
818
+ sweepHookMarkers({source: 'command'});
819
+ await sendQueued({timeoutMs: 3000, maxBatches: 1, source: 'command'});
820
+ } catch {
821
+ // Quietly: whatever sends next sends it.
822
+ }
823
+ }
824
+
825
+ function recordCommandFailed(command, error) {
826
+ recordEvent('command-failed', {command: commandName(command), errorCode: errorCodeOf(error), errorName: errorNameOf(error)}, {source: 'command'});
827
+ }
828
+
829
+ function recordTrackerNotStarted(source, reason, error = null) {
830
+ recordEvent('tracker-not-started', {reason, errorCode: errorCodeOf(error), errorName: errorNameOf(error)}, {source});
831
+ }
832
+
833
+ const WELCOME_REASONS = new Set(['missing-electron', 'electron-download-failed', 'electron-binary-missing', 'missing-entry', 'spawn-error', 'exited', 'invalid-platform']);
834
+
835
+ function recordWelcomeFailed(command, result) {
836
+ const exitCode = Number.isInteger(result?.code) ? result.code : null;
837
+ recordEvent('welcome-failed', {command, reason: WELCOME_REASONS.has(result?.reason) ? result.reason : 'other', exitCode}, {source: 'command'});
838
+ }
839
+
840
+ async function runDaemon(options, status = createStatusReporter()) {
841
+ status.set('Starting KitCode tracker...');
842
+ const log = createLog(TRACKER_LOG_PATH);
843
+ // The tracker runs detached, with its output discarded: what matters goes to tracker.log too.
844
+ const report = (level, message) => {
845
+ log[level](message);
846
+
847
+ if (level !== 'info') {
848
+ console.error(message);
849
+ }
850
+ };
851
+
852
+ // Anything thrown and not caught, and any promise rejected and not handled, is logged before the
853
+ // tracker exits, instead of ending it without a trace. Nothing is saved on the way out: the file
854
+ // keeps the last periodic save.
855
+ const fatal = (kind) => (error) => {
856
+ log.error(`${kind}: ${error?.stack ?? error}`);
857
+ recordEvent('tracker-crashed', {kind: kind === 'uncaughtException' ? 'uncaught-exception' : 'unhandled-rejection', errorCode: errorCodeOf(error), errorName: errorNameOf(error)}, {source: 'tracker'});
858
+ releaseTracker(process.pid);
859
+ process.exit(1);
860
+ };
861
+ process.on('uncaughtException', fatal('uncaughtException'));
862
+ process.on('unhandledRejection', fatal('unhandledRejection'));
863
+
864
+ // Checked here too, not only by `kitcode track`: anything can start this process.
865
+ const refusal = bindRefusal(options);
866
+
867
+ if (refusal) {
868
+ status.stop();
869
+ report('error', refusal);
870
+ recordTrackerNotStarted('tracker', 'bind-refused');
871
+ process.exit(1);
872
+ }
873
+
874
+ if (!isLoopbackAddress(options.host)) {
875
+ report('warn', nonLoopbackWarning(options));
876
+ }
877
+
878
+ const startedAt = new Date().toISOString();
879
+ const record = {pid: process.pid, host: options.host, port: options.port, startedAt, version: VERSION, tracking: 'ok', problems: [], heartbeatAt: startedAt};
880
+
881
+ // One tracker per HOME. A tracker that answers, or is starting, keeps its place; only one whose
882
+ // process does not answer may be replaced. The claim is made before anything is written.
883
+ const existing = await findRunningTracker();
884
+
885
+ if (existing && existing.state !== 'unresponsive') {
886
+ status.stop();
887
+ report('warn', `Not started: another KitCode tracker (process ${existing.record.pid}) is ${existing.state} at http://${existing.record.host}:${existing.record.port}.`);
888
+ recordTrackerNotStarted('tracker', 'already-running');
889
+ process.exit(1);
890
+ }
891
+
892
+ if (!claimTracker(record, existing?.record ?? null).claimed) {
893
+ status.stop();
894
+ report('warn', 'Not started: another KitCode tracker started at the same moment.');
895
+ recordTrackerNotStarted('tracker', 'claimed-elsewhere');
896
+ process.exit(1);
897
+ }
898
+
899
+ let runtime;
900
+
901
+ try {
902
+ runtime = createRuntime(options);
903
+ } catch (error) {
904
+ releaseTracker(process.pid);
905
+ report('error', `Not started: ${error.message}`);
906
+ recordTrackerNotStarted('tracker', error instanceof StateFileError || error instanceof StateLockError ? 'state-unreadable' : 'start-failed', error);
907
+ throw error;
908
+ }
909
+
910
+ // The install id the claim bundle carries, made once per HOME, and only by a tracker that has started:
911
+ // one refused above writes nothing (src/install-id.mjs). A tracker that cannot make it still counts;
912
+ // the bundle waits for it, and says so.
913
+ try {
914
+ ensureInstallId();
915
+ } catch (error) {
916
+ report('warn', `KitCode could not make its install id (${error.message}); the claim bundle is not given out until it can.`);
917
+ }
918
+
919
+ // A tester build's diagnostics while the tracker runs (src/diagnostics.mjs): its events sent every minute, a heartbeat every
920
+ // hour, and Send logs now through the local API. In any other build it does nothing.
921
+ const startedMs = Date.now();
922
+ const uptimeSeconds = () => Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
923
+ const diagnostics = startDiagnosticsLoop({
924
+ snapshot: () => {
925
+ const current = runtime.status?.() ?? {tracking: 'ok', problems: []};
926
+ return {projects: Object.values(runtime.state.projects).filter((project) => project.active).length, tracking: current.tracking, problems: current.problems.map((problem) => problem.code)};
927
+ },
928
+ });
929
+ const app = createServer(runtime, VERSION, {diagnostics});
930
+ let cleanup = async () => {};
931
+ let server = null;
932
+
933
+ const exit = (code) => {
934
+ releaseTracker(process.pid);
935
+
936
+ if (!server) {
937
+ process.exit(code);
938
+ return;
939
+ }
940
+
941
+ server.closeAllConnections?.();
942
+ server.close(() => process.exit(code));
943
+ };
944
+
945
+ const PROBLEMS = new Set(['watch-failed', 'project-missing', 'scan-failed', 'git-locked', 'git-unfinished']);
946
+
947
+ cleanup = startWatchers(runtime, {
948
+ log,
949
+ onProgress(message) {
950
+ status.set(message);
951
+ },
952
+ // tracker.json says how the tracker is doing, for the prompt hook; updating it is the heartbeat.
953
+ onStatus(current) {
954
+ updateTrackerRecord(process.pid, {...current, heartbeatAt: new Date().toISOString()});
955
+ },
956
+ // Each project's problem, raised or cleared, by its fixed code and an opaque reference to the project.
957
+ onProblem({code, projectId, state, error}) {
958
+ if (PROBLEMS.has(code)) {
959
+ recordEvent('tracker-problem', {problem: code, state, project: projectRef(projectId), errorCode: errorCodeOf(error)}, {source: 'tracker'});
960
+ }
961
+ },
962
+ // state.json can no longer be read. Stop without writing it: it holds the user's progress.
963
+ onStateError(error) {
964
+ status.stop();
965
+ report('error', `Stopped: ${error.message}`);
966
+ cleanup({persist: false});
967
+ recordEvent('tracker-stopped', {reason: 'state-unreadable', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
968
+ diagnostics.stop().finally(() => exit(1));
969
+ },
970
+ });
971
+
972
+ const shutdown = (signal) => async () => {
973
+ try {
974
+ await cleanup();
975
+ } catch (error) {
976
+ if (!(error instanceof StateFileError || error instanceof StateLockError)) {
977
+ throw error;
978
+ }
979
+
980
+ report('error', error.message);
981
+ }
982
+
983
+ log.info(`stopped (${signal})`);
984
+ recordEvent('tracker-stopped', {reason: signal === 'stop request' ? 'stop-request' : 'signal', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
985
+ await diagnostics.stop();
986
+ exit(0);
987
+ };
988
+
989
+ server = app.listen(options.port, options.host, () => {
990
+ status.stop();
991
+ log.info(`started: KitCode ${VERSION}, process ${process.pid}, http://${options.host}:${options.port}, node ${process.version}`);
992
+ recordEvent('tracker-started', {projects: Object.values(runtime.state.projects).filter((project) => project.active).length, port: options.port}, {source: 'tracker'});
993
+ });
994
+
995
+ server.on('error', async (error) => {
996
+ status.stop();
997
+ await cleanup().catch(() => {});
998
+ releaseTracker(process.pid);
999
+ recordTrackerNotStarted('tracker', error.code === 'EADDRINUSE' ? 'port-in-use' : 'start-failed', error);
1000
+ await diagnostics.stop({finalSend: false});
1001
+
1002
+ if (error.code === 'EADDRINUSE') {
1003
+ report('error', `Not started: port ${options.port} is already in use. Stop the other server or pass --port <port>.`);
1004
+ process.exit(1);
1005
+ }
1006
+
1007
+ report('error', `Not started: ${error.message}`);
1008
+ process.exit(1);
1009
+ });
1010
+
1011
+ process.on('SIGINT', shutdown('SIGINT'));
1012
+ process.on('SIGTERM', shutdown('SIGTERM'));
1013
+ // Windows has no SIGTERM: there `untrack` and `uninstall` leave a stop request (src/tracker-stop.mjs).
1014
+ watchForStopRequest(record, shutdown('stop request'));
1015
+ }
1016
+
1017
+ function printAlreadyRunning(record, options) {
1018
+ console.log('KitCode tracker is already running.');
1019
+ console.log(`Local server: http://${record.host}:${record.port}`);
1020
+
1021
+ if (String(record.host) !== String(options.host) || Number(record.port) !== Number(options.port)) {
1022
+ console.log(`It stays where it is. To move it, run \`kitcode untrack\`, then \`kitcode track --port ${options.port}\`.`);
1023
+ }
1024
+ }
1025
+
1026
+ async function startTracker(options) {
1027
+ const refusal = bindRefusal(options);
1028
+
1029
+ if (refusal) {
1030
+ console.error(refusal);
1031
+ recordTrackerNotStarted('command', 'bind-refused');
1032
+ await sendOnce();
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ if (!isLoopbackAddress(options.host)) {
1037
+ console.error(nonLoopbackWarning(options));
1038
+ }
1039
+
1040
+ // One tracker per HOME, whatever port it was started on. One that is starting gets a moment.
1041
+ let running = await findRunningTracker();
1042
+
1043
+ for (let attempt = 0; running?.state === 'starting' && attempt < 50; attempt += 1) {
1044
+ await sleep(200);
1045
+ running = await findRunningTracker();
1046
+ }
1047
+
1048
+ if (running && running.state !== 'unresponsive') {
1049
+ printAlreadyRunning(running.record, options);
1050
+ return;
1051
+ }
1052
+
1053
+ if (running) {
1054
+ console.log(`tracker.json names process ${running.record.pid}, which does not answer at http://${running.record.host}:${running.record.port}. Starting a new tracker in its place.`);
1055
+ } else if (await isServerRunning(options)) {
1056
+ // Something answers as KitCode here, but no tracker of this HOME: another HOME's, or an old one.
1057
+ console.log('KitCode tracker is already running.');
1058
+ console.log(`Local server: http://${options.host}:${options.port}`);
1059
+ return;
1060
+ }
1061
+
1062
+ // The tracker runs detached, with nowhere to print: a state file it could not read, or a snapshot
1063
+ // it could not convert, stops it here, where the reason can be shown. The file is converted here too.
1064
+ prepareState();
1065
+ releaseTracker();
1066
+
1067
+ const child = spawn(process.execPath, [
1068
+ fileURLToPath(import.meta.url),
1069
+ 'track',
1070
+ '--host',
1071
+ options.host,
1072
+ '--port',
1073
+ String(options.port),
1074
+ ...(options.allowNonLoopback ? ['--allow-non-loopback'] : []),
1075
+ ], {
1076
+ detached: true,
1077
+ env: trackerChildEnvironment(),
1078
+ stdio: 'ignore',
1079
+ windowsHide: true,
1080
+ });
1081
+
1082
+ let childExited = false;
1083
+ child.once('exit', () => {
1084
+ childExited = true;
1085
+ });
1086
+ child.unref();
1087
+
1088
+ for (let attempt = 0; attempt < 25; attempt += 1) {
1089
+ if (await isServerRunning(options)) {
1090
+ console.log('KitCode tracker started.');
1091
+ console.log(`Local server: http://${options.host}:${options.port}`);
1092
+ return;
1093
+ }
1094
+
1095
+ // Another `kitcode track` started a tracker at the same moment, and that one won: report it.
1096
+ const winner = readTrackerRecord();
1097
+
1098
+ if (winner && winner.pid !== child.pid && isProcessAlive(winner.pid) && await trackerHealth(winner)) {
1099
+ printAlreadyRunning(winner, options);
1100
+ return;
1101
+ }
1102
+
1103
+ if (childExited) {
1104
+ break;
1105
+ }
1106
+
1107
+ await sleep(200);
1108
+ }
1109
+
1110
+ console.error('KitCode tracker did not start. Try running `kitcode track` again.');
1111
+ recordTrackerNotStarted('command', 'did-not-answer');
1112
+ await sendOnce();
1113
+ process.exit(1);
1114
+ }
1115
+
1116
+ // Stops the tracker tracker.json names. A process that does not answer as that tracker is left
1117
+ // alone: after a restart its pid may belong to anything.
1118
+ async function stopTracker() {
1119
+ const running = await findRunningTracker();
1120
+
1121
+ if (!running) {
1122
+ releaseTracker();
1123
+ console.log('KitCode tracker is not running.');
1124
+ return;
1125
+ }
1126
+
1127
+ if (running.state === 'unresponsive') {
1128
+ const stop = process.platform === 'win32' ? `taskkill /PID ${running.record.pid} /F` : `kill ${running.record.pid}`;
1129
+ console.log(`KitCode tracker is not answering: tracker.json names process ${running.record.pid}, which does not answer at http://${running.record.host}:${running.record.port}. KitCode does not stop a process it cannot identify. If it is a KitCode tracker, stop it with: ${stop}`);
1130
+ return;
1131
+ }
1132
+
1133
+ // SIGTERM, or on Windows, which has none, a stop request (src/tracker-stop.mjs).
1134
+ try {
1135
+ askTrackerToStop(running.record);
1136
+ } catch (error) {
1137
+ if (process.platform === 'win32') {
1138
+ console.log(`KitCode could not ask the tracker (process ${running.record.pid}) to stop: ${error.message}`);
1139
+ return;
1140
+ }
1141
+
1142
+ releaseTracker();
1143
+ console.log('KitCode tracker is not running.');
1144
+ return;
1145
+ }
1146
+
1147
+ // It scans the changes still waiting and saves before it exits.
1148
+ for (let attempt = 0; attempt < 50; attempt += 1) {
1149
+ if (!isProcessAlive(running.record.pid)) {
1150
+ releaseTracker();
1151
+ console.log('KitCode tracker stopped.');
1152
+ return;
1153
+ }
1154
+
1155
+ await sleep(200);
1156
+ }
1157
+
1158
+ console.log('KitCode tracker stop requested.');
1159
+ }
1160
+
1161
+ const options = parseArgs(process.argv);
1162
+
1163
+ try {
1164
+ if (options.command === 'track' && process.env.KITCODE_DAEMON === '1') {
1165
+ await runDaemon(options);
1166
+ } else if (options.command === 'dashboard') {
1167
+ const status = createStatusReporter();
1168
+ status.set('Checking local server...');
1169
+
1170
+ if (await isServerRunning(options)) {
1171
+ status.stop();
1172
+ console.log(`${colorize('KitCode', COLOR.bold, COLOR.primary)} is already live.`);
1173
+ printDashboardHint(options);
1174
+ } else {
1175
+ status.stop();
1176
+ printTrackerNotRunning();
1177
+ process.exit(1);
1178
+ }
1179
+ } else if (options.command === 'add') {
1180
+ const targetPath = process.argv[3] ?? '.';
1181
+ let totals;
1182
+
1183
+ try {
1184
+ totals = registerProject(targetPath);
1185
+ } catch (error) {
1186
+ if (error instanceof StateFileError || error instanceof StateLockError) {
1187
+ throw error;
1188
+ }
1189
+
1190
+ // The folder could not be listed: it is gone, not a folder, or git could not list it.
1191
+ console.error(`KitCode could not add ${path.resolve(targetPath)}: ${error.message}`);
1192
+ recordCommandFailed('add', error);
1193
+ await sendOnce();
1194
+ process.exit(1);
1195
+ }
1196
+
1197
+ console.log('Project added to KitCode.');
1198
+ console.log(`Added projects: ${totals.trackingProjects}`);
1199
+ } else if (options.command === 'remove') {
1200
+ const targetPath = process.argv[3] ?? '.';
1201
+ const totals = removeProject(targetPath);
1202
+
1203
+ if (!totals) {
1204
+ console.error('Folder not found.');
1205
+ process.exit(1);
1206
+ }
1207
+
1208
+ console.log('Project removed from KitCode.');
1209
+ console.log(`Added projects: ${totals.trackingProjects}`);
1210
+ } else if (options.command === 'track') {
1211
+ await startTracker(options);
1212
+ } else if (options.command === 'list') {
1213
+ const totals = listProjects();
1214
+
1215
+ if (totals.trackingProjects === 0) {
1216
+ console.log('No added projects. Run: kitcode add');
1217
+ } else {
1218
+ console.log(`Added projects: ${totals.trackingProjects}`);
1219
+ }
1220
+ } else if (options.command === 'status') {
1221
+ await printStatus(options);
1222
+ } else if (options.command === 'summary') {
1223
+ printRewardSummary();
1224
+ } else if (options.command === 'awards' || options.command === 'award' || options.command === 'rewards') {
1225
+ printAwards();
1226
+ } else if (options.command === 'untrack') {
1227
+ await stopTracker();
1228
+ } else if (options.command === 'setup') {
1229
+ // Welcome reads and writes state.json: a file it could not read stops here, with the reason.
1230
+ loadState();
1231
+
1232
+ if (!(await openOnboardingWindow(options))) {
1233
+ process.exit(1);
1234
+ }
1235
+ } else if (options.command === 'uninstall') {
1236
+ await handleUninstall(options);
1237
+ } else if (options.command === 'hook' && options.subcommand === 'prompt') {
1238
+ await runPromptHook({source: options.source});
1239
+ } else if (options.command === 'codex') {
1240
+ await handleHookInstaller('codex', options.subcommand);
1241
+ } else if (options.command === 'claude') {
1242
+ await handleHookInstaller('claude', options.subcommand);
1243
+ } else if (options.command === 'diagnostics') {
1244
+ await handleDiagnostics(options.subcommand);
1245
+ } else if (options.command === 'version') {
1246
+ console.log(VERSION);
1247
+ } else {
1248
+ printHelp();
1249
+ process.exit(options.command === 'help' || !options.command ? 0 : 1);
1250
+ }
1251
+ } catch (error) {
1252
+ // A tester build records the failure, and sends it, once: but never from the prompt hook, which touches no network, nor
1253
+ // from the tracker, which recorded its own.
1254
+ if (options.command !== 'hook' && !(options.command === 'track' && process.env.KITCODE_DAEMON === '1')) {
1255
+ recordCommandFailed(options.command, error);
1256
+ await sendOnce();
1257
+ }
1258
+
1259
+ // A state file KitCode cannot read or lock stops the command with the reason. It is never replaced.
1260
+ if (error instanceof StateFileError || error instanceof StateLockError) {
1261
+ console.error(error.message);
1262
+ process.exit(1);
1263
+ }
1264
+
1265
+ throw error;
1266
+ }