fraim-hub 2.0.287 → 2.0.288

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,22 @@ function parseSeekMentoringSignal(line) {
82
82
  return sig;
83
83
  }
84
84
  }
85
+ // Copilot CLI shape (issue #1399): { type: 'tool.execution_start', data: {
86
+ // mcpServerName, mcpToolName, arguments } }. Live-verified via a direct
87
+ // spike against the installed CLI under --output-format json (see
88
+ // docs/rfcs/1399-copilot-host-chattiness-technical-design.md) — Copilot
89
+ // reports the MCP server and tool name as separate, unambiguous fields
90
+ // rather than a qualifier string, so this reads data.mcpToolName directly
91
+ // instead of going through canonicalToolName's __/./: splitting.
92
+ if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
93
+ const data = obj.data;
94
+ if (isFraimTool(data.mcpToolName, 'seekMentoring')) {
95
+ const args = normalizeToolArgs(data.arguments) || undefined;
96
+ const sig = extractSignalFromArgs(args);
97
+ if (sig)
98
+ return sig;
99
+ }
100
+ }
85
101
  // Claude Code shape: tool_use blocks live inside parsed.message.content.
86
102
  // The name is MCP-prefixed (e.g. 'mcp__fraim__seekMentoring').
87
103
  const candidates = [obj];
@@ -133,6 +149,16 @@ function parseFraimJobLoadSignal(line) {
133
149
  return sig;
134
150
  }
135
151
  }
152
+ // Copilot CLI shape (issue #1399): see parseSeekMentoringSignal's Copilot
153
+ // block above for the field-shape rationale.
154
+ if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
155
+ const data = obj.data;
156
+ if (isFraimTool(data.mcpToolName, 'get_fraim_job')) {
157
+ const sig = readFraimJobFromArgs(data.arguments);
158
+ if (sig)
159
+ return sig;
160
+ }
161
+ }
136
162
  // Claude Code shape: tool_use blocks live inside message.content with
137
163
  // an MCP-prefixed name such as mcp__fraim__get_fraim_job.
138
164
  const candidates = [obj];
@@ -289,6 +315,14 @@ function parseAgentIdentitySignal(line) {
289
315
  return readAgentFromArgs(normalizeToolArgs(item.arguments) || undefined);
290
316
  }
291
317
  }
318
+ // Copilot CLI shape (issue #1399): see parseSeekMentoringSignal's Copilot
319
+ // block for the field-shape rationale.
320
+ if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
321
+ const data = obj.data;
322
+ if (isFraimTool(data.mcpToolName, 'fraim_connect')) {
323
+ return readAgentFromArgs(normalizeToolArgs(data.arguments) || undefined);
324
+ }
325
+ }
292
326
  // Claude Code shape.
293
327
  const candidates = [obj];
294
328
  if (typeof obj.message === 'object' && obj.message !== null) {
@@ -1434,7 +1468,12 @@ function buildStartPlan(hostId, message, sessionId) {
1434
1468
  const browser = sharedBrowserHostConfig('copilot');
1435
1469
  return {
1436
1470
  command: COPILOT_BINARY,
1437
- args: ['--yolo', ...browser.args],
1471
+ // Issue #1399: request Copilot's structured JSONL output mode
1472
+ // (one JSON object per line) instead of its default human-
1473
+ // readable terminal rendering, so parseHostLine can classify
1474
+ // tool calls/results/deltas separately from genuine reply text
1475
+ // instead of pattern-matching rendered chrome characters.
1476
+ args: ['--yolo', '--output-format', 'json', ...browser.args],
1438
1477
  stdin: transformHeadlessFraimMessage(message, 'start'),
1439
1478
  env: browser.env,
1440
1479
  };
@@ -1495,7 +1534,8 @@ function buildContinuePlan(hostId, sessionId, message) {
1495
1534
  const browser = sharedBrowserHostConfig('copilot');
1496
1535
  return {
1497
1536
  command: COPILOT_BINARY,
1498
- args: ['--yolo', '--resume', sessionId, ...browser.args],
1537
+ // Issue #1399: see buildStartPlan's copilot branch for rationale.
1538
+ args: ['--yolo', '--output-format', 'json', '--resume', sessionId, ...browser.args],
1499
1539
  stdin: transformHeadlessFraimMessage(message, 'continue'),
1500
1540
  env: browser.env,
1501
1541
  };
@@ -1562,9 +1602,10 @@ function buildDirectStartPlan(hostId, message, sessionId) {
1562
1602
  }
1563
1603
  if (hostId === 'copilot') {
1564
1604
  // Direct (A/B) mode for Copilot: headless, no FRAIM MCP wiring.
1605
+ // Issue #1399: --output-format json for the same reason as buildStartPlan.
1565
1606
  return {
1566
1607
  command: COPILOT_BINARY,
1567
- args: ['--yolo'],
1608
+ args: ['--yolo', '--output-format', 'json'],
1568
1609
  stdin: DIRECT_PREAMBLE + message,
1569
1610
  };
1570
1611
  }
@@ -1608,9 +1649,10 @@ function buildDirectContinuePlan(hostId, sessionId, message) {
1608
1649
  }
1609
1650
  if (hostId === 'copilot') {
1610
1651
  // Direct continue mode for Copilot: resume session, no FRAIM MCP wiring.
1652
+ // Issue #1399: --output-format json for the same reason as buildStartPlan.
1611
1653
  return {
1612
1654
  command: COPILOT_BINARY,
1613
- args: ['--yolo', '--resume', sessionId],
1655
+ args: ['--yolo', '--output-format', 'json', '--resume', sessionId],
1614
1656
  stdin: DIRECT_PREAMBLE + message,
1615
1657
  };
1616
1658
  }
@@ -1723,31 +1765,69 @@ function parseHostLine(hostId, line) {
1723
1765
  if (hostId === 'antigravity') {
1724
1766
  return withSignal({ raw: trimmed });
1725
1767
  }
1726
- // GitHub Copilot CLI output: JSON stream where each event carries a `type`
1727
- // field. Known event shapes (from the agentic CLI stream):
1728
- // { "type": "session.started", "session_id": "..." } — session id
1729
- // { "type": "message", "role": "assistant", "content": "..." } reply text
1730
- // { "type": "turn.completed", "usage": { ... } } — token usage (same shape as Codex)
1731
- // For any JSON event not matching the above, signal scanning (seekMentoring,
1732
- // agent identity) still runs because withSignal is applied to every parsed result.
1733
- // Non-JSON lines from Copilot are treated as plain-text employee messages.
1768
+ // GitHub Copilot CLI output (issue #1399): Hub invokes Copilot with
1769
+ // --output-format json (see buildStartPlan's copilot branch), which
1770
+ // reports every event as one JSON object per line. Two shapes from the
1771
+ // original #531 integration are kept as defensive fallbacks never
1772
+ // confirmed against real traffic, but harmless to keep recognizing if a
1773
+ // future CLI version or mode emits them:
1774
+ // { "type": "session.started", "session_id": "..." }
1775
+ // { "type": "message", "role": "assistant", "content": "..." }
1776
+ // The real vocabulary below was captured via a live spike against the
1777
+ // installed CLI binary (see "Spike Findings" in
1778
+ // docs/rfcs/1399-copilot-host-chattiness-technical-design.md):
1779
+ // { "type": "assistant.message", "data": { "content": "...", "toolRequests": [...] } }
1780
+ // — the genuine reply. data.content is the message text; toolRequests
1781
+ // is metadata about an about-to-run tool call, not display text.
1782
+ // { "type": "result", "sessionId": "...", "exitCode": 0, "usage": {...} }
1783
+ // — terminal turn/session summary. This is the only place sessionId
1784
+ // was observed in a single-shot run (no early session-id event fired).
1785
+ // { "type": "tool.execution_start" | "tool.execution_complete", "data": {...} },
1786
+ // { "type": "model.tool_execution", "data": {...} }
1787
+ // — tool invocation and result. Diagnostic only, never a message.
1788
+ // seekMentoring/get_fraim_job/fraim_connect signal extraction (via
1789
+ // withSignal below) reads the call arguments off tool.execution_start's
1790
+ // data.arguments/data.mcpServerName/data.mcpToolName.
1791
+ // { "type": "assistant.message_delta" | "assistant.tool_call_delta", ...,
1792
+ // "ephemeral": true }
1793
+ // — streaming deltas. The CLI's own schema marks these ephemeral, an
1794
+ // explicit signal (not a heuristic) that they are safe to drop.
1795
+ // session.*/model.*/user.message/assistant.turn_*/assistant.idle
1796
+ // — setup, bookkeeping, and input-echo diagnostics.
1797
+ // Any other/unrecognized type, and any line that fails JSON.parse entirely,
1798
+ // fails CLOSED (raw only) — matching Codex's and Claude's existing
1799
+ // fail-closed default. This replaces the prior fail-OPEN catch-all that
1800
+ // promoted arbitrary unrecognized Copilot text (in practice, ~80% of a
1801
+ // real run's lines — the CLI's own tool-call/result terminal rendering) to
1802
+ // a manager-visible message.
1734
1803
  if (hostId === 'copilot') {
1804
+ let parsed;
1735
1805
  try {
1736
- const parsed = JSON.parse(trimmed);
1737
- if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
1738
- return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1739
- }
1740
- if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
1741
- return withSignal({ message: parsed.content, raw: trimmed });
1742
- }
1743
- // All other JSON events: apply signal scanning and surface as raw.
1744
- return withSignal({ raw: trimmed });
1806
+ parsed = JSON.parse(trimmed);
1745
1807
  }
1746
1808
  catch {
1747
- // Non-JSON line from Copilot: treat as a plain-text employee message,
1748
- // same pattern as Gemini CLI's non-JSON output.
1749
- return withSignal({ message: trimmed, raw: trimmed });
1809
+ return withSignal({ raw: trimmed });
1810
+ }
1811
+ if (!parsed || typeof parsed !== 'object')
1812
+ return withSignal({ raw: trimmed });
1813
+ if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
1814
+ return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1815
+ }
1816
+ if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
1817
+ return withSignal({ message: parsed.content, raw: trimmed });
1818
+ }
1819
+ if (parsed.type === 'assistant.message') {
1820
+ const content = typeof parsed.data?.content === 'string' ? parsed.data.content : undefined;
1821
+ return content ? withSignal({ message: content, raw: trimmed }) : withSignal({ raw: trimmed });
1750
1822
  }
1823
+ if (parsed.type === 'result') {
1824
+ const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0 ? parsed.sessionId : undefined;
1825
+ return withSignal({ sessionId, raw: trimmed });
1826
+ }
1827
+ // tool.execution_start/complete, model.tool_execution, the *_delta
1828
+ // events, and session/model/user/turn bookkeeping all fall here:
1829
+ // diagnostic-only, but still signal-scanned by withSignal above.
1830
+ return withSignal({ raw: trimmed });
1751
1831
  }
1752
1832
  try {
1753
1833
  const parsed = JSON.parse(trimmed);
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
+ exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
40
41
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
42
  exports.hubCommandVersion = hubCommandVersion;
42
43
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
@@ -1699,6 +1700,35 @@ function hubAgentOption(hubId) {
1699
1700
  const frId = HUB_TO_FIRST_RUN_ID[hubId];
1700
1701
  return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
1701
1702
  }
1703
+ const defaultSelfHealDeps = {
1704
+ checkHealth: agent_cli_health_checks_1.checkAgentCliHealthByCommand,
1705
+ install: (option, systemPath) => (0, managed_agent_install_1.installManagedAgent)(option, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion }),
1706
+ invalidateCache: hosts_1.invalidateEmployeeDetectionCache,
1707
+ };
1708
+ async function selfHealBrokenManagedAgents(deps = defaultSelfHealDeps) {
1709
+ for (const hubId of ['claude', 'codex']) {
1710
+ try {
1711
+ const health = await deps.checkHealth(hubId);
1712
+ if (!health || health.status !== 'error')
1713
+ continue;
1714
+ const details = health.details;
1715
+ if (!details?.managedPath || details.managedVersion)
1716
+ continue;
1717
+ const option = hubAgentOption(hubId);
1718
+ if (!option || !option.installPackage)
1719
+ continue;
1720
+ console.warn(`[ai-hub] self-heal: ${option.label}'s FRAIM-managed install is broken (${health.message}) — attempting automatic reinstall`);
1721
+ const systemPath = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(process.env.PATH);
1722
+ await deps.install({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath);
1723
+ deps.invalidateCache();
1724
+ const verified = await deps.checkHealth(hubId);
1725
+ console.warn(`[ai-hub] self-heal: ${option.label} reinstall ${verified?.status === 'error' ? 'did NOT fix it — manual reinstall needed' : 'succeeded'}`);
1726
+ }
1727
+ catch (error) {
1728
+ console.warn(`[ai-hub] self-heal check failed for ${hubId}:`, error instanceof Error ? error.message : String(error));
1729
+ }
1730
+ }
1731
+ }
1702
1732
  /**
1703
1733
  * Issue #747: after the Hub installs an agent CLI, run the `add-ide` command for that agent so the
1704
1734
  * FRAIM MCP (plus slash commands / rules) is wired into its config and its first run works.
@@ -2699,6 +2729,17 @@ class AiHubServer {
2699
2729
  void (0, hosts_1.detectEmployeesAsync)({ force: true }).catch((error) => {
2700
2730
  console.warn('[ai-hub] agent availability priming failed:', error?.message || error);
2701
2731
  });
2732
+ // Issue #1412: fire-and-forget self-heal of any broken FRAIM-managed CLI
2733
+ // install (e.g. an interrupted Claude Code/Codex update left a
2734
+ // non-executable launcher). Never blocks or fails startup. Skipped in
2735
+ // NODE_ENV=test so the test suite's many short-lived Hub instances never
2736
+ // trigger a real `npm install -g`, matching this file's existing
2737
+ // NODE_ENV-gated test-safety pattern (see hubCommandVersion/hubRunProcess).
2738
+ if (process.env.NODE_ENV !== 'test') {
2739
+ void selfHealBrokenManagedAgents().catch((error) => {
2740
+ console.warn('[ai-hub] self-heal pass failed:', error?.message || error);
2741
+ });
2742
+ }
2702
2743
  // Issue #578: rehydrate active scheduled deployments from disk. Test and preview
2703
2744
  // servers that inject a fake host but not a deployment store should not run the
2704
2745
  // user's real scheduled deployments from the default store.
@@ -6083,6 +6124,13 @@ class AiHubServer {
6083
6124
  if (reviewApprovalSystemEventText)
6084
6125
  current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
6085
6126
  current.events.push((0, hosts_1.createHubEvent)('system', 'No resumable session - starting a fresh agent turn from conversation context.'));
6127
+ // Issue #1373 (Defect 2): nextJobRecommendations can only be stale
6128
+ // here — it is only ever set at retrospective (issue #848), and a
6129
+ // job that just received coaching has not reached retrospective
6130
+ // again yet. Clear it at send-time so a coached Done job stops
6131
+ // offering next-job recommendations immediately, not only once the
6132
+ // resumed session's next seekMentoring signal arrives.
6133
+ current.nextJobRecommendations = null;
6086
6134
  });
6087
6135
  const startedFresh = this.runRegistry.get(run.id);
6088
6136
  if (startedFresh)
@@ -6139,6 +6187,9 @@ class AiHubServer {
6139
6187
  current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus));
6140
6188
  if (reviewApprovalSystemEventText)
6141
6189
  current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
6190
+ // Issue #1373 (Defect 2): see the no-session branch above for why
6191
+ // this can only be stale at coaching-send time.
6192
+ current.nextJobRecommendations = null;
6142
6193
  });
6143
6194
  const started = this.runRegistry.get(run.id);
6144
6195
  if (started)
@@ -6281,18 +6332,28 @@ class AiHubServer {
6281
6332
  messages: persistedConversation ? persistedMessagesForRun(persistedConversation) : [],
6282
6333
  events: resumeEvents,
6283
6334
  eventLogRefs: persistedConversation?.eventLogRefs || [],
6284
- // Only carry phase state forward when the prior run was interrupted mid-job
6285
- // (status !== 'completed'). A completed conversation is a finished run;
6286
- // resuming the session starts a fresh job, so the tracker must be blank.
6287
- currentPhase: persistedConversation?.status !== 'completed' ? (persistedRun?.currentPhase || null) : null,
6288
- phaseHistory: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseHistory || []) : [],
6289
- phaseVisits: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseVisits || []) : [],
6335
+ // Issue #1373: a conversation is permanently scoped to one job for its
6336
+ // lifetime, including after that job's conversation is marked
6337
+ // 'completed' a coaching turn sent to a Done job resumes the SAME
6338
+ // job's phase history, it does not start unrelated new work. Always
6339
+ // carry the persisted run projection forward regardless of the
6340
+ // conversation's prior status. If the resumed session genuinely has
6341
+ // nothing left to do, it simply reaches the same completed state
6342
+ // again on its own the next time seekMentoring reports its phase.
6343
+ currentPhase: persistedRun?.currentPhase || null,
6344
+ phaseHistory: persistedRun?.phaseHistory || [],
6345
+ phaseVisits: persistedRun?.phaseVisits || [],
6290
6346
  totals: persistedRun?.totals || emptyTotals(),
6291
6347
  lastStatusChangeAt: now,
6292
- runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
6348
+ runDiscriminant: persistedRun?.runDiscriminant || undefined,
6293
6349
  // Issue #1357: consult custom persona owner before falling back to catalog.
6294
6350
  personaKey: getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
6295
6351
  continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
6352
+ // Issue #1373 (Defect 2): nextJobRecommendations are only ever set at
6353
+ // retrospective/completion (issue #848); a resumed run has not reached
6354
+ // retrospective, so this is always null here. Explicit for readability
6355
+ // and to make the invariant visible next to the Defect 1 fix above.
6356
+ nextJobRecommendations: null,
6296
6357
  };
6297
6358
  host_session_state_1.hostSessionState.applySession(run, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, sessionId, { sourceRunId: run.id, status: resolvedHostSession?.status || 'suspect' });
6298
6359
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
@@ -97,6 +97,27 @@ async function runAgentCliHealthCheck(cli) {
97
97
  : managedPath === ambientPath
98
98
  ? ambientVersion
99
99
  : probeVersion(managedPath);
100
+ // Issue #1412: a resolved file that fails to execute at all (e.g. an
101
+ // interrupted update left a non-executable fallback launcher) is a harder
102
+ // failure than drift — `getSystemCommandPath()` only proves the file
103
+ // exists, not that it can run. Without this check, a single broken install
104
+ // with nothing else on PATH to disagree with (ambientPath === managedPath,
105
+ // both versions null) falls straight through the mismatch check below and
106
+ // reads as "consistent". Check this before computing npm-global/drift
107
+ // details that don't matter once the CLI can't run at all.
108
+ const brokenPath = ambientPath && !ambientVersion
109
+ ? ambientPath
110
+ : managedPath && !managedVersion
111
+ ? managedPath
112
+ : null;
113
+ if (brokenPath) {
114
+ return {
115
+ status: 'error',
116
+ message: `${cli.label} is installed at ${brokenPath} but failed to run (\`${cli.command} --version\` produced no output). An interrupted or partial update may have left a non-functional launcher.`,
117
+ suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
118
+ details: { ambientPath, ambientVersion, managedPath, managedVersion },
119
+ };
120
+ }
100
121
  const npmGlobalBinDirs = (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)();
101
122
  const npmGlobalPath = npmGlobalBinDirs.length > 0
102
123
  ? (0, command_resolution_1.getSystemCommandPath)(cli.command, npmGlobalBinDirs.join(path_1.default.delimiter))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.287",
3
+ "version": "2.0.288",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -210,7 +210,7 @@
210
210
  "electron-updater": "^6.8.9",
211
211
  "express": "^5.2.1",
212
212
  "extract-zip": "^2.0.1",
213
- "fraim": "2.0.287",
213
+ "fraim": "2.0.288",
214
214
  "mongodb": "^7.0.0",
215
215
  "node-cron": "4.2.1",
216
216
  "node-edge-tts": "^1.2.10",
@@ -15,11 +15,12 @@ const TREE_WIDTH_MIN = 176;
15
15
  const TREE_WIDTH_MAX = 380;
16
16
  const TREE_WIDTH_DEFAULT = 216;
17
17
  const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis', 'create-hub-configured-agent']);
18
- // Jobs scoped to the Company/Manager area never shown in the Projects workspace rail.
19
- // These are the truly area-only jobs. Issue #702: persona jobs (e.g. Ashley's) are NOT
20
- // listed here a persona can run a job from a project OR from the Manager tab, and a
21
- // run's placement is decided per-invocation by conv.invokedArea, not by job id.
22
- const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements', 'create-hub-configured-agent']);
18
+ // Issue #1408: there is no fixed per-job-id area home. Every job's placement, including
19
+ // organization-onboarding/organizational-learning-synthesis/manager-agreements/
20
+ // create-hub-configured-agent, is decided per-invocation by conv.invokedArea/convScope,
21
+ // exactly like persona jobs (e.g. Ashley's, #702) always were. A conversation renders
22
+ // wherever it was actually started; forcing these job ids to a fixed home regardless of
23
+ // invokedArea left runs started from the "wrong" tab unreachable from either rail.
23
24
  // Issue #702: personas whose home is the MANAGER tab because their work is inherently
24
25
  // cross-project and manager-facing (not project work). Per the spec (R1b), Ashley — the
25
26
  // AI Executive Assistant — is Manager-scoped. Every other hired persona works inside
@@ -1426,10 +1427,11 @@ async function hydrateConversationsFromServer() {
1426
1427
  const enc = encodeURIComponent(projectPath);
1427
1428
  const pickActive = (list, serverActiveId) => {
1428
1429
  const activeCandidates = [state.activeId, serverActiveId].filter(Boolean);
1429
- // Area-scoped jobs must not become the Projects workspace active conversation —
1430
- // they are shown in their own Company/Manager panels via tfActiveOrgConv/tfActiveMgrConv.
1431
- const projectConv = (id) => list.find((c) => c.id === id && !AREA_SCOPED_JOBS.has(c.jobId));
1432
- const firstProjectConv = list.find((c) => !AREA_SCOPED_JOBS.has(c.jobId));
1430
+ // Issue #1408: a manager/company-scoped conversation must not become the Projects
1431
+ // workspace active conversation — it's shown in its own Company/Manager panel via
1432
+ // tfActiveOrgConv/tfActiveMgrConv instead. Decided by scope, not by job id.
1433
+ const projectConv = (id) => list.find((c) => c.id === id && convScope(c) === 'project');
1434
+ const firstProjectConv = list.find((c) => convScope(c) === 'project');
1433
1435
  const bestActive = activeCandidates.map(projectConv).find(Boolean) || firstProjectConv || null;
1434
1436
  state.activeId = bestActive ? bestActive.id : null;
1435
1437
  };
@@ -2341,8 +2343,8 @@ function renderRail() {
2341
2343
  const list = allProjectConversations.filter((conv) =>
2342
2344
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
2343
2345
  !isManagedDelegationChild(conv) &&
2344
- !AREA_SCOPED_JOBS.has(conv.jobId) &&
2345
- // #702/#708: manager/company-scoped runs surface on those tabs, not in a project.
2346
+ // #702/#708/#1408: manager/company-scoped runs surface on those tabs, not in a
2347
+ // project, decided by scope alone no job id is ever excluded from Projects.
2346
2348
  convScope(conv) === 'project'
2347
2349
  );
2348
2350
 
@@ -9045,21 +9047,24 @@ function buildConfiguredAgentForm(agent, opts) {
9045
9047
  if (agent?.command?.command) {
9046
9048
  command.value = agent.command.command;
9047
9049
  }
9048
- // Legacy `setupScript`-shaped agents are NOT translated into a synthesized
9049
- // command string here. A dot-sourced line like `. <path> <args>` parses as
9050
- // a valid-looking command, but sourcing a script and launching the base
9050
+ // Setup-script profiles display a launch preview in the same Command field
9051
+ // but still round-trip as setupScript unless the user edits the preview.
9052
+ // Sourcing a script and launching the base
9051
9053
  // agent with extra flags are structurally different operations — the
9052
9054
  // launch decorator only ever keeps the trailing flags of `command` and
9053
9055
  // discards the leading token, so a script path placed there is silently
9054
9056
  // dropped as a bogus flag rather than ever sourced (PR #1209 review).
9055
- // Leaving Command blank and round-tripping the existing `setupScript`
9056
- // unchanged on save (below) keeps the agent working; typing a real single
9057
- // command here is a deliberate migration away from the script.
9058
9057
  const legacySetupScript = !agent?.command && agent?.setupScript ? agent.setupScript : null;
9058
+ const legacyLaunchPreview = legacySetupScript
9059
+ ? configuredAgentSetupLaunchPreview(legacySetupScript, agent.baseHostId)
9060
+ : '';
9061
+ if (legacyLaunchPreview) {
9062
+ command.value = legacyLaunchPreview;
9063
+ }
9059
9064
  const commandHint = document.createElement('p');
9060
9065
  commandHint.className = 'configured-agent-command-hint';
9061
9066
  commandHint.textContent = legacySetupScript
9062
- ? `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.`
9067
+ ? 'This preview shows the setup script Hub runs before the agent. Internal Hub flags are hidden; edit it only to replace this setup-script profile with a plain command.'
9063
9068
  : 'Reference where a secret resolves at runtime (a file, a keychain, an already-set env var) — never paste a literal secret value here.';
9064
9069
 
9065
9070
  form.appendChild(configuredAgentField('ID', id));
@@ -9102,13 +9107,14 @@ function buildConfiguredAgentForm(agent, opts) {
9102
9107
  );
9103
9108
  if (!proceed) return;
9104
9109
  }
9110
+ const shouldPreserveLegacySetup = !!legacySetupScript && (!commandValue || commandValue === legacyLaunchPreview);
9105
9111
  const body = {
9106
9112
  id: id.value.trim(),
9107
9113
  label: label.value.trim(),
9108
9114
  baseHostId: baseHost.value,
9109
9115
  description: description.value.trim(),
9110
9116
  enabled: enabled.checked,
9111
- ...(commandValue ? {
9117
+ ...(!shouldPreserveLegacySetup && commandValue ? {
9112
9118
  command: {
9113
9119
  runner: configuredAgentPlatformRunner(),
9114
9120
  command: commandValue,
@@ -9142,6 +9148,19 @@ function configuredAgentPlatformRunner() {
9142
9148
  return /Windows/i.test(platform) ? 'powershell' : 'bash';
9143
9149
  }
9144
9150
 
9151
+ function configuredAgentSetupLaunchPreview(setupScript, baseHostId) {
9152
+ if (!setupScript?.command || !baseHostId) return '';
9153
+ const args = Array.isArray(setupScript.args) ? setupScript.args : [];
9154
+ const quote = (value) => {
9155
+ const text = String(value || '');
9156
+ return `"${text.replace(/"/g, '\\"')}"`;
9157
+ };
9158
+ const setupInvocation = [quote(setupScript.command), ...args.map(quote)].join(' ');
9159
+ return setupScript.runner === 'powershell'
9160
+ ? `. ${setupInvocation}; ${baseHostId}`
9161
+ : `source ${setupInvocation}; ${baseHostId}`;
9162
+ }
9163
+
9145
9164
  // Mirrors the server-side check in configured-agent-command.ts's
9146
9165
  // commandContainsLiteralSecret so the user sees the warning before the
9147
9166
  // request round-trips, not only after.
@@ -10976,10 +10995,10 @@ function tfShowArea(area) {
10976
10995
  tf.area = area;
10977
10996
 
10978
10997
  if (area === 'projects') {
10979
- // Restore the saved Projects conv; discard it if it turned out to be area-scoped.
10998
+ // Restore the saved Projects conv; discard it if it turned out to be manager/company-scoped.
10980
10999
  const savedId = state.areaActiveId.projects || null;
10981
11000
  const savedConv = savedId && Object.values(state.conversations || {}).flat().find((c) => c.id === savedId);
10982
- state.activeId = (savedConv && !AREA_SCOPED_JOBS.has(savedConv.jobId)) ? savedId : null;
11001
+ state.activeId = (savedConv && convScope(savedConv) === 'project') ? savedId : null;
10983
11002
  // Return the shared .page to the Projects workspace and reset Company/Manager hosts.
10984
11003
  tfEnsurePageInArea('projects');
10985
11004
  for (const el of document.querySelectorAll('.area-conv-host')) el.hidden = true;
@@ -13007,11 +13026,16 @@ function tfActiveOrgConv() {
13007
13026
  }
13008
13027
 
13009
13028
  // #594 R2: return the manager-scoped conversation to show in Manager area.
13010
- // #702: manager-tab runs are those invoked from the manager area (any persona job),
13011
- // plus the area-only manager-agreements job (older runs predate invokedArea). An active
13012
- // manager-invoked run surfaces in the Manager conversation host (Coach panel included).
13029
+ // #702/#1408: manager-tab runs are those invoked from the manager area (any persona job,
13030
+ // including manager-agreements), decided by scope alone. A pre-scope-tracking legacy
13031
+ // manager-agreements record with no scope field falls back to convScope()'s 'project'
13032
+ // default and won't be picked here; it's still reachable, just not as this tab's
13033
+ // single "most active" spotlight. An active manager-invoked run surfaces in the
13034
+ // Manager conversation host (Coach panel included).
13013
13035
  function tfActiveMgrConv() {
13014
- const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (convScope(c) === 'manager' || c.jobId === 'manager-agreements'));
13036
+ // Issue #1408: no job-id exception a manager-agreements run only counts here if it
13037
+ // was genuinely invoked from the Manager tab (convScope(c) === 'manager').
13038
+ const convs = Object.values(state.conversations || {}).flat().filter((c) => c && convScope(c) === 'manager');
13015
13039
  return (
13016
13040
  tfMostRecentMatch(convs, (c) => c.status === 'running') ||
13017
13041
  tfMostRecentMatch(convs, (c) => c.status === 'waiting') ||