@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
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import {reachableAddress} from './api-access.mjs';
6
+ import {writeFileAtomically} from './atomic-file.mjs';
7
+ import {STORE_DIR, withStateLock} from './store.mjs';
8
+
9
+ // ~/.kitcode/tracker.json names the one tracker of this HOME: two trackers would each write their
10
+ // own time into the same projects. Only the tracker it names writes or removes it.
11
+ export const TRACKER_PATH = path.join(STORE_DIR, 'tracker.json');
12
+ // What the tracker did and what went wrong: it runs detached, with nowhere else to say it.
13
+ export const TRACKER_LOG_PATH = path.join(STORE_DIR, 'tracker.log');
14
+
15
+ // A tracker that has claimed tracker.json but does not answer yet is still starting, for this long.
16
+ const STARTING_GRACE_MS = 15_000;
17
+ const TRACKER_FILE_MODE = 0o600;
18
+ // Status updates wait this long for the state lock, then wait for the next update.
19
+ const UPDATE_LOCK_TIMEOUT_MS = 500;
20
+
21
+ // A process of this user that exists. One that belongs to another user cannot be this user's tracker.
22
+ export function isProcessAlive(pid) {
23
+ if (!Number.isInteger(pid) || pid <= 0) {
24
+ return false;
25
+ }
26
+
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ export function readTrackerRecord() {
36
+ try {
37
+ const record = JSON.parse(fs.readFileSync(TRACKER_PATH, 'utf8'));
38
+ return record && typeof record === 'object' && Number.isInteger(record.pid) ? record : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function writeTrackerRecord(record) {
45
+ fs.mkdirSync(STORE_DIR, {recursive: true});
46
+ writeFileAtomically(TRACKER_PATH, `${JSON.stringify(record, null, 2)}\n`, {mode: TRACKER_FILE_MODE});
47
+ }
48
+
49
+ // What the tracker at host:port says about itself, or null when nothing there answers as KitCode.
50
+ // It asks with Host 127.0.0.1:<port>, as a browser on this machine does: the API answers no other
51
+ // Host (api-access.mjs), whatever address the tracker is bound to. fetch cannot set Host.
52
+ export function trackerHealth({host, port}, timeoutMs = 800) {
53
+ return new Promise((resolve) => {
54
+ let request;
55
+
56
+ try {
57
+ request = http.get({
58
+ host: reachableAddress(host),
59
+ port,
60
+ path: '/api/health',
61
+ agent: false,
62
+ headers: {host: `127.0.0.1:${port}`},
63
+ signal: AbortSignal.timeout(timeoutMs),
64
+ }, (response) => {
65
+ let text = '';
66
+
67
+ response.setEncoding('utf8');
68
+ response.on('data', (chunk) => {
69
+ text += chunk;
70
+ });
71
+ response.on('error', () => resolve(null));
72
+ response.on('end', () => {
73
+ try {
74
+ const health = response.statusCode >= 200 && response.statusCode < 300 ? JSON.parse(text) : null;
75
+ resolve(health?.status === 'ok' && health?.app === 'kitcode' ? health : null);
76
+ } catch {
77
+ resolve(null);
78
+ }
79
+ });
80
+ });
81
+ } catch {
82
+ // A port that is not a port, say.
83
+ resolve(null);
84
+ return;
85
+ }
86
+
87
+ request.on('error', () => resolve(null));
88
+ });
89
+ }
90
+
91
+ // The tracker tracker.json names, when its process is alive: `running` when it answers as that
92
+ // tracker, `starting` when it claimed the file moments ago, and otherwise `unresponsive`, which is a
93
+ // tracker that hangs or, more likely, a pid that now belongs to another process. A tracker from
94
+ // before 2026-09-24 does not report its pid, and counts as running when it answers.
95
+ export async function findRunningTracker() {
96
+ const record = readTrackerRecord();
97
+
98
+ if (!record || !isProcessAlive(record.pid)) {
99
+ return null;
100
+ }
101
+
102
+ const health = await trackerHealth(record);
103
+
104
+ if (health && (health.pid === undefined || health.pid === record.pid)) {
105
+ return {record, state: 'running', health};
106
+ }
107
+
108
+ const startedAt = Date.parse(record.startedAt);
109
+
110
+ if (Number.isFinite(startedAt) && Date.now() - startedAt < STARTING_GRACE_MS) {
111
+ return {record, state: 'starting'};
112
+ }
113
+
114
+ return {record, state: 'unresponsive'};
115
+ }
116
+
117
+ function sameRecord(left, right) {
118
+ return Boolean(left && right) && left.pid === right.pid && left.startedAt === right.startedAt;
119
+ }
120
+
121
+ // Makes `record` the tracker of record, unless another live process holds tracker.json. The check
122
+ // and the write happen under the state lock, so of two trackers starting together one wins.
123
+ // `replacing` is a record the caller found unresponsive; that one may be replaced.
124
+ export function claimTracker(record, replacing = null) {
125
+ return withStateLock(() => {
126
+ const current = readTrackerRecord();
127
+
128
+ if (current && current.pid !== record.pid && isProcessAlive(current.pid) && !sameRecord(current, replacing)) {
129
+ return {claimed: false, current};
130
+ }
131
+
132
+ writeTrackerRecord(record);
133
+ return {claimed: true};
134
+ });
135
+ }
136
+
137
+ // Updates the record of the tracker `pid`, if it is still the tracker of record. A busy lock skips it.
138
+ export function updateTrackerRecord(pid, patch) {
139
+ try {
140
+ return withStateLock(() => {
141
+ const current = readTrackerRecord();
142
+
143
+ if (current?.pid !== pid) {
144
+ return false;
145
+ }
146
+
147
+ writeTrackerRecord({...current, ...patch});
148
+ return true;
149
+ }, {lockTimeoutMs: UPDATE_LOCK_TIMEOUT_MS});
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // Removes tracker.json if it names `pid`, or, with no pid, if its process is gone.
156
+ export function releaseTracker(pid = null) {
157
+ try {
158
+ const current = readTrackerRecord();
159
+
160
+ if (current && (pid === null ? !isProcessAlive(current.pid) : current.pid === pid)) {
161
+ fs.rmSync(TRACKER_PATH, {force: true});
162
+ }
163
+ } catch {
164
+ // Nothing to remove.
165
+ }
166
+ }
@@ -0,0 +1,30 @@
1
+ // What KitCode says when its tracker is stopped or degraded, in fixed words: the prompt hook tells the
2
+ // agent (hook-prompt.mjs), and the dashboard tells the user (apps/web). tracker.json and /api/health
3
+ // give problem codes only, and a code this version does not know gets the general sentence, so nothing
4
+ // written into either becomes text. No imports: the dashboard runs this in the browser.
5
+
6
+ export const TRACKER_STOPPED = 'KitCode tracker is stopped, so focus time is not being counted; `kitcode track` starts it.';
7
+
8
+ const PROBLEM_SENTENCES = {
9
+ 'watch-failed': 'KitCode tracker could not watch a project folder, so it checks that folder every few seconds instead.',
10
+ 'project-missing': 'A KitCode project folder is missing, so nothing in it is counted.',
11
+ 'scan-failed': 'KitCode tracker could not read a project, so its = are not being counted.',
12
+ 'git-locked': 'A KitCode project\'s git index has stayed locked (index.lock in its .git folder), so nothing in it is counted until the lock goes; a git command that crashed can leave that file behind.',
13
+ };
14
+ const UNKNOWN_PROBLEM = 'KitCode tracker is working with a problem; its log is ~/.kitcode/tracker.log.';
15
+
16
+ // A problem code fit to print in a terminal. The tracker's own codes are short identifiers
17
+ // (watch-failed); anything with other characters came from a process squatting the port
18
+ // (docs/security.md), so only identifier characters are kept and the rest is dropped, and the length
19
+ // is capped, so no escape or control sequence reaches the terminal.
20
+ export function safeProblemCode(code) {
21
+ const cleaned = String(code ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40);
22
+ return cleaned || 'unknown';
23
+ }
24
+
25
+ // One sentence per kind of problem, in the order first seen; the general one when there is none.
26
+ export function problemSentences(problems) {
27
+ const codes = (Array.isArray(problems) ? problems : []).map((problem) => problem?.code);
28
+ const sentences = new Set(codes.map((code) => (Object.hasOwn(PROBLEM_SENTENCES, code) ? PROBLEM_SENTENCES[code] : UNKNOWN_PROBLEM)));
29
+ return [...(sentences.size > 0 ? sentences : [UNKNOWN_PROBLEM])];
30
+ }
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import {writeFileAtomically} from './atomic-file.mjs';
5
+ import {STORE_DIR} from './store.mjs';
6
+
7
+ // How `kitcode untrack` and `kitcode uninstall` stop the tracker. On macOS and Linux they send SIGTERM,
8
+ // and the tracker's handler scans the changes still waiting, saves, and logs that it stopped. Windows has
9
+ // no SIGTERM: Node ends the process there at once, and no handler runs, so what the last periodic save
10
+ // missed was lost, and the log never said the tracker stopped (the first Windows CI run, 2026-09-29;
11
+ // docs/lessons-learned.md). There they leave a stop request instead, which the tracker reads every second
12
+ // and answers as it answers SIGTERM (the operator's decision, 2026-09-29).
13
+ //
14
+ // Only a process running as the user can write ~/.kitcode, and such a process can end the tracker anyway:
15
+ // the request opens no new way in. It names one tracker, by the process and the moment tracker.json says
16
+ // it started, because Windows soon reuses a process id: a request left behind never stops a tracker
17
+ // started later, and whichever tracker finds it removes it.
18
+ export const STOP_REQUEST_PATH = path.join(STORE_DIR, 'tracker-stop.json');
19
+
20
+ const STOP_REQUEST_MODE = 0o600;
21
+ const STOP_CHECK_MS = 1000;
22
+
23
+ // Asks the tracker `record` describes (tracker.json) to stop, as this platform allows. Throws what
24
+ // process.kill throws, ESRCH when the process has gone, or, on Windows, what writing the request throws.
25
+ export function askTrackerToStop(record, platform = process.platform) {
26
+ if (platform === 'win32') {
27
+ writeFileAtomically(STOP_REQUEST_PATH, `${JSON.stringify({pid: record.pid, startedAt: record.startedAt})}\n`, {mode: STOP_REQUEST_MODE});
28
+ return;
29
+ }
30
+
31
+ process.kill(record.pid, 'SIGTERM');
32
+ }
33
+
34
+ // The request waiting, removed as it is read: null when there is none. One that cannot be read, or is not
35
+ // JSON, reads as a request for no tracker. Nothing here throws, since it runs inside the tracker.
36
+ function takeStopRequest() {
37
+ if (!fs.existsSync(STOP_REQUEST_PATH)) {
38
+ return null;
39
+ }
40
+
41
+ let request = {};
42
+
43
+ try {
44
+ request = JSON.parse(fs.readFileSync(STOP_REQUEST_PATH, 'utf8'));
45
+ } catch {
46
+ // Read as a request for no tracker, and removed below.
47
+ }
48
+
49
+ try {
50
+ fs.rmSync(STOP_REQUEST_PATH, {force: true});
51
+ } catch {
52
+ // Removed at the next check.
53
+ }
54
+
55
+ return request;
56
+ }
57
+
58
+ // Run by the tracker, with its own record: calls `onStop` once, when a request names it. Every request it
59
+ // finds is removed, its own and any other, since one tracker runs per HOME: a request for another tracker
60
+ // is one left behind.
61
+ export function watchForStopRequest(record, onStop) {
62
+ const timer = setInterval(() => {
63
+ const request = takeStopRequest();
64
+
65
+ if (request?.pid === record.pid && request?.startedAt === record.startedAt) {
66
+ clearInterval(timer);
67
+ onStop();
68
+ }
69
+ }, STOP_CHECK_MS);
70
+
71
+ timer.unref();
72
+ return () => clearInterval(timer);
73
+ }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import process from 'node:process';
3
+ import {hookStatus} from './hook-installers.mjs';
4
+ import {uninstallIntegration} from './integration-installers.mjs';
5
+ import {AGENT_SPECS} from './integration-spec.mjs';
6
+ import {uninstallSkillTree} from './skill-installer.mjs';
7
+ import {STORE_DIR} from './store.mjs';
8
+ import {findRunningTracker, isProcessAlive, releaseTracker} from './tracker-instance.mjs';
9
+ import {askTrackerToStop} from './tracker-stop.mjs';
10
+
11
+ const INTEGRATION_SOURCES = Object.keys(AGENT_SPECS);
12
+
13
+ function sleep(ms) {
14
+ return new Promise((resolve) => setTimeout(resolve, ms));
15
+ }
16
+
17
+ // Stops the tracker tracker.json names, if it answers as that tracker: a process that does not may
18
+ // be anything, since a pid is reused after a restart.
19
+ export async function stopTrackerForUninstall() {
20
+ const running = await findRunningTracker();
21
+ const host = running?.record.host ?? '127.0.0.1';
22
+ const port = Number(running?.record.port ?? 4747);
23
+
24
+ if (!running || running.state === 'unresponsive') {
25
+ releaseTracker();
26
+ return {stopped: false, host, port};
27
+ }
28
+
29
+ // SIGTERM, or on Windows, which has none, a stop request (tracker-stop.mjs).
30
+ try {
31
+ askTrackerToStop(running.record);
32
+ } catch {
33
+ releaseTracker();
34
+ return {stopped: false, host, port};
35
+ }
36
+
37
+ for (let attempt = 0; attempt < 50; attempt += 1) {
38
+ if (!isProcessAlive(running.record.pid)) {
39
+ releaseTracker();
40
+ return {stopped: true, host, port};
41
+ }
42
+
43
+ await sleep(200);
44
+ }
45
+
46
+ return {stopped: 'requested', host, port};
47
+ }
48
+
49
+ export async function uninstallKitCode() {
50
+ // A config KitCode cannot parse may still call the runner under ~/.kitcode, and so may one the user
51
+ // made read-only, which KitCode does not write. Removing the runner but not the hook would leave a
52
+ // hook error on every prompt, so nothing is changed at all. A read-only config with no KitCode
53
+ // entry calls no runner, and needs no write.
54
+ const refused = INTEGRATION_SOURCES
55
+ .map((source) => ({source, ...hookStatus(source)}))
56
+ .filter((status) => status.installed === null || (status.installed && status.error?.readOnly));
57
+
58
+ if (refused.length > 0) {
59
+ return {refused};
60
+ }
61
+
62
+ const report = {
63
+ tracker: await stopTrackerForUninstall(),
64
+ integrations: {},
65
+ skills: {},
66
+ store: {removed: false, path: STORE_DIR},
67
+ };
68
+
69
+ for (const source of INTEGRATION_SOURCES) {
70
+ report.integrations[source] = uninstallIntegration(source);
71
+ report.skills[source] = uninstallSkillTree(source);
72
+ }
73
+
74
+ if (fs.existsSync(STORE_DIR)) {
75
+ fs.rmSync(STORE_DIR, {recursive: true, force: true});
76
+ report.store.removed = true;
77
+ }
78
+
79
+ return report;
80
+ }
package/src/vibe.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import {createSourceSnapshot, scanSourceChanges} from './source-snapshot.mjs';
2
+
3
+ export const createVibeSnapshot = createSourceSnapshot;
4
+ export const scanVibeChanges = scanSourceChanges;
@@ -0,0 +1,7 @@
1
+ // A KitCode window shows its own page and nothing else: it opens no other window, and it never
2
+ // navigates away, whatever the page asks. The dashboard opens in the user's browser through the
3
+ // preload bridge (companion-electron.mjs, shell.openExternal), not in a window of ours.
4
+ export function keepWindowOnItsPage(window) {
5
+ window.webContents.setWindowOpenHandler(() => ({action: 'deny'}));
6
+ window.webContents.on('will-navigate', (event) => event.preventDefault());
7
+ }
@@ -0,0 +1,49 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+
4
+ // What Welcome's and the Mini's pages share: their fonts, embedded, and their Content-Security-Policy.
5
+
6
+ // The latin subsets Google Fonts serves, as published in @fontsource/vt323 and @fontsource/fira-code
7
+ // 5.3.0, under the SIL Open Font License 1.1 (fonts/<family>/OFL.txt, from github.com/google/fonts).
8
+ // Embedded as data: URLs, so a page asks no server for them, whether it is a file, a data: URL or
9
+ // served by the tracker. A character outside the subset falls back to the next font in the stack.
10
+ const FONTS = {
11
+ 'vt323': {family: 'VT323', faces: [{weight: 400, file: 'vt323/vt323-latin-400-normal.woff2'}]},
12
+ 'fira-code': {
13
+ family: 'Fira Code',
14
+ faces: [400, 500, 700].map((weight) => ({weight, file: `fira-code/fira-code-latin-${weight}-normal.woff2`})),
15
+ },
16
+ };
17
+
18
+ const fontFaceCache = new Map();
19
+
20
+ export function fontFaces(names) {
21
+ const key = names.join(',');
22
+
23
+ if (!fontFaceCache.has(key)) {
24
+ fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file}) => {
25
+ const data = fs.readFileSync(new URL(`./fonts/${file}`, import.meta.url)).toString('base64');
26
+ return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')}`;
27
+ })).join('\n'));
28
+ }
29
+
30
+ return fontFaceCache.get(key);
31
+ }
32
+
33
+ function sha256Source(text) {
34
+ return `'sha256-${crypto.createHash('sha256').update(text, 'utf8').digest('base64')}'`;
35
+ }
36
+
37
+ // Only the page's own inline script and stylesheet run, by their hashes; fonts come from data: URLs;
38
+ // the page connects only to `connect`, and nothing else loads, frames, submits or changes its base.
39
+ export function contentSecurityPolicy({script, style, connect = []}) {
40
+ return [
41
+ 'default-src \'none\'',
42
+ `script-src ${sha256Source(script)}`,
43
+ `style-src ${sha256Source(style)}`,
44
+ 'font-src data:',
45
+ `connect-src ${connect.length > 0 ? connect.join(' ') : '\'none\''}`,
46
+ 'base-uri \'none\'',
47
+ 'form-action \'none\'',
48
+ ].join('; ');
49
+ }