moflo 4.9.33 → 4.9.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/commands/doctor-fixes.js +32 -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/spells/core/auth-error-classifier.js +92 -0
- package/dist/src/cli/spells/core/runner.js +188 -0
- 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))
|
|
@@ -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);
|
|
@@ -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
|
}
|
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"
|