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.
Files changed (45) hide show
  1. package/dist/commands/doctor.d.ts +2 -21
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +41 -33
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/submit.d.ts.map +1 -1
  6. package/dist/commands/submit.js +17 -1
  7. package/dist/commands/submit.js.map +1 -1
  8. package/dist/lib/config.d.ts +51 -0
  9. package/dist/lib/config.d.ts.map +1 -1
  10. package/dist/lib/config.js +76 -0
  11. package/dist/lib/config.js.map +1 -1
  12. package/dist/lib/deadline.d.ts +92 -14
  13. package/dist/lib/deadline.d.ts.map +1 -1
  14. package/dist/lib/deadline.js +112 -17
  15. package/dist/lib/deadline.js.map +1 -1
  16. package/dist/lib/detect-project.d.ts.map +1 -1
  17. package/dist/lib/detect-project.js +48 -2
  18. package/dist/lib/detect-project.js.map +1 -1
  19. package/dist/lib/detection.d.ts +32 -0
  20. package/dist/lib/detection.d.ts.map +1 -0
  21. package/dist/lib/detection.js +80 -0
  22. package/dist/lib/detection.js.map +1 -0
  23. package/dist/lib/hook-logger.cjs +50 -2
  24. package/dist/lib/platform.d.ts +34 -10
  25. package/dist/lib/platform.d.ts.map +1 -1
  26. package/dist/lib/platform.js +56 -16
  27. package/dist/lib/platform.js.map +1 -1
  28. package/dist/lib/schemas.d.ts +6 -0
  29. package/dist/lib/schemas.d.ts.map +1 -1
  30. package/dist/lib/schemas.js +8 -0
  31. package/dist/lib/schemas.js.map +1 -1
  32. package/dist/lib/shadow.d.ts +79 -0
  33. package/dist/lib/shadow.d.ts.map +1 -0
  34. package/dist/lib/shadow.js +268 -0
  35. package/dist/lib/shadow.js.map +1 -0
  36. package/dist/lib/tracker.d.ts +1 -1
  37. package/dist/lib/tracker.d.ts.map +1 -1
  38. package/dist/lib/tracker.js +42 -4
  39. package/dist/lib/tracker.js.map +1 -1
  40. package/dist/lib/watcher.js +436 -85
  41. package/dist/lib/watcher.js.map +1 -1
  42. package/dist/lib/zip.d.ts.map +1 -1
  43. package/dist/lib/zip.js +24 -1
  44. package/dist/lib/zip.js.map +1 -1
  45. package/package.json +1 -1
@@ -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 { armDeadlineAutoSubmit } from "./deadline.js";
27
+ import { armDeadlinePoll, createAutoSubmitController } from "./deadline.js";
27
28
  import { resumeChainFromLog } from "./chain.js";
28
- import { getProcessList, getConnections, extractConnectionIP } from "./platform.js";
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
- let present;
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
- present = fs.existsSync(watchedConfigPath);
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 either way
834
+ return; // transient fs error proves nothing
575
835
  }
576
- if (present !== configWasPresent) {
577
- emit({ ts: new Date().toISOString(), type: present ? "config_restored" : "config_missing" });
578
- process.stderr.write(`[watcher] ${present ? "config restored" : "CONFIG MISSING"}: ${watchedConfigPath}\n`);
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
- configWasPresent = present;
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 = { ts: new Date().toISOString(), type: "env_detected" };
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
- parseNetworkOutput(stdout);
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 ipMatch = extractConnectionIP(line);
813
- if (!ipMatch)
1107
+ const conn = extractConnection(line);
1108
+ if (!conn)
814
1109
  continue;
815
- const ip = ipMatch[1];
816
- const entry = ipToTool.get(ip);
1110
+ const entry = ipToTool.get(conn.ip);
817
1111
  if (entry) {
818
- if (!detected.has(entry.tool))
819
- detected.set(entry.tool, new Set());
820
- detected.get(entry.tool).add(entry.host);
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 if same tools detected as last check
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
- emit({
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
- // The deadline fires via a wall-clock POLL, not a single absolute setTimeout.
1049
- // setTimeout(fn, msUntilDeadline) is frozen while the machine is asleep /
1050
- // suspended (macOS lid-close / App Nap), so it fires late or, if the deadline
1051
- // elapses entirely during sleep, only once enough *active* time has accrued.
1052
- // Polling Date.now() reconciles on wake: the interval also pauses while the
1053
- // process is suspended, but the first tick after resume sees that wall-clock
1054
- // has passed the deadline and submits within one poll interval. Polling also
1055
- // removes the old ~24.8-day setTimeout ceiling.
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 effectiveDeadline = getEffectiveDeadline(litmusConfig);
1058
- if (effectiveDeadline !== null && Number.isFinite(effectiveDeadline)) {
1059
- let deadlineAutoSubmitFired = false;
1060
- const fireDeadlineAutoSubmit = () => {
1061
- if (deadlineAutoSubmitFired)
1062
- return;
1063
- deadlineAutoSubmitFired = true;
1064
- process.stderr.write("[watcher] deadline reached, auto-submitting\n");
1065
- execFile(process.execPath, [cliBinPath, "submit", "--yes"], { cwd: projectDir, timeout: 5 * 60000 }, (err, stdout, stderr) => {
1066
- if (!err) {
1067
- process.stderr.write("[watcher] auto-submit succeeded\n");
1068
- // The watcher's spawn() in tracker.ts uses stdio:["ignore", "ignore",
1069
- // errFd] anything we write to stderr goes to .litmus/tracker.log,
1070
- // which the candidate isn't looking at. And `stdout` here (the CLI's
1071
- // "Code submitted one step left. Open your dashboard ..." handoff)
1072
- // would otherwise be discarded. Drop a sentinel file at the project
1073
- // root with the dashboard URL extracted from the CLI's output so the
1074
- // candidate's editor / `ls` / file watcher surfaces the next step
1075
- // even if their terminal session is gone.
1076
- const urlMatch = stdout && stdout.match(/https?:\/\/[^\s]+\/candidate\/[A-Za-z0-9_-]+/);
1077
- const handoffUrl = urlMatch ? urlMatch[0] : null;
1078
- if (handoffUrl) {
1079
- process.stderr.write(`[watcher] dashboard URL: ${handoffUrl}\n`);
1080
- // Mode-neutral wording: the assessment may be VIDEO/LIVE_AGENT
1081
- // (record walkthrough) or WRITTEN (complete a written
1082
- // reflection) — the watcher only has the URL, not the mode, so
1083
- // we say "finish your assessment" rather than over-specifying.
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
- emit({ ts: new Date().toISOString(), type: "auto_submit" });
1389
+ catch { /* non-critical */ }
1094
1390
  }
1095
- else {
1096
- process.stderr.write(`[watcher] auto-submit failed: ${stderr || err.message}\n`);
1097
- emit({ ts: new Date().toISOString(), type: "auto_submit_failed", error: (stderr || err.message).slice(0, 500) });
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;