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.
@@ -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.setupScript) {
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 setup script configured; this profile launches the base CLI unchanged.');
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 || !agent.setupScript)
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
- function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
299
- if (!agent)
300
- return plan;
301
- return {
302
- ...plan,
303
- env: { ...(plan.env || {}), ...agentEnv },
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
- const value = await fetchNpmLatest();
20
- // Only cache successful lookups; a failed fetch retries next call rather than
21
- // caching a null for an hour.
22
- if (value !== null)
23
- cache = { value, at: now };
24
- return value ?? cache.value;
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 = await (0, hub_latest_version_1.getLatestPublishedVersion)();
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((0, configured_agents_1.projectConfiguredAgent)(result.agent, this.hostRuntime.detectEmployees()));
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((0, configured_agents_1.projectConfiguredAgent)(result.agent, this.hostRuntime.detectEmployees()));
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
- // R38: once the corrective continuation's own handlers see their first
5726
- // event, the redirect has actually started (the previous active child
5727
- // closed and this one spawned) — clear the transient flag so Send
5728
- // re-enables. A no-op for the common (non-redirect) 'after' case.
5729
- let redirectStarted = deliveryIntent !== 'stop';
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 (!redirectStarted)
5753
- current.pendingRedirect = false;
5769
+ if (!turnStarted) {
5770
+ for (const pending of current.messages) {
5771
+ if (pending.deliveryStatus)
5772
+ delete pending.deliveryStatus;
5773
+ }
5774
+ }
5754
5775
  });
5755
- redirectStarted = true;
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. Existing user files are preserved.
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
- installFileIfMissing(path_1.default.join(claudeDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildClaudeSkillContent)(), 'Claude FRAIM skill (~/.claude/skills/fraim/SKILL.md)');
54
- installFileIfMissing(path_1.default.join(claudeDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildClaudeCommandShimContent)(), 'Claude compatibility command (~/.claude/commands/fraim.md)');
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
- * Does not overwrite existing files.
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
- installFileIfMissing(path_1.default.join(cursorDir, 'rules', 'fraim.mdc'), (0, ide_invocation_surfaces_1.buildCursorMentionRuleContent)(), 'Cursor FRAIM rule (~/.cursor/rules/fraim.mdc)');
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
- installFileIfMissing(path_1.default.join(codexDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildCodexSkillContent)(), 'Codex FRAIM skill (~/.codex/skills/fraim/SKILL.md)');
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
- installFileIfMissing(path_1.default.join(grokDir, 'skills', 'fraim', 'SKILL.md'), (0, ide_invocation_surfaces_1.buildGrokSkillContent)(), 'Grok FRAIM skill (~/.grok/skills/fraim/SKILL.md)');
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
- installFileIfMissing(path_1.default.join(geminiDir, 'commands', 'fraim.toml'), (0, ide_invocation_surfaces_1.buildGeminiCommandContent)(), 'Gemini CLI FRAIM command (~/.gemini/commands/fraim.toml)');
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
- installFileIfMissing(path_1.default.join(antigravityDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildAntigravityCommandContent)(), 'Antigravity FRAIM command (~/.gemini/antigravity/commands/fraim.md)');
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
- installFileIfMissing(path_1.default.join(windsurfDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildWindsurfCommandContent)(), 'Windsurf FRAIM command (~/.codeium/windsurf/commands/fraim.md)');
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
- installFileIfMissing(path_1.default.join(kiroDir, 'commands', 'fraim.md'), (0, ide_invocation_surfaces_1.buildKiroCommandContent)(), 'Kiro FRAIM command (~/.kiro/commands/fraim.md)');
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
- function installFileIfMissing(filePath, content, displayName) {
93
- if (fs_1.default.existsSync(filePath)) {
94
- console.log(chalk_1.default.gray(` ${displayName.split('(')[0].trim()} already exists — skipping`));
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.275",
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.275",
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",
@@ -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
- const messageFingerprints = messages.map((message) => `${message.role || ''}\u0000${message.text || ''}\u0000${messageTimestamp(message) || ''}`);
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
- // Issue #1176 R38: distinct pill treatment while a redirect is in flight.
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
- function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate) {
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
- if (nearBottom) {
5054
- host.scrollTop = host.scrollHeight;
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.setupScript?.command) parts.push(`setup: ${agent.setupScript.command}`);
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
- const scriptRunner = document.createElement('select');
8749
- for (const runner of ['powershell', 'bash']) {
8750
- const option = document.createElement('option');
8751
- option.value = runner;
8752
- option.textContent = runner;
8753
- scriptRunner.appendChild(option);
8754
- }
8755
- scriptRunner.value = agent?.setupScript?.runner || 'powershell';
8756
- const scriptCommand = document.createElement('input');
8757
- scriptCommand.value = agent?.setupScript?.command || '';
8758
- scriptCommand.placeholder = 'Path to setup script';
8759
- const scriptArgs = configuredAgentTextarea(agent?.setupScript?.args || []);
8760
- const timeout = document.createElement('input');
8761
- timeout.type = 'number';
8762
- timeout.min = '1000';
8763
- timeout.step = '1000';
8764
- timeout.value = String(agent?.setupScript?.timeoutMs || 10000);
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
- form.appendChild(configuredAgentField('Setup script runner', scriptRunner));
8772
- form.appendChild(configuredAgentField('Setup script path', scriptCommand));
8773
- form.appendChild(configuredAgentField('Setup script args', scriptArgs));
8774
- form.appendChild(configuredAgentField('Timeout ms', timeout));
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 scriptCommandValue = scriptCommand.value.trim();
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
- ...(scriptCommandValue ? {
8804
- setupScript: {
8805
- runner: scriptRunner.value,
8806
- command: scriptCommandValue,
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
- if (state.bootstrap) state.bootstrap.configuredAgents = Array.isArray(configuredAgents) ? configuredAgents : [];
8834
- const ids = new Set((state.bootstrap?.configuredAgents || []).map((agent) => agent.id));
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
- renderConfiguredAgentsPanel();
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();
@@ -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;