moflo 4.9.33 → 4.9.34

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.
@@ -10,6 +10,7 @@ import { join } from 'path';
10
10
  import { output } from '../output.js';
11
11
  import { errorDetail } from '../shared/utils/error-detail.js';
12
12
  import { repairHookWiring } from '../services/hook-wiring.js';
13
+ import { getDaemonLockHolder } from '../services/daemon-lock.js';
13
14
  import { findZombieProcesses } from './doctor-zombies.js';
14
15
  import { installClaudeCode, runCommand } from './doctor-checks-runtime.js';
15
16
  /** Run a shell command as a fix action. Returns true on exit code 0. */
@@ -181,6 +182,37 @@ export async function autoFixCheck(check) {
181
182
  'Gate Health': async () => {
182
183
  return fixGateHealthHooks();
183
184
  },
185
+ 'Embedding hygiene': async () => {
186
+ // The session-start launcher already runs the same migration BEFORE
187
+ // daemon/MCP boot — that's where consumer autoheal happens. Running
188
+ // it here mid-session is unsafe because any long-lived moflo writer
189
+ // (daemon, MCP server) holds its own sql.js in-memory snapshot from
190
+ // before we'd repair, and on its next flush dumps the stale buffer
191
+ // back to disk, clobbering the repair. Pre-#1046 we shelled out to
192
+ // `npx moflo embeddings init` here and falsely reported success
193
+ // when the writeback clobber was about to undo it.
194
+ // `getDaemonLockHolder` validates both PID liveness AND
195
+ // that the process is actually a moflo daemon (Windows PID
196
+ // recycling is real — see daemon-lock.ts:isDaemonProcess).
197
+ if (getDaemonLockHolder(process.cwd()) !== null) {
198
+ output.writeln(output.dim(' Embedding hygiene is repaired automatically by the session-start launcher.'));
199
+ output.writeln(output.dim(' Restart Claude Code (or run `flo daemon stop` first) to apply.'));
200
+ return false;
201
+ }
202
+ // No daemon — safe to run the migration in-process. In-process is
203
+ // preferred over `runFixCommand` because the migration's TTY/stderr
204
+ // progress UI is then visible to the user, and any thrown error
205
+ // surfaces in the autoFixCheck try/catch instead of being swallowed
206
+ // by a child-process exit code.
207
+ try {
208
+ const { runEmbeddingsMigrationIfNeeded } = await import('../services/embeddings-migration.js');
209
+ return await runEmbeddingsMigrationIfNeeded();
210
+ }
211
+ catch (e) {
212
+ output.writeln(output.warning(` Embeddings migration failed: ${errorDetail(e)}`));
213
+ return false;
214
+ }
215
+ },
184
216
  'Status Line': async () => {
185
217
  const settingsPath = join(process.cwd(), '.claude', 'settings.json');
186
218
  if (!existsSync(settingsPath))
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Per-model USD/1M rates. Lookup goes through {@link rateForModel}, which
3
+ * normalises the transcript's loose model names ("opus", "claude-opus-4-7",
4
+ * "haiku-4-5", etc.) onto a canonical key.
5
+ */
6
+ export const CLAUDE_RATES = {
7
+ // Opus 4.x family
8
+ opus: { input: 15, output: 75, cacheCreate: 18.75, cacheRead: 1.5 },
9
+ // Sonnet 4.x family
10
+ sonnet: { input: 3, output: 15, cacheCreate: 3.75, cacheRead: 0.3 },
11
+ // Haiku 4.x family
12
+ haiku: { input: 0.8, output: 4, cacheCreate: 1, cacheRead: 0.08 },
13
+ // Unknown — zeroed out so the cost UI shows $0 for unrecognised models
14
+ // rather than guessing wrong. The model name still surfaces in the
15
+ // distribution card so the user can see the gap.
16
+ unknown: { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
17
+ };
18
+ /** Canonical model keys, ordered most-expensive → cheapest for stable display. */
19
+ export const CANONICAL_MODELS = ['opus', 'sonnet', 'haiku', 'unknown'];
20
+ /**
21
+ * Map a transcript model name to a canonical rate key. Recognises:
22
+ * - bare family names: "opus", "sonnet", "haiku"
23
+ * - dated/dotted variants: "claude-opus-4-7", "claude-3-5-sonnet-20241022"
24
+ * - case-insensitive
25
+ * Anything else falls through to `'unknown'`.
26
+ */
27
+ export function canonicalModelKey(model) {
28
+ if (!model || typeof model !== 'string')
29
+ return 'unknown';
30
+ const lower = model.toLowerCase();
31
+ if (lower.includes('opus'))
32
+ return 'opus';
33
+ if (lower.includes('sonnet'))
34
+ return 'sonnet';
35
+ if (lower.includes('haiku'))
36
+ return 'haiku';
37
+ return 'unknown';
38
+ }
39
+ /** Resolve a `ClaudeRate` row for the given (possibly raw) model name. */
40
+ export function rateForModel(model) {
41
+ return CLAUDE_RATES[canonicalModelKey(model)] ?? CLAUDE_RATES.unknown;
42
+ }
43
+ /**
44
+ * Compute USD cost for a single usage record.
45
+ * Rates are per 1M tokens; divide by 1e6 once at the end.
46
+ */
47
+ export function costFromUsage(rate, usage) {
48
+ const i = usage.input ?? 0;
49
+ const o = usage.output ?? 0;
50
+ const cc = usage.cacheCreate ?? 0;
51
+ const cr = usage.cacheRead ?? 0;
52
+ return ((i * rate.input + o * rate.output + cc * rate.cacheCreate + cr * rate.cacheRead) / 1_000_000);
53
+ }
54
+ //# sourceMappingURL=claude-model-rates.js.map
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Claude Stats aggregator — issue #1044.
3
+ *
4
+ * Reads `~/.claude/projects/<encoded-cwd>/*.jsonl` line-by-line and produces
5
+ * the JSON shape consumed by The Luminarium's "Claude Stats" tab. Pure I/O
6
+ * + reduce; no persistent storage, no network.
7
+ *
8
+ * Performance posture:
9
+ * - Streaming readline (not `readFileSync().split('\n')`) — transcripts
10
+ * can grow to tens of MB and we don't want to balloon dashboard memory.
11
+ * - Aggregate counters only — message bodies are dropped after extracting
12
+ * `usage`, `model`, `tool_use.name`, `tool_result.is_error`.
13
+ * - 30s TTL cache keyed on the most-recent file mtime in the project dir
14
+ * so consecutive dashboard polls reuse the prior aggregation when no
15
+ * transcript has changed.
16
+ */
17
+ import { createReadStream } from 'node:fs';
18
+ import { stat, readdir } from 'node:fs/promises';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { createInterface } from 'node:readline';
22
+ import { canonicalModelKey, costFromUsage, rateForModel } from './claude-model-rates.js';
23
+ // ============================================================================
24
+ // Path resolution
25
+ // ============================================================================
26
+ /**
27
+ * Encode a CWD the same way Claude Code does for `~/.claude/projects/<dir>`.
28
+ * Replaces `\`, `/`, and `:` with `-` so `C:\Users\eric\Projects\moflo` →
29
+ * `C--Users-eric-Projects-moflo`.
30
+ */
31
+ export function encodeCwdForClaudeProjects(cwd) {
32
+ return cwd.replace(/[\\/:]/g, '-');
33
+ }
34
+ /** Absolute path to `~/.claude/projects/<encoded-cwd>` for the given CWD. */
35
+ export function claudeProjectDirFor(cwd) {
36
+ return join(homedir(), '.claude', 'projects', encodeCwdForClaudeProjects(cwd));
37
+ }
38
+ const CACHE_TTL_MS = 30_000;
39
+ let cache = null;
40
+ /** Test-only — drop the in-memory cache so the next call re-aggregates. */
41
+ export function _resetClaudeStatsCache() {
42
+ cache = null;
43
+ }
44
+ // ============================================================================
45
+ // Aggregation
46
+ // ============================================================================
47
+ const DAY_MS = 86_400_000;
48
+ function emptyWindow() {
49
+ return { sessions: new Set(), input: 0, output: 0, cacheCreate: 0, cacheRead: 0, costUsd: 0 };
50
+ }
51
+ function freezeWindow(w) {
52
+ const total = w.input + w.output + w.cacheCreate + w.cacheRead;
53
+ return {
54
+ sessions: w.sessions.size,
55
+ tokens: {
56
+ input: w.input,
57
+ output: w.output,
58
+ cacheCreate: w.cacheCreate,
59
+ cacheRead: w.cacheRead,
60
+ total,
61
+ },
62
+ costUsd: round4(w.costUsd),
63
+ };
64
+ }
65
+ function round4(n) {
66
+ return Math.round(n * 10_000) / 10_000;
67
+ }
68
+ function makeAggregator() {
69
+ return {
70
+ today: emptyWindow(),
71
+ last7d: emptyWindow(),
72
+ last30d: emptyWindow(),
73
+ lifetime: emptyWindow(),
74
+ modelMessages: new Map(),
75
+ modelTokens: new Map(),
76
+ toolCounts: new Map(),
77
+ sessions: new Map(),
78
+ parseErrors: 0,
79
+ };
80
+ }
81
+ /** Walk one parsed JSONL line and update the aggregator. */
82
+ function consumeLine(agg, line, now) {
83
+ const ts = line.timestamp ? Date.parse(line.timestamp) : NaN;
84
+ const sessionId = line.sessionId;
85
+ if (sessionId && Number.isFinite(ts)) {
86
+ const meta = agg.sessions.get(sessionId);
87
+ if (meta) {
88
+ if (ts < meta.firstTs)
89
+ meta.firstTs = ts;
90
+ if (ts > meta.lastTs)
91
+ meta.lastTs = ts;
92
+ }
93
+ else {
94
+ agg.sessions.set(sessionId, { firstTs: ts, lastTs: ts, hasError: false });
95
+ }
96
+ }
97
+ // Tool-error detection: tool_result blocks with is_error: true.
98
+ // These appear on `type:"user"` lines whose message.content is an array
99
+ // of tool_result blocks. We only check is_error so we don't have to
100
+ // copy any payloads.
101
+ const content = line.message?.content;
102
+ if (Array.isArray(content)) {
103
+ for (const block of content) {
104
+ if (!block || typeof block !== 'object')
105
+ continue;
106
+ const b = block;
107
+ if (b.type === 'tool_use' && typeof b.name === 'string') {
108
+ agg.toolCounts.set(b.name, (agg.toolCounts.get(b.name) ?? 0) + 1);
109
+ }
110
+ else if (b.type === 'tool_result' && b.is_error === true && sessionId) {
111
+ const meta = agg.sessions.get(sessionId);
112
+ if (meta)
113
+ meta.hasError = true;
114
+ }
115
+ }
116
+ }
117
+ // Only assistant lines carry billable usage. Skip everything else.
118
+ if (line.type !== 'assistant')
119
+ return;
120
+ const usage = line.message?.usage;
121
+ if (!usage)
122
+ return;
123
+ const input = usage.input_tokens ?? 0;
124
+ const output = usage.output_tokens ?? 0;
125
+ const cc = usage.cache_creation_input_tokens ?? 0;
126
+ const cr = usage.cache_read_input_tokens ?? 0;
127
+ const totalThisLine = input + output + cc + cr;
128
+ const modelKey = canonicalModelKey(line.message?.model);
129
+ const rate = rateForModel(line.message?.model);
130
+ const cost = costFromUsage(rate, { input, output, cacheCreate: cc, cacheRead: cr });
131
+ agg.modelMessages.set(modelKey, (agg.modelMessages.get(modelKey) ?? 0) + 1);
132
+ agg.modelTokens.set(modelKey, (agg.modelTokens.get(modelKey) ?? 0) + totalThisLine);
133
+ // Lifetime always.
134
+ bump(agg.lifetime, sessionId, input, output, cc, cr, cost);
135
+ // Bucketed windows — only when we have a usable timestamp.
136
+ if (Number.isFinite(ts)) {
137
+ const ageMs = now - ts;
138
+ if (ageMs >= 0 && ageMs < DAY_MS)
139
+ bump(agg.today, sessionId, input, output, cc, cr, cost);
140
+ if (ageMs >= 0 && ageMs < 7 * DAY_MS)
141
+ bump(agg.last7d, sessionId, input, output, cc, cr, cost);
142
+ if (ageMs >= 0 && ageMs < 30 * DAY_MS)
143
+ bump(agg.last30d, sessionId, input, output, cc, cr, cost);
144
+ }
145
+ }
146
+ function bump(w, sessionId, input, output, cc, cr, cost) {
147
+ if (sessionId)
148
+ w.sessions.add(sessionId);
149
+ w.input += input;
150
+ w.output += output;
151
+ w.cacheCreate += cc;
152
+ w.cacheRead += cr;
153
+ w.costUsd += cost;
154
+ }
155
+ // ============================================================================
156
+ // File walking
157
+ // ============================================================================
158
+ /** List the `.jsonl` files in a project dir, returning [path, mtime, size]. */
159
+ async function listTranscripts(dir) {
160
+ let entries;
161
+ try {
162
+ entries = await readdir(dir);
163
+ }
164
+ catch (err) {
165
+ if (err.code === 'ENOENT')
166
+ return [];
167
+ throw err;
168
+ }
169
+ const candidates = entries.filter(n => n.endsWith('.jsonl'));
170
+ // Stat in parallel — at ~700 files a serial loop is the dominant cost
171
+ // before any file content is read.
172
+ const stats = await Promise.all(candidates.map(async (name) => {
173
+ const full = join(dir, name);
174
+ try {
175
+ const s = await stat(full);
176
+ return s.isFile() ? { path: full, mtimeMs: s.mtimeMs, size: s.size } : null;
177
+ }
178
+ catch {
179
+ // Skip transient ENOENT (file rotated mid-listing) and perms errors.
180
+ return null;
181
+ }
182
+ }));
183
+ return stats.filter((s) => s !== null);
184
+ }
185
+ async function streamFile(agg, path, now) {
186
+ const stream = createReadStream(path, { encoding: 'utf-8' });
187
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
188
+ for await (const raw of rl) {
189
+ if (!raw)
190
+ continue;
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(raw);
194
+ }
195
+ catch {
196
+ agg.parseErrors++;
197
+ continue;
198
+ }
199
+ consumeLine(agg, parsed, now);
200
+ }
201
+ }
202
+ /**
203
+ * Build the JSON shape returned by `GET /api/claude-stats`.
204
+ *
205
+ * Returns an `available: false` shape when the project dir doesn't exist or
206
+ * holds no transcripts — the dashboard renders a "no sessions found" empty
207
+ * state from that signal.
208
+ */
209
+ export async function aggregateClaudeStats(cwd, options = {}) {
210
+ const startedAt = Date.now();
211
+ const dir = options.projectDir ?? claudeProjectDirFor(cwd);
212
+ const now = options.now ?? Date.now();
213
+ const files = await listTranscripts(dir);
214
+ if (files.length === 0) {
215
+ return emptyShape(dir, Date.now() - startedAt);
216
+ }
217
+ // Cache key: max(mtime) + sum(size). If neither shifts, the prior
218
+ // aggregation is still valid. Also folds in the number of files so a
219
+ // delete invalidates correctly.
220
+ const maxMtime = files.reduce((m, f) => Math.max(m, f.mtimeMs), 0);
221
+ const totalSize = files.reduce((s, f) => s + f.size, 0);
222
+ const cacheKey = `${dir}|${files.length}|${maxMtime}|${totalSize}`;
223
+ if (!options.skipCache && cache && cache.key === cacheKey && Date.now() - cache.cachedAt < CACHE_TTL_MS) {
224
+ return cache.value;
225
+ }
226
+ const agg = makeAggregator();
227
+ for (const f of files) {
228
+ try {
229
+ await streamFile(agg, f.path, now);
230
+ }
231
+ catch (err) {
232
+ // One bad file shouldn't blank the whole tab.
233
+ console.warn(`[claude-stats] failed to read ${f.path}: ${err.message ?? err}`);
234
+ }
235
+ }
236
+ const value = freezeAggregator(agg, dir, Date.now() - startedAt);
237
+ cache = { key: cacheKey, value, cachedAt: Date.now() };
238
+ return value;
239
+ }
240
+ /** Build the all-zero shape — exported so the dashboard route handler can
241
+ * reuse it on its own catch path without re-declaring the structure. */
242
+ export function emptyClaudeStatsShape(dir = null, elapsedMs = 0) {
243
+ return emptyShape(dir, elapsedMs);
244
+ }
245
+ function emptyShape(dir, elapsedMs) {
246
+ const empty = freezeWindow(emptyWindow());
247
+ return {
248
+ available: false,
249
+ projectDir: dir,
250
+ windows: { today: empty, last7d: empty, last30d: empty, lifetime: empty },
251
+ models: [],
252
+ tools: [],
253
+ errorSessions: 0,
254
+ sessionDurationMs: { median: 0, p95: 0 },
255
+ totalSessions: 0,
256
+ parseErrors: 0,
257
+ elapsedMs,
258
+ };
259
+ }
260
+ function freezeAggregator(agg, dir, elapsedMs) {
261
+ const models = [];
262
+ for (const key of agg.modelMessages.keys()) {
263
+ models.push({
264
+ model: key,
265
+ messages: agg.modelMessages.get(key) ?? 0,
266
+ tokens: agg.modelTokens.get(key) ?? 0,
267
+ });
268
+ }
269
+ models.sort((a, b) => b.tokens - a.tokens);
270
+ const tools = Array.from(agg.toolCounts, ([name, count]) => ({ name, count }))
271
+ .sort((a, b) => b.count - a.count)
272
+ .slice(0, 10);
273
+ let errorSessions = 0;
274
+ const durations = [];
275
+ for (const meta of agg.sessions.values()) {
276
+ if (meta.hasError)
277
+ errorSessions++;
278
+ const span = meta.lastTs - meta.firstTs;
279
+ if (span > 0)
280
+ durations.push(span);
281
+ }
282
+ durations.sort((a, b) => a - b);
283
+ const median = durations.length ? durations[Math.floor(durations.length / 2)] : 0;
284
+ const p95 = durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] : 0;
285
+ return {
286
+ available: true,
287
+ projectDir: dir,
288
+ windows: {
289
+ today: freezeWindow(agg.today),
290
+ last7d: freezeWindow(agg.last7d),
291
+ last30d: freezeWindow(agg.last30d),
292
+ lifetime: freezeWindow(agg.lifetime),
293
+ },
294
+ models,
295
+ tools,
296
+ errorSessions,
297
+ sessionDurationMs: { median, p95 },
298
+ totalSessions: agg.sessions.size,
299
+ parseErrors: agg.parseErrors,
300
+ elapsedMs,
301
+ };
302
+ }
303
+ //# sourceMappingURL=claude-stats.js.map
@@ -15,6 +15,7 @@
15
15
  import { createServer } from 'node:http';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
17
  import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
18
+ import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
18
19
  export const DEFAULT_DASHBOARD_PORT = 3117;
19
20
  /**
20
21
  * Process-wide promise for the shared MemoryAccessor. Memoized as a *promise*
@@ -253,6 +254,23 @@ async function handleMemoryStats() {
253
254
  return { namespaces: {}, totalEntries: 0, available: false };
254
255
  }
255
256
  }
257
+ /**
258
+ * Build the `/api/claude-stats` response (#1044).
259
+ *
260
+ * Reads `~/.claude/projects/<encoded-cwd>/*.jsonl` for the daemon's CWD
261
+ * and returns the aggregated shape consumed by the Claude Stats tab.
262
+ * Failures degrade to an empty shape rather than 500ing — the dashboard
263
+ * is read-only context, never the user's primary workflow.
264
+ */
265
+ async function handleClaudeStats() {
266
+ try {
267
+ return await aggregateClaudeStats(process.cwd());
268
+ }
269
+ catch (err) {
270
+ console.warn(`[dashboard] claude-stats failed: ${err.message ?? err}`);
271
+ return emptyClaudeStatsShape();
272
+ }
273
+ }
256
274
  // ============================================================================
257
275
  // Flo Run Context — build and store human-readable run metadata
258
276
  // ============================================================================
@@ -418,6 +436,9 @@ async function handleRequest(req, res, daemon, opts) {
418
436
  else if (url === '/api/memory/stats') {
419
437
  sendJson(res, 200, await handleMemoryStats());
420
438
  }
439
+ else if (url === '/api/claude-stats') {
440
+ sendJson(res, 200, await handleClaudeStats());
441
+ }
421
442
  else {
422
443
  sendJson(res, 404, { error: 'Not found' });
423
444
  }
@@ -709,14 +730,16 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
709
730
  <div id="panel-schedules" class="panel" style="display:none"><div id="schedules-active"></div><div id="schedules-events"></div></div>
710
731
  <div id="panel-executions" class="panel" style="display:none"></div>
711
732
  <div id="panel-memory" class="panel" style="display:none"></div>
733
+ <div id="panel-claude-stats" class="panel" style="display:none"></div>
712
734
  <div id="poll-indicator" class="poll-indicator"></div>
713
735
  <script>
714
736
  // Tab navigation — plain DOM, no framework
715
- const tabIds = ['workers', 'schedules', 'executions', 'memory'];
716
- const tabLabels = ['Workers', 'Schedules', 'Flo Runs', 'Memory'];
737
+ const tabIds = ['workers', 'schedules', 'executions', 'memory', 'claude-stats'];
738
+ const tabLabels = ['Workers', 'Schedules', 'Flo Runs', 'Memory', 'Claude Stats'];
717
739
  let activeTab = 'workers';
718
740
 
719
741
  function switchTab(id) {
742
+ const prev = activeTab;
720
743
  activeTab = id;
721
744
  tabIds.forEach(t => {
722
745
  document.getElementById('panel-' + t).style.display = t === id ? '' : 'none';
@@ -724,6 +747,12 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
724
747
  document.querySelectorAll('.nav-tab').forEach(el => {
725
748
  el.classList.toggle('active', el.dataset.tab === id);
726
749
  });
750
+ // Tabs whose data is fetched lazily (currently only Claude Stats)
751
+ // need an immediate poll on entry — otherwise the user waits up to
752
+ // the 5s polling interval for first paint.
753
+ if (id === 'claude-stats' && prev !== id && typeof poll === 'function') {
754
+ poll();
755
+ }
727
756
  }
728
757
 
729
758
  // Build nav tabs
@@ -1019,20 +1048,132 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
1019
1048
  '<table><thead><tr><th>Namespace</th><th>Entries</th></tr></thead><tbody>' + rows + '</tbody></table>';
1020
1049
  }
1021
1050
 
1051
+ // Format: 1234567 → "1.23M", 5432 → "5.43K"
1052
+ const fmtCount = (n) => {
1053
+ if (n == null) return '-';
1054
+ if (n < 1000) return String(n);
1055
+ if (n < 1_000_000) return (n / 1000).toFixed(2) + 'K';
1056
+ if (n < 1_000_000_000) return (n / 1_000_000).toFixed(2) + 'M';
1057
+ return (n / 1_000_000_000).toFixed(2) + 'B';
1058
+ };
1059
+ // USD with two decimals; sub-cent renders as "<$0.01".
1060
+ const fmtUsd = (n) => {
1061
+ if (n == null) return '-';
1062
+ if (n === 0) return '$0.00';
1063
+ if (n < 0.01) return '<$0.01';
1064
+ return '$' + n.toFixed(2);
1065
+ };
1066
+
1067
+ function renderClaudeStats(cs) {
1068
+ const el = document.getElementById('panel-claude-stats');
1069
+ if (!cs) { el.innerHTML = '<div class="empty">Loading...</div>'; return; }
1070
+
1071
+ // Always-visible disclaimer banner — kept verbatim per the issue's
1072
+ // wording so the user understands the scope and limits at a glance.
1073
+ const disclaimer =
1074
+ '<div style="background:#161b22;border:1px solid #30363d;border-left:3px solid #d29922;border-radius:6px;padding:10px 14px;margin-bottom:16px;color:#c9d1d9;font-size:0.85rem;line-height:1.5">' +
1075
+ '<strong>Local stats only.</strong> Counts what Claude Code wrote to disk for THIS project on THIS machine. ' +
1076
+ 'Doesn\\'t include claude.ai web sessions, other projects, other devices, or your account-level plan quota. ' +
1077
+ 'Cost is a local estimate using current public model rates.' +
1078
+ '</div>';
1079
+
1080
+ if (!cs.available) {
1081
+ el.innerHTML = disclaimer +
1082
+ '<div class="empty">No Claude Code sessions found in this project' +
1083
+ (cs.projectDir ? ' (looked in <code style="color:#8b949e">' + esc(cs.projectDir) + '</code>)' : '') +
1084
+ '</div>';
1085
+ return;
1086
+ }
1087
+
1088
+ const w = cs.windows;
1089
+
1090
+ // Summary windows (today / 7d / 30d / lifetime).
1091
+ const winRow = (label, win) => {
1092
+ return '<tr><td style="font-weight:600">' + label + '</td>' +
1093
+ '<td>' + fmtCount(win.sessions) + '</td>' +
1094
+ '<td>' + fmtCount(win.tokens.total) + '</td>' +
1095
+ '<td>' + fmtCount(win.tokens.input) + '</td>' +
1096
+ '<td>' + fmtCount(win.tokens.output) + '</td>' +
1097
+ '<td>' + fmtCount(win.tokens.cacheCreate) + '</td>' +
1098
+ '<td>' + fmtCount(win.tokens.cacheRead) + '</td>' +
1099
+ '<td>' + fmtUsd(win.costUsd) + '</td></tr>';
1100
+ };
1101
+ const winTable =
1102
+ '<h2>Sessions, Tokens, Cost</h2>' +
1103
+ '<table><thead><tr>' +
1104
+ '<th>Window</th><th>Sessions</th><th>Total Tokens</th>' +
1105
+ '<th>Input</th><th>Output</th><th>Cache Create</th><th>Cache Read</th><th>Est. Cost</th>' +
1106
+ '</tr></thead><tbody>' +
1107
+ winRow('Today', w.today) +
1108
+ winRow('Last 7 days', w.last7d) +
1109
+ winRow('Last 30 days', w.last30d) +
1110
+ winRow('Lifetime', w.lifetime) +
1111
+ '</tbody></table>';
1112
+
1113
+ // Model distribution.
1114
+ const modelRows = (cs.models && cs.models.length)
1115
+ ? cs.models.map(m => '<tr><td>' + esc(m.model) + '</td>' +
1116
+ '<td>' + fmtCount(m.messages) + '</td>' +
1117
+ '<td>' + fmtCount(m.tokens) + '</td></tr>').join('')
1118
+ : '<tr><td colspan="3" class="empty">No model data</td></tr>';
1119
+ const modelsTable =
1120
+ '<h2>Models Used</h2>' +
1121
+ '<table><thead><tr><th>Model</th><th>Messages</th><th>Total Tokens</th></tr></thead>' +
1122
+ '<tbody>' + modelRows + '</tbody></table>';
1123
+
1124
+ // Top-10 tools.
1125
+ const toolRows = (cs.tools && cs.tools.length)
1126
+ ? cs.tools.map(t => '<tr><td>' + esc(t.name) + '</td><td>' + fmtCount(t.count) + '</td></tr>').join('')
1127
+ : '<tr><td colspan="2" class="empty">No tool calls recorded</td></tr>';
1128
+ const toolsTable =
1129
+ '<h2>Top Tools</h2>' +
1130
+ '<table><thead><tr><th>Tool</th><th>Calls</th></tr></thead>' +
1131
+ '<tbody>' + toolRows + '</tbody></table>';
1132
+
1133
+ // Headline cards.
1134
+ const cards =
1135
+ '<div class="grid">' +
1136
+ '<div class="stat-card"><div class="label">Total Sessions</div><div class="value">' + fmtCount(cs.totalSessions) + '</div></div>' +
1137
+ '<div class="stat-card"><div class="label">Sessions w/ Errors</div><div class="value">' + fmtCount(cs.errorSessions) + '</div></div>' +
1138
+ '<div class="stat-card"><div class="label">Median Duration</div><div class="value">' + fmtDuration(cs.sessionDurationMs.median) + '</div></div>' +
1139
+ '<div class="stat-card"><div class="label">p95 Duration</div><div class="value">' + fmtDuration(cs.sessionDurationMs.p95) + '</div></div>' +
1140
+ '</div>';
1141
+
1142
+ // Footer note linking to the rate-table source comment.
1143
+ const footer =
1144
+ '<div class="dim" style="margin-top:12px">' +
1145
+ 'Cost estimate uses rates from <code>src/cli/services/claude-model-rates.ts</code> ' +
1146
+ '(USD/1M tokens, list price). Aggregation took ' + fmtDuration(cs.elapsedMs) +
1147
+ (cs.parseErrors ? ' &middot; ' + cs.parseErrors + ' lines skipped (parse error)' : '') +
1148
+ '</div>';
1149
+
1150
+ el.innerHTML = disclaimer + cards + winTable + modelsTable + toolsTable + footer;
1151
+ }
1152
+
1022
1153
  // Polling
1023
1154
  const poll = async () => {
1024
1155
  try {
1025
- const [s, sc, w, m] = await Promise.all([
1156
+ // Claude Stats aggregation walks the user's transcript dir — only
1157
+ // pull it when the tab is visible so steady-state polling stays
1158
+ // four lightweight endpoints. Switching to the tab triggers an
1159
+ // immediate poll so the user doesn't wait up to 5s for first paint.
1160
+ const wantClaudeStats = activeTab === 'claude-stats';
1161
+ const fetches = [
1026
1162
  fetch('/api/status').then(r => r.json()),
1027
1163
  fetch('/api/schedules').then(r => r.json()),
1028
1164
  fetch('/api/spells').then(r => r.json()),
1029
1165
  fetch('/api/memory/stats').then(r => r.json()),
1030
- ]);
1166
+ wantClaudeStats
1167
+ ? fetch('/api/claude-stats').then(r => r.json()).catch(() => null)
1168
+ : Promise.resolve(null),
1169
+ ];
1170
+ const [s, sc, w, m, cs] = await Promise.all(fetches);
1031
1171
  renderStatus(s);
1032
1172
  renderWorkers(s);
1033
1173
  renderSchedules(sc);
1034
1174
  renderExecutions(w);
1035
1175
  renderMemory(m);
1176
+ if (wantClaudeStats) renderClaudeStats(cs);
1036
1177
  document.getElementById('poll-indicator').textContent = 'Last poll: ' + new Date().toLocaleTimeString();
1037
1178
  } catch (e) {
1038
1179
  console.error('Poll failed:', e);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Auth-Error Classifier
3
+ *
4
+ * Pattern-matches step-failure text against known upstream auth-shaped
5
+ * signals (HTTP 401, OAuth `invalid_grant`, MS Graph `IDX1410x`, etc.).
6
+ * The runner uses the result to offer in-product credential clearing +
7
+ * re-prompt + single-retry instead of looping forever on a stale token.
8
+ *
9
+ * Design: pure function, no I/O, no `/g` flag (statefulness was banned in
10
+ * `feedback_publish_catches_straggler_bugs.md`). Adding upstream-specific
11
+ * signals is a one-line addition to {@link AUTH_ERROR_PATTERNS}.
12
+ *
13
+ * Story #1042.
14
+ */
15
+ /**
16
+ * Initial pattern table from issue #1042. Order matters — high-confidence
17
+ * upstream-specific patterns come before generic literals so the most
18
+ * actionable `reason` is reported when a message matches several rules.
19
+ */
20
+ export const AUTH_ERROR_PATTERNS = [
21
+ {
22
+ name: 'msal-idx141',
23
+ pattern: /\bIDX1410[0-9]\b/,
24
+ confidence: 'high',
25
+ reason: 'Microsoft Identity token rejection (IDX1410x)',
26
+ },
27
+ {
28
+ name: 'invalid-auth-token',
29
+ pattern: /InvalidAuthenticationToken/i,
30
+ confidence: 'high',
31
+ reason: 'Microsoft Graph InvalidAuthenticationToken',
32
+ },
33
+ {
34
+ name: 'github-bad-creds',
35
+ pattern: /\bBad\s+credentials\b/i,
36
+ confidence: 'high',
37
+ reason: 'GitHub Bad credentials',
38
+ },
39
+ {
40
+ name: 'oauth-invalid-grant',
41
+ pattern: /\binvalid_grant\b/i,
42
+ confidence: 'high',
43
+ reason: 'OAuth2 invalid_grant',
44
+ },
45
+ {
46
+ name: 'oauth-expired-token',
47
+ pattern: /\bexpired[_\s-]token\b/i,
48
+ confidence: 'high',
49
+ reason: 'OAuth2 expired_token',
50
+ },
51
+ {
52
+ name: 'token-expired-camel',
53
+ pattern: /\bTokenExpired\b/,
54
+ confidence: 'high',
55
+ reason: 'TokenExpired',
56
+ },
57
+ {
58
+ name: 'http-401',
59
+ pattern: /(?:^|[^0-9])401(?:[^0-9]|$)/,
60
+ confidence: 'high',
61
+ reason: 'HTTP 401 (unauthorized)',
62
+ },
63
+ {
64
+ name: 'http-403',
65
+ pattern: /(?:^|[^0-9])403(?:[^0-9]|$)/,
66
+ confidence: 'low',
67
+ reason: 'HTTP 403 (forbidden)',
68
+ },
69
+ {
70
+ name: 'unauthorized-word',
71
+ pattern: /\bUnauthorized\b/,
72
+ confidence: 'low',
73
+ reason: 'unauthorized literal',
74
+ },
75
+ ];
76
+ /**
77
+ * Match `text` against the pattern table. Returns the first matching
78
+ * pattern (highest-priority hit) or null.
79
+ *
80
+ * `text` is treated as the entire stderr/error blob from a failed step;
81
+ * patterns are non-anchored on purpose so they catch multi-line outputs.
82
+ */
83
+ export function classifyAuthError(text) {
84
+ if (typeof text !== 'string' || text.length === 0)
85
+ return null;
86
+ for (const pattern of AUTH_ERROR_PATTERNS) {
87
+ if (pattern.pattern.test(text))
88
+ return { pattern };
89
+ }
90
+ return null;
91
+ }
92
+ //# sourceMappingURL=auth-error-classifier.js.map
@@ -9,6 +9,9 @@ import { rollbackSteps } from './rollback-orchestrator.js';
9
9
  import { buildCredentialPatterns, addCredentialPattern, collectCredentialNames } from './credential-masker.js';
10
10
  import { executeSingleStep } from './step-executor.js';
11
11
  import { collectPrerequisites, resolveUnmetPrerequisites } from './prerequisite-checker.js';
12
+ import { classifyAuthError } from './auth-error-classifier.js';
13
+ import { readLineFromStdin } from './stdin-reader.js';
14
+ import { acquireTTYLock } from './tty-lock.js';
12
15
  import { collectPreflights, checkPreflights, formatPreflightErrors, partitionPreflightResults, runResolutionCommand, } from './preflight-checker.js';
13
16
  import { DENY_ALL_GATEWAY } from './capability-gateway.js';
14
17
  import { resolveEffectiveSandbox, formatSandboxLog, DEFAULT_SANDBOX_CONFIG, } from './platform-sandbox.js';
@@ -229,6 +232,18 @@ export class SpellCaster {
229
232
  let result = await this.runStep(step, state, i);
230
233
  const resultIdx = stepResults.length;
231
234
  stepResults.push(result);
235
+ // Auth-error recovery (issue #1042): if a step fails with an upstream
236
+ // auth-shaped signal AND the spell declares credential prereqs, offer
237
+ // (TTY) or surface (non-TTY) the chance to clear + re-prompt + retry
238
+ // once. The recovery returns a new result (success or marked failure)
239
+ // that replaces the original; null means no recovery happened.
240
+ if (result.status === 'failed') {
241
+ const recovered = await this.tryAuthErrorRecovery(definition, step, state, i, result);
242
+ if (recovered) {
243
+ result = recovered;
244
+ stepResults[resultIdx] = result;
245
+ }
246
+ }
232
247
  if (result.status === 'succeeded' && result.output) {
233
248
  if (step.output)
234
249
  variables[step.output] = result.output.data;
@@ -395,6 +410,179 @@ export class SpellCaster {
395
410
  const ctxBuilder = (v, a, sid, si, sig) => this.buildContext(v, a, sid, si, sig, state.effectiveSandbox);
396
411
  return executeSingleStep(step, state, index, this.registry, ctxBuilder);
397
412
  }
413
+ /**
414
+ * Issue #1042 — react to upstream auth-shaped errors at step-failure time.
415
+ *
416
+ * The runner already validates credentials at preflight (#1007/#1009), but
417
+ * preflight can't know whether a value the upstream accepts today still
418
+ * works tomorrow. When a step fails with output that matches the auth
419
+ * pattern table AND the spell has env-typed credential prereqs, we offer
420
+ * (TTY) or surface (non-TTY) clearing + re-prompt + a single retry.
421
+ *
422
+ * Returns a replacement {@link StepResult} when recovery ran (success on
423
+ * retry, or a `CREDENTIAL_LIKELY_STALE`-coded failure when non-TTY or when
424
+ * the retry hits the same auth-shape — second-failure short-circuit). Null
425
+ * means recovery did not run; the caller should fall through to the
426
+ * normal failure path with the original result intact.
427
+ */
428
+ async tryAuthErrorRecovery(definition, step, state, stepIndex, result) {
429
+ const errorText = this.collectStepErrorText(result);
430
+ const match = classifyAuthError(errorText);
431
+ if (!match)
432
+ return null;
433
+ const credPrereqs = collectPrerequisites(definition, this.registry)
434
+ .filter((p) => typeof p.envKey === 'string' && p.envKey.length > 0);
435
+ if (credPrereqs.length === 0)
436
+ return null;
437
+ const credKeys = credPrereqs.map(p => p.envKey);
438
+ // Low-confidence patterns (HTTP 403, bare "Unauthorized") are too noisy
439
+ // to drive destructive credential clearing — a 403 typically means
440
+ // "wrong scope" not "expired token", and "Unauthorized" appears in many
441
+ // unrelated 4xx responses. Surface a hint and fall through to the
442
+ // normal failure path so the user sees the real error.
443
+ if (match.pattern.confidence === 'low') {
444
+ console.log(`[spell] Step "${step.id}" failed with a possible auth signal (${match.pattern.reason}).`);
445
+ console.log(`[spell] If "${credKeys.join(', ')}" is stale, run \`flo spell credentials unset ${credKeys[0]}\` and re-cast.`);
446
+ return null;
447
+ }
448
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
449
+ const externalConfirm = state.options.authErrorConfirm;
450
+ if (!interactive && !externalConfirm) {
451
+ // Non-TTY (cron, headless, daemon spawn) with no host-supplied prompt
452
+ // hook: surface a dedicated error code so schedulers can route this
453
+ // to "stale credential, needs human" instead of generic step failure.
454
+ return this.markCredentialLikelyStale(result, match, credKeys, /* afterReprompt */ false);
455
+ }
456
+ // TTY path (or test/host-injected confirm). With a single credential
457
+ // the default is Y (per #1042 issue body); with multiple credentials we
458
+ // flip the default to N so a single typo doesn't trash a working
459
+ // credential alongside a stale one. The injected hook fully replaces
460
+ // both detection and the readline call.
461
+ const defaultYes = credKeys.length === 1;
462
+ let confirmed;
463
+ if (externalConfirm) {
464
+ try {
465
+ confirmed = await externalConfirm({
466
+ stepId: step.id,
467
+ pattern: match.pattern.name,
468
+ reason: match.pattern.reason,
469
+ credKeys,
470
+ });
471
+ }
472
+ catch {
473
+ return null;
474
+ }
475
+ }
476
+ else {
477
+ const lock = acquireTTYLock();
478
+ try {
479
+ console.log('');
480
+ console.log(`[spell] Step "${step.id}" failed with what looks like a credential error.`);
481
+ console.log(`[spell] Detected: ${match.pattern.reason}`);
482
+ console.log(`[spell] Stored credential${credKeys.length === 1 ? '' : 's'}: ${credKeys.join(', ')}`);
483
+ if (!defaultYes) {
484
+ console.log(`[spell] Multiple credentials in scope — only confirm if you want ALL of them re-prompted.`);
485
+ }
486
+ const noun = credKeys.length === 1 ? 'this credential' : 'these credentials';
487
+ const promptSuffix = defaultYes ? '[Y/n]' : '[y/N]';
488
+ const prompt = `Clear ${noun} and re-prompt? ${promptSuffix} `;
489
+ let answer = '';
490
+ try {
491
+ answer = await readLineFromStdin(prompt, state.options.signal);
492
+ }
493
+ catch {
494
+ // Abort or stdin closed → carry forward the original failure.
495
+ return null;
496
+ }
497
+ const trimmed = answer.trim().toLowerCase();
498
+ const isYes = trimmed === 'y' || trimmed === 'yes';
499
+ confirmed = isYes || (defaultYes && trimmed === '');
500
+ }
501
+ finally {
502
+ lock.release();
503
+ }
504
+ }
505
+ if (!confirmed)
506
+ return null;
507
+ // Clear store + process.env so the resolver re-prompts. `delete` is
508
+ // optional on the accessor — read-only fixtures may omit it.
509
+ for (const key of credKeys) {
510
+ if (typeof this.credentials.delete === 'function') {
511
+ try {
512
+ await this.credentials.delete(key);
513
+ }
514
+ catch { /* surface as re-prompt fallthrough */ }
515
+ }
516
+ delete process.env[key];
517
+ }
518
+ // Don't force re-prompt here — we just deleted the value from the store
519
+ // and process.env, so the standard resolution chain naturally falls
520
+ // through to the TTY prompt (or, in test mode, lets the host inject a
521
+ // replacement via the credentials accessor's get() method).
522
+ const resolution = await resolveUnmetPrerequisites(credPrereqs, {
523
+ abortSignal: state.options.signal,
524
+ credentials: this.credentials,
525
+ });
526
+ if (!resolution.ok) {
527
+ return this.markCredentialLikelyStale(result, match, credKeys, /* afterReprompt */ true);
528
+ }
529
+ // Refresh resolvedCredentials + credential masking patterns so the retry
530
+ // sees the new value the same way the first attempt did.
531
+ for (const key of credKeys) {
532
+ try {
533
+ const fresh = await this.credentials.get(key);
534
+ if (fresh !== undefined) {
535
+ state.resolvedCredentials[key] = fresh;
536
+ addCredentialPattern(state.credentialPatterns, fresh);
537
+ }
538
+ }
539
+ catch { /* keep going — the env var is set, that's the load-bearing path */ }
540
+ }
541
+ console.log(`[spell] Retrying "${step.id}" with refreshed credentials...`);
542
+ const retried = await this.runStep(step, state, stepIndex);
543
+ if (retried.status === 'failed') {
544
+ const retryErrorText = this.collectStepErrorText(retried);
545
+ if (classifyAuthError(retryErrorText)) {
546
+ // Second auth-shape failure — short-circuit so we don't loop.
547
+ return this.markCredentialLikelyStale(retried, match, credKeys, /* afterReprompt */ true);
548
+ }
549
+ }
550
+ return retried;
551
+ }
552
+ collectStepErrorText(result) {
553
+ const parts = [];
554
+ if (result.error)
555
+ parts.push(result.error);
556
+ const out = result.output;
557
+ if (out && typeof out === 'object') {
558
+ if (typeof out.error === 'string')
559
+ parts.push(out.error);
560
+ // Step outputs frequently embed the upstream payload under data.{stderr,error,message}.
561
+ const data = out.data;
562
+ if (data && typeof data === 'object') {
563
+ for (const key of ['stderr', 'error', 'message', 'body']) {
564
+ const v = data[key];
565
+ if (typeof v === 'string')
566
+ parts.push(v);
567
+ }
568
+ }
569
+ }
570
+ return parts.join('\n');
571
+ }
572
+ markCredentialLikelyStale(result, match, credKeys, afterReprompt) {
573
+ const keyLabel = credKeys.join(', ');
574
+ const suffix = afterReprompt
575
+ ? ` Re-prompt accepted but the upstream still rejected the credential — manual intervention required.`
576
+ : ` Run \`flo spell credentials unset ${credKeys[0]}\` (or any key above) and re-cast on a TTY.`;
577
+ const prefix = `CREDENTIAL_LIKELY_STALE [${match.pattern.name}: ${match.pattern.reason}] for ${keyLabel}.`;
578
+ const original = result.error ? ` Underlying error: ${result.error}` : '';
579
+ return {
580
+ ...result,
581
+ status: 'failed',
582
+ errorCode: 'CREDENTIAL_LIKELY_STALE',
583
+ error: `${prefix}${suffix}${original}`,
584
+ };
585
+ }
398
586
  async doRollback(completed, state, results) {
399
587
  await rollbackSteps(completed, this.registry, (i) => this.buildContext(state.variables, state.resolvedArgs, state.spellId, i, state.options.signal, state.effectiveSandbox), results);
400
588
  }
@@ -122,5 +122,6 @@ export const lockedNoopAccessor = {
122
122
  async get() { return undefined; },
123
123
  async has() { return false; },
124
124
  async store() { },
125
+ async delete() { return false; },
125
126
  };
126
127
  //# sourceMappingURL=default-store.js.map
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.33';
5
+ export const VERSION = '4.9.34';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.33",
3
+ "version": "4.9.34",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -97,7 +97,7 @@
97
97
  "@typescript-eslint/eslint-plugin": "^7.18.0",
98
98
  "@typescript-eslint/parser": "^7.18.0",
99
99
  "eslint": "^8.0.0",
100
- "moflo": "^4.9.32",
100
+ "moflo": "^4.9.33",
101
101
  "tsx": "^4.21.0",
102
102
  "typescript": "^5.9.3",
103
103
  "vitest": "^4.0.0"