fraim-hub 2.0.302 → 2.0.303

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.
@@ -99,15 +99,15 @@ function normalizeCommand(value) {
99
99
  timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
100
100
  };
101
101
  }
102
- function synthesizeDefaultConfiguredAgents(employees) {
102
+ function synthesizeDefaultConfiguredAgents(hosts) {
103
103
  const timestamp = '1970-01-01T00:00:00.000Z';
104
- return employees
105
- .filter((employee) => employee.available)
106
- .map((employee) => ({
107
- id: `${employee.id}-default`,
108
- label: employee.label,
104
+ return hosts
105
+ .filter((host) => host.available)
106
+ .map((host) => ({
107
+ id: `${host.id}-default`,
108
+ label: host.label,
109
109
  description: 'Default local launch for this agent tool.',
110
- baseHostId: employee.id,
110
+ baseHostId: host.id,
111
111
  enabled: true,
112
112
  createdAt: timestamp,
113
113
  updatedAt: timestamp,
@@ -133,9 +133,9 @@ class AiHubConfiguredAgentStore {
133
133
  return [];
134
134
  }
135
135
  }
136
- listWithDefaults(employees) {
136
+ listWithDefaults(hosts) {
137
137
  const persisted = this.list();
138
- const defaults = synthesizeDefaultConfiguredAgents(employees);
138
+ const defaults = synthesizeDefaultConfiguredAgents(hosts);
139
139
  const seen = new Set(persisted.map((agent) => agent.id));
140
140
  return [...persisted, ...defaults.filter((agent) => !seen.has(agent.id))];
141
141
  }
@@ -166,14 +166,14 @@ class AiHubConfiguredAgentStore {
166
166
  }
167
167
  }
168
168
  exports.AiHubConfiguredAgentStore = AiHubConfiguredAgentStore;
169
- function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
169
+ function checkConfiguredAgentAvailability(agent, hosts, env = process.env) {
170
170
  const reasons = [];
171
171
  const warnings = [];
172
- const employee = employees.find((entry) => entry.id === agent.baseHostId);
172
+ const host = hosts.find((entry) => entry.id === agent.baseHostId);
173
173
  if (!agent.enabled)
174
174
  reasons.push('Configured agent is disabled.');
175
- if (!employee?.available)
176
- reasons.push(`${employee?.label || agent.baseHostId} is not available on this machine.`);
175
+ if (!host?.available)
176
+ reasons.push(`${host?.label || agent.baseHostId} is not available on this machine.`);
177
177
  if (agent.command) {
178
178
  if (!agent.command.command.trim()) {
179
179
  reasons.push('Command is empty.');
@@ -202,8 +202,8 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
202
202
  }
203
203
  return { id: agent.id, label: agent.label, baseHostId: agent.baseHostId, enabled: agent.enabled, available: reasons.length === 0, reasons, warnings };
204
204
  }
205
- function checkConfiguredAgentReadiness(agent, employees, env = process.env) {
206
- const check = checkConfiguredAgentAvailability(agent, employees, env);
205
+ function checkConfiguredAgentReadiness(agent, hosts, env = process.env) {
206
+ const check = checkConfiguredAgentAvailability(agent, hosts, env);
207
207
  if (!check.available)
208
208
  return check;
209
209
  if (agent.command)
@@ -225,8 +225,8 @@ function checkConfiguredAgentReadiness(agent, employees, env = process.env) {
225
225
  };
226
226
  }
227
227
  }
228
- function projectConfiguredAgent(agent, employees) {
229
- const check = checkConfiguredAgentAvailability(agent, employees);
228
+ function projectConfiguredAgent(agent, hosts) {
229
+ const check = checkConfiguredAgentAvailability(agent, hosts);
230
230
  return {
231
231
  id: agent.id,
232
232
  label: agent.label,
@@ -57,6 +57,15 @@ class HostSessionState {
57
57
  const fallback = Object.values(sessions).find((session) => this.isResumableForOwner(session, owner));
58
58
  if (fallback)
59
59
  return fallback;
60
+ // Issue #1515: once this conversation has ANY owner-qualified session recorded, that
61
+ // ledger is authoritative. The legacy `sessionId` mirror always reflects whichever
62
+ // configured agent last ran (see `mergeFromRun` below), not the requested owner, so
63
+ // treating it as resumable here would resume a different agent's session under the
64
+ // wrong owner (e.g. Codex Azure Script silently reusing a plain Codex session because
65
+ // both share `baseHostId: 'codex'`). The legacy fallback below is reserved for
66
+ // conversations that predate the owner-qualified ledger entirely.
67
+ if (Object.keys(sessions).length > 0)
68
+ return null;
60
69
  const legacySessionId = typeof conversation.sessionId === 'string' ? conversation.sessionId.trim() : '';
61
70
  if (!legacySessionId)
62
71
  return null;
@@ -5,7 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = exports.COMPACT_TASK_FAILURE_PATTERN = void 0;
7
7
  exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
+ exports.parseRecurringParkSignal = parseRecurringParkSignal;
8
9
  exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
10
+ exports.parseExecutionModeSignal = parseExecutionModeSignal;
9
11
  exports.parseUsageSignal = parseUsageSignal;
10
12
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
11
13
  exports.escapeWindowsArg = escapeWindowsArg;
@@ -124,6 +126,41 @@ function parseSeekMentoringSignal(line) {
124
126
  }
125
127
  return null;
126
128
  }
129
+ // Issue #1509: detect the recurring-park marker emitted by the mentor inside its
130
+ // tool_result when it resolves a phase transition to WAITING_FOR_NEXT_INVOCATION.
131
+ // Scoped to genuine tool-result-shaped sub-objects — not a bare substring match —
132
+ // so any job that prints ai-mentor.ts source in an assistant-text message does NOT
133
+ // accidentally set the flag. A plain assistant message carries no sub-object whose
134
+ // type matches the tool-result pattern, so it is naturally excluded.
135
+ //
136
+ // Detection property: the marker must appear inside a JSON sub-object whose own
137
+ // `type` field matches the tool-result-type pattern. This covers all known host
138
+ // shapes (Claude Code tool_result, Codex function_call_output/mcp_tool_result,
139
+ // Copilot tool.execution_complete) and future hosts without per-host branches.
140
+ const TOOL_RESULT_TYPE_RE = /tool_result|function_call_output|mcp_tool_result|tool\.execution_complete/i;
141
+ function containsMarkerInToolResult(value) {
142
+ if (Array.isArray(value))
143
+ return value.some(item => containsMarkerInToolResult(item));
144
+ if (typeof value !== 'object' || value === null)
145
+ return false;
146
+ const obj = value;
147
+ if (typeof obj.type === 'string' && TOOL_RESULT_TYPE_RE.test(obj.type)) {
148
+ return JSON.stringify(obj).includes('FRAIM_RECURRING_PARK');
149
+ }
150
+ return Object.values(obj).some(v => containsMarkerInToolResult(v));
151
+ }
152
+ function parseRecurringParkSignal(line) {
153
+ if (!line.includes('FRAIM_RECURRING_PARK'))
154
+ return false;
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(line);
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ return containsMarkerInToolResult(parsed);
163
+ }
127
164
  // Issue #710: extract the job name from a get_fraim_job tool call. This
128
165
  // is structured known-job evidence; the server still validates the job id
129
166
  // against the catalog before promoting any run.
@@ -184,6 +221,43 @@ function parseFraimJobLoadSignal(line) {
184
221
  }
185
222
  return null;
186
223
  }
224
+ function parseExecutionModeSignal(line) {
225
+ if (!line.includes('Execution Mode Context'))
226
+ return null;
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(line);
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ if (typeof parsed !== 'object' || parsed === null)
235
+ return null;
236
+ const obj = parsed;
237
+ // Codex/codex_apps shape: the completed get_fraim_job tool call carries its
238
+ // result in item.result.content[].text. The local proxy side-channel does not
239
+ // run for this connector path, so Hub must consume the same payload directly.
240
+ if (obj.type === 'item.completed' &&
241
+ typeof obj.item === 'object' &&
242
+ obj.item !== null) {
243
+ const item = obj.item;
244
+ if (item.type === 'mcp_tool_call' && isFraimTool(item.tool, 'get_fraim_job')) {
245
+ const result = item.result;
246
+ const content = result && Array.isArray(result.content) ? result.content : [];
247
+ for (const entry of content) {
248
+ if (typeof entry !== 'object' || entry === null)
249
+ continue;
250
+ const text = entry.text;
251
+ if (typeof text !== 'string')
252
+ continue;
253
+ const signal = readExecutionModeFromText(text);
254
+ if (signal)
255
+ return signal;
256
+ }
257
+ }
258
+ }
259
+ return null;
260
+ }
187
261
  // Issue #347 — extract per-turn usage from the host's JSON stream.
188
262
  // Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
189
263
  // Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
@@ -477,6 +551,29 @@ function readFraimJobFromArgs(rawArgs) {
477
551
  return null;
478
552
  return { jobId, source: 'get_fraim_job' };
479
553
  }
554
+ function readExecutionModeFromText(text) {
555
+ const marker = '**Execution Mode Context:**';
556
+ const idx = text.indexOf(marker);
557
+ if (idx < 0)
558
+ return null;
559
+ const jsonStart = text.indexOf('```json', idx);
560
+ const jsonEnd = text.indexOf('```', jsonStart + 7);
561
+ if (jsonStart < 0 || jsonEnd < 0)
562
+ return null;
563
+ try {
564
+ const parsed = JSON.parse(text.slice(jsonStart + 7, jsonEnd).trim());
565
+ const mode = parsed.executionMode?.mode;
566
+ if (typeof mode !== 'string')
567
+ return null;
568
+ return {
569
+ mode: mode === 'trusted' ? 'trusted' : 'coached',
570
+ completedRuns: typeof parsed.executionMode?.completedRuns === 'number' ? parsed.executionMode.completedRuns : 0,
571
+ };
572
+ }
573
+ catch {
574
+ return null;
575
+ }
576
+ }
480
577
  function normalizeToolArgs(rawArgs) {
481
578
  if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
482
579
  return rawArgs;
@@ -996,11 +1093,13 @@ const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
996
1093
  // cached. The TTL is a safety net for a CLI installed outside the Hub; installs performed
997
1094
  // THROUGH the Hub invalidate explicitly (see the install-agent route) so they appear at once.
998
1095
  const EMPLOYEE_DETECTION_TTL_MS = 5 * 60 * 1000;
1096
+ const EMPLOYEE_DETECTION_REFRESH_SIGNAL_MS = 500;
999
1097
  let employeeDetectionTtlMs = EMPLOYEE_DETECTION_TTL_MS;
1000
1098
  let cachedEmployees = null;
1001
1099
  let cachedEmployeesAtMs = 0;
1002
1100
  let cachedEmployeesContext = null;
1003
1101
  let inFlightDetection = null;
1102
+ let employeeDetectionRefreshSignalUntilMs = 0;
1004
1103
  function employeeDetectionContext() {
1005
1104
  return JSON.stringify({
1006
1105
  PATH: process.env.PATH || '',
@@ -1090,6 +1189,7 @@ function adoptPersistedEmployees(persisted) {
1090
1189
  cachedEmployeesAtMs = persisted.detectedAtMs;
1091
1190
  cachedEmployeesContext = employeeDetectionContext();
1092
1191
  if (Date.now() - persisted.detectedAtMs > employeeDetectionTtlMs) {
1192
+ employeeDetectionRefreshSignalUntilMs = Date.now() + EMPLOYEE_DETECTION_REFRESH_SIGNAL_MS;
1093
1193
  void detectEmployeesAsync({ force: true }).catch(() => undefined);
1094
1194
  }
1095
1195
  return persisted.employees;
@@ -1126,6 +1226,7 @@ function invalidateEmployeeDetectionCache() {
1126
1226
  cachedEmployeesAtMs = 0;
1127
1227
  cachedEmployeesContext = null;
1128
1228
  inFlightDetection = null;
1229
+ employeeDetectionRefreshSignalUntilMs = 0;
1129
1230
  try {
1130
1231
  fs_1.default.rmSync(agentAvailabilityFilePath(), { force: true });
1131
1232
  }
@@ -1137,6 +1238,7 @@ function __clearEmployeeDetectionMemoryCacheForTests() {
1137
1238
  cachedEmployeesAtMs = 0;
1138
1239
  cachedEmployeesContext = null;
1139
1240
  inFlightDetection = null;
1241
+ employeeDetectionRefreshSignalUntilMs = 0;
1140
1242
  }
1141
1243
  /** Test seam only. Mirrors __resetLatestVersionCache in hub-latest-version.ts. */
1142
1244
  function __setEmployeeDetectionTtlForTests(ttlMs) {
@@ -1176,7 +1278,7 @@ function buildEmployeeStatus(id, version) {
1176
1278
  * a one-shot answer.
1177
1279
  */
1178
1280
  function isEmployeeDetectionRefreshing() {
1179
- return inFlightDetection !== null;
1281
+ return inFlightDetection !== null || Date.now() < employeeDetectionRefreshSignalUntilMs;
1180
1282
  }
1181
1283
  /**
1182
1284
  * Non-blocking employee detection. Probes every agent CONCURRENTLY, so the cost is the
@@ -1697,24 +1799,29 @@ function parseHostLine(hostId, line) {
1697
1799
  return {};
1698
1800
  // Scan every line for structured signals the Hub UI cares about:
1699
1801
  // seekMentoring (tracker), get_fraim_job (job identity promotion),
1700
- // turn-level usage (totals), and fraim_connect agent identity
1701
- // (cost lookup when the host doesn't emit it).
1802
+ // turn-level usage (totals), fraim_connect agent identity
1803
+ // (cost lookup when the host doesn't emit it), and recurringPark
1804
+ // (marker emitted by the mentor when transitioning to the recurring sentinel).
1702
1805
  const seekMentoring = parseSeekMentoringSignal(trimmed);
1703
1806
  const fraimJob = parseFraimJobLoadSignal(trimmed);
1807
+ const executionMode = parseExecutionModeSignal(trimmed);
1704
1808
  const usage = parseUsageSignal(trimmed);
1705
1809
  const agentIdentity = parseAgentIdentitySignal(trimmed);
1810
+ const recurringPark = parseRecurringParkSignal(trimmed);
1706
1811
  // Issue #1264 (R3): only worth checking when seekMentoring itself did not
1707
1812
  // already resolve — a recognized call has nothing to diagnose.
1708
1813
  const unrecognizedReviewHandoffError = seekMentoring ? null : detectUnrecognizedReviewHandoffCall(trimmed);
1709
1814
  const withSignal = (event) => {
1710
- if (!seekMentoring && !fraimJob && !usage && !agentIdentity && !unrecognizedReviewHandoffError)
1815
+ if (!seekMentoring && !fraimJob && !executionMode && !usage && !agentIdentity && !recurringPark && !unrecognizedReviewHandoffError)
1711
1816
  return event;
1712
1817
  return {
1713
1818
  ...event,
1714
1819
  ...(seekMentoring ? { seekMentoring } : {}),
1715
1820
  ...(fraimJob ? { fraimJob } : {}),
1821
+ ...(executionMode ? { executionMode } : {}),
1716
1822
  ...(usage ? { usage } : {}),
1717
1823
  ...(agentIdentity ? { agentIdentity } : {}),
1824
+ ...(recurringPark ? { recurringPark: true } : {}),
1718
1825
  ...(unrecognizedReviewHandoffError && !event.hostError ? { hostError: unrecognizedReviewHandoffError } : {}),
1719
1826
  };
1720
1827
  };
@@ -58,9 +58,6 @@ const brand_store_1 = require("../core/brand-store");
58
58
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
59
59
  const catalog_1 = require("./catalog");
60
60
  const job_visualization_1 = require("../core/job-visualization");
61
- // Issue #1491: reserved sentinel phase a recurring job parks at between
62
- // scheduled checks (registry/delivery/recurring-checkpoint.md).
63
- const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
64
61
  const custom_employees_1 = require("./custom-employees");
65
62
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
66
63
  const hosts_1 = require("./hosts");
@@ -442,7 +439,7 @@ class AiHubRunRegistry {
442
439
  }
443
440
  }
444
441
  // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
445
- const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot', 'antigravity'];
442
+ const VALID_HOST_IDS = ['codex', 'claude', 'gemini', 'copilot', 'antigravity'];
446
443
  const SCHEDULED_FIRE_LEASE_TTL_MS = 2 * 60 * 1000;
447
444
  const SCHEDULED_FIRE_LEASE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
448
445
  function startSessionSeedForHost(hostId, runId) {
@@ -2614,6 +2611,19 @@ function classifyExit(run, exitCode) {
2614
2611
  const declaredPath = (0, catalog_1.loadJobPhases)(run.jobId, run.projectPath, run.runDiscriminant || 'feature');
2615
2612
  const lastDeclared = declaredPath.length > 0 ? declaredPath[declaredPath.length - 1] : null;
2616
2613
  if (lastDeclared && lastDeclared.id === currentPhase) {
2614
+ // Issues #1491/#1509: the mentor emits <!-- FRAIM_RECURRING_PARK --> in its reply
2615
+ // when it resolves the completed phase to the recurring-checkpoint sentinel.
2616
+ // This takes priority over the 'done' classification because the recurring-checkpoint
2617
+ // phase IS by design the terminal phase of its job — the job is NOT done, it is
2618
+ // parked until its next scheduled invocation.
2619
+ if (run.recurringPark === true) {
2620
+ // Scheduled/webhook runs park at the recurring sentinel; manager-triggered
2621
+ // runs are handled conservatively so the manager sees a response.
2622
+ if (run.sourceTrigger === 'scheduled' || run.sourceTrigger === 'webhook') {
2623
+ return { action: 'park', pauseReason: 'parked_recurring' };
2624
+ }
2625
+ return { action: 'park', pauseReason: 'awaiting_user' };
2626
+ }
2617
2627
  return { action: 'done', pauseReason: 'done' };
2618
2628
  }
2619
2629
  if (compactingActive) {
@@ -2637,13 +2647,10 @@ function classifyExit(run, exitCode) {
2637
2647
  // real description, not the post-clear fallback.
2638
2648
  return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
2639
2649
  }
2640
- // Issue #1491: a non-final scheduled/webhook check parks at the reserved
2641
- // recurring-checkpoint sentinel by design (registry/delivery/recurring-checkpoint.md).
2642
- // This is not a human-decision gate — the scheduler resumes it on its own. Gated on
2643
- // sourceTrigger so a manager-initiated run that somehow lands on the sentinel phase
2644
- // (not expected in normal operation) keeps today's conservative awaiting_user default.
2645
- if (currentPhase === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION
2646
- && (run.sourceTrigger === 'scheduled' || run.sourceTrigger === 'webhook')) {
2650
+ // Issues #1491/#1509: also check recurringPark for mid-job phases (not the last
2651
+ // declared phase) — covers jobs where the sentinel is reached before the final step.
2652
+ if ((run.sourceTrigger === 'scheduled' || run.sourceTrigger === 'webhook')
2653
+ && run.recurringPark === true) {
2647
2654
  return { action: 'park', pauseReason: 'parked_recurring' };
2648
2655
  }
2649
2656
  return { action: 'park', pauseReason: 'awaiting_user' };
@@ -3266,9 +3273,18 @@ class AiHubServer {
3266
3273
  // cache miss the synchronous form would spawnSync per agent (~2.7s) and freeze the
3267
3274
  // event loop, making every concurrent request wait. Falls back to the sync form for
3268
3275
  // HostRuntime stubs that do not implement the async variant.
3276
+ let employeesRefreshing = false;
3269
3277
  const employees = this.hostRuntime.detectEmployeesAsync
3270
- ? await this.hostRuntime.detectEmployeesAsync()
3278
+ ? await (() => {
3279
+ const wasRefreshing = this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false;
3280
+ const detection = this.hostRuntime.detectEmployeesAsync();
3281
+ employeesRefreshing = !wasRefreshing && (this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false);
3282
+ return detection;
3283
+ })()
3271
3284
  : this.hostRuntime.detectEmployees();
3285
+ if (!this.hostRuntime.detectEmployeesAsync) {
3286
+ employeesRefreshing = this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false;
3287
+ }
3272
3288
  const configuredAgents = this.configuredAgentStore
3273
3289
  .listWithDefaults(employees)
3274
3290
  .map((agent) => (0, configured_agents_1.projectConfiguredAgent)(agent, employees));
@@ -3360,7 +3376,7 @@ class AiHubServer {
3360
3376
  // Issue #1256 (slice b, AC-B1): true when `employees` above may be a stale persisted
3361
3377
  // snapshot while a background re-probe is in flight. The client uses this to schedule
3362
3378
  // a bounded follow-up refresh instead of trusting a one-shot bootstrap forever.
3363
- employeesRefreshing: this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false,
3379
+ employeesRefreshing,
3364
3380
  configuredAgents,
3365
3381
  personas,
3366
3382
  // Issue #1005: lets the client tell a resolved projection from the first-paint
@@ -3558,7 +3574,23 @@ class AiHubServer {
3558
3574
  run.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostIdForCommand, sessionId);
3559
3575
  }
3560
3576
  recordHostEvent(run, hostId, event, channel) {
3577
+ // Issue #1509: reset when a new agent process session starts (system event with
3578
+ // sessionId). Without this, a marker set during a compaction or background-task-lost
3579
+ // auto-resume's first process would bleed into the continuation's unrelated exit and
3580
+ // force parked_recurring on work that has nothing to do with the recurring sentinel.
3581
+ if (channel === 'system' && event.sessionId)
3582
+ run.recurringPark = undefined;
3583
+ // Set once the marker is confirmed in the current process; cleared above at next start.
3584
+ if (event.recurringPark)
3585
+ run.recurringPark = true;
3561
3586
  appendHostMessage(run, hostId, event, channel);
3587
+ if (event.executionMode) {
3588
+ const normalizedMode = event.executionMode.mode === 'trusted' ? 'trusted' : 'coached';
3589
+ const normalizedRuns = typeof event.executionMode.completedRuns === 'number'
3590
+ ? event.executionMode.completedRuns
3591
+ : 0;
3592
+ run.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
3593
+ }
3562
3594
  // Issue #1221: a real assistant reply proves the host recovered, so a
3563
3595
  // previously recorded error is stale and must not keep haunting the next
3564
3596
  // exit/recovery message. Issue #1284: a classified recovery signature is
@@ -5458,11 +5490,20 @@ class AiHubServer {
5458
5490
  }
5459
5491
  const employees = this.hostRuntime.detectEmployees();
5460
5492
  const priorBaseHostId = (conversation.baseHostId || conversation.agentName);
5461
- const priorSessionId = typeof conversation.sessionId === 'string' && conversation.sessionId.trim() ? conversation.sessionId.trim() : '';
5462
5493
  const resolved = this.resolveLaunchAgent(configuredAgentId, undefined, employees);
5494
+ // Issue #1515: a shared baseHostId (e.g. Codex vs. Codex Azure Script) does not mean
5495
+ // the two configured agents share a session/auth store. Require the base host to
5496
+ // still match, then resolve the owner-qualified host session from #1047's ledger
5497
+ // instead of trusting whichever session the conversation's legacy `sessionId` field
5498
+ // last mirrored, so a switch across an isolated CODEX_HOME always restarts with a
5499
+ // handoff brief rather than attempting a resume that cannot succeed.
5500
+ const resolvedHostSession = priorBaseHostId === resolved.hostId
5501
+ ? host_session_state_1.hostSessionState.resolve(conversation, { configuredAgentId: resolved.agent.id, baseHostId: resolved.hostId })
5502
+ : null;
5503
+ const priorSessionId = resolvedHostSession?.sessionId || '';
5463
5504
  const managerNote = (body.instructions || '').trim() || 'Continue this job from the preserved Hub conversation state.';
5464
5505
  const handoffSummary = buildAgentSwitchHandoffSummary(conversation);
5465
- const canResumeSameHost = priorBaseHostId === resolved.hostId && !!priorSessionId;
5506
+ const canResumeSameHost = !!priorSessionId;
5466
5507
  const initialStrategy = canResumeSameHost ? 'resume_same_host' : 'restart_with_handoff';
5467
5508
  const activePriorRun = typeof conversation.runId === 'string' ? this.runRegistry.get(conversation.runId) : undefined;
5468
5509
  if (activePriorRun?.status === 'running') {
@@ -6580,6 +6621,7 @@ class AiHubServer {
6580
6621
  // offering next-job recommendations immediately, not only once the
6581
6622
  // resumed session's next seekMentoring signal arrives.
6582
6623
  current.nextJobRecommendations = null;
6624
+ current.reviewHandoff = null;
6583
6625
  });
6584
6626
  const startedFresh = this.runRegistry.get(run.id);
6585
6627
  if (startedFresh)
@@ -6639,6 +6681,7 @@ class AiHubServer {
6639
6681
  // Issue #1373 (Defect 2): see the no-session branch above for why
6640
6682
  // this can only be stale at coaching-send time.
6641
6683
  current.nextJobRecommendations = null;
6684
+ current.reviewHandoff = null;
6642
6685
  });
6643
6686
  const started = this.runRegistry.get(run.id);
6644
6687
  if (started)
@@ -7102,8 +7145,8 @@ class AiHubServer {
7102
7145
  if (!cronLib.validate(cronExpr)) {
7103
7146
  return res.status(400).json({ error: 'Invalid cron expression.' });
7104
7147
  }
7105
- const validEmployees = VALID_EMPLOYEE_IDS;
7106
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
7148
+ const validHosts = VALID_HOST_IDS;
7149
+ const resolvedHostId = validHosts.includes(hostId) ? hostId : 'claude';
7107
7150
  let resolvedProjectPath;
7108
7151
  let normalizedExpiresAt;
7109
7152
  try {
@@ -7212,7 +7255,7 @@ class AiHubServer {
7212
7255
  catch (err) {
7213
7256
  return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
7214
7257
  }
7215
- const validEmployees = VALID_EMPLOYEE_IDS;
7258
+ const validHosts = VALID_HOST_IDS;
7216
7259
  const existing = this.deploymentStore.load().find((d) => d.id === id && d.type === 'scheduled');
7217
7260
  if (!existing)
7218
7261
  return res.status(404).json({ error: 'Deployment not found.' });
@@ -7221,7 +7264,7 @@ class AiHubServer {
7221
7264
  if (jobId !== undefined && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7222
7265
  return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7223
7266
  }
7224
- const nextHostId = hostId !== undefined && validEmployees.includes(hostId) ? hostId : existing.hostId;
7267
+ const nextHostId = hostId !== undefined && validHosts.includes(hostId) ? hostId : existing.hostId;
7225
7268
  const nextConfiguredAgentId = configuredAgentId !== undefined
7226
7269
  ? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
7227
7270
  : existing.configuredAgentId;
@@ -7294,8 +7337,8 @@ class AiHubServer {
7294
7337
  if (!label || !jobId) {
7295
7338
  return res.status(400).json({ error: 'label and jobId are required.' });
7296
7339
  }
7297
- const validEmployees = VALID_EMPLOYEE_IDS;
7298
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
7340
+ const validHosts = VALID_HOST_IDS;
7341
+ const resolvedHostId = validHosts.includes(hostId) ? hostId : 'claude';
7299
7342
  const now = new Date().toISOString();
7300
7343
  const deployment = {
7301
7344
  id: (0, crypto_1.randomUUID)(),
@@ -7347,7 +7390,7 @@ class AiHubServer {
7347
7390
  catch (err) {
7348
7391
  return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
7349
7392
  }
7350
- const validEmployees = VALID_EMPLOYEE_IDS;
7393
+ const validHosts = VALID_HOST_IDS;
7351
7394
  let updated = null;
7352
7395
  const ok = this.deploymentStore.update(id, (dep) => {
7353
7396
  if (label !== undefined)
@@ -7356,7 +7399,7 @@ class AiHubServer {
7356
7399
  dep.jobId = jobId;
7357
7400
  if (resolvedProjectPath !== undefined)
7358
7401
  dep.projectPath = resolvedProjectPath;
7359
- if (hostId !== undefined && validEmployees.includes(hostId))
7402
+ if (hostId !== undefined && validHosts.includes(hostId))
7360
7403
  dep.hostId = hostId;
7361
7404
  if (configuredAgentId !== undefined)
7362
7405
  dep.configuredAgentId = typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined;
@@ -7640,23 +7683,33 @@ class AiHubServer {
7640
7683
  // Stable API endpoint for extension surfaces (Office add-ins, browser
7641
7684
  // extensions, VS Code extensions, Electron tray) to start FRAIM jobs.
7642
7685
  //
7643
- // Body: { employeeId, jobName, context?: { text, sourceApp, fileName, ... }, projectPath? }
7644
- // Response: { runId, status: "started", employee, job }
7686
+ // Body: { configuredAgentId?, hostId?, personaKey?, jobName, context?, projectPath? }
7687
+ // Response: { hubRunId, status, hostId, configuredAgentId, personaKey, jobName }
7645
7688
  // -------------------------------------------------------------------------
7646
7689
  this.app.post('/api/trigger', (req, res) => {
7647
7690
  try {
7648
- const { employeeId, jobName, context, projectPath: reqProjectPath } = req.body;
7649
- if (!employeeId) {
7650
- return res.status(400).json({ error: 'employeeId is required.' });
7651
- }
7691
+ const { jobName, context, projectPath: reqProjectPath } = req.body;
7692
+ const configuredAgentId = typeof req.body.configuredAgentId === 'string' ? req.body.configuredAgentId.trim() : '';
7693
+ const hostIdInput = typeof req.body.hostId === 'string' ? req.body.hostId.trim() : '';
7694
+ const explicitPersonaKey = typeof req.body.personaKey === 'string' ? req.body.personaKey.trim() : '';
7652
7695
  if (!jobName) {
7653
7696
  return res.status(400).json({ error: 'jobName is required.' });
7654
7697
  }
7698
+ if (hostIdInput && !VALID_HOST_IDS.includes(hostIdInput)) {
7699
+ return res.status(400).json({ error: 'hostId must be one of the supported execution hosts.' });
7700
+ }
7655
7701
  const projectPath = ensureDirectoryPath(reqProjectPath || this.defaultProjectPath());
7656
- // Use the requested agent directly caller specifies which agent to run (claude, codex, gemini).
7657
- const requestedAgentId = req.body.configuredAgentId || employeeId;
7658
- const requestedHostId = VALID_EMPLOYEE_IDS.includes(employeeId) ? employeeId : undefined;
7659
- const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(requestedAgentId, requestedHostId);
7702
+ // Use the requested configured profile or base host directly.
7703
+ const requestedConfiguredAgentId = configuredAgentId || undefined;
7704
+ const requestedHostId = hostIdInput ? hostIdInput : undefined;
7705
+ if (!requestedConfiguredAgentId && !requestedHostId) {
7706
+ return res.status(400).json({ error: 'configuredAgentId or hostId is required.' });
7707
+ }
7708
+ const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(requestedConfiguredAgentId, requestedHostId);
7709
+ if (requestedConfiguredAgentId && requestedHostId && hostId !== requestedHostId) {
7710
+ return res.status(400).json({ error: 'configuredAgentId does not match hostId.' });
7711
+ }
7712
+ const personaKey = explicitPersonaKey || getCustomPersonaForJob(projectPath, jobName) || getHubPersonaForJob(jobName);
7660
7713
  const contextText = context?.text?.trim() || '';
7661
7714
  const sourceInfo = [
7662
7715
  context?.sourceApp ? `sourceApp: ${context.sourceApp}` : '',
@@ -7687,8 +7740,7 @@ class AiHubServer {
7687
7740
  phaseVisits: [],
7688
7741
  totals: emptyTotals(),
7689
7742
  lastStatusChangeAt: startTimestamp,
7690
- // Issue #1357: consult custom persona owner before falling back to catalog.
7691
- personaKey: getCustomPersonaForJob(projectPath, jobName) ?? getHubPersonaForJob(jobName),
7743
+ personaKey,
7692
7744
  };
7693
7745
  // Register the run before spawning so onEvent/onExit callbacks can
7694
7746
  // safely call update() even if they fire synchronously (FakeHostRuntime).
@@ -7726,7 +7778,14 @@ class AiHubServer {
7726
7778
  }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7727
7779
  // Update the registry entry with the real child process handle.
7728
7780
  this.runRegistry.attachChildIfRunning(run.id, child);
7729
- return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
7781
+ return res.json({
7782
+ hubRunId: run.id,
7783
+ status: 'started',
7784
+ hostId,
7785
+ configuredAgentId: configuredAgent.id,
7786
+ personaKey,
7787
+ jobName,
7788
+ });
7730
7789
  }
7731
7790
  catch (error) {
7732
7791
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
@@ -81,7 +81,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
81
81
  personaKey: 'huxley',
82
82
  bundleId: 'persona-huxley-core',
83
83
  catalogMetadata: buildCatalogMetadata('huxley', ['design-system-creation', 'user-facing-prototyping', 'ux-research-synthesis']),
84
- protectedJobs: ['design-system-creation', 'user-facing-prototyping', 'website-creation', 'brand-creation', 'branding-quality-audit', 'ux-research-synthesis', 'user-journey-mapping'],
84
+ protectedJobs: ['design-system-creation', 'user-facing-prototyping', 'website-creation', 'brand-creation', 'branding-quality-audit', 'ux-research-synthesis', 'user-journey-mapping', 'ui-quality-assessment'],
85
85
  protectedAliases: ['ux-design', 'brand-design'],
86
86
  defaultHireMode: 'job',
87
87
  lockCopy: 'Hire hUXley to unlock design-system, UX research, and brand design work for this request.'
@@ -39,6 +39,8 @@ exports.QUALITY_REGISTRY = {
39
39
  'branding-quality-audit': { stage: 'branding', enforced: true, telemetryKind: 'score' },
40
40
  // Product Quality
41
41
  'code-quality-assessment': { stage: 'product-quality', enforced: true, telemetryKind: 'score' },
42
+ // UI/UX Quality
43
+ 'ui-quality-assessment': { stage: 'ui-ux-quality', enforced: true, telemetryKind: 'score' },
42
44
  // Test Quality
43
45
  'test-quality-assessment': { stage: 'test-quality', enforced: true, telemetryKind: 'score' },
44
46
  // Security
@@ -76,6 +78,7 @@ exports.STAGE_DISPLAY_NAMES = {
76
78
  'business-strategy': 'Business Strategy',
77
79
  'branding': 'Branding',
78
80
  'product-quality': 'Product Quality',
81
+ 'ui-ux-quality': 'UI/UX Quality',
79
82
  'test-quality': 'Test Quality',
80
83
  'security': 'Security',
81
84
  'production-readiness': 'Production Readiness',
@@ -90,6 +93,7 @@ exports.ALL_STAGE_CATEGORIES = [
90
93
  'business-strategy',
91
94
  'branding',
92
95
  'product-quality',
96
+ 'ui-ux-quality',
93
97
  'test-quality',
94
98
  'security',
95
99
  'production-readiness',
@@ -153,6 +157,12 @@ const QUALITY_SCORE_DIMENSIONS = {
153
157
  'architecture',
154
158
  'maintainability'
155
159
  ],
160
+ 'ui-quality-assessment': [
161
+ 'lookAndFeel',
162
+ 'usabilityHeuristics',
163
+ 'interactionConsistency',
164
+ 'accessibility'
165
+ ],
156
166
  'test-quality-assessment': [
157
167
  'coverage',
158
168
  'testIntegrity',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.302",
3
+ "version": "2.0.303",
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",
@@ -211,7 +211,7 @@
211
211
  "electron-updater": "^6.8.9",
212
212
  "express": "^5.2.1",
213
213
  "extract-zip": "^2.0.1",
214
- "fraim": "2.0.302",
214
+ "fraim": "2.0.303",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -3187,6 +3187,15 @@ function truncateKickoffForTitle(text) {
3187
3187
  return words.slice(0, 6).join(' ');
3188
3188
  }
3189
3189
 
3190
+ function hasManagerKickoffMessage(conv) {
3191
+ return !!(conv && Array.isArray(conv.messages)
3192
+ && conv.messages.some((message) => message && message.role === 'manager' && (message.text || '').trim()));
3193
+ }
3194
+
3195
+ function savedTitleMatchesKickoff(conv, kickoffText) {
3196
+ return !!(conv && conv.title && String(conv.title).replace(/\s+/g, ' ').trim() === String(kickoffText || '').replace(/\s+/g, ' ').trim());
3197
+ }
3198
+
3190
3199
  function kickoffDescription(conv) {
3191
3200
  const text = managerKickoffText(conv);
3192
3201
  if (!text) return '';
@@ -3197,9 +3206,12 @@ function kickoffDescription(conv) {
3197
3206
  function composedConversationTitle(conv) {
3198
3207
  if (!conv) return '';
3199
3208
  const jobName = resolvedConversationJobName(conv);
3200
- const kickoff = truncateKickoffForTitle(managerKickoffText(conv));
3209
+ const kickoffText = managerKickoffText(conv);
3210
+ const kickoff = truncateKickoffForTitle(kickoffText);
3201
3211
  if (jobName && kickoff) return `${jobName}: ${kickoff}`;
3202
3212
  if (jobName) return jobName;
3213
+ if (kickoff && hasManagerKickoffMessage(conv) && savedTitleMatchesKickoff(conv, kickoffText)) return kickoff;
3214
+ if (conv && conv.title) return conv.title;
3203
3215
  if (kickoff) return kickoff;
3204
3216
  return 'New job';
3205
3217
  }