fraim-hub 2.0.301 → 2.0.302

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/README.md CHANGED
@@ -312,7 +312,7 @@ Use these defaults:
312
312
  - `feature-specification` when the request is still fuzzy or needs clarified requirements, UX, or acceptance criteria.
313
313
  - `technical-design` after the spec is approved and you need the implementation plan, file touchpoints, and risk handling.
314
314
  - `feature-implementation` for code changes, bug fixes, and documentation updates that should be executed and validated.
315
- - `test-execution` when you need reproduction coverage, missing tests, or stronger regression protection before implementation.
315
+ - `test-authoring` when you need reproduction coverage, missing tests, or stronger regression protection before implementation.
316
316
  - `browser-application-validation` or `ui-polish-validation` after user-facing UI changes or when the ask is explicitly browser validation.
317
317
  - `implementation-feature-review` when you need to verify the delivered behavior matches the feature spec.
318
318
  - `implementation-design-review` when you need to verify the code matches the approved technical design.
@@ -689,6 +689,13 @@ class AiHubConversationStore {
689
689
  const { headers } = this.loadIndex(this.bucketDir(key), key);
690
690
  return headers.map((h) => ({ ...h }));
691
691
  }
692
+ // Issue #1477: the active conversation id, from the same cheap index used by
693
+ // loadProjectHeaders, so a headers-only list response can still report it.
694
+ loadProjectActiveId(projectPath) {
695
+ this.ensureMigrated();
696
+ const key = normalizeConversationKey(projectPath);
697
+ return this.loadIndex(this.bucketDir(key), key).activeId;
698
+ }
692
699
  // Lazy body read: one full conversation.
693
700
  loadConversation(projectPath, conversationId) {
694
701
  this.ensureMigrated();
@@ -16,6 +16,7 @@ exports.__clearEmployeeDetectionMemoryCacheForTests = __clearEmployeeDetectionMe
16
16
  exports.__setEmployeeDetectionTtlForTests = __setEmployeeDetectionTtlForTests;
17
17
  exports.__getEmployeeProbeRoundsForTests = __getEmployeeProbeRoundsForTests;
18
18
  exports.__resetEmployeeProbeRoundsForTests = __resetEmployeeProbeRoundsForTests;
19
+ exports.isEmployeeDetectionRefreshing = isEmployeeDetectionRefreshing;
19
20
  exports.detectEmployeesAsync = detectEmployeesAsync;
20
21
  exports.detectEmployees = detectEmployees;
21
22
  exports.prepareCodexBrowserHome = prepareCodexBrowserHome;
@@ -895,10 +896,11 @@ function resolveHostInvocation(plan) {
895
896
  args: ['/d', '/s', '/c', [command, ...args].map(escapeWindowsArg).join(' ')],
896
897
  };
897
898
  }
899
+ // Issue #1256 (slice a, AC-A3): delegates to the shared recovery helper so this probe agrees
900
+ // with server.ts's hubCommandVersion() and fraim doctor's health check on the same PATH,
901
+ // instead of each independently deciding what "the agent's PATH" means.
898
902
  function buildAgentVersionProbePath(basePath) {
899
- const withoutManaged = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(basePath);
900
- const withoutProjectBins = (0, managed_agent_paths_1.stripProjectLocalNodeBinDirs)(withoutManaged);
901
- return (0, managed_agent_paths_1.appendBinDirsToPath)(withoutProjectBins, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
903
+ return (0, managed_agent_paths_1.buildRecoveredAgentPath)(basePath);
902
904
  }
903
905
  // Single source for the probe environment, shared by the sync and async probes so the two
904
906
  // cannot drift on how agent bin directories are put on PATH. Project-local npm shims are
@@ -918,7 +920,10 @@ const availableByVersionProbe = (command) => {
918
920
  });
919
921
  if (result.status !== 0 || result.error)
920
922
  return null;
921
- return (result.stdout || result.stderr || '').trim() || null;
923
+ // Issue #1256 (AC-D1/D2): only stdout counts as a version an agent that exits 0 while
924
+ // writing only to stderr (e.g. GitHub Copilot CLI's current failure mode) is broken, not
925
+ // "available with an odd version string".
926
+ return (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr);
922
927
  };
923
928
  // Issue #1010: the async counterpart of availableByVersionProbe. Same semantics (exit 0
924
929
  // from `<agent> --version` means the CLI is installed AND actually runs), but non-blocking
@@ -937,7 +942,11 @@ const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
937
942
  let settled = false;
938
943
  let timer;
939
944
  let child;
940
- const chunks = [];
945
+ // Issue #1256 (AC-D1/D2): kept as separate stdout/stderr buffers rather than one combined
946
+ // buffer, so a broken CLI that exits 0 while writing only to stderr cannot have its error
947
+ // text mistaken for a version string.
948
+ const stdoutChunks = [];
949
+ const stderrChunks = [];
941
950
  const finish = (value) => {
942
951
  if (settled)
943
952
  return;
@@ -951,8 +960,8 @@ const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
951
960
  env: versionProbeEnv(),
952
961
  stdio: ['ignore', 'pipe', 'pipe'],
953
962
  });
954
- child.stdout?.on('data', (chunk) => chunks.push(chunk));
955
- child.stderr?.on('data', (chunk) => chunks.push(chunk));
963
+ child.stdout?.on('data', (chunk) => stdoutChunks.push(chunk));
964
+ child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
956
965
  timer = setTimeout(() => {
957
966
  console.warn(`[ai-hub] agent version probe timed out after ${VERSION_PROBE_TIMEOUT_MS}ms: ${command}`);
958
967
  try {
@@ -965,8 +974,9 @@ const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
965
974
  timer.unref?.();
966
975
  child.on('error', () => finish(null));
967
976
  child.on('close', (code) => {
968
- const raw = Buffer.concat(chunks).toString('utf8').trim();
969
- finish(code === 0 ? (raw || null) : null);
977
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
978
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
979
+ finish(code === 0 ? (0, managed_agent_paths_1.versionFromProbeOutput)(stdout, stderr) : null);
970
980
  });
971
981
  }
972
982
  catch {
@@ -1158,6 +1168,16 @@ function buildEmployeeStatus(id, version) {
1158
1168
  supportsRaw: supportsDirectPath(id),
1159
1169
  };
1160
1170
  }
1171
+ /**
1172
+ * Issue #1256 (slice b, AC-B1): true when a background re-probe is currently in flight —
1173
+ * i.e. the most recent detectEmployees()/detectEmployeesAsync() answer may have been served
1174
+ * from a stale persisted snapshot rather than a fresh probe. bootstrapResponse reads this
1175
+ * right after detection so the client can be told to poll again shortly instead of latching
1176
+ * a one-shot answer.
1177
+ */
1178
+ function isEmployeeDetectionRefreshing() {
1179
+ return inFlightDetection !== null;
1180
+ }
1161
1181
  /**
1162
1182
  * Non-blocking employee detection. Probes every agent CONCURRENTLY, so the cost is the
1163
1183
  * slowest single probe rather than the sum of all of them, and the event loop stays free
@@ -2140,6 +2160,11 @@ class CliHostRuntime {
2140
2160
  detectEmployeesAsync() {
2141
2161
  return detectEmployeesAsync();
2142
2162
  }
2163
+ // Issue #1256 (slice b): lets bootstrapResponse tell the client whether the roster it just
2164
+ // served may be stale while a background re-probe lands.
2165
+ isEmployeeDetectionRefreshing() {
2166
+ return isEmployeeDetectionRefreshing();
2167
+ }
2143
2168
  startRun(hostId, projectPath, message, handlers, sessionId, launchContext) {
2144
2169
  // R11: start/startDirect mint sessions rather than resuming one, so they
2145
2170
  // stay outside the continuation queue entirely.
@@ -41,6 +41,8 @@ exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
41
41
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
42
42
  exports.installAgentAndRefreshDetection = installAgentAndRefreshDetection;
43
43
  exports.hubCommandVersion = hubCommandVersion;
44
+ exports.__setAgentInstallTimeoutMsForTests = __setAgentInstallTimeoutMsForTests;
45
+ exports.hubRunProcess = hubRunProcess;
44
46
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
45
47
  const express_1 = __importDefault(require("express"));
46
48
  const path_1 = __importDefault(require("path"));
@@ -56,6 +58,9 @@ const brand_store_1 = require("../core/brand-store");
56
58
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
57
59
  const catalog_1 = require("./catalog");
58
60
  const job_visualization_1 = require("../core/job-visualization");
61
+ // Issue #1491: reserved sentinel phase a recurring job parks at between
62
+ // scheduled checks (registry/delivery/recurring-checkpoint.md).
63
+ const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
59
64
  const custom_employees_1 = require("./custom-employees");
60
65
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
61
66
  const hosts_1 = require("./hosts");
@@ -281,6 +286,12 @@ const MACHINE_LEVEL_JOB_IDS = new Set([
281
286
  const DEFAULT_CONVERSATION_FLUSH_DELAY_MS = 2000;
282
287
  const HUB_RESTART_RECOVERY_LOCK_TIMEOUT_MS = 5000;
283
288
  const HUB_RESTART_RECOVERY_LOCK_STALE_MS = 10000;
289
+ // The complete catalog (fraim/ai-employee/jobs + the registry/ source fallback), used
290
+ // everywhere the Hub needs to know about every job it could ever offer or resolve —
291
+ // state.bootstrap.jobs, the firstRun job count, and resolveHubJob. A caller that omits
292
+ // includeRegistry only sees the synced fraim/ layer, which is empty for a project (like
293
+ // this repo) that runs against source registry/ content before it has been synced.
294
+ const FULL_JOB_CATALOG_OPTIONS = { includeRegistry: true };
284
295
  function conversationFlushDelayMs() {
285
296
  const configured = Number(process.env.FRAIM_HUB_CONVERSATION_FLUSH_MS);
286
297
  if (Number.isFinite(configured) && configured >= 0)
@@ -1674,6 +1685,12 @@ function applySeekMentoringSignal(run, signal) {
1674
1685
  applyReviewHandoffToRun(run, signal.reviewHandoff);
1675
1686
  verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, isCrossJob ? callJobName : null);
1676
1687
  }
1688
+ else if (!isCrossJob) {
1689
+ // Issue #1501: No reviewHandoff in this same-job call. Clear any prior reviewHandoff so
1690
+ // run.reviewHandoff always reflects the most recent seekMentoring intent,
1691
+ // not a stale flag from an earlier submit phase.
1692
+ run.reviewHandoff = null;
1693
+ }
1677
1694
  if (isCrossJob)
1678
1695
  return;
1679
1696
  // Issue #848: capture the issue number the call carries so a launched follow-on
@@ -1929,25 +1946,32 @@ function hubCommandVersion(command, extraBinDirs, basePath) {
1929
1946
  if (process.env.NODE_ENV === 'test' && process.env.FRAIM_TEST_HUB_COMMAND_VERSION_EMPTY === '1') {
1930
1947
  return null;
1931
1948
  }
1932
- const pathValue = extraBinDirs && extraBinDirs.length > 0
1949
+ const suppliedPath = extraBinDirs && extraBinDirs.length > 0
1933
1950
  ? (0, managed_agent_paths_1.appendBinDirsToPath)(basePath ?? process.env.PATH, extraBinDirs)
1934
1951
  : basePath;
1952
+ // Issue #1256 (AC-A3/AC-C3): route through the same shared recovery helper the version
1953
+ // probe (hosts.ts) and fraim doctor use, so both callers of this function — the install
1954
+ // route's existing-version check and check-agent — find a CLI installed outside FRAIM's
1955
+ // own managed directory (npm-global, ~/.local/bin, a vendor installer) exactly like the
1956
+ // probe does. A strict superset of whatever `suppliedPath`/`process.env.PATH` already had.
1957
+ const recoveredPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(suppliedPath ?? process.env.PATH);
1935
1958
  // Issue #1284/#1285 (Defect D, §0): resolve to a real absolute path — with
1936
1959
  // project-local node_modules/.bin stripped — instead of handing the bare
1937
1960
  // command name to cmd.exe's own PATH search, which does not exclude those
1938
1961
  // shims. Falls back to the bare command (today's behavior, resolves to
1939
1962
  // null below) when no system install is found on the constructed PATH.
1940
- const resolvedCommand = (0, command_resolution_1.getSystemCommandPath)(command, pathValue ?? process.env.PATH) || command;
1963
+ const resolvedCommand = (0, command_resolution_1.getSystemCommandPath)(command, recoveredPath) || command;
1941
1964
  const executable = process.platform === 'win32' ? 'cmd.exe' : resolvedCommand;
1942
1965
  const args = process.platform === 'win32'
1943
1966
  ? ['/d', '/s', '/c', `${(0, hosts_1.escapeWindowsArg)(resolvedCommand)} --version`]
1944
1967
  : ['--version'];
1945
- const env = pathValue === undefined ? undefined : { ...process.env, PATH: pathValue };
1946
- const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000, ...(env ? { env } : {}) });
1968
+ // Issue #1256: set both casings see the matching comment in resolveNpmGlobalBinDirs().
1969
+ const env = { ...process.env, PATH: recoveredPath, Path: recoveredPath };
1970
+ const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000, env });
1947
1971
  if (result.status !== 0 || result.error)
1948
1972
  return null;
1949
- const raw = (result.stdout || result.stderr || '').trim();
1950
- return raw || null;
1973
+ // Issue #1256 (AC-D1/D2): only stdout counts as a version — see versionFromProbeOutput.
1974
+ return (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr);
1951
1975
  }
1952
1976
  function hubRunSync(command, args, env) {
1953
1977
  const resolvedCommand = (0, command_resolution_1.getSystemCommandPath)(command, process.env.PATH) || command;
@@ -1974,6 +1998,22 @@ function hubRunSync(command, args, env) {
1974
1998
  ...(result.error ? { error: result.error } : {}),
1975
1999
  };
1976
2000
  }
2001
+ // Issue #1256 (slice c, AC-C1/AC-C4): the root cause of "Installing…" hanging forever.
2002
+ // hubRunProcess runs the actual `npm install -g <package>` behind the install-agent route,
2003
+ // and previously had NO bound — only `close` resolved the promise. A stalled npm (registry
2004
+ // timeout, an interactive auth prompt with nothing to answer it, a proxy stall) never closed,
2005
+ // so this promise never settled, the route handler's `await` never returned, and the client's
2006
+ // button stayed greyed at "Installing…" with no way back. 90s is generous for a real install
2007
+ // (typically seconds) while still bounding the failure the way AC-C1 requires.
2008
+ const DEFAULT_AGENT_INSTALL_TIMEOUT_MS = 90_000;
2009
+ let agentInstallTimeoutMs = DEFAULT_AGENT_INSTALL_TIMEOUT_MS;
2010
+ /** Test seam only. Mirrors __setEmployeeDetectionTtlForTests in hosts.ts — proves the bound
2011
+ * actually fires without a real 90s hang. Pass null to restore the production value. */
2012
+ function __setAgentInstallTimeoutMsForTests(ms) {
2013
+ agentInstallTimeoutMs = ms ?? DEFAULT_AGENT_INSTALL_TIMEOUT_MS;
2014
+ }
2015
+ // Exported for direct unit testing (issue #1256, AC-C1/AC-C4): asserting a process that never
2016
+ // closes still rejects within the bound, without needing a real 90s-hanging npm install.
1977
2017
  function hubRunProcess(command, args, env) {
1978
2018
  if (process.env.NODE_ENV === 'test' && command === 'npm' && process.env.FRAIM_TEST_HUB_NPM_ERROR) {
1979
2019
  return Promise.reject(new Error(process.env.FRAIM_TEST_HUB_NPM_ERROR));
@@ -1995,15 +2035,46 @@ function hubRunProcess(command, args, env) {
1995
2035
  });
1996
2036
  let stdout = '';
1997
2037
  let stderr = '';
2038
+ let settled = false;
2039
+ const timer = setTimeout(() => {
2040
+ if (settled)
2041
+ return;
2042
+ settled = true;
2043
+ // Issue #1256: tree-kill, not child.kill() — on win32 `child` is the cmd.exe wrapper
2044
+ // (see realCmd/realArgs above); killing only that leaves the real supervised process
2045
+ // (e.g. npm.exe, or a grandchild it spawns) running as an orphan, and — since cmd.exe
2046
+ // typically shares its stdout/stderr pipe handles with what it launches — can keep
2047
+ // those pipes open after the wrapper dies, which is exactly what stopped this promise's
2048
+ // own process from exiting cleanly during this phase's validation run. Same pattern as
2049
+ // HostProcessRegistry.stop() above.
2050
+ if (child.pid != null) {
2051
+ try {
2052
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
2053
+ }
2054
+ catch { /* already gone */ }
2055
+ }
2056
+ reject(new Error(`${command} ${args.join(' ')} did not complete within ${agentInstallTimeoutMs / 1000}s.`));
2057
+ }, agentInstallTimeoutMs);
2058
+ timer.unref?.();
1998
2059
  child.stdout.on('data', (d) => { stdout += d.toString(); });
1999
2060
  child.stderr.on('data', (d) => { stderr += d.toString(); });
2000
2061
  child.on('close', (code) => {
2062
+ if (settled)
2063
+ return;
2064
+ settled = true;
2065
+ clearTimeout(timer);
2001
2066
  if (code === 0)
2002
2067
  resolve({ stdout, stderr });
2003
2068
  else
2004
2069
  reject(new Error(stderr || `Process exited with code ${code}`));
2005
2070
  });
2006
- child.on('error', reject);
2071
+ child.on('error', (error) => {
2072
+ if (settled)
2073
+ return;
2074
+ settled = true;
2075
+ clearTimeout(timer);
2076
+ reject(error);
2077
+ });
2007
2078
  });
2008
2079
  }
2009
2080
  function hubOpenTerminal(command) {
@@ -2566,6 +2637,15 @@ function classifyExit(run, exitCode) {
2566
2637
  // real description, not the post-clear fallback.
2567
2638
  return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
2568
2639
  }
2640
+ // Issue #1491: a non-final scheduled/webhook check parks at the reserved
2641
+ // recurring-checkpoint sentinel by design (registry/delivery/recurring-checkpoint.md).
2642
+ // This is not a human-decision gate — the scheduler resumes it on its own. Gated on
2643
+ // sourceTrigger so a manager-initiated run that somehow lands on the sentinel phase
2644
+ // (not expected in normal operation) keeps today's conservative awaiting_user default.
2645
+ if (currentPhase === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION
2646
+ && (run.sourceTrigger === 'scheduled' || run.sourceTrigger === 'webhook')) {
2647
+ return { action: 'park', pauseReason: 'parked_recurring' };
2648
+ }
2569
2649
  return { action: 'park', pauseReason: 'awaiting_user' };
2570
2650
  }
2571
2651
  if (compactingActive) {
@@ -2932,6 +3012,18 @@ class AiHubServer {
2932
3012
  }
2933
3013
  });
2934
3014
  this.registerRoutes();
3015
+ // Issue #1477 R8: global error handler, registered after every route. Express's
3016
+ // default handler for an error that escapes a route (e.g. the RangeError a
3017
+ // JSON.stringify overflow used to throw from the conversations list route) renders
3018
+ // an HTML page with the stack trace. This returns structured JSON instead — the
3019
+ // stack still goes to the server log, never to the response.
3020
+ this.app.use((err, _req, res, next) => {
3021
+ const message = err instanceof Error ? err.message : String(err);
3022
+ console.error('[ai-hub] unhandled route error:', err instanceof Error ? err.stack : message);
3023
+ if (res.headersSent)
3024
+ return next(err);
3025
+ res.status(500).json({ error: message });
3026
+ });
2935
3027
  }
2936
3028
  getApp() {
2937
3029
  return this.app;
@@ -3192,8 +3284,7 @@ class AiHubServer {
3192
3284
  }
3193
3285
  }
3194
3286
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
3195
- const catalogOptions = { includeRegistry: true };
3196
- const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, catalogOptions);
3287
+ const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, FULL_JOB_CATALOG_OPTIONS);
3197
3288
  // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
3198
3289
  // (true for the fraim/personalized-employee layer). The Hub renders a plain
3199
3290
  // "Personalized" marking from that flag — no author/attribution is tracked.
@@ -3213,7 +3304,7 @@ class AiHubServer {
3213
3304
  ...job,
3214
3305
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id) ?? GENERIC_WORKER_PERSONA_KEY,
3215
3306
  }));
3216
- const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
3307
+ const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, FULL_JOB_CATALOG_OPTIONS);
3217
3308
  // Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
3218
3309
  // override, no ai-hub-state.json copy, no fallback chain.
3219
3310
  const resolvedApiKey = resolveApiKey();
@@ -3261,11 +3352,15 @@ class AiHubServer {
3261
3352
  // ~/.fraim/config.json, so public/ai-hub/script.js's existing
3262
3353
  // tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
3263
3354
  preferences: { ...preferences, apiKey: resolvedApiKey },
3264
- categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
3355
+ categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, FULL_JOB_CATALOG_OPTIONS),
3265
3356
  jobs,
3266
3357
  internalJobs,
3267
3358
  managerTemplates,
3268
3359
  employees,
3360
+ // Issue #1256 (slice b, AC-B1): true when `employees` above may be a stale persisted
3361
+ // snapshot while a background re-probe is in flight. The client uses this to schedule
3362
+ // a bounded follow-up refresh instead of trusting a one-shot bootstrap forever.
3363
+ employeesRefreshing: this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false,
3269
3364
  configuredAgents,
3270
3365
  personas,
3271
3366
  // Issue #1005: lets the client tell a resolved projection from the first-paint
@@ -4585,7 +4680,13 @@ class AiHubServer {
4585
4680
  resolveHubJob(projectPath, jobId) {
4586
4681
  if (!jobId || jobId === '__freeform__')
4587
4682
  return null;
4588
- const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
4683
+ // Issue #1477: include the source registry/ fallback, matching FULL_JOB_CATALOG_OPTIONS
4684
+ // used to build state.bootstrap.jobs (the list the UI's job pickers, including the
4685
+ // New Assignment modal, actually offer). Without it, a job that only ships from
4686
+ // registry/ (never synced into fraim/ai-employee/jobs, as on the FRAIM repo itself)
4687
+ // was selectable in the UI but unresolvable here — the exact gap that let
4688
+ // travel-fare-watch's title/persona silently fail to resolve at fire time.
4689
+ const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath, FULL_JOB_CATALOG_OPTIONS).find((job) => job.id === jobId);
4589
4690
  if (employeeJob) {
4590
4691
  return {
4591
4692
  id: employeeJob.id,
@@ -4594,7 +4695,7 @@ class AiHubServer {
4594
4695
  personaKey: employeeJob.requiredPersonaKey ?? getCustomPersonaForJob(projectPath, employeeJob.id) ?? getHubPersonaForJob(employeeJob.id),
4595
4696
  };
4596
4697
  }
4597
- const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
4698
+ const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath, FULL_JOB_CATALOG_OPTIONS).find((job) => job.id === jobId);
4598
4699
  if (managerTemplate) {
4599
4700
  return {
4600
4701
  id: managerTemplate.id,
@@ -4605,6 +4706,16 @@ class AiHubServer {
4605
4706
  }
4606
4707
  return null;
4607
4708
  }
4709
+ // Issue #1477 R1-R3: shared 400 message for a schedules jobId that doesn't resolve
4710
+ // to a catalog entry. Names the correct shape (catalog slug) and explicitly rules out
4711
+ // the per-run tracking UUID get_fraim_job prints, since that UUID was mistaken for
4712
+ // this field in the reported repro.
4713
+ invalidScheduleJobIdError(jobId) {
4714
+ return {
4715
+ error: `Unknown jobId "${String(jobId)}". jobId must be a catalog job slug (e.g. "travel-fare-watch") ` +
4716
+ 'from the Hub job catalog, not the per-run tracking UUID returned by get_fraim_job\'s "Job ID" field.',
4717
+ };
4718
+ }
4608
4719
  applySeekMentoringSignalToRun(run, signal) {
4609
4720
  // Issue #732: promote using the stable jobName slug, not the per-call UUID
4610
4721
  // jobId (resolveHubJob would never match a UUID, leaving a freeform run
@@ -5031,7 +5142,7 @@ class AiHubServer {
5031
5142
  // not to the directory the Hub launched from.
5032
5143
  const { personas, subscriptionActive, workspaceId, userKey, identityAuthoritative } = await this.computePersonas(apiKey, managerTeamPromise, projectPath);
5033
5144
  const managerTeam = await managerTeamPromise;
5034
- const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, { includeRegistry: true })
5145
+ const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, FULL_JOB_CATALOG_OPTIONS)
5035
5146
  .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
5036
5147
  .length;
5037
5148
  res.json({
@@ -5171,7 +5282,6 @@ class AiHubServer {
5171
5282
  // Issue #820: lazy read surfaces so the UI never has to parse the whole store.
5172
5283
  // ?conversationId=<id> -> one full body (lazy load on open).
5173
5284
  // ?headersOnly=1 -> newest-first headers, no bodies (fast list / first paint).
5174
- // (default) -> full project (backwards compatible).
5175
5285
  if (conversationId) {
5176
5286
  const conversation = this.conversationStore.loadConversation(key, conversationId);
5177
5287
  if (!conversation)
@@ -5184,12 +5294,28 @@ class AiHubServer {
5184
5294
  // Issue #1090: ownership is derived on read, never served from the frozen record.
5185
5295
  return res.json({ projectPath: key, scope: scope ?? 'project', conversation: withDerivedPersona(withFreshStages, key), source: 'disk' });
5186
5296
  }
5297
+ // Issue #1477 R6: a project-scoped list request (no conversationId) always serves
5298
+ // headers only now, never the full project body — the removed full-payload branch
5299
+ // could exceed V8's max string length in JSON.stringify once a project's
5300
+ // conversation store accumulates enough embedded message/event content, which
5301
+ // reached the client as an unhandled RangeError rendered as a raw HTML stack trace.
5302
+ // forcedHeadersOnly tells the caller this response is headers-only regardless of
5303
+ // whether it asked for headersOnly=1. Scope-based (manager/company) buckets are
5304
+ // unaffected and keep the headersOnly/full-body branches below.
5305
+ if (!scope) {
5306
+ const customOwners = buildCustomJobOwnerIndex(key);
5307
+ const headers = this.conversationStore.loadProjectHeaders(key)
5308
+ .map((header) => withDerivedPersona(header, key, customOwners));
5309
+ const activeId = this.conversationStore.loadProjectActiveId(key);
5310
+ return res.json({ projectPath: key, scope: 'project', activeId, headersOnly: true, forcedHeadersOnly: true, conversations: headers, source: 'disk' });
5311
+ }
5187
5312
  if (req.query.headersOnly === '1' || req.query.headersOnly === 'true') {
5188
5313
  // One disk walk for the whole list, not one per conversation.
5189
5314
  const customOwners = buildCustomJobOwnerIndex(key);
5190
5315
  const headers = this.conversationStore.loadProjectHeaders(key)
5191
5316
  .map((header) => withDerivedPersona(header, key, customOwners));
5192
- return res.json({ projectPath: key, scope: scope ?? 'project', headersOnly: true, conversations: headers, source: 'disk' });
5317
+ const activeId = this.conversationStore.loadProjectActiveId(key);
5318
+ return res.json({ projectPath: key, scope, activeId, headersOnly: true, conversations: headers, source: 'disk' });
5193
5319
  }
5194
5320
  const loaded = this.conversationStore.loadProject(key);
5195
5321
  const fullCustomOwners = buildCustomJobOwnerIndex(key);
@@ -5197,7 +5323,7 @@ class AiHubServer {
5197
5323
  ...loaded,
5198
5324
  conversations: (loaded.conversations || []).map((conversation) => withDerivedPersona(conversation, key, fullCustomOwners)),
5199
5325
  };
5200
- return res.json({ projectPath: key, scope: scope ?? 'project', ...derived, source: 'disk' });
5326
+ return res.json({ projectPath: key, scope, ...derived, source: 'disk' });
5201
5327
  });
5202
5328
  this.app.put('/api/ai-hub/conversations', (req, res) => {
5203
5329
  try {
@@ -6987,6 +7113,12 @@ class AiHubServer {
6987
7113
  catch (err) {
6988
7114
  return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid schedule request.' });
6989
7115
  }
7116
+ // Issue #1477 R1/R2: reject a jobId that doesn't resolve to a known catalog entry
7117
+ // (e.g. the per-run tracking UUID from get_fraim_job, mistaken for this field in
7118
+ // the reported repro) before the deployment is ever persisted.
7119
+ if (!this.resolveHubJob(resolvedProjectPath, jobId)) {
7120
+ return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7121
+ }
6990
7122
  const normalizedConversationId = typeof conversationId === 'string' && conversationId.trim()
6991
7123
  ? conversationId.trim()
6992
7124
  : undefined;
@@ -7084,6 +7216,11 @@ class AiHubServer {
7084
7216
  const existing = this.deploymentStore.load().find((d) => d.id === id && d.type === 'scheduled');
7085
7217
  if (!existing)
7086
7218
  return res.status(404).json({ error: 'Deployment not found.' });
7219
+ // Issue #1477 R3: same catalog validation as create, applied only when jobId is
7220
+ // actually part of this update.
7221
+ if (jobId !== undefined && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7222
+ return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7223
+ }
7087
7224
  const nextHostId = hostId !== undefined && validEmployees.includes(hostId) ? hostId : existing.hostId;
7088
7225
  const nextConfiguredAgentId = configuredAgentId !== undefined
7089
7226
  ? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
@@ -45,7 +45,8 @@ function probeVersion(commandPath) {
45
45
  const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000 });
46
46
  if (result.status !== 0 || result.error)
47
47
  return null;
48
- return (result.stdout || result.stderr || '').trim() || null;
48
+ // Issue #1256 (AC-D1/D2): only stdout counts as a version see versionFromProbeOutput.
49
+ return (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr);
49
50
  }
50
51
  catch {
51
52
  return null;
@@ -86,9 +87,32 @@ async function runAgentCliHealthCheck(cli) {
86
87
  const managedSearchPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
87
88
  const managedPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, managedSearchPath);
88
89
  if (!ambientPath && !managedPath) {
90
+ // Issue #1256 (AC-A3): before concluding the CLI is absent, try the same shared
91
+ // location-agnostic recovery the Hub's version probe and check-agent route use. This
92
+ // only changes the answer for a CLI this process's raw/managed PATH cannot see at all
93
+ // (e.g. doctor itself invoked from a PATH-truncated context) — it never overrides a
94
+ // drift verdict computed from a real ambient/managed hit above.
95
+ const recoveredSearchPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(process.env.PATH);
96
+ const recoveredPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, recoveredSearchPath);
97
+ if (!recoveredPath) {
98
+ return {
99
+ status: 'passed',
100
+ message: `${cli.label} is not installed; nothing to check.`,
101
+ };
102
+ }
103
+ const recoveredVersion = probeVersion(recoveredPath);
104
+ if (!recoveredVersion) {
105
+ return {
106
+ status: 'error',
107
+ message: `${cli.label} is installed at ${recoveredPath} but failed to run (\`${cli.command} --version\` produced no output).`,
108
+ suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
109
+ details: { recoveredPath },
110
+ };
111
+ }
89
112
  return {
90
113
  status: 'passed',
91
- message: `${cli.label} is not installed; nothing to check.`,
114
+ message: `${cli.label} is consistent (${recoveredVersion}).`,
115
+ details: { recoveredPath, recoveredVersion },
92
116
  };
93
117
  }
94
118
  const ambientVersion = ambientPath ? probeVersion(ambientPath) : null;
@@ -3,12 +3,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.expandPath = exports.findIDEByName = exports.getAllSupportedIDEs = exports.detectInstalledIDEs = exports.CLI_PROBE_CONFIGTYPES = exports.IDE_CONFIGS = void 0;
6
+ exports.expandPath = exports.findIDEByName = exports.getAllSupportedIDEs = exports.detectInstalledIDEs = exports.CLI_PROBE_CONFIGTYPES = exports.IDE_CONFIGS = exports.detectCodexDesktop = exports.CODEX_DESKTOP_DOWNLOAD_URL = exports.CODEX_DESKTOP_BUNDLE_ID = void 0;
7
7
  exports.getAdapterConfigType = getAdapterConfigType;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const os_1 = __importDefault(require("os"));
11
11
  const child_process_1 = require("child_process");
12
+ const managed_agent_paths_1 = require("../utils/managed-agent-paths");
12
13
  /** Returns the configType to use for adapter file filtering. Falls back to configType when adapterConfigType is not set. */
13
14
  function getAdapterConfigType(ide) {
14
15
  return ide.adapterConfigType ?? ide.configType;
@@ -78,10 +79,12 @@ const guiAppDetect = (configSurfaceCheck, appName, options = {}) => {
78
79
  const availableByVersionProbe = (command) => {
79
80
  if (process.env.FRAIM_DETECT_DISABLE_CLI_PROBES === '1')
80
81
  return false;
82
+ const recoveredPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(process.env.PATH ?? process.env.Path);
83
+ const env = { ...process.env, PATH: recoveredPath, Path: recoveredPath };
81
84
  const result = process.platform === 'win32'
82
- ? (0, child_process_1.spawnSync)('cmd.exe', ['/d', '/s', '/c', `${command} --version`], { encoding: 'utf8', timeout: 1500 })
83
- : (0, child_process_1.spawnSync)(command, ['--version'], { encoding: 'utf8', timeout: 1500 });
84
- return result.status === 0;
85
+ ? (0, child_process_1.spawnSync)('cmd.exe', ['/d', '/s', '/c', `${command} --version`], { encoding: 'utf8', timeout: 1500, env })
86
+ : (0, child_process_1.spawnSync)(command, ['--version'], { encoding: 'utf8', timeout: 1500, env });
87
+ return result.status === 0 && !result.error && (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr) !== null;
85
88
  };
86
89
  const detectClaude = () => {
87
90
  const paths = [
@@ -151,6 +154,73 @@ const detectCodexSurface = () => {
151
154
  ];
152
155
  return checkMultiplePaths(paths);
153
156
  };
157
+ // Issue #1478: OpenAI folded the standalone Codex desktop app into the unified
158
+ // ChatGPT desktop app (Codex is now a view inside it). That app has no CLI
159
+ // config folder to detect and, unlike Claude Desktop, no local MCP config
160
+ // file for FRAIM to bootstrap — so it is intentionally NOT an IDE_CONFIGS
161
+ // entry. Adding it there would flow into ~13 other detectInstalledIDEs()
162
+ // consumers (auto-mcp-setup, doctor checks, add-ide, sync, init-project) that
163
+ // all assume every catalog entry is MCP-configurable, producing a false
164
+ // "Unsupported config type" failure for a product that was never meant to
165
+ // have one. Detection here is standalone; callers splice its result directly
166
+ // into the surfaces/rows that need it (see session-service.ts).
167
+ //
168
+ // Detection matches a stable package/bundle identity, not a display name or
169
+ // guessed path — openai/codex#32631 documents the Codex CLI's own equivalent
170
+ // bug (searching for the old name `Codex.app`, broken by the rename to
171
+ // `ChatGPT.app`) as the live precedent for why name/path matching fails.
172
+ exports.CODEX_DESKTOP_BUNDLE_ID = 'com.openai.codex';
173
+ // detectCodexDesktop() runs on every platform, so this row renders for
174
+ // macOS/Windows/Linux users alike — a platform-specific doc page (e.g. the
175
+ // Windows-only install guide) would be wrong for everyone else. Use OpenAI's
176
+ // own universal download page, matching every sibling IDE_CONFIGS entry's
177
+ // convention of linking the vendor's own top-level product/download page,
178
+ // not a platform subpage (e.g. `claude.ai/download`, `cursor.com`).
179
+ exports.CODEX_DESKTOP_DOWNLOAD_URL = 'https://chatgpt.com/download/';
180
+ const detectCodexDesktopMac = () => {
181
+ if (process.env.FRAIM_DETECT_DISABLE_PROCESS_CHECK === '1')
182
+ return false;
183
+ const result = (0, child_process_1.spawnSync)('mdfind', [`kMDItemCFBundleIdentifier == '${exports.CODEX_DESKTOP_BUNDLE_ID}'`], { encoding: 'utf8', timeout: 2000 });
184
+ return result.status === 0 && Boolean((result.stdout || '').trim());
185
+ };
186
+ const detectCodexDesktopWindows = () => {
187
+ if (process.env.FRAIM_DETECT_DISABLE_PROCESS_CHECK === '1')
188
+ return false;
189
+ // The exact PackageFamilyName for the ChatGPT desktop app's MSIX package
190
+ // (Store ProductId 9PLM9XGG6VKS) is not yet confirmed against a real install
191
+ // (spec Open Question #2) — Get-AppxPackage's own `Name` property (a fixed
192
+ // publisher-assigned identifier, distinct from the mutable Start-menu
193
+ // display name) is queried by a name pattern as the best-available proxy.
194
+ const psCmd = `(Get-AppxPackage | Where-Object { $_.Name -like '*ChatGPT*' } | Select-Object -First 1).PackageFamilyName`;
195
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', timeout: 3000 });
196
+ return result.status === 0 && Boolean((result.stdout || '').trim());
197
+ };
198
+ const detectCodexDesktopOverride = () => {
199
+ const value = process.env.FRAIM_DETECT_CODEX_DESKTOP_OVERRIDE;
200
+ if (value === '1')
201
+ return true;
202
+ if (value === '0')
203
+ return false;
204
+ return null;
205
+ };
206
+ /**
207
+ * Issue #1478 R1: detects the Codex view inside OpenAI's ChatGPT desktop app,
208
+ * independent of IDE_CONFIGS (see comment above). Linux is not yet
209
+ * implemented — the app is a public preview (Aug 2026) with no confirmed
210
+ * package identity (spec Open Question #1); this is a deliberate, documented
211
+ * scope boundary, not an oversight.
212
+ */
213
+ const detectCodexDesktop = () => {
214
+ const override = detectCodexDesktopOverride();
215
+ if (override !== null)
216
+ return override;
217
+ if (process.platform === 'darwin')
218
+ return detectCodexDesktopMac();
219
+ if (process.platform === 'win32')
220
+ return detectCodexDesktopWindows();
221
+ return false;
222
+ };
223
+ exports.detectCodexDesktop = detectCodexDesktop;
154
224
  const detectGrokSurface = () => {
155
225
  const paths = [
156
226
  '~/.grok',