fraim-hub 2.0.267 → 2.0.269

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.
@@ -7,6 +7,7 @@ exports.summarizeProject = summarizeProject;
7
7
  exports.discoverEmployeeJobs = discoverEmployeeJobs;
8
8
  exports.discoverManagerTemplates = discoverManagerTemplates;
9
9
  exports.loadJobPhases = loadJobPhases;
10
+ exports.resolveJobPhaseTransition = resolveJobPhaseTransition;
10
11
  exports.loadAllJobPhaseIds = loadAllJobPhaseIds;
11
12
  exports.labelForPhaseId = labelForPhaseId;
12
13
  exports.getAiHubCategories = getAiHubCategories;
@@ -470,6 +471,19 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
470
471
  // this job before appending it to the tracker, so cross-job pollution
471
472
  // (agent calling seekMentoring for a different job mid-run) cannot
472
473
  // surface stages from elsewhere.
474
+ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discriminant = 'feature') {
475
+ const stubPath = findJobStubPath(projectPath, jobId);
476
+ if (!stubPath)
477
+ return null;
478
+ const fm = readJobFrontmatter(stubPath);
479
+ if (!fm || !fm.phases)
480
+ return null;
481
+ const phaseDef = fm.phases[phaseId];
482
+ if (!phaseDef)
483
+ return null;
484
+ const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
485
+ return nextPhase(edge, discriminant);
486
+ }
473
487
  function loadAllJobPhaseIds(jobId, projectPath) {
474
488
  const stubPath = findJobStubPath(projectPath, jobId);
475
489
  if (!stubPath)
@@ -36,6 +36,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.DESKTOP_HUB_READY_TIMEOUT_ENV = void 0;
40
+ exports.resolveDesktopReadyTimeoutMs = resolveDesktopReadyTimeoutMs;
39
41
  exports.waitForDesktopHubReady = waitForDesktopHubReady;
40
42
  exports.runHub = runHub;
41
43
  // Hub-owned launcher used by the fraim-hub package. Keep this outside
@@ -207,9 +209,55 @@ function fetchRunningHubVersion(port) {
207
209
  req.on('timeout', () => { req.destroy(); resolve(null); });
208
210
  });
209
211
  }
212
+ // #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
213
+ // Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
214
+ // evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
215
+ // trust call, and finally the listens. The same path measured 3.3s on an idle machine and
216
+ // 20.5s on a real `npx fraim-hub@latest --restart` immediately after a release, when npm has
217
+ // just written a fresh Electron dist. A fixed budget inside that spread turns a healthy launch
218
+ // into a reported failure, which is what issue #1110 reports.
219
+ //
220
+ // The only positive evidence of a failed launch is the child dying, and that is handled
221
+ // separately below. A live child that has not answered yet is still starting, so the wait is
222
+ // long, says so out loud while it waits, and stays tunable for slower machines.
223
+ exports.DESKTOP_HUB_READY_TIMEOUT_ENV = 'FRAIM_HUB_READY_TIMEOUT_MS';
224
+ const DEFAULT_DESKTOP_HUB_READY_TIMEOUT_MS = 120000;
225
+ const DEFAULT_DESKTOP_HUB_READY_PROGRESS_MS = 15000;
226
+ function resolveDesktopReadyTimeoutMs() {
227
+ const configured = Number(process.env[exports.DESKTOP_HUB_READY_TIMEOUT_ENV]);
228
+ if (Number.isFinite(configured) && configured > 0)
229
+ return configured;
230
+ return DEFAULT_DESKTOP_HUB_READY_TIMEOUT_MS;
231
+ }
232
+ // Whole seconds read best at the real cadence (15s, 30s, 120s), but rounding a sub-second
233
+ // duration to whole seconds prints "after 0s" or repeats "1s elapsed" on consecutive notices.
234
+ // Keep one decimal below this threshold so the number is never wrong at any budget.
235
+ const READY_SECONDS_DECIMAL_BELOW_MS = 10000;
236
+ function formatReadySeconds(durationMs) {
237
+ return durationMs >= READY_SECONDS_DECIMAL_BELOW_MS
238
+ ? `${Math.round(durationMs / 1000)}s`
239
+ : `${Math.round(durationMs / 100) / 10}s`;
240
+ }
241
+ function reportDesktopReadyProgress(progress) {
242
+ console.log(`Still waiting for the FRAIM Hub desktop shell on port ${progress.port} `
243
+ + `(${formatReadySeconds(progress.elapsedMs)} elapsed, giving up at ${formatReadySeconds(progress.timeoutMs)}). `
244
+ + 'A first launch after an upgrade unpacks Electron, so this can take a while.');
245
+ }
246
+ // Where the Hub could be answering: the shell records its chosen port in the runtime file once
247
+ // it is listening, which is the only way the launcher learns about a port other than the one it
248
+ // asked for. Re-read every poll, because that file is written mid-wait.
249
+ function readinessProbePorts(fraimDir, runtimeId, preferredPort) {
250
+ const runtime = (0, hub_runtime_file_1.readHubRuntimeFile)(fraimDir, runtimeId);
251
+ return runtime?.port && runtime.port !== preferredPort
252
+ ? [runtime.port, preferredPort]
253
+ : [preferredPort];
254
+ }
210
255
  async function waitForDesktopHubReady(child, preferredPort, options = {}) {
211
- const timeoutMs = options.timeoutMs ?? 15000;
256
+ const timeoutMs = options.timeoutMs ?? resolveDesktopReadyTimeoutMs();
212
257
  const pollMs = options.pollMs ?? 250;
258
+ const progressAfterMs = options.progressAfterMs ?? DEFAULT_DESKTOP_HUB_READY_PROGRESS_MS;
259
+ const progressIntervalMs = options.progressIntervalMs ?? progressAfterMs;
260
+ const onProgress = options.onProgress ?? reportDesktopReadyProgress;
213
261
  const runtimeId = options.runtimeId || 'hub';
214
262
  const fraimDir = options.fraimDir || (0, project_fraim_paths_1.getUserFraimDirPath)();
215
263
  const start = Date.now();
@@ -220,7 +268,10 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
220
268
  child.once('error', (error) => {
221
269
  childState.error = error;
222
270
  });
223
- while (Date.now() - start < timeoutMs) {
271
+ // A dead child is the only positive evidence that the launch failed, so it always wins over
272
+ // the elapsed budget. Checked at the top of every poll and once more after the loop, so a
273
+ // child that dies during the final sleep is still reported as an exit and not as a timeout.
274
+ const failIfChildDied = () => {
224
275
  if (childState.error) {
225
276
  throw new Error(`FRAIM Hub desktop shell failed to launch: ${childState.error.message}`);
226
277
  }
@@ -228,19 +279,30 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
228
279
  const detail = childState.exit.signal ? `signal ${childState.exit.signal}` : `exit code ${childState.exit.code ?? 'unknown'}`;
229
280
  throw new Error(`FRAIM Hub desktop shell exited before the Hub became ready (${detail}).`);
230
281
  }
231
- const runtime = (0, hub_runtime_file_1.readHubRuntimeFile)(fraimDir, runtimeId);
232
- const ports = runtime?.port && runtime.port !== preferredPort
233
- ? [runtime.port, preferredPort]
234
- : [preferredPort];
235
- for (const port of ports) {
282
+ };
283
+ let nextProgressAtMs = progressAfterMs;
284
+ while (Date.now() - start < timeoutMs) {
285
+ failIfChildDied();
286
+ for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
236
287
  const version = await fetchRunningHubVersion(port);
237
288
  if (version) {
238
289
  return { port, version };
239
290
  }
240
291
  }
292
+ const elapsedMs = Date.now() - start;
293
+ if (elapsedMs >= nextProgressAtMs) {
294
+ onProgress({ elapsedMs, timeoutMs, port: preferredPort });
295
+ nextProgressAtMs = elapsedMs + progressIntervalMs;
296
+ }
241
297
  await new Promise((r) => setTimeout(r, pollMs));
242
298
  }
243
- throw new Error(`Timed out waiting for FRAIM Hub desktop shell to become ready on port ${preferredPort}.`);
299
+ failIfChildDied();
300
+ const stillRunning = typeof child.pid === 'number'
301
+ ? ` The desktop shell (pid ${child.pid}) is still running and may finish starting on its own.`
302
+ : '';
303
+ throw new Error(`Timed out waiting for FRAIM Hub desktop shell to become ready on port ${preferredPort} after ${formatReadySeconds(timeoutMs)}.`
304
+ + `${stillRunning}`
305
+ + ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
244
306
  }
245
307
  async function reconcileRunningHub(flags, runtimeId = 'hub') {
246
308
  const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
@@ -5,7 +5,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.launchDesktopShell = launchDesktopShell;
7
7
  const electron_1 = require("electron");
8
- const electron_updater_1 = require("electron-updater");
9
8
  const path_1 = __importDefault(require("path"));
10
9
  const fs_1 = __importDefault(require("fs"));
11
10
  const server_1 = require("./server");
@@ -91,8 +90,14 @@ function ensureLoginItem() {
91
90
  function configureAutoUpdater() {
92
91
  if (!electron_1.app.isPackaged)
93
92
  return;
94
- electron_updater_1.autoUpdater.autoDownload = true;
95
- electron_updater_1.autoUpdater.checkForUpdatesAndNotify().catch((err) => {
93
+ // #1110: electron-updater compiles ~114 files (js-yaml, builder-util-runtime, ...) that a
94
+ // non-packaged `npx fraim-hub` launch never uses, and this whole function returns early
95
+ // there. Requiring it lazily keeps those file reads off the cold-start path, which is what
96
+ // dominates time-to-ready on a freshly unpacked install.
97
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
98
+ const { autoUpdater } = require('electron-updater');
99
+ autoUpdater.autoDownload = true;
100
+ autoUpdater.checkForUpdatesAndNotify().catch((err) => {
96
101
  console.warn('[fraim] auto-update check failed:', err);
97
102
  });
98
103
  }
@@ -329,13 +334,10 @@ async function launchDesktopShell(options) {
329
334
  const httpsPort = await (0, server_1.findAvailablePortExcluding)(43092, new Set([httpPort]));
330
335
  // Generate (or load cached) self-signed cert for HTTPS.
331
336
  // Fast on subsequent launches (file read); ~200ms on first launch (key gen).
337
+ // The cert bundle itself is needed here because server.start() binds the HTTPS listener with
338
+ // it. Trusting it in the OS store is a separate, Word-Online-only concern and happens after
339
+ // the Hub is serving (#1110) - see below.
332
340
  const certBundle = await (0, cert_store_1.loadOrCreateCert)();
333
- try {
334
- (0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
335
- }
336
- catch (err) {
337
- console.warn('[fraim] could not trust localhost certificate:', err);
338
- }
339
341
  server = new server_1.AiHubServer({
340
342
  ...(options.projectPath ? { projectPath: options.projectPath } : {}),
341
343
  // Issue #701: no local DB. Persona/manager-team state resolves through the hosted
@@ -366,6 +368,16 @@ async function launchDesktopShell(options) {
366
368
  catch (err) {
367
369
  console.warn('[fraim] could not write hub runtime file:', err);
368
370
  }
371
+ // #1110: trusting the loopback cert in the OS store is a blocking `certutil` subprocess and
372
+ // is only needed so Word *Online* will render the task pane over HTTPS. The Hub does not need
373
+ // it to serve, so it runs after the server is listening rather than in front of it. Grouped
374
+ // with the Office sideload because both are Word-only first-run housekeeping.
375
+ try {
376
+ (0, cert_store_1.trustCert)((0, cert_store_1.certPaths)().certPath);
377
+ }
378
+ catch (err) {
379
+ console.warn('[fraim] could not trust localhost certificate:', err);
380
+ }
369
381
  ensureWordSideload(resolvedProjectPath, httpsPort);
370
382
  const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
371
383
  createTray(hubUrl, runtimeId);
@@ -33,7 +33,6 @@ const os_1 = __importDefault(require("os"));
33
33
  const path_1 = __importDefault(require("path"));
34
34
  const manager_turns_1 = require("./manager-turns");
35
35
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
36
- const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
37
36
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
38
37
  const configured_agents_1 = require("./configured-agents");
39
38
  const pack_home_1 = require("../cli/utils/pack-home");
@@ -1155,7 +1154,13 @@ function prepareCodexBrowserHome(cdp, env = process.env) {
1155
1154
  const realConfig = path_1.default.join(real, 'config.toml');
1156
1155
  const existing = fs_1.default.existsSync(realConfig) ? fs_1.default.readFileSync(realConfig, 'utf8') : '';
1157
1156
  const pwBlock = `[mcp_servers.playwright]\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "${cdp}"]\n`;
1158
- const merged = (0, mcp_config_generator_1.mergeTomlMCPServers)(existing, pwBlock, ['playwright']).content;
1157
+ // #1110: this is the only use of mcp-config-generator here, and it runs when a run launches,
1158
+ // never at Hub startup. Importing it at module scope pulled the whole
1159
+ // mcp-server-builder -> mcp-server-registry -> provider-registry -> provider-client -> axios
1160
+ // chain (~30 files) into the graph the Hub must compile before it can listen.
1161
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1162
+ const { mergeTomlMCPServers } = require('../cli/setup/mcp-config-generator');
1163
+ const merged = mergeTomlMCPServers(existing, pwBlock, ['playwright']).content;
1159
1164
  fs_1.default.writeFileSync(path_1.default.join(home, 'config.toml'), merged, 'utf8');
1160
1165
  // Auth + the session index (so resume can find existing rollouts by thread id).
1161
1166
  for (const f of ['auth.json', 'session_index.jsonl', 'history.jsonl']) {
@@ -1,11 +1,7 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.HttpHubRemoteGateway = void 0;
7
4
  exports.resolveFraimRemoteUrl = resolveFraimRemoteUrl;
8
- const axios_1 = __importDefault(require("axios"));
9
5
  function resolveFraimRemoteUrl(explicit) {
10
6
  return (explicit || process.env.FRAIM_REMOTE_URL || 'https://fraim.wellnessatwork.me').replace(/\/+$/, '');
11
7
  }
@@ -18,7 +14,12 @@ class HttpHubRemoteGateway {
18
14
  this.baseURL = resolveFraimRemoteUrl(serverUrl);
19
15
  }
20
16
  client(apiKey) {
21
- return axios_1.default.create({
17
+ // #1110: the Hub server module graph is loaded before the local server can listen, and the
18
+ // axios subtree is ~28 files of that graph. Nothing here runs during startup - every caller
19
+ // is a request handler - so axios is required on first use instead of at import time.
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const axios = require('axios');
22
+ return axios.create({
22
23
  baseURL: this.baseURL,
23
24
  headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
24
25
  timeout: 10000,
@@ -68,13 +68,21 @@ const remote_hub_gateway_1 = require("./remote-hub-gateway");
68
68
  const managed_browser_1 = require("./managed-browser");
69
69
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
70
70
  const user_config_1 = require("../cli/utils/user-config");
71
- const org_publish_1 = require("../cli/utils/org-publish");
72
71
  const version_utils_1 = require("../cli/utils/version-utils");
73
72
  const hub_latest_version_1 = require("./hub-latest-version");
74
73
  const ui_runtime_1 = require("./ui-runtime");
75
74
  const ui_cache_1 = require("./ui-cache");
76
- const semver = __importStar(require("semver"));
77
75
  const tree_kill_1 = __importDefault(require("tree-kill"));
76
+ // #1110: semver compiles 45 files and this module graph must load before the Hub can listen,
77
+ // so it is required on use rather than at import time. `latest` is null until the best-effort
78
+ // npm registry lookup succeeds, and on that path semver is never needed at all.
79
+ function isNewerPublishedVersion(latest, current) {
80
+ if (!latest)
81
+ return false;
82
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
83
+ const semver = require('semver');
84
+ return !!(semver.valid(current) && semver.valid(latest) && semver.gt(latest, current));
85
+ }
78
86
  const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
79
87
  let personaHiringModule;
80
88
  let managerHiringModule;
@@ -200,6 +208,9 @@ function resolveConversationPersonaKey(conversation, projectPath, customJobOwner
200
208
  const custom = customJobOwners
201
209
  ? customJobOwners.get(jobId) ?? null
202
210
  : getCustomPersonaForJob(projectPath, jobId);
211
+ if (!custom && conversation.personaKey?.startsWith('custom:')) {
212
+ return conversation.personaKey;
213
+ }
203
214
  return custom ?? getHubPersonaForJob(jobId);
204
215
  }
205
216
  /**
@@ -953,6 +964,28 @@ function normalizePersistedPhaseHistory(value) {
953
964
  })
954
965
  .filter((entry) => Boolean(entry));
955
966
  }
967
+ function normalizePersistedPhaseVisits(value) {
968
+ if (!Array.isArray(value))
969
+ return [];
970
+ return value
971
+ .map((entry) => {
972
+ if (!entry || typeof entry !== 'object')
973
+ return null;
974
+ const raw = entry;
975
+ if (typeof raw.phaseId !== 'string' || !raw.phaseId)
976
+ return null;
977
+ const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
978
+ ? raw.latestStatus
979
+ : null;
980
+ return {
981
+ phaseId: raw.phaseId,
982
+ enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
983
+ latestStatus,
984
+ latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
985
+ };
986
+ })
987
+ .filter((entry) => Boolean(entry));
988
+ }
956
989
  function readPersistedRunProjection(conversation) {
957
990
  const rawRun = conversation?.run;
958
991
  if (!rawRun || typeof rawRun !== 'object')
@@ -961,6 +994,7 @@ function readPersistedRunProjection(conversation) {
961
994
  return {
962
995
  currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
963
996
  phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
997
+ phaseVisits: normalizePersistedPhaseVisits(value.phaseVisits),
964
998
  stages: Array.isArray(value.stages) ? value.stages : [],
965
999
  totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
966
1000
  runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
@@ -1137,6 +1171,28 @@ function applyAgentIdentitySignal(run, identity) {
1137
1171
  run.agentName = identity.agentName;
1138
1172
  run.agentModel = identity.agentModel;
1139
1173
  }
1174
+ function recordPhaseVisit(run, phaseId, latestStatus, latestText = null) {
1175
+ run.phaseVisits = run.phaseVisits || [];
1176
+ const last = run.phaseVisits[run.phaseVisits.length - 1];
1177
+ if (last && last.phaseId === phaseId) {
1178
+ last.latestStatus = latestStatus;
1179
+ if (latestText)
1180
+ last.latestText = latestText;
1181
+ return;
1182
+ }
1183
+ run.phaseVisits.push({
1184
+ phaseId,
1185
+ enteredAt: new Date().toISOString(),
1186
+ latestStatus,
1187
+ latestText,
1188
+ });
1189
+ }
1190
+ function appendSyntheticPhaseVisit(run, phaseId) {
1191
+ const last = run.phaseVisits?.[run.phaseVisits.length - 1];
1192
+ if (last?.phaseId === phaseId)
1193
+ return;
1194
+ recordPhaseVisit(run, phaseId, 'starting', null);
1195
+ }
1140
1196
  function stripStructuredHostPayloads(text) {
1141
1197
  return text
1142
1198
  .replace(/<delegation_ledger>\s*[\s\S]*?\s*<\/delegation_ledger>/gi, '')
@@ -1253,7 +1309,25 @@ function applySeekMentoringSignal(run, signal) {
1253
1309
  };
1254
1310
  run.phaseHistory.push(entry);
1255
1311
  }
1312
+ recordPhaseVisit(run, signal.phaseId, signal.phaseStatus, signal.findingsText || null);
1256
1313
  run.currentPhase = signal.phaseId;
1314
+ if (signal.phaseStatus === 'failure') {
1315
+ const nextPhase = (0, catalog_1.resolveJobPhaseTransition)(run.jobId, run.projectPath, signal.phaseId, 'failure', run.runDiscriminant || 'feature');
1316
+ if (nextPhase) {
1317
+ appendSyntheticPhaseVisit(run, nextPhase);
1318
+ run.currentPhase = nextPhase;
1319
+ }
1320
+ }
1321
+ }
1322
+ function phaseVisitsForProjection(run) {
1323
+ if (Array.isArray(run.phaseVisits) && run.phaseVisits.length > 0)
1324
+ return run.phaseVisits;
1325
+ return (run.phaseHistory || []).map((entry) => ({
1326
+ phaseId: entry.phaseId,
1327
+ enteredAt: entry.enteredAt,
1328
+ latestStatus: entry.latestStatus,
1329
+ latestText: entry.latestText,
1330
+ }));
1257
1331
  }
1258
1332
  // Build the stage list for a run. Combines the FSM's reachable path with
1259
1333
  // any phases the run has actually visited (in case the run took an
@@ -1272,7 +1346,8 @@ function deriveStages(run, projectPath) {
1272
1346
  // phase id NOT in the frontmatter — that last filter prevents
1273
1347
  // cross-job pollution from showing up on the tracker.
1274
1348
  const allDeclared = (0, catalog_1.loadAllJobPhaseIds)(run.jobId, projectPath);
1275
- const visited = (run.phaseHistory || []).map((entry) => entry.phaseId);
1349
+ const visits = phaseVisitsForProjection(run);
1350
+ const visited = visits.map((entry) => entry.phaseId);
1276
1351
  const known = new Set(declaredPath.map((p) => p.id));
1277
1352
  for (const visitedId of visited) {
1278
1353
  if (visitedId === 'starting' || visitedId === '__discriminant__')
@@ -1288,29 +1363,51 @@ function deriveStages(run, projectPath) {
1288
1363
  ? declaredPath.findIndex((p) => p.id === run.currentPhase)
1289
1364
  : -1;
1290
1365
  const historyMap = new Map((run.phaseHistory || []).map((e) => [e.phaseId, e]));
1366
+ const visitsByPhase = new Map();
1367
+ for (const visit of visits) {
1368
+ const existing = visitsByPhase.get(visit.phaseId) || [];
1369
+ existing.push(visit);
1370
+ visitsByPhase.set(visit.phaseId, existing);
1371
+ }
1291
1372
  const completedWithoutPhaseTelemetry = run.status === 'completed' &&
1292
1373
  currentIndex < 0 &&
1293
- historyMap.size === 0;
1374
+ historyMap.size === 0 &&
1375
+ visits.length === 0;
1294
1376
  if (completedWithoutPhaseTelemetry) {
1295
1377
  return declaredPath.map((phase) => ({ phaseId: phase.id, label: phase.label, state: 'done' }));
1296
1378
  }
1297
1379
  return declaredPath.map((phase, index) => {
1298
1380
  let state;
1299
1381
  const entry = historyMap.get(phase.id);
1382
+ const phaseVisits = visitsByPhase.get(phase.id) || [];
1383
+ const latestVisit = phaseVisits[phaseVisits.length - 1] || null;
1384
+ const latestStatus = latestVisit?.latestStatus ?? entry?.latestStatus ?? null;
1385
+ const latestText = latestVisit?.latestText ?? entry?.latestText ?? null;
1300
1386
  if (index === currentIndex) {
1301
1387
  // If the agent has already reported this phase as 'complete', advance
1302
1388
  // its visual state to 'done' so the tracker doesn't look frozen while
1303
1389
  // waiting for the agent to start the next phase (e.g. after
1304
1390
  // implement-submission completes but before address-feedback starts).
1305
- state = entry?.latestStatus === 'complete' ? 'done' : 'current';
1391
+ state = latestStatus === 'complete' ? 'done' : 'current';
1306
1392
  }
1307
- else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
1393
+ else if (latestStatus === 'complete' ||
1394
+ latestStatus === 'failure' ||
1395
+ latestStatus === 'incomplete' ||
1396
+ (currentIndex >= 0 && index < currentIndex && phaseVisits.length > 0)) {
1308
1397
  state = 'done';
1309
1398
  }
1310
1399
  else {
1311
1400
  state = 'upcoming';
1312
1401
  }
1313
- return { phaseId: phase.id, label: phase.label, state };
1402
+ return {
1403
+ phaseId: phase.id,
1404
+ label: phase.label,
1405
+ state,
1406
+ visitCount: phaseVisits.length,
1407
+ currentVisit: phase.id === run.currentPhase && phaseVisits.length > 0 ? phaseVisits.length : null,
1408
+ latestStatus,
1409
+ latestText,
1410
+ };
1314
1411
  });
1315
1412
  }
1316
1413
  // ---------------------------------------------------------------------------
@@ -2519,6 +2616,7 @@ class AiHubServer {
2519
2616
  stages,
2520
2617
  currentPhase: run.currentPhase || null,
2521
2618
  phaseHistory: run.phaseHistory || [],
2619
+ phaseVisits: run.phaseVisits || [],
2522
2620
  totals: run.totals || null,
2523
2621
  runDiscriminant: run.runDiscriminant || null,
2524
2622
  },
@@ -2788,6 +2886,7 @@ class AiHubServer {
2788
2886
  ],
2789
2887
  currentPhase: persistedRun?.currentPhase || null,
2790
2888
  phaseHistory: persistedRun?.phaseHistory || [],
2889
+ phaseVisits: persistedRun?.phaseVisits || [],
2791
2890
  totals: persistedRun?.totals || emptyTotals(),
2792
2891
  runDiscriminant: persistedRun?.runDiscriminant || undefined,
2793
2892
  artifacts: Array.isArray(conversation.artifacts) ? conversation.artifacts : [],
@@ -3145,6 +3244,7 @@ class AiHubServer {
3145
3244
  events: [(0, hosts_1.createHubEvent)('system', `Mandy started delegated workstream ${task.taskId} for ${task.personaKey || task.jobId}.`)],
3146
3245
  currentPhase: null,
3147
3246
  phaseHistory: [],
3247
+ phaseVisits: [],
3148
3248
  totals: emptyTotals(),
3149
3249
  lastStatusChangeAt: now,
3150
3250
  personaKey: task.personaKey,
@@ -4148,6 +4248,7 @@ class AiHubServer {
4148
4248
  ],
4149
4249
  currentPhase: persistedRun?.currentPhase || null,
4150
4250
  phaseHistory: persistedRun?.phaseHistory || [],
4251
+ phaseVisits: persistedRun?.phaseVisits || [],
4151
4252
  stages: persistedRun?.stages || [],
4152
4253
  totals: persistedRun?.totals || emptyTotals(),
4153
4254
  lastStatusChangeAt: now,
@@ -4420,7 +4521,7 @@ class AiHubServer {
4420
4521
  this.app.get('/api/ai-hub/version', async (_req, res) => {
4421
4522
  const version = (0, version_utils_1.getFraimVersion)();
4422
4523
  const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
4423
- const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
4524
+ const updateAvailable = isNewerPublishedVersion(latest, version);
4424
4525
  const uiRuntime = await this.resolveHubUiRuntimeResponse();
4425
4526
  return res.json({
4426
4527
  version,
@@ -4585,7 +4686,12 @@ class AiHubServer {
4585
4686
  return res.json({ ok: true, brand: stored, published: false });
4586
4687
  }
4587
4688
  try {
4588
- await (0, org_publish_1.publishOrgArtifacts)([{
4689
+ // #1110: org-publish drags in pack-git-publish and the whole axios subtree (~28 files)
4690
+ // that the Hub must otherwise compile before it can listen. Only this one brand-publish
4691
+ // handler needs it, so it is required on use rather than at import time.
4692
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
4693
+ const { publishOrgArtifacts } = require('../cli/utils/org-publish');
4694
+ await publishOrgArtifacts([{
4589
4695
  relativePath: 'context/org_brand.json',
4590
4696
  content: JSON.stringify(stored, null, 2),
4591
4697
  }]);
@@ -4854,6 +4960,7 @@ class AiHubServer {
4854
4960
  // Issue #347 — seed phase + totals state on creation.
4855
4961
  currentPhase: null,
4856
4962
  phaseHistory: [],
4963
+ phaseVisits: [],
4857
4964
  totals: emptyTotals(),
4858
4965
  lastStatusChangeAt: startTimestamp,
4859
4966
  personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
@@ -5285,6 +5392,7 @@ class AiHubServer {
5285
5392
  // resuming the session starts a fresh job, so the tracker must be blank.
5286
5393
  currentPhase: persistedConversation?.status !== 'completed' ? (persistedRun?.currentPhase || null) : null,
5287
5394
  phaseHistory: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseHistory || []) : [],
5395
+ phaseVisits: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseVisits || []) : [],
5288
5396
  totals: persistedRun?.totals || emptyTotals(),
5289
5397
  lastStatusChangeAt: now,
5290
5398
  runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
@@ -5962,6 +6070,7 @@ class AiHubServer {
5962
6070
  events: [(0, hosts_1.createHubEvent)('system', `Trigger: ${configuredAgent.label} / ${jobName} from ${context?.sourceApp || 'unknown'} in ${projectPath}`)],
5963
6071
  currentPhase: null,
5964
6072
  phaseHistory: [],
6073
+ phaseVisits: [],
5965
6074
  totals: emptyTotals(),
5966
6075
  lastStatusChangeAt: startTimestamp,
5967
6076
  personaKey: getHubPersonaForJob(jobName),
@@ -6113,6 +6222,7 @@ class AiHubServer {
6113
6222
  events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type}) using ${configuredAgent.label}`)],
6114
6223
  currentPhase: null,
6115
6224
  phaseHistory: [],
6225
+ phaseVisits: [],
6116
6226
  totals: emptyTotals(),
6117
6227
  lastStatusChangeAt: startTimestamp,
6118
6228
  personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
4
  };
@@ -41,7 +8,9 @@ exports.evaluateHubUiManifestCompatibility = evaluateHubUiManifestCompatibility;
41
8
  exports.resolveHubUiRuntime = resolveHubUiRuntime;
42
9
  const fs_1 = __importDefault(require("fs"));
43
10
  const path_1 = __importDefault(require("path"));
44
- const semver = __importStar(require("semver"));
11
+ // #1110: semver compiles 45 files. This module is on the AI Hub server's import graph, which
12
+ // must load before the Hub can listen, and the only use below runs when a UI release manifest
13
+ // is evaluated - never during startup. Required on use to keep it off the cold-start path.
45
14
  const version_utils_1 = require("../cli/utils/version-utils");
46
15
  const ui_cache_1 = require("./ui-cache");
47
16
  const ui_manifest_client_1 = require("./ui-manifest-client");
@@ -72,6 +41,8 @@ function evaluateHubUiManifestCompatibility(manifest, bridge) {
72
41
  if (manifest.schemaVersion !== 1) {
73
42
  return { compatible: false, updateRequired: true, reason: `schema-version:${manifest.schemaVersion}` };
74
43
  }
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const semver = require('semver');
75
46
  if (semver.valid(manifest.minBridgeVersion) && semver.valid(bridge.bridgeVersion) && semver.lt(bridge.bridgeVersion, manifest.minBridgeVersion)) {
76
47
  return { compatible: false, updateRequired: true, reason: `min-bridge-version:${manifest.minBridgeVersion}` };
77
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.267",
3
+ "version": "2.0.269",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -163,7 +163,7 @@
163
163
  "electron": "^41.2.2",
164
164
  "electron-updater": "^6.8.9",
165
165
  "express": "^5.2.1",
166
- "fraim": "2.0.267",
166
+ "fraim": "2.0.269",
167
167
  "mongodb": "^7.0.0",
168
168
  "node-cron": "4.2.1",
169
169
  "node-edge-tts": "^1.2.10",
@@ -765,6 +765,12 @@ function ensureTaskPaneLauncher() {
765
765
  const project = taskPaneProjectEntries().find((entry) => entry.folderPath === event.target.value);
766
766
  if (statusEl && project) statusEl.textContent = `Project: ${project.name || friendlyProjectShortName(project.folderPath)}`;
767
767
  });
768
+ document.getElementById('task-pane-employee-select')?.addEventListener('change', (event) => {
769
+ const value = event.target && event.target.value;
770
+ if (value && hubConfiguredAgents().some((agent) => agent.id === value)) {
771
+ state.selectedEmployeeId = value;
772
+ }
773
+ });
768
774
  return root;
769
775
  }
770
776
 
@@ -855,6 +861,8 @@ async function taskPaneStartSelectedJob() {
855
861
  if (result && result.ok) {
856
862
  taskPaneLauncherForcedOpen = false;
857
863
  if (statusEl) statusEl.textContent = 'Started.';
864
+ renderActive();
865
+ syncTaskPaneSurfaceMode();
858
866
  } else if (statusEl) {
859
867
  statusEl.textContent = (result && result.error) || 'Could not start.';
860
868
  }
@@ -863,6 +871,7 @@ async function taskPaneStartSelectedJob() {
863
871
  console.warn('[ai-hub] task-pane start failed:', error);
864
872
  } finally {
865
873
  renderTaskPaneLauncher();
874
+ syncTaskPaneSurfaceMode();
866
875
  }
867
876
  }
868
877
 
@@ -1028,18 +1037,18 @@ function normalizeGeminiConversationMessages(conv) {
1028
1037
  // request 413s, the error is swallowed, and the client-owned fields in the SAME
1029
1038
  // payload (e.g. pauseReason='done' from "Mark complete") never persist. Strip them
1030
1039
  // here; the server's PUT handler merges them back from the stored record.
1031
- const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
1032
- // Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
1033
- // body, which is a different claim from "the record has body fields". A run can
1034
- // legitimately finish with no messages, events, artifacts, run, or delegation;
1040
+ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation', 'handoffSummary'];
1041
+ // Issue #1090: `_bodyFetched` records that THIS CLIENT successfully fetched the
1042
+ // body, which is a different claim from "the record has body fields". A run can
1043
+ // legitimately finish with no messages, events, artifacts, run, or delegation;
1035
1044
  // before this marker existed such a body was indistinguishable from one that had
1036
1045
  // never been hydrated, so the transcript hung on "Loading conversation…" and
1037
1046
  // re-fetched on every poll tick forever.
1038
1047
  //
1039
- // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1040
- // any inbound value. That is what preserves issue #913: a marker arriving over
1041
- // the wire is never trusted, only one this client set after a real fetch.
1042
- const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1048
+ // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1049
+ // any inbound value. That is what preserves issue #913: a marker arriving over
1050
+ // the wire is never trusted, only one this client set after a real fetch.
1051
+ const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1043
1052
  function slimConversationForPersist(conv) {
1044
1053
  if (!conv || typeof conv !== 'object') return conv;
1045
1054
  const slim = { ...conv };
@@ -3905,7 +3914,7 @@ function renderTracker(conv) {
3905
3914
  const rowCapacity = trackerRowCapacity(tracker, stages);
3906
3915
 
3907
3916
  // Cache key so we only rebuild the DOM when the stage data changes.
3908
- const key = stages.map((s) => `${s.phaseId}:${s.state}`).join('|') +
3917
+ const key = stages.map((s) => `${s.phaseId}:${s.state}:${s.visitCount || 0}:${s.currentVisit || 0}:${s.latestStatus || ''}`).join('|') +
3909
3918
  '#' + (conv.run.currentPhase || '') +
3910
3919
  '#' + rowCapacity;
3911
3920
  if (key === renderedTrackerKey) {
@@ -3927,7 +3936,9 @@ function renderTracker(conv) {
3927
3936
  rowsHost.parentNode.insertBefore(activeLabelHost, els['tracker-note']);
3928
3937
  }
3929
3938
  const currentStage = stages.find((s) => s.state === 'current');
3930
- activeLabelHost.textContent = currentStage ? currentStage.label : '';
3939
+ activeLabelHost.textContent = currentStage
3940
+ ? `${currentStage.label}${currentStage.currentVisit && currentStage.currentVisit > 1 ? ` - visit ${currentStage.currentVisit}` : ''}`
3941
+ : '';
3931
3942
  // The pizza tracker is always a single horizontal row. When the pane is too
3932
3943
  // narrow to fit every stage label, collapse to circles + the active-stage
3933
3944
  // label (CSS .tracker-compact) instead of wrapping onto stacked rows.
@@ -3939,12 +3950,21 @@ function renderTracker(conv) {
3939
3950
  const stageEl = document.createElement('div');
3940
3951
  stageEl.className = 'stage ' + stage.state;
3941
3952
  const tooltipText = buildStageTooltip(conv, stage);
3942
- if (tooltipText) stageEl.setAttribute('title', tooltipText);
3953
+ if (tooltipText) {
3954
+ stageEl.setAttribute('title', tooltipText);
3955
+ stageEl.setAttribute('aria-label', tooltipText);
3956
+ }
3943
3957
  const circle = document.createElement('span');
3944
3958
  circle.className = 'stage-circle';
3945
3959
  if (stage.state === 'done') circle.textContent = '✓';
3946
3960
  else if (stage.state === 'current') circle.textContent = '●';
3947
3961
  else circle.textContent = String(index + 1);
3962
+ if (stage.visitCount && stage.visitCount > 1) {
3963
+ const badge = document.createElement('span');
3964
+ badge.className = 'stage-visit-badge';
3965
+ badge.textContent = `x${stage.visitCount}`;
3966
+ circle.appendChild(badge);
3967
+ }
3948
3968
  const label = document.createElement('span');
3949
3969
  label.className = 'stage-label';
3950
3970
  label.textContent = stage.label;
@@ -3958,6 +3978,11 @@ function renderTracker(conv) {
3958
3978
  function buildStageTooltip(conv, stage) {
3959
3979
  const history = (conv.run && conv.run.phaseHistory) || [];
3960
3980
  const entry = history.find((h) => h.phaseId === stage.phaseId);
3981
+ const details = [stage.phaseId];
3982
+ if (stage.visitCount && stage.visitCount > 1) details.push(`visit ${stage.currentVisit || stage.visitCount} of ${stage.visitCount}`);
3983
+ if (stage.latestStatus) details.push(stage.latestStatus);
3984
+ if (stage.latestText) details.push(stage.latestText);
3985
+ if (details.length > 1) return details.join(' - ');
3961
3986
  const findings = entry && entry.latestText;
3962
3987
  if (findings) return `${stage.phaseId} · ${findings}`;
3963
3988
  return stage.phaseId;
@@ -7687,6 +7712,7 @@ function foldRunIntoConversation(conv, run) {
7687
7712
  stages: run.stages || [],
7688
7713
  currentPhase: run.currentPhase || null,
7689
7714
  phaseHistory: run.phaseHistory || [],
7715
+ phaseVisits: run.phaseVisits || [],
7690
7716
  totals: run.totals || null,
7691
7717
  runDiscriminant: run.runDiscriminant || null,
7692
7718
  };
@@ -2380,6 +2380,26 @@ button.small { padding: 4px 10px; font-size: 12px; }
2380
2380
  color: #fff;
2381
2381
  box-shadow: 0 0 0 4px rgba(176, 132, 66, 0.15);
2382
2382
  }
2383
+ .tracker .stage-visit-badge {
2384
+ position: absolute;
2385
+ right: -13px;
2386
+ top: -10px;
2387
+ min-width: 18px;
2388
+ height: 16px;
2389
+ padding: 0 4px;
2390
+ border-radius: 999px;
2391
+ display: inline-flex;
2392
+ align-items: center;
2393
+ justify-content: center;
2394
+ border: 1px solid var(--surface);
2395
+ background: var(--text);
2396
+ color: var(--surface);
2397
+ font-size: 9px;
2398
+ font-weight: 800;
2399
+ line-height: 1;
2400
+ z-index: 3;
2401
+ box-sizing: border-box;
2402
+ }
2383
2403
  .tracker .stage-label {
2384
2404
  font-size: 10px;
2385
2405
  color: var(--muted);
@@ -2426,6 +2446,13 @@ button.small { padding: 4px 10px; font-size: 12px; }
2426
2446
  border-width: 1px;
2427
2447
  font-size: 10px;
2428
2448
  }
2449
+ .tracker .stage-visit-badge {
2450
+ right: -12px;
2451
+ top: -9px;
2452
+ min-width: 17px;
2453
+ height: 15px;
2454
+ font-size: 8px;
2455
+ }
2429
2456
  .tracker .stage::before { top: 10px; }
2430
2457
  .tracker-active-label { display: block; }
2431
2458
  }
@@ -2866,27 +2893,27 @@ button.small { padding: 4px 10px; font-size: 12px; }
2866
2893
  body:is([data-surface="task-pane"],[data-surface="extension"]). No media queries — this is a surface
2867
2894
  flag, not a viewport breakpoint. */
2868
2895
 
2869
- body:is([data-surface="task-pane"],[data-surface="extension"]) {
2870
- font-size: 13px;
2871
- overflow: auto;
2872
- background: var(--bg);
2873
- color: var(--text);
2874
- }
2896
+ body:is([data-surface="task-pane"],[data-surface="extension"]) {
2897
+ font-size: 13px;
2898
+ overflow: auto;
2899
+ background: var(--bg);
2900
+ color: var(--text);
2901
+ }
2875
2902
  body:is([data-surface="task-pane"],[data-surface="extension"]) .hub-tabs,
2876
2903
  body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .hub-area,
2877
2904
  body:is([data-surface="task-pane"],[data-surface="extension"]) .proj-tabs {
2878
2905
  display: none !important;
2879
2906
  }
2880
2907
  .task-pane-launcher { display: none; }
2881
- body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher {
2882
- display: flex;
2883
- flex-direction: column;
2884
- gap: 10px;
2885
- padding: 12px;
2886
- border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
2887
- background: var(--surface, #fff);
2888
- color: var(--text, #171717);
2889
- }
2908
+ body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher {
2909
+ display: flex;
2910
+ flex-direction: column;
2911
+ gap: 10px;
2912
+ padding: 12px;
2913
+ border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
2914
+ background: var(--surface, #fff);
2915
+ color: var(--text, #171717);
2916
+ }
2890
2917
  body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active #area-projects {
2891
2918
  display: flex !important;
2892
2919
  min-height: 100vh;
@@ -2925,11 +2952,11 @@ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-act
2925
2952
  font-weight: 650;
2926
2953
  color: var(--text, #171717);
2927
2954
  }
2928
- .tp-launcher-context,
2929
- .tp-launcher-status {
2930
- min-height: 16px;
2931
- font-size: 11px;
2932
- color: var(--muted);
2955
+ .tp-launcher-context,
2956
+ .tp-launcher-status {
2957
+ min-height: 16px;
2958
+ font-size: 11px;
2959
+ color: var(--muted);
2933
2960
  overflow: hidden;
2934
2961
  text-overflow: ellipsis;
2935
2962
  white-space: nowrap;
@@ -2943,27 +2970,27 @@ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-act
2943
2970
  font-weight: 600;
2944
2971
  color: var(--muted);
2945
2972
  }
2946
- .tp-launcher-field select,
2947
- .tp-launcher-field textarea {
2948
- width: 100%;
2949
- min-width: 0;
2950
- border: 1px solid var(--line, rgba(0,0,0,0.12));
2951
- border-radius: 8px;
2952
- background: var(--surface, #fff);
2953
- color: var(--text, #171717);
2954
- font: inherit;
2955
- font-size: 12px;
2956
- }
2957
- .tp-launcher-field select:focus,
2958
- .tp-launcher-field textarea:focus {
2959
- outline: 2px solid var(--accent, #0071e3);
2960
- outline-offset: 1px;
2961
- border-color: var(--accent, #0071e3);
2962
- }
2963
- .tp-launcher-field textarea::placeholder {
2964
- color: var(--muted);
2965
- opacity: 1;
2966
- }
2973
+ .tp-launcher-field select,
2974
+ .tp-launcher-field textarea {
2975
+ width: 100%;
2976
+ min-width: 0;
2977
+ border: 1px solid var(--line, rgba(0,0,0,0.12));
2978
+ border-radius: 8px;
2979
+ background: var(--surface, #fff);
2980
+ color: var(--text, #171717);
2981
+ font: inherit;
2982
+ font-size: 12px;
2983
+ }
2984
+ .tp-launcher-field select:focus,
2985
+ .tp-launcher-field textarea:focus {
2986
+ outline: 2px solid var(--accent, #0071e3);
2987
+ outline-offset: 1px;
2988
+ border-color: var(--accent, #0071e3);
2989
+ }
2990
+ .tp-launcher-field textarea::placeholder {
2991
+ color: var(--muted);
2992
+ opacity: 1;
2993
+ }
2967
2994
  .tp-launcher-field select {
2968
2995
  height: 34px;
2969
2996
  padding: 0 8px;
@@ -2974,98 +3001,98 @@ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-act
2974
3001
  padding: 8px;
2975
3002
  line-height: 1.35;
2976
3003
  }
2977
- .tp-launcher-actions {
2978
- display: grid;
2979
- grid-template-columns: minmax(0, 1fr) auto auto;
2980
- align-items: center;
2981
- gap: 8px;
2982
- }
2983
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-start-job,
2984
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job {
2985
- min-height: 34px;
2986
- padding: 7px 12px;
2987
- border-radius: 8px;
2988
- font-size: 12px;
2989
- }
2990
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job[hidden] {
2991
- display: none;
2992
- }
2993
- .task-pane-run-nav {
2994
- display: none;
2995
- }
2996
- body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
2997
- display: flex;
2998
- align-items: center;
2999
- justify-content: space-between;
3000
- gap: 10px;
3001
- min-width: 0;
3002
- padding: 10px 12px;
3003
- border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
3004
- background: var(--surface, #fff);
3005
- color: var(--text, #171717);
3006
- }
3007
- .tp-run-copy {
3008
- min-width: 0;
3009
- display: flex;
3010
- flex-direction: column;
3011
- gap: 1px;
3012
- }
3013
- .tp-run-eyebrow,
3014
- .tp-run-meta {
3015
- font-size: 11px;
3016
- line-height: 1.25;
3017
- color: var(--muted);
3018
- }
3019
- .tp-run-title {
3020
- min-width: 0;
3021
- font-size: 13px;
3022
- line-height: 1.3;
3023
- font-weight: 650;
3024
- color: var(--text, #171717);
3025
- overflow: hidden;
3026
- text-overflow: ellipsis;
3027
- white-space: nowrap;
3028
- }
3029
- .tp-run-actions {
3030
- flex: 0 0 auto;
3031
- }
3032
- body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-new-job {
3033
- min-height: 34px;
3034
- padding: 7px 12px;
3035
- border-radius: 8px;
3036
- font-size: 12px;
3037
- white-space: nowrap;
3038
- }
3039
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) {
3040
- background: var(--bg);
3041
- }
3042
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher,
3043
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
3044
- background: #242424;
3045
- border-color: rgba(255, 255, 255, 0.16);
3046
- }
3047
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select,
3048
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field textarea {
3049
- background: #171717;
3050
- color: #f2f2f2;
3051
- border-color: rgba(255, 255, 255, 0.22);
3052
- }
3053
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select option {
3054
- background: #171717;
3055
- color: #f2f2f2;
3056
- }
3057
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-context,
3058
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-status,
3059
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field,
3060
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-eyebrow,
3061
- :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-meta {
3062
- color: #c7c7c7;
3063
- }
3064
- body:is([data-surface="task-pane"],[data-surface="extension"]) .page {
3065
- padding: 10px 12px 8px;
3066
- gap: 10px;
3067
- height: auto;
3068
- min-height: 100vh;
3004
+ .tp-launcher-actions {
3005
+ display: grid;
3006
+ grid-template-columns: minmax(0, 1fr) auto auto;
3007
+ align-items: center;
3008
+ gap: 8px;
3009
+ }
3010
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-start-job,
3011
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job {
3012
+ min-height: 34px;
3013
+ padding: 7px 12px;
3014
+ border-radius: 8px;
3015
+ font-size: 12px;
3016
+ }
3017
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-current-job[hidden] {
3018
+ display: none;
3019
+ }
3020
+ .task-pane-run-nav {
3021
+ display: none;
3022
+ }
3023
+ body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
3024
+ display: flex;
3025
+ align-items: center;
3026
+ justify-content: space-between;
3027
+ gap: 10px;
3028
+ min-width: 0;
3029
+ padding: 10px 12px;
3030
+ border-bottom: 1px solid var(--line, rgba(0,0,0,0.07));
3031
+ background: var(--surface, #fff);
3032
+ color: var(--text, #171717);
3033
+ }
3034
+ .tp-run-copy {
3035
+ min-width: 0;
3036
+ display: flex;
3037
+ flex-direction: column;
3038
+ gap: 1px;
3039
+ }
3040
+ .tp-run-eyebrow,
3041
+ .tp-run-meta {
3042
+ font-size: 11px;
3043
+ line-height: 1.25;
3044
+ color: var(--muted);
3045
+ }
3046
+ .tp-run-title {
3047
+ min-width: 0;
3048
+ font-size: 13px;
3049
+ line-height: 1.3;
3050
+ font-weight: 650;
3051
+ color: var(--text, #171717);
3052
+ overflow: hidden;
3053
+ text-overflow: ellipsis;
3054
+ white-space: nowrap;
3055
+ }
3056
+ .tp-run-actions {
3057
+ flex: 0 0 auto;
3058
+ }
3059
+ body:is([data-surface="task-pane"],[data-surface="extension"]) #task-pane-new-job {
3060
+ min-height: 34px;
3061
+ padding: 7px 12px;
3062
+ border-radius: 8px;
3063
+ font-size: 12px;
3064
+ white-space: nowrap;
3065
+ }
3066
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) {
3067
+ background: var(--bg);
3068
+ }
3069
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]):not(.task-pane-run-active) .task-pane-launcher,
3070
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]).task-pane-run-active .task-pane-run-nav {
3071
+ background: #242424;
3072
+ border-color: rgba(255, 255, 255, 0.16);
3073
+ }
3074
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select,
3075
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field textarea {
3076
+ background: #171717;
3077
+ color: #f2f2f2;
3078
+ border-color: rgba(255, 255, 255, 0.22);
3079
+ }
3080
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field select option {
3081
+ background: #171717;
3082
+ color: #f2f2f2;
3083
+ }
3084
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-context,
3085
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-status,
3086
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-launcher-field,
3087
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-eyebrow,
3088
+ :root[data-theme="dark"] body:is([data-surface="task-pane"],[data-surface="extension"]) .tp-run-meta {
3089
+ color: #c7c7c7;
3090
+ }
3091
+ body:is([data-surface="task-pane"],[data-surface="extension"]) .page {
3092
+ padding: 10px 12px 8px;
3093
+ gap: 10px;
3094
+ height: auto;
3095
+ min-height: 100vh;
3069
3096
  }
3070
3097
  /* Hide the full-width welcome header — task pane has no room for it */
3071
3098
  body:is([data-surface="task-pane"],[data-surface="extension"]) .header { display: none; }
@@ -3451,8 +3478,8 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
3451
3478
  .pending-banner { background: var(--warn-soft); border: 1px solid rgba(176,132,66,.25); border-radius: 12px; padding: 12px 16px; display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
3452
3479
 
3453
3480
  /* Projects sub-tabs — HTML uses .proj-tabs (not .proj-tabs-bar) and .ptab-add (not .ptab-add-btn) */
3454
- .proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow: hidden; }
3455
- #proj-tab-list { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; overflow-x: auto; overflow-y: hidden; white-space: nowrap; }
3481
+ .proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow: hidden; }
3482
+ #proj-tab-list { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; overflow-x: auto; overflow-y: hidden; white-space: nowrap; }
3456
3483
  .ptab { padding: 9px 16px; font-size: 13px; font-weight: 500; color: var(--muted); border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; margin-bottom: -1px; }
3457
3484
  .ptab.on { color: var(--text); border-bottom-color: var(--text); font-weight: 600; }
3458
3485
  .ptab:hover { color: var(--text); }
@@ -4206,18 +4233,18 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4206
4233
  .modal-hdr { padding: 18px 22px; border-bottom: 1px solid var(--line); }
4207
4234
  .modal-hdr h2 { font-size: 18px; font-weight: 700; margin: 0 0 4px; }
4208
4235
  .modal-hdr p { font-size: 13px; color: var(--muted); margin: 0; line-height: 1.5; }
4209
- .modal-body { padding: 18px 22px; display: flex; flex-direction: column; gap: 14px; }
4210
- .modal-close { float: right; background: var(--bg); border: none; width: 28px; height: 28px; border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted); line-height: 1; }
4211
- .modal-close:hover { color: var(--text); }
4212
- .modal-close:focus, .modal-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4213
- .aom-cancel-btn { background: var(--surface); border: 1px solid color-mix(in srgb, var(--accent-strong) 52%, var(--line)); border-radius: 20px; padding: 9px 20px; font-size: 14px; cursor: pointer; color: var(--accent-strong); }
4214
- .aom-cancel-btn:hover { border-color: var(--accent-strong); color: var(--accent-strong); }
4215
- .aom-cancel-btn:focus, .aom-cancel-btn:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4216
- .modal-footer { padding: 12px 22px 20px; display: flex; align-items: center; justify-content: space-between; }
4217
- .modal-next { padding: 9px 22px; background: var(--accent-strong); color: var(--bg); border: none; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
4218
- .modal-next:hover { opacity: .9; }
4219
- .modal-next:focus, .modal-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4220
- .modal-back { font-size: 13px; color: var(--muted); background: none; border: none; cursor: pointer; }
4236
+ .modal-body { padding: 18px 22px; display: flex; flex-direction: column; gap: 14px; }
4237
+ .modal-close { float: right; background: var(--bg); border: none; width: 28px; height: 28px; border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted); line-height: 1; }
4238
+ .modal-close:hover { color: var(--text); }
4239
+ .modal-close:focus, .modal-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4240
+ .aom-cancel-btn { background: var(--surface); border: 1px solid color-mix(in srgb, var(--accent-strong) 52%, var(--line)); border-radius: 20px; padding: 9px 20px; font-size: 14px; cursor: pointer; color: var(--accent-strong); }
4241
+ .aom-cancel-btn:hover { border-color: var(--accent-strong); color: var(--accent-strong); }
4242
+ .aom-cancel-btn:focus, .aom-cancel-btn:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4243
+ .modal-footer { padding: 12px 22px 20px; display: flex; align-items: center; justify-content: space-between; }
4244
+ .modal-next { padding: 9px 22px; background: var(--accent-strong); color: var(--bg); border: none; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
4245
+ .modal-next:hover { opacity: .9; }
4246
+ .modal-next:focus, .modal-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4247
+ .modal-back { font-size: 13px; color: var(--muted); background: none; border: none; cursor: pointer; }
4221
4248
  .modal-step-dots { display: flex; gap: 6px; justify-content: center; margin-bottom: 18px; }
4222
4249
  .modal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--line); }
4223
4250
  .modal-dot.on { background: var(--text); }
@@ -4674,8 +4701,8 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4674
4701
  align-items: center;
4675
4702
  justify-content: center;
4676
4703
  }
4677
- .np-close:hover { color: var(--text); }
4678
- .np-close:focus, .np-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4704
+ .np-close:hover { color: var(--text); }
4705
+ .np-close:focus, .np-close:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4679
4706
 
4680
4707
  .np-step-dots {
4681
4708
  display: flex;
@@ -4737,29 +4764,29 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4737
4764
  justify-content: space-between;
4738
4765
  }
4739
4766
 
4740
- .np-next {
4741
- padding: 9px 22px;
4742
- background: var(--accent-strong);
4743
- color: var(--bg);
4744
- border: none;
4745
- border-radius: 20px;
4746
- font-size: 14px;
4747
- font-weight: 600;
4748
- cursor: pointer;
4749
- }
4750
- .np-next:hover { opacity: .88; }
4751
- .np-next:focus, .np-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4752
- .np-next:disabled { opacity: .55; cursor: not-allowed; }
4753
-
4754
- .np-back {
4755
- font-size: 13px;
4756
- color: var(--accent-strong);
4757
- background: none;
4758
- border: none;
4759
- cursor: pointer;
4760
- }
4761
- .np-back:hover { color: var(--accent-strong); text-decoration: underline; }
4762
- .np-back:focus, .np-back:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4767
+ .np-next {
4768
+ padding: 9px 22px;
4769
+ background: var(--accent-strong);
4770
+ color: var(--bg);
4771
+ border: none;
4772
+ border-radius: 20px;
4773
+ font-size: 14px;
4774
+ font-weight: 600;
4775
+ cursor: pointer;
4776
+ }
4777
+ .np-next:hover { opacity: .88; }
4778
+ .np-next:focus, .np-next:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4779
+ .np-next:disabled { opacity: .55; cursor: not-allowed; }
4780
+
4781
+ .np-back {
4782
+ font-size: 13px;
4783
+ color: var(--accent-strong);
4784
+ background: none;
4785
+ border: none;
4786
+ cursor: pointer;
4787
+ }
4788
+ .np-back:hover { color: var(--accent-strong); text-decoration: underline; }
4789
+ .np-back:focus, .np-back:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4763
4790
 
4764
4791
  /* ── Discard-guard confirm (shown when a dirty wizard is dismissed) ── */
4765
4792
  .np-discard {
@@ -4784,11 +4811,11 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4784
4811
  .np-discard-msg { font-size: 13px; color: var(--muted); margin: 0 0 18px; line-height: 1.5; }
4785
4812
  .np-discard-row { display: flex; gap: 8px; justify-content: flex-end; }
4786
4813
  .np-discard-row button { font: inherit; font-size: 13px; font-weight: 600; border-radius: 9px; padding: 8px 16px; cursor: pointer; }
4787
- .np-discard-keep { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
4788
- .np-discard-keep:hover { border-color: var(--muted); }
4789
- .np-discard-yes { background: var(--danger, #d2261f); color: #fff; border: none; }
4790
- .np-discard-yes:hover { opacity: .9; }
4791
- .np-discard-row button:focus, .np-discard-row button:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4814
+ .np-discard-keep { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
4815
+ .np-discard-keep:hover { border-color: var(--muted); }
4816
+ .np-discard-yes { background: var(--danger, #d2261f); color: #fff; border: none; }
4817
+ .np-discard-yes:hover { opacity: .9; }
4818
+ .np-discard-row button:focus, .np-discard-row button:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
4792
4819
 
4793
4820
  /* ── D11: Source attachment row (step 2) ── */
4794
4821
  .np-source {
@@ -5275,9 +5302,9 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5275
5302
  /* ── New-project: folder picker row ── */
5276
5303
  .np-folder-row { display:flex; gap:8px; }
5277
5304
  .np-folder-row input { flex:1; }
5278
- .np-browse-btn { padding:9px 16px; background:var(--accent-strong); color:var(--bg); border:none; border-radius:9px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; flex-shrink:0; }
5279
- .np-browse-btn:hover { opacity:.85; }
5280
- .np-browse-btn:focus, .np-browse-btn:focus-visible { outline:2px solid var(--accent-strong); outline-offset:2px; }
5305
+ .np-browse-btn { padding:9px 16px; background:var(--accent-strong); color:var(--bg); border:none; border-radius:9px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; flex-shrink:0; }
5306
+ .np-browse-btn:hover { opacity:.85; }
5307
+ .np-browse-btn:focus, .np-browse-btn:focus-visible { outline:2px solid var(--accent-strong); outline-offset:2px; }
5281
5308
  .np-field-hint { font-size:11px; color:var(--muted); margin-top:5px; line-height:1.5; }
5282
5309
 
5283
5310
  @media (max-width: 520px) {