claude-spotter 1.4.21 → 1.4.23

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.
@@ -91,6 +91,12 @@ export async function startDaemon({
91
91
  auditorBackendName = process.env.SPOTTER_AUDITOR_BACKEND || (haikuCaller ? 'haiku' : 'auto'),
92
92
  auditorEnv = process.env,
93
93
  stopShortFinalMaxChars = resolveStopShortFinalMaxChars(process.env),
94
+ runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
95
+ createAuditorBackendFn = createAuditorBackend,
96
+ createServerFn = createServer,
97
+ removeStaleSocketFileFn = removeStaleSocketFile,
98
+ secureSocketFileFn = secureSocketFile,
99
+ writePidFileFn = writeFile,
94
100
  } = {}) {
95
101
  if (!sessionId) {
96
102
  throw new TypeError('sessionId is required');
@@ -130,16 +136,31 @@ export async function startDaemon({
130
136
  // fail with E_BACKEND_HOST_UNKNOWN on Claude Code launches that don't set
131
137
  // CLAUDECODE/CLAUDE_CODE (e.g. custom wrappers), even though the daemon clearly
132
138
  // belongs to a Claude host.
133
- const auditorBackend = createAuditorBackend({
134
- backend: auditorBackendName,
135
- catalog: toolList,
136
- projectRoot,
137
- hostAgent: 'claude',
138
- env: auditorEnv,
139
- logger: logFn,
140
- haikuCaller,
141
- timeoutMs: DEFAULT_HAIKU_TIMEOUT_MS,
142
- });
139
+ const observeFailure = async (kind) => {
140
+ try {
141
+ await runtimeErrorObserver(kind);
142
+ } catch {
143
+ // The production observer is already non-throwing. Keep injected/custom observers
144
+ // from changing daemon behavior as the same telemetry-failure safety boundary.
145
+ }
146
+ };
147
+
148
+ let auditorBackend;
149
+ try {
150
+ auditorBackend = createAuditorBackendFn({
151
+ backend: auditorBackendName,
152
+ catalog: toolList,
153
+ projectRoot,
154
+ hostAgent: 'claude',
155
+ env: auditorEnv,
156
+ logger: logFn,
157
+ haikuCaller,
158
+ timeoutMs: DEFAULT_HAIKU_TIMEOUT_MS,
159
+ });
160
+ } catch (error) {
161
+ await observeFailure('auditor_unavailable');
162
+ throw error;
163
+ }
143
164
 
144
165
  // Per-turn state, reset on turn_end.
145
166
  const state = {
@@ -154,7 +175,12 @@ export async function startDaemon({
154
175
 
155
176
  const runAuditorJudgment = async (input) => {
156
177
  lastAuditorCallAt = Date.now();
157
- return auditorBackend.judge(input);
178
+ try {
179
+ return await auditorBackend.judge(input);
180
+ } catch (error) {
181
+ await observeFailure('auditor_unavailable');
182
+ throw error;
183
+ }
158
184
  };
159
185
 
160
186
  // v0.12.0: heartbeat. Reset on every envelope; if no event arrives within
@@ -370,27 +396,49 @@ export async function startDaemon({
370
396
  const onErrorFn = (err, envelope) => {
371
397
  const evt = envelope?.event ?? '(pre-parse)';
372
398
  logFn(`handler error on ${evt}: ${err.code ?? 'E_INTERNAL'}: ${err.message}`);
399
+ // Handler/auditor failures are owned above. A connection-level error has no
400
+ // envelope and belongs to the transport boundary here.
401
+ if (envelope === null) void observeFailure('daemon_transport');
373
402
  };
374
403
 
375
- const { server, path } = createServer({ sessionId, handler, onError: onErrorFn });
404
+ const { server, path } = createServerFn({ sessionId, handler, onError: onErrorFn });
376
405
 
377
406
  // assertNoLiveDaemon() above confirmed no live process owns this session_id. Any socket file at
378
407
  // `path` is therefore an orphan from a prior daemon that died ungracefully (stop()'s unlink runs
379
408
  // only on graceful shutdown). Remove it so listen() doesn't fail with EADDRINUSE — otherwise the
380
409
  // daemon dies before "daemon listening" and every resurrect crash-loops, leaving the session
381
410
  // permanently unaudited.
382
- await removeStaleSocketFile(path);
411
+ await removeStaleSocketFileFn(path);
383
412
 
384
- await new Promise((resolve, reject) => {
385
- server.on('error', (err) => reject(err));
386
- server.listen(path, resolve);
387
- });
388
- await secureSocketFile(path);
413
+ let listening = false;
414
+ try {
415
+ await new Promise((resolve, reject) => {
416
+ server.on('error', (error) => {
417
+ if (!listening) reject(error);
418
+ else void observeFailure('daemon_transport');
419
+ });
420
+ server.listen(path, () => {
421
+ listening = true;
422
+ resolve();
423
+ });
424
+ });
425
+ } catch (error) {
426
+ await observeFailure('daemon_transport');
427
+ throw error;
428
+ }
429
+ await secureSocketFileFn(path);
389
430
  logFn(`daemon listening on ${path}`);
390
431
 
391
432
  // Write PID file so uninstall/doctor can reason about liveness (§15.3 doctor).
392
433
  const pidPath = pidFilePath(sessionId);
393
- await writeFile(pidPath, String(process.pid), 'utf8');
434
+ try {
435
+ await writePidFileFn(pidPath, String(process.pid), 'utf8');
436
+ } catch (error) {
437
+ await observeFailure('daemon_persistence');
438
+ await new Promise((resolve) => server.close(resolve)).catch(() => {});
439
+ if (process.platform !== 'win32') await unlink(path).catch(() => {});
440
+ throw error;
441
+ }
394
442
 
395
443
  // Start the heartbeat. The first hook event (typically SessionStart's readiness
396
444
  // ping moments later) will reset it; if nothing arrives in heartbeatTimeoutMs we self-shut.
package/src/index.mjs CHANGED
@@ -79,6 +79,23 @@ export {
79
79
  summarizeDaemonLogText,
80
80
  summarizeDaemonLogs,
81
81
  } from './core/daemon-log-diagnostics.mjs';
82
+ export {
83
+ RUNTIME_ERROR_DEFINITIONS,
84
+ RUNTIME_ERROR_STORE_SCHEMA,
85
+ acknowledgeRuntimeErrors,
86
+ compactRuntimeErrors,
87
+ defaultFactoryReporterConfigPath,
88
+ defaultRuntimeErrorStorePath,
89
+ observeRuntimeError,
90
+ observeRuntimeErrorIsolatedSafe,
91
+ observeRuntimeErrorSafe,
92
+ readRuntimeCollectionMode,
93
+ readRuntimeErrorSnapshot,
94
+ readRuntimeErrorStoreStatus,
95
+ reopenRuntimeError,
96
+ resolveRuntimeError,
97
+ runtimeErrorFingerprint,
98
+ } from './core/runtime-error-store.mjs';
82
99
  export { loadDb, saveDb, emptyDb, ToolDbSchemaError, globalDbPath, localDbPath, normalizeToolDbHostAgent } from './tool-db/loader.mjs';
83
100
  export { resolveAll } from './tool-db/lookup.mjs';
84
101
  export { refresh, readLocal, buildInvestigationSnapshot } from './tool-db/refresh.mjs';