@kitkat-vn/kitcode 0.2.0-staging.4 → 0.2.0-staging.6

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.
package/bin/kitcode.mjs CHANGED
@@ -10,6 +10,19 @@ import process from 'node:process';
10
10
  import {fileURLToPath} from 'node:url';
11
11
  import {createServer} from '../src/api.mjs';
12
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';
13
26
  import {manualHookSnippet} from '../src/hook-installers.mjs';
14
27
  import {runPromptHook} from '../src/hook-prompt.mjs';
15
28
  import {installIntegration, integrationStatus, uninstallIntegration} from '../src/integration-installers.mjs';
@@ -236,6 +249,8 @@ Commands:
236
249
  Internal prompt hook used by Codex and Claude
237
250
  codex on|off|status Install, remove, or inspect the Codex hook and skill
238
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
239
254
 
240
255
  Options:
241
256
  --host <host> Host to bind, default ${DEFAULT_HOST}
@@ -443,7 +458,7 @@ function openElectronEntry(entryName, environment = {}) {
443
458
 
444
459
  function printElectronSetupHelp() {
445
460
  console.error('KitCode setup needs Electron for the desktop folder picker.');
446
- console.error("Reinstall the tester build: download kitcode-0.2.0-staging.4.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.4.tgz in the folder you saved it to.");
461
+ console.error("Reinstall the tester build with: npm install -g @kitkat-vn/kitcode@staging");
447
462
  console.error('If install succeeded but Electron is still missing, fix npm cache permissions and reinstall.');
448
463
  }
449
464
 
@@ -458,7 +473,7 @@ function printElectronLaunchFailure(result, label) {
458
473
 
459
474
  if (result?.reason === 'electron-binary-missing') {
460
475
  console.error(`Electron's binary is missing: Electron points to ${result.detail}, and nothing is there.`);
461
- console.error("Reinstall the tester build: download kitcode-0.2.0-staging.4.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.4.tgz in the folder you saved it to.");
476
+ console.error("Reinstall the tester build with: npm install -g @kitkat-vn/kitcode@staging");
462
477
  return;
463
478
  }
464
479
 
@@ -480,6 +495,8 @@ async function openOnboardingWindow(options = {}) {
480
495
  const platform = normalizeSetupPlatform(options.setupPlatform);
481
496
  if (!platform) {
482
497
  console.error('Invalid --platform. Use macos, windows, or linux (aliases: darwin, win32).');
498
+ recordWelcomeFailed(options.welcomeCommand ?? 'setup', {reason: 'invalid-platform'});
499
+ await sendOnce();
483
500
  return false;
484
501
  }
485
502
  environment.KITCODE_SETUP_PLATFORM = platform;
@@ -491,6 +508,8 @@ async function openOnboardingWindow(options = {}) {
491
508
  }
492
509
 
493
510
  printElectronLaunchFailure(result, 'KitCode Welcome');
511
+ recordWelcomeFailed(options.welcomeCommand ?? 'setup', result);
512
+ await sendOnce();
494
513
  return false;
495
514
  }
496
515
 
@@ -650,29 +669,44 @@ async function handleUninstall(options) {
650
669
  printUninstallReport(report);
651
670
  }
652
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
+
653
678
  async function handleHookInstaller(source, action) {
654
679
  if (action === 'on') {
655
680
  const status = installIntegration(source);
656
681
  printIntegrationStatus(source, status);
682
+ recordAgentHook(source, 'on', status.hook);
683
+ let failed = false;
657
684
  if (status.hook.action === 'refused') {
658
685
  printRefusedHook(source, status.hook);
659
686
  process.exitCode = 1;
687
+ failed = true;
660
688
  }
661
689
  if (!onboardingPreferences().completed) {
662
- if (!(await openOnboardingWindow({host: DEFAULT_HOST, port: DEFAULT_PORT, initialProject: process.cwd()}))) {
690
+ if (!(await openOnboardingWindow({host: DEFAULT_HOST, port: DEFAULT_PORT, initialProject: process.cwd(), welcomeCommand: `${source}-on`}))) {
663
691
  console.error('KitCode Welcome could not open. Fix Electron install, then re-run `kitcode setup`.');
664
692
  process.exitCode = 1;
693
+ failed = true;
665
694
  }
666
695
  }
696
+ if (failed) {
697
+ await sendOnce();
698
+ }
667
699
  return;
668
700
  }
669
701
 
670
702
  if (action === 'off') {
671
703
  const status = uninstallIntegration(source);
672
704
  printIntegrationStatus(source, status);
705
+ recordAgentHook(source, 'off', status.hook);
673
706
  if (status.hook.action === 'refused') {
674
707
  printRefusedHook(source, status.hook);
675
708
  process.exitCode = 1;
709
+ await sendOnce();
676
710
  }
677
711
  return;
678
712
  }
@@ -690,6 +724,54 @@ async function isServerRunning(options) {
690
724
  return Boolean(await trackerHealth(options));
691
725
  }
692
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
+
693
775
  // The local API has no login. Bound to anything but loopback, it is on every network that can reach
694
776
  // that address, so that takes --allow-non-loopback, and a warning.
695
777
  function bindRefusal(options) {
@@ -712,6 +794,49 @@ function printTrackerNotRunning() {
712
794
  console.error('KitCode tracker is not running. Run `kitcode track` first.');
713
795
  }
714
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
+
715
840
  async function runDaemon(options, status = createStatusReporter()) {
716
841
  status.set('Starting KitCode tracker...');
717
842
  const log = createLog(TRACKER_LOG_PATH);
@@ -729,6 +854,7 @@ async function runDaemon(options, status = createStatusReporter()) {
729
854
  // keeps the last periodic save.
730
855
  const fatal = (kind) => (error) => {
731
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'});
732
858
  releaseTracker(process.pid);
733
859
  process.exit(1);
734
860
  };
@@ -741,6 +867,7 @@ async function runDaemon(options, status = createStatusReporter()) {
741
867
  if (refusal) {
742
868
  status.stop();
743
869
  report('error', refusal);
870
+ recordTrackerNotStarted('tracker', 'bind-refused');
744
871
  process.exit(1);
745
872
  }
746
873
 
@@ -758,12 +885,14 @@ async function runDaemon(options, status = createStatusReporter()) {
758
885
  if (existing && existing.state !== 'unresponsive') {
759
886
  status.stop();
760
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');
761
889
  process.exit(1);
762
890
  }
763
891
 
764
892
  if (!claimTracker(record, existing?.record ?? null).claimed) {
765
893
  status.stop();
766
894
  report('warn', 'Not started: another KitCode tracker started at the same moment.');
895
+ recordTrackerNotStarted('tracker', 'claimed-elsewhere');
767
896
  process.exit(1);
768
897
  }
769
898
 
@@ -774,6 +903,7 @@ async function runDaemon(options, status = createStatusReporter()) {
774
903
  } catch (error) {
775
904
  releaseTracker(process.pid);
776
905
  report('error', `Not started: ${error.message}`);
906
+ recordTrackerNotStarted('tracker', error instanceof StateFileError || error instanceof StateLockError ? 'state-unreadable' : 'start-failed', error);
777
907
  throw error;
778
908
  }
779
909
 
@@ -786,7 +916,17 @@ async function runDaemon(options, status = createStatusReporter()) {
786
916
  report('warn', `KitCode could not make its install id (${error.message}); the claim bundle is not given out until it can.`);
787
917
  }
788
918
 
789
- const app = createServer(runtime, VERSION);
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});
790
930
  let cleanup = async () => {};
791
931
  let server = null;
792
932
 
@@ -802,6 +942,8 @@ async function runDaemon(options, status = createStatusReporter()) {
802
942
  server.close(() => process.exit(code));
803
943
  };
804
944
 
945
+ const PROBLEMS = new Set(['watch-failed', 'project-missing', 'scan-failed', 'git-locked', 'git-unfinished']);
946
+
805
947
  cleanup = startWatchers(runtime, {
806
948
  log,
807
949
  onProgress(message) {
@@ -811,12 +953,19 @@ async function runDaemon(options, status = createStatusReporter()) {
811
953
  onStatus(current) {
812
954
  updateTrackerRecord(process.pid, {...current, heartbeatAt: new Date().toISOString()});
813
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
+ },
814
962
  // state.json can no longer be read. Stop without writing it: it holds the user's progress.
815
963
  onStateError(error) {
816
964
  status.stop();
817
965
  report('error', `Stopped: ${error.message}`);
818
966
  cleanup({persist: false});
819
- exit(1);
967
+ recordEvent('tracker-stopped', {reason: 'state-unreadable', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
968
+ diagnostics.stop().finally(() => exit(1));
820
969
  },
821
970
  });
822
971
 
@@ -832,18 +981,23 @@ async function runDaemon(options, status = createStatusReporter()) {
832
981
  }
833
982
 
834
983
  log.info(`stopped (${signal})`);
984
+ recordEvent('tracker-stopped', {reason: signal === 'stop request' ? 'stop-request' : 'signal', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
985
+ await diagnostics.stop();
835
986
  exit(0);
836
987
  };
837
988
 
838
989
  server = app.listen(options.port, options.host, () => {
839
990
  status.stop();
840
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'});
841
993
  });
842
994
 
843
995
  server.on('error', async (error) => {
844
996
  status.stop();
845
997
  await cleanup().catch(() => {});
846
998
  releaseTracker(process.pid);
999
+ recordTrackerNotStarted('tracker', error.code === 'EADDRINUSE' ? 'port-in-use' : 'start-failed', error);
1000
+ await diagnostics.stop({finalSend: false});
847
1001
 
848
1002
  if (error.code === 'EADDRINUSE') {
849
1003
  report('error', `Not started: port ${options.port} is already in use. Stop the other server or pass --port <port>.`);
@@ -874,6 +1028,8 @@ async function startTracker(options) {
874
1028
 
875
1029
  if (refusal) {
876
1030
  console.error(refusal);
1031
+ recordTrackerNotStarted('command', 'bind-refused');
1032
+ await sendOnce();
877
1033
  process.exit(1);
878
1034
  }
879
1035
 
@@ -952,6 +1108,8 @@ async function startTracker(options) {
952
1108
  }
953
1109
 
954
1110
  console.error('KitCode tracker did not start. Try running `kitcode track` again.');
1111
+ recordTrackerNotStarted('command', 'did-not-answer');
1112
+ await sendOnce();
955
1113
  process.exit(1);
956
1114
  }
957
1115
 
@@ -1031,6 +1189,8 @@ try {
1031
1189
 
1032
1190
  // The folder could not be listed: it is gone, not a folder, or git could not list it.
1033
1191
  console.error(`KitCode could not add ${path.resolve(targetPath)}: ${error.message}`);
1192
+ recordCommandFailed('add', error);
1193
+ await sendOnce();
1034
1194
  process.exit(1);
1035
1195
  }
1036
1196
 
@@ -1080,6 +1240,8 @@ try {
1080
1240
  await handleHookInstaller('codex', options.subcommand);
1081
1241
  } else if (options.command === 'claude') {
1082
1242
  await handleHookInstaller('claude', options.subcommand);
1243
+ } else if (options.command === 'diagnostics') {
1244
+ await handleDiagnostics(options.subcommand);
1083
1245
  } else if (options.command === 'version') {
1084
1246
  console.log(VERSION);
1085
1247
  } else {
@@ -1087,6 +1249,13 @@ try {
1087
1249
  process.exit(options.command === 'help' || !options.command ? 0 : 1);
1088
1250
  }
1089
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
+
1090
1259
  // A state file KitCode cannot read or lock stops the command with the reason. It is never replaced.
1091
1260
  if (error instanceof StateFileError || error instanceof StateLockError) {
1092
1261
  console.error(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitkat-vn/kitcode",
3
- "version": "0.2.0-staging.4",
3
+ "version": "0.2.0-staging.6",
4
4
  "description": "Local aggregate activity server for KitCode.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,5 +37,5 @@
37
37
  "optionalDependencies": {
38
38
  "electron": "^43.0.0"
39
39
  },
40
- "gitHead": "ed3ca58fd6379cf7cfa15824f451773f092fb6d5"
40
+ "gitHead": "db529cdd770406de653f0ab1a448c868cc0af68a"
41
41
  }
package/src/api.mjs CHANGED
@@ -3,6 +3,7 @@ import {MAX_EVENT_STREAMS, requireDashboardOrigin, requireDashboardRead, require
3
3
  import {claimBundleFromState} from './claim-bundle.mjs';
4
4
  import {corsMiddleware} from './cors.mjs';
5
5
  import {renderCompanionWindow} from './companion-window.mjs';
6
+ import {describeOutcome, diagnosticsStatus, sendQueued} from './diagnostics.mjs';
6
7
  import {INSTALL_ID_DISPLAY_PATH, InstallIdError, readInstallId} from './install-id.mjs';
7
8
  import {STATE_DISPLAY_PATH} from './integration-spec.mjs';
8
9
  import {MilestoneRecordError} from './milestones-reached.mjs';
@@ -20,7 +21,8 @@ const redeemGone = (_req, res) => {
20
21
  res.status(410).json({error: 'Break codes are no longer shown or recorded: each milestone reached shows its message on the dashboard.'});
21
22
  };
22
23
 
23
- export function createServer(runtime, version) {
24
+ // `diagnostics` is the tracker's send loop (src/diagnostics.mjs, startDiagnosticsLoop), for Send logs now.
25
+ export function createServer(runtime, version, {diagnostics = null} = {}) {
24
26
  const app = express();
25
27
  let openEventStreams = 0;
26
28
 
@@ -105,6 +107,25 @@ export function createServer(runtime, version) {
105
107
  }
106
108
  });
107
109
 
110
+ // A tester build's diagnostics, for the staging dashboard (docs/security.md, "Tester diagnostics"): whether this tracker
111
+ // sends them, its install id, which the dashboard links its own events by, what waits, and the last send. Only to an
112
+ // allow-listed Origin, as the claim bundle, which holds the same install id; never the events, or the invited email.
113
+ app.get('/api/diagnostics', requireDashboardOrigin, (_req, res) => {
114
+ res.setHeader('Cache-Control', 'no-store');
115
+ const {invitedEmail, ...status} = diagnosticsStatus();
116
+ res.json(invitedEmail === undefined ? status : {...status, emailGiven: invitedEmail !== null});
117
+ });
118
+
119
+ // Send logs now: the dashboard's button. A write, so it passes every write's guards first: a loopback Host, an allow-listed
120
+ // Origin and a JSON body (api-access.mjs). It sends what waits, past any backoff, and answers with exactly what the
121
+ // server confirmed, or why nothing was sent, in plain words; in a build that sends nothing, that it sends nothing.
122
+ app.post('/api/diagnostics/send', (_req, res, next) => {
123
+ (diagnostics ? diagnostics.sendNow() : sendQueued({timeoutMs: 10_000, maxBatches: 5, manual: true, source: 'tracker'})).then((result) => {
124
+ res.setHeader('Cache-Control', 'no-store');
125
+ res.json({...result, words: describeOutcome(result)});
126
+ }, next);
127
+ });
128
+
108
129
  app.get('/api/projects/:id/commits', projectLevelGone);
109
130
  app.post('/api/projects/:id/start', projectLevelGone);
110
131
  app.post('/api/projects/:id/stop', projectLevelGone);