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.
- package/CHANGELOG.md +26 -1
- package/README.ja.md +1 -1
- package/README.md +37 -3
- package/bin/spotter.mjs +15 -0
- package/package.json +2 -2
- package/src/cli/codex-hook-cmd.mjs +21 -2
- package/src/cli/daemon-cmd.mjs +7 -1
- package/src/cli/diagnostics-cmd.mjs +98 -1
- package/src/cli/factory-diagnostics.mjs +178 -0
- package/src/core/runtime-error-store-worker.mjs +51 -0
- package/src/core/runtime-error-store.mjs +936 -0
- package/src/daemon/daemon.mjs +67 -19
- package/src/index.mjs +17 -0
package/src/daemon/daemon.mjs
CHANGED
|
@@ -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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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 } =
|
|
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
|
|
411
|
+
await removeStaleSocketFileFn(path);
|
|
383
412
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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';
|