fraim-hub 2.0.286 → 2.0.288
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/dist/src/ai-hub/hosts.js +104 -24
- package/dist/src/ai-hub/server.js +135 -11
- package/dist/src/cli/doctor/checks/agent-cli-health-checks.js +21 -0
- package/dist/src/cli/setup/ide-invocation-surfaces.js +4 -1
- package/dist/src/config/persona-capability-bundles.js +10 -0
- package/dist/src/fraim/db-service.js +33 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +9 -2
- package/public/ai-hub/script.js +284 -130
- package/public/ai-hub/styles.css +71 -11
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -82,6 +82,22 @@ function parseSeekMentoringSignal(line) {
|
|
|
82
82
|
return sig;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
// Copilot CLI shape (issue #1399): { type: 'tool.execution_start', data: {
|
|
86
|
+
// mcpServerName, mcpToolName, arguments } }. Live-verified via a direct
|
|
87
|
+
// spike against the installed CLI under --output-format json (see
|
|
88
|
+
// docs/rfcs/1399-copilot-host-chattiness-technical-design.md) — Copilot
|
|
89
|
+
// reports the MCP server and tool name as separate, unambiguous fields
|
|
90
|
+
// rather than a qualifier string, so this reads data.mcpToolName directly
|
|
91
|
+
// instead of going through canonicalToolName's __/./: splitting.
|
|
92
|
+
if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
|
|
93
|
+
const data = obj.data;
|
|
94
|
+
if (isFraimTool(data.mcpToolName, 'seekMentoring')) {
|
|
95
|
+
const args = normalizeToolArgs(data.arguments) || undefined;
|
|
96
|
+
const sig = extractSignalFromArgs(args);
|
|
97
|
+
if (sig)
|
|
98
|
+
return sig;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
85
101
|
// Claude Code shape: tool_use blocks live inside parsed.message.content.
|
|
86
102
|
// The name is MCP-prefixed (e.g. 'mcp__fraim__seekMentoring').
|
|
87
103
|
const candidates = [obj];
|
|
@@ -133,6 +149,16 @@ function parseFraimJobLoadSignal(line) {
|
|
|
133
149
|
return sig;
|
|
134
150
|
}
|
|
135
151
|
}
|
|
152
|
+
// Copilot CLI shape (issue #1399): see parseSeekMentoringSignal's Copilot
|
|
153
|
+
// block above for the field-shape rationale.
|
|
154
|
+
if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
|
|
155
|
+
const data = obj.data;
|
|
156
|
+
if (isFraimTool(data.mcpToolName, 'get_fraim_job')) {
|
|
157
|
+
const sig = readFraimJobFromArgs(data.arguments);
|
|
158
|
+
if (sig)
|
|
159
|
+
return sig;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
136
162
|
// Claude Code shape: tool_use blocks live inside message.content with
|
|
137
163
|
// an MCP-prefixed name such as mcp__fraim__get_fraim_job.
|
|
138
164
|
const candidates = [obj];
|
|
@@ -289,6 +315,14 @@ function parseAgentIdentitySignal(line) {
|
|
|
289
315
|
return readAgentFromArgs(normalizeToolArgs(item.arguments) || undefined);
|
|
290
316
|
}
|
|
291
317
|
}
|
|
318
|
+
// Copilot CLI shape (issue #1399): see parseSeekMentoringSignal's Copilot
|
|
319
|
+
// block for the field-shape rationale.
|
|
320
|
+
if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
|
|
321
|
+
const data = obj.data;
|
|
322
|
+
if (isFraimTool(data.mcpToolName, 'fraim_connect')) {
|
|
323
|
+
return readAgentFromArgs(normalizeToolArgs(data.arguments) || undefined);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
292
326
|
// Claude Code shape.
|
|
293
327
|
const candidates = [obj];
|
|
294
328
|
if (typeof obj.message === 'object' && obj.message !== null) {
|
|
@@ -1434,7 +1468,12 @@ function buildStartPlan(hostId, message, sessionId) {
|
|
|
1434
1468
|
const browser = sharedBrowserHostConfig('copilot');
|
|
1435
1469
|
return {
|
|
1436
1470
|
command: COPILOT_BINARY,
|
|
1437
|
-
|
|
1471
|
+
// Issue #1399: request Copilot's structured JSONL output mode
|
|
1472
|
+
// (one JSON object per line) instead of its default human-
|
|
1473
|
+
// readable terminal rendering, so parseHostLine can classify
|
|
1474
|
+
// tool calls/results/deltas separately from genuine reply text
|
|
1475
|
+
// instead of pattern-matching rendered chrome characters.
|
|
1476
|
+
args: ['--yolo', '--output-format', 'json', ...browser.args],
|
|
1438
1477
|
stdin: transformHeadlessFraimMessage(message, 'start'),
|
|
1439
1478
|
env: browser.env,
|
|
1440
1479
|
};
|
|
@@ -1495,7 +1534,8 @@ function buildContinuePlan(hostId, sessionId, message) {
|
|
|
1495
1534
|
const browser = sharedBrowserHostConfig('copilot');
|
|
1496
1535
|
return {
|
|
1497
1536
|
command: COPILOT_BINARY,
|
|
1498
|
-
|
|
1537
|
+
// Issue #1399: see buildStartPlan's copilot branch for rationale.
|
|
1538
|
+
args: ['--yolo', '--output-format', 'json', '--resume', sessionId, ...browser.args],
|
|
1499
1539
|
stdin: transformHeadlessFraimMessage(message, 'continue'),
|
|
1500
1540
|
env: browser.env,
|
|
1501
1541
|
};
|
|
@@ -1562,9 +1602,10 @@ function buildDirectStartPlan(hostId, message, sessionId) {
|
|
|
1562
1602
|
}
|
|
1563
1603
|
if (hostId === 'copilot') {
|
|
1564
1604
|
// Direct (A/B) mode for Copilot: headless, no FRAIM MCP wiring.
|
|
1605
|
+
// Issue #1399: --output-format json for the same reason as buildStartPlan.
|
|
1565
1606
|
return {
|
|
1566
1607
|
command: COPILOT_BINARY,
|
|
1567
|
-
args: ['--yolo'],
|
|
1608
|
+
args: ['--yolo', '--output-format', 'json'],
|
|
1568
1609
|
stdin: DIRECT_PREAMBLE + message,
|
|
1569
1610
|
};
|
|
1570
1611
|
}
|
|
@@ -1608,9 +1649,10 @@ function buildDirectContinuePlan(hostId, sessionId, message) {
|
|
|
1608
1649
|
}
|
|
1609
1650
|
if (hostId === 'copilot') {
|
|
1610
1651
|
// Direct continue mode for Copilot: resume session, no FRAIM MCP wiring.
|
|
1652
|
+
// Issue #1399: --output-format json for the same reason as buildStartPlan.
|
|
1611
1653
|
return {
|
|
1612
1654
|
command: COPILOT_BINARY,
|
|
1613
|
-
args: ['--yolo', '--resume', sessionId],
|
|
1655
|
+
args: ['--yolo', '--output-format', 'json', '--resume', sessionId],
|
|
1614
1656
|
stdin: DIRECT_PREAMBLE + message,
|
|
1615
1657
|
};
|
|
1616
1658
|
}
|
|
@@ -1723,31 +1765,69 @@ function parseHostLine(hostId, line) {
|
|
|
1723
1765
|
if (hostId === 'antigravity') {
|
|
1724
1766
|
return withSignal({ raw: trimmed });
|
|
1725
1767
|
}
|
|
1726
|
-
// GitHub Copilot CLI output
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1768
|
+
// GitHub Copilot CLI output (issue #1399): Hub invokes Copilot with
|
|
1769
|
+
// --output-format json (see buildStartPlan's copilot branch), which
|
|
1770
|
+
// reports every event as one JSON object per line. Two shapes from the
|
|
1771
|
+
// original #531 integration are kept as defensive fallbacks — never
|
|
1772
|
+
// confirmed against real traffic, but harmless to keep recognizing if a
|
|
1773
|
+
// future CLI version or mode emits them:
|
|
1774
|
+
// { "type": "session.started", "session_id": "..." }
|
|
1775
|
+
// { "type": "message", "role": "assistant", "content": "..." }
|
|
1776
|
+
// The real vocabulary below was captured via a live spike against the
|
|
1777
|
+
// installed CLI binary (see "Spike Findings" in
|
|
1778
|
+
// docs/rfcs/1399-copilot-host-chattiness-technical-design.md):
|
|
1779
|
+
// { "type": "assistant.message", "data": { "content": "...", "toolRequests": [...] } }
|
|
1780
|
+
// — the genuine reply. data.content is the message text; toolRequests
|
|
1781
|
+
// is metadata about an about-to-run tool call, not display text.
|
|
1782
|
+
// { "type": "result", "sessionId": "...", "exitCode": 0, "usage": {...} }
|
|
1783
|
+
// — terminal turn/session summary. This is the only place sessionId
|
|
1784
|
+
// was observed in a single-shot run (no early session-id event fired).
|
|
1785
|
+
// { "type": "tool.execution_start" | "tool.execution_complete", "data": {...} },
|
|
1786
|
+
// { "type": "model.tool_execution", "data": {...} }
|
|
1787
|
+
// — tool invocation and result. Diagnostic only, never a message.
|
|
1788
|
+
// seekMentoring/get_fraim_job/fraim_connect signal extraction (via
|
|
1789
|
+
// withSignal below) reads the call arguments off tool.execution_start's
|
|
1790
|
+
// data.arguments/data.mcpServerName/data.mcpToolName.
|
|
1791
|
+
// { "type": "assistant.message_delta" | "assistant.tool_call_delta", ...,
|
|
1792
|
+
// "ephemeral": true }
|
|
1793
|
+
// — streaming deltas. The CLI's own schema marks these ephemeral, an
|
|
1794
|
+
// explicit signal (not a heuristic) that they are safe to drop.
|
|
1795
|
+
// session.*/model.*/user.message/assistant.turn_*/assistant.idle
|
|
1796
|
+
// — setup, bookkeeping, and input-echo diagnostics.
|
|
1797
|
+
// Any other/unrecognized type, and any line that fails JSON.parse entirely,
|
|
1798
|
+
// fails CLOSED (raw only) — matching Codex's and Claude's existing
|
|
1799
|
+
// fail-closed default. This replaces the prior fail-OPEN catch-all that
|
|
1800
|
+
// promoted arbitrary unrecognized Copilot text (in practice, ~80% of a
|
|
1801
|
+
// real run's lines — the CLI's own tool-call/result terminal rendering) to
|
|
1802
|
+
// a manager-visible message.
|
|
1734
1803
|
if (hostId === 'copilot') {
|
|
1804
|
+
let parsed;
|
|
1735
1805
|
try {
|
|
1736
|
-
|
|
1737
|
-
if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
|
|
1738
|
-
return withSignal({ sessionId: parsed.session_id, raw: trimmed });
|
|
1739
|
-
}
|
|
1740
|
-
if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
|
|
1741
|
-
return withSignal({ message: parsed.content, raw: trimmed });
|
|
1742
|
-
}
|
|
1743
|
-
// All other JSON events: apply signal scanning and surface as raw.
|
|
1744
|
-
return withSignal({ raw: trimmed });
|
|
1806
|
+
parsed = JSON.parse(trimmed);
|
|
1745
1807
|
}
|
|
1746
1808
|
catch {
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1809
|
+
return withSignal({ raw: trimmed });
|
|
1810
|
+
}
|
|
1811
|
+
if (!parsed || typeof parsed !== 'object')
|
|
1812
|
+
return withSignal({ raw: trimmed });
|
|
1813
|
+
if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
|
|
1814
|
+
return withSignal({ sessionId: parsed.session_id, raw: trimmed });
|
|
1815
|
+
}
|
|
1816
|
+
if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
|
|
1817
|
+
return withSignal({ message: parsed.content, raw: trimmed });
|
|
1818
|
+
}
|
|
1819
|
+
if (parsed.type === 'assistant.message') {
|
|
1820
|
+
const content = typeof parsed.data?.content === 'string' ? parsed.data.content : undefined;
|
|
1821
|
+
return content ? withSignal({ message: content, raw: trimmed }) : withSignal({ raw: trimmed });
|
|
1750
1822
|
}
|
|
1823
|
+
if (parsed.type === 'result') {
|
|
1824
|
+
const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0 ? parsed.sessionId : undefined;
|
|
1825
|
+
return withSignal({ sessionId, raw: trimmed });
|
|
1826
|
+
}
|
|
1827
|
+
// tool.execution_start/complete, model.tool_execution, the *_delta
|
|
1828
|
+
// events, and session/model/user/turn bookkeeping all fall here:
|
|
1829
|
+
// diagnostic-only, but still signal-scanned by withSignal above.
|
|
1830
|
+
return withSignal({ raw: trimmed });
|
|
1751
1831
|
}
|
|
1752
1832
|
try {
|
|
1753
1833
|
const parsed = JSON.parse(trimmed);
|
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
40
|
+
exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
|
|
40
41
|
exports.configureFraimForHubAgent = configureFraimForHubAgent;
|
|
41
42
|
exports.hubCommandVersion = hubCommandVersion;
|
|
42
43
|
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
@@ -1699,6 +1700,35 @@ function hubAgentOption(hubId) {
|
|
|
1699
1700
|
const frId = HUB_TO_FIRST_RUN_ID[hubId];
|
|
1700
1701
|
return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
|
|
1701
1702
|
}
|
|
1703
|
+
const defaultSelfHealDeps = {
|
|
1704
|
+
checkHealth: agent_cli_health_checks_1.checkAgentCliHealthByCommand,
|
|
1705
|
+
install: (option, systemPath) => (0, managed_agent_install_1.installManagedAgent)(option, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion }),
|
|
1706
|
+
invalidateCache: hosts_1.invalidateEmployeeDetectionCache,
|
|
1707
|
+
};
|
|
1708
|
+
async function selfHealBrokenManagedAgents(deps = defaultSelfHealDeps) {
|
|
1709
|
+
for (const hubId of ['claude', 'codex']) {
|
|
1710
|
+
try {
|
|
1711
|
+
const health = await deps.checkHealth(hubId);
|
|
1712
|
+
if (!health || health.status !== 'error')
|
|
1713
|
+
continue;
|
|
1714
|
+
const details = health.details;
|
|
1715
|
+
if (!details?.managedPath || details.managedVersion)
|
|
1716
|
+
continue;
|
|
1717
|
+
const option = hubAgentOption(hubId);
|
|
1718
|
+
if (!option || !option.installPackage)
|
|
1719
|
+
continue;
|
|
1720
|
+
console.warn(`[ai-hub] self-heal: ${option.label}'s FRAIM-managed install is broken (${health.message}) — attempting automatic reinstall`);
|
|
1721
|
+
const systemPath = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(process.env.PATH);
|
|
1722
|
+
await deps.install({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath);
|
|
1723
|
+
deps.invalidateCache();
|
|
1724
|
+
const verified = await deps.checkHealth(hubId);
|
|
1725
|
+
console.warn(`[ai-hub] self-heal: ${option.label} reinstall ${verified?.status === 'error' ? 'did NOT fix it — manual reinstall needed' : 'succeeded'}`);
|
|
1726
|
+
}
|
|
1727
|
+
catch (error) {
|
|
1728
|
+
console.warn(`[ai-hub] self-heal check failed for ${hubId}:`, error instanceof Error ? error.message : String(error));
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1702
1732
|
/**
|
|
1703
1733
|
* Issue #747: after the Hub installs an agent CLI, run the `add-ide` command for that agent so the
|
|
1704
1734
|
* FRAIM MCP (plus slash commands / rules) is wired into its config and its first run works.
|
|
@@ -1916,6 +1946,24 @@ function buildManagedLoginCommand(command) {
|
|
|
1916
1946
|
function getUserHubDir() {
|
|
1917
1947
|
return path_1.default.join(os_1.default.homedir(), '.fraim');
|
|
1918
1948
|
}
|
|
1949
|
+
// Issue #1345: recursively find first file matching name under dir.
|
|
1950
|
+
function findFileRecursive(dir, filename) {
|
|
1951
|
+
try {
|
|
1952
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
1953
|
+
const full = path_1.default.join(dir, entry.name);
|
|
1954
|
+
if (entry.isDirectory()) {
|
|
1955
|
+
const hit = findFileRecursive(full, filename);
|
|
1956
|
+
if (hit)
|
|
1957
|
+
return hit;
|
|
1958
|
+
}
|
|
1959
|
+
else if (entry.isFile() && entry.name === filename) {
|
|
1960
|
+
return full;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
catch { /* ignore unreadable dirs */ }
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1919
1967
|
function ensureDirectoryPath(projectPath) {
|
|
1920
1968
|
const trimmed = (projectPath || '').trim();
|
|
1921
1969
|
if (!trimmed) {
|
|
@@ -2681,6 +2729,17 @@ class AiHubServer {
|
|
|
2681
2729
|
void (0, hosts_1.detectEmployeesAsync)({ force: true }).catch((error) => {
|
|
2682
2730
|
console.warn('[ai-hub] agent availability priming failed:', error?.message || error);
|
|
2683
2731
|
});
|
|
2732
|
+
// Issue #1412: fire-and-forget self-heal of any broken FRAIM-managed CLI
|
|
2733
|
+
// install (e.g. an interrupted Claude Code/Codex update left a
|
|
2734
|
+
// non-executable launcher). Never blocks or fails startup. Skipped in
|
|
2735
|
+
// NODE_ENV=test so the test suite's many short-lived Hub instances never
|
|
2736
|
+
// trigger a real `npm install -g`, matching this file's existing
|
|
2737
|
+
// NODE_ENV-gated test-safety pattern (see hubCommandVersion/hubRunProcess).
|
|
2738
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
2739
|
+
void selfHealBrokenManagedAgents().catch((error) => {
|
|
2740
|
+
console.warn('[ai-hub] self-heal pass failed:', error?.message || error);
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2684
2743
|
// Issue #578: rehydrate active scheduled deployments from disk. Test and preview
|
|
2685
2744
|
// servers that inject a fake host but not a deployment store should not run the
|
|
2686
2745
|
// user's real scheduled deployments from the default store.
|
|
@@ -3242,6 +3301,7 @@ class AiHubServer {
|
|
|
3242
3301
|
// so completed conversations render "What's next?" chips after reload.
|
|
3243
3302
|
nextJobRecommendations: run.nextJobRecommendations || null,
|
|
3244
3303
|
issueNumber: run.issueNumber ?? null,
|
|
3304
|
+
executionMode: run.executionMode || null,
|
|
3245
3305
|
managedByRunId: run.managedByRunId || null,
|
|
3246
3306
|
managedByPersonaKey: run.managedByPersonaKey || null,
|
|
3247
3307
|
humanCoachingDisabled: run.humanCoachingDisabled || false,
|
|
@@ -5686,6 +5746,25 @@ class AiHubServer {
|
|
|
5686
5746
|
return res.status(404).json({ error: 'Configured agent not found.' });
|
|
5687
5747
|
return res.json((0, configured_agents_1.checkConfiguredAgentReadiness)(agent, employees));
|
|
5688
5748
|
});
|
|
5749
|
+
// Issue #1345: accept executionMode forwarded from get_fraim_job response by the local proxy.
|
|
5750
|
+
// The proxy parses the Execution Mode Context block and POSTs here; Hub script reads from run poll.
|
|
5751
|
+
this.app.post('/api/ai-hub/runs/by-session/:sessionId/execution-mode', (req, res) => {
|
|
5752
|
+
if (!this.requireTrustedHubOrigin(req, res))
|
|
5753
|
+
return;
|
|
5754
|
+
const { sessionId } = req.params;
|
|
5755
|
+
const { mode, completedRuns } = req.body;
|
|
5756
|
+
if (!sessionId)
|
|
5757
|
+
return res.status(400).json({ error: 'sessionId required' });
|
|
5758
|
+
const normalizedMode = mode === 'trusted' ? 'trusted' : 'coached';
|
|
5759
|
+
const normalizedRuns = typeof completedRuns === 'number' ? completedRuns : 0;
|
|
5760
|
+
const run = this.runRegistry.all().find((r) => r.sessionId === sessionId);
|
|
5761
|
+
if (!run)
|
|
5762
|
+
return res.status(404).json({ error: 'Run not found for session.' });
|
|
5763
|
+
this.runRegistry.update(run.id, (current) => {
|
|
5764
|
+
current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
|
|
5765
|
+
});
|
|
5766
|
+
return res.json({ ok: true });
|
|
5767
|
+
});
|
|
5689
5768
|
this.app.post('/api/ai-hub/runs', (req, res) => {
|
|
5690
5769
|
try {
|
|
5691
5770
|
// Issue #892: project-independent (manager/company) runs resolve a working dir
|
|
@@ -5726,6 +5805,26 @@ class AiHubServer {
|
|
|
5726
5805
|
if (!jobId) {
|
|
5727
5806
|
throw new Error('Choose a FRAIM job before starting a run.');
|
|
5728
5807
|
}
|
|
5808
|
+
// #1394: Dedup guard — return an already-running identical run rather than spawning a second
|
|
5809
|
+
// process. Key includes managerDisplay (messages[0].text) so runs for different issues are
|
|
5810
|
+
// never blocked even when they share the same jobId + hostId + projectPath.
|
|
5811
|
+
// Window of 300 ms: UI race duplicates arrive within ~100 ms (React render cycle);
|
|
5812
|
+
// sequential test runs with the same params are separated by 600 ms+ (test body +
|
|
5813
|
+
// beforeEach page.goto), so the window never fires across test boundaries.
|
|
5814
|
+
const MANAGER_DEDUP_WINDOW_MS = 300;
|
|
5815
|
+
if ((req.body.sourceTrigger ?? 'manager') === 'manager') {
|
|
5816
|
+
const now = Date.now();
|
|
5817
|
+
const activeRun = this.runRegistry.all().find((r) => r.status === 'running'
|
|
5818
|
+
&& r.projectPath === projectPath
|
|
5819
|
+
&& r.jobId === jobId
|
|
5820
|
+
&& r.hostId === hostId
|
|
5821
|
+
&& r.messages[0]?.text === managerDisplay
|
|
5822
|
+
&& now - Date.parse(r.createdAt) < MANAGER_DEDUP_WINDOW_MS);
|
|
5823
|
+
if (activeRun) {
|
|
5824
|
+
console.warn('[ai-hub] hub.duplicate_run_blocked', { projectPath, jobId, hostId, existingRunId: activeRun.id });
|
|
5825
|
+
return res.json(this.enrichRunForResponse(activeRun));
|
|
5826
|
+
}
|
|
5827
|
+
}
|
|
5729
5828
|
const startTimestamp = new Date().toISOString();
|
|
5730
5829
|
const jobMetadata = this.resolveHubJob(projectPath, jobId);
|
|
5731
5830
|
const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
|
|
@@ -5765,7 +5864,9 @@ class AiHubServer {
|
|
|
5765
5864
|
phaseVisits: [],
|
|
5766
5865
|
totals: emptyTotals(),
|
|
5767
5866
|
lastStatusChangeAt: startTimestamp,
|
|
5768
|
-
|
|
5867
|
+
// Issue #1357: fall back to custom persona resolution before the catalog lookup
|
|
5868
|
+
// so custom employee owners (e.g. SidCoder for feature-implementation) are used.
|
|
5869
|
+
personaKey: jobMetadata?.personaKey ?? getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
|
|
5769
5870
|
// Issue #892: persist the invocation scope so the run is routed to the right
|
|
5770
5871
|
// conversation bucket (manager/company get a project-independent home) and so
|
|
5771
5872
|
// the resolved fallback working dir is never mistaken for the active project.
|
|
@@ -6023,6 +6124,13 @@ class AiHubServer {
|
|
|
6023
6124
|
if (reviewApprovalSystemEventText)
|
|
6024
6125
|
current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
|
|
6025
6126
|
current.events.push((0, hosts_1.createHubEvent)('system', 'No resumable session - starting a fresh agent turn from conversation context.'));
|
|
6127
|
+
// Issue #1373 (Defect 2): nextJobRecommendations can only be stale
|
|
6128
|
+
// here — it is only ever set at retrospective (issue #848), and a
|
|
6129
|
+
// job that just received coaching has not reached retrospective
|
|
6130
|
+
// again yet. Clear it at send-time so a coached Done job stops
|
|
6131
|
+
// offering next-job recommendations immediately, not only once the
|
|
6132
|
+
// resumed session's next seekMentoring signal arrives.
|
|
6133
|
+
current.nextJobRecommendations = null;
|
|
6026
6134
|
});
|
|
6027
6135
|
const startedFresh = this.runRegistry.get(run.id);
|
|
6028
6136
|
if (startedFresh)
|
|
@@ -6079,6 +6187,9 @@ class AiHubServer {
|
|
|
6079
6187
|
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus));
|
|
6080
6188
|
if (reviewApprovalSystemEventText)
|
|
6081
6189
|
current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
|
|
6190
|
+
// Issue #1373 (Defect 2): see the no-session branch above for why
|
|
6191
|
+
// this can only be stale at coaching-send time.
|
|
6192
|
+
current.nextJobRecommendations = null;
|
|
6082
6193
|
});
|
|
6083
6194
|
const started = this.runRegistry.get(run.id);
|
|
6084
6195
|
if (started)
|
|
@@ -6221,17 +6332,28 @@ class AiHubServer {
|
|
|
6221
6332
|
messages: persistedConversation ? persistedMessagesForRun(persistedConversation) : [],
|
|
6222
6333
|
events: resumeEvents,
|
|
6223
6334
|
eventLogRefs: persistedConversation?.eventLogRefs || [],
|
|
6224
|
-
//
|
|
6225
|
-
//
|
|
6226
|
-
//
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
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.
|
|
6343
|
+
currentPhase: persistedRun?.currentPhase || null,
|
|
6344
|
+
phaseHistory: persistedRun?.phaseHistory || [],
|
|
6345
|
+
phaseVisits: persistedRun?.phaseVisits || [],
|
|
6230
6346
|
totals: persistedRun?.totals || emptyTotals(),
|
|
6231
6347
|
lastStatusChangeAt: now,
|
|
6232
|
-
runDiscriminant:
|
|
6233
|
-
|
|
6348
|
+
runDiscriminant: persistedRun?.runDiscriminant || undefined,
|
|
6349
|
+
// Issue #1357: consult custom persona owner before falling back to catalog.
|
|
6350
|
+
personaKey: getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
|
|
6234
6351
|
continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
|
|
6352
|
+
// Issue #1373 (Defect 2): nextJobRecommendations are only ever set at
|
|
6353
|
+
// retrospective/completion (issue #848); a resumed run has not reached
|
|
6354
|
+
// retrospective, so this is always null here. Explicit for readability
|
|
6355
|
+
// and to make the invariant visible next to the Defect 1 fix above.
|
|
6356
|
+
nextJobRecommendations: null,
|
|
6235
6357
|
};
|
|
6236
6358
|
host_session_state_1.hostSessionState.applySession(run, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, sessionId, { sourceRunId: run.id, status: resolvedHostSession?.status || 'suspect' });
|
|
6237
6359
|
// Continue-turn message (FRAIM invocation for the job + instructions) plus
|
|
@@ -7025,7 +7147,8 @@ class AiHubServer {
|
|
|
7025
7147
|
phaseVisits: [],
|
|
7026
7148
|
totals: emptyTotals(),
|
|
7027
7149
|
lastStatusChangeAt: startTimestamp,
|
|
7028
|
-
|
|
7150
|
+
// Issue #1357: consult custom persona owner before falling back to catalog.
|
|
7151
|
+
personaKey: getCustomPersonaForJob(projectPath, jobName) ?? getHubPersonaForJob(jobName),
|
|
7029
7152
|
};
|
|
7030
7153
|
// Register the run before spawning so onEvent/onExit callbacks can
|
|
7031
7154
|
// safely call update() even if they fire synchronously (FakeHostRuntime).
|
|
@@ -7178,7 +7301,8 @@ class AiHubServer {
|
|
|
7178
7301
|
phaseVisits: [],
|
|
7179
7302
|
totals: emptyTotals(),
|
|
7180
7303
|
lastStatusChangeAt: startTimestamp,
|
|
7181
|
-
|
|
7304
|
+
// Issue #1357: fall back to custom persona resolution before the catalog lookup.
|
|
7305
|
+
personaKey: jobMetadata?.personaKey ?? getCustomPersonaForJob(deployment.projectPath, jobId) ?? getHubPersonaForJob(jobId),
|
|
7182
7306
|
};
|
|
7183
7307
|
// Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
|
|
7184
7308
|
// can call runRegistry.update without "Run not found" throws.
|
|
@@ -97,6 +97,27 @@ async function runAgentCliHealthCheck(cli) {
|
|
|
97
97
|
: managedPath === ambientPath
|
|
98
98
|
? ambientVersion
|
|
99
99
|
: probeVersion(managedPath);
|
|
100
|
+
// Issue #1412: a resolved file that fails to execute at all (e.g. an
|
|
101
|
+
// interrupted update left a non-executable fallback launcher) is a harder
|
|
102
|
+
// failure than drift — `getSystemCommandPath()` only proves the file
|
|
103
|
+
// exists, not that it can run. Without this check, a single broken install
|
|
104
|
+
// with nothing else on PATH to disagree with (ambientPath === managedPath,
|
|
105
|
+
// both versions null) falls straight through the mismatch check below and
|
|
106
|
+
// reads as "consistent". Check this before computing npm-global/drift
|
|
107
|
+
// details that don't matter once the CLI can't run at all.
|
|
108
|
+
const brokenPath = ambientPath && !ambientVersion
|
|
109
|
+
? ambientPath
|
|
110
|
+
: managedPath && !managedVersion
|
|
111
|
+
? managedPath
|
|
112
|
+
: null;
|
|
113
|
+
if (brokenPath) {
|
|
114
|
+
return {
|
|
115
|
+
status: 'error',
|
|
116
|
+
message: `${cli.label} is installed at ${brokenPath} but failed to run (\`${cli.command} --version\` produced no output). An interrupted or partial update may have left a non-functional launcher.`,
|
|
117
|
+
suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
|
|
118
|
+
details: { ambientPath, ambientVersion, managedPath, managedVersion },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
100
121
|
const npmGlobalBinDirs = (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)();
|
|
101
122
|
const npmGlobalPath = npmGlobalBinDirs.length > 0
|
|
102
123
|
? (0, command_resolution_1.getSystemCommandPath)(cli.command, npmGlobalBinDirs.join(path_1.default.delimiter))
|
|
@@ -29,7 +29,10 @@ exports.FRAIM_MCP_UNAVAILABLE_MANAGER_GUIDANCE = [
|
|
|
29
29
|
'If required FRAIM MCP tools remain unavailable after the deferred-tool preload/retry step, stop the FRAIM job.',
|
|
30
30
|
'Do not continue from memory, local stubs, cached instructions, or prior context.',
|
|
31
31
|
'In the blocker sentence, state only the work-focused blocker and what outcome is blocked; do not narrate ToolSearch/tool_search, individual tool names, server internals, or retry mechanics.',
|
|
32
|
-
'Always help troubleshoot the unavailable FRAIM MCP connection: tell the user to reconnect or enable the FRAIM MCP server in their IDE/agent, restart or refresh the agent session, retry the FRAIM job, and if it still fails run `fraim doctor --test-mcp` and share the failing check output.'
|
|
32
|
+
'Always help troubleshoot the unavailable FRAIM MCP connection: tell the user to reconnect or enable the FRAIM MCP server in their IDE/agent, restart or refresh the agent session, retry the FRAIM job, and if it still fails run `fraim doctor --test-mcp` and share the failing check output.',
|
|
33
|
+
'Exception — troubleshooting FRAIM itself: if the user\'s request is to diagnose or fix a FRAIM issue and FRAIM MCP is unavailable, do NOT stop.',
|
|
34
|
+
'Instead, read `~/.fraim/docs/TROUBLESHOOTING.md` directly from disk (it is always synced in full by `fraim sync` and is available without MCP) and follow its guidance to diagnose the issue.',
|
|
35
|
+
'After completing the local diagnosis, prompt the user to re-run `fraim sync` and restart their agent session, then offer to run the `troubleshoot-fraim` FRAIM job for a structured investigation once MCP is restored.'
|
|
33
36
|
].join(' ');
|
|
34
37
|
function buildDeferredToolBootstrapSection(profile) {
|
|
35
38
|
if (profile === 'none') {
|
|
@@ -291,10 +291,20 @@ for (const bundle of Object.values(exports.PERSONA_CAPABILITY_BUNDLES)) {
|
|
|
291
291
|
// named specialist persona. They resolve through ownership (never short-circuited
|
|
292
292
|
// as "free") so the Hub attributes them to FRAIMworker, but they are never
|
|
293
293
|
// hire-gated because FRAIMworker is not a purchasable persona.
|
|
294
|
+
//
|
|
295
|
+
// Issue #1398: manager-agreements / organization-onboarding / organizational-learning-
|
|
296
|
+
// synthesis are Manager/Company area-level jobs, not tied to a named specialist — they
|
|
297
|
+
// were falling through to DEFAULT_UNASSIGNED_PERSONA_KEY ('mandy'), which misattributed
|
|
298
|
+
// them to an employee who never runs them and left them outside the Manager/Company
|
|
299
|
+
// employee rail's FRAIMworker group.
|
|
294
300
|
const GENERIC_WORKER_OWNED_JOBS = new Set([
|
|
295
301
|
'contribute-to-fraim',
|
|
296
302
|
'file-fraim-issue',
|
|
297
303
|
'praise-fraim',
|
|
304
|
+
'troubleshoot-fraim',
|
|
305
|
+
'manager-agreements',
|
|
306
|
+
'organization-onboarding',
|
|
307
|
+
'organizational-learning-synthesis',
|
|
298
308
|
]);
|
|
299
309
|
function getPersonaCapabilityBundle(personaKey) {
|
|
300
310
|
return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
|
|
@@ -170,6 +170,8 @@ class FraimDbService {
|
|
|
170
170
|
// Issue #563 — shared organization context (FRAIM-cloud backend).
|
|
171
171
|
this.orgArtifactsCollection = this.db.collection('fraim_org_artifacts');
|
|
172
172
|
this.orgAuditCollection = this.db.collection('fraim_org_audit');
|
|
173
|
+
// Issue #1345 — job execution modes (Coached/Trusted).
|
|
174
|
+
this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
|
|
173
175
|
}
|
|
174
176
|
async initializeIndexes() {
|
|
175
177
|
if (!this.db)
|
|
@@ -199,6 +201,7 @@ class FraimDbService {
|
|
|
199
201
|
// requirement — swallow failures like the other Cosmos-sensitive indexes above.
|
|
200
202
|
await this.orgArtifactsCollection.createIndex({ orgId: 1, relativePath: 1 }, { unique: true }).catch(() => { });
|
|
201
203
|
await this.orgAuditCollection.createIndex({ orgId: 1, at: -1 }).catch(() => { });
|
|
204
|
+
await this.jobExecutionModesCollection.createIndex({ userId: 1, jobName: 1 }, { unique: true }).catch(() => { });
|
|
202
205
|
await this.pendingVerificationsCollection.createIndex({ email: 1 });
|
|
203
206
|
// Compound index covers `findOne({email}, { sort: { createdAt: -1 } })` —
|
|
204
207
|
// the request-access flow's lookup of the most-recent pending row per
|
|
@@ -286,6 +289,36 @@ class FraimDbService {
|
|
|
286
289
|
throw new Error('DB not connected');
|
|
287
290
|
return await this.orgAuditCollection.find({ orgId }).sort({ at: -1 }).toArray();
|
|
288
291
|
}
|
|
292
|
+
async getJobExecutionMode(userId, jobName) {
|
|
293
|
+
if (!this.jobExecutionModesCollection)
|
|
294
|
+
return null;
|
|
295
|
+
return await this.jobExecutionModesCollection.findOne({ userId, jobName }) ?? null;
|
|
296
|
+
}
|
|
297
|
+
async upsertJobExecutionMode(userId, update) {
|
|
298
|
+
if (!this.jobExecutionModesCollection)
|
|
299
|
+
return;
|
|
300
|
+
const jobName = String(update['jobName'] ?? '');
|
|
301
|
+
if (!jobName)
|
|
302
|
+
return;
|
|
303
|
+
const existing = await this.jobExecutionModesCollection.findOne({ userId, jobName });
|
|
304
|
+
let completedRuns = typeof existing?.completedRuns === 'number' ? existing.completedRuns : 0;
|
|
305
|
+
if (update['incrementRun'] === true)
|
|
306
|
+
completedRuns += 1;
|
|
307
|
+
const record = {
|
|
308
|
+
userId,
|
|
309
|
+
jobName,
|
|
310
|
+
mode: (update['mode'] === 'trusted' || update['mode'] === 'coached')
|
|
311
|
+
? update['mode']
|
|
312
|
+
: (existing?.mode ?? 'coached'),
|
|
313
|
+
completedRuns,
|
|
314
|
+
trustedSince: typeof update['trustedSince'] === 'string' ? update['trustedSince'] : existing?.trustedSince,
|
|
315
|
+
trustedAtRun: typeof update['trustedAtRun'] === 'number' ? update['trustedAtRun'] : existing?.trustedAtRun,
|
|
316
|
+
lastGraduationOfferRun: typeof update['lastGraduationOfferRun'] === 'number' ? update['lastGraduationOfferRun'] : existing?.lastGraduationOfferRun,
|
|
317
|
+
graduationSuppressedUntilRun: typeof update['graduationSuppressedUntilRun'] === 'number' ? update['graduationSuppressedUntilRun'] : existing?.graduationSuppressedUntilRun,
|
|
318
|
+
updatedAt: new Date(),
|
|
319
|
+
};
|
|
320
|
+
await this.jobExecutionModesCollection.replaceOne({ userId, jobName }, record, { upsert: true });
|
|
321
|
+
}
|
|
289
322
|
async verifyApiKey(key) {
|
|
290
323
|
if (!this.keysCollection)
|
|
291
324
|
throw new Error('DB not connected');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.288",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"electron-updater": "^6.8.9",
|
|
211
211
|
"express": "^5.2.1",
|
|
212
212
|
"extract-zip": "^2.0.1",
|
|
213
|
-
"fraim": "2.0.
|
|
213
|
+
"fraim": "2.0.288",
|
|
214
214
|
"mongodb": "^7.0.0",
|
|
215
215
|
"node-cron": "4.2.1",
|
|
216
216
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -827,8 +827,10 @@
|
|
|
827
827
|
</div>
|
|
828
828
|
|
|
829
829
|
<!-- #1351: unified Add Agent dialog — add a configured profile directly, or delegate
|
|
830
|
-
setup to FRAIM.
|
|
831
|
-
|
|
830
|
+
setup to FRAIM. This is the single entry point for agent setup: the toolbar's
|
|
831
|
+
"Add AI agent" button and, on #1390, the per-tool quick-picks inside the Delegate
|
|
832
|
+
tab (which replaced the old standalone "setup another tool" disclosure) both open
|
|
833
|
+
this same dialog. -->
|
|
832
834
|
<div id="add-agent-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="aam-title" hidden>
|
|
833
835
|
<div class="modal-card">
|
|
834
836
|
<div class="modal-hdr">
|
|
@@ -845,6 +847,11 @@
|
|
|
845
847
|
<div class="modal-body">
|
|
846
848
|
<div id="aam-manual-pane"></div>
|
|
847
849
|
<div id="aam-delegate-pane" hidden>
|
|
850
|
+
<div id="aam-delegate-quickpicks" class="aam-delegate-quickpicks" data-testid="aam-delegate-quickpicks" hidden>
|
|
851
|
+
<p class="aam-delegate-quickpicks-label">Not set up yet on this machine:</p>
|
|
852
|
+
<div id="aam-delegate-quickpicks-list" class="aam-delegate-quickpicks-list"></div>
|
|
853
|
+
<p id="aam-delegate-quickpick-caption" class="aam-delegate-quickpick-caption" data-testid="aam-delegate-quickpick-caption" hidden></p>
|
|
854
|
+
</div>
|
|
848
855
|
<div class="np-field">
|
|
849
856
|
<label for="aam-delegate-context">Any specific direction for this run? <span class="np-optional">(optional, leave blank to start with defaults)</span></label>
|
|
850
857
|
<textarea id="aam-delegate-context" rows="4" data-testid="add-agent-delegate-context" placeholder="e.g. Configure a CLI, a cloud-credit route, or a custom profile."></textarea>
|