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.
- package/dist/src/ai-hub/configured-agents.js +17 -17
- package/dist/src/ai-hub/host-session-state.js +9 -0
- package/dist/src/ai-hub/hosts.js +111 -4
- package/dist/src/ai-hub/server.js +93 -40
- package/dist/src/config/persona-capability-bundles.js +3 -3
- package/dist/src/core/quality-evidence.js +10 -0
- package/package.json +2 -2
- package/public/ai-hub/script.js +13 -1
|
@@ -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(
|
|
102
|
+
function synthesizeDefaultConfiguredAgents(hosts) {
|
|
103
103
|
const timestamp = '1970-01-01T00:00:00.000Z';
|
|
104
|
-
return
|
|
105
|
-
.filter((
|
|
106
|
-
.map((
|
|
107
|
-
id: `${
|
|
108
|
-
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:
|
|
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(
|
|
136
|
+
listWithDefaults(hosts) {
|
|
137
137
|
const persisted = this.list();
|
|
138
|
-
const defaults = synthesizeDefaultConfiguredAgents(
|
|
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,
|
|
169
|
+
function checkConfiguredAgentAvailability(agent, hosts, env = process.env) {
|
|
170
170
|
const reasons = [];
|
|
171
171
|
const warnings = [];
|
|
172
|
-
const
|
|
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 (!
|
|
176
|
-
reasons.push(`${
|
|
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,
|
|
206
|
-
const check = checkConfiguredAgentAvailability(agent,
|
|
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,
|
|
229
|
-
const check = checkConfiguredAgentAvailability(agent,
|
|
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;
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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),
|
|
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
|
|
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
|
-
//
|
|
2641
|
-
//
|
|
2642
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
7106
|
-
const resolvedHostId =
|
|
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
|
|
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 &&
|
|
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
|
|
7298
|
-
const resolvedHostId =
|
|
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
|
|
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 &&
|
|
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: {
|
|
7644
|
-
// Response: {
|
|
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 {
|
|
7649
|
-
|
|
7650
|
-
|
|
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
|
|
7657
|
-
const
|
|
7658
|
-
const requestedHostId =
|
|
7659
|
-
|
|
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
|
-
|
|
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({
|
|
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-
|
|
165
|
-
protectedJobs: ['career-spec', 'job-application-
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
|
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
|
}
|