fraim-framework 2.0.124 → 2.0.127
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/bin/fraim.js +1 -1
- package/dist/src/ai-hub/catalog.js +280 -44
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/ai-hub/hosts.js +384 -10
- package/dist/src/ai-hub/server.js +255 -9
- package/dist/src/cli/commands/add-ide.js +4 -3
- package/dist/src/cli/commands/first-run.js +61 -0
- package/dist/src/cli/commands/hub.js +4 -4
- package/dist/src/cli/commands/init-project.js +4 -4
- package/dist/src/cli/commands/setup.js +4 -3
- package/dist/src/cli/commands/sync.js +21 -2
- package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +29 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
- package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
- package/dist/src/cli/setup/ide-detector.js +32 -1
- package/dist/src/cli/setup/ide-global-integration.js +5 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +70 -17
- package/dist/src/cli/setup/mcp-config-generator.js +12 -1
- package/dist/src/cli/utils/agent-adapters.js +12 -2
- package/dist/src/cli/utils/project-bootstrap.js +4 -3
- package/dist/src/core/quality-evidence.js +81 -8
- package/dist/src/core/utils/git-utils.js +32 -7
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/workflow-parser.js +3 -5
- package/dist/src/first-run/install-state.js +68 -0
- package/dist/src/first-run/server.js +153 -0
- package/dist/src/first-run/session-service.js +302 -0
- package/dist/src/first-run/types.js +40 -0
- package/dist/src/local-mcp-server/agent-token-prices.js +114 -0
- package/dist/src/local-mcp-server/codex-token-adapter.js +232 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +21 -8
- package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
- package/dist/src/local-mcp-server/stdio-server.js +70 -17
- package/dist/src/local-mcp-server/token-adapter-registry.js +64 -0
- package/dist/src/local-mcp-server/usage-collector.js +25 -0
- package/index.js +83 -83
- package/package.json +7 -1
- package/public/ai-hub/index.html +149 -102
- package/public/ai-hub/script.js +1154 -271
- package/public/ai-hub/styles.css +753 -450
- package/public/first-run/index.html +221 -0
- package/public/first-run/script.js +361 -0
- package/dist/src/cli/services/device-flow-service.js +0 -83
- package/dist/src/local-mcp-server/prometheus-scraper.js +0 -152
|
@@ -12,6 +12,7 @@ const net_1 = __importDefault(require("net"));
|
|
|
12
12
|
const crypto_1 = require("crypto");
|
|
13
13
|
const child_process_1 = require("child_process");
|
|
14
14
|
const catalog_1 = require("./catalog");
|
|
15
|
+
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
15
16
|
const hosts_1 = require("./hosts");
|
|
16
17
|
const preferences_1 = require("./preferences");
|
|
17
18
|
class AiHubRunRegistry {
|
|
@@ -25,6 +26,9 @@ class AiHubRunRegistry {
|
|
|
25
26
|
get(runId) {
|
|
26
27
|
return this.runs.get(runId);
|
|
27
28
|
}
|
|
29
|
+
all() {
|
|
30
|
+
return [...this.runs.values()];
|
|
31
|
+
}
|
|
28
32
|
create(run, child) {
|
|
29
33
|
this.runs.set(run.id, run);
|
|
30
34
|
this.children.set(run.id, child);
|
|
@@ -35,14 +39,190 @@ class AiHubRunRegistry {
|
|
|
35
39
|
if (!run) {
|
|
36
40
|
throw new Error(`Run ${runId} not found`);
|
|
37
41
|
}
|
|
42
|
+
const priorStatus = run.status;
|
|
38
43
|
updater(run);
|
|
39
|
-
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
// Issue #347: when status flipped, accumulate the previous status's
|
|
46
|
+
// duration into the appropriate working/waiting bucket. lastStatusChangeAt
|
|
47
|
+
// is the wall-clock timestamp of the most recent flip; on the first
|
|
48
|
+
// call it falls back to createdAt.
|
|
49
|
+
if (run.status !== priorStatus) {
|
|
50
|
+
const lastAt = run.lastStatusChangeAt || run.createdAt;
|
|
51
|
+
const elapsedMs = Date.parse(now) - Date.parse(lastAt);
|
|
52
|
+
if (Number.isFinite(elapsedMs) && elapsedMs > 0) {
|
|
53
|
+
run.totals = run.totals || emptyTotals();
|
|
54
|
+
if (priorStatus === 'running') {
|
|
55
|
+
run.totals.workingDurationMs += elapsedMs;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
run.totals.waitingDurationMs += elapsedMs;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
run.lastStatusChangeAt = now;
|
|
62
|
+
}
|
|
63
|
+
run.updatedAt = now;
|
|
40
64
|
return run;
|
|
41
65
|
}
|
|
42
66
|
dispose(runId) {
|
|
43
67
|
this.children.delete(runId);
|
|
44
68
|
}
|
|
45
69
|
}
|
|
70
|
+
function emptyTotals() {
|
|
71
|
+
return {
|
|
72
|
+
totalDurationMs: 0,
|
|
73
|
+
workingDurationMs: 0,
|
|
74
|
+
waitingDurationMs: 0,
|
|
75
|
+
tokenTotals: {
|
|
76
|
+
inputTokens: null,
|
|
77
|
+
outputTokens: null,
|
|
78
|
+
costUsd: null,
|
|
79
|
+
// Issue #347: Hub server cannot read tokenSnapshot from the local
|
|
80
|
+
// proxy in-process. Cross-process attribution is a follow-up;
|
|
81
|
+
// until then, every Hub-driven run reports unavailable. The Hub UI
|
|
82
|
+
// renders "—" per spec R4.6.
|
|
83
|
+
coverage: 'unavailable',
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Issue #347 — apply per-turn usage from the host stream into the
|
|
88
|
+
// run's tokenTotals. Idempotent on the same turn (we replace, not add)
|
|
89
|
+
// because Codex's `turn.completed.usage` is cumulative, not delta.
|
|
90
|
+
// When the host doesn't emit costUsd directly (Codex), we compute it
|
|
91
|
+
// from the captured agent identity via the price table.
|
|
92
|
+
function applyUsageSignal(run, usage) {
|
|
93
|
+
run.totals = run.totals || emptyTotals();
|
|
94
|
+
const tt = run.totals.tokenTotals;
|
|
95
|
+
// Total input tokens (for the user-facing display) = non-cached + cached.
|
|
96
|
+
// Cumulative across the whole run on both hosts; take max to guard
|
|
97
|
+
// against any out-of-order delivery.
|
|
98
|
+
const totalInput = usage.nonCachedInputTokens + usage.cachedInputTokens;
|
|
99
|
+
tt.inputTokens = Math.max(tt.inputTokens || 0, totalInput);
|
|
100
|
+
tt.outputTokens = Math.max(tt.outputTokens || 0, usage.outputTokens);
|
|
101
|
+
// Cost: prefer the host's own number (Claude). For hosts that don't
|
|
102
|
+
// emit it (Codex), compute from the captured agent identity + price
|
|
103
|
+
// table. If neither source applies, leave costUsd null and coverage
|
|
104
|
+
// stays 'partial'.
|
|
105
|
+
let computedCost = null;
|
|
106
|
+
if (typeof usage.costUsd === 'number') {
|
|
107
|
+
computedCost = usage.costUsd;
|
|
108
|
+
}
|
|
109
|
+
else if (run.agentName && run.agentModel) {
|
|
110
|
+
const price = (0, agent_token_prices_1.lookupPrice)(run.agentName.toLowerCase(), run.agentModel.toLowerCase());
|
|
111
|
+
if (price) {
|
|
112
|
+
computedCost =
|
|
113
|
+
(usage.nonCachedInputTokens / 1_000_000) * price.inputPerMTok +
|
|
114
|
+
(usage.cachedInputTokens / 1_000_000) * price.cacheReadPerMTok +
|
|
115
|
+
(usage.cacheCreationTokens / 1_000_000) * price.cacheCreationPerMTok +
|
|
116
|
+
(usage.outputTokens / 1_000_000) * price.outputPerMTok;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (computedCost !== null) {
|
|
120
|
+
tt.costUsd = Math.max(tt.costUsd || 0, computedCost);
|
|
121
|
+
tt.coverage = 'complete';
|
|
122
|
+
}
|
|
123
|
+
else if (tt.coverage !== 'complete') {
|
|
124
|
+
tt.coverage = 'partial';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Issue #347 — capture agent identity from the host stream's fraim_connect
|
|
128
|
+
// call. Used downstream by applyUsageSignal to compute cost via the price
|
|
129
|
+
// table when the host does not emit costUsd directly.
|
|
130
|
+
function applyAgentIdentitySignal(run, identity) {
|
|
131
|
+
run.agentName = identity.agentName;
|
|
132
|
+
run.agentModel = identity.agentModel;
|
|
133
|
+
}
|
|
134
|
+
// Apply a parsed seekMentoring tool-use signal from the host stream to
|
|
135
|
+
// the run state. Returns the updated currentPhase.
|
|
136
|
+
function applySeekMentoringSignal(run, signal) {
|
|
137
|
+
// Issue #347: filter cross-job pollution. The agent may call
|
|
138
|
+
// seekMentoring for jobs OTHER than the one this run is tracking
|
|
139
|
+
// (e.g., consulting `organizational-learning-synthesis` mid-run).
|
|
140
|
+
// Only apply signals whose jobId/jobName matches this run's jobId.
|
|
141
|
+
// Match against either field because the agent passes both and they
|
|
142
|
+
// typically agree, but defenses are cheap.
|
|
143
|
+
const targetJobId = run.jobId;
|
|
144
|
+
const callJobId = signal.jobId || signal.jobName;
|
|
145
|
+
if (callJobId && targetJobId && callJobId !== targetJobId)
|
|
146
|
+
return;
|
|
147
|
+
// Discriminant signals are routing hints only — they don't move the
|
|
148
|
+
// tracker, but they do change which phase id will be considered
|
|
149
|
+
// reachable next time stages are derived. Persist on the run.
|
|
150
|
+
if (signal.phaseId === '__discriminant__') {
|
|
151
|
+
if (signal.discriminant)
|
|
152
|
+
run.runDiscriminant = signal.discriminant;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (signal.discriminant)
|
|
156
|
+
run.runDiscriminant = signal.discriminant;
|
|
157
|
+
// The seekMentoring workflow uses `currentPhase: "starting"` as a
|
|
158
|
+
// sentinel for the very first call ("give me phase 1 instructions").
|
|
159
|
+
// It is not an actual phase id — skip it so the tracker doesn't
|
|
160
|
+
// render an extra "starting" stage at the end of the path.
|
|
161
|
+
if (signal.phaseId === 'starting')
|
|
162
|
+
return;
|
|
163
|
+
run.phaseHistory = run.phaseHistory || [];
|
|
164
|
+
const existing = run.phaseHistory.find((entry) => entry.phaseId === signal.phaseId);
|
|
165
|
+
if (existing) {
|
|
166
|
+
existing.latestStatus = signal.phaseStatus;
|
|
167
|
+
if (signal.findingsText)
|
|
168
|
+
existing.latestText = signal.findingsText;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const entry = {
|
|
172
|
+
phaseId: signal.phaseId,
|
|
173
|
+
enteredAt: new Date().toISOString(),
|
|
174
|
+
latestStatus: signal.phaseStatus,
|
|
175
|
+
latestText: signal.findingsText || null,
|
|
176
|
+
};
|
|
177
|
+
run.phaseHistory.push(entry);
|
|
178
|
+
}
|
|
179
|
+
run.currentPhase = signal.phaseId;
|
|
180
|
+
}
|
|
181
|
+
// Build the stage list for a run. Combines the FSM's reachable path with
|
|
182
|
+
// any phases the run has actually visited (in case the run took an
|
|
183
|
+
// onFailure back-edge into a phase the simple onSuccess walk wouldn't
|
|
184
|
+
// surface). Each stage is marked done / current / upcoming based on
|
|
185
|
+
// whether it precedes / matches / follows the current phase along the
|
|
186
|
+
// rendered order.
|
|
187
|
+
function deriveStages(run, projectPath) {
|
|
188
|
+
const declaredPath = (0, catalog_1.loadJobPhases)(run.jobId, projectPath, run.runDiscriminant || 'feature');
|
|
189
|
+
if (declaredPath.length === 0)
|
|
190
|
+
return [];
|
|
191
|
+
// Merge in any visited phase that's not on the declared path BUT is
|
|
192
|
+
// declared in the job's frontmatter (e.g., a phase reached via an
|
|
193
|
+
// onFailure back-edge that the simple onSuccess walk didn't surface).
|
|
194
|
+
// Skip the 'starting' sentinel, the __discriminant__ marker, and any
|
|
195
|
+
// phase id NOT in the frontmatter — that last filter prevents
|
|
196
|
+
// cross-job pollution from showing up on the tracker.
|
|
197
|
+
const allDeclared = (0, catalog_1.loadAllJobPhaseIds)(run.jobId, projectPath);
|
|
198
|
+
const visited = (run.phaseHistory || []).map((entry) => entry.phaseId);
|
|
199
|
+
const known = new Set(declaredPath.map((p) => p.id));
|
|
200
|
+
for (const visitedId of visited) {
|
|
201
|
+
if (visitedId === 'starting' || visitedId === '__discriminant__')
|
|
202
|
+
continue;
|
|
203
|
+
if (!allDeclared.has(visitedId))
|
|
204
|
+
continue;
|
|
205
|
+
if (!known.has(visitedId)) {
|
|
206
|
+
declaredPath.push({ id: visitedId, label: (0, catalog_1.labelForPhaseId)(visitedId, run.jobId, projectPath) });
|
|
207
|
+
known.add(visitedId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const currentIndex = run.currentPhase
|
|
211
|
+
? declaredPath.findIndex((p) => p.id === run.currentPhase)
|
|
212
|
+
: -1;
|
|
213
|
+
return declaredPath.map((phase, index) => {
|
|
214
|
+
let state;
|
|
215
|
+
if (currentIndex < 0)
|
|
216
|
+
state = 'upcoming';
|
|
217
|
+
else if (index < currentIndex)
|
|
218
|
+
state = 'done';
|
|
219
|
+
else if (index === currentIndex)
|
|
220
|
+
state = 'current';
|
|
221
|
+
else
|
|
222
|
+
state = 'upcoming';
|
|
223
|
+
return { phaseId: phase.id, label: phase.label, state };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
46
226
|
function ensureDirectoryPath(projectPath) {
|
|
47
227
|
const trimmed = (projectPath || '').trim();
|
|
48
228
|
if (!trimmed) {
|
|
@@ -99,15 +279,20 @@ class AiHubServer {
|
|
|
99
279
|
const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
|
|
100
280
|
const jobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
|
|
101
281
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
|
|
282
|
+
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
283
|
+
// so the bootstrap surface (used on first paint) carries stages and
|
|
284
|
+
// live totals — not just the raw run state.
|
|
285
|
+
const latest = this.runRegistry.listLatest();
|
|
286
|
+
const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
|
|
102
287
|
return {
|
|
103
|
-
title: '
|
|
288
|
+
title: 'AI Hub',
|
|
104
289
|
project,
|
|
105
290
|
preferences,
|
|
106
|
-
categories: (0, catalog_1.getAiHubCategories)(),
|
|
291
|
+
categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath),
|
|
107
292
|
jobs,
|
|
108
293
|
managerTemplates,
|
|
109
294
|
employees: this.hostRuntime.detectEmployees(),
|
|
110
|
-
activeRun
|
|
295
|
+
activeRun,
|
|
111
296
|
};
|
|
112
297
|
}
|
|
113
298
|
registerRoutes() {
|
|
@@ -148,16 +333,22 @@ class AiHubServer {
|
|
|
148
333
|
if (!employee?.available) {
|
|
149
334
|
throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
|
|
150
335
|
}
|
|
336
|
+
const startTimestamp = new Date().toISOString();
|
|
151
337
|
const run = {
|
|
152
338
|
id: (0, crypto_1.randomUUID)(),
|
|
153
339
|
jobId,
|
|
154
340
|
hostId,
|
|
155
341
|
projectPath,
|
|
156
342
|
status: 'running',
|
|
157
|
-
createdAt:
|
|
158
|
-
updatedAt:
|
|
343
|
+
createdAt: startTimestamp,
|
|
344
|
+
updatedAt: startTimestamp,
|
|
159
345
|
messages: [(0, hosts_1.createHubMessage)('manager', message)],
|
|
160
346
|
events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
|
|
347
|
+
// Issue #347 — seed phase + totals state on creation.
|
|
348
|
+
currentPhase: null,
|
|
349
|
+
phaseHistory: [],
|
|
350
|
+
totals: emptyTotals(),
|
|
351
|
+
lastStatusChangeAt: startTimestamp,
|
|
161
352
|
};
|
|
162
353
|
this.runRegistry.create(run, {});
|
|
163
354
|
const child = this.hostRuntime.startRun(hostId, projectPath, message, {
|
|
@@ -169,6 +360,12 @@ class AiHubServer {
|
|
|
169
360
|
current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
|
|
170
361
|
if (event.raw)
|
|
171
362
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
363
|
+
if (event.agentIdentity)
|
|
364
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
365
|
+
if (event.seekMentoring)
|
|
366
|
+
applySeekMentoringSignal(current, event.seekMentoring);
|
|
367
|
+
if (event.usage)
|
|
368
|
+
applyUsageSignal(current, event.usage);
|
|
172
369
|
});
|
|
173
370
|
},
|
|
174
371
|
onExit: (exitCode) => {
|
|
@@ -188,7 +385,7 @@ class AiHubServer {
|
|
|
188
385
|
employeeId: hostId,
|
|
189
386
|
recentJobIds: existingPreferences.recentJobIds,
|
|
190
387
|
}, jobId);
|
|
191
|
-
res.status(201).json(run);
|
|
388
|
+
res.status(201).json(this.enrichRunForResponse(run));
|
|
192
389
|
}
|
|
193
390
|
catch (error) {
|
|
194
391
|
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
|
|
@@ -221,6 +418,12 @@ class AiHubServer {
|
|
|
221
418
|
current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
|
|
222
419
|
if (event.raw)
|
|
223
420
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
421
|
+
if (event.agentIdentity)
|
|
422
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
423
|
+
if (event.seekMentoring)
|
|
424
|
+
applySeekMentoringSignal(current, event.seekMentoring);
|
|
425
|
+
if (event.usage)
|
|
426
|
+
applyUsageSignal(current, event.usage);
|
|
224
427
|
});
|
|
225
428
|
},
|
|
226
429
|
onExit: (exitCode) => {
|
|
@@ -233,7 +436,8 @@ class AiHubServer {
|
|
|
233
436
|
},
|
|
234
437
|
});
|
|
235
438
|
this.runRegistry.create(run, child);
|
|
236
|
-
|
|
439
|
+
const refreshed = this.runRegistry.get(run.id);
|
|
440
|
+
res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
|
|
237
441
|
}
|
|
238
442
|
catch (error) {
|
|
239
443
|
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
|
|
@@ -244,9 +448,51 @@ class AiHubServer {
|
|
|
244
448
|
if (!run) {
|
|
245
449
|
return res.status(404).json({ error: 'Run not found.' });
|
|
246
450
|
}
|
|
247
|
-
return res.json(run);
|
|
451
|
+
return res.json(this.enrichRunForResponse(run));
|
|
248
452
|
});
|
|
249
453
|
}
|
|
454
|
+
// Issue #347 — assemble the read-side projection of a run. Stages are
|
|
455
|
+
// derived from job frontmatter + visited phases; totalDurationMs ticks
|
|
456
|
+
// forward while the run is still running so the UI's totals line
|
|
457
|
+
// updates each poll without the server having to rewrite the run on
|
|
458
|
+
// every tick.
|
|
459
|
+
enrichRunForResponse(run) {
|
|
460
|
+
const stages = deriveStages(run, run.projectPath);
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
const created = Date.parse(run.createdAt);
|
|
463
|
+
const updated = Date.parse(run.updatedAt);
|
|
464
|
+
const liveTotalMs = run.status === 'running'
|
|
465
|
+
? Math.max(0, now - created)
|
|
466
|
+
: Math.max(0, updated - created);
|
|
467
|
+
const baseTotals = run.totals || emptyTotals();
|
|
468
|
+
// While the run is still in its current status (not yet flipped), the
|
|
469
|
+
// current bucket needs to include the in-flight delta since the last
|
|
470
|
+
// flip. Compute it here so the client sees a smoothly increasing
|
|
471
|
+
// working/waiting figure instead of frozen counters between flips.
|
|
472
|
+
const lastFlipMs = run.lastStatusChangeAt
|
|
473
|
+
? Date.parse(run.lastStatusChangeAt)
|
|
474
|
+
: created;
|
|
475
|
+
const inflightMs = Math.max(0, now - lastFlipMs);
|
|
476
|
+
const liveTotals = {
|
|
477
|
+
totalDurationMs: liveTotalMs,
|
|
478
|
+
workingDurationMs: baseTotals.workingDurationMs + (run.status === 'running' ? inflightMs : 0),
|
|
479
|
+
waitingDurationMs: baseTotals.waitingDurationMs + (run.status !== 'running' ? inflightMs : 0),
|
|
480
|
+
tokenTotals: baseTotals.tokenTotals,
|
|
481
|
+
};
|
|
482
|
+
// Defensive cap: working + waiting should never exceed total. Trim
|
|
483
|
+
// the trailing bucket if rounding pushes us past.
|
|
484
|
+
const sum = liveTotals.workingDurationMs + liveTotals.waitingDurationMs;
|
|
485
|
+
if (sum > liveTotalMs) {
|
|
486
|
+
const overflow = sum - liveTotalMs;
|
|
487
|
+
if (run.status === 'running') {
|
|
488
|
+
liveTotals.workingDurationMs = Math.max(0, liveTotals.workingDurationMs - overflow);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return { ...run, stages, totals: liveTotals };
|
|
495
|
+
}
|
|
250
496
|
}
|
|
251
497
|
exports.AiHubServer = AiHubServer;
|
|
252
498
|
async function findAvailablePort(preferredPort) {
|
|
@@ -187,7 +187,7 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
if (ide.configFormat === 'toml') {
|
|
190
|
-
// Handle TOML format (e.g., Codex
|
|
190
|
+
// Handle TOML format (e.g., Codex)
|
|
191
191
|
let existingTomlContent = '';
|
|
192
192
|
if (fs_1.default.existsSync(configPath)) {
|
|
193
193
|
existingTomlContent = fs_1.default.readFileSync(configPath, 'utf8');
|
|
@@ -270,6 +270,7 @@ const listSupportedIDEs = () => {
|
|
|
270
270
|
console.log(chalk_1.default.yellow('💡 Use "fraim add-ide --ide <name>" to configure a specific IDE'));
|
|
271
271
|
console.log(chalk_1.default.yellow(' Example: fraim add-ide --ide claude-code'));
|
|
272
272
|
console.log(chalk_1.default.yellow(' Anthropic aliases: claude, claude-code, claude-desktop, claude-cowork'));
|
|
273
|
+
console.log(chalk_1.default.yellow(' Gemini aliases: gemini, gemini-cli, gemini cli'));
|
|
273
274
|
};
|
|
274
275
|
const promptForIDESelection = async (availableIDEs, tokens) => {
|
|
275
276
|
console.log(chalk_1.default.green(`✅ Found ${availableIDEs.length} IDEs that can be configured:\n`));
|
|
@@ -357,7 +358,7 @@ const runAddIDE = async (options) => {
|
|
|
357
358
|
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
358
359
|
if (detectedIDEs.length === 0) {
|
|
359
360
|
console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
|
|
360
|
-
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Kiro, Cursor, VSCode, Codex, Windsurf'));
|
|
361
|
+
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Windsurf'));
|
|
361
362
|
console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
|
|
362
363
|
return;
|
|
363
364
|
}
|
|
@@ -435,7 +436,7 @@ const runAddIDE = async (options) => {
|
|
|
435
436
|
exports.runAddIDE = runAddIDE;
|
|
436
437
|
exports.addIDECommand = new commander_1.Command('add-ide')
|
|
437
438
|
.description('Add FRAIM configuration to additional IDEs')
|
|
438
|
-
.option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, kiro, cursor, vscode, codex, windsurf)')
|
|
439
|
+
.option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, gemini, gemini-cli, kiro, cursor, vscode, codex, windsurf)')
|
|
439
440
|
.option('--all', 'Configure all detected IDEs')
|
|
440
441
|
.option('--list', 'List all supported IDEs and their detection status')
|
|
441
442
|
.action(exports.runAddIDE);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.firstRunCommand = exports.runFirstRun = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const server_1 = require("../../first-run/server");
|
|
11
|
+
const session_service_1 = require("../../first-run/session-service");
|
|
12
|
+
const server_2 = require("../../ai-hub/server");
|
|
13
|
+
function openBrowser(url) {
|
|
14
|
+
try {
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
(0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`], { detached: true, stdio: 'ignore' }).unref();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (process.platform === 'darwin') {
|
|
20
|
+
(0, child_process_1.spawn)('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
(0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Best effort only.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const runFirstRun = async (options) => {
|
|
30
|
+
const key = options.key || process.env.FRAIM_API_KEY || process.env.FRAIM_SETUP_KEY || process.env.FRAIM_INSTALL_KEY;
|
|
31
|
+
if (!key) {
|
|
32
|
+
console.log(chalk_1.default.red('FRAIM key is required for first-run.'));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const sessionService = new session_service_1.FirstRunSessionService({
|
|
36
|
+
key,
|
|
37
|
+
headless: options.headless,
|
|
38
|
+
resume: options.resume,
|
|
39
|
+
projectRoot: options.projectRoot,
|
|
40
|
+
});
|
|
41
|
+
const server = new server_1.FirstRunServer({ sessionService });
|
|
42
|
+
const port = await (0, server_2.findAvailablePort)(43120);
|
|
43
|
+
const url = `http://127.0.0.1:${port}/first-run/`;
|
|
44
|
+
await server.start(port);
|
|
45
|
+
console.log(chalk_1.default.blue('Starting FRAIM first-run...'));
|
|
46
|
+
console.log(chalk_1.default.white(`First run available at ${url}`));
|
|
47
|
+
if (!options.headless) {
|
|
48
|
+
openBrowser(url);
|
|
49
|
+
}
|
|
50
|
+
await server.waitForFinish();
|
|
51
|
+
await server.stop();
|
|
52
|
+
console.log(chalk_1.default.green('FRAIM first-run completed.'));
|
|
53
|
+
};
|
|
54
|
+
exports.runFirstRun = runFirstRun;
|
|
55
|
+
exports.firstRunCommand = new commander_1.Command('first-run')
|
|
56
|
+
.description('Run the guided first-run installer experience')
|
|
57
|
+
.option('--key <key>', 'FRAIM API key')
|
|
58
|
+
.option('--resume', 'Resume an interrupted first-run session')
|
|
59
|
+
.option('--headless', 'Do not attempt to open a browser automatically')
|
|
60
|
+
.option('--project-root <path>', 'Seed the first-run flow with an explicit project root')
|
|
61
|
+
.action(exports.runFirstRun);
|
|
@@ -63,7 +63,7 @@ function openBrowser(url) {
|
|
|
63
63
|
child.unref();
|
|
64
64
|
}
|
|
65
65
|
exports.hubCommand = new commander_1.Command('hub')
|
|
66
|
-
.description('Start the
|
|
66
|
+
.description('Start the AI Hub local companion for running FRAIM jobs through Codex or Claude Code')
|
|
67
67
|
.option('--port <port>', 'Preferred local port for the hub', (value) => Number(value), 43091)
|
|
68
68
|
.option('--project-path <path>', 'Initial project path for job discovery', process.cwd())
|
|
69
69
|
.option('--no-open', 'Do not open the hub after startup')
|
|
@@ -78,12 +78,12 @@ exports.hubCommand = new commander_1.Command('hub')
|
|
|
78
78
|
const server = new server_1.AiHubServer({ projectPath });
|
|
79
79
|
await server.start(port);
|
|
80
80
|
const url = `http://127.0.0.1:${port}/ai-hub/`;
|
|
81
|
-
console.log(`
|
|
81
|
+
console.log(`AI Hub running at ${url}`);
|
|
82
82
|
console.log(`Project path: ${projectPath}`);
|
|
83
83
|
openBrowser(url);
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
|
-
console.log('
|
|
86
|
+
console.log('AI Hub desktop shell launched.');
|
|
87
87
|
console.log(`Project path: ${projectPath}`);
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
@@ -91,6 +91,6 @@ exports.hubCommand = new commander_1.Command('hub')
|
|
|
91
91
|
const server = new server_1.AiHubServer({ projectPath });
|
|
92
92
|
await server.start(port);
|
|
93
93
|
const url = `http://127.0.0.1:${port}/ai-hub/`;
|
|
94
|
-
console.log(`
|
|
94
|
+
console.log(`AI Hub running at ${url}`);
|
|
95
95
|
console.log(`Project path: ${projectPath}`);
|
|
96
96
|
});
|
|
@@ -158,7 +158,7 @@ const createGitHubLabels = (projectRoot) => {
|
|
|
158
158
|
console.log(chalk_1.default.yellow(`Error reading labels.json: ${error.message}`));
|
|
159
159
|
}
|
|
160
160
|
};
|
|
161
|
-
const runInitProject = async () => {
|
|
161
|
+
const runInitProject = async (options = {}) => {
|
|
162
162
|
console.log(chalk_1.default.blue('Initializing FRAIM project...'));
|
|
163
163
|
const globalSetup = checkGlobalSetup();
|
|
164
164
|
if (!globalSetup.exists) {
|
|
@@ -167,7 +167,7 @@ const runInitProject = async () => {
|
|
|
167
167
|
console.log(chalk_1.default.cyan(' fraim setup'));
|
|
168
168
|
process.exit(1);
|
|
169
169
|
}
|
|
170
|
-
const projectRoot = process.cwd();
|
|
170
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
171
171
|
const fraimDirDisplayPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)();
|
|
172
172
|
const configDisplayPath = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('config.json');
|
|
173
173
|
const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
|
|
@@ -312,7 +312,7 @@ const runInitProject = async () => {
|
|
|
312
312
|
result.repositoryDetected = true;
|
|
313
313
|
}
|
|
314
314
|
if (!process.env.FRAIM_SKIP_SYNC) {
|
|
315
|
-
await (0, sync_1.runSync)({});
|
|
315
|
+
await (0, sync_1.runSync)({ projectRoot });
|
|
316
316
|
result.syncPerformed = true;
|
|
317
317
|
}
|
|
318
318
|
else {
|
|
@@ -338,4 +338,4 @@ const runInitProject = async () => {
|
|
|
338
338
|
exports.runInitProject = runInitProject;
|
|
339
339
|
exports.initProjectCommand = new commander_1.Command('init-project')
|
|
340
340
|
.description('Initialize FRAIM in the current project (requires global setup)')
|
|
341
|
-
.action(exports.runInitProject);
|
|
341
|
+
.action(() => (0, exports.runInitProject)());
|
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.setupCommandInitialization = exports.setupCommand = exports.runSetup = void 0;
|
|
39
|
+
exports.setupCommandInitialization = exports.setupCommand = exports.runSetup = exports.saveGlobalConfig = void 0;
|
|
40
40
|
// Refactored setup.ts - Generic provider system with zero hardcoded provider knowledge
|
|
41
41
|
const commander_1 = require("commander");
|
|
42
42
|
const chalk_1 = __importDefault(require("chalk"));
|
|
@@ -244,6 +244,7 @@ const saveGlobalConfig = (fraimKey, mode, tokens, configs) => {
|
|
|
244
244
|
fs_1.default.writeFileSync(globalConfigPath, JSON.stringify(config, null, 2));
|
|
245
245
|
console.log(chalk_1.default.green('✅ Global FRAIM configuration saved'));
|
|
246
246
|
};
|
|
247
|
+
exports.saveGlobalConfig = saveGlobalConfig;
|
|
247
248
|
// Parse CLI options into generic format using provider registry from server
|
|
248
249
|
// Tokens/config are optional - will prompt if not provided
|
|
249
250
|
const parseLegacyOptions = async (options, fraimKey) => {
|
|
@@ -435,7 +436,7 @@ const runSetup = async (options) => {
|
|
|
435
436
|
}
|
|
436
437
|
// Save and configure MCP
|
|
437
438
|
console.log(chalk_1.default.blue('\n💾 Saving global configuration...'));
|
|
438
|
-
saveGlobalConfig(fraimKey, mode, tokens, configs);
|
|
439
|
+
(0, exports.saveGlobalConfig)(fraimKey, mode, tokens, configs);
|
|
439
440
|
console.log(chalk_1.default.blue('\n🔌 Configuring MCP servers...'));
|
|
440
441
|
const mcpTokens = {};
|
|
441
442
|
Object.entries(tokens).forEach(([id, token]) => {
|
|
@@ -572,7 +573,7 @@ const runSetup = async (options) => {
|
|
|
572
573
|
}
|
|
573
574
|
// Save global configuration
|
|
574
575
|
console.log(chalk_1.default.blue('💾 Saving global configuration...'));
|
|
575
|
-
saveGlobalConfig(fraimKey, mode, tokens, configs);
|
|
576
|
+
(0, exports.saveGlobalConfig)(fraimKey, mode, tokens, configs);
|
|
576
577
|
// Configure MCP servers
|
|
577
578
|
if (!isUpdate) {
|
|
578
579
|
// Initial setup - configure all detected IDEs
|
|
@@ -48,6 +48,24 @@ const git_utils_1 = require("../../core/utils/git-utils");
|
|
|
48
48
|
const agent_adapters_1 = require("../utils/agent-adapters");
|
|
49
49
|
const fraim_gitignore_1 = require("../utils/fraim-gitignore");
|
|
50
50
|
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
51
|
+
function resolveExplicitLocalSyncUrl() {
|
|
52
|
+
const candidates = [
|
|
53
|
+
process.env.FRAIM_TEST_SERVER_URL,
|
|
54
|
+
process.env.FRAIM_REMOTE_URL,
|
|
55
|
+
].filter((value) => Boolean(value));
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(candidate);
|
|
59
|
+
if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
|
|
60
|
+
return parsed.origin;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Ignore malformed overrides and fall back to the derived loopback URL.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
51
69
|
/**
|
|
52
70
|
* Load API key from user-level config (~/.fraim/config.json)
|
|
53
71
|
*/
|
|
@@ -98,7 +116,7 @@ const runSync = async (options) => {
|
|
|
98
116
|
}
|
|
99
117
|
return;
|
|
100
118
|
}
|
|
101
|
-
const projectRoot = process.cwd();
|
|
119
|
+
const projectRoot = options.projectRoot ? path_1.default.resolve(options.projectRoot) : process.cwd();
|
|
102
120
|
const config = (0, config_loader_1.loadFraimConfig)();
|
|
103
121
|
const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectRoot);
|
|
104
122
|
const refreshLocalIgnoreConfig = () => {
|
|
@@ -121,8 +139,9 @@ const runSync = async (options) => {
|
|
|
121
139
|
if (options.local) {
|
|
122
140
|
console.log(chalk_1.default.blue('Syncing FRAIM jobs from local server...'));
|
|
123
141
|
const localPort = process.env.FRAIM_MCP_PORT ? parseInt(process.env.FRAIM_MCP_PORT) : (0, git_utils_1.getPort)();
|
|
142
|
+
const localUrl = resolveExplicitLocalSyncUrl() || `http://localhost:${localPort}`;
|
|
124
143
|
const result = await syncFromRemote({
|
|
125
|
-
remoteUrl:
|
|
144
|
+
remoteUrl: localUrl,
|
|
126
145
|
apiKey: 'local-dev',
|
|
127
146
|
projectRoot,
|
|
128
147
|
skipUpdates: true
|
|
@@ -60,6 +60,8 @@ function checkMCPConfigsExist() {
|
|
|
60
60
|
let existCount = 0;
|
|
61
61
|
let missingCount = 0;
|
|
62
62
|
const missing = [];
|
|
63
|
+
const bootstrapableMissing = [];
|
|
64
|
+
const blockingMissing = [];
|
|
63
65
|
for (const ide of installedIDEs) {
|
|
64
66
|
const configPath = (0, ide_detector_1.expandPath)(ide.configPath);
|
|
65
67
|
if (fs_1.default.existsSync(configPath)) {
|
|
@@ -68,6 +70,12 @@ function checkMCPConfigsExist() {
|
|
|
68
70
|
else {
|
|
69
71
|
missingCount++;
|
|
70
72
|
missing.push(ide.name);
|
|
73
|
+
if (ide.supportsConfigBootstrap) {
|
|
74
|
+
bootstrapableMissing.push(ide.name);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
blockingMissing.push(ide.name);
|
|
78
|
+
}
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
81
|
if (missingCount === 0) {
|
|
@@ -86,11 +94,21 @@ function checkMCPConfigsExist() {
|
|
|
86
94
|
details: { existCount, missingCount, missing }
|
|
87
95
|
};
|
|
88
96
|
}
|
|
97
|
+
if (blockingMissing.length === 0 && bootstrapableMissing.length > 0) {
|
|
98
|
+
return {
|
|
99
|
+
status: 'warning',
|
|
100
|
+
message: `Detected IDEs still need initial MCP config bootstrap: ${bootstrapableMissing.join(', ')}`,
|
|
101
|
+
suggestion: 'Run fraim add-ide to create the initial MCP configuration',
|
|
102
|
+
command: 'fraim add-ide',
|
|
103
|
+
details: { missingCount, missing, bootstrapableMissing }
|
|
104
|
+
};
|
|
105
|
+
}
|
|
89
106
|
return {
|
|
90
107
|
status: 'error',
|
|
91
|
-
message:
|
|
108
|
+
message: `No MCP configs found for detected IDEs: ${blockingMissing.join(', ')}`,
|
|
92
109
|
suggestion: 'Run fraim add-ide to configure MCP servers',
|
|
93
|
-
command: 'fraim add-ide'
|
|
110
|
+
command: 'fraim add-ide',
|
|
111
|
+
details: { missingCount, missing, blockingMissing, bootstrapableMissing }
|
|
94
112
|
};
|
|
95
113
|
}
|
|
96
114
|
};
|
package/dist/src/cli/fraim.js
CHANGED
|
@@ -51,6 +51,7 @@ const login_1 = require("./commands/login");
|
|
|
51
51
|
const mcp_1 = require("./commands/mcp");
|
|
52
52
|
const migrate_project_fraim_1 = require("./commands/migrate-project-fraim");
|
|
53
53
|
const hub_1 = require("./commands/hub");
|
|
54
|
+
const first_run_1 = require("./commands/first-run");
|
|
54
55
|
const fs_1 = __importDefault(require("fs"));
|
|
55
56
|
const path_1 = __importDefault(require("path"));
|
|
56
57
|
const program = new commander_1.Command();
|
|
@@ -91,6 +92,7 @@ program.addCommand(login_1.loginCommand);
|
|
|
91
92
|
program.addCommand(mcp_1.mcpCommand);
|
|
92
93
|
program.addCommand(migrate_project_fraim_1.migrateProjectFraimCommand);
|
|
93
94
|
program.addCommand(hub_1.hubCommand);
|
|
95
|
+
program.addCommand(first_run_1.firstRunCommand);
|
|
94
96
|
// Wait for async command initialization before parsing
|
|
95
97
|
(async () => {
|
|
96
98
|
// Import the initialization promise from setup command
|