fraim-hub 2.0.275 → 2.0.276
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/configured-agent-command.js +152 -0
- package/dist/src/ai-hub/configured-agents.js +65 -13
- package/dist/src/ai-hub/hub-latest-version.js +35 -6
- package/dist/src/ai-hub/server.js +36 -15
- package/dist/src/cli/setup/ide-global-integration.js +24 -15
- package/package.json +2 -2
- package/public/ai-hub/script.js +151 -65
- package/public/ai-hub/styles.css +22 -5
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseConfiguredCommand = parseConfiguredCommand;
|
|
7
|
+
exports.describeConfiguredCommandInvocationProblem = describeConfiguredCommandInvocationProblem;
|
|
8
|
+
exports.commandContainsLiteralSecret = commandContainsLiteralSecret;
|
|
9
|
+
exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
13
|
+
const BASH_ENV_PREFIX = /^([A-Za-z_][A-Za-z0-9_]*)=(?:\$\([^)]*\)|"[^"]*"|'[^']*'|\S*)\s+/;
|
|
14
|
+
// The value may be a parenthesized PowerShell expression followed by a
|
|
15
|
+
// property-access chain (e.g. `(Get-Content ... | ConvertFrom-Json).api_key`),
|
|
16
|
+
// a quoted string, or a bare token — always terminated by the statement `;`.
|
|
17
|
+
const POWERSHELL_ENV_PREFIX = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:\([^)]*\)(?:\.[A-Za-z_][A-Za-z0-9_]*)*|"[^"]*"|'[^']*'|\S+?)\s*;\s*/;
|
|
18
|
+
function parseConfiguredCommand(runner, commandText) {
|
|
19
|
+
let rest = String(commandText || '').trim();
|
|
20
|
+
const prefixPattern = runner === 'powershell' ? POWERSHELL_ENV_PREFIX : BASH_ENV_PREFIX;
|
|
21
|
+
let prefixEnd = 0;
|
|
22
|
+
while (true) {
|
|
23
|
+
const match = rest.slice(prefixEnd).match(prefixPattern);
|
|
24
|
+
if (!match)
|
|
25
|
+
break;
|
|
26
|
+
prefixEnd += match[0].length;
|
|
27
|
+
}
|
|
28
|
+
const envPrefixText = rest.slice(0, prefixEnd).trim();
|
|
29
|
+
const invocation = rest.slice(prefixEnd).trim();
|
|
30
|
+
const tokens = tokenizeShellLike(invocation);
|
|
31
|
+
return { envPrefixText, extraArgs: tokens.slice(1), invocationHead: tokens[0] || '' };
|
|
32
|
+
}
|
|
33
|
+
// Reviewer finding (PR #1209, Round 3): parseConfiguredCommand's leniency is
|
|
34
|
+
// intentional for *parsing* (it never throws), but that leniency means a
|
|
35
|
+
// structurally wrong command — most dangerously a shell built-in like `.`
|
|
36
|
+
// (source) or `source` at the head, meant to load a setup script into the
|
|
37
|
+
// current shell — parses "successfully" into a garbage invocationHead that
|
|
38
|
+
// decorateHostPlanWithConfiguredAgent then silently discards, appending only
|
|
39
|
+
// the path/args that followed it as bogus positional arguments to the real
|
|
40
|
+
// agent binary instead of ever sourcing the script. Since the invocation
|
|
41
|
+
// head is always discarded and replaced with Hub's own known base binary,
|
|
42
|
+
// the only way to catch this class of error is to check, at readiness time,
|
|
43
|
+
// that what the user typed there actually was that same base binary — not
|
|
44
|
+
// to attempt to execute or otherwise interpret it.
|
|
45
|
+
function describeConfiguredCommandInvocationProblem(parsed, baseHostId) {
|
|
46
|
+
if (!parsed.invocationHead) {
|
|
47
|
+
return 'Command has no invocation to run after the environment prefix.';
|
|
48
|
+
}
|
|
49
|
+
if (parsed.invocationHead !== baseHostId) {
|
|
50
|
+
return `Command must invoke "${baseHostId}" (found "${parsed.invocationHead}"). A setup script should be registered as a setup script, not composed into this field — see hub-configured-agent-setup.md.`;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function tokenizeShellLike(text) {
|
|
55
|
+
const tokens = [];
|
|
56
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = re.exec(text)) !== null) {
|
|
59
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
60
|
+
}
|
|
61
|
+
return tokens;
|
|
62
|
+
}
|
|
63
|
+
// Issue #1201 (R2.3): UX safety net only — flags what pattern-matches as a
|
|
64
|
+
// literal high-entropy secret pasted into the command field. Not a substitute
|
|
65
|
+
// for the routing skill never generating such a command in the first place.
|
|
66
|
+
const LITERAL_SECRET_TOKEN = /^[A-Za-z0-9+/_.=-]{20,}$/;
|
|
67
|
+
function commandContainsLiteralSecret(commandText) {
|
|
68
|
+
for (const token of String(commandText || '').split(/\s+/)) {
|
|
69
|
+
const eq = token.indexOf('=');
|
|
70
|
+
if (eq <= 0)
|
|
71
|
+
continue;
|
|
72
|
+
const value = token.slice(eq + 1);
|
|
73
|
+
if (!value || value.startsWith('$') || value.startsWith('`') || value.startsWith('('))
|
|
74
|
+
continue;
|
|
75
|
+
if (LITERAL_SECRET_TOKEN.test(value))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// The env-prefix assignment is evaluated as a bash "temporary environment"
|
|
81
|
+
// directly attached to the `exec` command on the same line (`VAR=$(...) exec
|
|
82
|
+
// "$@"`), not as a separate statement before it. A bare `VAR=value` on its
|
|
83
|
+
// own line only sets a local shell variable — it is NOT exported, so it
|
|
84
|
+
// would silently vanish from the real binary's environment once `exec`
|
|
85
|
+
// replaces this process image. Attaching it to the same command line is
|
|
86
|
+
// exactly what a terminal user's `VAR=$(...) codex ...` already does.
|
|
87
|
+
const BASH_LAUNCH_WRAPPER = [
|
|
88
|
+
'#!/usr/bin/env bash',
|
|
89
|
+
'set -e',
|
|
90
|
+
'if [ -n "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX" ]; then',
|
|
91
|
+
' eval "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX exec \\"\\$@\\""',
|
|
92
|
+
'else',
|
|
93
|
+
' exec "$@"',
|
|
94
|
+
'fi',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
const POWERSHELL_LAUNCH_WRAPPER = [
|
|
98
|
+
'$ErrorActionPreference = "Stop"',
|
|
99
|
+
'if ($env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX) {',
|
|
100
|
+
' Invoke-Expression $env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX',
|
|
101
|
+
'}',
|
|
102
|
+
'$exe = $args[0]',
|
|
103
|
+
'if ($args.Length -gt 1) { $rest = $args[1..($args.Length - 1)] } else { $rest = @() }',
|
|
104
|
+
'& $exe @rest',
|
|
105
|
+
'exit $LASTEXITCODE',
|
|
106
|
+
'',
|
|
107
|
+
].join('\r\n');
|
|
108
|
+
// Writes the small, fixed wrapper script that lets a configured-agent's
|
|
109
|
+
// env-prefix (which may contain `$(...)`/`(...)` substitutions) resolve in a
|
|
110
|
+
// real shell at spawn time, then `exec`s Hub's own required invocation with
|
|
111
|
+
// the parsed extra CLI args appended. Content-checked so it self-heals like
|
|
112
|
+
// any other FRAIM-generated artifact (issue #1206 precedent) rather than
|
|
113
|
+
// silently going stale if regenerated with different logic.
|
|
114
|
+
//
|
|
115
|
+
// Security review (issue #1201): written under the per-user FRAIM home
|
|
116
|
+
// (`getUserFraimDirPath()`), NOT `os.tmpdir()`. A predictable filename in a
|
|
117
|
+
// shared, world-writable OS temp directory is a symlink-race target on
|
|
118
|
+
// multi-user machines — another local user could pre-place a symlink at the
|
|
119
|
+
// predictable path, and `fs.writeFileSync` would follow it and overwrite an
|
|
120
|
+
// arbitrary file the *victim* user can write to. The per-user FRAIM home is
|
|
121
|
+
// not shared across OS accounts, closing that class of attack.
|
|
122
|
+
function ensureConfiguredAgentLaunchWrapper(runner) {
|
|
123
|
+
const content = runner === 'bash' ? BASH_LAUNCH_WRAPPER : POWERSHELL_LAUNCH_WRAPPER;
|
|
124
|
+
const filePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), runner === 'bash' ? 'hub-configured-agent-launch.sh' : 'hub-configured-agent-launch.ps1');
|
|
125
|
+
const existing = fs_1.default.existsSync(filePath) ? fs_1.default.readFileSync(filePath, 'utf8') : null;
|
|
126
|
+
if (existing !== content) {
|
|
127
|
+
fs_1.default.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 });
|
|
128
|
+
}
|
|
129
|
+
return filePath;
|
|
130
|
+
}
|
|
131
|
+
function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
|
|
132
|
+
if (!agent)
|
|
133
|
+
return plan;
|
|
134
|
+
const withEnv = { ...plan, env: { ...(plan.env || {}), ...agentEnv } };
|
|
135
|
+
if (!agent.command)
|
|
136
|
+
return withEnv;
|
|
137
|
+
const parsed = parseConfiguredCommand(agent.command.runner, agent.command.command);
|
|
138
|
+
if (!parsed.envPrefixText && parsed.extraArgs.length === 0)
|
|
139
|
+
return withEnv;
|
|
140
|
+
const wrapperPath = ensureConfiguredAgentLaunchWrapper(agent.command.runner);
|
|
141
|
+
const realInvocation = [plan.command, ...plan.args, ...parsed.extraArgs];
|
|
142
|
+
const decoratedEnv = { ...withEnv.env, FRAIM_CONFIGURED_AGENT_ENV_PREFIX: parsed.envPrefixText };
|
|
143
|
+
if (agent.command.runner === 'bash') {
|
|
144
|
+
return { ...withEnv, command: 'bash', args: [wrapperPath, ...realInvocation], env: decoratedEnv };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
...withEnv,
|
|
148
|
+
command: 'powershell.exe',
|
|
149
|
+
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', wrapperPath, ...realInvocation],
|
|
150
|
+
env: decoratedEnv,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -3,18 +3,18 @@ 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.AiHubConfiguredAgentStore = void 0;
|
|
6
|
+
exports.describeConfiguredCommandInvocationProblem = exports.decorateHostPlanWithConfiguredAgent = exports.commandContainsLiteralSecret = exports.parseConfiguredCommand = exports.AiHubConfiguredAgentStore = void 0;
|
|
7
7
|
exports.synthesizeDefaultConfiguredAgents = synthesizeDefaultConfiguredAgents;
|
|
8
8
|
exports.checkConfiguredAgentAvailability = checkConfiguredAgentAvailability;
|
|
9
9
|
exports.checkConfiguredAgentReadiness = checkConfiguredAgentReadiness;
|
|
10
10
|
exports.projectConfiguredAgent = projectConfiguredAgent;
|
|
11
11
|
exports.resolveConfiguredAgentForHost = resolveConfiguredAgentForHost;
|
|
12
12
|
exports.resolveConfiguredAgentEnv = resolveConfiguredAgentEnv;
|
|
13
|
-
exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
|
|
14
13
|
const fs_1 = __importDefault(require("fs"));
|
|
15
14
|
const path_1 = __importDefault(require("path"));
|
|
16
15
|
const child_process_1 = require("child_process");
|
|
17
16
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
|
+
const configured_agent_command_1 = require("./configured-agent-command");
|
|
18
18
|
const VALID_HOSTS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
19
19
|
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
20
20
|
function nowIso() {
|
|
@@ -48,6 +48,9 @@ function normalizeAgent(input) {
|
|
|
48
48
|
const setupScript = normalizeSetupScript(input.setupScript);
|
|
49
49
|
if (input.setupScript && !setupScript)
|
|
50
50
|
return { ok: false, error: 'Setup script command is required.' };
|
|
51
|
+
const command = normalizeCommand(input.command);
|
|
52
|
+
if (input.command && !command)
|
|
53
|
+
return { ok: false, error: 'Command is required.' };
|
|
51
54
|
const createdAt = input.createdAt || nowIso();
|
|
52
55
|
return {
|
|
53
56
|
ok: true,
|
|
@@ -57,6 +60,7 @@ function normalizeAgent(input) {
|
|
|
57
60
|
...(input.description && String(input.description).trim() ? { description: String(input.description).trim() } : {}),
|
|
58
61
|
baseHostId,
|
|
59
62
|
enabled: input.enabled !== false,
|
|
63
|
+
...(command ? { command } : {}),
|
|
60
64
|
...(setupScript ? { setupScript } : {}),
|
|
61
65
|
createdAt,
|
|
62
66
|
updatedAt: nowIso(),
|
|
@@ -77,6 +81,24 @@ function normalizeSetupScript(value) {
|
|
|
77
81
|
timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
|
|
78
82
|
};
|
|
79
83
|
}
|
|
84
|
+
// Issue #1201 (R2.1): the single-text-field setup surface. `runner` is an
|
|
85
|
+
// internal execution detail — callers that omit it get the current platform's
|
|
86
|
+
// default shell so the UI never has to ask the user to pick one.
|
|
87
|
+
function normalizeCommand(value) {
|
|
88
|
+
if (!value)
|
|
89
|
+
return null;
|
|
90
|
+
const command = String(value.command || '').trim();
|
|
91
|
+
if (!command)
|
|
92
|
+
return null;
|
|
93
|
+
const runner = value.runner === 'bash' || value.runner === 'powershell'
|
|
94
|
+
? value.runner
|
|
95
|
+
: (process.platform === 'win32' ? 'powershell' : 'bash');
|
|
96
|
+
return {
|
|
97
|
+
runner,
|
|
98
|
+
command,
|
|
99
|
+
timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
80
102
|
function synthesizeDefaultConfiguredAgents(employees) {
|
|
81
103
|
const timestamp = '1970-01-01T00:00:00.000Z';
|
|
82
104
|
return employees
|
|
@@ -152,7 +174,12 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
|
|
|
152
174
|
reasons.push('Configured agent is disabled.');
|
|
153
175
|
if (!employee?.available)
|
|
154
176
|
reasons.push(`${employee?.label || agent.baseHostId} is not available on this machine.`);
|
|
155
|
-
if (agent.
|
|
177
|
+
if (agent.command) {
|
|
178
|
+
if (!agent.command.command.trim()) {
|
|
179
|
+
reasons.push('Command is empty.');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (agent.setupScript) {
|
|
156
183
|
const command = agent.setupScript.command;
|
|
157
184
|
const isPathLike = /[\\/]/.test(command) || /^[A-Za-z]:[\\/]/.test(command);
|
|
158
185
|
if (isPathLike && !fs_1.default.existsSync(command)) {
|
|
@@ -160,13 +187,35 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
|
|
|
160
187
|
}
|
|
161
188
|
}
|
|
162
189
|
else if (!agent.id.endsWith('-default')) {
|
|
163
|
-
warnings.push('No
|
|
190
|
+
warnings.push('No command configured; this profile launches the base CLI unchanged.');
|
|
164
191
|
}
|
|
165
192
|
return { id: agent.id, label: agent.label, baseHostId: agent.baseHostId, enabled: agent.enabled, available: reasons.length === 0, reasons, warnings };
|
|
166
193
|
}
|
|
167
194
|
function checkConfiguredAgentReadiness(agent, employees, env = process.env) {
|
|
168
195
|
const check = checkConfiguredAgentAvailability(agent, employees, env);
|
|
169
|
-
if (!check.available
|
|
196
|
+
if (!check.available)
|
|
197
|
+
return check;
|
|
198
|
+
if (agent.command) {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = (0, configured_agent_command_1.parseConfiguredCommand)(agent.command.runner, agent.command.command);
|
|
201
|
+
const problem = (0, configured_agent_command_1.describeConfiguredCommandInvocationProblem)(parsed, agent.baseHostId);
|
|
202
|
+
if (problem) {
|
|
203
|
+
return { ...check, available: false, reasons: [...check.reasons, problem] };
|
|
204
|
+
}
|
|
205
|
+
return check;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
...check,
|
|
210
|
+
available: false,
|
|
211
|
+
reasons: [
|
|
212
|
+
...check.reasons,
|
|
213
|
+
error instanceof Error ? error.message : 'Configured agent command could not be parsed.',
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!agent.setupScript)
|
|
170
219
|
return check;
|
|
171
220
|
try {
|
|
172
221
|
resolveConfiguredAgentEnv(agent);
|
|
@@ -191,6 +240,7 @@ function projectConfiguredAgent(agent, employees) {
|
|
|
191
240
|
...(agent.description ? { description: agent.description } : {}),
|
|
192
241
|
baseHostId: agent.baseHostId,
|
|
193
242
|
enabled: agent.enabled,
|
|
243
|
+
...(agent.command ? { command: { ...agent.command } } : {}),
|
|
194
244
|
...(agent.setupScript ? { setupScript: { ...agent.setupScript, args: [...(agent.setupScript.args || [])] } } : {}),
|
|
195
245
|
createdAt: agent.createdAt,
|
|
196
246
|
updatedAt: agent.updatedAt,
|
|
@@ -295,11 +345,13 @@ function filterSafeEnv(env) {
|
|
|
295
345
|
}
|
|
296
346
|
return safe;
|
|
297
347
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
348
|
+
// Issue #1201: command parsing, secret detection, and launch decoration for
|
|
349
|
+
// the single-command configured-agent surface live in their own module
|
|
350
|
+
// (kept a cohesive, separately-testable unit rather than growing this file
|
|
351
|
+
// past the 500-line quality threshold) — re-exported here so existing
|
|
352
|
+
// callers importing from `./configured-agents` are unaffected.
|
|
353
|
+
var configured_agent_command_2 = require("./configured-agent-command");
|
|
354
|
+
Object.defineProperty(exports, "parseConfiguredCommand", { enumerable: true, get: function () { return configured_agent_command_2.parseConfiguredCommand; } });
|
|
355
|
+
Object.defineProperty(exports, "commandContainsLiteralSecret", { enumerable: true, get: function () { return configured_agent_command_2.commandContainsLiteralSecret; } });
|
|
356
|
+
Object.defineProperty(exports, "decorateHostPlanWithConfiguredAgent", { enumerable: true, get: function () { return configured_agent_command_2.decorateHostPlanWithConfiguredAgent; } });
|
|
357
|
+
Object.defineProperty(exports, "describeConfiguredCommandInvocationProblem", { enumerable: true, get: function () { return configured_agent_command_2.describeConfiguredCommandInvocationProblem; } });
|
|
@@ -4,28 +4,57 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.getLatestPublishedVersion = getLatestPublishedVersion;
|
|
7
|
+
exports.getCachedLatestPublishedVersion = getCachedLatestPublishedVersion;
|
|
8
|
+
exports.refreshLatestPublishedVersionInBackground = refreshLatestPublishedVersionInBackground;
|
|
7
9
|
exports.__resetLatestVersionCache = __resetLatestVersionCache;
|
|
8
10
|
const https_1 = __importDefault(require("https"));
|
|
9
11
|
// Issue #755: best-effort "latest published fraim version" for the Hub's
|
|
10
12
|
// update-available nudge. Cached in-process (1h TTL) and never throws — returns
|
|
11
13
|
// null when offline or the registry is unreachable, so the nudge simply hides.
|
|
12
14
|
const TTL_MS = 60 * 60 * 1000;
|
|
15
|
+
const FAILED_RETRY_MS = 5 * 60 * 1000;
|
|
13
16
|
const REGISTRY_URL = 'https://registry.npmjs.org/fraim/latest';
|
|
14
17
|
let cache = { value: null, at: 0 };
|
|
18
|
+
let lastAttemptAt = 0;
|
|
19
|
+
let inFlight = null;
|
|
15
20
|
async function getLatestPublishedVersion() {
|
|
16
21
|
const now = Date.now();
|
|
17
22
|
if (cache.at !== 0 && now - cache.at < TTL_MS)
|
|
18
23
|
return cache.value;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
return refreshLatestPublishedVersion();
|
|
25
|
+
}
|
|
26
|
+
function getCachedLatestPublishedVersion() {
|
|
27
|
+
return cache.value;
|
|
28
|
+
}
|
|
29
|
+
function refreshLatestPublishedVersionInBackground() {
|
|
30
|
+
void refreshLatestPublishedVersion();
|
|
31
|
+
}
|
|
32
|
+
async function refreshLatestPublishedVersion() {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (cache.at !== 0 && now - cache.at < TTL_MS)
|
|
35
|
+
return cache.value;
|
|
36
|
+
if (inFlight)
|
|
37
|
+
return inFlight;
|
|
38
|
+
if (cache.value === null && lastAttemptAt !== 0 && now - lastAttemptAt < FAILED_RETRY_MS) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
lastAttemptAt = now;
|
|
42
|
+
inFlight = fetchNpmLatest()
|
|
43
|
+
.then((value) => {
|
|
44
|
+
if (value !== null)
|
|
45
|
+
cache = { value, at: Date.now() };
|
|
46
|
+
return value ?? cache.value;
|
|
47
|
+
})
|
|
48
|
+
.finally(() => {
|
|
49
|
+
inFlight = null;
|
|
50
|
+
});
|
|
51
|
+
return inFlight;
|
|
25
52
|
}
|
|
26
53
|
/** Test-only: reset the module cache. */
|
|
27
54
|
function __resetLatestVersionCache() {
|
|
28
55
|
cache = { value: null, at: 0 };
|
|
56
|
+
lastAttemptAt = 0;
|
|
57
|
+
inFlight = null;
|
|
29
58
|
}
|
|
30
59
|
function fetchNpmLatest() {
|
|
31
60
|
return new Promise((resolve) => {
|
|
@@ -2653,6 +2653,20 @@ class AiHubServer {
|
|
|
2653
2653
|
configuredAgentsForCurrentMachine(employees = this.hostRuntime.detectEmployees()) {
|
|
2654
2654
|
return this.configuredAgentStore.listWithDefaults(employees);
|
|
2655
2655
|
}
|
|
2656
|
+
// Issue #1201 (R2.3): a UX safety net, not a replacement for the routing
|
|
2657
|
+
// skill's own secret-hygiene discipline — flags what pattern-matches as a
|
|
2658
|
+
// literal secret pasted into the single command field, on every write path
|
|
2659
|
+
// (UI form and direct API callers alike).
|
|
2660
|
+
projectConfiguredAgentWithSecretWarning(agent) {
|
|
2661
|
+
const projected = (0, configured_agents_1.projectConfiguredAgent)(agent, this.hostRuntime.detectEmployees());
|
|
2662
|
+
if (agent.command && (0, configured_agents_1.commandContainsLiteralSecret)(agent.command.command)) {
|
|
2663
|
+
return {
|
|
2664
|
+
...projected,
|
|
2665
|
+
secretWarning: 'This command looks like it contains a literal secret value. Reference where the secret resolves at runtime (a file, a keychain, an env var already set) instead of pasting the value directly.',
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
return projected;
|
|
2669
|
+
}
|
|
2656
2670
|
resolveLaunchAgent(configuredAgentId, hostId, employees = this.hostRuntime.detectEmployees()) {
|
|
2657
2671
|
const resolved = (0, configured_agents_1.resolveConfiguredAgentForHost)(configuredAgentId, hostId, this.configuredAgentsForCurrentMachine(employees));
|
|
2658
2672
|
const check = (0, configured_agents_1.checkConfiguredAgentAvailability)(resolved.agent, employees);
|
|
@@ -4925,7 +4939,8 @@ class AiHubServer {
|
|
|
4925
4939
|
// `fraim hub` CLI also queries this to confirm a running instance's version.
|
|
4926
4940
|
this.app.get('/api/ai-hub/version', async (_req, res) => {
|
|
4927
4941
|
const version = (0, version_utils_1.getFraimVersion)();
|
|
4928
|
-
const latest =
|
|
4942
|
+
const latest = (0, hub_latest_version_1.getCachedLatestPublishedVersion)();
|
|
4943
|
+
(0, hub_latest_version_1.refreshLatestPublishedVersionInBackground)();
|
|
4929
4944
|
const updateAvailable = isNewerPublishedVersion(latest, version);
|
|
4930
4945
|
const uiRuntime = await this.resolveHubUiRuntimeResponse();
|
|
4931
4946
|
return res.json({
|
|
@@ -5304,7 +5319,7 @@ class AiHubServer {
|
|
|
5304
5319
|
const result = this.configuredAgentStore.upsert(req.body);
|
|
5305
5320
|
if (!result.ok || !result.agent)
|
|
5306
5321
|
return res.status(400).json({ error: result.error || 'Invalid configured agent.' });
|
|
5307
|
-
return res.status(201).json(
|
|
5322
|
+
return res.status(201).json(this.projectConfiguredAgentWithSecretWarning(result.agent));
|
|
5308
5323
|
});
|
|
5309
5324
|
this.app.put('/api/ai-hub/configured-agents/:id', (req, res) => {
|
|
5310
5325
|
if (!this.requireTrustedHubOrigin(req, res))
|
|
@@ -5312,7 +5327,7 @@ class AiHubServer {
|
|
|
5312
5327
|
const result = this.configuredAgentStore.upsert({ ...req.body, id: req.params.id });
|
|
5313
5328
|
if (!result.ok || !result.agent)
|
|
5314
5329
|
return res.status(400).json({ error: result.error || 'Invalid configured agent.' });
|
|
5315
|
-
return res.json(
|
|
5330
|
+
return res.json(this.projectConfiguredAgentWithSecretWarning(result.agent));
|
|
5316
5331
|
});
|
|
5317
5332
|
this.app.delete('/api/ai-hub/configured-agents/:id', (req, res) => {
|
|
5318
5333
|
if (!this.requireTrustedHubOrigin(req, res))
|
|
@@ -5710,10 +5725,6 @@ class AiHubServer {
|
|
|
5710
5725
|
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus));
|
|
5711
5726
|
if (reviewApprovalSystemEventText)
|
|
5712
5727
|
current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
|
|
5713
|
-
// R38: mark REDIRECTING before the active child is even asked to stop, so
|
|
5714
|
-
// the UI sees the disabled/redirecting state in the very next poll.
|
|
5715
|
-
if (deliveryIntent === 'stop')
|
|
5716
|
-
current.pendingRedirect = true;
|
|
5717
5728
|
});
|
|
5718
5729
|
const started = this.runRegistry.get(run.id);
|
|
5719
5730
|
if (started)
|
|
@@ -5722,11 +5733,17 @@ class AiHubServer {
|
|
|
5722
5733
|
const continueLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
|
|
5723
5734
|
let codexMissingReasoningResumeError = false;
|
|
5724
5735
|
let startedCodexReviewApprovalFallback = false;
|
|
5725
|
-
//
|
|
5726
|
-
// event, the
|
|
5727
|
-
// closed and this one spawned
|
|
5728
|
-
//
|
|
5729
|
-
|
|
5736
|
+
// Issue #1249: once this continuation's own handlers see their first
|
|
5737
|
+
// event, the turn has actually started — the previous active child
|
|
5738
|
+
// closed and this one spawned. CliHostRuntime.dequeueNext drains its
|
|
5739
|
+
// entire pending queue into one combined continuation whenever the
|
|
5740
|
+
// active child closes, so by construction every message still
|
|
5741
|
+
// carrying a deliveryStatus (queued or redirecting) at this point has
|
|
5742
|
+
// just been delivered as part of this turn. Clear their badges here
|
|
5743
|
+
// instead of tracking a run-level flag (removed — see the issue for
|
|
5744
|
+
// why pendingRedirect was an unnecessary, bug-prone second lock on
|
|
5745
|
+
// top of the ordinary "running" state). A no-op when nothing queued.
|
|
5746
|
+
let turnStarted = false;
|
|
5730
5747
|
const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
|
|
5731
5748
|
onEvent: (event, channel) => {
|
|
5732
5749
|
if (run.hostId === 'codex' && reviewApprovalSystemEventText) {
|
|
@@ -5749,10 +5766,14 @@ class AiHubServer {
|
|
|
5749
5766
|
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
5750
5767
|
if (event.usage)
|
|
5751
5768
|
applyUsageSignal(current, event.usage);
|
|
5752
|
-
if (!
|
|
5753
|
-
current.
|
|
5769
|
+
if (!turnStarted) {
|
|
5770
|
+
for (const pending of current.messages) {
|
|
5771
|
+
if (pending.deliveryStatus)
|
|
5772
|
+
delete pending.deliveryStatus;
|
|
5773
|
+
}
|
|
5774
|
+
}
|
|
5754
5775
|
});
|
|
5755
|
-
|
|
5776
|
+
turnStarted = true;
|
|
5756
5777
|
const updated = this.runRegistry.get(run.id);
|
|
5757
5778
|
if (updated) {
|
|
5758
5779
|
this.maybeStartDelegatedChildRuns(updated);
|
|
@@ -42,7 +42,8 @@ function describeOnboardingInvocationSurfaces(installedIDEs) {
|
|
|
42
42
|
/**
|
|
43
43
|
* Install Claude FRAIM discovery artifacts at the user level.
|
|
44
44
|
* Writes a skill to ~/.claude/skills/fraim/SKILL.md and a compatibility command
|
|
45
|
-
* to ~/.claude/commands/fraim.md.
|
|
45
|
+
* to ~/.claude/commands/fraim.md. Stale content from prior FRAIM versions is
|
|
46
|
+
* refreshed; already-current content is left untouched.
|
|
46
47
|
*/
|
|
47
48
|
async function installSlashCommands(homeDir) {
|
|
48
49
|
const home = homeDir || os_1.default.homedir();
|
|
@@ -50,51 +51,59 @@ async function installSlashCommands(homeDir) {
|
|
|
50
51
|
if (!fs_1.default.existsSync(claudeDir)) {
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
installOrUpdateManagedFile(path_1.default.join(claudeDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildClaudeSkillContent)(), 'Claude FRAIM skill (~/.claude/skills/fraim/SKILL.md)');
|
|
55
|
+
installOrUpdateManagedFile(path_1.default.join(claudeDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildClaudeCommandShimContent)(), 'Claude compatibility command (~/.claude/commands/fraim.md)');
|
|
55
56
|
}
|
|
56
57
|
/**
|
|
57
58
|
* Install FRAIM invocation artifacts for non-Claude IDEs.
|
|
58
59
|
* Supports: Cursor, Codex, Grok, Gemini CLI, Copilot CLI, Windsurf, Kiro
|
|
59
|
-
*
|
|
60
|
+
* Stale content from prior FRAIM versions is refreshed; already-current
|
|
61
|
+
* content is left untouched.
|
|
60
62
|
*/
|
|
61
63
|
async function installGlobalRules(homeDir) {
|
|
62
64
|
const home = homeDir || os_1.default.homedir();
|
|
63
65
|
const cursorDir = path_1.default.join(home, '.cursor');
|
|
64
66
|
if (fs_1.default.existsSync(cursorDir)) {
|
|
65
|
-
|
|
67
|
+
installOrUpdateManagedFile(path_1.default.join(cursorDir, 'rules', 'fraim.mdc'), (0, ide_invocation_surfaces_1.buildCursorMentionRuleContent)(), 'Cursor FRAIM rule (~/.cursor/rules/fraim.mdc)');
|
|
66
68
|
}
|
|
67
69
|
const codexDir = path_1.default.join(home, '.codex');
|
|
68
70
|
if (fs_1.default.existsSync(codexDir)) {
|
|
69
|
-
|
|
71
|
+
installOrUpdateManagedFile(path_1.default.join(codexDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildCodexSkillContent)(), 'Codex FRAIM skill (~/.codex/skills/fraim/SKILL.md)');
|
|
70
72
|
}
|
|
71
73
|
const grokDir = path_1.default.join(home, '.grok');
|
|
72
74
|
if (fs_1.default.existsSync(grokDir)) {
|
|
73
|
-
|
|
75
|
+
installOrUpdateManagedFile(path_1.default.join(grokDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildGrokSkillContent)(), 'Grok FRAIM skill (~/.grok/skills/fraim/SKILL.md)');
|
|
74
76
|
}
|
|
75
77
|
const geminiDir = path_1.default.join(home, '.gemini');
|
|
76
78
|
if (fs_1.default.existsSync(geminiDir)) {
|
|
77
|
-
|
|
79
|
+
installOrUpdateManagedFile(path_1.default.join(geminiDir, 'commands', 'fraim.toml'), (0, ide_invocation_surfaces_1.buildGeminiCommandContent)(), 'Gemini CLI FRAIM command (~/.gemini/commands/fraim.toml)');
|
|
78
80
|
}
|
|
79
81
|
const antigravityDir = path_1.default.join(home, '.gemini', 'antigravity');
|
|
80
82
|
if (fs_1.default.existsSync(antigravityDir)) {
|
|
81
|
-
|
|
83
|
+
installOrUpdateManagedFile(path_1.default.join(antigravityDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildAntigravityCommandContent)(), 'Antigravity FRAIM command (~/.gemini/antigravity/commands/fraim.md)');
|
|
82
84
|
}
|
|
83
85
|
const windsurfDir = path_1.default.join(home, '.codeium', 'windsurf');
|
|
84
86
|
if (fs_1.default.existsSync(windsurfDir)) {
|
|
85
|
-
|
|
87
|
+
installOrUpdateManagedFile(path_1.default.join(windsurfDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildWindsurfCommandContent)(), 'Windsurf FRAIM command (~/.codeium/windsurf/commands/fraim.md)');
|
|
86
88
|
}
|
|
87
89
|
const kiroDir = path_1.default.join(home, '.kiro');
|
|
88
90
|
if (fs_1.default.existsSync(kiroDir)) {
|
|
89
|
-
|
|
91
|
+
installOrUpdateManagedFile(path_1.default.join(kiroDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildKiroCommandContent)(), 'Kiro FRAIM command (~/.kiro/commands/fraim.md)');
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// These paths (~/.claude/skills/fraim/, ~/.codex/skills/fraim/, etc.) are
|
|
95
|
+
// FRAIM-exclusive — nothing else writes to them — so the file is always
|
|
96
|
+
// brought in line with the current generator output, the same contract
|
|
97
|
+
// `ensureAgentAdapterFiles()` already uses for the equivalent project-level
|
|
98
|
+
// files in agent-adapters.ts. Skipping on existence (issue #1242) permanently
|
|
99
|
+
// locked in stale content from older FRAIM releases with no way to self-heal.
|
|
100
|
+
function installOrUpdateManagedFile(filePath, content, displayName) {
|
|
101
|
+
const existing = fs_1.default.existsSync(filePath) ? fs_1.default.readFileSync(filePath, 'utf8') : null;
|
|
102
|
+
if (existing === content) {
|
|
103
|
+
console.log(chalk_1.default.gray(` ${displayName.split('(')[0].trim()} already up to date — skipping`));
|
|
95
104
|
return;
|
|
96
105
|
}
|
|
97
106
|
fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
|
|
98
107
|
fs_1.default.writeFileSync(filePath, content, 'utf8');
|
|
99
|
-
console.log(chalk_1.default.green(` ✅ Installed ${displayName}`));
|
|
108
|
+
console.log(chalk_1.default.green(` ✅ ${existing === null ? 'Installed' : 'Updated'} ${displayName}`));
|
|
100
109
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.276",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
"electron-updater": "^6.8.9",
|
|
169
169
|
"express": "^5.2.1",
|
|
170
170
|
"extract-zip": "^2.0.1",
|
|
171
|
-
"fraim": "2.0.
|
|
171
|
+
"fraim": "2.0.276",
|
|
172
172
|
"mongodb": "^7.0.0",
|
|
173
173
|
"node-cron": "4.2.1",
|
|
174
174
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -2803,9 +2803,6 @@ function conversationStateDotClass(conv) {
|
|
|
2803
2803
|
}
|
|
2804
2804
|
|
|
2805
2805
|
function conversationStateLabel(conv) {
|
|
2806
|
-
// Issue #1176 R38: a "Redirect now" course-correction in flight takes
|
|
2807
|
-
// priority over the ordinary Working label until the corrective turn starts.
|
|
2808
|
-
if (conv && conv.pendingRedirect) return 'Redirecting';
|
|
2809
2806
|
if (isManagedDelegationChild(conv)) {
|
|
2810
2807
|
if (conv.status === 'running') return 'Working for Mandy';
|
|
2811
2808
|
if (conv.status === 'completed' && conv.managedReviewStatus === 'reviewed') return 'Reviewed by Mandy';
|
|
@@ -3661,10 +3658,18 @@ function renderActive() {
|
|
|
3661
3658
|
}
|
|
3662
3659
|
|
|
3663
3660
|
// FRAIM messages — render into #messages via appendMessageDom (same for AB and non-AB).
|
|
3664
|
-
|
|
3661
|
+
// Issue #1249: include deliveryStatus in the fingerprint. Without it, a
|
|
3662
|
+
// server-side badge clear (queued/redirecting -> delivered) is invisible to
|
|
3663
|
+
// this diff (same role/text/timestamp), so the "Queued N" / "Redirecting
|
|
3664
|
+
// now" badge would never disappear even after the server clears the field.
|
|
3665
|
+
const messageFingerprints = messages.map((message) => `${message.role || ''}\u0000${message.text || ''}\u0000${messageTimestamp(message) || ''}\u0000${message.deliveryStatus ? JSON.stringify(message.deliveryStatus) : ''}`);
|
|
3665
3666
|
const messagesMutated =
|
|
3666
3667
|
messages.length === renderedMessageCount &&
|
|
3667
3668
|
messageFingerprints.some((fingerprint, index) => fingerprint !== renderedMessageFingerprints[index]);
|
|
3669
|
+
// Issue #1249: capture the pre-reset count so "newly arrived" below reflects
|
|
3670
|
+
// genuinely new messages, not every row a messagesMutated full rebuild just
|
|
3671
|
+
// re-appended (e.g. an existing message's badge disappearing).
|
|
3672
|
+
const priorRenderedMessageCount = renderedMessageCount;
|
|
3668
3673
|
if (messages.length < renderedMessageCount || messagesMutated) {
|
|
3669
3674
|
els['messages'].innerHTML = '';
|
|
3670
3675
|
renderedMessageCount = 0;
|
|
@@ -3676,6 +3681,10 @@ function renderActive() {
|
|
|
3676
3681
|
const appendedMessages = messages.length - renderedMessageCount;
|
|
3677
3682
|
renderedMessageCount = messages.length;
|
|
3678
3683
|
renderedMessageFingerprints = messageFingerprints;
|
|
3684
|
+
// Issue #1249 (Problem 2): a message the manager just sent while the agent
|
|
3685
|
+
// was mid-turn (queued or redirecting) must be visible without the manager
|
|
3686
|
+
// scrolling, even if they had scrolled up to review earlier history.
|
|
3687
|
+
const hasNewPendingDeliveryMessage = messages.slice(priorRenderedMessageCount).some((message) => !!message.deliveryStatus);
|
|
3679
3688
|
syncWorkingIndicator(conv);
|
|
3680
3689
|
// Direct raw event log — append-only like the FRAIM micro-log, shows all
|
|
3681
3690
|
// stdout/stderr/system events from the B run so the user sees everything.
|
|
@@ -3717,18 +3726,18 @@ function renderActive() {
|
|
|
3717
3726
|
// marks a manager is stepping through. Re-emit them from find state instead of injecting once.
|
|
3718
3727
|
if (typeof convFindRefresh === 'function') convFindRefresh();
|
|
3719
3728
|
const shouldScrollForUpdate = switchedConv || statusChanged || appendedMessages > 0 || messagesMutated;
|
|
3720
|
-
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate);
|
|
3729
|
+
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage);
|
|
3721
3730
|
requestAnimationFrame(() => {
|
|
3722
3731
|
syncThreadMessageViewport();
|
|
3723
|
-
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate);
|
|
3732
|
+
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage);
|
|
3724
3733
|
});
|
|
3725
3734
|
setTimeout(() => {
|
|
3726
3735
|
syncThreadMessageViewport();
|
|
3727
|
-
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate);
|
|
3736
|
+
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage);
|
|
3728
3737
|
});
|
|
3729
3738
|
setTimeout(() => {
|
|
3730
3739
|
syncThreadMessageViewport();
|
|
3731
|
-
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate);
|
|
3740
|
+
scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage);
|
|
3732
3741
|
}, 50);
|
|
3733
3742
|
|
|
3734
3743
|
// Issue #347 — tracker, totals, picker. The data lives on conv.run
|
|
@@ -3827,8 +3836,7 @@ function renderRunStatePill(conv) {
|
|
|
3827
3836
|
// #549 R3: conversationUiState now returns 'stopped' for manager-stopped runs.
|
|
3828
3837
|
// conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
|
|
3829
3838
|
pill.textContent = conversationStateLabel(conv).toUpperCase();
|
|
3830
|
-
|
|
3831
|
-
pill.className = `run-state-pill ${conversationUiState(conv)}${conv.pendingRedirect ? ' redirecting' : ''}`;
|
|
3839
|
+
pill.className = `run-state-pill ${conversationUiState(conv)}`;
|
|
3832
3840
|
// #521: Stop is offered only while the employee is actively working.
|
|
3833
3841
|
const stopBtn = els['run-stop-btn'];
|
|
3834
3842
|
if (stopBtn) {
|
|
@@ -4949,6 +4957,9 @@ function latestEmployeeSurfaceText(conv) {
|
|
|
4949
4957
|
function appendMessageDom(role, text, conv, message) {
|
|
4950
4958
|
const article = document.createElement('article');
|
|
4951
4959
|
article.className = 'message ' + role;
|
|
4960
|
+
// Issue #1249: tag pending (queued/redirecting) rows so the scroll-anchor
|
|
4961
|
+
// logic can find the whole backlog, not just the newest one.
|
|
4962
|
+
if (message && message.deliveryStatus) article.dataset.pending = 'true';
|
|
4952
4963
|
|
|
4953
4964
|
const meta = document.createElement('div');
|
|
4954
4965
|
meta.className = 'message-meta';
|
|
@@ -5041,7 +5052,26 @@ function syncThreadMessageViewport() {
|
|
|
5041
5052
|
messages.style.maxHeight = '';
|
|
5042
5053
|
}
|
|
5043
5054
|
|
|
5044
|
-
|
|
5055
|
+
// Issue #1249: scrolling all the way to scrollHeight only ever reveals the
|
|
5056
|
+
// newest message. When multiple messages are queued behind the same active
|
|
5057
|
+
// turn, that clips earlier queued/redirecting rows off the top of the visible
|
|
5058
|
+
// area — exactly the "I don't see the whole queued backlog" gap reported on
|
|
5059
|
+
// the PR. Anchor on the first still-pending row instead: scrollTop = min(that
|
|
5060
|
+
// row's offset, the absolute max scroll). When the backlog fits in one
|
|
5061
|
+
// screen, that offset is already >= maxScrollTop, so this clamps to the same
|
|
5062
|
+
// bottom-of-thread position as before (no change from prior behavior). When
|
|
5063
|
+
// the backlog is taller than the container and can't fully fit, this favors
|
|
5064
|
+
// showing it FROM THE START (so the manager sees "yes, multiple things are
|
|
5065
|
+
// queued, here's the first one") instead of only ever showing the tail end.
|
|
5066
|
+
function pendingBacklogScrollTop(host) {
|
|
5067
|
+
const firstPending = host.querySelector('.message[data-pending="true"]');
|
|
5068
|
+
if (!firstPending) return host.scrollHeight;
|
|
5069
|
+
const backlogTop = firstPending.getBoundingClientRect().top - host.getBoundingClientRect().top + host.scrollTop;
|
|
5070
|
+
const maxScrollTop = host.scrollHeight - host.clientHeight;
|
|
5071
|
+
return Math.max(0, Math.min(backlogTop, maxScrollTop));
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom) {
|
|
5045
5075
|
const latest = activeConversation();
|
|
5046
5076
|
if (!latest || latest.id !== conv.id) return;
|
|
5047
5077
|
const host = els['messages'];
|
|
@@ -5050,8 +5080,11 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate) {
|
|
|
5050
5080
|
// #936: re-evaluate nearBottom at call time so deferred invocations respect
|
|
5051
5081
|
// any scroll the user made between the render tick and this callback.
|
|
5052
5082
|
const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
|
|
5053
|
-
|
|
5054
|
-
|
|
5083
|
+
// Issue #1249: a message the manager just sent while the agent was
|
|
5084
|
+
// mid-turn (queued or redirecting) must be visible without the manager
|
|
5085
|
+
// having to scroll, even if they had scrolled up to review history.
|
|
5086
|
+
if (nearBottom || forceBottom) {
|
|
5087
|
+
host.scrollTop = pendingBacklogScrollTop(host);
|
|
5055
5088
|
}
|
|
5056
5089
|
return;
|
|
5057
5090
|
} else if (shouldScrollForUpdate) {
|
|
@@ -5095,13 +5128,6 @@ function syncSendButton() {
|
|
|
5095
5128
|
els['send'].disabled = true;
|
|
5096
5129
|
return;
|
|
5097
5130
|
}
|
|
5098
|
-
// Issue #1176 R38: block a second submission while a "Redirect now" is
|
|
5099
|
-
// in flight (the active child hasn't closed yet), so the manager can't fire
|
|
5100
|
-
// a duplicate correction on top of one already stopping the current turn.
|
|
5101
|
-
if (conv && conv.pendingRedirect) {
|
|
5102
|
-
els['send'].disabled = true;
|
|
5103
|
-
return;
|
|
5104
|
-
}
|
|
5105
5131
|
// Send is enabled as soon as the host session exists. We deliberately
|
|
5106
5132
|
// do NOT gate on status='running' — the manager should be able to
|
|
5107
5133
|
// coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
|
|
@@ -7987,9 +8013,6 @@ function foldRunIntoConversation(conv, run) {
|
|
|
7987
8013
|
if (run.managedByRunId !== undefined) conv.managedByRunId = run.managedByRunId;
|
|
7988
8014
|
if (run.managedByPersonaKey !== undefined) conv.managedByPersonaKey = run.managedByPersonaKey;
|
|
7989
8015
|
if (run.humanCoachingDisabled !== undefined) conv.humanCoachingDisabled = run.humanCoachingDisabled;
|
|
7990
|
-
// Issue #1176: transient REDIRECTING flag, cleared server-side once the
|
|
7991
|
-
// corrective continuation actually starts — just mirror it every poll.
|
|
7992
|
-
conv.pendingRedirect = !!run.pendingRedirect;
|
|
7993
8016
|
if (run.managedReviewStatus !== undefined) conv.managedReviewStatus = run.managedReviewStatus;
|
|
7994
8017
|
if ((conv.createdAt === undefined || conv.createdAt === null) && run.createdAt !== undefined && run.createdAt !== null) {
|
|
7995
8018
|
conv.createdAt = run.createdAt;
|
|
@@ -8545,7 +8568,8 @@ function renderConfiguredAgentsPanel() {
|
|
|
8545
8568
|
const meta = document.createElement('div');
|
|
8546
8569
|
meta.className = 'configured-agent-meta';
|
|
8547
8570
|
const parts = [`CLI: ${agent.baseHostId}`];
|
|
8548
|
-
if (agent.
|
|
8571
|
+
if (agent.command?.command) parts.push(`command: ${agent.command.command}`);
|
|
8572
|
+
else if (agent.setupScript?.command) parts.push(`setup: ${agent.setupScript.command}`);
|
|
8549
8573
|
meta.textContent = parts.join(' | ');
|
|
8550
8574
|
|
|
8551
8575
|
const detail = document.createElement('div');
|
|
@@ -8712,13 +8736,6 @@ function configuredAgentField(labelText, control) {
|
|
|
8712
8736
|
return label;
|
|
8713
8737
|
}
|
|
8714
8738
|
|
|
8715
|
-
function configuredAgentTextarea(value) {
|
|
8716
|
-
const textarea = document.createElement('textarea');
|
|
8717
|
-
textarea.rows = 2;
|
|
8718
|
-
textarea.value = (value || []).join('\n');
|
|
8719
|
-
return textarea;
|
|
8720
|
-
}
|
|
8721
|
-
|
|
8722
8739
|
function buildConfiguredAgentForm(agent) {
|
|
8723
8740
|
const form = document.createElement('form');
|
|
8724
8741
|
form.className = 'configured-agent-form';
|
|
@@ -8745,33 +8762,44 @@ function buildConfiguredAgentForm(agent) {
|
|
|
8745
8762
|
const enabled = document.createElement('input');
|
|
8746
8763
|
enabled.type = 'checkbox';
|
|
8747
8764
|
enabled.checked = agent ? agent.enabled !== false : true;
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
+
|
|
8766
|
+
// The setup surface is one command — literal text, including any env-var
|
|
8767
|
+
// prefix and any CLI flag. No runner/args/timeout fields for the user to
|
|
8768
|
+
// coordinate; runner is derived from this Hub host's own platform at save
|
|
8769
|
+
// time ("implementation detail" allowance).
|
|
8770
|
+
const command = document.createElement('textarea');
|
|
8771
|
+
command.rows = 3;
|
|
8772
|
+
command.className = 'configured-agent-command-field';
|
|
8773
|
+
command.placeholder = 'codex --profile azure';
|
|
8774
|
+
if (agent?.command?.command) {
|
|
8775
|
+
command.value = agent.command.command;
|
|
8776
|
+
}
|
|
8777
|
+
// Legacy `setupScript`-shaped agents are NOT translated into a synthesized
|
|
8778
|
+
// command string here. A dot-sourced line like `. <path> <args>` parses as
|
|
8779
|
+
// a valid-looking command, but sourcing a script and launching the base
|
|
8780
|
+
// agent with extra flags are structurally different operations — the
|
|
8781
|
+
// launch decorator only ever keeps the trailing flags of `command` and
|
|
8782
|
+
// discards the leading token, so a script path placed there is silently
|
|
8783
|
+
// dropped as a bogus flag rather than ever sourced (PR #1209 review).
|
|
8784
|
+
// Leaving Command blank and round-tripping the existing `setupScript`
|
|
8785
|
+
// unchanged on save (below) keeps the agent working; typing a real single
|
|
8786
|
+
// command here is a deliberate migration away from the script.
|
|
8787
|
+
const legacySetupScript = !agent?.command && agent?.setupScript ? agent.setupScript : null;
|
|
8788
|
+
const commandHint = document.createElement('p');
|
|
8789
|
+
commandHint.className = 'configured-agent-command-hint';
|
|
8790
|
+
commandHint.textContent = legacySetupScript
|
|
8791
|
+
? `This agent currently uses a legacy setup script (${legacySetupScript.command}). Leave Command blank to keep it working as-is, or enter the single command a terminal user would type to migrate away from the script.`
|
|
8792
|
+
: 'Reference where a secret resolves at runtime (a file, a keychain, an already-set env var) — never paste a literal secret value here.';
|
|
8765
8793
|
|
|
8766
8794
|
form.appendChild(configuredAgentField('ID', id));
|
|
8767
8795
|
form.appendChild(configuredAgentField('Label', label));
|
|
8768
8796
|
form.appendChild(configuredAgentField('Base tool', baseHost));
|
|
8769
8797
|
form.appendChild(configuredAgentField('Description', description));
|
|
8770
8798
|
form.appendChild(configuredAgentField('Enabled', enabled));
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
form.appendChild(
|
|
8774
|
-
form.appendChild(
|
|
8799
|
+
const commandField = configuredAgentField('Command', command);
|
|
8800
|
+
commandField.classList.add('configured-agent-command-wrap');
|
|
8801
|
+
form.appendChild(commandField);
|
|
8802
|
+
form.appendChild(commandHint);
|
|
8775
8803
|
|
|
8776
8804
|
const actions = document.createElement('div');
|
|
8777
8805
|
actions.className = 'configured-agent-form-actions';
|
|
@@ -8793,24 +8821,30 @@ function buildConfiguredAgentForm(agent) {
|
|
|
8793
8821
|
|
|
8794
8822
|
form.addEventListener('submit', async (event) => {
|
|
8795
8823
|
event.preventDefault();
|
|
8796
|
-
const
|
|
8824
|
+
const commandValue = command.value.trim();
|
|
8825
|
+
// Client-side warning before saving — a safety net, not a substitute
|
|
8826
|
+
// for the routing skill never generating such a command.
|
|
8827
|
+
if (commandValue && configuredAgentCommandLooksLikeSecret(commandValue)) {
|
|
8828
|
+
const proceed = window.confirm(
|
|
8829
|
+
'This command looks like it contains a literal secret value. Reference where the secret resolves at runtime instead. Save anyway?'
|
|
8830
|
+
);
|
|
8831
|
+
if (!proceed) return;
|
|
8832
|
+
}
|
|
8797
8833
|
const body = {
|
|
8798
8834
|
id: id.value.trim(),
|
|
8799
8835
|
label: label.value.trim(),
|
|
8800
8836
|
baseHostId: baseHost.value,
|
|
8801
8837
|
description: description.value.trim(),
|
|
8802
8838
|
enabled: enabled.checked,
|
|
8803
|
-
...(
|
|
8804
|
-
|
|
8805
|
-
runner:
|
|
8806
|
-
command:
|
|
8807
|
-
args: scriptArgs.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
|
8808
|
-
timeoutMs: Number(timeout.value) || 10000,
|
|
8839
|
+
...(commandValue ? {
|
|
8840
|
+
command: {
|
|
8841
|
+
runner: configuredAgentPlatformRunner(),
|
|
8842
|
+
command: commandValue,
|
|
8809
8843
|
},
|
|
8810
|
-
} : {}),
|
|
8844
|
+
} : legacySetupScript ? { setupScript: legacySetupScript } : {}),
|
|
8811
8845
|
};
|
|
8812
8846
|
try {
|
|
8813
|
-
await requestJson(isNew
|
|
8847
|
+
const saved = await requestJson(isNew
|
|
8814
8848
|
? '/api/ai-hub/configured-agents'
|
|
8815
8849
|
: `/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}`, {
|
|
8816
8850
|
method: isNew ? 'POST' : 'PUT',
|
|
@@ -8820,7 +8854,7 @@ function buildConfiguredAgentForm(agent) {
|
|
|
8820
8854
|
state.configuredAgentEditingId = null;
|
|
8821
8855
|
delete state.configuredAgentCheckResults[body.id || agent?.id];
|
|
8822
8856
|
await refreshConfiguredAgents();
|
|
8823
|
-
showStatus(`${body.label || body.id} saved
|
|
8857
|
+
showStatus(saved?.secretWarning || `${body.label || body.id} saved.`, !!saved?.secretWarning);
|
|
8824
8858
|
} catch (err) {
|
|
8825
8859
|
showStatus(err.message || 'Save failed.', true);
|
|
8826
8860
|
}
|
|
@@ -8828,17 +8862,65 @@ function buildConfiguredAgentForm(agent) {
|
|
|
8828
8862
|
return form;
|
|
8829
8863
|
}
|
|
8830
8864
|
|
|
8865
|
+
// Runner is an internal execution detail derived from this Hub host's own
|
|
8866
|
+
// platform, never a field the setup surface asks the user to pick.
|
|
8867
|
+
function configuredAgentPlatformRunner() {
|
|
8868
|
+
const platform = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
|
|
8869
|
+
return /Windows/i.test(platform) ? 'powershell' : 'bash';
|
|
8870
|
+
}
|
|
8871
|
+
|
|
8872
|
+
// Mirrors the server-side check in configured-agent-command.ts's
|
|
8873
|
+
// commandContainsLiteralSecret so the user sees the warning before the
|
|
8874
|
+
// request round-trips, not only after.
|
|
8875
|
+
function configuredAgentCommandLooksLikeSecret(commandText) {
|
|
8876
|
+
for (const token of String(commandText || '').split(/\s+/)) {
|
|
8877
|
+
const eq = token.indexOf('=');
|
|
8878
|
+
if (eq <= 0) continue;
|
|
8879
|
+
const value = token.slice(eq + 1);
|
|
8880
|
+
if (!value || value.startsWith('$') || value.startsWith('`') || value.startsWith('(')) continue;
|
|
8881
|
+
if (/^[A-Za-z0-9+/_.=-]{20,}$/.test(value)) return true;
|
|
8882
|
+
}
|
|
8883
|
+
return false;
|
|
8884
|
+
}
|
|
8885
|
+
|
|
8831
8886
|
async function refreshConfiguredAgents() {
|
|
8832
8887
|
const configuredAgents = await requestJson('/api/ai-hub/configured-agents');
|
|
8833
|
-
|
|
8834
|
-
|
|
8888
|
+
const next = Array.isArray(configuredAgents) ? configuredAgents : [];
|
|
8889
|
+
if (state.bootstrap) state.bootstrap.configuredAgents = next;
|
|
8890
|
+
const ids = new Set(next.map((agent) => agent.id));
|
|
8835
8891
|
for (const id of Object.keys(state.configuredAgentCheckResults)) {
|
|
8836
8892
|
if (!ids.has(id)) delete state.configuredAgentCheckResults[id];
|
|
8837
8893
|
}
|
|
8838
|
-
|
|
8894
|
+
// A configured agent currently mid-edit in an open form in this window
|
|
8895
|
+
// must not have that edit silently discarded by an incoming external
|
|
8896
|
+
// change. Data is refreshed either way; only the panel/form re-render is
|
|
8897
|
+
// deferred while an existing (non-`__new__`) agent is being edited.
|
|
8898
|
+
const editingExisting = state.configuredAgentEditingId && state.configuredAgentEditingId !== '__new__';
|
|
8899
|
+
if (!editingExisting) {
|
|
8900
|
+
renderConfiguredAgentsPanel();
|
|
8901
|
+
}
|
|
8839
8902
|
renderActiveEmployeeSelect(activeConversation());
|
|
8840
8903
|
}
|
|
8841
8904
|
|
|
8905
|
+
// An already-open Hub window must reflect a configured agent
|
|
8906
|
+
// registered/updated/deleted through the API by any actor (e.g. a FRAIM job
|
|
8907
|
+
// running in a separate terminal), without a reload or restart. Short
|
|
8908
|
+
// interval: this surface's own evidence showed the configured-agents API
|
|
8909
|
+
// responding sub-second locally, and the list is cheap to poll.
|
|
8910
|
+
let _bgConfiguredAgentPollHandle = null;
|
|
8911
|
+
async function bgRefreshConfiguredAgents() {
|
|
8912
|
+
try {
|
|
8913
|
+
await refreshConfiguredAgents();
|
|
8914
|
+
} catch (_) { /* silent — background poll, not critical */ }
|
|
8915
|
+
}
|
|
8916
|
+
function startBgConfiguredAgentPoll() {
|
|
8917
|
+
if (_bgConfiguredAgentPollHandle) return;
|
|
8918
|
+
_bgConfiguredAgentPollHandle = window.setInterval(bgRefreshConfiguredAgents, 5000);
|
|
8919
|
+
}
|
|
8920
|
+
function stopBgConfiguredAgentPoll() {
|
|
8921
|
+
if (_bgConfiguredAgentPollHandle) { window.clearInterval(_bgConfiguredAgentPollHandle); _bgConfiguredAgentPollHandle = null; }
|
|
8922
|
+
}
|
|
8923
|
+
|
|
8842
8924
|
// Also add delegate buttons to the team roster (R8-R9).
|
|
8843
8925
|
// Called from renderTeamRoster (patched below) when managerTeam has members.
|
|
8844
8926
|
function renderDelegateButton(personaKey) {
|
|
@@ -9365,6 +9447,10 @@ function wireEvents() {
|
|
|
9365
9447
|
if (typeof tfRenderOverview === 'function' && typeof tf !== 'undefined' && tf.projectView === 'overview') tfRenderOverview();
|
|
9366
9448
|
}, 0);
|
|
9367
9449
|
startBgConvPoll();
|
|
9450
|
+
// Keep the AI Agents panel/picker current without a reload when a
|
|
9451
|
+
// configured agent is registered/updated/deleted by a separate process
|
|
9452
|
+
// (e.g. a FRAIM job running in a terminal).
|
|
9453
|
+
startBgConfiguredAgentPoll();
|
|
9368
9454
|
// #769: post-Stripe checkout redirect — start hire-pending polling so the new
|
|
9369
9455
|
// employee appears as soon as the webhook completes, without a manual reload.
|
|
9370
9456
|
if (isHiredRedirect) beginHirePendingPoll();
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -2001,11 +2001,6 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
2001
2001
|
color: var(--danger);
|
|
2002
2002
|
font-weight: 600;
|
|
2003
2003
|
}
|
|
2004
|
-
/* REDIRECTING run-state pill, matching the .failed danger treatment. */
|
|
2005
|
-
.run-state-pill.redirecting {
|
|
2006
|
-
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
|
2007
|
-
color: var(--danger);
|
|
2008
|
-
}
|
|
2009
2004
|
/* Per-message queued/redirecting badge shown next to a manager bubble's meta row. */
|
|
2010
2005
|
.message-delivery-badge {
|
|
2011
2006
|
display: inline-flex;
|
|
@@ -5893,6 +5888,28 @@ img.eh-av { object-fit: cover; background: var(--surface); }
|
|
|
5893
5888
|
resize: vertical;
|
|
5894
5889
|
}
|
|
5895
5890
|
|
|
5891
|
+
.configured-agent-command-wrap {
|
|
5892
|
+
grid-column: 1 / -1;
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
/* Specificity note: `.configured-agent-field textarea` already sets
|
|
5896
|
+
`font: inherit` (the shorthand resets font-family too), at specificity
|
|
5897
|
+
(0,1,1) — higher than a bare `.configured-agent-command-field` class
|
|
5898
|
+
selector (0,1,0), which would silently lose. Scope through the parent
|
|
5899
|
+
class so this rule wins. */
|
|
5900
|
+
.configured-agent-field .configured-agent-command-field {
|
|
5901
|
+
font-family: var(--mono, monospace);
|
|
5902
|
+
font-size: 12px;
|
|
5903
|
+
}
|
|
5904
|
+
|
|
5905
|
+
.configured-agent-command-hint {
|
|
5906
|
+
grid-column: 1 / -1;
|
|
5907
|
+
margin: -4px 0 0;
|
|
5908
|
+
color: var(--muted);
|
|
5909
|
+
font-size: 11px;
|
|
5910
|
+
line-height: 1.35;
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5896
5913
|
.configured-agent-form-actions {
|
|
5897
5914
|
grid-column: 1 / -1;
|
|
5898
5915
|
justify-content: flex-end;
|