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.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  try {
3
3
  const { createFraimHub2Program } = require('../dist/src/cli/fraim-hub-2.js');
4
4
  createFraimHub2Program().parseAsync(process.argv).catch((error) => {
@@ -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 = nextPhase(phaseDef.onSuccess, discriminant);
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 nextPhase(edge, discriminant);
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
- return path_1.default.resolve(key || process.cwd());
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);
@@ -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
- const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
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 hub install` — register the FRAIM Hub as an OS application so users
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
- // This is an interim mitigation. Full packaged installers (electron-builder, code
17
- // signing, .msi/.dmg) are a separate effort requiring signing infrastructure.
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 hub_release_download_1 = require("./hub-release-download");
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 binary that OS shortcuts should point at. On Windows this is the Electron .exe;
30
- // on POSIX systems it is the unpacked Electron binary or a shell wrapper.
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
- const installDir = resolveHubInstallDir();
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} --no-open
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
- "$sc.WindowStyle = 1",
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 cmd = `@echo off\r\nstart "" "${safeTarget}"\r\n`;
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
- const downloaded = await (0, hub_release_download_1.downloadLatestHubBinary)(installDir, target);
131
- console.log(` Downloaded release asset: ${downloaded.assetName}`);
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
- return path_1.default.resolve(projectPath || process.cwd());
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' };