fraim-hub 2.0.349 → 2.0.351

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.
@@ -213,6 +213,7 @@ function summarizeProject(projectPath) {
213
213
  path: '',
214
214
  exists: false,
215
215
  hasFraim: false,
216
+ isOnboarded: false,
216
217
  message: 'Choose a project path to load FRAIM jobs.',
217
218
  };
218
219
  }
@@ -221,17 +222,20 @@ function summarizeProject(projectPath) {
221
222
  path: projectPath,
222
223
  exists: false,
223
224
  hasFraim: false,
225
+ isOnboarded: (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath),
224
226
  message: 'Project path does not exist on this machine.',
225
227
  };
226
228
  }
227
229
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
228
230
  const hasFraim = fs_1.default.existsSync(fraimDir);
231
+ const isOnboarded = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath);
229
232
  if (!hasFraim) {
230
233
  return {
231
234
  path: projectPath,
232
235
  exists: true,
233
236
  hasFraim: false,
234
- needsOnboarding: true,
237
+ isOnboarded,
238
+ needsOnboarding: isOnboarded === false,
235
239
  message: 'This folder has not been set up with FRAIM yet. Onboarding will get your AI employee started.',
236
240
  };
237
241
  }
@@ -239,6 +243,8 @@ function summarizeProject(projectPath) {
239
243
  path: projectPath,
240
244
  exists: true,
241
245
  hasFraim: true,
246
+ isOnboarded,
247
+ needsOnboarding: isOnboarded === false,
242
248
  };
243
249
  }
244
250
  function resolveLayerRoot(projectPath, layer) {
@@ -16,7 +16,6 @@ const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
16
16
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
17
17
  const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
18
18
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
19
- const hub_app_materializer_1 = require("./hub-app-materializer");
20
19
  function isDesktopRuntimeComplete() {
21
20
  if (![(0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)(), (0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)(), (0, fraim_mcp_latest_launcher_1.getFraimNpxShimPath)(), (0, fraim_mcp_latest_launcher_1.getFraimMcpLatestLauncherPath)()]
22
21
  .every(file => fs_1.default.existsSync(file)))
@@ -68,19 +67,11 @@ async function repairInstallerIdeMcpEntries(options = {}) {
68
67
  process.env.FRAIM_NON_INTERACTIVE = previousNonInteractive;
69
68
  }
70
69
  }
71
- async function warmInstallerCliCache() {
72
- const cliShim = (0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)();
73
- const result = await (0, hub_app_materializer_1.runNpmInstall)(cliShim, ['--version']);
74
- if (result.status !== 0) {
75
- throw new Error(`FRAIM CLI bootstrap exited with code ${result.status ?? 'unknown'}.`);
76
- }
77
- }
78
70
  async function runDesktopRuntimeSetup(logger = console) {
79
71
  await (0, managed_node_runtime_1.ensureManagedNpm)({
80
72
  onProgress: (message) => logger.log(`[fraim-installer-setup] ${message}`),
81
73
  });
82
74
  (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
83
- await warmInstallerCliCache();
84
75
  }
85
76
  async function runDesktopInstallerSetup(logger = console) {
86
77
  await runDesktopRuntimeSetup(logger);
@@ -2398,36 +2398,63 @@ function detectCodexCompactTaskFailure(line) {
2398
2398
  }
2399
2399
  return null;
2400
2400
  }
2401
+ const CODEX_PERMANENT_REFRESH_FAILURES = new Set([
2402
+ 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.',
2403
+ 'Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.',
2404
+ 'Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
2405
+ 'Your access token could not be refreshed. Please log out and sign in again.',
2406
+ 'Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.',
2407
+ ]);
2408
+ // Exec has no typed auth code. Scope transport 401s to Codex's own OpenAI
2409
+ // Responses endpoints; tools, MCP servers and custom relays can also return 401.
2410
+ function isCodexProviderAuthFailure(message) {
2411
+ if (typeof message !== 'string')
2412
+ return false;
2413
+ if (message === 'workspace routing discovery unauthorized (401)')
2414
+ return true;
2415
+ const transport = /^unexpected status 401 Unauthorized: [\s\S]*?, url: ([^\s,]+)(?:, [\s\S]*)?$/.exec(message);
2416
+ if (!transport)
2417
+ return false;
2418
+ try {
2419
+ const endpoint = new URL(transport[1]);
2420
+ if (!['https:', 'wss:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.port || endpoint.hash)
2421
+ return false;
2422
+ const responsePath = endpoint.hostname === 'api.openai.com' ? '/v1/responses'
2423
+ : endpoint.hostname === 'chatgpt.com' ? '/backend-api/codex/responses' : null;
2424
+ return responsePath !== null && (endpoint.pathname === responsePath || endpoint.pathname === `${responsePath}/compact`);
2425
+ }
2426
+ catch {
2427
+ return false;
2428
+ }
2429
+ }
2401
2430
  function wireHostProcess(hostId, child, handlers, spawnCwd) {
2402
- // Exec's JSON error has no auth code. Refresh failures need corroborating
2403
- // stderr; workspace-routing 401 needs the exact terminal provider event.
2404
- // Both require a failed process exit; never scan tool/prose text.
2405
- const codexRefreshFailure = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.';
2406
- const codexWorkspaceRoutingFailure = 'workspace routing discovery unauthorized (401)';
2407
- let codexAuthDiagnostic = false;
2408
- let codexTerminalAuthFailure = false;
2409
- let codexTerminalWorkspaceAuthFailure = false;
2431
+ // Refresh failures need matching auth-manager stderr. Provider 401s need a
2432
+ // terminal turn.failed. Both require normal failed exit; never scan prose.
2433
+ const codexAuthDiagnostics = new Set();
2434
+ let codexTerminalRefreshFailure = null;
2435
+ let codexTerminalProviderFailure = null;
2410
2436
  const observeCodexAuth = (line, channel) => {
2411
2437
  if (hostId !== 'codex')
2412
2438
  return;
2413
2439
  if (channel === 'stderr') {
2414
2440
  const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
2415
2441
  const diagnostic = clean.match(/^(?:\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+)?ERROR\s+codex_login::auth::manager:\s+Failed to refresh token: (.+)$/);
2416
- if (diagnostic?.[1] === codexRefreshFailure)
2417
- codexAuthDiagnostic = true;
2442
+ if (diagnostic && CODEX_PERMANENT_REFRESH_FAILURES.has(diagnostic[1]))
2443
+ codexAuthDiagnostics.add(diagnostic[1]);
2418
2444
  return;
2419
2445
  }
2420
2446
  try {
2421
2447
  const event = JSON.parse(line);
2422
2448
  if (event.type === 'turn.failed') {
2423
- codexTerminalWorkspaceAuthFailure = event.error?.message === codexWorkspaceRoutingFailure;
2449
+ codexTerminalProviderFailure = isCodexProviderAuthFailure(event.error?.message) ? event.error.message : null;
2424
2450
  }
2425
2451
  if (event.type === 'turn.failed' || event.type === 'error') {
2426
- codexTerminalAuthFailure = (event.type === 'turn.failed' ? event.error?.message : event.message) === codexRefreshFailure;
2452
+ const message = event.type === 'turn.failed' ? event.error?.message : event.message;
2453
+ codexTerminalRefreshFailure = CODEX_PERMANENT_REFRESH_FAILURES.has(message) ? message : null;
2427
2454
  }
2428
2455
  else if (event.type === 'turn.started' || event.type === 'turn.completed') {
2429
- codexTerminalAuthFailure = false;
2430
- codexTerminalWorkspaceAuthFailure = false;
2456
+ codexTerminalRefreshFailure = null;
2457
+ codexTerminalProviderFailure = null;
2431
2458
  }
2432
2459
  }
2433
2460
  catch { /* Plain stdout cannot establish authentication failure. */ }
@@ -2481,8 +2508,8 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2481
2508
  // `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
2482
2509
  // non-zero branch fires and recovery is attempted.
2483
2510
  child.on('close', (code, signal) => {
2484
- if (!signal && code !== null && code !== 0 && ((codexAuthDiagnostic && codexTerminalAuthFailure) || codexTerminalWorkspaceAuthFailure)) {
2485
- const failure = codexTerminalWorkspaceAuthFailure ? codexWorkspaceRoutingFailure : codexRefreshFailure;
2511
+ const failure = codexTerminalProviderFailure || (codexTerminalRefreshFailure && codexAuthDiagnostics.has(codexTerminalRefreshFailure) ? codexTerminalRefreshFailure : null);
2512
+ if (!signal && code !== null && code !== 0 && failure) {
2486
2513
  handlers.onEvent({ hostAuthenticationRequired: true, hostError: failure, raw: failure }, 'system');
2487
2514
  }
2488
2515
  handlers.onExit(signal ? -1 : code);
@@ -383,20 +383,21 @@ function directoryExists(projectPath) {
383
383
  // project list. This deliberately never falls back to process.cwd() — the launch
384
384
  // directory must not masquerade as a project.
385
385
  //
386
- // Issue #1253: existence alone is not validity. Eligibility is a positive rule —
387
- // a recorded path qualifies only if it IS a FRAIM project. The temp/Home/Desktop/
388
- // Downloads filters narrow that rule further; they do not define it. Framing
389
- // eligibility as an exclusion list is what let C:\Windows stay pinned forever:
390
- // it was simply not on the list, and such a list is unbounded by construction.
386
+ // A recorded folder must be a discoverable FRAIM project or a deliberately added
387
+ // project list entry. The latter remains selectable before onboarding (#2039).
388
+ // Existing temporary/Home/Desktop/Downloads safeguards still apply; a stale bare
389
+ // recorded path is not enough to turn an arbitrary directory into a project.
391
390
  function resolveHubProjectPath(preferencesStore, options = {}) {
392
- const recorded = preferencesStore.load('').projectPath;
391
+ const preferences = preferencesStore.load('');
392
+ const recorded = preferences.projectPath;
393
393
  if (typeof recorded === 'string' && recorded.trim().length > 0) {
394
394
  const resolved = path_1.default.resolve(recorded);
395
395
  const allowedTemporary = options.allowTemporaryRelatedTo
396
396
  ? isRelatedTemporaryProjectPath(resolved, options.allowTemporaryRelatedTo)
397
397
  : false;
398
398
  if (!isLikelyTemporaryProjectPath(resolved) || allowedTemporary) {
399
- if (directoryExists(resolved) && (0, project_fraim_paths_1.workspaceFraimExists)(resolved))
399
+ const explicitlyAdded = preferences.projects?.some((project) => sameDirectoryPath(project.folderPath, resolved));
400
+ if (directoryExists(resolved) && ((0, project_fraim_paths_1.workspaceFraimExists)(resolved) || explicitlyAdded))
400
401
  return { projectPath: resolved };
401
402
  // A missing folder can be a moved project. Report it separately from a
402
403
  // non-FRAIM folder so the Hub can show the rehome path instead of leaving
@@ -419,10 +420,15 @@ function resolveHubProjectPath(preferencesStore, options = {}) {
419
420
  * handed to `preferencesStore.save()`.
420
421
  */
421
422
  function decorateProjectsWithFraimStatus(projects) {
422
- return projects.map((project) => ({
423
- ...project,
424
- hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
425
- }));
423
+ return projects.map((project) => {
424
+ const isOnboarded = project.folderPath ? (0, project_fraim_paths_1.workspaceOnboardingReady)(path_1.default.resolve(project.folderPath)) : false;
425
+ return {
426
+ ...project,
427
+ hasFraim: !!project.folderPath && (0, project_fraim_paths_1.workspaceFraimExists)(path_1.default.resolve(project.folderPath)),
428
+ isOnboarded,
429
+ needsOnboarding: isOnboarded === false,
430
+ };
431
+ });
426
432
  }
427
433
  class AiHubRunRegistry {
428
434
  constructor() {
@@ -2411,6 +2417,18 @@ function normalizedDirectoryPath(projectPath) {
2411
2417
  function sameDirectoryPath(left, right) {
2412
2418
  return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
2413
2419
  }
2420
+ function onboardingConversationIsActive(conversation) {
2421
+ if (conversation.status === 'running')
2422
+ return true;
2423
+ if (conversation.pauseReason === 'done' || conversation.reviewApproved === true)
2424
+ return false;
2425
+ const handoff = normalizeReviewHandoff(conversation.reviewHandoff);
2426
+ if (handoff)
2427
+ return handoff.reviewRequired;
2428
+ if (conversation.pauseReason === 'awaiting_review')
2429
+ return false;
2430
+ return conversation.status === 'completed' && (conversation.artifacts || []).some((artifact) => Boolean(artifact.path || artifact.url || (('where' in artifact) && artifact.where && artifact.name)));
2431
+ }
2414
2432
  function isPathInsideDirectory(candidatePath, parentPath) {
2415
2433
  const relative = path_1.default.relative(path_1.default.resolve(parentPath), path_1.default.resolve(candidatePath));
2416
2434
  return relative === '' || (!!relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative));
@@ -2480,8 +2498,9 @@ function resolveApiKey() {
2480
2498
  }
2481
2499
  // Read persisted Get-started step states from ~/.fraim/install-state.json
2482
2500
  // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
2483
- // missing key falls back to derivation. Persisted `true` is sticky so the rail
2484
- // never re-shows once a step is completed (R13.4).
2501
+ // missing key falls back to derivation. Persisted `true` is sticky for install,
2502
+ // company, and hire (R13.4). computeFirstRun ignores the legacy project flag:
2503
+ // project readiness is derived live from the selected project's config presence.
2485
2504
  function readPersistedFirstRun() {
2486
2505
  const out = {};
2487
2506
  const apply = (source) => {
@@ -3000,6 +3019,7 @@ class AiHubServer {
3000
3019
  constructor(options = {}) {
3001
3020
  this.app = (0, express_1.default)();
3002
3021
  this.runRegistry = new AiHubRunRegistry();
3022
+ this.projectSelectionGeneration = 0;
3003
3023
  this.activeAvailabilityWarnings = new Set();
3004
3024
  // Issue #925: the last identity successfully resolved from the hosted authority,
3005
3025
  // keyed by the exact apiKey it was resolved for. When the authority is transiently
@@ -3584,10 +3604,26 @@ class AiHubServer {
3584
3604
  this.conversationStore.moveProjectBucket(oldProjectPath, newProjectPath);
3585
3605
  }
3586
3606
  }
3587
- async bootstrapResponse(projectPath) {
3607
+ async bootstrapResponse(projectPath, explicitSelection = false) {
3588
3608
  // #866 R2: guard against path.resolve('') === cwd; '' means "no active project".
3589
3609
  const activeProjectPath = projectPath || this.projectPath;
3590
3610
  const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
3611
+ if (explicitSelection) {
3612
+ this.projectSelectionGeneration += 1;
3613
+ // Record the chosen directory before slow employee/persona reads. A
3614
+ // pathless refresh during that work must already resolve this selection.
3615
+ if (directoryExists(normalizedProjectPath)) {
3616
+ const selectedPreferences = this.preferencesStore.load('');
3617
+ this.preferencesStore.save({
3618
+ ...selectedPreferences,
3619
+ projectPath: normalizedProjectPath,
3620
+ projects: (0, preferences_1.normalizeAiHubProjectList)(selectedPreferences.projects || [], normalizedProjectPath, {
3621
+ removedProjectPaths: selectedPreferences.removedProjectPaths || [],
3622
+ }),
3623
+ });
3624
+ }
3625
+ }
3626
+ const selectionGeneration = this.projectSelectionGeneration;
3591
3627
  // Issue #1010: prefer the non-blocking parallel probe on the first-paint path. On a
3592
3628
  // cache miss the synchronous form would spawnSync per agent (~2.7s) and freeze the
3593
3629
  // event loop, making every concurrent request wait. Falls back to the sync form for
@@ -3616,7 +3652,8 @@ class AiHubServer {
3616
3652
  const firstAvailable = employees.find((e) => e.available);
3617
3653
  if (firstAvailable) {
3618
3654
  preferences = { ...preferences, employeeId: firstAvailable.id };
3619
- this.preferencesStore.save(preferences);
3655
+ if (selectionGeneration === this.projectSelectionGeneration)
3656
+ this.preferencesStore.save(preferences);
3620
3657
  }
3621
3658
  }
3622
3659
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
@@ -3682,7 +3719,8 @@ class AiHubServer {
3682
3719
  // shell is visible.
3683
3720
  const projects = this.knownProjects(normalizedProjectPath, [], { includeConversationProjects: false });
3684
3721
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
3685
- this.preferencesStore.save(preferences);
3722
+ if (selectionGeneration === this.projectSelectionGeneration)
3723
+ this.preferencesStore.save(preferences);
3686
3724
  // Issue #1253: `hasFraim` is decorated AFTER the save above, never before. The
3687
3725
  // line that persists `preferences` takes whatever list it is handed, so a
3688
3726
  // derived field attached upstream of it silently becomes persisted state. See
@@ -5174,8 +5212,7 @@ class AiHubServer {
5174
5212
  const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
5175
5213
  // hire = any persona hired (entitlement active).
5176
5214
  const hireDone = personas.some((p) => p.status === 'hired');
5177
- // project = a project brief exists OR the workspace has FRAIM jobs set up.
5178
- const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
5215
+ const projectDone = (0, project_fraim_paths_1.workspaceOnboardingReady)(projectPath) === true;
5179
5216
  const derived = {
5180
5217
  install: true, // the Hub is running, so the toolchain install step is satisfied.
5181
5218
  company: companyDone,
@@ -5187,7 +5224,7 @@ class AiHubServer {
5187
5224
  install: persisted.install ?? derived.install,
5188
5225
  company: persisted.company || derived.company,
5189
5226
  hire: persisted.hire || derived.hire,
5190
- project: persisted.project || derived.project,
5227
+ project: derived.project,
5191
5228
  };
5192
5229
  }
5193
5230
  // Issue #512 (S3, R3) — presence + display paths of the three-layer context
@@ -5722,7 +5759,7 @@ class AiHubServer {
5722
5759
  }
5723
5760
  // #719 / local Hub package: a bare reload must land on the user-level saved
5724
5761
  // Hub project, not whatever directory happened to launch the process.
5725
- res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath()));
5762
+ res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath(), !!projectPath));
5726
5763
  });
5727
5764
  this.app.get('/api/ai-hub/personas', async (req, res) => {
5728
5765
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
@@ -6540,7 +6577,7 @@ class AiHubServer {
6540
6577
  .filter((id) => id.length > 0));
6541
6578
  const retainedProjects = this.knownProjects(projectPath).filter((project) => !(project.id && incomingProjectIds.has(project.id)));
6542
6579
  const projects = this.preferencesStore.saveProjects(projectPath, [...retainedProjects, ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
6543
- return res.json({ projectPath, projects, source: 'disk' });
6580
+ return res.json({ projectPath, projects: decorateProjectsWithFraimStatus(projects), source: 'disk' });
6544
6581
  }
6545
6582
  catch (error) {
6546
6583
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
@@ -7160,6 +7197,35 @@ class AiHubServer {
7160
7197
  if (!jobId) {
7161
7198
  throw new Error('Choose a FRAIM job before starting a run.');
7162
7199
  }
7200
+ // A project has one active onboarding workflow, including review gates.
7201
+ // Disk records cover review after restart; optimistic records without a
7202
+ // run id cannot block their own first launch.
7203
+ if (jobId === 'project-onboarding' && scope === 'project') {
7204
+ const conversations = this.conversationStore.loadProject(projectPath).conversations;
7205
+ const liveRun = this.runRegistry.all().find((run) => {
7206
+ if (run.jobId !== jobId || run.scope === 'manager' || run.scope === 'company'
7207
+ || !sameDirectoryPath(run.projectPath, projectPath))
7208
+ return false;
7209
+ const stored = conversations.find((conversation) => conversation.id === (run.conversationId || run.id));
7210
+ return onboardingConversationIsActive({
7211
+ ...run,
7212
+ reviewApproved: stored?.reviewApproved,
7213
+ pauseReason: stored?.pauseReason === 'done' ? 'done' : run.pauseReason,
7214
+ });
7215
+ });
7216
+ const existing = conversations.find((conversation) => !!conversation.runId
7217
+ && conversation.jobId === jobId && conversation.scope !== 'manager' && conversation.scope !== 'company'
7218
+ && !!conversation.projectPath && sameDirectoryPath(conversation.projectPath, projectPath)
7219
+ && onboardingConversationIsActive(conversation));
7220
+ if (liveRun || existing) {
7221
+ return res.status(409).json({
7222
+ code: 'project_onboarding_active',
7223
+ error: 'Project onboarding is already in progress.',
7224
+ existingConversationId: liveRun ? (liveRun.conversationId || liveRun.id) : existing.id,
7225
+ existingRunId: liveRun?.id || existing?.runId,
7226
+ });
7227
+ }
7228
+ }
7163
7229
  // #1394: Dedup guard — return an already-running identical run rather than spawning a second
7164
7230
  // process. Key includes managerDisplay (messages[0].text) so runs for different issues are
7165
7231
  // never blocked even when they share the same jobId + hostId + projectPath.
@@ -232,9 +232,26 @@ function runPackage(plan, cacheDir) {
232
232
  const plan = resolvePackage();
233
233
  const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
234
234
  pruneCaches(versionCacheDir, Date.now());
235
+
236
+ /**
237
+ * True when npx materialized the package into this cache. A non-zero exit from a package
238
+ * that DID install is the CLI's own result, not a cache problem, and must be returned verbatim.
239
+ */
240
+ function packageMaterialized(cacheDir) {
241
+ try {
242
+ const npxDir = path.join(cacheDir, '_npx');
243
+ return fs.readdirSync(npxDir).some(function (entry) {
244
+ return fs.existsSync(path.join(npxDir, entry, 'node_modules', plan.packageName));
245
+ });
246
+ } catch (error) {
247
+ return false;
248
+ }
249
+ }
250
+
235
251
  let status = runPackage(plan, versionCacheDir);
236
252
 
237
- if (status !== 0 && process.platform === 'win32') {
253
+ // Retry ONLY when npx could not materialize the package: the corrupt-cache case this exists for.
254
+ if (status !== 0 && process.platform === 'win32' && !packageMaterialized(versionCacheDir)) {
238
255
  const retryCache = path.join(CACHE_ROOT, 'retry-' + Date.now() + '-' + process.pid);
239
256
  console.error('[fraim-mcp-launcher] Package execution failed; retrying once with a fresh npm cache.');
240
257
  status = runPackage(plan, retryCache);
@@ -70,6 +70,8 @@ const script_sync_utils_1 = require("../utils/script-sync-utils");
70
70
  const USER_LEVEL_RUNTIME_DEPS = {
71
71
  'node-edge-tts': '*', // used by scripts/author-audio.js
72
72
  'adm-zip': '^0.6.0', // used by scripts/communication/*.js (DOCX review layer) — matches root/packages/* manifests
73
+ 'fast-glob': '^3.3.3', // used by scripts/exec-with-timeout.ts (Issue #1839)
74
+ 'tree-kill': '^1.2.2', // used by scripts/exec-with-timeout.ts (Issue #1839)
73
75
  };
74
76
  /**
75
77
  * Ensure the user-level FRAIM directory structure exists.
@@ -7,6 +7,7 @@ exports.WORKSPACE_SYNCED_CONTENT_DIRS = exports.WORKSPACE_FRAIM_DIRNAME = void 0
7
7
  exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
8
8
  exports.workspaceFraimExists = workspaceFraimExists;
9
9
  exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
10
+ exports.workspaceOnboardingReady = workspaceOnboardingReady;
10
11
  exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
11
12
  exports.assertWorkspaceFraimRoot = assertWorkspaceFraimRoot;
12
13
  exports.assertWorkspaceFraimPath = assertWorkspaceFraimPath;
@@ -75,6 +76,19 @@ function workspaceFraimExists(projectRoot = process.cwd()) {
75
76
  function getWorkspaceConfigPath(projectRoot = process.cwd()) {
76
77
  return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), 'config.json');
77
78
  }
79
+ /** Config presence is readiness; unreadable filesystem state is not absence. */
80
+ function workspaceOnboardingReady(projectRoot) {
81
+ if (!projectRoot)
82
+ return false;
83
+ try {
84
+ (0, fs_1.statSync)(getWorkspaceConfigPath(projectRoot));
85
+ return true;
86
+ }
87
+ catch (error) {
88
+ const code = error.code;
89
+ return code === 'ENOENT' || code === 'ENOTDIR' ? false : null;
90
+ }
91
+ }
78
92
  function getWorkspaceFraimPath(projectRoot = process.cwd(), ...parts) {
79
93
  return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), ...parts);
80
94
  }