fraim-hub 2.0.272 → 2.0.274

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.
@@ -28,6 +28,7 @@ exports.parseHostLine = parseHostLine;
28
28
  exports.findGeminiSessionIdForPrompt = findGeminiSessionIdForPrompt;
29
29
  const crypto_1 = require("crypto");
30
30
  const child_process_1 = require("child_process");
31
+ const tree_kill_1 = __importDefault(require("tree-kill"));
31
32
  const fs_1 = __importDefault(require("fs"));
32
33
  const os_1 = __importDefault(require("os"));
33
34
  const path_1 = __importDefault(require("path"));
@@ -743,20 +744,40 @@ function resolveHostInvocation(plan) {
743
744
  args: ['/d', '/s', '/c', [command, ...args.map(escapeWindowsArg)].join(' ')],
744
745
  };
745
746
  }
747
+ function stripProjectLocalNodeBinDirs(basePath) {
748
+ return (basePath ?? '')
749
+ .split(path_1.default.delimiter)
750
+ .filter(Boolean)
751
+ .filter((entry) => {
752
+ const normalized = path_1.default.normalize(entry).toLowerCase();
753
+ return !normalized.endsWith(`${path_1.default.sep}node_modules${path_1.default.sep}.bin`);
754
+ })
755
+ .join(path_1.default.delimiter);
756
+ }
757
+ function buildAgentVersionProbePath(basePath) {
758
+ const withoutManaged = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(basePath);
759
+ const withoutProjectBins = stripProjectLocalNodeBinDirs(withoutManaged);
760
+ return (0, managed_agent_paths_1.appendBinDirsToPath)(withoutProjectBins, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
761
+ }
746
762
  // Single source for the probe environment, shared by the sync and async probes so the two
747
- // cannot drift on how the managed-agent bin directories are put on PATH.
763
+ // cannot drift on how agent bin directories are put on PATH. Project-local npm shims are
764
+ // intentionally excluded: the Hub is checking installed user agent CLIs, not devDependency
765
+ // shims from whichever project happened to launch it.
748
766
  const versionProbeEnv = () => ({
749
767
  ...process.env,
750
- PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
751
- Path: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
768
+ PATH: buildAgentVersionProbePath(process.env.PATH ?? process.env.Path),
769
+ Path: buildAgentVersionProbePath(process.env.PATH ?? process.env.Path),
752
770
  });
771
+ // Returns the version string on success, or null when the CLI is absent.
753
772
  const availableByVersionProbe = (command) => {
754
773
  const invocation = resolveHostInvocation({ command, args: ['--version'] });
755
774
  const result = (0, child_process_1.spawnSync)(invocation.command, invocation.args, {
756
775
  encoding: 'utf8',
757
776
  env: versionProbeEnv(),
758
777
  });
759
- return result.status === 0;
778
+ if (result.status !== 0 || result.error)
779
+ return null;
780
+ return (result.stdout || result.stderr || '').trim() || null;
760
781
  };
761
782
  // Issue #1010: the async counterpart of availableByVersionProbe. Same semantics (exit 0
762
783
  // from `<agent> --version` means the CLI is installed AND actually runs), but non-blocking
@@ -769,11 +790,13 @@ const availableByVersionProbe = (command) => {
769
790
  // print its version within 10s is reported unavailable rather than allowed to hang the Hub.
770
791
  // It is recoverable from the UI via the existing per-agent "Check" action.
771
792
  const VERSION_PROBE_TIMEOUT_MS = 10_000;
793
+ // Returns the version string on success, or null when the CLI is absent.
772
794
  const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
773
795
  const invocation = resolveHostInvocation({ command, args: ['--version'] });
774
796
  let settled = false;
775
797
  let timer;
776
798
  let child;
799
+ const chunks = [];
777
800
  const finish = (value) => {
778
801
  if (settled)
779
802
  return;
@@ -785,23 +808,28 @@ const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
785
808
  try {
786
809
  child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
787
810
  env: versionProbeEnv(),
788
- stdio: 'ignore',
811
+ stdio: ['ignore', 'pipe', 'pipe'],
789
812
  });
813
+ child.stdout?.on('data', (chunk) => chunks.push(chunk));
814
+ child.stderr?.on('data', (chunk) => chunks.push(chunk));
790
815
  timer = setTimeout(() => {
791
816
  console.warn(`[ai-hub] agent version probe timed out after ${VERSION_PROBE_TIMEOUT_MS}ms: ${command}`);
792
817
  try {
793
818
  child?.kill();
794
819
  }
795
820
  catch { /* already gone */ }
796
- finish(false);
821
+ finish(null);
797
822
  }, VERSION_PROBE_TIMEOUT_MS);
798
823
  // Do not hold the process open just for a probe.
799
824
  timer.unref?.();
800
- child.on('error', () => finish(false));
801
- child.on('close', (code) => finish(code === 0));
825
+ child.on('error', () => finish(null));
826
+ child.on('close', (code) => {
827
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
828
+ finish(code === 0 ? (raw || null) : null);
829
+ });
802
830
  }
803
831
  catch {
804
- finish(false);
832
+ finish(null);
805
833
  }
806
834
  });
807
835
  // ─── Issue #1010: employee-detection cache ───────────────────────────────────
@@ -978,11 +1006,13 @@ function __getEmployeeProbeRoundsForTests() {
978
1006
  function __resetEmployeeProbeRoundsForTests() {
979
1007
  employeeProbeRounds = 0;
980
1008
  }
981
- function buildEmployeeStatus(id, available) {
1009
+ function buildEmployeeStatus(id, version) {
1010
+ const available = version !== null;
982
1011
  return {
983
1012
  id,
984
1013
  label: EMPLOYEE_LABELS[id],
985
1014
  available,
1015
+ version,
986
1016
  detail: available ? 'Installed and ready on this machine.' : 'CLI not detected on this machine.',
987
1017
  supportsRaw: supportsDirectPath(id),
988
1018
  };
@@ -1532,6 +1562,22 @@ function parseHostLine(hostId, line) {
1532
1562
  if (parsed.type === 'turn_context' && typeof parsed.payload?.model === 'string') {
1533
1563
  return withSignal({ raw: trimmed, agentIdentity: { agentName: 'codex', agentModel: parsed.payload.model } });
1534
1564
  }
1565
+ // Issue #1221: Codex reports stream failures (session-model mismatch
1566
+ // notices, reconnect/rate-limit retries, and the terminal turn.failed)
1567
+ // as `type: "error"` / `type: "item.completed" item.type: "error"` /
1568
+ // `type: "turn.failed"`. These previously fell through to raw JSON,
1569
+ // which the conversation projector drops as opaque payload — so a
1570
+ // debilitating failure (e.g. an Azure rate limit) never reached the
1571
+ // manager. Surface the message text as hostError instead.
1572
+ if (parsed.type === 'error' && typeof parsed.message === 'string') {
1573
+ return withSignal({ hostError: parsed.message, raw: trimmed });
1574
+ }
1575
+ if (parsed.type === 'item.completed' && parsed.item?.type === 'error' && typeof parsed.item.message === 'string') {
1576
+ return withSignal({ hostError: parsed.item.message, raw: trimmed });
1577
+ }
1578
+ if (parsed.type === 'turn.failed' && typeof parsed.error?.message === 'string') {
1579
+ return withSignal({ hostError: parsed.error.message, raw: trimmed });
1580
+ }
1535
1581
  return withSignal({ raw: trimmed });
1536
1582
  }
1537
1583
  catch {
@@ -1595,6 +1641,40 @@ function parseHostLine(hostId, line) {
1595
1641
  }
1596
1642
  try {
1597
1643
  const parsed = JSON.parse(trimmed);
1644
+ // Issue #1234: a task appearing in `background_tasks_changed.tasks` is a
1645
+ // genuinely backgrounded (still-running) command. `task_started` /
1646
+ // `task_notification` fire for every Bash call, sync or backgrounded, so
1647
+ // they are deliberately NOT parsed here — only this event and
1648
+ // `task_updated` distinguish real background-task state.
1649
+ if (parsed.type === 'system' && parsed.subtype === 'background_tasks_changed' && Array.isArray(parsed.tasks)) {
1650
+ return withSignal({
1651
+ sessionId: parsed.session_id,
1652
+ raw: trimmed,
1653
+ hostLifecycle: {
1654
+ status: 'background_tasks_changed',
1655
+ source: hostId,
1656
+ backgroundTasks: parsed.tasks
1657
+ .filter((t) => typeof t.task_id === 'string')
1658
+ .map((t) => ({ taskId: t.task_id, description: t.description })),
1659
+ },
1660
+ });
1661
+ }
1662
+ // Issue #1234: the authoritative terminal-status update for a tracked
1663
+ // background task. `patch.status` is `"completed"` or `"killed"` in the
1664
+ // observed schema; treat anything else as `"killed"` conservatively.
1665
+ if (parsed.type === 'system' && parsed.subtype === 'task_updated' && typeof parsed.task_id === 'string') {
1666
+ const backgroundTaskStatus = parsed.patch?.status === 'completed' ? 'completed' : 'killed';
1667
+ return withSignal({
1668
+ sessionId: parsed.session_id,
1669
+ raw: trimmed,
1670
+ hostLifecycle: {
1671
+ status: 'background_task_updated',
1672
+ source: hostId,
1673
+ backgroundTaskId: parsed.task_id,
1674
+ backgroundTaskStatus,
1675
+ },
1676
+ });
1677
+ }
1598
1678
  if (parsed.type === 'system' && parsed.session_id) {
1599
1679
  return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1600
1680
  }
@@ -1776,6 +1856,16 @@ function normalizeGeminiPromptForMatch(value) {
1776
1856
  return String(value || '').replace(/\s+/g, ' ').trim();
1777
1857
  }
1778
1858
  class CliHostRuntime {
1859
+ constructor(spawn = spawnHostProcess, killTree = (pid, signal) => (0, tree_kill_1.default)(pid, signal)) {
1860
+ this.spawn = spawn;
1861
+ this.killTree = killTree;
1862
+ // Issue #1176: active continuation runs keyed by `${hostId}::${sessionId}` (R1,
1863
+ // R13, R14). Lives on this long-lived singleton instance, in-memory only (R15).
1864
+ // `spawn`/`killTree` are constructor-injectable so tests can prove queueing,
1865
+ // batching, and course-correction deterministically without launching a real
1866
+ // host CLI or touching a real OS process (R20).
1867
+ this.activeContinueRuns = new Map();
1868
+ }
1779
1869
  detectEmployees() {
1780
1870
  return detectEmployees();
1781
1871
  }
@@ -1784,16 +1874,91 @@ class CliHostRuntime {
1784
1874
  return detectEmployeesAsync();
1785
1875
  }
1786
1876
  startRun(hostId, projectPath, message, handlers, sessionId, launchContext) {
1787
- return spawnHostProcess(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
1877
+ // R11: start/startDirect mint sessions rather than resuming one, so they
1878
+ // stay outside the continuation queue entirely.
1879
+ return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
1788
1880
  }
1789
- continueRun(hostId, projectPath, sessionId, message, handlers, launchContext) {
1790
- return spawnHostProcess(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildContinuePlan(hostId, sessionId, message), launchContext?.agent, launchContext?.env), projectPath, handlers);
1881
+ continueRun(hostId, projectPath, sessionId, message, handlers, launchContext, deliveryIntent) {
1882
+ return this.guardedContinue(hostId, sessionId, { projectPath, message, handlers, launchContext, deliveryIntent });
1791
1883
  }
1792
1884
  startDirectRun(hostId, message, projectPath, handlers, sessionId, launchContext) {
1793
- return spawnHostProcess(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
1885
+ return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
1886
+ }
1887
+ continueDirectRun(hostId, sessionId, message, projectPath, handlers, launchContext, deliveryIntent) {
1888
+ // R10: continueDirectRun is protected by the same per-hostId::sessionId
1889
+ // serialization as continueRun (same map, same key shape).
1890
+ return this.guardedContinue(hostId, sessionId, { projectPath, message, handlers, launchContext, deliveryIntent, direct: true });
1891
+ }
1892
+ // Issue #1176 (R34/R35/AC22): 1-based position the NEXT continuation would
1893
+ // take if queued right now, or null if it would spawn immediately.
1894
+ getQueuePosition(hostId, sessionId) {
1895
+ const active = this.activeContinueRuns.get(`${hostId}::${sessionId}`);
1896
+ if (!active)
1897
+ return null;
1898
+ return active.pending.length + 1;
1794
1899
  }
1795
- continueDirectRun(hostId, sessionId, message, projectPath, handlers, launchContext) {
1796
- return spawnHostProcess(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectContinuePlan(hostId, sessionId, message), launchContext?.agent, launchContext?.env), projectPath, handlers);
1900
+ guardedContinue(hostId, sessionId, entry) {
1901
+ const key = `${hostId}::${sessionId}`;
1902
+ const active = this.activeContinueRuns.get(key);
1903
+ if (!active) {
1904
+ return this.spawnAndRegister(key, hostId, sessionId, entry);
1905
+ }
1906
+ // R3/R31: an ordinary follow-up queues behind existing work; a course
1907
+ // correction (deliveryIntent 'stop') jumps to the front of the queue and
1908
+ // stops the active child so the correction is what runs next.
1909
+ if (entry.deliveryIntent === 'stop') {
1910
+ active.pending.unshift(entry);
1911
+ if (active.child.pid != null) {
1912
+ try {
1913
+ this.killTree(active.child.pid, 'SIGTERM');
1914
+ }
1915
+ catch {
1916
+ // Best-effort: `close` still fires on normal process exit either way.
1917
+ }
1918
+ }
1919
+ }
1920
+ else {
1921
+ active.pending.push(entry);
1922
+ }
1923
+ // R17: the caller receives the ALREADY-active child, not a process of its
1924
+ // own — callers must not treat this return value as proof the queued
1925
+ // message has its own process; event routing for the queued message goes
1926
+ // through `entry.handlers` once it is actually dequeued and spawned.
1927
+ return active.child;
1928
+ }
1929
+ spawnAndRegister(key, hostId, sessionId, entry) {
1930
+ const plan = (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(entry.direct ? buildDirectContinuePlan(hostId, sessionId, entry.message) : buildContinuePlan(hostId, sessionId, entry.message), entry.launchContext?.agent, entry.launchContext?.env);
1931
+ const child = this.spawn(hostId, plan, entry.projectPath, entry.handlers);
1932
+ const runEntry = { child, pending: [] };
1933
+ this.activeContinueRuns.set(key, runEntry);
1934
+ child.once('close', () => this.dequeueNext(key, hostId, sessionId, runEntry));
1935
+ return child;
1936
+ }
1937
+ // R4-R6, R9, R16 (batch-delivery per issue #1176 Round 2 design feedback):
1938
+ // when the active child closes, drain every pending continuation at once
1939
+ // (FIFO), join their messages into a single combined turn, and spawn exactly
1940
+ // one continuation for the whole batch. A course-correction entry (if any)
1941
+ // leads the combined message (R31); any "after" messages queued before or
1942
+ // during the redirect follow it, preserved rather than discarded (R32). The
1943
+ // batch uses the LAST entry's projectPath/handlers/launchContext unless a
1944
+ // 'stop' entry is present, in which case that correction's context wins —
1945
+ // it is the manager's most recent, most urgent instruction.
1946
+ dequeueNext(key, hostId, sessionId, runEntry) {
1947
+ this.activeContinueRuns.delete(key);
1948
+ const batch = runEntry.pending.splice(0);
1949
+ if (!batch.length)
1950
+ return;
1951
+ const combinedMessage = batch.map((e) => e.message).join('\n\n');
1952
+ const primary = batch.find((e) => e.deliveryIntent === 'stop') ?? batch[batch.length - 1];
1953
+ try {
1954
+ this.guardedContinue(hostId, sessionId, { ...primary, message: combinedMessage });
1955
+ }
1956
+ catch {
1957
+ // R19: a synchronous spawn failure must not leave the queue permanently
1958
+ // stuck behind this key — clear it so the next continueRun call spawns
1959
+ // fresh instead of queueing behind a dead entry.
1960
+ this.activeContinueRuns.delete(key);
1961
+ }
1797
1962
  }
1798
1963
  }
1799
1964
  exports.CliHostRuntime = CliHostRuntime;
@@ -1801,11 +1966,11 @@ class FakeHostRuntime {
1801
1966
  constructor() {
1802
1967
  this.isTestDouble = true;
1803
1968
  this.employees = [
1804
- { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
1805
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
1806
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
1807
- { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
1808
- { id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Test double agent tool.', supportsRaw: false },
1969
+ { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true, version: '1.0.0-test' },
1970
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true, version: '1.0.0-test' },
1971
+ { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true, version: '1.0.0-test' },
1972
+ { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true, version: '1.0.0-test' },
1973
+ { id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Test double agent tool.', supportsRaw: false, version: '1.0.0-test' },
1809
1974
  ];
1810
1975
  // Remembered across turns like a resumed agent session: the job label from the
1811
1976
  // start turn. Issue #732 — a same-job continue no longer carries a /fraim <job>
@@ -1893,11 +2058,11 @@ class ScriptedHostRuntime {
1893
2058
  constructor() {
1894
2059
  this.isTestDouble = true;
1895
2060
  this.employees = [
1896
- { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
1897
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
1898
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1899
- { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1900
- { id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Scripted test double.', supportsRaw: false },
2061
+ { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true, version: '1.0.0-test' },
2062
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true, version: '1.0.0-test' },
2063
+ { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true, version: '1.0.0-test' },
2064
+ { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true, version: '1.0.0-test' },
2065
+ { id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Scripted test double.', supportsRaw: false, version: '1.0.0-test' },
1901
2066
  ];
1902
2067
  // Track each active run so the test can emit signals at it. The Hub
1903
2068
  // passes the run id as the requested start session id in test/demo paths;
@@ -2105,11 +2270,12 @@ class ScriptedHostRuntime {
2105
2270
  }
2106
2271
  }
2107
2272
  exports.ScriptedHostRuntime = ScriptedHostRuntime;
2108
- const createHubMessage = (role, text) => ({
2273
+ const createHubMessage = (role, text, deliveryStatus) => ({
2109
2274
  id: (0, crypto_1.randomUUID)(),
2110
2275
  role,
2111
2276
  text,
2112
2277
  createdAt: new Date().toISOString(),
2278
+ ...(deliveryStatus ? { deliveryStatus } : {}),
2113
2279
  });
2114
2280
  exports.createHubMessage = createHubMessage;
2115
2281
  const createHubEvent = (channel, text) => ({