litmus-cli 1.4.3 → 1.4.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/dist/commands/doctor.d.ts +2 -21
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +41 -33
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/submit.d.ts.map +1 -1
- package/dist/commands/submit.js +17 -1
- package/dist/commands/submit.js.map +1 -1
- package/dist/lib/config.d.ts +51 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +76 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/deadline.d.ts +92 -14
- package/dist/lib/deadline.d.ts.map +1 -1
- package/dist/lib/deadline.js +112 -17
- package/dist/lib/deadline.js.map +1 -1
- package/dist/lib/detect-project.d.ts.map +1 -1
- package/dist/lib/detect-project.js +48 -2
- package/dist/lib/detect-project.js.map +1 -1
- package/dist/lib/detection.d.ts +32 -0
- package/dist/lib/detection.d.ts.map +1 -0
- package/dist/lib/detection.js +80 -0
- package/dist/lib/detection.js.map +1 -0
- package/dist/lib/hook-logger.cjs +50 -2
- package/dist/lib/platform.d.ts +34 -10
- package/dist/lib/platform.d.ts.map +1 -1
- package/dist/lib/platform.js +56 -16
- package/dist/lib/platform.js.map +1 -1
- package/dist/lib/schemas.d.ts +6 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +8 -0
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/shadow.d.ts +79 -0
- package/dist/lib/shadow.d.ts.map +1 -0
- package/dist/lib/shadow.js +268 -0
- package/dist/lib/shadow.js.map +1 -0
- package/dist/lib/tracker.d.ts +1 -1
- package/dist/lib/tracker.d.ts.map +1 -1
- package/dist/lib/tracker.js +42 -4
- package/dist/lib/tracker.js.map +1 -1
- package/dist/lib/watcher.js +436 -85
- package/dist/lib/watcher.js.map +1 -1
- package/dist/lib/zip.d.ts.map +1 -1
- package/dist/lib/zip.js +24 -1
- package/dist/lib/zip.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/watcher.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Writes newline-delimited JSON to the activity log.
|
|
15
15
|
*/
|
|
16
16
|
import fs from "fs";
|
|
17
|
+
import os from "os";
|
|
17
18
|
import path from "path";
|
|
18
19
|
import crypto from "crypto";
|
|
19
20
|
import { execSync, execFile } from "child_process";
|
|
@@ -21,11 +22,17 @@ import dns from "dns";
|
|
|
21
22
|
import https from "https";
|
|
22
23
|
import http from "http";
|
|
23
24
|
import { watch as chokidarWatch } from "chokidar";
|
|
24
|
-
import { getEffectiveDeadline } from "./config.js";
|
|
25
|
+
import { getEffectiveDeadline, writeConfig, validateRebuiltTimes, computeTimeSync } from "./config.js";
|
|
25
26
|
import { CLI_VERSION } from "./version.js";
|
|
26
|
-
import {
|
|
27
|
+
import { armDeadlinePoll, createAutoSubmitController } from "./deadline.js";
|
|
27
28
|
import { resumeChainFromLog } from "./chain.js";
|
|
28
|
-
import {
|
|
29
|
+
import { fetchInitMetadata, ServerError } from "./api.js";
|
|
30
|
+
import { ServerTimesSchema } from "./schemas.js";
|
|
31
|
+
import { detectInstalledHooks } from "./ai-tracking.js";
|
|
32
|
+
import { setupAiTracking } from "../commands/init.js";
|
|
33
|
+
import { writeShadowChainCursor, readShadowChainCursor, writeShadowTrackerFile, readShadowConfigById, findShadowConfigForDir, deleteShadowForAssessment, writeRepairFile, removeRepairFile, } from "./shadow.js";
|
|
34
|
+
import { getProcessList, getConnections, extractConnection, resolvePidNames } from "./platform.js";
|
|
35
|
+
import { classifyClaudeProcesses, refineClaudeTool } from "./detection.js";
|
|
29
36
|
// Surface any uncaught throw during startup, before user code reaches the
|
|
30
37
|
// uncaughtException handler installed later. Without this, an early sync
|
|
31
38
|
// failure (createWriteStream EPERM under Windows Defender, import error,
|
|
@@ -47,12 +54,17 @@ process.stderr.write(`[watcher] node ${process.version}, platform=${process.plat
|
|
|
47
54
|
// Touch the nonce file on startup so tracker.ts can distinguish "our watcher
|
|
48
55
|
// is alive" from "a recycled PID happens to match an old tracker.pid".
|
|
49
56
|
// Heartbeat updates the mtime; tracker.ts compares against NONCE_MAX_AGE_MS.
|
|
57
|
+
// Set once the config loads (below); lets the nonce/pid liveness signals be
|
|
58
|
+
// mirrored outside the repo so doctor can find a live watcher after a wipe.
|
|
59
|
+
let shadowAssessmentId = null;
|
|
50
60
|
function touchNonce() {
|
|
51
61
|
if (!noncePath)
|
|
52
62
|
return;
|
|
53
63
|
try {
|
|
54
64
|
const now = new Date();
|
|
55
65
|
fs.writeFileSync(noncePath, String(now.getTime()));
|
|
66
|
+
if (shadowAssessmentId)
|
|
67
|
+
writeShadowTrackerFile(shadowAssessmentId, "tracker.nonce", String(now.getTime()));
|
|
56
68
|
}
|
|
57
69
|
catch { /* non-critical */ }
|
|
58
70
|
}
|
|
@@ -230,6 +242,16 @@ if (litmusConfig && litmusConfig.token && litmusConfig.backendUrl) {
|
|
|
230
242
|
uploadConfig = { token: litmusConfig.token, backendUrl: litmusConfig.backendUrl };
|
|
231
243
|
process.stderr.write("[watcher] upload enabled\n");
|
|
232
244
|
}
|
|
245
|
+
// ENG-1490: assigned at the bottom of this file where the deadline poll is
|
|
246
|
+
// armed; referenced earlier by syncServerTimes (upload responses can arrive
|
|
247
|
+
// before the arm block runs — the optional chain covers that window).
|
|
248
|
+
let autoSubmitController = null;
|
|
249
|
+
// ENG-1453: mirror liveness state outside the repo. The pid is written once
|
|
250
|
+
// (it never changes); the nonce mirror rides every touchNonce from here on.
|
|
251
|
+
if (litmusConfig?.assessmentId) {
|
|
252
|
+
shadowAssessmentId = litmusConfig.assessmentId;
|
|
253
|
+
writeShadowTrackerFile(shadowAssessmentId, "tracker.pid", String(process.pid));
|
|
254
|
+
}
|
|
233
255
|
/**
|
|
234
256
|
* Upload buffered events to server.
|
|
235
257
|
* Returns a Promise that resolves when the HTTP request completes (used by shutdown).
|
|
@@ -285,15 +307,41 @@ function uploadEvents() {
|
|
|
285
307
|
const errorCode = body?.detail?.error;
|
|
286
308
|
if (errorCode === "Session over") {
|
|
287
309
|
process.stderr.write("[watcher] server signaled session over; shutting down\n");
|
|
310
|
+
autoSubmitController?.markDone();
|
|
311
|
+
// ENG-1453: the assessment is over — the mirrored credentials
|
|
312
|
+
// and any repair prompt go with it. This is the cleanup path
|
|
313
|
+
// for candidates who never run another CLI command (abandoned
|
|
314
|
+
// or auto-submitted sessions).
|
|
315
|
+
if (litmusConfig)
|
|
316
|
+
deleteShadowForAssessment(litmusConfig.assessmentId);
|
|
317
|
+
removeRepairFile(projectDir);
|
|
288
318
|
shutdown();
|
|
289
319
|
}
|
|
290
320
|
}
|
|
291
321
|
catch { /* not JSON; nothing to act on */ }
|
|
292
322
|
}
|
|
293
323
|
else {
|
|
324
|
+
// ENG-1453: record the chain position the server just acknowledged,
|
|
325
|
+
// outside the repo. A replacement watcher whose activity.jsonl was
|
|
326
|
+
// wiped resumes from here instead of forking a second genesis chain.
|
|
327
|
+
if (litmusConfig) {
|
|
328
|
+
let ackSeq = -1;
|
|
329
|
+
let ackHash = "";
|
|
330
|
+
for (const e of events) {
|
|
331
|
+
if (typeof e._seq === "number" && e._seq > ackSeq && typeof e._hash === "string") {
|
|
332
|
+
ackSeq = e._seq;
|
|
333
|
+
ackHash = e._hash;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (ackSeq >= 0 && ackHash)
|
|
337
|
+
writeShadowChainCursor(litmusConfig.assessmentId, ackSeq, ackHash);
|
|
338
|
+
}
|
|
294
339
|
// Parse response for server signals (e.g., pending questions)
|
|
295
340
|
try {
|
|
296
341
|
const body = JSON.parse(responseData);
|
|
342
|
+
// ENG-1489: authoritative times ride every activity response.
|
|
343
|
+
if (body.times)
|
|
344
|
+
syncServerTimes(body.times, "heartbeat");
|
|
297
345
|
if (body.pendingQuestions && body.pendingQuestions > 0) {
|
|
298
346
|
const nudgePath = path.join(projectDir, ".litmus", "QUESTION.md");
|
|
299
347
|
const nudge = `# You have ${body.pendingQuestions} unanswered question${body.pendingQuestions > 1 ? "s" : ""}\n\nPlease check your Litmus browser tab to view and answer.\n`;
|
|
@@ -387,6 +435,17 @@ function startChain() {
|
|
|
387
435
|
const chainStart = resumeChainFromLog(activityLogPath);
|
|
388
436
|
prevHash = chainStart.prevHash;
|
|
389
437
|
nextSeq = chainStart.nextSeq;
|
|
438
|
+
// ENG-1453: a genesis resume with no local log usually means the repo's
|
|
439
|
+
// .litmus was wiped, not that this is a fresh assessment. Continue from
|
|
440
|
+
// the last server-acknowledged position instead of forking the chain.
|
|
441
|
+
if (nextSeq === 0 && litmusConfig) {
|
|
442
|
+
const cursor = readShadowChainCursor(litmusConfig.assessmentId);
|
|
443
|
+
if (cursor) {
|
|
444
|
+
prevHash = cursor.hash;
|
|
445
|
+
nextSeq = cursor.seq + 1;
|
|
446
|
+
process.stderr.write(`[watcher] chain resumed from shadow cursor at seq ${cursor.seq}\n`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
390
449
|
const queued = preChainBuffer;
|
|
391
450
|
preChainBuffer = null;
|
|
392
451
|
for (const event of queued)
|
|
@@ -565,23 +624,240 @@ const watchedConfigPath = path.join(projectDir, ".litmus", "config.json");
|
|
|
565
624
|
// without one (tests, manual spawns) never saw a transition and has no
|
|
566
625
|
// upload credentials to report it anyway.
|
|
567
626
|
let configWasPresent = fs.existsSync(watchedConfigPath);
|
|
627
|
+
/** present, or why not. Null = transient fs error, proves nothing. */
|
|
628
|
+
function probeConfig() {
|
|
629
|
+
try {
|
|
630
|
+
if (!fs.existsSync(watchedConfigPath))
|
|
631
|
+
return { present: false, reason: "absent" };
|
|
632
|
+
try {
|
|
633
|
+
JSON.parse(fs.readFileSync(watchedConfigPath, "utf8"));
|
|
634
|
+
return { present: true };
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return { present: false, reason: "corrupt" };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
568
644
|
function checkConfigPresence() {
|
|
569
|
-
|
|
645
|
+
const probe = probeConfig();
|
|
646
|
+
if (probe === null)
|
|
647
|
+
return;
|
|
648
|
+
if (probe.present !== configWasPresent) {
|
|
649
|
+
emit(probe.present
|
|
650
|
+
? { ts: new Date().toISOString(), type: "config_restored" }
|
|
651
|
+
: { ts: new Date().toISOString(), type: "config_missing", detail: probe.reason });
|
|
652
|
+
process.stderr.write(`[watcher] ${probe.present ? "config restored" : `CONFIG ${probe.reason.toUpperCase()}`}: ${watchedConfigPath}\n`);
|
|
653
|
+
if (probe.present)
|
|
654
|
+
removeRepairFile(projectDir);
|
|
655
|
+
}
|
|
656
|
+
configWasPresent = probe.present;
|
|
657
|
+
// ENG-1453 self-heal: don't just report the outage — end it. Retried every
|
|
658
|
+
// heartbeat until something works, so a transient offline stretch only
|
|
659
|
+
// delays the repair.
|
|
660
|
+
if (!probe.present)
|
|
661
|
+
void selfHealConfig(probe.reason);
|
|
662
|
+
}
|
|
663
|
+
// ── Config self-heal (ENG-1453) ──────────────────────────────────
|
|
664
|
+
// The watcher outlives the file: it kept the full config in memory from
|
|
665
|
+
// spawn, so a deleted/corrupted config.json is restorable with zero
|
|
666
|
+
// candidate action. Order of preference:
|
|
667
|
+
// 1. Fresh server fetch — times re-validated, can never be stale.
|
|
668
|
+
// 2. The ~/.litmus shadow mirror — offline continuity, same validation.
|
|
669
|
+
// 3. This process's own in-memory config — valid at spawn, same
|
|
670
|
+
// validation; also re-mirrors the shadow, so a destroyed shadow
|
|
671
|
+
// self-repairs too.
|
|
672
|
+
// 4. Everything failed (in practice: .litmus unwritable): write
|
|
673
|
+
// LITMUS-REPAIR.md into the project root pointing at the assessment
|
|
674
|
+
// page's "Something not working?" panel — NEVER the token itself; a
|
|
675
|
+
// project-root file can be committed, pushed, or read into an agent's
|
|
676
|
+
// context. Rewritten every heartbeat, removed the moment the config
|
|
677
|
+
// is back.
|
|
678
|
+
// Every restore is loud: a `config_restored` event with its source, never a
|
|
679
|
+
// silent write. A corrupt file is moved aside first, never destroyed.
|
|
680
|
+
let healInFlight = false;
|
|
681
|
+
async function selfHealConfig(reason) {
|
|
682
|
+
if (healInFlight)
|
|
683
|
+
return;
|
|
684
|
+
healInFlight = true;
|
|
570
685
|
try {
|
|
571
|
-
|
|
686
|
+
const base = litmusConfig;
|
|
687
|
+
if (reason === "corrupt") {
|
|
688
|
+
try {
|
|
689
|
+
fs.renameSync(watchedConfigPath, `${watchedConfigPath}.corrupt-${Date.now()}`);
|
|
690
|
+
}
|
|
691
|
+
catch { /* already gone */ }
|
|
692
|
+
}
|
|
693
|
+
let restored = null;
|
|
694
|
+
let restoredFrom = null;
|
|
695
|
+
if (base) {
|
|
696
|
+
try {
|
|
697
|
+
const meta = await fetchInitMetadata(base.apiBase, base.token);
|
|
698
|
+
const candidate = {
|
|
699
|
+
...base,
|
|
700
|
+
assessmentId: meta.assessmentId,
|
|
701
|
+
assessmentName: meta.assessmentName,
|
|
702
|
+
candidateEmail: meta.candidateEmail,
|
|
703
|
+
candidateName: meta.candidateName,
|
|
704
|
+
startedAt: meta.startedAt,
|
|
705
|
+
deadline: meta.deadline,
|
|
706
|
+
timeLimit: meta.timeLimit,
|
|
707
|
+
walkthroughWindowMinutes: meta.walkthroughWindowMinutes,
|
|
708
|
+
backendUrl: meta.backendUrl ?? base.backendUrl,
|
|
709
|
+
cliVersion: CLI_VERSION,
|
|
710
|
+
};
|
|
711
|
+
if (validateRebuiltTimes(candidate, Date.now()) === null) {
|
|
712
|
+
restored = candidate;
|
|
713
|
+
restoredFrom = "server";
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch { /* offline or rejected — try the shadow */ }
|
|
717
|
+
}
|
|
718
|
+
if (!restored) {
|
|
719
|
+
const shadow = (base ? readShadowConfigById(base.assessmentId) : null) ?? findShadowConfigForDir(projectDir);
|
|
720
|
+
if (shadow && validateRebuiltTimes(shadow, Date.now()) === null) {
|
|
721
|
+
const { projectDir: _dir, mirroredAt: _at, ...config } = shadow;
|
|
722
|
+
restored = config;
|
|
723
|
+
restoredFrom = "shadow";
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!restored && base && validateRebuiltTimes(base, Date.now()) === null) {
|
|
727
|
+
restored = base;
|
|
728
|
+
restoredFrom = "memory";
|
|
729
|
+
}
|
|
730
|
+
let healed = false;
|
|
731
|
+
if (restored && restoredFrom) {
|
|
732
|
+
try {
|
|
733
|
+
await writeConfig(projectDir, restored); // re-mirrors the shadow too
|
|
734
|
+
litmusConfig = restored;
|
|
735
|
+
if (restored.token && restored.backendUrl) {
|
|
736
|
+
uploadConfig = { token: restored.token, backendUrl: restored.backendUrl };
|
|
737
|
+
}
|
|
738
|
+
shadowAssessmentId = restored.assessmentId;
|
|
739
|
+
configWasPresent = true; // the transition check must not double-report
|
|
740
|
+
removeRepairFile(projectDir);
|
|
741
|
+
// A restored config can carry moved times (the server fetch above is
|
|
742
|
+
// exactly how an extension reaches a session whose config was wiped).
|
|
743
|
+
// The poll getter picks the new deadline up by itself, but the
|
|
744
|
+
// controller's retry budget must reset too — otherwise attempts
|
|
745
|
+
// exhausted against the OLD cutoff leave the NEW one unarmed.
|
|
746
|
+
autoSubmitController?.noteDeadlineChanged(getEffectiveDeadline(restored));
|
|
747
|
+
emit({ ts: new Date().toISOString(), type: "config_restored", restoredFrom });
|
|
748
|
+
process.stderr.write(`[watcher] config self-healed from ${restoredFrom}\n`);
|
|
749
|
+
healed = true;
|
|
750
|
+
}
|
|
751
|
+
catch { /* .litmus unwritable — candidate guidance below */ }
|
|
752
|
+
}
|
|
753
|
+
if (!healed && base)
|
|
754
|
+
writeRepairFile(projectDir);
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
healInFlight = false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// ── Server time sync (ENG-1489) ──────────────────────────────────
|
|
761
|
+
// The config is written once at init, but the server's times can move
|
|
762
|
+
// mid-session (extension, per-candidate override, support correction). Every
|
|
763
|
+
// /cli/activity response now carries the authoritative times; fold them in
|
|
764
|
+
// here. The deadline poll reads the effective deadline through a getter, so
|
|
765
|
+
// updating litmusConfig IS the re-arm — no timer surgery.
|
|
766
|
+
function syncServerTimes(raw, source) {
|
|
767
|
+
if (!litmusConfig)
|
|
768
|
+
return;
|
|
769
|
+
const parsed = ServerTimesSchema.safeParse(raw);
|
|
770
|
+
if (!parsed.success)
|
|
771
|
+
return;
|
|
772
|
+
const result = computeTimeSync(litmusConfig, parsed.data, Date.now());
|
|
773
|
+
if (result === null)
|
|
774
|
+
return;
|
|
775
|
+
if (result.kind === "refused") {
|
|
776
|
+
process.stderr.write(`[watcher] server time sync refused: ${result.reason}\n`);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const previousDeadlineMs = getEffectiveDeadline(litmusConfig);
|
|
780
|
+
litmusConfig = result.next;
|
|
781
|
+
const newDeadlineMs = getEffectiveDeadline(result.next);
|
|
782
|
+
// Persist so a restart (and doctor, and the shadow) see the synced times.
|
|
783
|
+
// Failure is non-fatal: the in-memory config and the poll are already
|
|
784
|
+
// updated, and the config self-heal machinery owns bigger file problems.
|
|
785
|
+
void writeConfig(projectDir, result.next).catch(() => {
|
|
786
|
+
process.stderr.write("[watcher] synced times could not be written to config.json (in-memory values still apply)\n");
|
|
787
|
+
});
|
|
788
|
+
const iso = (ms) => (ms === null ? null : new Date(ms).toISOString());
|
|
789
|
+
emit({
|
|
790
|
+
ts: new Date().toISOString(),
|
|
791
|
+
type: "deadline_synced",
|
|
792
|
+
previousDeadline: iso(previousDeadlineMs),
|
|
793
|
+
newDeadline: iso(newDeadlineMs),
|
|
794
|
+
timeLimit: result.next.timeLimit ?? null,
|
|
795
|
+
source,
|
|
796
|
+
});
|
|
797
|
+
process.stderr.write(`[watcher] times synced from server (${source}): effective deadline ${iso(previousDeadlineMs) ?? "none"} -> ${iso(newDeadlineMs) ?? "none"}\n`);
|
|
798
|
+
autoSubmitController?.noteDeadlineChanged(newDeadlineMs);
|
|
799
|
+
}
|
|
800
|
+
// ── Hook self-heal (ENG-1453) ────────────────────────────────────
|
|
801
|
+
// The capture hooks are files too (user-scope configs, the repo-scope Copilot
|
|
802
|
+
// hook, the logger, the registry entry) and any of them can be deleted or
|
|
803
|
+
// clobbered mid-session. Verify every leg each heartbeat and reinstall
|
|
804
|
+
// through the same idempotent setup init uses. Transition events only —
|
|
805
|
+
// `hooks_missing` when a healthy setup breaks, `hooks_restored` when it is
|
|
806
|
+
// whole again — so a long-broken leg doesn't spam.
|
|
807
|
+
let lastHooksHealthy = true; // init installs the hooks before spawning us
|
|
808
|
+
function missingHookLegs() {
|
|
809
|
+
const hooks = detectInstalledHooks(projectDir);
|
|
810
|
+
const legs = [];
|
|
811
|
+
if (hooks.claude === null)
|
|
812
|
+
legs.push("claude");
|
|
813
|
+
if (!hooks.codex)
|
|
814
|
+
legs.push("codex");
|
|
815
|
+
if (!hooks.cursor)
|
|
816
|
+
legs.push("cursor");
|
|
817
|
+
if (!hooks.copilot)
|
|
818
|
+
legs.push("copilot");
|
|
819
|
+
if (!hooks.loggerPresent)
|
|
820
|
+
legs.push("logger");
|
|
821
|
+
if (!hooks.registered)
|
|
822
|
+
legs.push("registry");
|
|
823
|
+
return legs;
|
|
824
|
+
}
|
|
825
|
+
function checkHooksPresence() {
|
|
826
|
+
// Hooks capture nothing without credentials — let the config heal first.
|
|
827
|
+
if (!configWasPresent)
|
|
828
|
+
return;
|
|
829
|
+
let legs;
|
|
830
|
+
try {
|
|
831
|
+
legs = missingHookLegs();
|
|
572
832
|
}
|
|
573
833
|
catch {
|
|
574
|
-
return; // transient fs error proves nothing
|
|
834
|
+
return; // transient fs error proves nothing
|
|
575
835
|
}
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
|
|
836
|
+
if (legs.length === 0) {
|
|
837
|
+
if (!lastHooksHealthy)
|
|
838
|
+
emit({ ts: new Date().toISOString(), type: "hooks_restored" });
|
|
839
|
+
lastHooksHealthy = true;
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (lastHooksHealthy) {
|
|
843
|
+
emit({ ts: new Date().toISOString(), type: "hooks_missing", legs });
|
|
844
|
+
process.stderr.write(`[watcher] HOOKS MISSING: ${legs.join(", ")}\n`);
|
|
845
|
+
}
|
|
846
|
+
lastHooksHealthy = false;
|
|
847
|
+
try {
|
|
848
|
+
setupAiTracking(projectDir);
|
|
849
|
+
if (missingHookLegs().length === 0) {
|
|
850
|
+
emit({ ts: new Date().toISOString(), type: "hooks_restored", restoredFrom: "reinstall" });
|
|
851
|
+
process.stderr.write("[watcher] hooks self-healed\n");
|
|
852
|
+
lastHooksHealthy = true;
|
|
853
|
+
}
|
|
579
854
|
}
|
|
580
|
-
|
|
855
|
+
catch { /* leave broken; retried next heartbeat, reported already */ }
|
|
581
856
|
}
|
|
582
857
|
// ── Heartbeats ───────────────────────────────────────────────────
|
|
583
858
|
function emitHeartbeat() {
|
|
584
859
|
checkConfigPresence(); // before the heartbeat so both ride the same upload
|
|
860
|
+
checkHooksPresence();
|
|
585
861
|
emit({ ts: new Date().toISOString(), type: "heartbeat" });
|
|
586
862
|
uploadEvents(); // Upload buffered events to server
|
|
587
863
|
touchNonce(); // Keep our liveness signal fresh for the CLI's PID-reuse check
|
|
@@ -669,7 +945,6 @@ const EDITOR_PATTERNS = {
|
|
|
669
945
|
zed: /\bzed\b/,
|
|
670
946
|
};
|
|
671
947
|
const AI_TOOL_PATTERNS = {
|
|
672
|
-
claude: /\bclaude\b/,
|
|
673
948
|
aider: /\baider\b/,
|
|
674
949
|
codeium: /\bcodeium\b/,
|
|
675
950
|
codex: /\bcodex\b/,
|
|
@@ -694,12 +969,18 @@ function detectEnvironment() {
|
|
|
694
969
|
const aiTools = Object.entries(AI_TOOL_PATTERNS)
|
|
695
970
|
.filter(([, re]) => re.test(ps))
|
|
696
971
|
.map(([name]) => name);
|
|
972
|
+
aiTools.push(...classifyClaudeProcesses(ps));
|
|
697
973
|
// Cursor is both an editor and an AI tool
|
|
698
974
|
if (editors.includes("cursor") && !aiTools.includes("cursor")) {
|
|
699
975
|
aiTools.push("cursor");
|
|
700
976
|
}
|
|
701
977
|
if (editors.length > 0 || aiTools.length > 0) {
|
|
702
|
-
const event = {
|
|
978
|
+
const event = {
|
|
979
|
+
ts: new Date().toISOString(),
|
|
980
|
+
type: "env_detected",
|
|
981
|
+
platform: process.platform,
|
|
982
|
+
osRelease: os.release(),
|
|
983
|
+
};
|
|
703
984
|
if (editors.length)
|
|
704
985
|
event.editors = editors;
|
|
705
986
|
if (aiTools.length)
|
|
@@ -795,6 +1076,17 @@ function refreshDnsCache() {
|
|
|
795
1076
|
}
|
|
796
1077
|
refreshDnsCache(); // Resolve on startup
|
|
797
1078
|
let lastNetworkTools = ""; // Dedup: JSON stringified tool set from previous check
|
|
1079
|
+
let lastNetworkEmitMs = 0;
|
|
1080
|
+
// Re-emit an UNCHANGED tool set after this long. The old dedup emitted only on
|
|
1081
|
+
// change, so a chat tab left open for a whole session produced exactly one
|
|
1082
|
+
// event — and the grader's recurrence gate (>=2 events before a network-derived
|
|
1083
|
+
// tool may be shown) then read "continuously present" as "seen once, could be a
|
|
1084
|
+
// shared-IP coincidence" and suppressed it. Re-emitting at a slow cadence keeps
|
|
1085
|
+
// the event stream meaning "still present on this scan" without flooding the
|
|
1086
|
+
// activity log: with 5-minute scans, any session spanning ~20+ minutes yields
|
|
1087
|
+
// the two events the gate needs, while a tab that appears on a single scan
|
|
1088
|
+
// still (correctly) does not.
|
|
1089
|
+
const NETWORK_REEMIT_MS = 15 * 60 * 1000;
|
|
798
1090
|
function detectNetworkAI() {
|
|
799
1091
|
if (Date.now() - lastDnsRefresh > DNS_REFRESH_MS)
|
|
800
1092
|
refreshDnsCache();
|
|
@@ -803,30 +1095,38 @@ function detectNetworkAI() {
|
|
|
803
1095
|
getConnections((err, stdout) => {
|
|
804
1096
|
if (err || !stdout)
|
|
805
1097
|
return;
|
|
806
|
-
|
|
1098
|
+
// Windows netstat rows carry only a PID; resolve names first so the
|
|
1099
|
+
// owner-based classification below works there too.
|
|
1100
|
+
resolvePidNames((pidNames) => parseNetworkOutput(stdout, pidNames));
|
|
807
1101
|
});
|
|
808
1102
|
}
|
|
809
|
-
function parseNetworkOutput(output) {
|
|
1103
|
+
function parseNetworkOutput(output, pidNames) {
|
|
810
1104
|
const detected = new Map(); // tool -> Set<host>
|
|
1105
|
+
const owners = new Set();
|
|
811
1106
|
for (const line of output.split("\n")) {
|
|
812
|
-
const
|
|
813
|
-
if (!
|
|
1107
|
+
const conn = extractConnection(line);
|
|
1108
|
+
if (!conn)
|
|
814
1109
|
continue;
|
|
815
|
-
const
|
|
816
|
-
const entry = ipToTool.get(ip);
|
|
1110
|
+
const entry = ipToTool.get(conn.ip);
|
|
817
1111
|
if (entry) {
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1112
|
+
const owner = conn.owner || (conn.pid && pidNames?.get(conn.pid)) || "";
|
|
1113
|
+
const tool = refineClaudeTool(entry.tool, owner);
|
|
1114
|
+
if (owner)
|
|
1115
|
+
owners.add(owner);
|
|
1116
|
+
if (!detected.has(tool))
|
|
1117
|
+
detected.set(tool, new Set());
|
|
1118
|
+
detected.get(tool).add(entry.host);
|
|
821
1119
|
}
|
|
822
1120
|
}
|
|
823
1121
|
if (detected.size === 0)
|
|
824
1122
|
return;
|
|
825
|
-
// Dedup: don't re-emit
|
|
1123
|
+
// Dedup: don't re-emit an unchanged tool set — except after
|
|
1124
|
+
// NETWORK_REEMIT_MS, so sustained presence stays visible in the stream.
|
|
826
1125
|
const toolKey = JSON.stringify([...detected.keys()].sort());
|
|
827
|
-
if (toolKey === lastNetworkTools)
|
|
1126
|
+
if (toolKey === lastNetworkTools && Date.now() - lastNetworkEmitMs < NETWORK_REEMIT_MS)
|
|
828
1127
|
return;
|
|
829
1128
|
lastNetworkTools = toolKey;
|
|
1129
|
+
lastNetworkEmitMs = Date.now();
|
|
830
1130
|
const tools = [];
|
|
831
1131
|
const hosts = [];
|
|
832
1132
|
for (const [tool, hostSet] of detected) {
|
|
@@ -834,12 +1134,15 @@ function parseNetworkOutput(output) {
|
|
|
834
1134
|
for (const h of hostSet)
|
|
835
1135
|
hosts.push(h);
|
|
836
1136
|
}
|
|
837
|
-
|
|
1137
|
+
const event = {
|
|
838
1138
|
ts: new Date().toISOString(),
|
|
839
1139
|
type: "network_ai_detected",
|
|
840
1140
|
tools,
|
|
841
1141
|
hosts,
|
|
842
|
-
}
|
|
1142
|
+
};
|
|
1143
|
+
if (owners.size)
|
|
1144
|
+
event.owners = [...owners].sort();
|
|
1145
|
+
emit(event);
|
|
843
1146
|
}
|
|
844
1147
|
detectNetworkAI(); // Run immediately
|
|
845
1148
|
setInterval(detectNetworkAI, 5 * 60 * 1000); // Every 5 minutes
|
|
@@ -877,6 +1180,7 @@ const SNAPSHOT_IGNORE = [
|
|
|
877
1180
|
/package-lock\.json$/,
|
|
878
1181
|
/yarn\.lock$/,
|
|
879
1182
|
/pnpm-lock\.yaml$/,
|
|
1183
|
+
/bun\.lockb?$/,
|
|
880
1184
|
// Likely-secret files. Defensive: dotfile rule catches `.env` and `.envrc`,
|
|
881
1185
|
// but a candidate's `secrets.json`, `credentials.yaml`, RSA keys, or PEM
|
|
882
1186
|
// certificates would otherwise stream to /cli/snapshot. The snapshot is
|
|
@@ -1045,70 +1349,117 @@ setTimeout(() => {
|
|
|
1045
1349
|
setInterval(uploadSnapshot, SNAPSHOT_INTERVAL_MS);
|
|
1046
1350
|
}, 60 * 1000);
|
|
1047
1351
|
// ── Auto-submit at deadline ──────────────────────────────────────
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1352
|
+
// Wall-clock POLL (see deadline.ts for the sleep/wake rationale). The poll
|
|
1353
|
+
// reads the effective deadline through a getter, so ENG-1489 server syncs
|
|
1354
|
+
// re-arm it by simply updating litmusConfig. The fire decision itself is
|
|
1355
|
+
// server-verified (ENG-1490): a stale-early local deadline must never turn
|
|
1356
|
+
// into an accepted early submission — in the 96h incident the only reason a
|
|
1357
|
+
// candidate wasn't auto-submitted ~88h early is that the submit happened to
|
|
1358
|
+
// fail. Failures retry with bounded backoff; the server-side cron remains
|
|
1359
|
+
// the final backstop.
|
|
1056
1360
|
if (cliBinPath && litmusConfig) {
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const note = "Your code was auto-submitted at the time limit.\n\n" +
|
|
1085
|
-
"Open this URL in your browser to finish your assessment:\n\n" +
|
|
1086
|
-
` ${handoffUrl}\n\n` +
|
|
1087
|
-
`You have about ${litmusConfig.walkthroughWindowMinutes ?? 10} minutes. If you miss it, your submission will be graded on code alone.\n`;
|
|
1088
|
-
try {
|
|
1089
|
-
fs.writeFileSync(path.join(projectDir, "NEXT-STEP.txt"), note, "utf8");
|
|
1090
|
-
}
|
|
1091
|
-
catch { /* non-critical */ }
|
|
1361
|
+
const runAutoSubmit = () => new Promise((resolveRun) => {
|
|
1362
|
+
process.stderr.write("[watcher] deadline reached (server-verified), auto-submitting\n");
|
|
1363
|
+
execFile(process.execPath, [cliBinPath, "submit", "--yes"], { cwd: projectDir, timeout: 5 * 60000 }, (err, stdout, stderr) => {
|
|
1364
|
+
if (!err) {
|
|
1365
|
+
process.stderr.write("[watcher] auto-submit succeeded\n");
|
|
1366
|
+
// The watcher's spawn() in tracker.ts uses stdio:["ignore", "ignore",
|
|
1367
|
+
// errFd] — anything we write to stderr goes to the tracker log,
|
|
1368
|
+
// which the candidate isn't looking at. And `stdout` here (the CLI's
|
|
1369
|
+
// "Code submitted — one step left. Open your dashboard ..." handoff)
|
|
1370
|
+
// would otherwise be discarded. Drop a sentinel file at the project
|
|
1371
|
+
// root with the dashboard URL extracted from the CLI's output so the
|
|
1372
|
+
// candidate's editor / `ls` / file watcher surfaces the next step
|
|
1373
|
+
// even if their terminal session is gone.
|
|
1374
|
+
const urlMatch = stdout && stdout.match(/https?:\/\/[^\s]+\/candidate\/[A-Za-z0-9_-]+/);
|
|
1375
|
+
const handoffUrl = urlMatch ? urlMatch[0] : null;
|
|
1376
|
+
if (handoffUrl) {
|
|
1377
|
+
process.stderr.write(`[watcher] dashboard URL: ${handoffUrl}\n`);
|
|
1378
|
+
// Mode-neutral wording: the assessment may be VIDEO/LIVE_AGENT
|
|
1379
|
+
// (record walkthrough) or WRITTEN (complete a written
|
|
1380
|
+
// reflection) — the watcher only has the URL, not the mode, so
|
|
1381
|
+
// we say "finish your assessment" rather than over-specifying.
|
|
1382
|
+
const note = "Your code was auto-submitted at the time limit.\n\n" +
|
|
1383
|
+
"Open this URL in your browser to finish your assessment:\n\n" +
|
|
1384
|
+
` ${handoffUrl}\n\n` +
|
|
1385
|
+
`You have about ${litmusConfig?.walkthroughWindowMinutes ?? 10} minutes. If you miss it, your submission will be graded on code alone.\n`;
|
|
1386
|
+
try {
|
|
1387
|
+
fs.writeFileSync(path.join(projectDir, "NEXT-STEP.txt"), note, "utf8");
|
|
1092
1388
|
}
|
|
1093
|
-
|
|
1389
|
+
catch { /* non-critical */ }
|
|
1094
1390
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
};
|
|
1101
|
-
armDeadlineAutoSubmit({
|
|
1102
|
-
deadline: effectiveDeadline,
|
|
1103
|
-
pollMs: 30 * 1000,
|
|
1104
|
-
onFire: fireDeadlineAutoSubmit,
|
|
1105
|
-
onArmed: () => process.stderr.write(`[watcher] deadline poll armed for ${new Date(effectiveDeadline).toISOString()} (${Math.round((effectiveDeadline - Date.now()) / 1000)}s)\n`),
|
|
1106
|
-
// Watcher started after the deadline already elapsed (e.g. a restart
|
|
1107
|
-
// post-deadline). The server-side deadline path owns that case; don't
|
|
1108
|
-
// re-submit from here.
|
|
1109
|
-
onAlreadyPassed: () => process.stderr.write("[watcher] deadline already passed at startup, skipping auto-submit\n"),
|
|
1391
|
+
resolveRun({ ok: true });
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
resolveRun({ ok: false, error: (stderr || err.message).slice(0, 500) });
|
|
1395
|
+
}
|
|
1110
1396
|
});
|
|
1111
|
-
}
|
|
1397
|
+
});
|
|
1398
|
+
// Ask the server for the authoritative times and fold them in. Marking a
|
|
1399
|
+
// pending candidate in_progress is the route's documented side effect and a
|
|
1400
|
+
// no-op mid-session.
|
|
1401
|
+
const verifyTimes = async () => {
|
|
1402
|
+
const cfg = litmusConfig;
|
|
1403
|
+
if (!cfg)
|
|
1404
|
+
return { status: "rejected" };
|
|
1405
|
+
try {
|
|
1406
|
+
const meta = await fetchInitMetadata(cfg.apiBase, cfg.token);
|
|
1407
|
+
syncServerTimes({ startedAt: meta.startedAt, deadline: meta.deadline, timeLimit: meta.timeLimit }, "fire_check");
|
|
1408
|
+
return {
|
|
1409
|
+
status: "ok",
|
|
1410
|
+
effectiveDeadline: litmusConfig ? getEffectiveDeadline(litmusConfig) : null,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
const status = err instanceof ServerError ? err.statusCode : undefined;
|
|
1415
|
+
if (typeof status === "number" && status >= 400 && status < 500)
|
|
1416
|
+
return { status: "rejected" };
|
|
1417
|
+
return { status: "unavailable" };
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
autoSubmitController = createAutoSubmitController({
|
|
1421
|
+
verifyTimes,
|
|
1422
|
+
runSubmit: runAutoSubmit,
|
|
1423
|
+
onOutcome: (outcome) => {
|
|
1424
|
+
const ts = new Date().toISOString();
|
|
1425
|
+
switch (outcome.kind) {
|
|
1426
|
+
case "submitted":
|
|
1427
|
+
emit({ ts, type: "auto_submit", attempt: outcome.attempt });
|
|
1428
|
+
break;
|
|
1429
|
+
case "failed": {
|
|
1430
|
+
const final = (outcome.attempt ?? 0) >= 3;
|
|
1431
|
+
process.stderr.write(`[watcher] auto-submit failed (attempt ${outcome.attempt}${final ? ", final" : ""}): ${outcome.detail}\n`);
|
|
1432
|
+
emit({ ts, type: "auto_submit_failed", error: outcome.detail ?? "unknown", attempt: outcome.attempt, final });
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
case "averted":
|
|
1436
|
+
process.stderr.write("[watcher] auto-submit averted: server says the deadline has not passed; poll re-armed with server times\n");
|
|
1437
|
+
emit({
|
|
1438
|
+
ts,
|
|
1439
|
+
type: "auto_submit_averted",
|
|
1440
|
+
serverDeadline: litmusConfig
|
|
1441
|
+
? (() => { const d = getEffectiveDeadline(litmusConfig); return d === null ? null : new Date(d).toISOString(); })()
|
|
1442
|
+
: null,
|
|
1443
|
+
});
|
|
1444
|
+
break;
|
|
1445
|
+
case "deferred":
|
|
1446
|
+
process.stderr.write(`[watcher] auto-submit deferred: ${outcome.detail}\n`);
|
|
1447
|
+
break;
|
|
1448
|
+
case "exhausted":
|
|
1449
|
+
process.stderr.write("[watcher] auto-submit retry budget exhausted; server-side deadline handling owns this session\n");
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
});
|
|
1454
|
+
armDeadlinePoll({
|
|
1455
|
+
getDeadline: () => (litmusConfig ? getEffectiveDeadline(litmusConfig) : null),
|
|
1456
|
+
pollMs: 30 * 1000,
|
|
1457
|
+
onDue: () => { void autoSubmitController?.handleDue(); },
|
|
1458
|
+
onWake: (gapMs) => emit({ ts: new Date().toISOString(), type: "wake_gap", gapMs }),
|
|
1459
|
+
onArmed: (ms) => process.stderr.write(ms === null
|
|
1460
|
+
? "[watcher] deadline poll armed (no deadline configured; server sync may set one)\n"
|
|
1461
|
+
: `[watcher] deadline poll armed for ${new Date(Date.now() + ms).toISOString()} (${Math.round(ms / 1000)}s)\n`),
|
|
1462
|
+
});
|
|
1112
1463
|
}
|
|
1113
1464
|
// ── Graceful shutdown ────────────────────────────────────────────
|
|
1114
1465
|
let shuttingDown = false;
|