fraim-hub 2.0.302 → 2.0.304

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) {
@@ -1588,13 +1585,13 @@ function applyReviewHandoffToRun(run, reviewHandoff) {
1588
1585
  run.artifacts = normalizedReviewHandoff.reviewTarget?.type === 'artifact_set'
1589
1586
  ? normalizedReviewHandoff.artifacts
1590
1587
  : [];
1588
+ // Do not set pauseReason here. pauseReason transitions to 'awaiting_review' only
1589
+ // at turn exit via classifyExit → isHumanActionGate. Setting it here would show
1590
+ // "Waiting on You" while the agent's turn is still running, enabling unsafe
1591
+ // approval queueing before the agent has finished its current work.
1591
1592
  if (normalizedReviewHandoff.reviewRequired === true) {
1592
- run.pauseReason = 'awaiting_review';
1593
1593
  run.nextJobRecommendations = null;
1594
1594
  }
1595
- else if (normalizedReviewHandoff.reviewRequired === false && run.pauseReason === 'awaiting_review') {
1596
- run.pauseReason = 'working';
1597
- }
1598
1595
  }
1599
1596
  // Issue #1447: a mismatched `pull_request` target, isolated for readability.
1600
1597
  function pullRequestTargetMismatch(stored, submittedTarget) {
@@ -2614,6 +2611,14 @@ 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
+ return { action: 'park', pauseReason: 'parked_recurring' };
2621
+ }
2617
2622
  return { action: 'done', pauseReason: 'done' };
2618
2623
  }
2619
2624
  if (compactingActive) {
@@ -2637,13 +2642,9 @@ function classifyExit(run, exitCode) {
2637
2642
  // real description, not the post-clear fallback.
2638
2643
  return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
2639
2644
  }
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')) {
2645
+ // Issues #1491/#1509: also check recurringPark for mid-job phases (not the last
2646
+ // declared phase) — covers jobs where the sentinel is reached before the final step.
2647
+ if (run.recurringPark === true) {
2647
2648
  return { action: 'park', pauseReason: 'parked_recurring' };
2648
2649
  }
2649
2650
  return { action: 'park', pauseReason: 'awaiting_user' };
@@ -3266,9 +3267,18 @@ class AiHubServer {
3266
3267
  // cache miss the synchronous form would spawnSync per agent (~2.7s) and freeze the
3267
3268
  // event loop, making every concurrent request wait. Falls back to the sync form for
3268
3269
  // HostRuntime stubs that do not implement the async variant.
3270
+ let employeesRefreshing = false;
3269
3271
  const employees = this.hostRuntime.detectEmployeesAsync
3270
- ? await this.hostRuntime.detectEmployeesAsync()
3272
+ ? await (() => {
3273
+ const wasRefreshing = this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false;
3274
+ const detection = this.hostRuntime.detectEmployeesAsync();
3275
+ employeesRefreshing = !wasRefreshing && (this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false);
3276
+ return detection;
3277
+ })()
3271
3278
  : this.hostRuntime.detectEmployees();
3279
+ if (!this.hostRuntime.detectEmployeesAsync) {
3280
+ employeesRefreshing = this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false;
3281
+ }
3272
3282
  const configuredAgents = this.configuredAgentStore
3273
3283
  .listWithDefaults(employees)
3274
3284
  .map((agent) => (0, configured_agents_1.projectConfiguredAgent)(agent, employees));
@@ -3360,7 +3370,7 @@ class AiHubServer {
3360
3370
  // Issue #1256 (slice b, AC-B1): true when `employees` above may be a stale persisted
3361
3371
  // snapshot while a background re-probe is in flight. The client uses this to schedule
3362
3372
  // a bounded follow-up refresh instead of trusting a one-shot bootstrap forever.
3363
- employeesRefreshing: this.hostRuntime.isEmployeeDetectionRefreshing?.() ?? false,
3373
+ employeesRefreshing,
3364
3374
  configuredAgents,
3365
3375
  personas,
3366
3376
  // Issue #1005: lets the client tell a resolved projection from the first-paint
@@ -3558,7 +3568,23 @@ class AiHubServer {
3558
3568
  run.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostIdForCommand, sessionId);
3559
3569
  }
3560
3570
  recordHostEvent(run, hostId, event, channel) {
3571
+ // Issue #1509: reset when a new agent process session starts (system event with
3572
+ // sessionId). Without this, a marker set during a compaction or background-task-lost
3573
+ // auto-resume's first process would bleed into the continuation's unrelated exit and
3574
+ // force parked_recurring on work that has nothing to do with the recurring sentinel.
3575
+ if (channel === 'system' && event.sessionId)
3576
+ run.recurringPark = undefined;
3577
+ // Set once the marker is confirmed in the current process; cleared above at next start.
3578
+ if (event.recurringPark)
3579
+ run.recurringPark = true;
3561
3580
  appendHostMessage(run, hostId, event, channel);
3581
+ if (event.executionMode) {
3582
+ const normalizedMode = event.executionMode.mode === 'trusted' ? 'trusted' : 'coached';
3583
+ const normalizedRuns = typeof event.executionMode.completedRuns === 'number'
3584
+ ? event.executionMode.completedRuns
3585
+ : 0;
3586
+ run.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
3587
+ }
3562
3588
  // Issue #1221: a real assistant reply proves the host recovered, so a
3563
3589
  // previously recorded error is stale and must not keep haunting the next
3564
3590
  // exit/recovery message. Issue #1284: a classified recovery signature is
@@ -5458,11 +5484,20 @@ class AiHubServer {
5458
5484
  }
5459
5485
  const employees = this.hostRuntime.detectEmployees();
5460
5486
  const priorBaseHostId = (conversation.baseHostId || conversation.agentName);
5461
- const priorSessionId = typeof conversation.sessionId === 'string' && conversation.sessionId.trim() ? conversation.sessionId.trim() : '';
5462
5487
  const resolved = this.resolveLaunchAgent(configuredAgentId, undefined, employees);
5488
+ // Issue #1515: a shared baseHostId (e.g. Codex vs. Codex Azure Script) does not mean
5489
+ // the two configured agents share a session/auth store. Require the base host to
5490
+ // still match, then resolve the owner-qualified host session from #1047's ledger
5491
+ // instead of trusting whichever session the conversation's legacy `sessionId` field
5492
+ // last mirrored, so a switch across an isolated CODEX_HOME always restarts with a
5493
+ // handoff brief rather than attempting a resume that cannot succeed.
5494
+ const resolvedHostSession = priorBaseHostId === resolved.hostId
5495
+ ? host_session_state_1.hostSessionState.resolve(conversation, { configuredAgentId: resolved.agent.id, baseHostId: resolved.hostId })
5496
+ : null;
5497
+ const priorSessionId = resolvedHostSession?.sessionId || '';
5463
5498
  const managerNote = (body.instructions || '').trim() || 'Continue this job from the preserved Hub conversation state.';
5464
5499
  const handoffSummary = buildAgentSwitchHandoffSummary(conversation);
5465
- const canResumeSameHost = priorBaseHostId === resolved.hostId && !!priorSessionId;
5500
+ const canResumeSameHost = !!priorSessionId;
5466
5501
  const initialStrategy = canResumeSameHost ? 'resume_same_host' : 'restart_with_handoff';
5467
5502
  const activePriorRun = typeof conversation.runId === 'string' ? this.runRegistry.get(conversation.runId) : undefined;
5468
5503
  if (activePriorRun?.status === 'running') {
@@ -6580,6 +6615,7 @@ class AiHubServer {
6580
6615
  // offering next-job recommendations immediately, not only once the
6581
6616
  // resumed session's next seekMentoring signal arrives.
6582
6617
  current.nextJobRecommendations = null;
6618
+ current.reviewHandoff = null;
6583
6619
  });
6584
6620
  const startedFresh = this.runRegistry.get(run.id);
6585
6621
  if (startedFresh)
@@ -6639,6 +6675,7 @@ class AiHubServer {
6639
6675
  // Issue #1373 (Defect 2): see the no-session branch above for why
6640
6676
  // this can only be stale at coaching-send time.
6641
6677
  current.nextJobRecommendations = null;
6678
+ current.reviewHandoff = null;
6642
6679
  });
6643
6680
  const started = this.runRegistry.get(run.id);
6644
6681
  if (started)
@@ -7102,8 +7139,8 @@ class AiHubServer {
7102
7139
  if (!cronLib.validate(cronExpr)) {
7103
7140
  return res.status(400).json({ error: 'Invalid cron expression.' });
7104
7141
  }
7105
- const validEmployees = VALID_EMPLOYEE_IDS;
7106
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
7142
+ const validHosts = VALID_HOST_IDS;
7143
+ const resolvedHostId = validHosts.includes(hostId) ? hostId : 'claude';
7107
7144
  let resolvedProjectPath;
7108
7145
  let normalizedExpiresAt;
7109
7146
  try {
@@ -7212,7 +7249,7 @@ class AiHubServer {
7212
7249
  catch (err) {
7213
7250
  return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
7214
7251
  }
7215
- const validEmployees = VALID_EMPLOYEE_IDS;
7252
+ const validHosts = VALID_HOST_IDS;
7216
7253
  const existing = this.deploymentStore.load().find((d) => d.id === id && d.type === 'scheduled');
7217
7254
  if (!existing)
7218
7255
  return res.status(404).json({ error: 'Deployment not found.' });
@@ -7221,7 +7258,7 @@ class AiHubServer {
7221
7258
  if (jobId !== undefined && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7222
7259
  return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7223
7260
  }
7224
- const nextHostId = hostId !== undefined && validEmployees.includes(hostId) ? hostId : existing.hostId;
7261
+ const nextHostId = hostId !== undefined && validHosts.includes(hostId) ? hostId : existing.hostId;
7225
7262
  const nextConfiguredAgentId = configuredAgentId !== undefined
7226
7263
  ? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
7227
7264
  : existing.configuredAgentId;
@@ -7294,8 +7331,8 @@ class AiHubServer {
7294
7331
  if (!label || !jobId) {
7295
7332
  return res.status(400).json({ error: 'label and jobId are required.' });
7296
7333
  }
7297
- const validEmployees = VALID_EMPLOYEE_IDS;
7298
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
7334
+ const validHosts = VALID_HOST_IDS;
7335
+ const resolvedHostId = validHosts.includes(hostId) ? hostId : 'claude';
7299
7336
  const now = new Date().toISOString();
7300
7337
  const deployment = {
7301
7338
  id: (0, crypto_1.randomUUID)(),
@@ -7347,7 +7384,7 @@ class AiHubServer {
7347
7384
  catch (err) {
7348
7385
  return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
7349
7386
  }
7350
- const validEmployees = VALID_EMPLOYEE_IDS;
7387
+ const validHosts = VALID_HOST_IDS;
7351
7388
  let updated = null;
7352
7389
  const ok = this.deploymentStore.update(id, (dep) => {
7353
7390
  if (label !== undefined)
@@ -7356,7 +7393,7 @@ class AiHubServer {
7356
7393
  dep.jobId = jobId;
7357
7394
  if (resolvedProjectPath !== undefined)
7358
7395
  dep.projectPath = resolvedProjectPath;
7359
- if (hostId !== undefined && validEmployees.includes(hostId))
7396
+ if (hostId !== undefined && validHosts.includes(hostId))
7360
7397
  dep.hostId = hostId;
7361
7398
  if (configuredAgentId !== undefined)
7362
7399
  dep.configuredAgentId = typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined;
@@ -7640,23 +7677,33 @@ class AiHubServer {
7640
7677
  // Stable API endpoint for extension surfaces (Office add-ins, browser
7641
7678
  // extensions, VS Code extensions, Electron tray) to start FRAIM jobs.
7642
7679
  //
7643
- // Body: { employeeId, jobName, context?: { text, sourceApp, fileName, ... }, projectPath? }
7644
- // Response: { runId, status: "started", employee, job }
7680
+ // Body: { configuredAgentId?, hostId?, personaKey?, jobName, context?, projectPath? }
7681
+ // Response: { hubRunId, status, hostId, configuredAgentId, personaKey, jobName }
7645
7682
  // -------------------------------------------------------------------------
7646
7683
  this.app.post('/api/trigger', (req, res) => {
7647
7684
  try {
7648
- const { employeeId, jobName, context, projectPath: reqProjectPath } = req.body;
7649
- if (!employeeId) {
7650
- return res.status(400).json({ error: 'employeeId is required.' });
7651
- }
7685
+ const { jobName, context, projectPath: reqProjectPath } = req.body;
7686
+ const configuredAgentId = typeof req.body.configuredAgentId === 'string' ? req.body.configuredAgentId.trim() : '';
7687
+ const hostIdInput = typeof req.body.hostId === 'string' ? req.body.hostId.trim() : '';
7688
+ const explicitPersonaKey = typeof req.body.personaKey === 'string' ? req.body.personaKey.trim() : '';
7652
7689
  if (!jobName) {
7653
7690
  return res.status(400).json({ error: 'jobName is required.' });
7654
7691
  }
7692
+ if (hostIdInput && !VALID_HOST_IDS.includes(hostIdInput)) {
7693
+ return res.status(400).json({ error: 'hostId must be one of the supported execution hosts.' });
7694
+ }
7655
7695
  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);
7696
+ // Use the requested configured profile or base host directly.
7697
+ const requestedConfiguredAgentId = configuredAgentId || undefined;
7698
+ const requestedHostId = hostIdInput ? hostIdInput : undefined;
7699
+ if (!requestedConfiguredAgentId && !requestedHostId) {
7700
+ return res.status(400).json({ error: 'configuredAgentId or hostId is required.' });
7701
+ }
7702
+ const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(requestedConfiguredAgentId, requestedHostId);
7703
+ if (requestedConfiguredAgentId && requestedHostId && hostId !== requestedHostId) {
7704
+ return res.status(400).json({ error: 'configuredAgentId does not match hostId.' });
7705
+ }
7706
+ const personaKey = explicitPersonaKey || getCustomPersonaForJob(projectPath, jobName) || getHubPersonaForJob(jobName);
7660
7707
  const contextText = context?.text?.trim() || '';
7661
7708
  const sourceInfo = [
7662
7709
  context?.sourceApp ? `sourceApp: ${context.sourceApp}` : '',
@@ -7687,8 +7734,7 @@ class AiHubServer {
7687
7734
  phaseVisits: [],
7688
7735
  totals: emptyTotals(),
7689
7736
  lastStatusChangeAt: startTimestamp,
7690
- // Issue #1357: consult custom persona owner before falling back to catalog.
7691
- personaKey: getCustomPersonaForJob(projectPath, jobName) ?? getHubPersonaForJob(jobName),
7737
+ personaKey,
7692
7738
  };
7693
7739
  // Register the run before spawning so onEvent/onExit callbacks can
7694
7740
  // safely call update() even if they fire synchronously (FakeHostRuntime).
@@ -7726,7 +7772,14 @@ class AiHubServer {
7726
7772
  }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7727
7773
  // Update the registry entry with the real child process handle.
7728
7774
  this.runRegistry.attachChildIfRunning(run.id, child);
7729
- return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
7775
+ return res.json({
7776
+ hubRunId: run.id,
7777
+ status: 'started',
7778
+ hostId,
7779
+ configuredAgentId: configuredAgent.id,
7780
+ personaKey,
7781
+ jobName,
7782
+ });
7730
7783
  }
7731
7784
  catch (error) {
7732
7785
  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.'
@@ -161,8 +161,8 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
161
161
  careena: {
162
162
  personaKey: 'careena',
163
163
  bundleId: 'persona-careena-core',
164
- catalogMetadata: buildCatalogMetadata('careena', ['career-spec', 'resume-creation', 'job-application-execution']),
165
- protectedJobs: ['career-spec', 'job-application-execution', 'resume-creation', 'public-profile-updates', 'career-trajectory-planning', 'pitch-and-interview-prep', 'career-networking', 'offer-negotiation', 'resilience-planning'],
164
+ catalogMetadata: buildCatalogMetadata('careena', ['career-spec', 'resume-creation', 'job-search-strategy-and-application-plan']),
165
+ protectedJobs: ['career-spec', 'job-search-strategy-and-application-plan', 'job-applications-and-networking', 'resume-creation', 'public-profile-updates', 'career-trajectory-planning', 'pitch-and-interview-prep', 'career-networking', 'offer-negotiation', 'resilience-planning'],
166
166
  protectedAliases: ['career', 'career-coach', 'job-search'],
167
167
  defaultHireMode: 'job',
168
168
  lockCopy: 'Hire CAREEna to unlock job-search execution, interview prep, and career coaching 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.304",
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.304",
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
  }