@bububuger/spanory 0.1.14

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/index.js ADDED
@@ -0,0 +1,1688 @@
1
+ #!/usr/bin/env node
2
+ // @ts-nocheck
3
+ import { chmod, copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { Command } from 'commander';
9
+ import { claudeCodeAdapter } from './runtime/claude/adapter.js';
10
+ import { codexAdapter } from './runtime/codex/adapter.js';
11
+ import { createCodexProxyServer } from './runtime/codex/proxy.js';
12
+ import { openclawAdapter } from './runtime/openclaw/adapter.js';
13
+ import { compileOtlp, parseHeaders, sendOtlp } from './otlp.js';
14
+ import { loadUserEnv } from './env.js';
15
+ import { langfuseBackendAdapter } from '../../backend-langfuse/dist/index.js';
16
+ import { evaluateRules, loadAlertRules, sendAlertWebhook } from './alert/evaluate.js';
17
+ import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
18
+ const runtimeAdapters = {
19
+ 'claude-code': claudeCodeAdapter,
20
+ codex: codexAdapter,
21
+ openclaw: openclawAdapter,
22
+ };
23
+ const backendAdapters = {
24
+ langfuse: langfuseBackendAdapter,
25
+ };
26
+ const OPENCLAW_SPANORY_PLUGIN_ID = 'spanory-openclaw-plugin';
27
+ const OPENCODE_SPANORY_PLUGIN_ID = 'spanory-opencode-plugin';
28
+ const DEFAULT_SETUP_RUNTIMES = ['claude-code', 'codex', 'openclaw', 'opencode'];
29
+ const EMPTY_OUTPUT_RETRY_WINDOW_MS = 1000;
30
+ const EMPTY_OUTPUT_RETRY_INTERVAL_MS = 120;
31
+ const CODEX_WATCH_DEFAULT_POLL_MS = 1200;
32
+ const CODEX_WATCH_DEFAULT_SETTLE_MS = 250;
33
+ function getResource() {
34
+ return {
35
+ serviceName: 'spanory',
36
+ serviceVersion: process.env.SPANORY_VERSION ?? '0.1.1',
37
+ environment: process.env.SPANORY_ENV ?? 'development',
38
+ };
39
+ }
40
+ function getBackendAdapter() {
41
+ const backendName = process.env.SPANORY_BACKEND ?? 'langfuse';
42
+ const backend = backendAdapters[backendName];
43
+ if (!backend) {
44
+ throw new Error(`unsupported backend: ${backendName}`);
45
+ }
46
+ return backend;
47
+ }
48
+ function parseHookPayload(raw) {
49
+ if (!raw || !raw.trim())
50
+ return {};
51
+ try {
52
+ const payload = JSON.parse(raw);
53
+ return {
54
+ hookEventName: payload.hook_event_name ?? payload.hookEventName,
55
+ sessionId: payload.session_id ?? payload.sessionId ?? payload.thread_id ?? payload.threadId,
56
+ threadId: payload.thread_id ?? payload.threadId,
57
+ turnId: payload.turn_id ?? payload.turnId,
58
+ cwd: payload.cwd,
59
+ event: payload.event ?? payload.type ?? payload.event_name ?? payload.eventName,
60
+ transcriptPath: payload.transcript_path ?? payload.transcriptPath,
61
+ };
62
+ }
63
+ catch {
64
+ throw new Error('hook payload is not valid JSON');
65
+ }
66
+ }
67
+ async function readStdinText() {
68
+ const chunks = [];
69
+ for await (const chunk of process.stdin)
70
+ chunks.push(chunk);
71
+ return Buffer.concat(chunks).toString('utf-8');
72
+ }
73
+ function fingerprintSession(context, events) {
74
+ const hash = createHash('sha256');
75
+ hash.update(String(context.projectId ?? ''));
76
+ hash.update('\u001f');
77
+ hash.update(String(context.sessionId ?? ''));
78
+ hash.update('\u001f');
79
+ hash.update(String(context.transcriptPath ?? ''));
80
+ hash.update('\u001f');
81
+ for (const event of events) {
82
+ hash.update(String(event.turnId ?? ''));
83
+ hash.update('\u001f');
84
+ hash.update(String(event.category ?? ''));
85
+ hash.update('\u001f');
86
+ hash.update(String(event.name ?? ''));
87
+ hash.update('\u001f');
88
+ hash.update(String(event.startedAt ?? ''));
89
+ hash.update('\u001f');
90
+ hash.update(String(event.endedAt ?? ''));
91
+ hash.update('\u001f');
92
+ hash.update(String(event.input ?? ''));
93
+ hash.update('\u001f');
94
+ hash.update(String(event.output ?? ''));
95
+ hash.update('\u001f');
96
+ const attrs = event.attributes ?? {};
97
+ const keys = Object.keys(attrs).sort();
98
+ for (const key of keys) {
99
+ hash.update(key);
100
+ hash.update('=');
101
+ hash.update(String(attrs[key] ?? ''));
102
+ hash.update('\u001f');
103
+ }
104
+ }
105
+ return hash.digest('hex');
106
+ }
107
+ function parseTurnOrdinal(turnId) {
108
+ const m = String(turnId ?? '').match(/^turn-(\d+)$/);
109
+ if (!m)
110
+ return undefined;
111
+ const n = Number(m[1]);
112
+ return Number.isFinite(n) ? n : undefined;
113
+ }
114
+ function selectLatestTurnEvents(events) {
115
+ if (!Array.isArray(events) || events.length === 0) {
116
+ return { turnId: undefined, events: [] };
117
+ }
118
+ let latestTurnId;
119
+ let latestTurnOrdinal = -1;
120
+ let hasOrdinal = false;
121
+ for (const event of events) {
122
+ if (!event?.turnId)
123
+ continue;
124
+ const turnOrdinal = parseTurnOrdinal(event.turnId);
125
+ if (turnOrdinal === undefined)
126
+ continue;
127
+ hasOrdinal = true;
128
+ if (turnOrdinal > latestTurnOrdinal) {
129
+ latestTurnOrdinal = turnOrdinal;
130
+ latestTurnId = event.turnId;
131
+ }
132
+ }
133
+ if (!hasOrdinal) {
134
+ const latestTurnByTime = events
135
+ .filter((event) => event?.category === 'turn' && event?.turnId)
136
+ .slice()
137
+ .sort((a, b) => new Date(b.startedAt ?? 0).getTime() - new Date(a.startedAt ?? 0).getTime())[0];
138
+ if (!latestTurnByTime?.turnId)
139
+ return { turnId: undefined, events: [] };
140
+ latestTurnId = latestTurnByTime.turnId;
141
+ }
142
+ return {
143
+ turnId: latestTurnId,
144
+ events: events.filter((event) => event.turnId === latestTurnId),
145
+ };
146
+ }
147
+ function isTurnOutputEmpty(events, turnId) {
148
+ if (!Array.isArray(events) || events.length === 0)
149
+ return true;
150
+ const turn = events.find((event) => event.category === 'turn' && (!turnId || event.turnId === turnId));
151
+ if (!turn)
152
+ return true;
153
+ return String(turn.output ?? '').trim().length === 0;
154
+ }
155
+ function sleep(ms) {
156
+ return new Promise((resolve) => setTimeout(resolve, ms));
157
+ }
158
+ function resolveRuntimeHome(runtimeName, explicitRuntimeHome) {
159
+ if (explicitRuntimeHome)
160
+ return explicitRuntimeHome;
161
+ if (runtimeName === 'codex') {
162
+ return process.env.SPANORY_CODEX_HOME ?? path.join(process.env.HOME || '', '.codex');
163
+ }
164
+ if (runtimeName === 'openclaw') {
165
+ return (process.env.SPANORY_OPENCLOW_HOME
166
+ ?? process.env.SPANORY_OPENCLAW_HOME
167
+ ?? path.join(process.env.HOME || '', '.openclaw'));
168
+ }
169
+ if (runtimeName === 'opencode') {
170
+ return process.env.SPANORY_OPENCODE_HOME ?? path.join(process.env.HOME || '', '.config', 'opencode');
171
+ }
172
+ return path.join(process.env.HOME || '', '.claude');
173
+ }
174
+ function resolveRuntimeProjectRoot(runtimeName, explicitRuntimeHome) {
175
+ return path.join(resolveRuntimeHome(runtimeName, explicitRuntimeHome), 'projects');
176
+ }
177
+ function resolveRuntimeStateRoot(runtimeName, explicitRuntimeHome) {
178
+ return path.join(resolveRuntimeHome(runtimeName, explicitRuntimeHome), 'state');
179
+ }
180
+ function resolveRuntimeExportDir(runtimeName, explicitRuntimeHome) {
181
+ return path.join(resolveRuntimeStateRoot(runtimeName, explicitRuntimeHome), 'spanory-json');
182
+ }
183
+ function hookStatePath(runtimeName, sessionId, runtimeHome) {
184
+ return path.join(resolveRuntimeStateRoot(runtimeName, runtimeHome), 'spanory', `${sessionId}.json`);
185
+ }
186
+ async function readHookState(runtimeName, sessionId, runtimeHome) {
187
+ const file = hookStatePath(runtimeName, sessionId, runtimeHome);
188
+ try {
189
+ const raw = await readFile(file, 'utf-8');
190
+ return JSON.parse(raw);
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ async function writeHookState(runtimeName, sessionId, value, runtimeHome) {
197
+ const file = hookStatePath(runtimeName, sessionId, runtimeHome);
198
+ await mkdir(path.dirname(file), { recursive: true });
199
+ await writeFile(file, JSON.stringify(value, null, 2), 'utf-8');
200
+ }
201
+ function getRuntimeAdapter(runtimeName) {
202
+ const adapter = runtimeAdapters[runtimeName];
203
+ if (!adapter)
204
+ throw new Error(`unsupported runtime: ${runtimeName}`);
205
+ return adapter;
206
+ }
207
+ async function emitSession({ runtimeName, context, events, endpoint, headers, exportJsonPath }) {
208
+ const backend = getBackendAdapter();
209
+ const backendEvents = backend.mapEvents(events, {
210
+ backendName: backend.backendName,
211
+ runtimeName,
212
+ projectId: context.projectId,
213
+ sessionId: context.sessionId,
214
+ });
215
+ const payload = compileOtlp(backendEvents, getResource());
216
+ console.log(`runtime=${runtimeName} projectId=${context.projectId} sessionId=${context.sessionId} events=${events.length}`);
217
+ if (endpoint) {
218
+ await sendOtlp(endpoint, payload, headers);
219
+ console.log(`otlp=sent endpoint=${endpoint}`);
220
+ }
221
+ else {
222
+ console.log('otlp=skipped endpoint=unset');
223
+ }
224
+ if (exportJsonPath) {
225
+ await mkdir(path.dirname(exportJsonPath), { recursive: true });
226
+ await writeFile(exportJsonPath, JSON.stringify({ context, events: backendEvents, payload }, null, 2), 'utf-8');
227
+ console.log(`json=${exportJsonPath}`);
228
+ }
229
+ }
230
+ function resolveEndpoint(optionValue) {
231
+ return optionValue ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
232
+ }
233
+ function resolveHeaders(optionValue) {
234
+ return parseHeaders(optionValue ?? process.env.OTEL_EXPORTER_OTLP_HEADERS);
235
+ }
236
+ async function runHookMode(options) {
237
+ const runtimeName = options.runtimeName ?? 'claude-code';
238
+ const raw = await readStdinText();
239
+ const hookPayload = parseHookPayload(raw);
240
+ const adapter = getRuntimeAdapter(runtimeName);
241
+ const resolvedContext = adapter.resolveContextFromHook(hookPayload);
242
+ if (!resolvedContext) {
243
+ throw new Error('cannot resolve runtime context from hook payload; require session_id (or thread_id)');
244
+ }
245
+ await runContextExportMode({
246
+ runtimeName,
247
+ context: resolvedContext,
248
+ runtimeHome: options.runtimeHome,
249
+ endpoint: options.endpoint,
250
+ headers: options.headers,
251
+ exportJsonDir: options.exportJsonDir,
252
+ force: options.force,
253
+ lastTurnOnly: options.lastTurnOnly,
254
+ preferredTurnId: hookPayload.turnId,
255
+ });
256
+ }
257
+ async function runContextExportMode(options) {
258
+ const runtimeName = options.runtimeName;
259
+ const adapter = getRuntimeAdapter(runtimeName);
260
+ const contextWithRuntimeHome = {
261
+ ...options.context,
262
+ ...(options.runtimeHome ? { runtimeHome: options.runtimeHome } : {}),
263
+ };
264
+ let allEvents = [];
265
+ let fullFingerprint = '';
266
+ let selectedTurnId;
267
+ let events = [];
268
+ let selectedFingerprint = '';
269
+ const retryDeadline = Date.now() + EMPTY_OUTPUT_RETRY_WINDOW_MS;
270
+ for (;;) {
271
+ allEvents = await adapter.collectEvents(contextWithRuntimeHome);
272
+ fullFingerprint = fingerprintSession(contextWithRuntimeHome, allEvents);
273
+ selectedTurnId = undefined;
274
+ events = allEvents;
275
+ selectedFingerprint = fullFingerprint;
276
+ if (options.lastTurnOnly) {
277
+ if (options.preferredTurnId) {
278
+ selectedTurnId = options.preferredTurnId;
279
+ events = allEvents.filter((event) => event.turnId === selectedTurnId);
280
+ }
281
+ else {
282
+ const latest = selectLatestTurnEvents(allEvents);
283
+ selectedTurnId = latest.turnId;
284
+ events = latest.events;
285
+ }
286
+ if (!selectedTurnId || events.length === 0) {
287
+ console.log(`skip=no-turn sessionId=${contextWithRuntimeHome.sessionId}`);
288
+ return;
289
+ }
290
+ selectedFingerprint = fingerprintSession(contextWithRuntimeHome, events);
291
+ }
292
+ const shouldRetryEmptyOutput = options.lastTurnOnly && isTurnOutputEmpty(events, selectedTurnId);
293
+ if (!shouldRetryEmptyOutput)
294
+ break;
295
+ const remainingMs = retryDeadline - Date.now();
296
+ if (remainingMs <= 0) {
297
+ console.log(`retry=empty-output-timeout sessionId=${contextWithRuntimeHome.sessionId} turnId=${selectedTurnId} waitedMs=${EMPTY_OUTPUT_RETRY_WINDOW_MS}`);
298
+ break;
299
+ }
300
+ const waitMs = Math.min(EMPTY_OUTPUT_RETRY_INTERVAL_MS, remainingMs);
301
+ await sleep(waitMs);
302
+ }
303
+ if (!options.force) {
304
+ const prev = await readHookState(runtimeName, contextWithRuntimeHome.sessionId, options.runtimeHome);
305
+ if (options.lastTurnOnly) {
306
+ if (prev?.lastTurnId === selectedTurnId && prev?.lastTurnFingerprint === selectedFingerprint) {
307
+ console.log(`skip=unchanged-turn sessionId=${contextWithRuntimeHome.sessionId} turnId=${selectedTurnId}`);
308
+ return;
309
+ }
310
+ }
311
+ else if (prev?.fingerprint === fullFingerprint) {
312
+ console.log(`skip=unchanged sessionId=${contextWithRuntimeHome.sessionId}`);
313
+ return;
314
+ }
315
+ }
316
+ const exportJsonPath = options.exportJsonDir
317
+ ? path.join(options.exportJsonDir, `${contextWithRuntimeHome.sessionId}.json`)
318
+ : undefined;
319
+ await emitSession({
320
+ runtimeName: adapter.runtimeName,
321
+ context: contextWithRuntimeHome,
322
+ events,
323
+ endpoint: resolveEndpoint(options.endpoint),
324
+ headers: resolveHeaders(options.headers),
325
+ exportJsonPath,
326
+ });
327
+ await writeHookState(runtimeName, contextWithRuntimeHome.sessionId, {
328
+ sessionId: contextWithRuntimeHome.sessionId,
329
+ projectId: contextWithRuntimeHome.projectId,
330
+ fingerprint: fullFingerprint,
331
+ ...(options.lastTurnOnly ? { lastTurnId: selectedTurnId, lastTurnFingerprint: selectedFingerprint } : {}),
332
+ updatedAt: new Date().toISOString(),
333
+ }, options.runtimeHome);
334
+ return { status: 'sent', sessionId: contextWithRuntimeHome.sessionId, turnId: selectedTurnId };
335
+ }
336
+ function sessionIdFromFilename(filename) {
337
+ return filename.endsWith('.jsonl') ? filename.slice(0, -6) : filename;
338
+ }
339
+ async function listJsonlFilesRecursively(rootDir) {
340
+ const out = [];
341
+ const stack = [rootDir];
342
+ while (stack.length > 0) {
343
+ const dir = stack.pop();
344
+ let names = [];
345
+ try {
346
+ names = await readdir(dir, { withFileTypes: true });
347
+ }
348
+ catch {
349
+ continue;
350
+ }
351
+ for (const name of names) {
352
+ const fullPath = path.join(dir, name.name);
353
+ if (name.isDirectory()) {
354
+ stack.push(fullPath);
355
+ continue;
356
+ }
357
+ if (name.isFile() && name.name.endsWith('.jsonl')) {
358
+ out.push(fullPath);
359
+ }
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+ function normalizePositiveInt(raw, fallback, label) {
365
+ const value = raw ?? fallback;
366
+ const parsed = Number(value);
367
+ if (!Number.isFinite(parsed) || parsed < 0) {
368
+ throw new Error(`invalid ${label}: ${value}`);
369
+ }
370
+ return Math.floor(parsed);
371
+ }
372
+ async function listCodexSessions(runtimeHome, options = {}) {
373
+ const sessionsRoot = path.join(runtimeHome, 'sessions');
374
+ const files = await listJsonlFilesRecursively(sessionsRoot);
375
+ const withStat = await Promise.all(files.map(async (fullPath) => {
376
+ const fileStat = await stat(fullPath);
377
+ return {
378
+ transcriptPath: fullPath,
379
+ sessionId: sessionIdFromFilename(path.basename(fullPath)),
380
+ mtimeMs: fileStat.mtimeMs,
381
+ };
382
+ }));
383
+ const sinceMs = options.since ? new Date(options.since).getTime() : undefined;
384
+ const untilMs = options.until ? new Date(options.until).getTime() : undefined;
385
+ const filtered = withStat.filter((item) => {
386
+ if (Number.isFinite(sinceMs) && item.mtimeMs < sinceMs)
387
+ return false;
388
+ if (Number.isFinite(untilMs) && item.mtimeMs > untilMs)
389
+ return false;
390
+ return true;
391
+ });
392
+ const sorted = filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
393
+ if (!Number.isFinite(options.limit))
394
+ return sorted;
395
+ return sorted.slice(0, Number(options.limit));
396
+ }
397
+ async function runCodexWatch(options) {
398
+ const runtimeName = 'codex';
399
+ const runtimeHome = resolveRuntimeHome(runtimeName, options.runtimeHome);
400
+ const pollMs = normalizePositiveInt(options.pollMs, CODEX_WATCH_DEFAULT_POLL_MS, '--poll-ms');
401
+ const settleMs = normalizePositiveInt(options.settleMs, CODEX_WATCH_DEFAULT_SETTLE_MS, '--settle-ms');
402
+ const includeExisting = Boolean(options.includeExisting);
403
+ const processedMtimeByPath = new Map();
404
+ if (!includeExisting) {
405
+ const baseline = await listCodexSessions(runtimeHome);
406
+ for (const session of baseline) {
407
+ processedMtimeByPath.set(session.transcriptPath, session.mtimeMs);
408
+ }
409
+ console.log(`watch=baseline files=${baseline.length}`);
410
+ }
411
+ let stopped = false;
412
+ const stop = () => {
413
+ stopped = true;
414
+ };
415
+ process.on('SIGINT', stop);
416
+ process.on('SIGTERM', stop);
417
+ try {
418
+ do {
419
+ const nowMs = Date.now();
420
+ const sessions = await listCodexSessions(runtimeHome);
421
+ let exportedCount = 0;
422
+ let skippedCount = 0;
423
+ for (const session of sessions) {
424
+ const prevProcessedMtime = processedMtimeByPath.get(session.transcriptPath);
425
+ if (prevProcessedMtime !== undefined && session.mtimeMs <= prevProcessedMtime) {
426
+ continue;
427
+ }
428
+ if (nowMs - session.mtimeMs < settleMs) {
429
+ continue;
430
+ }
431
+ const context = {
432
+ projectId: options.projectId ?? 'codex',
433
+ sessionId: session.sessionId,
434
+ transcriptPath: session.transcriptPath,
435
+ runtimeHome,
436
+ };
437
+ try {
438
+ const result = await runContextExportMode({
439
+ runtimeName,
440
+ context,
441
+ runtimeHome,
442
+ endpoint: options.endpoint,
443
+ headers: options.headers,
444
+ exportJsonDir: options.exportJsonDir,
445
+ force: options.force,
446
+ lastTurnOnly: options.lastTurnOnly,
447
+ });
448
+ if (result?.status === 'sent')
449
+ exportedCount += 1;
450
+ else
451
+ skippedCount += 1;
452
+ }
453
+ catch (error) {
454
+ skippedCount += 1;
455
+ const message = error?.message ? String(error.message).replace(/\s+/g, ' ') : 'unknown-error';
456
+ console.log(`watch=error sessionId=${session.sessionId} error=${message}`);
457
+ }
458
+ finally {
459
+ processedMtimeByPath.set(session.transcriptPath, session.mtimeMs);
460
+ }
461
+ }
462
+ console.log(`watch=scan files=${sessions.length} exported=${exportedCount} skipped=${skippedCount}`);
463
+ if (options.once)
464
+ break;
465
+ if (!stopped)
466
+ await sleep(pollMs);
467
+ } while (!stopped);
468
+ }
469
+ finally {
470
+ process.off('SIGINT', stop);
471
+ process.off('SIGTERM', stop);
472
+ }
473
+ }
474
+ async function listCandidateSessions(runtimeName, projectId, options) {
475
+ if (options.sessionIds) {
476
+ return options.sessionIds
477
+ .split(',')
478
+ .map((s) => s.trim())
479
+ .filter(Boolean)
480
+ .map((sessionId) => ({ sessionId }));
481
+ }
482
+ if (runtimeName === 'codex') {
483
+ const runtimeHome = resolveRuntimeHome(runtimeName, options.runtimeHome);
484
+ const sessions = await listCodexSessions(runtimeHome, options);
485
+ return sessions.map(({ sessionId, transcriptPath }) => ({ sessionId, transcriptPath }));
486
+ }
487
+ let projectDir = path.join(resolveRuntimeProjectRoot(runtimeName, options.runtimeHome), projectId);
488
+ if (runtimeName === 'openclaw') {
489
+ const runtimeHome = resolveRuntimeHome(runtimeName, options.runtimeHome);
490
+ const openclawCandidates = [
491
+ path.join(runtimeHome, 'projects', projectId),
492
+ path.join(runtimeHome, 'agents', projectId, 'sessions'),
493
+ ];
494
+ let selected = null;
495
+ for (const dir of openclawCandidates) {
496
+ try {
497
+ await stat(dir);
498
+ selected = dir;
499
+ break;
500
+ }
501
+ catch {
502
+ // try next candidate
503
+ }
504
+ }
505
+ if (selected) {
506
+ projectDir = selected;
507
+ }
508
+ }
509
+ const names = (await readdir(projectDir)).filter((name) => name.endsWith('.jsonl'));
510
+ const withStat = await Promise.all(names.map(async (name) => {
511
+ const fullPath = path.join(projectDir, name);
512
+ const fileStat = await stat(fullPath);
513
+ return {
514
+ sessionId: sessionIdFromFilename(name),
515
+ mtimeMs: fileStat.mtimeMs,
516
+ };
517
+ }));
518
+ const sinceMs = options.since ? new Date(options.since).getTime() : undefined;
519
+ const untilMs = options.until ? new Date(options.until).getTime() : undefined;
520
+ const filtered = withStat.filter((item) => {
521
+ if (Number.isFinite(sinceMs) && item.mtimeMs < sinceMs)
522
+ return false;
523
+ if (Number.isFinite(untilMs) && item.mtimeMs > untilMs)
524
+ return false;
525
+ return true;
526
+ });
527
+ const sorted = filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
528
+ return sorted.slice(0, Number(options.limit)).map(({ sessionId }) => ({ sessionId }));
529
+ }
530
+ function runtimeDisplayName(runtimeName) {
531
+ if (runtimeName === 'codex')
532
+ return 'Codex';
533
+ if (runtimeName === 'openclaw')
534
+ return 'OpenClaw';
535
+ if (runtimeName === 'opencode')
536
+ return 'OpenCode';
537
+ return 'Claude Code';
538
+ }
539
+ function runtimeDescription(runtimeName) {
540
+ return `${runtimeDisplayName(runtimeName)} transcript runtime`;
541
+ }
542
+ function runSystemCommand(command, args, options = {}) {
543
+ const result = spawnSync(command, args, {
544
+ encoding: 'utf-8',
545
+ ...options,
546
+ });
547
+ return {
548
+ code: result.status ?? 1,
549
+ stdout: result.stdout ?? '',
550
+ stderr: result.stderr ?? '',
551
+ error: result.error ? String(result.error.message ?? result.error) : undefined,
552
+ };
553
+ }
554
+ function parseListenAddress(input) {
555
+ const raw = String(input ?? '127.0.0.1:8787').trim();
556
+ if (!raw.includes(':')) {
557
+ return { host: '127.0.0.1', port: Number(raw) };
558
+ }
559
+ const idx = raw.lastIndexOf(':');
560
+ const host = raw.slice(0, idx) || '127.0.0.1';
561
+ const port = Number(raw.slice(idx + 1));
562
+ return { host, port };
563
+ }
564
+ function resolveOpenclawPluginDir() {
565
+ if (process.env.SPANORY_OPENCLAW_PLUGIN_DIR) {
566
+ return process.env.SPANORY_OPENCLAW_PLUGIN_DIR;
567
+ }
568
+ return path.resolve(process.cwd(), 'packages/openclaw-plugin');
569
+ }
570
+ function resolveOpencodePluginDir() {
571
+ if (process.env.SPANORY_OPENCODE_PLUGIN_DIR) {
572
+ return process.env.SPANORY_OPENCODE_PLUGIN_DIR;
573
+ }
574
+ return path.resolve(process.cwd(), 'packages/opencode-plugin');
575
+ }
576
+ function resolveOpencodePluginInstallDir(runtimeHome) {
577
+ return path.join(resolveRuntimeHome('opencode', runtimeHome), 'plugin');
578
+ }
579
+ function opencodePluginLoaderPath(runtimeHome) {
580
+ return path.join(resolveOpencodePluginInstallDir(runtimeHome), `${OPENCODE_SPANORY_PLUGIN_ID}.js`);
581
+ }
582
+ function parsePluginEnabledFromInfoOutput(output) {
583
+ if (!output)
584
+ return undefined;
585
+ const yes = /enabled\s*[:=]\s*(true|yes|1)/i.test(output);
586
+ const no = /enabled\s*[:=]\s*(false|no|0)/i.test(output);
587
+ if (yes)
588
+ return true;
589
+ if (no)
590
+ return false;
591
+ return undefined;
592
+ }
593
+ async function runOpenclawPluginDoctor(runtimeHome) {
594
+ const checks = [];
595
+ const info = runSystemCommand('openclaw', ['plugins', 'info', OPENCLAW_SPANORY_PLUGIN_ID], {
596
+ env: {
597
+ ...process.env,
598
+ ...(runtimeHome ? { OPENCLAW_STATE_DIR: resolveRuntimeHome('openclaw', runtimeHome) } : {}),
599
+ },
600
+ });
601
+ checks.push({
602
+ id: 'plugin_installed',
603
+ ok: info.code === 0,
604
+ detail: info.code === 0 ? 'openclaw plugins info succeeded' : info.stderr || info.error || 'plugin not installed',
605
+ });
606
+ const enabled = parsePluginEnabledFromInfoOutput(`${info.stdout}\n${info.stderr}`);
607
+ checks.push({
608
+ id: 'plugin_enabled',
609
+ ok: enabled !== false,
610
+ detail: enabled === undefined ? 'cannot infer enabled status from openclaw output' : `enabled=${enabled}`,
611
+ });
612
+ checks.push({
613
+ id: 'otlp_endpoint',
614
+ ok: Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
615
+ detail: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
616
+ ? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
617
+ : 'OTEL_EXPORTER_OTLP_ENDPOINT is unset',
618
+ });
619
+ const spoolDir = process.env.SPANORY_OPENCLAW_SPOOL_DIR
620
+ ?? path.join(resolveRuntimeStateRoot('openclaw', runtimeHome), 'spanory', 'spool');
621
+ try {
622
+ await mkdir(spoolDir, { recursive: true });
623
+ checks.push({ id: 'spool_writable', ok: true, detail: spoolDir });
624
+ }
625
+ catch (err) {
626
+ checks.push({ id: 'spool_writable', ok: false, detail: String(err) });
627
+ }
628
+ const statusFile = path.join(resolveRuntimeStateRoot('openclaw', runtimeHome), 'spanory', 'plugin-status.json');
629
+ try {
630
+ const raw = await readFile(statusFile, 'utf-8');
631
+ checks.push({ id: 'last_send_status', ok: true, detail: raw.slice(0, 500) });
632
+ }
633
+ catch {
634
+ checks.push({
635
+ id: 'last_send_status',
636
+ ok: true,
637
+ detail: `status file not generated yet: ${statusFile}`,
638
+ });
639
+ }
640
+ const ok = checks.every((item) => item.ok);
641
+ return { ok, checks };
642
+ }
643
+ async function runOpencodePluginDoctor(runtimeHome) {
644
+ const checks = [];
645
+ const loaderFile = opencodePluginLoaderPath(runtimeHome);
646
+ try {
647
+ await stat(loaderFile);
648
+ checks.push({ id: 'plugin_installed', ok: true, detail: loaderFile });
649
+ }
650
+ catch {
651
+ checks.push({ id: 'plugin_installed', ok: false, detail: `plugin loader missing: ${loaderFile}` });
652
+ }
653
+ checks.push({
654
+ id: 'otlp_endpoint',
655
+ ok: Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
656
+ detail: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
657
+ ? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
658
+ : 'OTEL_EXPORTER_OTLP_ENDPOINT is unset',
659
+ });
660
+ const spoolDir = process.env.SPANORY_OPENCODE_SPOOL_DIR
661
+ ?? path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'spool');
662
+ try {
663
+ await mkdir(spoolDir, { recursive: true });
664
+ checks.push({ id: 'spool_writable', ok: true, detail: spoolDir });
665
+ }
666
+ catch (err) {
667
+ checks.push({ id: 'spool_writable', ok: false, detail: String(err) });
668
+ }
669
+ const statusFile = path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'plugin-status.json');
670
+ const logFile = path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'plugin.log');
671
+ try {
672
+ await mkdir(path.dirname(logFile), { recursive: true });
673
+ checks.push({ id: 'opencode_plugin_log', ok: true, detail: logFile });
674
+ }
675
+ catch (err) {
676
+ checks.push({ id: 'opencode_plugin_log', ok: false, detail: String(err) });
677
+ }
678
+ try {
679
+ const raw = await readFile(statusFile, 'utf-8');
680
+ checks.push({ id: 'last_send_status', ok: true, detail: raw.slice(0, 500) });
681
+ try {
682
+ const parsed = JSON.parse(raw);
683
+ const endpointConfigured = parsed?.endpointConfigured;
684
+ if (endpointConfigured === false) {
685
+ checks.push({
686
+ id: 'last_send_endpoint_configured',
687
+ ok: false,
688
+ detail: 'last send skipped or failed to resolve OTLP endpoint in opencode process; check ~/.env and restart opencode',
689
+ });
690
+ }
691
+ else if (endpointConfigured === true) {
692
+ checks.push({
693
+ id: 'last_send_endpoint_configured',
694
+ ok: true,
695
+ detail: 'last send had OTLP endpoint configured in opencode process',
696
+ });
697
+ }
698
+ }
699
+ catch {
700
+ // ignore malformed status file
701
+ }
702
+ }
703
+ catch {
704
+ checks.push({
705
+ id: 'last_send_status',
706
+ ok: true,
707
+ detail: `status file not generated yet: ${statusFile}`,
708
+ });
709
+ }
710
+ const ok = checks.every((item) => item.ok);
711
+ return { ok, checks };
712
+ }
713
+ function setupHomeRoot(homeOption) {
714
+ if (homeOption)
715
+ return path.resolve(homeOption);
716
+ return process.env.HOME || '';
717
+ }
718
+ function parseSetupRuntimes(csv) {
719
+ const selected = (csv ?? DEFAULT_SETUP_RUNTIMES.join(','))
720
+ .split(',')
721
+ .map((item) => item.trim())
722
+ .filter(Boolean);
723
+ const allowed = new Set(DEFAULT_SETUP_RUNTIMES);
724
+ for (const runtimeName of selected) {
725
+ if (!allowed.has(runtimeName)) {
726
+ throw new Error(`unsupported runtime in --runtimes: ${runtimeName}`);
727
+ }
728
+ }
729
+ return selected;
730
+ }
731
+ function backupSuffix() {
732
+ return new Date().toISOString().replace(/[:.]/g, '-');
733
+ }
734
+ async function backupIfExists(filePath) {
735
+ try {
736
+ await stat(filePath);
737
+ }
738
+ catch {
739
+ return null;
740
+ }
741
+ const backupPath = `${filePath}.bak.${backupSuffix()}`;
742
+ await copyFile(filePath, backupPath);
743
+ return backupPath;
744
+ }
745
+ function isSpanoryHookCommand(command) {
746
+ const text = String(command ?? '');
747
+ return /\bspanory\b/.test(text) && /\bhook\b/.test(text);
748
+ }
749
+ function ensureClaudeHookEvent(settings, eventName, command) {
750
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
751
+ settings.hooks = {};
752
+ }
753
+ if (!Array.isArray(settings.hooks[eventName]) || settings.hooks[eventName].length === 0) {
754
+ settings.hooks[eventName] = [{ hooks: [] }];
755
+ }
756
+ if (!settings.hooks[eventName][0] || typeof settings.hooks[eventName][0] !== 'object') {
757
+ settings.hooks[eventName][0] = { hooks: [] };
758
+ }
759
+ if (!Array.isArray(settings.hooks[eventName][0].hooks)) {
760
+ settings.hooks[eventName][0].hooks = [];
761
+ }
762
+ const hooks = settings.hooks[eventName][0].hooks.filter((hook) => !(hook && typeof hook === 'object' && isSpanoryHookCommand(hook.command)));
763
+ hooks.unshift({ type: 'command', command });
764
+ settings.hooks[eventName][0].hooks = hooks;
765
+ }
766
+ async function applyClaudeSetup({ homeRoot, spanoryBin, dryRun }) {
767
+ const settingsPath = path.join(homeRoot, '.claude', 'settings.json');
768
+ let settings = {};
769
+ try {
770
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
771
+ }
772
+ catch (error) {
773
+ if (error?.code !== 'ENOENT') {
774
+ throw new Error(`failed to parse Claude settings: ${error.message ?? String(error)}`);
775
+ }
776
+ }
777
+ const before = JSON.stringify(settings);
778
+ const hookCommand = `${spanoryBin} hook --last-turn-only`;
779
+ ensureClaudeHookEvent(settings, 'Stop', hookCommand);
780
+ ensureClaudeHookEvent(settings, 'SessionEnd', hookCommand);
781
+ const after = JSON.stringify(settings);
782
+ const changed = before !== after;
783
+ let backup = null;
784
+ if (changed && !dryRun) {
785
+ await mkdir(path.dirname(settingsPath), { recursive: true });
786
+ backup = await backupIfExists(settingsPath);
787
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
788
+ }
789
+ return {
790
+ runtime: 'claude-code',
791
+ ok: true,
792
+ changed,
793
+ dryRun,
794
+ settingsPath,
795
+ backup,
796
+ };
797
+ }
798
+ function upsertCodexNotifyConfig(configText, notifyScriptRef) {
799
+ const notifyLine = `notify = ["${escapeTomlBasicString(notifyScriptRef)}"]`;
800
+ if (/^notify\s*=.*$/m.test(configText)) {
801
+ return configText.replace(/^notify\s*=.*$/m, notifyLine);
802
+ }
803
+ const withNewline = configText.trimEnd();
804
+ if (!withNewline)
805
+ return `${notifyLine}\n`;
806
+ return `${withNewline}\n\n${notifyLine}\n`;
807
+ }
808
+ function escapeTomlBasicString(value) {
809
+ return String(value)
810
+ .replaceAll('\\', '\\\\')
811
+ .replaceAll('"', '\\"');
812
+ }
813
+ function codexNotifyScriptContent({ spanoryBin, codexHome, exportDir, logFile }) {
814
+ return '#!/usr/bin/env bash\n'
815
+ + 'set -euo pipefail\n'
816
+ + 'payload="${1:-}"\n'
817
+ + 'if [[ -z "$payload" ]] && [[ ! -t 0 ]]; then\n'
818
+ + ' payload="$(cat || true)"\n'
819
+ + 'fi\n'
820
+ + 'if [[ -z "${payload//[$\'\\t\\r\\n \']/}" ]]; then\n'
821
+ + ` echo "skip=empty-payload source=codex-notify args=$#" >> "${logFile}"\n`
822
+ + ' exit 0\n'
823
+ + 'fi\n'
824
+ + `echo "$payload" | "${spanoryBin}" runtime codex hook \\\n`
825
+ + ' --last-turn-only \\\n'
826
+ + ` --runtime-home "${codexHome}" \\\n`
827
+ + ` --export-json-dir "${exportDir}" \\\n`
828
+ + ` >> "${logFile}" 2>&1 || true\n`;
829
+ }
830
+ async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
831
+ const codexHome = path.join(homeRoot, '.codex');
832
+ const binDir = path.join(codexHome, 'bin');
833
+ const stateDir = path.join(codexHome, 'state', 'spanory');
834
+ const scriptPath = path.join(binDir, 'spanory-codex-notify.sh');
835
+ const logFile = path.join(codexHome, 'state', 'spanory-codex-hook.log');
836
+ const configPath = path.join(codexHome, 'config.toml');
837
+ const notifyScriptRef = scriptPath;
838
+ const scriptContent = codexNotifyScriptContent({
839
+ spanoryBin,
840
+ codexHome,
841
+ exportDir: stateDir,
842
+ logFile,
843
+ });
844
+ let currentConfig = '';
845
+ try {
846
+ currentConfig = await readFile(configPath, 'utf-8');
847
+ }
848
+ catch (error) {
849
+ if (error?.code !== 'ENOENT')
850
+ throw error;
851
+ }
852
+ const nextConfig = upsertCodexNotifyConfig(currentConfig, notifyScriptRef);
853
+ const configChanged = currentConfig !== nextConfig;
854
+ let currentScript = '';
855
+ try {
856
+ currentScript = await readFile(scriptPath, 'utf-8');
857
+ }
858
+ catch (error) {
859
+ if (error?.code !== 'ENOENT')
860
+ throw error;
861
+ }
862
+ const scriptChanged = currentScript !== scriptContent;
863
+ const changed = configChanged || scriptChanged;
864
+ let configBackup = null;
865
+ if (changed && !dryRun) {
866
+ await mkdir(binDir, { recursive: true });
867
+ await mkdir(stateDir, { recursive: true });
868
+ if (configChanged) {
869
+ configBackup = await backupIfExists(configPath);
870
+ await writeFile(configPath, nextConfig, 'utf-8');
871
+ }
872
+ if (scriptChanged) {
873
+ await writeFile(scriptPath, scriptContent, 'utf-8');
874
+ await chmod(scriptPath, 0o755);
875
+ }
876
+ }
877
+ return {
878
+ runtime: 'codex',
879
+ ok: true,
880
+ changed,
881
+ dryRun,
882
+ configPath,
883
+ scriptPath,
884
+ configBackup,
885
+ };
886
+ }
887
+ function commandExists(command) {
888
+ const result = runSystemCommand('which', [command], { env: process.env });
889
+ return result.code === 0;
890
+ }
891
+ function openclawRuntimeHomeForSetup(homeRoot, explicitRuntimeHome) {
892
+ return explicitRuntimeHome || path.join(homeRoot, '.openclaw');
893
+ }
894
+ function opencodeRuntimeHomeForSetup(homeRoot, explicitRuntimeHome) {
895
+ return explicitRuntimeHome || path.join(homeRoot, '.config', 'opencode');
896
+ }
897
+ function installOpenclawPlugin(runtimeHome) {
898
+ const pluginDir = path.resolve(resolveOpenclawPluginDir());
899
+ const installResult = runSystemCommand('openclaw', ['plugins', 'install', '-l', pluginDir], {
900
+ env: {
901
+ ...process.env,
902
+ OPENCLAW_STATE_DIR: runtimeHome,
903
+ },
904
+ });
905
+ if (installResult.code !== 0) {
906
+ throw new Error(installResult.stderr || installResult.error || 'openclaw plugins install failed');
907
+ }
908
+ const enableResult = runSystemCommand('openclaw', ['plugins', 'enable', OPENCLAW_SPANORY_PLUGIN_ID], {
909
+ env: {
910
+ ...process.env,
911
+ OPENCLAW_STATE_DIR: runtimeHome,
912
+ },
913
+ });
914
+ if (enableResult.code !== 0) {
915
+ throw new Error(enableResult.stderr || enableResult.error || 'openclaw plugins enable failed');
916
+ }
917
+ return {
918
+ installStdout: installResult.stdout.trim(),
919
+ enableStdout: enableResult.stdout.trim(),
920
+ };
921
+ }
922
+ async function installOpencodePlugin(runtimeHome) {
923
+ const pluginDir = path.resolve(resolveOpencodePluginDir());
924
+ const pluginEntry = path.join(pluginDir, 'src', 'index.js');
925
+ await stat(pluginEntry);
926
+ const installDir = resolveOpencodePluginInstallDir(runtimeHome);
927
+ const loaderFile = opencodePluginLoaderPath(runtimeHome);
928
+ await mkdir(installDir, { recursive: true });
929
+ const importUrl = pathToFileURL(pluginEntry).href;
930
+ const loader = `import plugin from ${JSON.stringify(importUrl)};\n`
931
+ + 'export const SpanoryOpencodePlugin = plugin;\n'
932
+ + 'export default SpanoryOpencodePlugin;\n';
933
+ await writeFile(loaderFile, loader, 'utf-8');
934
+ return { loaderFile };
935
+ }
936
+ async function runSetupDetect(options) {
937
+ const homeRoot = setupHomeRoot(options.home);
938
+ const report = {
939
+ homeRoot,
940
+ runtimes: [],
941
+ };
942
+ const claudeSettingsPath = path.join(homeRoot, '.claude', 'settings.json');
943
+ let claudeHooks = { stop: false, sessionEnd: false };
944
+ try {
945
+ const parsed = JSON.parse(await readFile(claudeSettingsPath, 'utf-8'));
946
+ const hooks = parsed?.hooks ?? {};
947
+ const stopHooks = hooks?.Stop?.[0]?.hooks ?? [];
948
+ const endHooks = hooks?.SessionEnd?.[0]?.hooks ?? [];
949
+ claudeHooks = {
950
+ stop: Array.isArray(stopHooks) && stopHooks.some((h) => isSpanoryHookCommand(h?.command)),
951
+ sessionEnd: Array.isArray(endHooks) && endHooks.some((h) => isSpanoryHookCommand(h?.command)),
952
+ };
953
+ }
954
+ catch {
955
+ // keep defaults
956
+ }
957
+ report.runtimes.push({
958
+ runtime: 'claude-code',
959
+ available: commandExists('claude'),
960
+ configured: claudeHooks.stop && claudeHooks.sessionEnd,
961
+ details: {
962
+ settingsPath: claudeSettingsPath,
963
+ stopHookConfigured: claudeHooks.stop,
964
+ sessionEndHookConfigured: claudeHooks.sessionEnd,
965
+ },
966
+ });
967
+ const codexConfigPath = path.join(homeRoot, '.codex', 'config.toml');
968
+ const codexScriptPath = path.join(homeRoot, '.codex', 'bin', 'spanory-codex-notify.sh');
969
+ let codexNotifyConfigured = false;
970
+ try {
971
+ const config = await readFile(codexConfigPath, 'utf-8');
972
+ codexNotifyConfigured = /notify\s*=\s*\[[^\]]*spanory-codex-notify\.sh[^\]]*\]/m.test(config);
973
+ }
974
+ catch {
975
+ // keep false
976
+ }
977
+ let codexScriptPresent = true;
978
+ try {
979
+ await stat(codexScriptPath);
980
+ }
981
+ catch {
982
+ codexScriptPresent = false;
983
+ }
984
+ report.runtimes.push({
985
+ runtime: 'codex',
986
+ available: commandExists('codex'),
987
+ configured: codexNotifyConfigured && codexScriptPresent,
988
+ details: {
989
+ configPath: codexConfigPath,
990
+ scriptPath: codexScriptPath,
991
+ notifyConfigured: codexNotifyConfigured,
992
+ scriptPresent: codexScriptPresent,
993
+ },
994
+ });
995
+ report.runtimes.push({
996
+ runtime: 'openclaw',
997
+ available: commandExists('openclaw'),
998
+ configured: undefined,
999
+ details: {
1000
+ runtimeHome: openclawRuntimeHomeForSetup(homeRoot, options.openclawRuntimeHome),
1001
+ },
1002
+ });
1003
+ report.runtimes.push({
1004
+ runtime: 'opencode',
1005
+ available: commandExists('opencode'),
1006
+ configured: undefined,
1007
+ details: {
1008
+ runtimeHome: opencodeRuntimeHomeForSetup(homeRoot, options.opencodeRuntimeHome),
1009
+ },
1010
+ });
1011
+ return report;
1012
+ }
1013
+ async function runSetupDoctor(options) {
1014
+ const homeRoot = setupHomeRoot(options.home);
1015
+ const selected = parseSetupRuntimes(options.runtimes);
1016
+ const checks = [];
1017
+ if (selected.includes('claude-code')) {
1018
+ const settingsPath = path.join(homeRoot, '.claude', 'settings.json');
1019
+ let stopHookConfigured = false;
1020
+ let sessionEndHookConfigured = false;
1021
+ try {
1022
+ const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
1023
+ const stopHooks = parsed?.hooks?.Stop?.[0]?.hooks ?? [];
1024
+ const endHooks = parsed?.hooks?.SessionEnd?.[0]?.hooks ?? [];
1025
+ stopHookConfigured = Array.isArray(stopHooks) && stopHooks.some((h) => isSpanoryHookCommand(h?.command));
1026
+ sessionEndHookConfigured = Array.isArray(endHooks) && endHooks.some((h) => isSpanoryHookCommand(h?.command));
1027
+ }
1028
+ catch {
1029
+ // keep defaults
1030
+ }
1031
+ checks.push({
1032
+ id: 'claude_hook_stop',
1033
+ runtime: 'claude-code',
1034
+ ok: stopHookConfigured,
1035
+ detail: settingsPath,
1036
+ });
1037
+ checks.push({
1038
+ id: 'claude_hook_session_end',
1039
+ runtime: 'claude-code',
1040
+ ok: sessionEndHookConfigured,
1041
+ detail: settingsPath,
1042
+ });
1043
+ }
1044
+ if (selected.includes('codex')) {
1045
+ const configPath = path.join(homeRoot, '.codex', 'config.toml');
1046
+ const scriptPath = path.join(homeRoot, '.codex', 'bin', 'spanory-codex-notify.sh');
1047
+ let notifyConfigured = false;
1048
+ try {
1049
+ const config = await readFile(configPath, 'utf-8');
1050
+ notifyConfigured = /notify\s*=\s*\[[^\]]*spanory-codex-notify\.sh[^\]]*\]/m.test(config);
1051
+ }
1052
+ catch {
1053
+ // keep default false
1054
+ }
1055
+ let scriptPresent = false;
1056
+ try {
1057
+ await stat(scriptPath);
1058
+ scriptPresent = true;
1059
+ }
1060
+ catch {
1061
+ // keep default false
1062
+ }
1063
+ checks.push({
1064
+ id: 'codex_notify_configured',
1065
+ runtime: 'codex',
1066
+ ok: notifyConfigured,
1067
+ detail: configPath,
1068
+ });
1069
+ checks.push({
1070
+ id: 'codex_notify_script',
1071
+ runtime: 'codex',
1072
+ ok: scriptPresent,
1073
+ detail: scriptPath,
1074
+ });
1075
+ }
1076
+ if (selected.includes('openclaw')) {
1077
+ const openclawHome = openclawRuntimeHomeForSetup(homeRoot, options.openclawRuntimeHome);
1078
+ if (commandExists('openclaw')) {
1079
+ const report = await runOpenclawPluginDoctor(openclawHome);
1080
+ for (const check of report.checks) {
1081
+ checks.push({
1082
+ ...check,
1083
+ runtime: 'openclaw',
1084
+ });
1085
+ }
1086
+ }
1087
+ else {
1088
+ checks.push({
1089
+ id: 'openclaw_binary',
1090
+ runtime: 'openclaw',
1091
+ ok: false,
1092
+ detail: 'openclaw command not found in PATH',
1093
+ });
1094
+ }
1095
+ }
1096
+ if (selected.includes('opencode')) {
1097
+ const opencodeHome = opencodeRuntimeHomeForSetup(homeRoot, options.opencodeRuntimeHome);
1098
+ const report = await runOpencodePluginDoctor(opencodeHome);
1099
+ for (const check of report.checks) {
1100
+ checks.push({
1101
+ ...check,
1102
+ runtime: 'opencode',
1103
+ });
1104
+ }
1105
+ }
1106
+ return {
1107
+ ok: checks.every((check) => check.ok),
1108
+ checks,
1109
+ };
1110
+ }
1111
+ async function runSetupApply(options) {
1112
+ const homeRoot = setupHomeRoot(options.home);
1113
+ const selected = parseSetupRuntimes(options.runtimes);
1114
+ const spanoryBin = options.spanoryBin ?? 'spanory';
1115
+ const dryRun = Boolean(options.dryRun);
1116
+ const codexMode = options.codexMode ?? 'notify';
1117
+ const results = [];
1118
+ if (selected.includes('claude-code')) {
1119
+ try {
1120
+ const result = await applyClaudeSetup({ homeRoot, spanoryBin, dryRun });
1121
+ results.push(result);
1122
+ }
1123
+ catch (error) {
1124
+ results.push({
1125
+ runtime: 'claude-code',
1126
+ ok: false,
1127
+ error: String(error?.message ?? error),
1128
+ });
1129
+ }
1130
+ }
1131
+ if (selected.includes('codex')) {
1132
+ if (codexMode !== 'notify') {
1133
+ results.push({
1134
+ runtime: 'codex',
1135
+ ok: true,
1136
+ skipped: true,
1137
+ detail: `codex mode "${codexMode}" skips notify setup`,
1138
+ });
1139
+ }
1140
+ else {
1141
+ try {
1142
+ const result = await applyCodexSetup({ homeRoot, spanoryBin, dryRun });
1143
+ results.push(result);
1144
+ }
1145
+ catch (error) {
1146
+ results.push({
1147
+ runtime: 'codex',
1148
+ ok: false,
1149
+ error: String(error?.message ?? error),
1150
+ });
1151
+ }
1152
+ }
1153
+ }
1154
+ if (selected.includes('openclaw')) {
1155
+ if (!commandExists('openclaw')) {
1156
+ results.push({
1157
+ runtime: 'openclaw',
1158
+ ok: true,
1159
+ skipped: true,
1160
+ detail: 'openclaw command not found in PATH',
1161
+ });
1162
+ }
1163
+ else {
1164
+ const runtimeHome = openclawRuntimeHomeForSetup(homeRoot, options.openclawRuntimeHome);
1165
+ try {
1166
+ if (!dryRun)
1167
+ installOpenclawPlugin(runtimeHome);
1168
+ const doctor = await runOpenclawPluginDoctor(runtimeHome);
1169
+ results.push({
1170
+ runtime: 'openclaw',
1171
+ ok: doctor.ok,
1172
+ dryRun,
1173
+ doctor,
1174
+ });
1175
+ }
1176
+ catch (error) {
1177
+ results.push({
1178
+ runtime: 'openclaw',
1179
+ ok: false,
1180
+ error: String(error?.message ?? error),
1181
+ });
1182
+ }
1183
+ }
1184
+ }
1185
+ if (selected.includes('opencode')) {
1186
+ const runtimeHome = opencodeRuntimeHomeForSetup(homeRoot, options.opencodeRuntimeHome);
1187
+ try {
1188
+ if (!dryRun)
1189
+ await installOpencodePlugin(runtimeHome);
1190
+ const doctor = await runOpencodePluginDoctor(runtimeHome);
1191
+ results.push({
1192
+ runtime: 'opencode',
1193
+ ok: doctor.ok,
1194
+ dryRun,
1195
+ doctor,
1196
+ });
1197
+ }
1198
+ catch (error) {
1199
+ results.push({
1200
+ runtime: 'opencode',
1201
+ ok: false,
1202
+ error: String(error?.message ?? error),
1203
+ });
1204
+ }
1205
+ }
1206
+ return {
1207
+ ok: results.every((item) => item.ok),
1208
+ results,
1209
+ };
1210
+ }
1211
+ function registerRuntimeCommands(runtimeRoot, runtimeName) {
1212
+ const runtimeCmd = runtimeRoot.command(runtimeName).description(runtimeDescription(runtimeName));
1213
+ const displayName = runtimeDisplayName(runtimeName);
1214
+ const hasTranscriptAdapter = Boolean(runtimeAdapters[runtimeName]);
1215
+ if (hasTranscriptAdapter) {
1216
+ const exportCmd = runtimeCmd
1217
+ .command('export')
1218
+ .description(`Export one ${displayName} session as OTLP spans`);
1219
+ if (runtimeName !== 'codex') {
1220
+ exportCmd.requiredOption('--project-id <id>', `${displayName} project id (folder under runtime projects root)`);
1221
+ }
1222
+ else {
1223
+ exportCmd.option('--project-id <id>', 'Project id override (optional; defaults to cwd-derived id)');
1224
+ }
1225
+ exportCmd
1226
+ .requiredOption('--session-id <id>', `${displayName} session id (jsonl filename without extension)`)
1227
+ .option('--transcript-path <path>', 'Override transcript path instead of <runtime-home>/projects/<project>/<session>.jsonl')
1228
+ .option('--runtime-home <path>', 'Override runtime home directory')
1229
+ .option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
1230
+ .option('--headers <kv>', 'OTLP HTTP headers, comma-separated k=v (fallback: OTEL_EXPORTER_OTLP_HEADERS)')
1231
+ .option('--export-json <path>', 'Write parsed events and OTLP payload to a local JSON file')
1232
+ .addHelpText('after', '\nExamples:\n'
1233
+ + ` spanory runtime ${runtimeName} export --project-id my-project --session-id 1234\n`
1234
+ + ` spanory runtime ${runtimeName} export --project-id my-project --session-id 1234 --endpoint http://localhost:3000/api/public/otel/v1/traces\n`)
1235
+ .action(async (options) => {
1236
+ const adapter = getRuntimeAdapter(runtimeName);
1237
+ const context = {
1238
+ projectId: options.projectId ?? 'codex',
1239
+ sessionId: options.sessionId,
1240
+ ...(options.transcriptPath ? { transcriptPath: options.transcriptPath } : {}),
1241
+ ...(options.runtimeHome ? { runtimeHome: options.runtimeHome } : {}),
1242
+ };
1243
+ const events = await adapter.collectEvents(context);
1244
+ await emitSession({
1245
+ runtimeName: adapter.runtimeName,
1246
+ context,
1247
+ events,
1248
+ endpoint: resolveEndpoint(options.endpoint),
1249
+ headers: resolveHeaders(options.headers),
1250
+ exportJsonPath: options.exportJson,
1251
+ });
1252
+ });
1253
+ runtimeCmd
1254
+ .command('hook')
1255
+ .description(`Read ${displayName} hook payload from stdin and export the matched session`)
1256
+ .option('--runtime-home <path>', 'Override runtime home directory')
1257
+ .option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
1258
+ .option('--headers <kv>', 'OTLP HTTP headers, comma-separated k=v (fallback: OTEL_EXPORTER_OTLP_HEADERS)')
1259
+ .option('--export-json-dir <dir>', 'Write <sessionId>.json into this directory')
1260
+ .option('--last-turn-only', 'Export only the newest turn and dedupe by turn fingerprint', false)
1261
+ .option('--force', 'Force export even if session payload fingerprint is unchanged', false)
1262
+ .addHelpText('after', '\nExamples:\n'
1263
+ + ` echo "{...}" | spanory runtime ${runtimeName} hook\n`
1264
+ + ` cat payload.json | spanory runtime ${runtimeName} hook --export-json-dir ${resolveRuntimeExportDir(runtimeName)}\n`)
1265
+ .action(async (options) => runHookMode({
1266
+ runtimeName,
1267
+ runtimeHome: options.runtimeHome,
1268
+ endpoint: options.endpoint,
1269
+ headers: options.headers,
1270
+ lastTurnOnly: options.lastTurnOnly,
1271
+ force: options.force,
1272
+ exportJsonDir: options.exportJsonDir,
1273
+ }));
1274
+ const backfillCmd = runtimeCmd
1275
+ .command('backfill')
1276
+ .description(`Batch export historical ${displayName} sessions for one project`);
1277
+ if (runtimeName !== 'codex') {
1278
+ backfillCmd.requiredOption('--project-id <id>', `${displayName} project id (folder under runtime projects root)`);
1279
+ }
1280
+ else {
1281
+ backfillCmd.option('--project-id <id>', 'Project id override (optional; defaults to cwd-derived id)');
1282
+ }
1283
+ backfillCmd
1284
+ .option('--runtime-home <path>', 'Override runtime home directory')
1285
+ .option('--session-ids <csv>', 'Comma-separated session ids; if set, since/until/limit are ignored')
1286
+ .option('--since <iso>', 'Only include sessions with transcript file mtime >= this ISO timestamp')
1287
+ .option('--until <iso>', 'Only include sessions with transcript file mtime <= this ISO timestamp')
1288
+ .option('--limit <n>', 'Max number of sessions when auto-selecting by mtime', '50')
1289
+ .option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
1290
+ .option('--headers <kv>', 'OTLP HTTP headers, comma-separated k=v (fallback: OTEL_EXPORTER_OTLP_HEADERS)')
1291
+ .option('--export-json-dir <dir>', 'Write one <sessionId>.json file per session into this directory')
1292
+ .option('--dry-run', 'Print selected sessions without sending OTLP', false)
1293
+ .addHelpText('after', '\nExamples:\n'
1294
+ + ` spanory runtime ${runtimeName} backfill --project-id my-project --since 2026-02-27T00:00:00Z --limit 20\n`
1295
+ + ` spanory runtime ${runtimeName} backfill --project-id my-project --session-ids a,b,c --dry-run\n`)
1296
+ .action(async (options) => {
1297
+ const adapter = getRuntimeAdapter(runtimeName);
1298
+ const endpoint = resolveEndpoint(options.endpoint);
1299
+ const headers = resolveHeaders(options.headers);
1300
+ const candidates = await listCandidateSessions(runtimeName, options.projectId ?? 'codex', options);
1301
+ if (!candidates.length) {
1302
+ console.log('backfill=empty selected=0');
1303
+ return;
1304
+ }
1305
+ console.log(`backfill=selected count=${candidates.length}`);
1306
+ for (const candidate of candidates) {
1307
+ const context = {
1308
+ projectId: options.projectId ?? 'codex',
1309
+ sessionId: candidate.sessionId,
1310
+ ...(candidate.transcriptPath ? { transcriptPath: candidate.transcriptPath } : {}),
1311
+ ...(options.runtimeHome ? { runtimeHome: options.runtimeHome } : {}),
1312
+ };
1313
+ if (options.dryRun) {
1314
+ console.log(`dry-run sessionId=${candidate.sessionId}`);
1315
+ continue;
1316
+ }
1317
+ const events = await adapter.collectEvents(context);
1318
+ const exportJsonPath = options.exportJsonDir ? path.join(options.exportJsonDir, `${candidate.sessionId}.json`) : undefined;
1319
+ await emitSession({
1320
+ runtimeName: adapter.runtimeName,
1321
+ context,
1322
+ events,
1323
+ endpoint,
1324
+ headers,
1325
+ exportJsonPath,
1326
+ });
1327
+ }
1328
+ });
1329
+ }
1330
+ if (runtimeName === 'codex') {
1331
+ runtimeCmd
1332
+ .command('watch')
1333
+ .description('Poll Codex session transcripts and export newly updated sessions (notify fallback)')
1334
+ .option('--project-id <id>', 'Project id override (optional; defaults to cwd-derived id)')
1335
+ .option('--runtime-home <path>', 'Override runtime home directory')
1336
+ .option('--poll-ms <n>', `Polling interval in milliseconds (default: ${CODEX_WATCH_DEFAULT_POLL_MS})`)
1337
+ .option('--settle-ms <n>', `Minimum file age before parsing (default: ${CODEX_WATCH_DEFAULT_SETTLE_MS})`)
1338
+ .option('--include-existing', 'Also process existing sessions on startup', false)
1339
+ .option('--once', 'Run one scan cycle and exit', false)
1340
+ .option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
1341
+ .option('--headers <kv>', 'OTLP HTTP headers, comma-separated k=v (fallback: OTEL_EXPORTER_OTLP_HEADERS)')
1342
+ .option('--export-json-dir <dir>', 'Write one <sessionId>.json file per exported session into this directory')
1343
+ .option('--last-turn-only', 'Export only the newest turn and dedupe by turn fingerprint', true)
1344
+ .option('--force', 'Force export even if session payload fingerprint is unchanged', false)
1345
+ .addHelpText('after', '\nExamples:\n'
1346
+ + ' spanory runtime codex watch\n'
1347
+ + ' spanory runtime codex watch --include-existing --once --settle-ms 0\n')
1348
+ .action(async (options) => {
1349
+ await runCodexWatch(options);
1350
+ });
1351
+ runtimeCmd
1352
+ .command('proxy')
1353
+ .description('Run OpenAI-compatible proxy capture for Codex traffic with full redaction')
1354
+ .option('--listen <host:port>', 'Listen address (default: 127.0.0.1:8787)', '127.0.0.1:8787')
1355
+ .option('--upstream <url>', 'Upstream OpenAI-compatible base URL')
1356
+ .option('--spool-dir <path>', 'Capture spool directory')
1357
+ .option('--max-body-bytes <n>', 'Maximum bytes to keep per redacted body', '131072')
1358
+ .action(async (options) => {
1359
+ const { host, port } = parseListenAddress(options.listen);
1360
+ if (!Number.isFinite(port) || port <= 0) {
1361
+ throw new Error(`invalid --listen port: ${options.listen}`);
1362
+ }
1363
+ const proxy = createCodexProxyServer({
1364
+ upstreamBaseUrl: options.upstream ?? process.env.SPANORY_CODEX_PROXY_UPSTREAM ?? process.env.OPENAI_BASE_URL,
1365
+ spoolDir: options.spoolDir
1366
+ ?? process.env.SPANORY_CODEX_PROXY_SPOOL_DIR
1367
+ ?? path.join(resolveRuntimeStateRoot('codex'), 'spanory', 'proxy-spool'),
1368
+ maxBodyBytes: Number(options.maxBodyBytes),
1369
+ logger: console,
1370
+ });
1371
+ await proxy.start({ host, port });
1372
+ console.log(`proxy=listening url=${proxy.url()} upstream=${options.upstream ?? process.env.SPANORY_CODEX_PROXY_UPSTREAM ?? process.env.OPENAI_BASE_URL ?? 'https://api.openai.com'}`);
1373
+ await new Promise((resolve) => {
1374
+ const stop = async () => {
1375
+ process.off('SIGINT', stop);
1376
+ process.off('SIGTERM', stop);
1377
+ await proxy.stop();
1378
+ resolve();
1379
+ };
1380
+ process.on('SIGINT', stop);
1381
+ process.on('SIGTERM', stop);
1382
+ });
1383
+ });
1384
+ }
1385
+ if (runtimeName === 'openclaw') {
1386
+ const plugin = runtimeCmd
1387
+ .command('plugin')
1388
+ .description('Manage Spanory OpenClaw plugin runtime integration');
1389
+ plugin
1390
+ .command('install')
1391
+ .description('Install Spanory OpenClaw plugin using openclaw plugins install -l')
1392
+ .option('--plugin-dir <path>', 'Plugin directory path (default: packages/openclaw-plugin)')
1393
+ .option('--runtime-home <path>', 'Override runtime home directory')
1394
+ .action((options) => {
1395
+ const pluginDir = path.resolve(options.pluginDir ?? resolveOpenclawPluginDir());
1396
+ const result = runSystemCommand('openclaw', ['plugins', 'install', '-l', pluginDir], {
1397
+ env: {
1398
+ ...process.env,
1399
+ ...(options.runtimeHome ? { OPENCLAW_STATE_DIR: resolveRuntimeHome('openclaw', options.runtimeHome) } : {}),
1400
+ },
1401
+ });
1402
+ if (result.stdout.trim())
1403
+ console.log(result.stdout.trim());
1404
+ if (result.code !== 0) {
1405
+ throw new Error(result.stderr || result.error || 'openclaw plugins install failed');
1406
+ }
1407
+ });
1408
+ plugin
1409
+ .command('enable')
1410
+ .description('Enable Spanory OpenClaw plugin')
1411
+ .option('--runtime-home <path>', 'Override runtime home directory')
1412
+ .action((options) => {
1413
+ const result = runSystemCommand('openclaw', ['plugins', 'enable', OPENCLAW_SPANORY_PLUGIN_ID], {
1414
+ env: {
1415
+ ...process.env,
1416
+ ...(options.runtimeHome ? { OPENCLAW_STATE_DIR: resolveRuntimeHome('openclaw', options.runtimeHome) } : {}),
1417
+ },
1418
+ });
1419
+ if (result.stdout.trim())
1420
+ console.log(result.stdout.trim());
1421
+ if (result.code !== 0) {
1422
+ throw new Error(result.stderr || result.error || 'openclaw plugins enable failed');
1423
+ }
1424
+ });
1425
+ plugin
1426
+ .command('disable')
1427
+ .description('Disable Spanory OpenClaw plugin')
1428
+ .option('--runtime-home <path>', 'Override runtime home directory')
1429
+ .action((options) => {
1430
+ const result = runSystemCommand('openclaw', ['plugins', 'disable', OPENCLAW_SPANORY_PLUGIN_ID], {
1431
+ env: {
1432
+ ...process.env,
1433
+ ...(options.runtimeHome ? { OPENCLAW_STATE_DIR: resolveRuntimeHome('openclaw', options.runtimeHome) } : {}),
1434
+ },
1435
+ });
1436
+ if (result.stdout.trim())
1437
+ console.log(result.stdout.trim());
1438
+ if (result.code !== 0) {
1439
+ throw new Error(result.stderr || result.error || 'openclaw plugins disable failed');
1440
+ }
1441
+ });
1442
+ plugin
1443
+ .command('uninstall')
1444
+ .description('Uninstall Spanory OpenClaw plugin')
1445
+ .option('--runtime-home <path>', 'Override runtime home directory')
1446
+ .action((options) => {
1447
+ const result = runSystemCommand('openclaw', ['plugins', 'uninstall', OPENCLAW_SPANORY_PLUGIN_ID], {
1448
+ env: {
1449
+ ...process.env,
1450
+ ...(options.runtimeHome ? { OPENCLAW_STATE_DIR: resolveRuntimeHome('openclaw', options.runtimeHome) } : {}),
1451
+ },
1452
+ });
1453
+ if (result.stdout.trim())
1454
+ console.log(result.stdout.trim());
1455
+ if (result.code !== 0) {
1456
+ throw new Error(result.stderr || result.error || 'openclaw plugins uninstall failed');
1457
+ }
1458
+ });
1459
+ plugin
1460
+ .command('doctor')
1461
+ .description('Run local diagnostic checks for Spanory OpenClaw plugin')
1462
+ .option('--runtime-home <path>', 'Override runtime home directory')
1463
+ .action(async (options) => {
1464
+ const report = await runOpenclawPluginDoctor(options.runtimeHome);
1465
+ console.log(JSON.stringify(report, null, 2));
1466
+ if (!report.ok)
1467
+ process.exitCode = 2;
1468
+ });
1469
+ }
1470
+ if (runtimeName === 'opencode') {
1471
+ const plugin = runtimeCmd
1472
+ .command('plugin')
1473
+ .description('Manage Spanory OpenCode plugin runtime integration');
1474
+ plugin
1475
+ .command('install')
1476
+ .description('Install Spanory OpenCode plugin loader into ~/.config/opencode/plugin')
1477
+ .option('--plugin-dir <path>', 'Plugin directory path (default: packages/opencode-plugin)')
1478
+ .option('--runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
1479
+ .action(async (options) => {
1480
+ const pluginDir = path.resolve(options.pluginDir ?? resolveOpencodePluginDir());
1481
+ const pluginEntry = path.join(pluginDir, 'src', 'index.js');
1482
+ await stat(pluginEntry);
1483
+ const installDir = resolveOpencodePluginInstallDir(options.runtimeHome);
1484
+ const loaderFile = opencodePluginLoaderPath(options.runtimeHome);
1485
+ await mkdir(installDir, { recursive: true });
1486
+ const importUrl = pathToFileURL(pluginEntry).href;
1487
+ const loader = `import plugin from ${JSON.stringify(importUrl)};\n`
1488
+ + 'export const SpanoryOpencodePlugin = plugin;\n'
1489
+ + 'export default SpanoryOpencodePlugin;\n';
1490
+ await writeFile(loaderFile, loader, 'utf-8');
1491
+ console.log(`installed=${loaderFile}`);
1492
+ });
1493
+ plugin
1494
+ .command('uninstall')
1495
+ .description('Remove Spanory OpenCode plugin loader')
1496
+ .option('--runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
1497
+ .action(async (options) => {
1498
+ const loaderFile = opencodePluginLoaderPath(options.runtimeHome);
1499
+ await rm(loaderFile, { force: true });
1500
+ console.log(`removed=${loaderFile}`);
1501
+ });
1502
+ plugin
1503
+ .command('doctor')
1504
+ .description('Run local diagnostic checks for Spanory OpenCode plugin')
1505
+ .option('--runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
1506
+ .action(async (options) => {
1507
+ const report = await runOpencodePluginDoctor(options.runtimeHome);
1508
+ console.log(JSON.stringify(report, null, 2));
1509
+ if (!report.ok)
1510
+ process.exitCode = 2;
1511
+ });
1512
+ }
1513
+ }
1514
+ const program = new Command();
1515
+ program
1516
+ .name('spanory')
1517
+ .description('Cross-runtime observability CLI for agent sessions')
1518
+ .showHelpAfterError()
1519
+ .showSuggestionAfterError(true)
1520
+ .version('0.1.1');
1521
+ const runtime = program.command('runtime').description('Runtime-specific parsers and exporters');
1522
+ for (const runtimeName of ['claude-code', 'codex', 'openclaw', 'opencode']) {
1523
+ registerRuntimeCommands(runtime, runtimeName);
1524
+ }
1525
+ const report = program.command('report').description('Aggregate exported session JSON into infra-level views');
1526
+ report
1527
+ .command('session')
1528
+ .description('Session-level summary view')
1529
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1530
+ .action(async (options) => {
1531
+ const sessions = await loadExportedEvents(options.inputJson);
1532
+ console.log(JSON.stringify({ view: 'session-summary', rows: summarizeSessions(sessions) }, null, 2));
1533
+ });
1534
+ report
1535
+ .command('mcp')
1536
+ .description('MCP server aggregation view')
1537
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1538
+ .action(async (options) => {
1539
+ const sessions = await loadExportedEvents(options.inputJson);
1540
+ console.log(JSON.stringify({ view: 'mcp-summary', rows: summarizeMcp(sessions) }, null, 2));
1541
+ });
1542
+ report
1543
+ .command('command')
1544
+ .description('Agent command aggregation view')
1545
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1546
+ .action(async (options) => {
1547
+ const sessions = await loadExportedEvents(options.inputJson);
1548
+ console.log(JSON.stringify({ view: 'command-summary', rows: summarizeCommands(sessions) }, null, 2));
1549
+ });
1550
+ report
1551
+ .command('agent')
1552
+ .description('Agent activity summary per session')
1553
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1554
+ .action(async (options) => {
1555
+ const sessions = await loadExportedEvents(options.inputJson);
1556
+ console.log(JSON.stringify({ view: 'agent-summary', rows: summarizeAgents(sessions) }, null, 2));
1557
+ });
1558
+ report
1559
+ .command('cache')
1560
+ .description('Cache usage summary per session')
1561
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1562
+ .action(async (options) => {
1563
+ const sessions = await loadExportedEvents(options.inputJson);
1564
+ console.log(JSON.stringify({ view: 'cache-summary', rows: summarizeCache(sessions) }, null, 2));
1565
+ });
1566
+ report
1567
+ .command('tool')
1568
+ .description('Tool usage aggregation view')
1569
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1570
+ .action(async (options) => {
1571
+ const sessions = await loadExportedEvents(options.inputJson);
1572
+ console.log(JSON.stringify({ view: 'tool-summary', rows: summarizeTools(sessions) }, null, 2));
1573
+ });
1574
+ report
1575
+ .command('turn-diff')
1576
+ .description('Turn input diff summary view')
1577
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1578
+ .action(async (options) => {
1579
+ const sessions = await loadExportedEvents(options.inputJson);
1580
+ console.log(JSON.stringify({ view: 'turn-diff-summary', rows: summarizeTurnDiff(sessions) }, null, 2));
1581
+ });
1582
+ const alert = program.command('alert').description('Evaluate alert rules against exported telemetry data');
1583
+ alert
1584
+ .command('eval')
1585
+ .description('Run threshold rules and emit alert events')
1586
+ .requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
1587
+ .requiredOption('--rules <path>', 'Path to alert rules JSON file')
1588
+ .option('--webhook-url <url>', 'Optional webhook URL to post alert payload')
1589
+ .option('--webhook-headers <kv>', 'Webhook headers, comma-separated k=v')
1590
+ .option('--fail-on-alert', 'Exit with non-zero code when alert count > 0', false)
1591
+ .addHelpText('after', '\nRule file format:\n'
1592
+ + ' {\n'
1593
+ + ' "rules": [\n'
1594
+ + ' {"id":"high-token","scope":"session","metric":"usage.total","op":"gt","threshold":10000}\n'
1595
+ + ' ]\n'
1596
+ + ' }\n')
1597
+ .action(async (options) => {
1598
+ const sessions = await loadExportedEvents(options.inputJson);
1599
+ const rules = await loadAlertRules(options.rules);
1600
+ const alerts = evaluateRules(rules, sessions);
1601
+ const result = {
1602
+ evaluatedAt: new Date().toISOString(),
1603
+ sessions: sessions.length,
1604
+ rules: rules.length,
1605
+ alerts,
1606
+ };
1607
+ console.log(JSON.stringify(result, null, 2));
1608
+ if (options.webhookUrl) {
1609
+ await sendAlertWebhook(options.webhookUrl, result, parseHeaders(options.webhookHeaders));
1610
+ console.log(`webhook=sent url=${options.webhookUrl}`);
1611
+ }
1612
+ if (options.failOnAlert && alerts.length > 0) {
1613
+ process.exitCode = 2;
1614
+ }
1615
+ });
1616
+ program
1617
+ .command('hook')
1618
+ .description('Minimal hook entrypoint (defaults to runtime payload + ~/.env + default export dir)')
1619
+ .option('--runtime <name>', 'Runtime name (default: SPANORY_HOOK_RUNTIME or claude-code)')
1620
+ .option('--runtime-home <path>', 'Override runtime home directory')
1621
+ .option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
1622
+ .option('--headers <kv>', 'OTLP HTTP headers, comma-separated k=v (fallback: OTEL_EXPORTER_OTLP_HEADERS)')
1623
+ .option('--export-json-dir <dir>', 'Write <sessionId>.json into this directory')
1624
+ .option('--last-turn-only', 'Export only the newest turn and dedupe by turn fingerprint', false)
1625
+ .option('--force', 'Force export even if session payload fingerprint is unchanged', false)
1626
+ .addHelpText('after', '\nMinimal usage in SessionEnd hook command:\n'
1627
+ + ' spanory hook\n'
1628
+ + ' spanory hook --runtime openclaw\n')
1629
+ .action(async (options) => {
1630
+ const runtimeName = options.runtime ?? process.env.SPANORY_HOOK_RUNTIME ?? 'claude-code';
1631
+ await runHookMode({
1632
+ runtimeName,
1633
+ runtimeHome: options.runtimeHome,
1634
+ endpoint: options.endpoint,
1635
+ headers: options.headers,
1636
+ lastTurnOnly: options.lastTurnOnly,
1637
+ force: options.force,
1638
+ exportJsonDir: options.exportJsonDir
1639
+ ?? process.env.SPANORY_HOOK_EXPORT_JSON_DIR
1640
+ ?? resolveRuntimeExportDir(runtimeName, options.runtimeHome),
1641
+ });
1642
+ });
1643
+ const setup = program.command('setup').description('One-command local runtime integration setup and diagnostics');
1644
+ setup
1645
+ .command('detect')
1646
+ .description('Detect local runtime availability and setup status')
1647
+ .option('--home <path>', 'Home directory root override (default: $HOME)')
1648
+ .option('--openclaw-runtime-home <path>', 'Override OpenClaw runtime home for reporting')
1649
+ .option('--opencode-runtime-home <path>', 'Override OpenCode runtime home for reporting')
1650
+ .action(async (options) => {
1651
+ const report = await runSetupDetect(options);
1652
+ console.log(JSON.stringify(report, null, 2));
1653
+ });
1654
+ setup
1655
+ .command('apply')
1656
+ .description('Apply idempotent local setup for selected runtimes')
1657
+ .option('--runtimes <csv>', `Comma-separated runtimes (default: ${DEFAULT_SETUP_RUNTIMES.join(',')})`, DEFAULT_SETUP_RUNTIMES.join(','))
1658
+ .option('--home <path>', 'Home directory root override (default: $HOME)')
1659
+ .option('--spanory-bin <path>', 'Spanory binary/command to write into runtime configs', 'spanory')
1660
+ .option('--codex-mode <mode>', 'Codex setup mode: notify | proxy (default: notify)', 'notify')
1661
+ .option('--openclaw-runtime-home <path>', 'Override OpenClaw runtime home (default: ~/.openclaw)')
1662
+ .option('--opencode-runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
1663
+ .option('--dry-run', 'Only print planned changes without writing files', false)
1664
+ .action(async (options) => {
1665
+ const report = await runSetupApply(options);
1666
+ console.log(JSON.stringify(report, null, 2));
1667
+ if (!report.ok)
1668
+ process.exitCode = 2;
1669
+ });
1670
+ setup
1671
+ .command('doctor')
1672
+ .description('Run setup diagnostics for selected runtimes')
1673
+ .option('--runtimes <csv>', `Comma-separated runtimes (default: ${DEFAULT_SETUP_RUNTIMES.join(',')})`, DEFAULT_SETUP_RUNTIMES.join(','))
1674
+ .option('--home <path>', 'Home directory root override (default: $HOME)')
1675
+ .option('--openclaw-runtime-home <path>', 'Override OpenClaw runtime home (default: ~/.openclaw)')
1676
+ .option('--opencode-runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
1677
+ .action(async (options) => {
1678
+ const report = await runSetupDoctor(options);
1679
+ console.log(JSON.stringify(report, null, 2));
1680
+ if (!report.ok)
1681
+ process.exitCode = 2;
1682
+ });
1683
+ loadUserEnv()
1684
+ .then(() => program.parseAsync(process.argv))
1685
+ .catch((error) => {
1686
+ console.error(`[spanory] ${error instanceof Error ? error.message : String(error)}`);
1687
+ process.exitCode = 1;
1688
+ });