moflo 4.9.32 → 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))
@@ -15,6 +15,7 @@ import { callMCPTool } from '../mcp-client.js';
15
15
  import { TOOL_MEMORY_STORE, TOOL_MEMORY_LIST, TOOL_MEMORY_RETRIEVE } from '../mcp-tools/tool-names.js';
16
16
  import { handleMCPError } from '../services/cli-formatters.js';
17
17
  import { ensureDaemonForScheduling } from '../services/daemon-readiness.js';
18
+ import { checkScheduleAcceptance } from '../services/schedule-acceptance-check.js';
18
19
  import { reconcileDaemonAutostart } from '../services/daemon-autostart-lifecycle.js';
19
20
  import { isDaemonInstalled } from '../services/daemon-service.js';
20
21
  import { validateSchedule, computeNextRun } from '../spells/scheduler/cron-parser.js';
@@ -123,6 +124,16 @@ const createCommand = {
123
124
  for (const warning of readiness.warnings) {
124
125
  output.printWarning(warning);
125
126
  }
127
+ // Permission-acceptance check (#1037): scheduled fires run in the daemon's
128
+ // non-interactive context and have no way to prompt for permissions. If
129
+ // this spell hasn't been manually cast yet, the user needs to know NOW so
130
+ // they can run `flo spell cast -n <name>` once before relying on the
131
+ // schedule. This is a warning, never a block — the user may have a legit
132
+ // reason (about to cast, scripted setup, etc.).
133
+ const acceptance = await checkScheduleAcceptance(projectRoot, name);
134
+ if (acceptance.message) {
135
+ output.printWarning(acceptance.message);
136
+ }
126
137
  // Always create the schedule, regardless of daemon state
127
138
  const id = `sched-adhoc-${now}-${Math.random().toString(36).slice(2, 8)}`;
128
139
  const record = {
@@ -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);
@@ -24,9 +24,17 @@ export class DaemonSpellExecutor {
24
24
  this.explicitSandbox = opts.sandboxConfig;
25
25
  }
26
26
  exists(spellName) {
27
+ // Invalidate before resolve so newly-added yamls are visible to the
28
+ // poll loop. Without this, stale-false from exists() causes the
29
+ // scheduler to auto-disable schedules whose spell was added on disk
30
+ // after daemon boot (#1034).
31
+ this.registry.invalidate();
27
32
  return this.registry.resolve(spellName) !== undefined;
28
33
  }
29
34
  async execute(spellName, args, signal, mofloLevel) {
35
+ // Invalidate before resolve so yaml edits on disk reach the next fire
36
+ // without needing a daemon restart (#1034).
37
+ this.registry.invalidate();
30
38
  const loaded = this.registry.resolve(spellName);
31
39
  if (!loaded) {
32
40
  return failedResult(`scheduled-${spellName}-${Date.now()}`, 'STEP_EXECUTION_FAILED', `Spell not found in grimoire: ${spellName}`);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Schedule Acceptance Check
3
+ *
4
+ * Resolves a spell, computes its current permission hash, and checks whether
5
+ * `.moflo/accepted-permissions/<name>.json` records a valid prior acceptance.
6
+ *
7
+ * The schedule-create command consumes the result to warn — never block — when
8
+ * the spell is missing acceptance. Without it, scheduled fires running in the
9
+ * non-interactive daemon context fail with `Missing credentials` and the user
10
+ * has no signal at create time that a one-time manual cast was the missing
11
+ * step (#1037).
12
+ */
13
+ import { buildGrimoire } from './grimoire-builder.js';
14
+ import { checkAcceptance } from '../spells/core/permission-acceptance.js';
15
+ /**
16
+ * Resolve `spellName` via the Grimoire, hash its permissions, compare against
17
+ * any stored acceptance under `<projectRoot>/.moflo/accepted-permissions/`.
18
+ *
19
+ * Always returns — never throws. A check failure (e.g. Grimoire unavailable)
20
+ * resolves to `check-failed` with an empty message so callers don't surface
21
+ * noise; the schedule create proceeds either way.
22
+ */
23
+ export async function checkScheduleAcceptance(projectRoot, spellName) {
24
+ try {
25
+ const { registry } = await buildGrimoire(projectRoot);
26
+ const loaded = registry.resolve(spellName);
27
+ if (!loaded) {
28
+ return {
29
+ state: 'spell-not-found',
30
+ message: `Spell "${spellName}" was not found in the grimoire. The schedule will be created, but the daemon will auto-disable it on the first fire. Check the spell name (try \`flo spell list\`).`,
31
+ };
32
+ }
33
+ const [{ analyzeSpellPermissions }, { StepCommandRegistry }, { builtinCommands },] = await Promise.all([
34
+ import('../spells/core/permission-disclosure.js'),
35
+ import('../spells/core/step-command-registry.js'),
36
+ import('../spells/commands/index.js'),
37
+ ]);
38
+ const stepRegistry = new StepCommandRegistry();
39
+ for (const cmd of builtinCommands) {
40
+ stepRegistry.register(cmd, 'built-in');
41
+ }
42
+ const report = analyzeSpellPermissions(loaded.definition, stepRegistry);
43
+ const result = await checkAcceptance(projectRoot, loaded.definition.name, report.permissionHash);
44
+ if (result.accepted) {
45
+ return { state: 'accepted', message: '' };
46
+ }
47
+ if (result.reason === 'no-acceptance') {
48
+ return {
49
+ state: 'never-accepted',
50
+ message: `Spell "${loaded.definition.name}" has not been accepted yet. Scheduled fires run non-interactively, so the first run will fail with "missing credentials". Run \`flo spell cast -n ${loaded.definition.name}\` once manually to accept permissions, then this schedule will work on the next fire.`,
51
+ };
52
+ }
53
+ return {
54
+ state: 'hash-mismatch',
55
+ message: `Spell "${loaded.definition.name}" permissions have changed since you last accepted them. Re-run \`flo spell cast -n ${loaded.definition.name}\` once to review and re-accept the new permissions; otherwise scheduled fires will fail.`,
56
+ };
57
+ }
58
+ catch (err) {
59
+ // Soft-fail: a Grimoire load error or permission analysis failure must
60
+ // never block schedule creation. Return a quiet check-failed state and
61
+ // let the create proceed. Surface the cause via console.debug so a
62
+ // developer chasing a regression can see why the check degraded
63
+ // without polluting normal CLI output.
64
+ console.debug(`[schedule-acceptance-check] check failed for ${spellName}: ${err.message}`);
65
+ return { state: 'check-failed', message: '' };
66
+ }
67
+ }
68
+ //# sourceMappingURL=schedule-acceptance-check.js.map
@@ -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
  }
@@ -8,7 +8,7 @@
8
8
  * Story #106: Encrypted Credential Storage
9
9
  */
10
10
  import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, } from 'node:crypto';
11
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
11
+ import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
12
12
  import { dirname } from 'node:path';
13
13
  // ============================================================================
14
14
  // Constants
@@ -55,6 +55,11 @@ export class CredentialStore {
55
55
  filePath;
56
56
  derivedKey = null;
57
57
  data = null;
58
+ // Tracks the file mtime that produced `this.data`. `null` means the file
59
+ // didn't exist when we last read. refreshIfStale() compares against the
60
+ // current mtime to detect external writes (e.g. CLI subprocesses calling
61
+ // `flo spell credentials set` while the daemon's instance is alive — #1035).
62
+ lastReadMtimeMs = null;
58
63
  constructor(options) {
59
64
  this.filePath = options.filePath;
60
65
  if (options.passphrase) {
@@ -70,6 +75,7 @@ export class CredentialStore {
70
75
  throw new CredentialStoreError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`, 'WEAK_PASSPHRASE');
71
76
  }
72
77
  this.data = this.readFile();
78
+ this.lastReadMtimeMs = this.fileMtimeMs();
73
79
  const salt = Buffer.from(this.data.salt, 'hex');
74
80
  this.derivedKey = deriveKey(passphrase, salt);
75
81
  }
@@ -85,9 +91,17 @@ export class CredentialStore {
85
91
  }
86
92
  /**
87
93
  * Store an encrypted credential.
94
+ *
95
+ * The refreshIfStale() call rebases on the latest on-disk state so we don't
96
+ * write back a snapshot that's missing concurrent additions. It is NOT a
97
+ * mutual-exclusion primitive: two processes calling store() on the same key
98
+ * concurrently still race, and the last writer wins. Cross-process locking
99
+ * is out of scope; the file write is small and the typical layout (one
100
+ * daemon reader + occasional CLI writers) makes the race window vanishing.
88
101
  */
89
102
  async store(name, value, description) {
90
103
  this.ensureUnlocked();
104
+ this.refreshIfStale();
91
105
  const now = new Date().toISOString();
92
106
  const encrypted = encrypt(value, this.derivedKey);
93
107
  const existing = this.data.credentials[name];
@@ -105,6 +119,7 @@ export class CredentialStore {
105
119
  */
106
120
  async get(name) {
107
121
  this.ensureUnlocked();
122
+ this.refreshIfStale();
108
123
  const entry = this.data.credentials[name];
109
124
  if (!entry)
110
125
  return undefined;
@@ -121,6 +136,7 @@ export class CredentialStore {
121
136
  */
122
137
  async has(name) {
123
138
  this.ensureUnlocked();
139
+ this.refreshIfStale();
124
140
  return name in this.data.credentials;
125
141
  }
126
142
  /**
@@ -128,6 +144,7 @@ export class CredentialStore {
128
144
  */
129
145
  async delete(name) {
130
146
  this.ensureUnlocked();
147
+ this.refreshIfStale();
131
148
  if (!(name in this.data.credentials))
132
149
  return false;
133
150
  delete this.data.credentials[name];
@@ -141,6 +158,7 @@ export class CredentialStore {
141
158
  */
142
159
  async clear() {
143
160
  this.ensureUnlocked();
161
+ this.refreshIfStale();
144
162
  const count = Object.keys(this.data.credentials).length;
145
163
  if (count === 0)
146
164
  return 0;
@@ -153,6 +171,7 @@ export class CredentialStore {
153
171
  */
154
172
  async list() {
155
173
  this.ensureUnlocked();
174
+ this.refreshIfStale();
156
175
  return Object.entries(this.data.credentials).map(([name, entry]) => ({
157
176
  name,
158
177
  description: entry.description,
@@ -166,6 +185,7 @@ export class CredentialStore {
166
185
  */
167
186
  async allValues() {
168
187
  this.ensureUnlocked();
188
+ this.refreshIfStale();
169
189
  const values = [];
170
190
  for (const entry of Object.values(this.data.credentials)) {
171
191
  try {
@@ -265,6 +285,49 @@ export class CredentialStore {
265
285
  writeFile(data) {
266
286
  mkdirSync(dirname(this.filePath), { recursive: true });
267
287
  writeFileSync(this.filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
288
+ // Adopt the just-written mtime so refreshIfStale() doesn't trigger an
289
+ // unnecessary re-read on the next operation through this instance.
290
+ this.lastReadMtimeMs = this.fileMtimeMs();
291
+ }
292
+ /**
293
+ * Return the file's mtime in ms, or null when the file doesn't exist.
294
+ * Other errors (permissions, etc.) are surfaced — they signal a real problem
295
+ * worth raising rather than silently treating as "no file".
296
+ */
297
+ fileMtimeMs() {
298
+ try {
299
+ return statSync(this.filePath).mtimeMs;
300
+ }
301
+ catch (err) {
302
+ if (err.code === 'ENOENT')
303
+ return null;
304
+ throw err;
305
+ }
306
+ }
307
+ /**
308
+ * Reload `this.data` from disk when the file's mtime differs from what we
309
+ * last read. This is the per-call hook that keeps long-lived instances
310
+ * (the daemon's singleton CredentialStore — see #1035) consistent with
311
+ * writes made by CLI subprocesses.
312
+ *
313
+ * Limitations:
314
+ * - If another process rotated the passphrase, the salt in the reloaded
315
+ * data will mismatch our derivedKey. Subsequent decrypt() calls throw
316
+ * DECRYPTION_FAILED, which the resolver treats as missing — same UX as
317
+ * today's stale-daemon failure mode and only resolved by daemon restart.
318
+ * Rotation-aware reload would need the new passphrase, which we don't
319
+ * have post-construction; out of scope here.
320
+ * - Designed for local filesystems. Network mounts (NFS/SMB) can return
321
+ * coarse or stale mtimes via client caching, which would weaken the
322
+ * detection. The credentials file lives at `~/.moflo/credentials.json`
323
+ * and is expected to be local; network-mounted homedirs aren't supported.
324
+ */
325
+ refreshIfStale() {
326
+ const current = this.fileMtimeMs();
327
+ if (current === this.lastReadMtimeMs)
328
+ return;
329
+ this.data = this.readFile();
330
+ this.lastReadMtimeMs = current;
268
331
  }
269
332
  }
270
333
  export class CredentialStoreError extends Error {
@@ -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.32';
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.32",
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.31",
100
+ "moflo": "^4.9.33",
101
101
  "tsx": "^4.21.0",
102
102
  "typescript": "^5.9.3",
103
103
  "vitest": "^4.0.0"