fullcourtdefense-cli 1.26.1 → 1.26.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.
@@ -942,7 +942,11 @@ async function runDaemon(args, config) {
942
942
  bundleState = { mode: bundle.mode, source: bundle.source, policyHash: bundle.policyHash };
943
943
  }
944
944
  catch { /* snapshot still useful without mode context */ }
945
- const snapshot = (0, perfSnapshot_1.collectPerfSnapshot)({ cliVersion: cliVersion(), ...bundleState });
945
+ // Async collection the benchmark spawns real hook children that ask
946
+ // THIS daemon for IPC verdicts, so the event loop must stay free or
947
+ // the snapshot measures its own blockage (2.5s IPC timeout per hook)
948
+ // instead of the true per-event cost.
949
+ const snapshot = await (0, perfSnapshot_1.collectPerfSnapshot)({ cliVersion: cliVersion(), ...bundleState });
946
950
  const uploaded = await uploadDiagnostics({ perfSnapshot: snapshot });
947
951
  if (!uploaded)
948
952
  throw new Error('Perf snapshot could not be uploaded (network or backend rejection).');
@@ -105,7 +105,7 @@ async function perfCheck(apiUrl, config) {
105
105
  else {
106
106
  console.log('mode: not enrolled (local defaults, monitor-first)');
107
107
  }
108
- const snapshot = (0, perfSnapshot_1.collectPerfSnapshot)({ mode, source, policyHash });
108
+ const snapshot = await (0, perfSnapshot_1.collectPerfSnapshot)({ mode, source, policyHash });
109
109
  const hp = snapshot.hotPath;
110
110
  console.log(`node spawn floor: ${hp.nodeSpawnFloorMs}ms median — OS+AV cost of any per-event process`);
111
111
  console.log(`hook end-to-end: ${hp.hookMedianMs}ms median, ${hp.hookMinMs}-${hp.hookMaxMs}ms range — per IDE event (shell/MCP/file)`);
@@ -684,6 +684,13 @@ async function evaluateHookRequest(args, stdinRaw, config) {
684
684
  * through respond() inside and never reach this catch.
685
685
  */
686
686
  async function hookCommand(args, config) {
687
+ // Start of the developer's wait. Taken once here and carried (in-memory) so
688
+ // every event this hook spools reports real latency, and the daemon can
689
+ // report the FULL wait rather than just its own slice. No probe, no timer.
690
+ const startedAt = Date.now();
691
+ return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
692
+ }
693
+ async function hookCommandOuter(args, config, startedAt) {
687
694
  try {
688
695
  let raw = '';
689
696
  let stdinErr = '';
@@ -700,7 +707,7 @@ async function hookCommand(args, config) {
700
707
  // the full local evaluation below, so the daemon can never make a hook
701
708
  // slower than it was before this feature. FCD_VERDICT_IPC=off opts out.
702
709
  if (process.env.FCD_VERDICT_IPC !== 'off') {
703
- const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw);
710
+ const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
704
711
  if (ipc.outcome === 'verdict') {
705
712
  dbg({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode });
706
713
  if (ipc.stderr) {
@@ -744,6 +751,12 @@ async function hookCommand(args, config) {
744
751
  */
745
752
  function failOpenOutcome(event, message) {
746
753
  const toolName = event || 'unknown';
754
+ // Label the spooled event below as a crash-allow: a rising fail_open rate is
755
+ // the fleet's earliest signal that the guard itself is broken on a machine.
756
+ try {
757
+ (0, telemetry_1.setVerdictPath)('fail_open');
758
+ }
759
+ catch { /* never throw */ }
747
760
  let suspended = false;
748
761
  try {
749
762
  suspended = (0, runtimeConfig_1.isSuspendedByCache)();
@@ -878,6 +878,13 @@ class McpGatewayServer {
878
878
  (0, telemetry_1.triggerFlush)(decision === 'block');
879
879
  }
880
880
  async handleToolCall(params) {
881
+ // Passive perf stamp for every event this call spools: how long the agent
882
+ // waited for OUR decision (path 'gateway'). Pure in-memory context — no
883
+ // probe, no I/O; the clock is restarted after the downstream call so the
884
+ // tool's own runtime is never billed to FullCourtDefense.
885
+ return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'gateway' }, () => this.handleToolCallInner(params));
886
+ }
887
+ async handleToolCallInner(params) {
881
888
  await this.ensureDownstream();
882
889
  const toolName = String(params.name || '');
883
890
  if (!toolName)
@@ -1248,6 +1255,9 @@ class McpGatewayServer {
1248
1255
  let rawResult = await this.downstream.callTool(toolName, toolArgs);
1249
1256
  downstreamRan = true;
1250
1257
  downstreamResult = rawResult;
1258
+ // Response-side events must measure our scanning overhead, not the
1259
+ // tool's execution time (see restartVerdictTiming).
1260
+ (0, telemetry_1.restartVerdictTiming)();
1251
1261
  const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1252
1262
  for (const warning of responseOutcome.warnings) {
1253
1263
  this.spoolLocalFinding(warning, toolName, operation, 'warn');
@@ -7,6 +7,17 @@
7
7
  * only. Nothing here installs a profiler, starts a timer, or samples in the
8
8
  * background — every number is collected in one shot, in a few seconds, and
9
9
  * the process that collected it exits.
10
+ *
11
+ * NON-BLOCKING BY CONTRACT: everything here uses async `spawn`, never
12
+ * `spawnSync`. The remote action runs INSIDE the daemon — the same process
13
+ * that answers verdict IPC for every live hook. A spawnSync benchmark froze
14
+ * the daemon's event loop, so each benchmarked hook child asked the daemon,
15
+ * got silence, waited out the full IPC response timeout (~2.5s) and fell back
16
+ * to local evaluation: the snapshot reported "hook median 2605ms" on a machine
17
+ * whose real per-event cost was milliseconds — the measurement was poisoning
18
+ * itself. Async spawns keep the daemon answering during the benchmark, so the
19
+ * number is the true production path (and real developer hooks keep their
20
+ * fast path while a snapshot is being collected).
10
21
  */
11
22
  export interface PerfProcessInfo {
12
23
  pid: number;
@@ -67,13 +78,13 @@ export interface PerfSnapshot {
67
78
  distress: unknown[];
68
79
  }
69
80
  /** Micro-bench of the per-event hot path: node spawn floor, hook end-to-end, cmd guard. */
70
- export declare function benchHotPath(entryJs?: string): PerfHotPathBench;
81
+ export declare function benchHotPath(entryJs?: string): Promise<PerfHotPathBench>;
71
82
  /**
72
83
  * Per-process footprint of every FullCourtDefense-owned process (daemon, MCP
73
84
  * gateways, desktop chat watcher, watchdog): working set, cumulative CPU,
74
85
  * uptime, handle count. On-demand only — this spawns ONE OS query and exits.
75
86
  */
76
- export declare function collectProcessFootprint(): PerfProcessInfo[];
87
+ export declare function collectProcessFootprint(): Promise<PerfProcessInfo[]>;
77
88
  /** Disk footprint: our logs/spool sizes, spool backlog, update-attempt markers. */
78
89
  export declare function collectDiskState(): PerfDiskState;
79
90
  export interface CollectPerfSnapshotInput {
@@ -86,6 +97,6 @@ export interface CollectPerfSnapshotInput {
86
97
  entryJs?: string;
87
98
  }
88
99
  /** One-shot collection of the full snapshot. Completes in a few seconds. */
89
- export declare function collectPerfSnapshot(input?: CollectPerfSnapshotInput): PerfSnapshot;
100
+ export declare function collectPerfSnapshot(input?: CollectPerfSnapshotInput): Promise<PerfSnapshot>;
90
101
  /** One-line human summary for machine-action result reporting. */
91
102
  export declare function summarizePerfSnapshot(s: PerfSnapshot): string;
@@ -55,23 +55,61 @@ function fileSizeKB(p) {
55
55
  return undefined;
56
56
  }
57
57
  }
58
+ /**
59
+ * Run one child to completion WITHOUT blocking the event loop, returning
60
+ * elapsed wall time and captured stdout. The event loop staying free is the
61
+ * whole point (see the header): the daemon must keep answering verdict IPC
62
+ * while these children run. Never throws — a spawn failure or timeout still
63
+ * resolves with whatever elapsed/output there is.
64
+ */
65
+ function runTimed(cmd, args, opts) {
66
+ return new Promise((resolve) => {
67
+ const t0 = Date.now();
68
+ let stdout = '';
69
+ let settled = false;
70
+ let killTimer;
71
+ const finish = () => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ if (killTimer)
76
+ clearTimeout(killTimer);
77
+ resolve({ elapsedMs: Date.now() - t0, stdout });
78
+ };
79
+ let child;
80
+ try {
81
+ child = (0, child_process_1.spawn)(cmd, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
82
+ }
83
+ catch {
84
+ finish();
85
+ return;
86
+ }
87
+ killTimer = setTimeout(() => { try {
88
+ child.kill();
89
+ }
90
+ catch { /* already gone */ } }, opts.timeoutMs);
91
+ child.on('error', finish);
92
+ child.on('close', finish);
93
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
94
+ child.stdin?.on('error', () => { });
95
+ if (opts.input !== undefined)
96
+ child.stdin?.write(opts.input);
97
+ child.stdin?.end();
98
+ });
99
+ }
58
100
  /** Micro-bench of the per-event hot path: node spawn floor, hook end-to-end, cmd guard. */
59
- function benchHotPath(entryJs) {
101
+ async function benchHotPath(entryJs) {
60
102
  const entry = entryJs || process.argv[1] || '';
103
+ // Sequential on purpose: overlapping children would contend for CPU and
104
+ // inflate each other's numbers.
61
105
  const spawnTimes = [];
62
106
  for (let i = 0; i < 5; i++) {
63
- const t0 = Date.now();
64
- (0, child_process_1.spawnSync)(process.execPath, ['-e', '0'], { windowsHide: true, timeout: 30_000 });
65
- spawnTimes.push(Date.now() - t0);
107
+ spawnTimes.push((await runTimed(process.execPath, ['-e', '0'], { timeoutMs: 30_000 })).elapsedMs);
66
108
  }
67
109
  const hookEvent = JSON.stringify({ hook_event_name: 'beforeShellExecution', command: 'echo fcd-perf-probe' });
68
110
  const hookTimes = [];
69
111
  for (let i = 0; i < 5; i++) {
70
- const t0 = Date.now();
71
- (0, child_process_1.spawnSync)(process.execPath, [entry, 'hook', '--event', 'shell', '--fcd-managed', 'true'], {
72
- input: hookEvent, encoding: 'utf8', windowsHide: true, timeout: 60_000,
73
- });
74
- hookTimes.push(Date.now() - t0);
112
+ hookTimes.push((await runTimed(process.execPath, [entry, 'hook', '--event', 'shell', '--fcd-managed', 'true'], { input: hookEvent, timeoutMs: 60_000 })).elapsedMs);
75
113
  }
76
114
  let cmdGuardOverheadMs;
77
115
  const guardJs = path.join(os.homedir(), '.fullcourtdefense-cmd-guard.js');
@@ -79,12 +117,8 @@ function benchHotPath(entryJs) {
79
117
  const direct = [];
80
118
  const guarded = [];
81
119
  for (let i = 0; i < 4; i++) {
82
- let t0 = Date.now();
83
- (0, child_process_1.spawnSync)(process.env.ComSpec || 'cmd.exe', ['/d', '/c', 'echo', 'fcd-perf'], { windowsHide: true, timeout: 30_000 });
84
- direct.push(Date.now() - t0);
85
- t0 = Date.now();
86
- (0, child_process_1.spawnSync)(process.execPath, [guardJs, 'echo', 'fcd-perf'], { windowsHide: true, timeout: 30_000 });
87
- guarded.push(Date.now() - t0);
120
+ direct.push((await runTimed(process.env.ComSpec || 'cmd.exe', ['/d', '/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
121
+ guarded.push((await runTimed(process.execPath, [guardJs, 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
88
122
  }
89
123
  cmdGuardOverheadMs = Math.max(0, median(guarded) - median(direct));
90
124
  }
@@ -102,11 +136,11 @@ function benchHotPath(entryJs) {
102
136
  * gateways, desktop chat watcher, watchdog): working set, cumulative CPU,
103
137
  * uptime, handle count. On-demand only — this spawns ONE OS query and exits.
104
138
  */
105
- function collectProcessFootprint() {
139
+ async function collectProcessFootprint() {
106
140
  try {
107
141
  if (process.platform === 'win32') {
108
142
  // On-demand action (never a hot path), so one PowerShell query is fine.
109
- const ps = (0, child_process_1.spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `
143
+ const ps = await runTimed('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `
110
144
  $now = Get-Date
111
145
  Get-CimInstance Win32_Process |
112
146
  Where-Object { $_.CommandLine -and ($_.CommandLine -match 'fullcourtdefense' -or $_.CommandLine -match 'FullCourtDefense') -and $_.CommandLine -notmatch 'Get-CimInstance' } |
@@ -121,7 +155,7 @@ function collectProcessFootprint() {
121
155
  cmd = if ($_.CommandLine.Length -gt 160) { $_.CommandLine.Substring(0,160) } else { $_.CommandLine }
122
156
  }
123
157
  } | ConvertTo-Json -Compress
124
- `], { encoding: 'utf8', windowsHide: true, timeout: 20_000 });
158
+ `], { timeoutMs: 20_000 });
125
159
  const raw = (ps.stdout || '').trim();
126
160
  if (!raw)
127
161
  return [];
@@ -138,7 +172,7 @@ function collectProcessFootprint() {
138
172
  }));
139
173
  }
140
174
  // POSIX: rss (KB), cumulative cpu time, elapsed, args.
141
- const ps = (0, child_process_1.spawnSync)('ps', ['-eo', 'pid=,rss=,time=,etimes=,args='], { encoding: 'utf8', timeout: 20_000 });
175
+ const ps = await runTimed('ps', ['-eo', 'pid=,rss=,time=,etimes=,args='], { timeoutMs: 20_000 });
142
176
  const lines = (ps.stdout || '').split('\n').filter(l => /fullcourtdefense/i.test(l) && !/ps -eo/.test(l));
143
177
  const result = [];
144
178
  for (const line of lines) {
@@ -190,8 +224,8 @@ function collectDiskState() {
190
224
  };
191
225
  }
192
226
  /** One-shot collection of the full snapshot. Completes in a few seconds. */
193
- function collectPerfSnapshot(input = {}) {
194
- const processes = collectProcessFootprint();
227
+ async function collectPerfSnapshot(input = {}) {
228
+ const processes = await collectProcessFootprint();
195
229
  return {
196
230
  collectedAt: new Date().toISOString(),
197
231
  cliVersion: input.cliVersion,
@@ -203,7 +237,7 @@ function collectPerfSnapshot(input = {}) {
203
237
  node: process.version,
204
238
  },
205
239
  enforcement: { mode: input.mode, source: input.source, policyHash: input.policyHash },
206
- hotPath: benchHotPath(input.entryJs),
240
+ hotPath: await benchHotPath(input.entryJs),
207
241
  processes,
208
242
  totalWorkingSetMB: Math.round(processes.reduce((sum, p) => sum + p.workingSetMB, 0) * 10) / 10,
209
243
  disk: collectDiskState(),
@@ -16,8 +16,44 @@ export interface SpoolEvent {
16
16
  policyHash?: string;
17
17
  /** True when this decision was enforced locally while the backend was unreachable. */
18
18
  offlineEnforced?: boolean;
19
+ /**
20
+ * Wall time the developer actually waited for this verdict, in ms — measured
21
+ * from the moment the hook process started to the moment the decision was
22
+ * spooled. Passive: computed from timestamps already taken on the hot path
23
+ * (see markVerdictTiming), never an extra probe, timer, or process.
24
+ */
25
+ latencyMs?: number;
26
+ /** Which path produced the verdict (see VerdictPath). */
27
+ verdictPath?: VerdictPath;
19
28
  occurredAt: string;
20
29
  }
30
+ /**
31
+ * How a verdict was produced — the difference between a healthy machine and a
32
+ * degraded one:
33
+ * - `ipc` resident daemon answered the thin client (the fast path)
34
+ * - `local` full local evaluation in the hook process (daemon down/busy)
35
+ * - `gateway` resident MCP gateway decided in-process (its own fast path)
36
+ * - `fail_open` the hook crashed and allowed by contract
37
+ */
38
+ export type VerdictPath = 'ipc' | 'local' | 'fail_open' | 'gateway';
39
+ interface VerdictTiming {
40
+ startedAt: number;
41
+ path: VerdictPath;
42
+ }
43
+ /** Run one hook evaluation with timing attached to everything it spools. */
44
+ export declare function markVerdictTiming<T>(timing: VerdictTiming, fn: () => T): T;
45
+ /**
46
+ * Re-label the current evaluation (e.g. it ended in the fail-open handler).
47
+ * Keeps the original start time so latency still covers the full wait.
48
+ */
49
+ export declare function setVerdictPath(path: VerdictPath): void;
50
+ /**
51
+ * Restart the current evaluation's clock. Used by the MCP gateway right after
52
+ * the downstream tool returns: everything spooled from then on (response
53
+ * scanning, masking, bookkeeping) must report OUR overhead, not the tool's own
54
+ * runtime — a 30s build tool is not a 30s FullCourtDefense stall.
55
+ */
56
+ export declare function restartVerdictTiming(): void;
21
57
  /** Append one decision to the spool. Never throws (telemetry must not break the hook). */
22
58
  export declare function spoolEvent(event: Omit<SpoolEvent, 'eventId' | 'occurredAt' | 'type'> & Partial<Pick<SpoolEvent, 'occurredAt' | 'type'>>): void;
23
59
  export interface FlushInput {
@@ -76,3 +112,4 @@ export declare function flushSpool(input: FlushInput): Promise<{
76
112
  * (used for critical blocks). The child reads creds from ~/.fullcourtdefense.yml.
77
113
  */
78
114
  export declare function triggerFlush(immediate?: boolean): void;
115
+ export {};
package/dist/telemetry.js CHANGED
@@ -33,9 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.markVerdictTiming = markVerdictTiming;
37
+ exports.setVerdictPath = setVerdictPath;
38
+ exports.restartVerdictTiming = restartVerdictTiming;
36
39
  exports.spoolEvent = spoolEvent;
37
40
  exports.flushSpool = flushSpool;
38
41
  exports.triggerFlush = triggerFlush;
42
+ const async_hooks_1 = require("async_hooks");
39
43
  const child_process_1 = require("child_process");
40
44
  const crypto = __importStar(require("crypto"));
41
45
  const fs = __importStar(require("fs"));
@@ -63,9 +67,50 @@ const MAX_BATCH = 200;
63
67
  const MAX_SPOOL_EVENTS = 2_000;
64
68
  /** Cheap pre-check: skip the trim parse entirely until the file is plausibly over cap. */
65
69
  const SPOOL_TRIM_BYTES = 2 * 1024 * 1024;
70
+ /**
71
+ * Per-evaluation timing context.
72
+ *
73
+ * AsyncLocalStorage (not a module variable) because the daemon evaluates
74
+ * several hook requests CONCURRENTLY in one process: a shared variable would
75
+ * attribute one hook's start time to another hook's event. The store follows
76
+ * each request's own async chain, so every spooled event gets its own numbers.
77
+ *
78
+ * Cost on the hot path: one object allocation per hook evaluation. No I/O, no
79
+ * timer, no network — the fields ride along on the verdict event the CLI was
80
+ * already spooling.
81
+ */
82
+ const verdictTiming = new async_hooks_1.AsyncLocalStorage();
83
+ /** Run one hook evaluation with timing attached to everything it spools. */
84
+ function markVerdictTiming(timing, fn) {
85
+ return verdictTiming.run(timing, fn);
86
+ }
87
+ /**
88
+ * Re-label the current evaluation (e.g. it ended in the fail-open handler).
89
+ * Keeps the original start time so latency still covers the full wait.
90
+ */
91
+ function setVerdictPath(path) {
92
+ const store = verdictTiming.getStore();
93
+ if (store)
94
+ store.path = path;
95
+ }
96
+ /**
97
+ * Restart the current evaluation's clock. Used by the MCP gateway right after
98
+ * the downstream tool returns: everything spooled from then on (response
99
+ * scanning, masking, bookkeeping) must report OUR overhead, not the tool's own
100
+ * runtime — a 30s build tool is not a 30s FullCourtDefense stall.
101
+ */
102
+ function restartVerdictTiming() {
103
+ const store = verdictTiming.getStore();
104
+ if (store)
105
+ store.startedAt = Date.now();
106
+ }
66
107
  /** Append one decision to the spool. Never throws (telemetry must not break the hook). */
67
108
  function spoolEvent(event) {
68
109
  try {
110
+ // Passive perf stamp: pure arithmetic on a timestamp the hot path already
111
+ // took. Absent when a caller spools outside a hook evaluation (guards,
112
+ // onboard) — the backend treats missing fields as "no sample".
113
+ const timing = verdictTiming.getStore();
69
114
  const full = {
70
115
  eventId: crypto.randomUUID(),
71
116
  occurredAt: event.occurredAt || new Date().toISOString(),
@@ -84,6 +129,8 @@ function spoolEvent(event) {
84
129
  explanation: event.explanation,
85
130
  policyHash: event.policyHash,
86
131
  offlineEnforced: event.offlineEnforced,
132
+ latencyMs: timing ? Math.max(0, Date.now() - timing.startedAt) : undefined,
133
+ verdictPath: timing?.path,
87
134
  };
88
135
  fs.appendFileSync(SPOOL_PATH, JSON.stringify(full) + '\n', { encoding: 'utf-8', mode: 0o600 });
89
136
  enforceSpoolCap();
@@ -33,6 +33,14 @@ export interface VerdictRequest {
33
33
  args: Record<string, string | undefined>;
34
34
  /** Raw stdin payload exactly as the IDE piped it (BOM already stripped). */
35
35
  stdin: string;
36
+ /**
37
+ * Client's `Date.now()` at hook start, so the daemon-written telemetry event
38
+ * carries the wall time the DEVELOPER waited (process spawn + pipe + verdict),
39
+ * not just the daemon's own evaluation slice. Optional on purpose: during a
40
+ * staged rollout an old client omits it (daemon falls back to its own clock)
41
+ * and an old daemon ignores it — same-machine clock, so no skew.
42
+ */
43
+ t0?: number;
36
44
  }
37
45
  export interface VerdictResponse {
38
46
  v: number;
@@ -68,7 +76,7 @@ export declare function verdictPipePath(): string;
68
76
  * get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
69
77
  * still fall back to local evaluation instantly.
70
78
  */
71
- export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number): Promise<VerdictClientResult>;
79
+ export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number, clientStartedAt?: number): Promise<VerdictClientResult>;
72
80
  export interface VerdictServerHandle {
73
81
  close(): void;
74
82
  }
@@ -43,6 +43,7 @@ const fs = __importStar(require("fs"));
43
43
  const net = __importStar(require("net"));
44
44
  const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
+ const telemetry_1 = require("./telemetry");
46
47
  /**
47
48
  * Daemon verdict IPC — the resident-agent pattern real endpoint products use
48
49
  * (one resident process; per-event interceptors are thin clients that ask it
@@ -118,15 +119,15 @@ const RESPONSE_TIMEOUT_MS = 2_500;
118
119
  * get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
119
120
  * still fall back to local evaluation instantly.
120
121
  */
121
- async function requestDaemonVerdict(args, stdin, responseTimeoutMs = RESPONSE_TIMEOUT_MS) {
122
- const first = await attemptDaemonVerdict(args, stdin, responseTimeoutMs);
122
+ async function requestDaemonVerdict(args, stdin, responseTimeoutMs = RESPONSE_TIMEOUT_MS, clientStartedAt) {
123
+ const first = await attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
123
124
  if (first.outcome === 'unavailable' && /EBUSY|busy|EAGAIN|connect timeout/i.test(first.detail || '')) {
124
125
  await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30)));
125
- return attemptDaemonVerdict(args, stdin, responseTimeoutMs);
126
+ return attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
126
127
  }
127
128
  return first;
128
129
  }
129
- function attemptDaemonVerdict(args, stdin, responseTimeoutMs) {
130
+ function attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt) {
130
131
  return new Promise((resolve) => {
131
132
  const token = readToken();
132
133
  if (!token) {
@@ -153,7 +154,7 @@ function attemptDaemonVerdict(args, stdin, responseTimeoutMs) {
153
154
  socket.on('error', (err) => fail(err.message));
154
155
  socket.on('connect', () => {
155
156
  clearTimeout(connectTimer);
156
- const request = { v: exports.VERDICT_IPC_PROTOCOL_VERSION, token, args, stdin };
157
+ const request = { v: exports.VERDICT_IPC_PROTOCOL_VERSION, token, args, stdin, t0: clientStartedAt };
157
158
  try {
158
159
  socket.write(JSON.stringify(request) + '\n');
159
160
  }
@@ -258,7 +259,11 @@ function startVerdictServer(evaluate, log) {
258
259
  return;
259
260
  }
260
261
  try {
261
- const result = await evaluate(request.args || {}, String(request.stdin ?? ''));
262
+ // Attach the requesting hook's own start time to whatever this
263
+ // evaluation spools, so fleet telemetry reports the wall time the
264
+ // developer waited. Per-request (AsyncLocalStorage), so concurrent
265
+ // hooks in this one daemon never borrow each other's timings.
266
+ const result = await (0, telemetry_1.markVerdictTiming)({ startedAt: typeof request.t0 === 'number' ? request.t0 : Date.now(), path: 'ipc' }, () => evaluate(request.args || {}, String(request.stdin ?? '')));
262
267
  reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, ...result });
263
268
  }
264
269
  catch (err) {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.1"
2
+ "version": "1.26.3"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.1",
3
+ "version": "1.26.3",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {