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.
- package/dist/src/cli/commands/doctor-fixes.js +32 -0
- package/dist/src/cli/commands/spell-schedule.js +11 -0
- package/dist/src/cli/services/claude-model-rates.js +54 -0
- package/dist/src/cli/services/claude-stats.js +303 -0
- package/dist/src/cli/services/daemon-dashboard.js +145 -4
- package/dist/src/cli/services/daemon-spell-executor.js +8 -0
- package/dist/src/cli/services/schedule-acceptance-check.js +68 -0
- package/dist/src/cli/spells/core/auth-error-classifier.js +92 -0
- package/dist/src/cli/spells/core/runner.js +188 -0
- package/dist/src/cli/spells/credentials/credential-store.js +64 -1
- package/dist/src/cli/spells/credentials/default-store.js +1 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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 ? ' · ' + 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
|
-
|
|
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 {
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
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.
|
|
100
|
+
"moflo": "^4.9.33",
|
|
101
101
|
"tsx": "^4.21.0",
|
|
102
102
|
"typescript": "^5.9.3",
|
|
103
103
|
"vitest": "^4.0.0"
|