fraim-hub 2.0.288 → 2.0.290

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.
Files changed (35) hide show
  1. package/dist/src/ai-hub/desktop-launcher.js +66 -0
  2. package/dist/src/ai-hub/desktop-main.js +48 -7
  3. package/dist/src/ai-hub/hub-app-materializer.js +285 -0
  4. package/dist/src/ai-hub/server.js +4 -8
  5. package/dist/src/cli/utils/managed-node-runtime.js +269 -0
  6. package/dist/src/config/ai-manager-hiring.js +6 -2
  7. package/dist/src/config/persona-capability-bundles.js +1 -1
  8. package/dist/src/config/persona-hiring.js +26 -26
  9. package/dist/src/first-run/session-service.js +12 -2
  10. package/package.json +5 -3
  11. package/public/ai-hub/script.js +46 -10
  12. package/public/first-run/script.js +10 -1
  13. package/public/portfolio/aida.html +13 -13
  14. package/public/portfolio/ashley.html +2 -2
  15. package/public/portfolio/auditya.html +2 -2
  16. package/public/portfolio/banke.html +2 -2
  17. package/public/portfolio/beza.html +2 -2
  18. package/public/portfolio/careena.html +2 -2
  19. package/public/portfolio/casey.html +2 -2
  20. package/public/portfolio/celia.html +2 -2
  21. package/public/portfolio/deidre.html +2 -2
  22. package/public/portfolio/hari.html +2 -2
  23. package/public/portfolio/index.html +5 -5
  24. package/public/portfolio/maestro.html +2 -2
  25. package/public/portfolio/mandy.html +2 -2
  26. package/public/portfolio/mona.html +2 -2
  27. package/public/portfolio/pam.html +2 -2
  28. package/public/portfolio/procella.html +2 -2
  29. package/public/portfolio/qasm.html +2 -2
  30. package/public/portfolio/ricardo.html +2 -2
  31. package/public/portfolio/sade.html +2 -2
  32. package/public/portfolio/sam.html +2 -2
  33. package/public/portfolio/sechar.html +2 -2
  34. package/public/portfolio/sreya.html +2 -2
  35. package/public/portfolio/swen.html +4 -4
@@ -0,0 +1,66 @@
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
+ const electron_1 = require("electron");
7
+ const path_1 = __importDefault(require("path"));
8
+ const hub_app_materializer_1 = require("./hub-app-materializer");
9
+ // Issue #1415: this is the packaged Electron `main` entry (see packages/fraim-hub/package.json
10
+ // `"main"`). It is deliberately thin and changes rarely: its only job is to resolve which version
11
+ // of the actual Hub application (the code in desktop-main.ts) to run — the latest published to
12
+ // npm, materializing it once per machine if not already cached — and hand off to it. The native
13
+ // installer only ever needs to ship a new build of this file and the Electron shell itself;
14
+ // every ordinary `fraim-hub` release reaches an already-installed user the moment this
15
+ // resolution runs on their next launch, with no new installer download.
16
+ const BUNDLED_ENTRY_PATH = path_1.default.join(__dirname, 'desktop-main.js');
17
+ async function bootstrapFrom(entryPath) {
18
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
19
+ const entry = require(entryPath);
20
+ await entry.bootstrap();
21
+ }
22
+ async function launch() {
23
+ // Issue #1415: deterministic escape hatch for integration testing — skips the real npm
24
+ // resolution (and its dependence on whatever happens to be published at test time) so a test can
25
+ // prove the Electron main-process boot chain itself (resolveHubAppEntry -> require -> bootstrap
26
+ // -> healthy server) without a network round-trip. Not used in any real launch path; mirrors the
27
+ // existing `FRAIM_INSTALLER_LIFECYCLE_TEST` kill-switch pattern already used for testability.
28
+ const resolved = process.env.FRAIM_HUB_SKIP_MATERIALIZE === '1'
29
+ ? { entryPath: BUNDLED_ENTRY_PATH, source: 'bundled', version: electron_1.app.getVersion() }
30
+ : await (0, hub_app_materializer_1.resolveHubAppEntry)({
31
+ bundledVersion: electron_1.app.getVersion(),
32
+ bundledEntryPath: BUNDLED_ENTRY_PATH,
33
+ onProgress: (message) => console.log(`[fraim-hub-launcher] ${message}`),
34
+ }).catch((error) => {
35
+ // resolveHubAppEntry is designed to never throw (every failure mode falls back internally),
36
+ // but a launch must survive even a bug in that resolution rather than fail here.
37
+ console.error('[fraim-hub-launcher] resolution failed unexpectedly; using the bundled build:', error);
38
+ return { entryPath: BUNDLED_ENTRY_PATH, source: 'bundled', version: electron_1.app.getVersion() };
39
+ });
40
+ console.log(`[fraim-hub-launcher] running FRAIM Hub ${resolved.version} (${resolved.source})`);
41
+ try {
42
+ await bootstrapFrom(resolved.entryPath);
43
+ }
44
+ catch (error) {
45
+ if (resolved.entryPath === BUNDLED_ENTRY_PATH)
46
+ throw error;
47
+ // A materialized copy that fails to load (corrupt cache, an ABI mismatch some future
48
+ // dependency introduces) must not strand the user on a broken launch — fall back to the
49
+ // build that shipped with this installer, which is always known-good for this Electron binary.
50
+ console.error(`[fraim-hub-launcher] materialized FRAIM Hub ${resolved.version} failed to load; falling back to the bundled build:`, error);
51
+ await bootstrapFrom(BUNDLED_ENTRY_PATH);
52
+ }
53
+ }
54
+ if (process.versions.electron && process.type !== 'renderer') {
55
+ launch().catch((error) => {
56
+ console.error(error instanceof Error ? error.message : error);
57
+ try {
58
+ electron_1.dialog.showErrorBox('FRAIM Hub failed to start', error instanceof Error ? error.message : String(error));
59
+ }
60
+ catch {
61
+ // dialog can be unavailable this early in rare startup failures; the console error above
62
+ // is the fallback record.
63
+ }
64
+ electron_1.app.exit(1);
65
+ });
66
+ }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.launchDesktopShell = launchDesktopShell;
7
+ exports.bootstrap = bootstrap;
7
8
  const electron_1 = require("electron");
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const fs_1 = __importDefault(require("fs"));
@@ -20,6 +21,7 @@ const server_2 = require("../first-run/server");
20
21
  const session_service_1 = require("../first-run/session-service");
21
22
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
22
23
  const desktop_auto_updater_1 = require("./desktop-auto-updater");
24
+ const hub_app_materializer_1 = require("./hub-app-materializer");
23
25
  // Keep installed, running, and user-pinned Windows shortcuts grouped under the
24
26
  // stable identity declared in packages/fraim-hub/package.json.
25
27
  electron_1.app.setAppUserModelId('ai.fraim.hub');
@@ -73,7 +75,7 @@ function configurePackagedFraimRuntime() {
73
75
  if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
74
76
  return;
75
77
  process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
76
- process.env.FRAIM_PACKAGED_CLI_SCRIPT = path_1.default.join(electron_1.app.getAppPath(), 'node_modules', 'fraim', 'bin', 'fraim.js');
78
+ process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
77
79
  (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
78
80
  }
79
81
  function shouldShowEmbeddedFirstRun() {
@@ -133,11 +135,39 @@ async function configureAutoUpdater() {
133
135
  });
134
136
  return result.action === 'installing';
135
137
  }
136
- function checkForUpdateAfterSecondInstance() {
138
+ // Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
139
+ // already running) is the common case once login-item autostart + hide-to-tray keep the Hub
140
+ // resident for days, and it is the one launch-shaped event where the desktop-launcher.ts
141
+ // materializer never gets a chance to run — bootstrap() has already won the single-instance lock
142
+ // by the time it fires. Without this check, an already-running Hub could only ever pick up a new
143
+ // `fraim-hub` release through the slower electron-updater feed below, which is the exact lagging
144
+ // signal #1415 was filed about. `getFraimVersion()` (not `app.getVersion()`) is compared, because
145
+ // it resolves from wherever the currently-running code actually lives — the bundled app.asar or a
146
+ // previously materialized copy — while `app.getVersion()` always names the former.
147
+ async function checkForHubAppUpdateOnSecondInstance() {
148
+ const runningVersion = (0, version_utils_1.getFraimVersion)();
149
+ const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
150
+ bundledVersion: runningVersion,
151
+ bundledEntryPath: __filename,
152
+ });
153
+ if (resolved.version === runningVersion)
154
+ return false;
155
+ console.log(`[fraim] newer FRAIM Hub ${resolved.version} available (was running ${runningVersion}); restarting to apply it`);
156
+ electron_1.app.relaunch();
157
+ electron_1.app.quit();
158
+ return true;
159
+ }
160
+ // Issue #1415: shared by both re-launch-shaped events on an already-running Hub — Windows/Linux's
161
+ // `second-instance` (a new process lost the single-instance lock) and macOS's `activate` (the
162
+ // Dock icon click that never spawns a new process at all, so `second-instance` never fires there).
163
+ // Before this, neither path re-checked npm-latest, only the older, slower electron-updater feed.
164
+ function checkForRelaunchAttemptUpdate() {
137
165
  if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
138
166
  return;
139
- void configureAutoUpdater().catch((err) => {
140
- console.warn('[fraim] second-instance update check failed:', err);
167
+ void checkForHubAppUpdateOnSecondInstance()
168
+ .then((restarting) => (restarting ? undefined : configureAutoUpdater().then(() => undefined)))
169
+ .catch((err) => {
170
+ console.warn('[fraim] relaunch-attempt update check failed:', err);
141
171
  });
142
172
  }
143
173
  // ---------------------------------------------------------------------------
@@ -469,6 +499,9 @@ async function launchDesktopShell(options) {
469
499
  // ---------------------------------------------------------------------------
470
500
  // Bootstrap
471
501
  // ---------------------------------------------------------------------------
502
+ // Issue #1415: exported so `desktop-launcher.ts` can `require()` this module (whether the copy
503
+ // bundled in this installer or a materialized npm-latest copy) and invoke bootstrap explicitly,
504
+ // instead of relying on the module's own top-level self-execution below.
472
505
  async function bootstrap() {
473
506
  const options = parseArgs(process.argv.slice(2));
474
507
  applyUserDataOverride();
@@ -483,7 +516,7 @@ async function bootstrap() {
483
516
  return;
484
517
  }
485
518
  electron_1.app.on('second-instance', () => {
486
- void electron_1.app.whenReady().then(checkForUpdateAfterSecondInstance);
519
+ void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
487
520
  if (mainWindow) {
488
521
  mainWindow.show();
489
522
  mainWindow.focus();
@@ -508,7 +541,10 @@ async function bootstrap() {
508
541
  return;
509
542
  }
510
543
  electron_1.app.on('activate', () => {
511
- // macOS: clicking dock icon re-shows the window
544
+ // macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
545
+ // second process (so 'second-instance' never fires here) — it is macOS's equivalent
546
+ // relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
547
+ checkForRelaunchAttemptUpdate();
512
548
  if (mainWindow) {
513
549
  mainWindow.show();
514
550
  mainWindow.focus();
@@ -533,7 +569,12 @@ async function bootstrap() {
533
569
  });
534
570
  await launchDesktopShell(options);
535
571
  }
536
- if (process.versions.electron && process.type !== 'renderer') {
572
+ // Issue #1415: self-execution is now gated on `require.main === module` in addition to the
573
+ // existing Electron-main-process check, so `desktop-launcher.ts` requiring this file (bundled or
574
+ // materialized) does not double-run bootstrap — the launcher calls the exported `bootstrap`
575
+ // explicitly instead. Direct invocation (`npm run hub:desktop`, Electron-launching test suites)
576
+ // is unaffected: this file is still `require.main` in that case.
577
+ if (require.main === module && process.versions.electron && process.type !== 'renderer') {
537
578
  bootstrap().catch(async (error) => {
538
579
  console.error(error instanceof Error ? error.message : error);
539
580
  await stopServerOnce();
@@ -0,0 +1,285 @@
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.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
7
+ exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
8
+ exports.planHubAppMaterialization = planHubAppMaterialization;
9
+ exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
10
+ exports.isHubAppCacheComplete = isHubAppCacheComplete;
11
+ exports.listCachedHubAppVersions = listCachedHubAppVersions;
12
+ exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
13
+ exports.resolveHubAppEntry = resolveHubAppEntry;
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const child_process_1 = require("child_process");
17
+ const semver_1 = __importDefault(require("semver"));
18
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
19
+ const hub_latest_version_1 = require("./hub-latest-version");
20
+ const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
21
+ // Issue #1415: a packaged FRAIM Hub install can already be behind npm's published "latest" the
22
+ // moment it is downloaded, and its own electron-updater feed (#1387) only advances once a full
23
+ // signed desktop build has finished — which happens on a much slower cadence than npm publishes.
24
+ // This module reconciles the two: on every launch, resolve the version actually published to npm
25
+ // (`fraim-hub` and `fraim` are lockstep-versioned, so `hub-latest-version.ts`'s existing 5-minute-
26
+ // cached npm lookup is reused rather than duplicated), materialize that version's application code
27
+ // once per machine (mirroring `electron-dist.ts`'s "extract once, reuse forever" shared-dist
28
+ // pattern), and hand back the entry point to run. The Electron shell itself — installed once via
29
+ // the native installer — never needs a new install for an ordinary `fraim-hub` point release; only
30
+ // the application payload this module resolves does.
31
+ //
32
+ // The decision (`planHubAppMaterialization`) is a pure function so the whole staleness/cache/
33
+ // offline matrix is unit-testable without touching npm, disk, or Electron — the same separation
34
+ // `hub-launch-decision.ts` (#755) and `desktop-auto-updater.ts` (#1387) already established for
35
+ // this exact class of problem.
36
+ exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
37
+ /** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
38
+ exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
39
+ /**
40
+ * Where the packaged CLI/MCP shim (`ensureFraimMcpLatestLauncher()`) should find `fraim`'s CLI
41
+ * script, derived from `desktop-main.ts`'s own directory (`<packageRoot>/dist/src/ai-hub/`) rather
42
+ * than Electron's `app.getAppPath()`. `desktop-launcher.ts` may have loaded `desktop-main.js` from a
43
+ * materialized `fraim-hub@<latest>` copy under `~/.fraim/bin/hub-app/<version>/` rather than from
44
+ * the original app.asar, and `app.getAppPath()` always names the latter regardless of which copy is
45
+ * actually running. `moduleDir` tracks whichever copy is really executing. Kept in this
46
+ * Electron-free module (rather than inlined in `desktop-main.ts`) so it is unit-testable without an
47
+ * Electron `app` object.
48
+ */
49
+ function resolvePackagedCliScriptPath(moduleDir) {
50
+ return path_1.default.join(moduleDir, '..', '..', '..', 'node_modules', 'fraim', 'bin', 'fraim.js');
51
+ }
52
+ function pickNewestComplete(cachedVersions) {
53
+ const versions = cachedVersions.filter((entry) => entry.complete).map((entry) => entry.version);
54
+ if (versions.length === 0)
55
+ return null;
56
+ return versions.reduce((newest, candidate) => (semver_1.default.gt(candidate, newest) ? candidate : newest));
57
+ }
58
+ /**
59
+ * Decide whether to run a cached materialized version, materialize a new one, or fall back to the
60
+ * version bundled with this installer. Pure: no fs, no network, no Electron.
61
+ */
62
+ function planHubAppMaterialization(input) {
63
+ const { bundledVersion, latestPublishedVersion, cachedVersions } = input;
64
+ if (latestPublishedVersion === null) {
65
+ const newestCached = pickNewestComplete(cachedVersions);
66
+ if (newestCached) {
67
+ return {
68
+ action: 'use-cached',
69
+ version: newestCached,
70
+ reason: 'npm registry unreachable; reusing the newest complete cached version',
71
+ };
72
+ }
73
+ return {
74
+ action: 'use-bundled',
75
+ version: bundledVersion,
76
+ reason: 'npm registry unreachable and no cached version available; falling back to the version bundled with this installer',
77
+ };
78
+ }
79
+ if (latestPublishedVersion === bundledVersion) {
80
+ return {
81
+ action: 'use-bundled',
82
+ version: bundledVersion,
83
+ reason: 'the installed shell already matches the latest published version',
84
+ };
85
+ }
86
+ const alreadyMaterialized = cachedVersions.some((entry) => entry.complete && entry.version === latestPublishedVersion);
87
+ if (alreadyMaterialized) {
88
+ return {
89
+ action: 'use-cached',
90
+ version: latestPublishedVersion,
91
+ reason: 'latest published version is already materialized on disk',
92
+ };
93
+ }
94
+ return {
95
+ action: 'materialize',
96
+ version: latestPublishedVersion,
97
+ reason: `latest published version ${latestPublishedVersion} is not yet materialized`,
98
+ };
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Cache directory layout: ~/.fraim/bin/hub-app/<version>/
102
+ // ---------------------------------------------------------------------------
103
+ // Same shape as an npm version, optionally with a prerelease/build suffix. Anchoring on this
104
+ // before ever joining a version into a path means a hostile string (`../../..`, an absolute path)
105
+ // can never escape the versioned cache directory.
106
+ const SAFE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
107
+ function assertPathSafeVersion(version) {
108
+ if (!SAFE_VERSION.test(version)) {
109
+ throw new Error(`Refusing to use "${version}" as a fraim-hub version: expected an exact release such as 2.0.290.`);
110
+ }
111
+ return version;
112
+ }
113
+ function resolveHubAppCacheDir(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
114
+ return path_1.default.join(fraimDir, 'bin', 'hub-app', assertPathSafeVersion(version));
115
+ }
116
+ /** Whether `dir` holds a complete materialized install for exactly `version`. */
117
+ function isHubAppCacheComplete(dir, version) {
118
+ try {
119
+ const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'version'), 'utf8').trim();
120
+ if (recorded !== version)
121
+ return false;
122
+ return fs_1.default.existsSync(path_1.default.join(dir, exports.HUB_APP_ENTRY_RELATIVE));
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ /** Every version directory under the cache root that is a complete, trustworthy install. */
129
+ function listCachedHubAppVersions(fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
130
+ const root = path_1.default.join(fraimDir, 'bin', 'hub-app');
131
+ let entries;
132
+ try {
133
+ entries = fs_1.default.readdirSync(root, { withFileTypes: true });
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ const versions = [];
139
+ for (const entry of entries) {
140
+ if (!entry.isDirectory() || !semver_1.default.valid(entry.name))
141
+ continue;
142
+ if (isHubAppCacheComplete(path_1.default.join(root, entry.name), entry.name)) {
143
+ versions.push({ version: entry.name, complete: true });
144
+ }
145
+ }
146
+ return versions;
147
+ }
148
+ /**
149
+ * Default installer: `npm install fraim-hub@<version>` into a scratch prefix, then flatten the
150
+ * installed package up to `destDir` so callers can treat `destDir` itself as the package root
151
+ * (`destDir/dist/...`), with the package's own dependencies left as siblings under
152
+ * `destDir/node_modules/` where Node's module resolution finds them from any depth under `destDir`.
153
+ * A fresh, empty `--prefix` install of a single package has nothing to conflict with, so npm hoists
154
+ * every dependency to that top level; nothing here relies on nested-dependency resolution.
155
+ *
156
+ * The `npm` executable itself is resolved via `ensureManagedNpm()` (system PATH, an already-managed
157
+ * runtime, or a freshly downloaded one) rather than assumed present on PATH — a genuinely GUI-only
158
+ * install (native installer only, no CLI ever run) has neither system npm nor FRAIM's own managed
159
+ * one, since first-run's embedded-desktop path only needs Electron's own bundled Node for its own
160
+ * purposes and never downloads a standalone npm (see managed-node-runtime.ts's own header comment).
161
+ */
162
+ // Node's spawnSync fails with EINVAL when a `.cmd` shim (npm's own launcher on Windows) is
163
+ // invoked directly without a shell — the same issue `fraim-mcp-latest-launcher.ts` already works
164
+ // around for `npm`/`npx`. Mirrors that exact fix: run through cmd.exe with args quoted, rather
165
+ // than inventing a different approach for this second call site.
166
+ function quoteCmdArg(arg) {
167
+ return /[\s"&|<>^]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg;
168
+ }
169
+ function runNpmInstall(npmPath, args) {
170
+ if (process.platform !== 'win32') {
171
+ return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8' });
172
+ }
173
+ return (0, child_process_1.spawnSync)(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', [quoteCmdArg(npmPath), ...args.map(quoteCmdArg)].join(' ')], { encoding: 'utf8' });
174
+ }
175
+ const defaultInstallPackage = async (version, destDir) => {
176
+ fs_1.default.mkdirSync(destDir, { recursive: true });
177
+ const npm = await (0, managed_node_runtime_1.ensureManagedNpm)();
178
+ const result = runNpmInstall(npm.npmPath, ['install', `${exports.HUB_APP_PACKAGE_NAME}@${version}`, '--no-save', '--omit=dev', '--prefix', destDir]);
179
+ if (result.status !== 0) {
180
+ const detail = result.stderr || result.stdout || result.error?.message || 'unknown error';
181
+ throw new Error(`npm install ${exports.HUB_APP_PACKAGE_NAME}@${version} failed via ${npm.source} npm (exit ${result.status}): ${detail}`);
182
+ }
183
+ const installedRoot = path_1.default.join(destDir, 'node_modules', exports.HUB_APP_PACKAGE_NAME);
184
+ if (!fs_1.default.existsSync(installedRoot)) {
185
+ throw new Error(`npm install ${exports.HUB_APP_PACKAGE_NAME}@${version} did not produce ${installedRoot}`);
186
+ }
187
+ for (const entry of fs_1.default.readdirSync(installedRoot)) {
188
+ const from = path_1.default.join(installedRoot, entry);
189
+ const to = path_1.default.join(destDir, entry);
190
+ if (entry === 'node_modules' && fs_1.default.existsSync(to)) {
191
+ // Only reachable if npm nested a dependency inside fraim-hub's own node_modules despite the
192
+ // empty prefix (a version conflict with itself is not possible, but defend anyway): merge
193
+ // child-by-child rather than clobbering the siblings already hoisted to destDir/node_modules.
194
+ for (const nested of fs_1.default.readdirSync(from)) {
195
+ fs_1.default.renameSync(path_1.default.join(from, nested), path_1.default.join(to, nested));
196
+ }
197
+ }
198
+ else {
199
+ fs_1.default.renameSync(from, to);
200
+ }
201
+ }
202
+ fs_1.default.rmSync(installedRoot, { recursive: true, force: true });
203
+ };
204
+ /**
205
+ * Return the entry path for `version`, materializing it first if it is not already a complete
206
+ * cache entry. Returns immediately (no npm invocation) when a complete entry already exists.
207
+ */
208
+ async function ensureMaterializedHubApp(version, options = {}) {
209
+ const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
210
+ const installPackage = options.installPackage ?? defaultInstallPackage;
211
+ const destDir = resolveHubAppCacheDir(version, fraimDir);
212
+ const entryPath = path_1.default.join(destDir, exports.HUB_APP_ENTRY_RELATIVE);
213
+ if (isHubAppCacheComplete(destDir, version)) {
214
+ return entryPath;
215
+ }
216
+ const stagingDir = `${destDir}.staging-${process.pid}`;
217
+ fs_1.default.mkdirSync(path_1.default.dirname(destDir), { recursive: true });
218
+ fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
219
+ try {
220
+ await installPackage(version, stagingDir);
221
+ // Written only on success, after the installer's own work, so a throw never leaves a staging
222
+ // tree that could be mistaken for complete.
223
+ fs_1.default.writeFileSync(path_1.default.join(stagingDir, 'version'), version, 'utf8');
224
+ if (!isHubAppCacheComplete(stagingDir, version)) {
225
+ throw new Error(`materialized ${exports.HUB_APP_PACKAGE_NAME}@${version} is missing ${exports.HUB_APP_ENTRY_RELATIVE}; refusing to promote an incomplete install`);
226
+ }
227
+ // A concurrent launch may have finished materializing the same version already; the winner
228
+ // keeps its copy rather than being clobbered by this one (mirrors electron-dist.ts).
229
+ if (!isHubAppCacheComplete(destDir, version)) {
230
+ fs_1.default.rmSync(destDir, { recursive: true, force: true });
231
+ try {
232
+ fs_1.default.renameSync(stagingDir, destDir);
233
+ }
234
+ catch (error) {
235
+ if (!isHubAppCacheComplete(destDir, version))
236
+ throw error;
237
+ }
238
+ }
239
+ }
240
+ finally {
241
+ fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
242
+ }
243
+ return entryPath;
244
+ }
245
+ /**
246
+ * Resolve which `desktop-main.js` to run: the latest published version, materializing it first if
247
+ * needed, or the bundled build when npm is unreachable, already current, or materialization fails
248
+ * for any reason. A materialization failure must never block a launch — it always falls back to
249
+ * the bundled copy rather than propagating.
250
+ */
251
+ async function resolveHubAppEntry(options) {
252
+ const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
253
+ const getLatest = options.getLatestPublishedVersion ?? hub_latest_version_1.getLatestPublishedVersion;
254
+ const cachedVersions = listCachedHubAppVersions(fraimDir);
255
+ let latestPublishedVersion;
256
+ try {
257
+ latestPublishedVersion = await getLatest();
258
+ }
259
+ catch {
260
+ latestPublishedVersion = null;
261
+ }
262
+ const plan = planHubAppMaterialization({
263
+ bundledVersion: options.bundledVersion,
264
+ latestPublishedVersion,
265
+ cachedVersions,
266
+ });
267
+ if (plan.action === 'use-bundled') {
268
+ return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version };
269
+ }
270
+ if (plan.action === 'use-cached') {
271
+ return {
272
+ entryPath: path_1.default.join(resolveHubAppCacheDir(plan.version, fraimDir), exports.HUB_APP_ENTRY_RELATIVE),
273
+ source: 'cached',
274
+ version: plan.version,
275
+ };
276
+ }
277
+ try {
278
+ options.onProgress?.(`Fetching FRAIM Hub ${plan.version}...`);
279
+ const entryPath = await ensureMaterializedHubApp(plan.version, { fraimDir, installPackage: options.installPackage });
280
+ return { entryPath, source: 'cached', version: plan.version };
281
+ }
282
+ catch {
283
+ return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion };
284
+ }
285
+ }
@@ -6332,14 +6332,10 @@ class AiHubServer {
6332
6332
  messages: persistedConversation ? persistedMessagesForRun(persistedConversation) : [],
6333
6333
  events: resumeEvents,
6334
6334
  eventLogRefs: persistedConversation?.eventLogRefs || [],
6335
- // Issue #1373: a conversation is permanently scoped to one job for its
6336
- // lifetime, including after that job's conversation is marked
6337
- // 'completed' a coaching turn sent to a Done job resumes the SAME
6338
- // job's phase history, it does not start unrelated new work. Always
6339
- // carry the persisted run projection forward regardless of the
6340
- // conversation's prior status. If the resumed session genuinely has
6341
- // nothing left to do, it simply reaches the same completed state
6342
- // again on its own the next time seekMentoring reports its phase.
6335
+ // A manager can issue new instructions after a job was marked done.
6336
+ // That correction stays in the same conversation/job identity and keeps
6337
+ // its phase history so the tracker can show the job moving back to an
6338
+ // earlier phase instead of pretending this is unrelated fresh work.
6343
6339
  currentPhase: persistedRun?.currentPhase || null,
6344
6340
  phaseHistory: persistedRun?.phaseHistory || [],
6345
6341
  phaseVisits: persistedRun?.phaseVisits || [],