fraim-hub 2.0.270 → 2.0.272
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-hub-2.js +1 -1
- package/dist/src/ai-hub/catalog.js +3 -17
- package/dist/src/ai-hub/conversation-store.js +73 -4
- package/dist/src/ai-hub/hosts.js +15 -1
- package/dist/src/ai-hub/hub-install.js +57 -23
- package/dist/src/ai-hub/hub-launcher.js +184 -0
- package/dist/src/ai-hub/preferences.js +4 -1
- package/dist/src/ai-hub/restart-recovery-policy.js +14 -0
- package/dist/src/ai-hub/server.js +204 -25
- package/dist/src/ai-hub/stale-bucket-sweep.js +162 -0
- package/dist/src/cli/commands/add-ide.js +37 -132
- package/dist/src/cli/fraim-hub.js +3 -0
- package/dist/src/cli/mcp/ide-formats.js +1 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +3 -3
- package/dist/src/cli/providers/local-provider-registry.js +4 -4
- package/dist/src/core/resolve-phase-edge.js +75 -0
- package/dist/src/core/utils/git-utils.js +24 -14
- package/dist/src/core/utils/project-fraim-paths.js +16 -1
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +306 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +448 -95
- package/dist/src/local-mcp-server/learning-usage-store.js +417 -0
- package/package.json +5 -2
- package/public/ai-hub/script.js +217 -19
- package/public/ai-hub/styles.css +19 -0
package/bin/fraim-hub-2.js
CHANGED
|
@@ -14,6 +14,7 @@ exports.getAiHubCategories = getAiHubCategories;
|
|
|
14
14
|
const fs_1 = __importDefault(require("fs"));
|
|
15
15
|
const path_1 = __importDefault(require("path"));
|
|
16
16
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
|
+
const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
|
|
17
18
|
// Directories scanned for employee jobs at runtime, in lowest-to-highest
|
|
18
19
|
// precedence order. Later entries win on {categoryId, jobId} collision.
|
|
19
20
|
//
|
|
@@ -411,21 +412,6 @@ function findJobStubPath(projectPath, jobId) {
|
|
|
411
412
|
}
|
|
412
413
|
return null;
|
|
413
414
|
}
|
|
414
|
-
// Resolve a phase's `onSuccess` edge to the next phase id given the run's
|
|
415
|
-
// discriminant. Returns null when the edge is absent or terminal.
|
|
416
|
-
function nextPhase(edge, discriminant) {
|
|
417
|
-
if (edge == null)
|
|
418
|
-
return null;
|
|
419
|
-
if (typeof edge === 'string')
|
|
420
|
-
return edge;
|
|
421
|
-
if (typeof edge === 'object') {
|
|
422
|
-
if (typeof edge[discriminant] === 'string')
|
|
423
|
-
return edge[discriminant];
|
|
424
|
-
if (typeof edge.default === 'string')
|
|
425
|
-
return edge.default;
|
|
426
|
-
}
|
|
427
|
-
return null;
|
|
428
|
-
}
|
|
429
415
|
// Parse the ordered phase list from a job stub's ## Steps section.
|
|
430
416
|
// Real FRAIM job stubs use Markdown steps rather than JSON frontmatter;
|
|
431
417
|
// this is the fallback parser that makes the pizza tracker work for them.
|
|
@@ -460,7 +446,7 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
|
|
|
460
446
|
const phaseDef = fm.phases[cursor];
|
|
461
447
|
if (!phaseDef)
|
|
462
448
|
break;
|
|
463
|
-
cursor =
|
|
449
|
+
cursor = (0, resolve_phase_edge_1.resolvePhaseEdge)(fm.phases[cursor]?.onSuccess, discriminant);
|
|
464
450
|
}
|
|
465
451
|
const labels = fm.phaseLabels || {};
|
|
466
452
|
return ordered.map((id) => ({ id, label: friendlyPhaseLabel(id, labels[id]) }));
|
|
@@ -482,7 +468,7 @@ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discrim
|
|
|
482
468
|
if (!phaseDef)
|
|
483
469
|
return null;
|
|
484
470
|
const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
|
|
485
|
-
return
|
|
471
|
+
return (0, resolve_phase_edge_1.resolvePhaseEdge)(edge, discriminant);
|
|
486
472
|
}
|
|
487
473
|
function loadAllJobPhaseIds(jobId, projectPath) {
|
|
488
474
|
const stubPath = findJobStubPath(projectPath, jobId);
|
|
@@ -26,7 +26,6 @@ const HEADER_OMITTED_FIELDS = [
|
|
|
26
26
|
'events',
|
|
27
27
|
'artifacts',
|
|
28
28
|
'run',
|
|
29
|
-
'delegation',
|
|
30
29
|
'handoffSummary',
|
|
31
30
|
'_bodyLoaded',
|
|
32
31
|
'_stopping',
|
|
@@ -44,6 +43,9 @@ function conversationScopeKey(scope, projectPath) {
|
|
|
44
43
|
return exports.MANAGER_SCOPE_KEY;
|
|
45
44
|
if (scope === 'company')
|
|
46
45
|
return exports.COMPANY_SCOPE_KEY;
|
|
46
|
+
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
|
47
|
+
throw new Error('conversationScopeKey: empty project path');
|
|
48
|
+
}
|
|
47
49
|
return normalizeConversationKey(projectPath);
|
|
48
50
|
}
|
|
49
51
|
const emptyProjectState = () => ({
|
|
@@ -68,7 +70,10 @@ function normalizeConversationKey(key) {
|
|
|
68
70
|
if (typeof key === 'string' && (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)) {
|
|
69
71
|
return key;
|
|
70
72
|
}
|
|
71
|
-
|
|
73
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
74
|
+
throw new Error('normalizeConversationKey: empty project path');
|
|
75
|
+
}
|
|
76
|
+
return path_1.default.resolve(key);
|
|
72
77
|
}
|
|
73
78
|
function normalizeProjectPath(projectPath) {
|
|
74
79
|
return normalizeConversationKey(projectPath);
|
|
@@ -181,13 +186,34 @@ function placeConversationInBucket(bucket, conv) {
|
|
|
181
186
|
const existingScore = conversationRichness(existing);
|
|
182
187
|
const incomingScore = conversationRichness(conv);
|
|
183
188
|
if (incomingScore > existingScore) {
|
|
184
|
-
bucket.conversations[idx] = conv;
|
|
189
|
+
bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
|
|
185
190
|
}
|
|
186
191
|
else if (incomingScore === existingScore
|
|
187
192
|
&& timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
|
|
188
|
-
bucket.conversations[idx] = conv;
|
|
193
|
+
bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
|
|
189
194
|
}
|
|
190
195
|
}
|
|
196
|
+
function stableConversationCreatedAt(existing, incoming) {
|
|
197
|
+
const existingValue = existing?.createdAt;
|
|
198
|
+
const incomingValue = incoming?.createdAt;
|
|
199
|
+
const existingTs = timestampValue(existingValue);
|
|
200
|
+
const incomingTs = timestampValue(incomingValue);
|
|
201
|
+
if (existingTs > 0 && incomingTs > 0)
|
|
202
|
+
return existingTs <= incomingTs ? existingValue : incomingValue;
|
|
203
|
+
if (existingTs > 0)
|
|
204
|
+
return existingValue;
|
|
205
|
+
if (incomingTs > 0)
|
|
206
|
+
return incomingValue;
|
|
207
|
+
return existingValue ?? incomingValue;
|
|
208
|
+
}
|
|
209
|
+
function withStableConversationCreatedAt(existing, incoming) {
|
|
210
|
+
if (!incoming || typeof incoming !== 'object')
|
|
211
|
+
return incoming;
|
|
212
|
+
const createdAt = stableConversationCreatedAt(existing, incoming);
|
|
213
|
+
if (createdAt === undefined)
|
|
214
|
+
return incoming;
|
|
215
|
+
return { ...incoming, createdAt };
|
|
216
|
+
}
|
|
191
217
|
// Relocate any project-scoped record mis-filed under the wrong project bucket back to its own
|
|
192
218
|
// project (see docs/rca/hub-conversation-cross-project-leak.md). Sentinel buckets are left as-is.
|
|
193
219
|
function migrateProjectBuckets(store) {
|
|
@@ -303,14 +329,45 @@ function newestFirst(a, b) {
|
|
|
303
329
|
}
|
|
304
330
|
function toHeader(conv) {
|
|
305
331
|
const header = { ...conv };
|
|
332
|
+
const delegation = compactDelegationForHeader(header.delegation);
|
|
306
333
|
for (const field of HEADER_OMITTED_FIELDS)
|
|
307
334
|
delete header[field];
|
|
335
|
+
if (delegation)
|
|
336
|
+
header.delegation = delegation;
|
|
308
337
|
for (const field of Object.keys(header)) {
|
|
309
338
|
if (field.startsWith('_'))
|
|
310
339
|
delete header[field];
|
|
311
340
|
}
|
|
312
341
|
return header;
|
|
313
342
|
}
|
|
343
|
+
function compactDelegationForHeader(raw) {
|
|
344
|
+
if (!raw || typeof raw !== 'object')
|
|
345
|
+
return undefined;
|
|
346
|
+
const delegation = raw;
|
|
347
|
+
const tasks = Array.isArray(delegation.tasks) ? delegation.tasks : [];
|
|
348
|
+
if (!tasks.length)
|
|
349
|
+
return undefined;
|
|
350
|
+
return {
|
|
351
|
+
delegationRequired: delegation.delegationRequired === true,
|
|
352
|
+
objective: delegation.objective,
|
|
353
|
+
orchestratorPersonaKey: delegation.orchestratorPersonaKey,
|
|
354
|
+
rootRunId: delegation.rootRunId,
|
|
355
|
+
managerRunId: delegation.managerRunId,
|
|
356
|
+
tasks: tasks.map((task) => {
|
|
357
|
+
const value = task && typeof task === 'object' ? task : {};
|
|
358
|
+
return {
|
|
359
|
+
taskId: value.taskId,
|
|
360
|
+
title: value.title,
|
|
361
|
+
status: value.status,
|
|
362
|
+
personaKey: value.personaKey,
|
|
363
|
+
jobId: value.jobId,
|
|
364
|
+
runId: value.runId,
|
|
365
|
+
conversationId: value.conversationId,
|
|
366
|
+
dependsOn: Array.isArray(value.dependsOn) ? value.dependsOn : [],
|
|
367
|
+
};
|
|
368
|
+
}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
314
371
|
function headerNeedsSanitization(header) {
|
|
315
372
|
const value = header;
|
|
316
373
|
return Object.keys(value).some((field) => HEADER_OMITTED_FIELD_SET.has(field) || field.startsWith('_'));
|
|
@@ -345,6 +402,17 @@ class AiHubConversationStore {
|
|
|
345
402
|
// Sibling directory of the legacy file, e.g. ~/.fraim/ai-hub-conversations/
|
|
346
403
|
this.shardRoot = path_1.default.join(dir, base);
|
|
347
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Root directory holding the per-bucket shards.
|
|
407
|
+
*
|
|
408
|
+
* Issue #1164: exposed read-only so callers that walk the shard layout, such as
|
|
409
|
+
* the stale-bucket sweep, ask the store where its data lives instead of
|
|
410
|
+
* re-deriving the path. Two independent derivations of the same layout drift the
|
|
411
|
+
* moment one changes, and a sweep pointed at the wrong directory fails silently.
|
|
412
|
+
*/
|
|
413
|
+
get shardRootPath() {
|
|
414
|
+
return this.shardRoot;
|
|
415
|
+
}
|
|
348
416
|
// ---- path helpers ----
|
|
349
417
|
bucketDir(bucketKey) {
|
|
350
418
|
const canonical = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY
|
|
@@ -732,6 +800,7 @@ class AiHubConversationStore {
|
|
|
732
800
|
id: existing.id,
|
|
733
801
|
projectPath: key,
|
|
734
802
|
agentName: patch.agentName || existing.agentName,
|
|
803
|
+
createdAt: stableConversationCreatedAt(existing, patch) ?? existing.createdAt,
|
|
735
804
|
lastUpdatedAt: patch.lastUpdatedAt ?? new Date().toISOString(),
|
|
736
805
|
}) ?? existing;
|
|
737
806
|
this.writeConvFile(bucketDir, key, merged);
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -381,7 +381,19 @@ function extractSignalFromArgs(args) {
|
|
|
381
381
|
: 'starting';
|
|
382
382
|
const findings = args.findings;
|
|
383
383
|
const findingsText = findings && typeof findings.summary === 'string' ? findings.summary : undefined;
|
|
384
|
-
|
|
384
|
+
// Issue #1135: `runDiscriminant` is not a field of the seekMentoring tool schema
|
|
385
|
+
// and no agent sends it, so before issue #1123 this was populated only by the
|
|
386
|
+
// scripted test double and production always resolved with the literal default
|
|
387
|
+
// 'feature'. The real discriminant is the one the mentor routes on
|
|
388
|
+
// (`findings.phaseOutcome`), so read that first and keep the legacy field as a
|
|
389
|
+
// fallback for the test double.
|
|
390
|
+
const evidenceArgs = args.evidence;
|
|
391
|
+
const discriminantFromFindings = findings && typeof findings.phaseOutcome === 'string' ? findings.phaseOutcome
|
|
392
|
+
: findings && typeof findings.issueType === 'string' ? findings.issueType
|
|
393
|
+
: evidenceArgs && typeof evidenceArgs.issueType === 'string' ? evidenceArgs.issueType
|
|
394
|
+
: undefined;
|
|
395
|
+
const discriminant = discriminantFromFindings
|
|
396
|
+
?? (typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined);
|
|
385
397
|
const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
|
|
386
398
|
const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
|
|
387
399
|
const issueNumber = typeof args.issueNumber === 'string' ? args.issueNumber
|
|
@@ -1787,6 +1799,7 @@ class CliHostRuntime {
|
|
|
1787
1799
|
exports.CliHostRuntime = CliHostRuntime;
|
|
1788
1800
|
class FakeHostRuntime {
|
|
1789
1801
|
constructor() {
|
|
1802
|
+
this.isTestDouble = true;
|
|
1790
1803
|
this.employees = [
|
|
1791
1804
|
{ id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
1792
1805
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
@@ -1878,6 +1891,7 @@ exports.FakeHostRuntime = FakeHostRuntime;
|
|
|
1878
1891
|
// FakeHostRuntime (smaller surface, no seekMentoring).
|
|
1879
1892
|
class ScriptedHostRuntime {
|
|
1880
1893
|
constructor() {
|
|
1894
|
+
this.isTestDouble = true;
|
|
1881
1895
|
this.employees = [
|
|
1882
1896
|
{ id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1883
1897
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
@@ -10,33 +10,34 @@ exports.getOsShortcutPaths = getOsShortcutPaths;
|
|
|
10
10
|
exports.writeMacosLaunchAgent = writeMacosLaunchAgent;
|
|
11
11
|
exports.writeLinuxDesktopEntry = writeLinuxDesktopEntry;
|
|
12
12
|
exports.runHubInstall = runHubInstall;
|
|
13
|
-
// #921: `fraim
|
|
13
|
+
// #921/#1179: `fraim-hub install` — register the FRAIM Hub as an OS application so users
|
|
14
14
|
// can launch it without a terminal or npx command.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// #921 shipped this as a downloader: it fetched a packaged Electron app from the repository's
|
|
17
|
+
// latest GitHub Release and pointed the shortcut at it. That never worked. The lookup is
|
|
18
|
+
// unauthenticated and `mathursrus/FRAIM` is private (GitHub answers 404, not 403), and no release
|
|
19
|
+
// has ever been published, so every run ended in `GitHub release lookup failed with HTTP 404`.
|
|
20
|
+
//
|
|
21
|
+
// #1179 replaces the download with a generated launcher that runs the Hub through
|
|
22
|
+
// `npx fraim-hub@latest` (see `./hub-launcher`). That needs no release feed and no signing
|
|
23
|
+
// infrastructure, and it matches the shape of the macOS installer that already ships:
|
|
24
|
+
// `scripts/build-macos-installer.sh` builds a `pkgbuild --nopayload` package whose entire payload
|
|
25
|
+
// is a script that runs `npx -y fraim@latest first-run`.
|
|
18
26
|
const commander_1 = require("commander");
|
|
19
27
|
const fs_1 = __importDefault(require("fs"));
|
|
20
28
|
const path_1 = __importDefault(require("path"));
|
|
21
29
|
const os_1 = __importDefault(require("os"));
|
|
22
30
|
const child_process_1 = require("child_process");
|
|
23
31
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
24
|
-
const
|
|
32
|
+
const hub_launcher_1 = require("./hub-launcher");
|
|
25
33
|
// Stable install directory under ~/.fraim/bin/fraim-hub-electron/
|
|
26
34
|
function resolveHubInstallDir() {
|
|
27
35
|
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'bin', 'fraim-hub-electron');
|
|
28
36
|
}
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
37
|
+
// The file OS shortcuts point at: the generated launcher, `fraim-hub.cmd` on Windows and
|
|
38
|
+
// `fraim-hub` elsewhere. Before #1179 this named a downloaded Electron binary that never arrived.
|
|
31
39
|
function resolveShortcutTarget() {
|
|
32
|
-
|
|
33
|
-
if (process.platform === 'win32') {
|
|
34
|
-
return path_1.default.join(installDir, 'fraim-hub.exe');
|
|
35
|
-
}
|
|
36
|
-
if (process.platform === 'darwin') {
|
|
37
|
-
return path_1.default.join(installDir, 'FRAIM Hub.app', 'Contents', 'MacOS', 'FRAIM Hub');
|
|
38
|
-
}
|
|
39
|
-
return path_1.default.join(installDir, 'fraim-hub');
|
|
40
|
+
return (0, hub_launcher_1.resolveLauncherPath)(resolveHubInstallDir());
|
|
40
41
|
}
|
|
41
42
|
// Platform-correct locations for OS launcher entries.
|
|
42
43
|
function getOsShortcutPaths(home) {
|
|
@@ -48,7 +49,10 @@ function getOsShortcutPaths(home) {
|
|
|
48
49
|
}
|
|
49
50
|
if (process.platform === 'darwin') {
|
|
50
51
|
return {
|
|
52
|
+
// The LaunchAgent starts the Hub headless at login; the .app is what Launchpad and Finder
|
|
53
|
+
// show. Neither substitutes for the other, so both are installed.
|
|
51
54
|
launchAgent: path_1.default.join(home, 'Library', 'LaunchAgents', 'ai.fraim.hub.plist'),
|
|
55
|
+
appBundle: path_1.default.join(home, 'Applications', 'FRAIM Hub.app'),
|
|
52
56
|
};
|
|
53
57
|
}
|
|
54
58
|
return {
|
|
@@ -81,11 +85,21 @@ function writeMacosLaunchAgent(plistPath, target) {
|
|
|
81
85
|
fs_1.default.mkdirSync(path_1.default.dirname(plistPath), { recursive: true });
|
|
82
86
|
fs_1.default.writeFileSync(plistPath, content, 'utf8');
|
|
83
87
|
}
|
|
84
|
-
function writeLinuxDesktopEntry(desktopPath, target) {
|
|
88
|
+
function writeLinuxDesktopEntry(desktopPath, target, workingDir = os_1.default.homedir()) {
|
|
89
|
+
// No `--no-open` here. A .desktop entry is something the user clicks, and `--no-open` would start
|
|
90
|
+
// the server without ever showing the Hub. Only the macOS LaunchAgent, which runs at login with
|
|
91
|
+
// no click behind it, wants the headless form.
|
|
92
|
+
//
|
|
93
|
+
// `Path=` is not decoration. #646 lost a whole release to the same class of bug on macOS: the
|
|
94
|
+
// installer ran its bootstrap from a temp directory that no longer existed, so `npx`'s
|
|
95
|
+
// `process.cwd()` threw `ENOENT: uv_cwd` and first-run never launched (fixed in d680d2a6 by
|
|
96
|
+
// cd-ing to the user's home). A launcher started by a desktop environment inherits whatever
|
|
97
|
+
// working directory that environment had, so name one that is guaranteed to exist.
|
|
85
98
|
const content = `[Desktop Entry]
|
|
86
99
|
Name=FRAIM Hub
|
|
87
100
|
Comment=FRAIM AI Workforce Hub
|
|
88
|
-
Exec=${target}
|
|
101
|
+
Exec=${target}
|
|
102
|
+
Path=${workingDir}
|
|
89
103
|
Icon=fraim-hub
|
|
90
104
|
Terminal=false
|
|
91
105
|
Type=Application
|
|
@@ -99,13 +113,26 @@ StartupNotify=true
|
|
|
99
113
|
}
|
|
100
114
|
catch { /* non-fatal */ }
|
|
101
115
|
}
|
|
102
|
-
function writeWindowsShortcutScript(lnkPath, target) {
|
|
116
|
+
function writeWindowsShortcutScript(lnkPath, target, workingDir = os_1.default.homedir()) {
|
|
103
117
|
// PowerShell WScript.Shell creates a real .lnk shortcut.
|
|
118
|
+
//
|
|
119
|
+
// WindowStyle 7 (minimized), not 1 (normal): the target is a .cmd, so Windows creates a console
|
|
120
|
+
// host for the length of the launch. `src/ai-hub/cli.ts` detaches and unrefs the Electron child,
|
|
121
|
+
// so npx returns once the Hub is ready and that console closes on its own; minimized keeps it
|
|
122
|
+
// from taking focus in front of the Hub window while it is up. Hiding it outright would need a
|
|
123
|
+
// wscript/VBS shim, which this repo has no precedent for.
|
|
124
|
+
//
|
|
125
|
+
// WorkingDirectory is set rather than left empty. #646 lost a release to the same class of bug:
|
|
126
|
+
// the macOS installer ran its bootstrap from a temp directory that no longer existed, `npx`'s
|
|
127
|
+
// `process.cwd()` threw `ENOENT: uv_cwd`, and first-run never launched (fixed in d680d2a6 by
|
|
128
|
+
// cd-ing to the user's home). A shortcut with no WorkingDirectory hands npx whatever directory
|
|
129
|
+
// the shell that launched it happened to be in.
|
|
104
130
|
const ps = [
|
|
105
131
|
'$ws = New-Object -ComObject WScript.Shell',
|
|
106
132
|
`$sc = $ws.CreateShortcut('${lnkPath.replace(/'/g, "''")}')`,
|
|
107
133
|
`$sc.TargetPath = '${target.replace(/'/g, "''")}'`,
|
|
108
|
-
|
|
134
|
+
`$sc.WorkingDirectory = '${workingDir.replace(/'/g, "''")}'`,
|
|
135
|
+
"$sc.WindowStyle = 7",
|
|
109
136
|
'$sc.Save()',
|
|
110
137
|
].join('; ');
|
|
111
138
|
try {
|
|
@@ -113,8 +140,10 @@ function writeWindowsShortcutScript(lnkPath, target) {
|
|
|
113
140
|
}
|
|
114
141
|
catch {
|
|
115
142
|
// PowerShell unavailable (e.g. minimal CI image) — write a plain .cmd launcher as fallback.
|
|
143
|
+
// Same working-directory guarantee the .lnk gets above, for the same #646 reason.
|
|
116
144
|
const safeTarget = target.replace(/"/g, '""');
|
|
117
|
-
const
|
|
145
|
+
const safeWorkingDir = workingDir.replace(/"/g, '""');
|
|
146
|
+
const cmd = `@echo off\r\ncd /d "${safeWorkingDir}"\r\nstart "" "${safeTarget}"\r\n`;
|
|
118
147
|
const cmdPath = lnkPath.replace(/\.lnk$/i, '.cmd');
|
|
119
148
|
fs_1.default.mkdirSync(path_1.default.dirname(cmdPath), { recursive: true });
|
|
120
149
|
fs_1.default.writeFileSync(cmdPath, cmd, 'utf8');
|
|
@@ -127,14 +156,18 @@ async function runHubInstall() {
|
|
|
127
156
|
const home = os_1.default.homedir();
|
|
128
157
|
const shortcuts = getOsShortcutPaths(home);
|
|
129
158
|
fs_1.default.mkdirSync(installDir, { recursive: true });
|
|
130
|
-
|
|
131
|
-
console.log(`
|
|
159
|
+
(0, hub_launcher_1.writeHubLauncher)({ installDir });
|
|
160
|
+
console.log(` Launcher: ${target}`);
|
|
132
161
|
if (process.platform === 'win32' && shortcuts.startMenu) {
|
|
133
162
|
fs_1.default.mkdirSync(path_1.default.dirname(shortcuts.startMenu), { recursive: true });
|
|
134
|
-
writeWindowsShortcutScript(shortcuts.startMenu, target);
|
|
163
|
+
writeWindowsShortcutScript(shortcuts.startMenu, target, home);
|
|
135
164
|
console.log(` Start Menu shortcut: ${shortcuts.startMenu}`);
|
|
136
165
|
}
|
|
137
166
|
else if (process.platform === 'darwin' && shortcuts.launchAgent) {
|
|
167
|
+
if (shortcuts.appBundle) {
|
|
168
|
+
(0, hub_launcher_1.writeMacosAppBundle)(shortcuts.appBundle, target);
|
|
169
|
+
console.log(` Application: ${shortcuts.appBundle}`);
|
|
170
|
+
}
|
|
138
171
|
writeMacosLaunchAgent(shortcuts.launchAgent, target);
|
|
139
172
|
// Load the agent so it is active immediately without a logout/login.
|
|
140
173
|
try {
|
|
@@ -144,7 +177,7 @@ async function runHubInstall() {
|
|
|
144
177
|
console.log(` LaunchAgent: ${shortcuts.launchAgent}`);
|
|
145
178
|
}
|
|
146
179
|
else if (shortcuts.desktopEntry) {
|
|
147
|
-
writeLinuxDesktopEntry(shortcuts.desktopEntry, target);
|
|
180
|
+
writeLinuxDesktopEntry(shortcuts.desktopEntry, target, home);
|
|
148
181
|
// Notify the desktop environment to refresh its app database.
|
|
149
182
|
try {
|
|
150
183
|
(0, child_process_1.execFileSync)('update-desktop-database', [path_1.default.dirname(shortcuts.desktopEntry)], { stdio: 'ignore' });
|
|
@@ -155,6 +188,7 @@ async function runHubInstall() {
|
|
|
155
188
|
console.log('');
|
|
156
189
|
console.log('FRAIM Hub is registered as an OS application.');
|
|
157
190
|
console.log('Launch it from your Start Menu / Launchpad / application launcher.');
|
|
191
|
+
console.log('Each launch resolves the latest published fraim-hub, so there is nothing to reinstall.');
|
|
158
192
|
}
|
|
159
193
|
exports.hubInstallCommand = new commander_1.Command('install')
|
|
160
194
|
.description('Register FRAIM Hub as an OS application (Start Menu / Launchpad entry)')
|
|
@@ -0,0 +1,184 @@
|
|
|
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.resolveLauncherPath = resolveLauncherPath;
|
|
7
|
+
exports.writeHubLauncher = writeHubLauncher;
|
|
8
|
+
exports.writeMacosAppBundle = writeMacosAppBundle;
|
|
9
|
+
// #1179: the launcher an OS shortcut points at.
|
|
10
|
+
//
|
|
11
|
+
// `fraim-hub install` used to download a packaged Electron app from
|
|
12
|
+
// `https://api.github.com/repos/mathursrus/FRAIM/releases/latest`. That call is unauthenticated and
|
|
13
|
+
// `mathursrus/FRAIM` is private, so GitHub answered 404 rather than 403; and no release has ever
|
|
14
|
+
// been published, because `.github/workflows/fraim-hub-release.yml` fires only on `release:
|
|
15
|
+
// published`. The command therefore failed for every user and never produced anything launchable.
|
|
16
|
+
//
|
|
17
|
+
// The Hub does not need a packaged binary to be double-clickable. `npx fraim-hub@latest` already
|
|
18
|
+
// resolves a shared Electron dist from `~/.fraim/bin/electron/<version>/` (#1112) and detaches the
|
|
19
|
+
// desktop child (`src/ai-hub/cli.ts`), and the shipped macOS installer is the same shape: a
|
|
20
|
+
// `pkgbuild --nopayload` package whose whole job is to run `npx -y fraim@latest first-run`. So the
|
|
21
|
+
// shortcut target is a small generated script that runs the Hub through npx.
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
/**
|
|
25
|
+
* What the launcher runs.
|
|
26
|
+
*
|
|
27
|
+
* `@latest` and not a pinned version, because an npx-launched Hub has no self-update path:
|
|
28
|
+
* `configureAutoUpdater()` in `desktop-main.ts` returns immediately unless `app.isPackaged` (never
|
|
29
|
+
* true under npx), it reads from the same absent release feed, and `/api/ai-hub/version` only
|
|
30
|
+
* reports `updateAvailable` without acting on it. Resolving `@latest` at launch is the update
|
|
31
|
+
* mechanism; pinning here would strand users on the version they installed.
|
|
32
|
+
*/
|
|
33
|
+
const HUB_LAUNCH_SPEC = 'fraim-hub@latest';
|
|
34
|
+
/**
|
|
35
|
+
* No `--restart`: it replaces a running Hub unconditionally, so double-clicking the icon while the
|
|
36
|
+
* Hub is up would kill it and pay a cold relaunch (~20s in #1110). The default already replaces
|
|
37
|
+
* only an older or stale instance and re-focuses a current one (`src/cli/fraim-hub.ts`).
|
|
38
|
+
*
|
|
39
|
+
* No `--no-open` either: that belongs to the background LaunchAgent, not to a clicked app entry.
|
|
40
|
+
*/
|
|
41
|
+
const NPX_ARGS = `-y ${HUB_LAUNCH_SPEC}`;
|
|
42
|
+
/**
|
|
43
|
+
* Shown when no `npx` can be resolved. Worth a real sentence because the failure surface is a
|
|
44
|
+
* double-clicked icon with no terminal behind it, where the alternative is cmd's
|
|
45
|
+
* `'npx' is not recognized` flashing past.
|
|
46
|
+
*
|
|
47
|
+
* Emitted by a batch `echo`, so this string must stay free of `& | < > ^`, which cmd interprets
|
|
48
|
+
* rather than prints.
|
|
49
|
+
*/
|
|
50
|
+
const MISSING_NODE_MESSAGE = 'FRAIM Hub needs Node.js. Install it from https://nodejs.org/ and open FRAIM Hub again.';
|
|
51
|
+
/**
|
|
52
|
+
* Quote a value for a POSIX shell script.
|
|
53
|
+
*
|
|
54
|
+
* The launcher interpolates filesystem paths into a script it then executes. Inside double quotes a
|
|
55
|
+
* shell still expands `$(...)`, backticks, and `$VAR`, so a path carrying any of those would be
|
|
56
|
+
* evaluated rather than used. Single quotes suppress all of it, and `'\''` is the standard way to
|
|
57
|
+
* carry a literal single quote through.
|
|
58
|
+
*
|
|
59
|
+
* These paths derive from `process.execPath` and `getUserFraimDirPath()` (which honours
|
|
60
|
+
* `FRAIM_USER_DIR`), so an attacker who controls them can already run code as this user. This is
|
|
61
|
+
* not a privilege boundary; it is a write-boundary guard kept next to the construction it protects,
|
|
62
|
+
* so a future caller passing a less trustworthy path does not turn this into one.
|
|
63
|
+
*/
|
|
64
|
+
function posixSingleQuote(value) {
|
|
65
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Escape a value for literal use inside a batch file.
|
|
69
|
+
*
|
|
70
|
+
* `%` is the variable sigil, and a Windows path may legally contain it, so `C:\%TEMP%\node` would
|
|
71
|
+
* expand at parse time instead of naming the directory. In a batch file `%%` yields a literal `%`.
|
|
72
|
+
* `"` needs no handling: it is not a legal character in a Windows path.
|
|
73
|
+
*/
|
|
74
|
+
function batchEscape(value) {
|
|
75
|
+
return value.replace(/%/g, '%%');
|
|
76
|
+
}
|
|
77
|
+
/** `fraim-hub.cmd` on Windows, `fraim-hub` elsewhere. */
|
|
78
|
+
function launcherFileName(platform = process.platform) {
|
|
79
|
+
return platform === 'win32' ? 'fraim-hub.cmd' : 'fraim-hub';
|
|
80
|
+
}
|
|
81
|
+
function resolveLauncherPath(installDir, platform = process.platform) {
|
|
82
|
+
return path_1.default.join(installDir, launcherFileName(platform));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The Node directory recorded into the launcher is *appended* to PATH, never prepended.
|
|
86
|
+
*
|
|
87
|
+
* Appended, because prepending would pin every future launch to whichever Node ran the install and
|
|
88
|
+
* shadow a later system upgrade indefinitely. A system `npx` should keep winning; this directory is
|
|
89
|
+
* the fallback for the case that made recording it necessary at all: a GUI shortcut inherits no
|
|
90
|
+
* shell PATH, and the portable-Node bootstrap in `scripts/installer/fraim-install-win.template.cmd`
|
|
91
|
+
* puts Node under `~/.fraim/node/...` where the system PATH never points.
|
|
92
|
+
*/
|
|
93
|
+
function renderWindowsLauncher(nodeDir) {
|
|
94
|
+
return [
|
|
95
|
+
'@echo off',
|
|
96
|
+
'rem FRAIM Hub launcher - generated by `fraim-hub install` (issue #1179). Safe to delete;',
|
|
97
|
+
'rem re-running `npx fraim-hub@latest install` recreates it.',
|
|
98
|
+
'setlocal',
|
|
99
|
+
`set "PATH=%PATH%;${batchEscape(nodeDir)}"`,
|
|
100
|
+
'where npx >nul 2>nul',
|
|
101
|
+
'if errorlevel 1 (',
|
|
102
|
+
` echo ${MISSING_NODE_MESSAGE}`,
|
|
103
|
+
' pause',
|
|
104
|
+
' exit /b 1',
|
|
105
|
+
')',
|
|
106
|
+
`call npx ${NPX_ARGS} %*`,
|
|
107
|
+
'',
|
|
108
|
+
].join('\r\n');
|
|
109
|
+
}
|
|
110
|
+
function renderPosixLauncher(nodeDir) {
|
|
111
|
+
return [
|
|
112
|
+
'#!/bin/sh',
|
|
113
|
+
'# FRAIM Hub launcher - generated by `fraim-hub install` (issue #1179). Safe to delete;',
|
|
114
|
+
'# re-running `npx fraim-hub@latest install` recreates it.',
|
|
115
|
+
`FRAIM_NODE_DIR=${posixSingleQuote(nodeDir)}`,
|
|
116
|
+
'PATH="$PATH:$FRAIM_NODE_DIR"',
|
|
117
|
+
'export PATH',
|
|
118
|
+
'if ! command -v npx >/dev/null 2>&1; then',
|
|
119
|
+
` echo "${MISSING_NODE_MESSAGE}" >&2`,
|
|
120
|
+
' exit 1',
|
|
121
|
+
'fi',
|
|
122
|
+
`exec npx ${NPX_ARGS} "$@"`,
|
|
123
|
+
'',
|
|
124
|
+
].join('\n');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Write the launcher and return its path. Idempotent: the contents depend only on the platform and
|
|
128
|
+
* the Node directory, so a repeat install rewrites the same bytes.
|
|
129
|
+
*/
|
|
130
|
+
function writeHubLauncher(options) {
|
|
131
|
+
const platform = options.platform ?? process.platform;
|
|
132
|
+
const nodeDir = options.nodeDir ?? path_1.default.dirname(process.execPath);
|
|
133
|
+
const launcherPath = resolveLauncherPath(options.installDir, platform);
|
|
134
|
+
fs_1.default.mkdirSync(options.installDir, { recursive: true });
|
|
135
|
+
fs_1.default.writeFileSync(launcherPath, platform === 'win32' ? renderWindowsLauncher(nodeDir) : renderPosixLauncher(nodeDir), 'utf8');
|
|
136
|
+
if (platform !== 'win32') {
|
|
137
|
+
fs_1.default.chmodSync(launcherPath, 0o755);
|
|
138
|
+
}
|
|
139
|
+
return launcherPath;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A minimal `.app` around the launcher, so macOS has something to show in Launchpad and Finder.
|
|
143
|
+
*
|
|
144
|
+
* The LaunchAgent `runHubInstall` already writes is a login item, not an application: it starts the
|
|
145
|
+
* Hub headless at login and produces no clickable entry. The two are complementary and both are
|
|
146
|
+
* installed.
|
|
147
|
+
*
|
|
148
|
+
* `CFBundleIdentifier` is deliberately `ai.fraim.hub.launcher`, not the `ai.fraim.hub` that
|
|
149
|
+
* `packages/fraim-hub/package.json` declares as electron-builder's `appId`. If a packaged Hub is
|
|
150
|
+
* ever installed alongside this one, two bundles sharing an identifier would leave LaunchServices
|
|
151
|
+
* to guess which is which.
|
|
152
|
+
*/
|
|
153
|
+
function writeMacosAppBundle(appDir, launcherPath) {
|
|
154
|
+
const executableName = 'FRAIM Hub';
|
|
155
|
+
const macosDir = path_1.default.join(appDir, 'Contents', 'MacOS');
|
|
156
|
+
fs_1.default.mkdirSync(macosDir, { recursive: true });
|
|
157
|
+
const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
158
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
159
|
+
<plist version="1.0">
|
|
160
|
+
<dict>
|
|
161
|
+
<key>CFBundleName</key>
|
|
162
|
+
<string>FRAIM Hub</string>
|
|
163
|
+
<key>CFBundleDisplayName</key>
|
|
164
|
+
<string>FRAIM Hub</string>
|
|
165
|
+
<key>CFBundleIdentifier</key>
|
|
166
|
+
<string>ai.fraim.hub.launcher</string>
|
|
167
|
+
<key>CFBundleExecutable</key>
|
|
168
|
+
<string>${executableName}</string>
|
|
169
|
+
<key>CFBundlePackageType</key>
|
|
170
|
+
<string>APPL</string>
|
|
171
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
172
|
+
<string>6.0</string>
|
|
173
|
+
<key>CFBundleVersion</key>
|
|
174
|
+
<string>1</string>
|
|
175
|
+
<key>CFBundleShortVersionString</key>
|
|
176
|
+
<string>1.0</string>
|
|
177
|
+
</dict>
|
|
178
|
+
</plist>
|
|
179
|
+
`;
|
|
180
|
+
fs_1.default.writeFileSync(path_1.default.join(appDir, 'Contents', 'Info.plist'), infoPlist, 'utf8');
|
|
181
|
+
const executablePath = path_1.default.join(macosDir, executableName);
|
|
182
|
+
fs_1.default.writeFileSync(executablePath, `#!/bin/sh\nexec ${posixSingleQuote(launcherPath)} "$@"\n`, 'utf8');
|
|
183
|
+
fs_1.default.chmodSync(executablePath, 0o755);
|
|
184
|
+
}
|
|
@@ -22,7 +22,10 @@ const defaultPreferences = (projectPath) => ({
|
|
|
22
22
|
removedProjectPaths: [],
|
|
23
23
|
});
|
|
24
24
|
function normalizeProjectPath(projectPath) {
|
|
25
|
-
|
|
25
|
+
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
|
26
|
+
throw new Error('normalizeProjectPath: empty project path');
|
|
27
|
+
}
|
|
28
|
+
return path_1.default.resolve(projectPath);
|
|
26
29
|
}
|
|
27
30
|
function canonicalProjectPath(projectPath) {
|
|
28
31
|
const normalized = normalizeProjectPath(projectPath);
|
|
@@ -8,6 +8,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const crypto_1 = require("crypto");
|
|
10
10
|
const conversation_store_1 = require("./conversation-store");
|
|
11
|
+
const process_liveness_1 = require("./process-liveness");
|
|
11
12
|
exports.DEFAULT_RESTART_RECOVERY_LEASE_MS = 60_000;
|
|
12
13
|
function normalizedDirectoryPath(projectPath) {
|
|
13
14
|
const resolved = path_1.default.resolve(projectPath);
|
|
@@ -49,6 +50,8 @@ class RestartRecoveryPolicy {
|
|
|
49
50
|
this.recoveryLeaseMs = options.recoveryLeaseMs ?? exports.DEFAULT_RESTART_RECOVERY_LEASE_MS;
|
|
50
51
|
this.projectExists = options.projectExists || ((projectPath) => fs_1.default.existsSync(projectPath));
|
|
51
52
|
this.machineLevelJobIds = options.machineLevelJobIds || new Set();
|
|
53
|
+
this.currentPid = options.currentPid ?? process.pid;
|
|
54
|
+
this.pidAlive = options.pidAlive || process_liveness_1.isPidAlive;
|
|
52
55
|
}
|
|
53
56
|
classify(conversation, bucketKey, options = {}) {
|
|
54
57
|
const bucketReason = restartRecoveryBucketOwnershipReason(conversation, bucketKey);
|
|
@@ -81,6 +84,17 @@ class RestartRecoveryPolicy {
|
|
|
81
84
|
if (options.activeRunExists) {
|
|
82
85
|
return { action: 'defer', reason: 'active_run_exists' };
|
|
83
86
|
}
|
|
87
|
+
// Issue #1159: `activeRunExists` only sees this process's run registry, so it
|
|
88
|
+
// cannot tell that a *different* live Hub owns this run. Two Hubs on one
|
|
89
|
+
// machine is the normal case here: the desktop Hub plus any Hub a job starts
|
|
90
|
+
// for validation. Without this check the second one adopts the first one's
|
|
91
|
+
// in-flight work, replaces its run id, and parks it as "Waiting on you".
|
|
92
|
+
// A dead owner pid is exactly the restart case recovery exists for, so only
|
|
93
|
+
// a live foreign owner defers.
|
|
94
|
+
const ownerPid = typeof conversation.ownerPid === 'number' ? conversation.ownerPid : 0;
|
|
95
|
+
if (ownerPid > 0 && ownerPid !== this.currentPid && this.pidAlive(ownerPid)) {
|
|
96
|
+
return { action: 'defer', reason: 'owner_process_alive' };
|
|
97
|
+
}
|
|
84
98
|
const recoveredAt = timestampMs(conversation.restartRecovery?.recoveredAt);
|
|
85
99
|
if (recoveredAt > 0 && this.nowMs() - recoveredAt < this.recoveryLeaseMs) {
|
|
86
100
|
return { action: 'defer', reason: 'recent_recovery' };
|