fraim-hub 2.0.264 → 2.0.266
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/conversation-store-lock.js +4 -2
- package/dist/src/ai-hub/host-session-state.js +2 -0
- package/dist/src/ai-hub/hosts.js +11 -0
- package/dist/src/ai-hub/server.js +231 -44
- package/dist/src/config/persona-capability-bundles.js +5 -4
- package/dist/src/local-mcp-server/learning-context-builder.js +44 -11
- package/package.json +2 -2
- package/public/ai-hub/index.html +0 -13
- package/public/ai-hub/script.js +11 -43
- package/public/ai-hub/styles.css +2 -1
|
@@ -51,9 +51,11 @@ function withBucketLock(lockPath, fn, opts = {}) {
|
|
|
51
51
|
fd = fs_1.default.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL: fails if the lock already exists
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
|
-
|
|
54
|
+
const code = error.code;
|
|
55
|
+
const retryableContention = code === 'EEXIST' || code === 'EPERM' || code === 'EACCES';
|
|
56
|
+
if (!retryableContention)
|
|
55
57
|
throw error;
|
|
56
|
-
if (isLockStale(lockPath, staleMs)) {
|
|
58
|
+
if (fs_1.default.existsSync(lockPath) && isLockStale(lockPath, staleMs)) {
|
|
57
59
|
try {
|
|
58
60
|
fs_1.default.unlinkSync(lockPath);
|
|
59
61
|
}
|
|
@@ -89,6 +89,7 @@ class HostSessionState {
|
|
|
89
89
|
if (conversation.sessionId === normalizedSessionId) {
|
|
90
90
|
const replacement = this.resolve({ ...conversation, sessionId: null }, owner);
|
|
91
91
|
conversation.sessionId = replacement?.sessionId || null;
|
|
92
|
+
conversation.resumeCommand = null;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
markInvalidRun(run, owner, sessionId, reason, at = new Date().toISOString()) {
|
|
@@ -110,6 +111,7 @@ class HostSessionState {
|
|
|
110
111
|
};
|
|
111
112
|
if (run.sessionId === normalizedSessionId) {
|
|
112
113
|
run.sessionId = undefined;
|
|
114
|
+
run.resumeCommand = null;
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
hasInvalidSession(conversation, owner, sessionId) {
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.parseUsageSignal = parseUsageSignal;
|
|
|
10
10
|
exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
|
|
11
11
|
exports.__setAgentAvailabilityPathForTests = __setAgentAvailabilityPathForTests;
|
|
12
12
|
exports.invalidateEmployeeDetectionCache = invalidateEmployeeDetectionCache;
|
|
13
|
+
exports.__clearEmployeeDetectionMemoryCacheForTests = __clearEmployeeDetectionMemoryCacheForTests;
|
|
13
14
|
exports.__setEmployeeDetectionTtlForTests = __setEmployeeDetectionTtlForTests;
|
|
14
15
|
exports.__getEmployeeProbeRoundsForTests = __getEmployeeProbeRoundsForTests;
|
|
15
16
|
exports.__resetEmployeeProbeRoundsForTests = __resetEmployeeProbeRoundsForTests;
|
|
@@ -940,6 +941,13 @@ function invalidateEmployeeDetectionCache() {
|
|
|
940
941
|
}
|
|
941
942
|
catch { /* best effort */ }
|
|
942
943
|
}
|
|
944
|
+
/** Test seam only. Simulates a process restart while preserving persisted last-known data. */
|
|
945
|
+
function __clearEmployeeDetectionMemoryCacheForTests() {
|
|
946
|
+
cachedEmployees = null;
|
|
947
|
+
cachedEmployeesAtMs = 0;
|
|
948
|
+
cachedEmployeesContext = null;
|
|
949
|
+
inFlightDetection = null;
|
|
950
|
+
}
|
|
943
951
|
/** Test seam only. Mirrors __resetLatestVersionCache in hub-latest-version.ts. */
|
|
944
952
|
function __setEmployeeDetectionTtlForTests(ttlMs) {
|
|
945
953
|
employeeDetectionTtlMs = ttlMs ?? EMPLOYEE_DETECTION_TTL_MS;
|
|
@@ -1493,6 +1501,9 @@ function parseHostLine(hostId, line) {
|
|
|
1493
1501
|
if (hostId === 'codex') {
|
|
1494
1502
|
try {
|
|
1495
1503
|
const parsed = JSON.parse(trimmed);
|
|
1504
|
+
if (parsed.type === 'system' && parsed.subtype === 'status' && parsed.status === 'compacting') {
|
|
1505
|
+
return withSignal({ raw: trimmed, hostLifecycle: { status: 'compacting', source: hostId } });
|
|
1506
|
+
}
|
|
1496
1507
|
if (parsed.type === 'thread.started' && parsed.thread_id) {
|
|
1497
1508
|
return withSignal({ sessionId: parsed.thread_id, raw: trimmed });
|
|
1498
1509
|
}
|
|
@@ -74,6 +74,7 @@ const hub_latest_version_1 = require("./hub-latest-version");
|
|
|
74
74
|
const ui_runtime_1 = require("./ui-runtime");
|
|
75
75
|
const ui_cache_1 = require("./ui-cache");
|
|
76
76
|
const semver = __importStar(require("semver"));
|
|
77
|
+
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
77
78
|
const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
|
|
78
79
|
let personaHiringModule;
|
|
79
80
|
let managerHiringModule;
|
|
@@ -118,6 +119,14 @@ function buildReviewApprovalSystemEventText(instructions) {
|
|
|
118
119
|
return 'review_approved send_delivery merge_pr_work_completion';
|
|
119
120
|
if (/^Approved and send, then clean up branch\.$/i.test(text))
|
|
120
121
|
return 'review_approved send_delivery cleanup_branch';
|
|
122
|
+
// Generic fallback: any "Approved and <something>." or "Approved, <something>." produced
|
|
123
|
+
// by a job-owned domain action not covered by a specific pattern above. These arrive when
|
|
124
|
+
// approvalCommandFromLabel builds a label-derived command for a non-send-campaign domain
|
|
125
|
+
// action (e.g. "Approved and dispatch.", "Approved and send outreach.", "Approved and stage drafts.").
|
|
126
|
+
// Return review_approved so prepareContinueMessage sends the raw command to the agent instead
|
|
127
|
+
// of wrapping it in "Continue the active FRAIM job..." prose.
|
|
128
|
+
if (/^Approved(?:\sand\s|\s*,\s*)\S/i.test(text))
|
|
129
|
+
return 'review_approved send_delivery';
|
|
121
130
|
return null;
|
|
122
131
|
}
|
|
123
132
|
function extractMissingHostSessionId(text) {
|
|
@@ -317,15 +326,17 @@ class AiHubRunRegistry {
|
|
|
317
326
|
dispose(runId) {
|
|
318
327
|
this.children.delete(runId);
|
|
319
328
|
}
|
|
320
|
-
// #521: terminate the agent process
|
|
321
|
-
// true if a live child was signalled. The
|
|
322
|
-
// parks the run in its waiting state.
|
|
329
|
+
// #521/#1082: terminate the agent process and its entire process tree for a run
|
|
330
|
+
// (manager clicked Stop). Returns true if a live child was signalled. The
|
|
331
|
+
// child's onExit handler then fires and parks the run in its waiting state.
|
|
332
|
+
// tree-kill is used so that supervised long-running children (which are
|
|
333
|
+
// grandchildren of the cmd.exe wrapper) are also killed, not just the wrapper.
|
|
323
334
|
stop(runId) {
|
|
324
335
|
const child = this.children.get(runId);
|
|
325
|
-
if (!child ||
|
|
336
|
+
if (!child || child.pid == null)
|
|
326
337
|
return false;
|
|
327
338
|
try {
|
|
328
|
-
child.
|
|
339
|
+
(0, tree_kill_1.default)(child.pid, 'SIGTERM');
|
|
329
340
|
return true;
|
|
330
341
|
}
|
|
331
342
|
catch {
|
|
@@ -1139,6 +1150,12 @@ function applySeekMentoringSignal(run, signal) {
|
|
|
1139
1150
|
run.artifacts = normalizedReviewHandoff?.reviewTarget?.type === 'artifact_set'
|
|
1140
1151
|
? normalizedReviewHandoff.artifacts
|
|
1141
1152
|
: [];
|
|
1153
|
+
if (run.reviewHandoff?.reviewRequired === true) {
|
|
1154
|
+
run.pauseReason = 'awaiting_review';
|
|
1155
|
+
}
|
|
1156
|
+
else if (run.reviewHandoff?.reviewRequired === false && run.pauseReason === 'awaiting_review') {
|
|
1157
|
+
run.pauseReason = 'working';
|
|
1158
|
+
}
|
|
1142
1159
|
}
|
|
1143
1160
|
if (signal.delegationLedger &&
|
|
1144
1161
|
run.jobId === 'fully-delegate' &&
|
|
@@ -1579,6 +1596,21 @@ function buildHubRecoveryContinueMessage(run, exitCode, attempt) {
|
|
|
1579
1596
|
function createHubRecoveryEvent(run, exitCode, attempt) {
|
|
1580
1597
|
return (0, hosts_1.createHubEvent)('system', `Hub auto-recovery attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS} for run ${run.id} session ${run.sessionId || 'unknown'} after exit ${exitCode ?? 'unknown'}.`);
|
|
1581
1598
|
}
|
|
1599
|
+
function buildHubCompactionRecoveryContinueMessage(run, exitCode, attempt) {
|
|
1600
|
+
return [
|
|
1601
|
+
'[FRAIM Hub system recovery]',
|
|
1602
|
+
'The host compacted context or restarted its internal runtime while this run was still active.',
|
|
1603
|
+
'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
|
|
1604
|
+
`Run id: ${run.id}`,
|
|
1605
|
+
`Session id: ${run.sessionId || 'unknown'}`,
|
|
1606
|
+
`Compaction recovery attempt: ${attempt}/${MAX_RECOVERY_ATTEMPTS}`,
|
|
1607
|
+
`Prior exit code: ${exitCode ?? 'unknown'}`,
|
|
1608
|
+
'Resume only if the tracked FRAIM phase is non-terminal and not waiting for human review or approval.',
|
|
1609
|
+
].join('\n');
|
|
1610
|
+
}
|
|
1611
|
+
function createHubCompactionRecoveryEvent(run, exitCode, attempt) {
|
|
1612
|
+
return (0, hosts_1.createHubEvent)('system', `Hub compaction recovery attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS} for run ${run.id} session ${run.sessionId || 'unknown'} after exit ${exitCode ?? 'unknown'}.`);
|
|
1613
|
+
}
|
|
1582
1614
|
function isHumanActionGate(run) {
|
|
1583
1615
|
if (run.stoppedByUser)
|
|
1584
1616
|
return true;
|
|
@@ -1612,6 +1644,7 @@ function classifyExit(run, exitCode) {
|
|
|
1612
1644
|
const lastEntry = phaseHistory.length > 0 ? phaseHistory[phaseHistory.length - 1] : null;
|
|
1613
1645
|
const lastStatus = lastEntry?.latestStatus;
|
|
1614
1646
|
const currentPhase = run.currentPhase;
|
|
1647
|
+
const compactingActive = run.hostLifecycle?.compacting?.active === true;
|
|
1615
1648
|
if (currentPhase && currentPhase !== '__discriminant__' && currentPhase !== 'starting') {
|
|
1616
1649
|
if (lastStatus === 'incomplete' || lastStatus === 'failure') {
|
|
1617
1650
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
@@ -1622,14 +1655,66 @@ function classifyExit(run, exitCode) {
|
|
|
1622
1655
|
if (lastDeclared && lastDeclared.id === currentPhase) {
|
|
1623
1656
|
return { action: 'done', pauseReason: 'done' };
|
|
1624
1657
|
}
|
|
1658
|
+
if (compactingActive) {
|
|
1659
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
|
|
1660
|
+
}
|
|
1625
1661
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
1626
1662
|
}
|
|
1663
|
+
if (compactingActive) {
|
|
1664
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
|
|
1665
|
+
}
|
|
1627
1666
|
// Phase mid-flight (started but not completed/incomplete): conservative park.
|
|
1628
1667
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
1629
1668
|
}
|
|
1669
|
+
if (compactingActive) {
|
|
1670
|
+
return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
|
|
1671
|
+
}
|
|
1630
1672
|
// Signal-less non-FRAIM run: conservative park.
|
|
1631
1673
|
return { action: 'park', pauseReason: 'awaiting_user' };
|
|
1632
1674
|
}
|
|
1675
|
+
function applyHostLifecycleSignal(run, signal) {
|
|
1676
|
+
if (!signal)
|
|
1677
|
+
return;
|
|
1678
|
+
const now = new Date().toISOString();
|
|
1679
|
+
if (signal.status === 'compacting') {
|
|
1680
|
+
const existing = run.hostLifecycle?.compacting;
|
|
1681
|
+
run.hostLifecycle = {
|
|
1682
|
+
...(run.hostLifecycle || {}),
|
|
1683
|
+
compacting: {
|
|
1684
|
+
active: true,
|
|
1685
|
+
startedAt: existing?.startedAt || now,
|
|
1686
|
+
lastEventAt: now,
|
|
1687
|
+
source: signal.source,
|
|
1688
|
+
},
|
|
1689
|
+
};
|
|
1690
|
+
if (!isHumanActionGate(run)) {
|
|
1691
|
+
run.status = 'running';
|
|
1692
|
+
run.pauseReason = 'working';
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
else if (signal.status === 'resumed' && run.hostLifecycle?.compacting) {
|
|
1696
|
+
run.hostLifecycle = {
|
|
1697
|
+
...run.hostLifecycle,
|
|
1698
|
+
compacting: {
|
|
1699
|
+
...run.hostLifecycle.compacting,
|
|
1700
|
+
active: false,
|
|
1701
|
+
lastEventAt: now,
|
|
1702
|
+
},
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function clearCompactionLifecycle(run) {
|
|
1707
|
+
if (!run.hostLifecycle?.compacting?.active)
|
|
1708
|
+
return;
|
|
1709
|
+
run.hostLifecycle = {
|
|
1710
|
+
...run.hostLifecycle,
|
|
1711
|
+
compacting: {
|
|
1712
|
+
...run.hostLifecycle.compacting,
|
|
1713
|
+
active: false,
|
|
1714
|
+
lastEventAt: new Date().toISOString(),
|
|
1715
|
+
},
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1633
1718
|
class AiHubServer {
|
|
1634
1719
|
get hubBase() {
|
|
1635
1720
|
return process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
|
|
@@ -2273,6 +2358,44 @@ class AiHubServer {
|
|
|
2273
2358
|
res.status(403).json({ error: 'Configured agent management is only allowed from the local Hub origin.' });
|
|
2274
2359
|
return false;
|
|
2275
2360
|
}
|
|
2361
|
+
applyRunHostSessionSignal(run, sessionId, hostIdForCommand = run.hostId) {
|
|
2362
|
+
const owner = {
|
|
2363
|
+
configuredAgentId: run.configuredAgentId || null,
|
|
2364
|
+
baseHostId: (run.baseHostId || run.hostId),
|
|
2365
|
+
};
|
|
2366
|
+
host_session_state_1.hostSessionState.applySession(run, owner, sessionId, { sourceRunId: run.id });
|
|
2367
|
+
run.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostIdForCommand, sessionId);
|
|
2368
|
+
}
|
|
2369
|
+
repairConversationHostSessionFromEvents(conversation, owner) {
|
|
2370
|
+
if (!conversation || !Array.isArray(conversation.events))
|
|
2371
|
+
return false;
|
|
2372
|
+
let repaired = false;
|
|
2373
|
+
for (const event of conversation.events) {
|
|
2374
|
+
const text = typeof event?.text === 'string' ? event.text.trim() : '';
|
|
2375
|
+
if (!text)
|
|
2376
|
+
continue;
|
|
2377
|
+
try {
|
|
2378
|
+
const parsed = (0, hosts_1.parseHostLine)(owner.baseHostId, text);
|
|
2379
|
+
if (!parsed.sessionId)
|
|
2380
|
+
continue;
|
|
2381
|
+
const projectedRun = {
|
|
2382
|
+
id: conversation.runId || conversation.id,
|
|
2383
|
+
hostSessions: conversation.hostSessions,
|
|
2384
|
+
sessionId: conversation.sessionId || undefined,
|
|
2385
|
+
};
|
|
2386
|
+
host_session_state_1.hostSessionState.applySession(projectedRun, owner, parsed.sessionId, { sourceRunId: conversation.runId || conversation.id });
|
|
2387
|
+
conversation.hostSessions = projectedRun.hostSessions;
|
|
2388
|
+
conversation.sessionId = projectedRun.sessionId || null;
|
|
2389
|
+
conversation.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(owner.baseHostId, parsed.sessionId);
|
|
2390
|
+
repaired = true;
|
|
2391
|
+
}
|
|
2392
|
+
catch {
|
|
2393
|
+
// Persisted event logs include plain text, diagnostics, and historical host output.
|
|
2394
|
+
// Non-parseable lines are not session signals.
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return repaired;
|
|
2398
|
+
}
|
|
2276
2399
|
conversationRecordFromRun(run) {
|
|
2277
2400
|
const lastUpdatedAt = run.updatedAt || new Date().toISOString();
|
|
2278
2401
|
const stages = deriveStages(run, run.projectPath);
|
|
@@ -2289,6 +2412,7 @@ class AiHubServer {
|
|
|
2289
2412
|
personaKey: run.personaKey ?? null,
|
|
2290
2413
|
runId: run.id,
|
|
2291
2414
|
sessionId: run.sessionId || null,
|
|
2415
|
+
resumeCommand: run.resumeCommand || (run.sessionId ? (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, run.sessionId) : null),
|
|
2292
2416
|
status: run.status,
|
|
2293
2417
|
// Issue #904: carry auxiliary exit classification to the persisted record.
|
|
2294
2418
|
...(run.pauseReason !== undefined && { pauseReason: run.pauseReason }),
|
|
@@ -2329,6 +2453,7 @@ class AiHubServer {
|
|
|
2329
2453
|
hostSessions: { ...(run.hostSessions || {}) },
|
|
2330
2454
|
restartRecovery: run.restartRecovery || null,
|
|
2331
2455
|
restartRecoverySkippedReason: run.restartRecoverySkippedReason || null,
|
|
2456
|
+
hostLifecycle: run.hostLifecycle || null,
|
|
2332
2457
|
// Issue #578: preserve trigger source so the UI can render the chip.
|
|
2333
2458
|
sourceTrigger: run.sourceTrigger,
|
|
2334
2459
|
// Issue #708: carry the invocation scope so the record lands in (and is keyed to)
|
|
@@ -2345,6 +2470,13 @@ class AiHubServer {
|
|
|
2345
2470
|
},
|
|
2346
2471
|
};
|
|
2347
2472
|
host_session_state_1.hostSessionState.mergeFromRun(record, run);
|
|
2473
|
+
const activeHostSession = host_session_state_1.hostSessionState.resolve(record, {
|
|
2474
|
+
configuredAgentId: run.configuredAgentId || record.configuredAgentId || null,
|
|
2475
|
+
baseHostId: (run.baseHostId || run.hostId || record.baseHostId || record.agentName),
|
|
2476
|
+
});
|
|
2477
|
+
record.resumeCommand = activeHostSession
|
|
2478
|
+
? (0, hosts_1.buildInteractiveResumeCommand)(record.baseHostId || record.agentName, activeHostSession.sessionId)
|
|
2479
|
+
: null;
|
|
2348
2480
|
return record;
|
|
2349
2481
|
}
|
|
2350
2482
|
persistRunConversationNow(run, activeId) {
|
|
@@ -2394,9 +2526,10 @@ class AiHubServer {
|
|
|
2394
2526
|
onEvent: (event, channel) => {
|
|
2395
2527
|
this.runRegistry.update(run.id, (current) => {
|
|
2396
2528
|
if (event.sessionId) {
|
|
2397
|
-
|
|
2398
|
-
current
|
|
2529
|
+
this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
|
|
2530
|
+
clearCompactionLifecycle(current);
|
|
2399
2531
|
}
|
|
2532
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
2400
2533
|
appendHostMessage(current, run.hostId, event, channel);
|
|
2401
2534
|
if (event.raw) {
|
|
2402
2535
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -2572,6 +2705,10 @@ class AiHubServer {
|
|
|
2572
2705
|
const projectPath = conversation.projectPath
|
|
2573
2706
|
? path_1.default.resolve(conversation.projectPath)
|
|
2574
2707
|
: (MACHINE_LEVEL_JOB_IDS.has(conversation.jobId) ? os_1.default.homedir() : this.defaultProjectPath());
|
|
2708
|
+
const hostSessionOwner = { configuredAgentId: configuredAgent.id, baseHostId: hostId };
|
|
2709
|
+
this.repairConversationHostSessionFromEvents(conversation, hostSessionOwner);
|
|
2710
|
+
const recoveredHostSession = host_session_state_1.hostSessionState.resolve(conversation, hostSessionOwner);
|
|
2711
|
+
const recoveredSessionId = recoveredHostSession?.sessionId || undefined;
|
|
2575
2712
|
return {
|
|
2576
2713
|
id: (0, crypto_1.randomUUID)(),
|
|
2577
2714
|
conversationId: conversation.id,
|
|
@@ -2585,8 +2722,9 @@ class AiHubServer {
|
|
|
2585
2722
|
projectPath,
|
|
2586
2723
|
scope: conversation.scope || 'project',
|
|
2587
2724
|
status: 'running',
|
|
2588
|
-
sessionId:
|
|
2589
|
-
|
|
2725
|
+
sessionId: recoveredSessionId,
|
|
2726
|
+
hostSessions: conversation.hostSessions ? { ...conversation.hostSessions } : undefined,
|
|
2727
|
+
resumeCommand: recoveredSessionId ? (0, hosts_1.buildInteractiveResumeCommand)(hostId, recoveredSessionId) : null,
|
|
2590
2728
|
createdAt: typeof conversation.createdAt === 'string' ? conversation.createdAt : now,
|
|
2591
2729
|
updatedAt: now,
|
|
2592
2730
|
messages: persistedMessagesForRun(conversation),
|
|
@@ -2615,6 +2753,7 @@ class AiHubServer {
|
|
|
2615
2753
|
reason,
|
|
2616
2754
|
},
|
|
2617
2755
|
restartRecoverySkippedReason: null,
|
|
2756
|
+
hostLifecycle: conversation.hostLifecycle || undefined,
|
|
2618
2757
|
nextJobRecommendations: conversation.nextJobRecommendations || null,
|
|
2619
2758
|
issueNumber: conversation.issueNumber ?? null,
|
|
2620
2759
|
agentSwitches: conversation.agentSwitches || [],
|
|
@@ -2646,9 +2785,10 @@ class AiHubServer {
|
|
|
2646
2785
|
onEvent: (event, channel) => {
|
|
2647
2786
|
this.runRegistry.update(currentRun.id, (current) => {
|
|
2648
2787
|
if (event.sessionId) {
|
|
2649
|
-
current.sessionId
|
|
2650
|
-
|
|
2788
|
+
this.applyRunHostSessionSignal(current, event.sessionId, current.hostId);
|
|
2789
|
+
clearCompactionLifecycle(current);
|
|
2651
2790
|
}
|
|
2791
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
2652
2792
|
appendHostMessage(current, current.hostId, event, channel);
|
|
2653
2793
|
if (event.raw) {
|
|
2654
2794
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -2970,9 +3110,10 @@ class AiHubServer {
|
|
|
2970
3110
|
onEvent: (event, channel) => {
|
|
2971
3111
|
this.runRegistry.update(childRun.id, (current) => {
|
|
2972
3112
|
if (event.sessionId) {
|
|
2973
|
-
current.sessionId
|
|
2974
|
-
current
|
|
3113
|
+
this.applyRunHostSessionSignal(current, event.sessionId, managerRun.hostId);
|
|
3114
|
+
clearCompactionLifecycle(current);
|
|
2975
3115
|
}
|
|
3116
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
2976
3117
|
appendHostMessage(current, managerRun.hostId, event, channel);
|
|
2977
3118
|
if (event.raw) {
|
|
2978
3119
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -3122,9 +3263,10 @@ class AiHubServer {
|
|
|
3122
3263
|
onEvent: (event, channel) => {
|
|
3123
3264
|
this.runRegistry.update(managerRun.id, (current) => {
|
|
3124
3265
|
if (event.sessionId) {
|
|
3125
|
-
current.sessionId
|
|
3126
|
-
current
|
|
3266
|
+
this.applyRunHostSessionSignal(current, event.sessionId, managerRun.hostId);
|
|
3267
|
+
clearCompactionLifecycle(current);
|
|
3127
3268
|
}
|
|
3269
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
3128
3270
|
appendHostMessage(current, managerRun.hostId, event, channel);
|
|
3129
3271
|
if (event.raw) {
|
|
3130
3272
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -3793,7 +3935,19 @@ class AiHubServer {
|
|
|
3793
3935
|
.filter((incoming) => belongsInBucket(incoming))
|
|
3794
3936
|
.map((incoming) => {
|
|
3795
3937
|
const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
|
|
3796
|
-
|
|
3938
|
+
const merged = existing ? { ...existing, ...incoming } : incoming;
|
|
3939
|
+
const activeRun = merged?.runId ? this.runRegistry.get(merged.runId) : undefined;
|
|
3940
|
+
const incomingErrorState = merged.status === 'failed'
|
|
3941
|
+
|| (merged.status === 'running' && merged.pauseReason === 'error');
|
|
3942
|
+
const activeRunAwaitingReview = activeRun?.pauseReason === 'awaiting_review';
|
|
3943
|
+
if (activeRun?.status === 'running' && (incomingErrorState || activeRunAwaitingReview)) {
|
|
3944
|
+
return {
|
|
3945
|
+
...merged,
|
|
3946
|
+
status: 'running',
|
|
3947
|
+
pauseReason: activeRun.pauseReason === 'awaiting_review' ? 'awaiting_review' : 'working',
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
return merged;
|
|
3797
3951
|
});
|
|
3798
3952
|
const saved = this.conversationStore.replaceProject(key, {
|
|
3799
3953
|
activeId: body.activeId ?? null,
|
|
@@ -3960,9 +4114,10 @@ class AiHubServer {
|
|
|
3960
4114
|
onEvent: (event, channel) => {
|
|
3961
4115
|
this.runRegistry.update(run.id, (current) => {
|
|
3962
4116
|
if (event.sessionId) {
|
|
3963
|
-
|
|
3964
|
-
current
|
|
4117
|
+
this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
|
|
4118
|
+
clearCompactionLifecycle(current);
|
|
3965
4119
|
}
|
|
4120
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
3966
4121
|
appendHostMessage(current, run.hostId, event, channel);
|
|
3967
4122
|
if (event.raw) {
|
|
3968
4123
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -4650,7 +4805,7 @@ class AiHubServer {
|
|
|
4650
4805
|
continuityDecision: continuity.continuityDecision,
|
|
4651
4806
|
};
|
|
4652
4807
|
this.runRegistry.create(run, {});
|
|
4653
|
-
this.
|
|
4808
|
+
this.persistRunConversation(run, run.conversationId || run.id);
|
|
4654
4809
|
// Issue #442: create the Direct (B) run before spawning either process
|
|
4655
4810
|
// so we can cross-link both runs via compareRunId before any events arrive.
|
|
4656
4811
|
// directMsg is the plain user instructions — no FRAIM invocation prefix.
|
|
@@ -4692,9 +4847,10 @@ class AiHubServer {
|
|
|
4692
4847
|
onEvent: (event, channel) => {
|
|
4693
4848
|
this.runRegistry.update(run.id, (current) => {
|
|
4694
4849
|
if (event.sessionId) {
|
|
4695
|
-
current
|
|
4696
|
-
current
|
|
4850
|
+
this.applyRunHostSessionSignal(current, event.sessionId, hostId);
|
|
4851
|
+
clearCompactionLifecycle(current);
|
|
4697
4852
|
}
|
|
4853
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
4698
4854
|
appendHostMessage(current, hostId, event, channel);
|
|
4699
4855
|
if (event.raw) {
|
|
4700
4856
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -4733,9 +4889,10 @@ class AiHubServer {
|
|
|
4733
4889
|
onEvent: (event, channel) => {
|
|
4734
4890
|
this.runRegistry.update(directId, (current) => {
|
|
4735
4891
|
if (event.sessionId) {
|
|
4736
|
-
current
|
|
4737
|
-
current
|
|
4892
|
+
this.applyRunHostSessionSignal(current, event.sessionId, hostId);
|
|
4893
|
+
clearCompactionLifecycle(current);
|
|
4738
4894
|
}
|
|
4895
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
4739
4896
|
appendHostMessage(current, hostId, event, channel);
|
|
4740
4897
|
if (event.raw)
|
|
4741
4898
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -4884,6 +5041,7 @@ class AiHubServer {
|
|
|
4884
5041
|
: this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message);
|
|
4885
5042
|
this.runRegistry.update(run.id, (current) => {
|
|
4886
5043
|
current.status = 'running';
|
|
5044
|
+
current.pauseReason = 'working';
|
|
4887
5045
|
current.sessionId = undefined;
|
|
4888
5046
|
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
|
|
4889
5047
|
if (reviewApprovalSystemEventText)
|
|
@@ -4899,9 +5057,10 @@ class AiHubServer {
|
|
|
4899
5057
|
onEvent: (event, channel) => {
|
|
4900
5058
|
this.runRegistry.update(run.id, (current) => {
|
|
4901
5059
|
if (event.sessionId) {
|
|
4902
|
-
current.sessionId
|
|
4903
|
-
current
|
|
5060
|
+
this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
|
|
5061
|
+
clearCompactionLifecycle(current);
|
|
4904
5062
|
}
|
|
5063
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
4905
5064
|
appendHostMessage(current, run.hostId, event, channel);
|
|
4906
5065
|
if (event.raw) {
|
|
4907
5066
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -4934,6 +5093,7 @@ class AiHubServer {
|
|
|
4934
5093
|
}
|
|
4935
5094
|
this.runRegistry.update(run.id, (current) => {
|
|
4936
5095
|
current.status = 'running';
|
|
5096
|
+
current.pauseReason = 'working';
|
|
4937
5097
|
// #521: bubble shows the manager's words; the agent gets the full message.
|
|
4938
5098
|
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
|
|
4939
5099
|
if (reviewApprovalSystemEventText)
|
|
@@ -4948,9 +5108,10 @@ class AiHubServer {
|
|
|
4948
5108
|
onEvent: (event, channel) => {
|
|
4949
5109
|
this.runRegistry.update(run.id, (current) => {
|
|
4950
5110
|
if (event.sessionId) {
|
|
4951
|
-
current.sessionId
|
|
4952
|
-
current
|
|
5111
|
+
this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
|
|
5112
|
+
clearCompactionLifecycle(current);
|
|
4953
5113
|
}
|
|
5114
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
4954
5115
|
appendHostMessage(current, run.hostId, event, channel);
|
|
4955
5116
|
if (event.raw) {
|
|
4956
5117
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -5026,11 +5187,16 @@ class AiHubServer {
|
|
|
5026
5187
|
? this.conversationStore.loadProject(conversationBucketKey).conversations.find((entry) => entry.id === requestedConversationId)
|
|
5027
5188
|
: inferredConversation ?? undefined;
|
|
5028
5189
|
const persistedRun = readPersistedRunProjection(persistedConversation);
|
|
5029
|
-
const
|
|
5190
|
+
const hostSessionOwner = {
|
|
5030
5191
|
configuredAgentId: configuredAgent.id,
|
|
5031
5192
|
baseHostId: hostId,
|
|
5032
|
-
}
|
|
5033
|
-
|
|
5193
|
+
};
|
|
5194
|
+
const repairedHostSessionFromEvents = this.repairConversationHostSessionFromEvents(persistedConversation, hostSessionOwner);
|
|
5195
|
+
if (repairedHostSessionFromEvents && persistedConversation) {
|
|
5196
|
+
this.conversationStore.upsertConversation(conversationBucketKey, persistedConversation, conversationId || undefined);
|
|
5197
|
+
}
|
|
5198
|
+
const resolvedHostSession = host_session_state_1.hostSessionState.resolve(persistedConversation, hostSessionOwner);
|
|
5199
|
+
if (!resolvedHostSession && host_session_state_1.hostSessionState.hasInvalidSession(persistedConversation, hostSessionOwner, requestedSessionId)) {
|
|
5034
5200
|
return res.status(409).json({
|
|
5035
5201
|
error: 'The saved host session was already marked invalid. Start a fresh handoff-backed run instead of retrying the same session.',
|
|
5036
5202
|
hostSessionStatus: 'invalid',
|
|
@@ -5044,6 +5210,7 @@ class AiHubServer {
|
|
|
5044
5210
|
conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
|
|
5045
5211
|
jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
|
|
5046
5212
|
jobId, hostId, configuredAgentId: configuredAgent.id, configuredAgentLabel: configuredAgent.label, baseHostId: configuredAgent.baseHostId, projectPath, status: 'running', sessionId,
|
|
5213
|
+
resumeCommand: (0, hosts_1.buildInteractiveResumeCommand)(hostId, sessionId),
|
|
5047
5214
|
hostSessions: persistedConversation?.hostSessions ? { ...persistedConversation.hostSessions } : undefined,
|
|
5048
5215
|
// Issue #892: keep the invocation scope so a resumed manager/company run stays
|
|
5049
5216
|
// in its project-independent conversation bucket.
|
|
@@ -5078,16 +5245,19 @@ class AiHubServer {
|
|
|
5078
5245
|
this.scheduleRunConversationPersistence(run, run.conversationId || run.id);
|
|
5079
5246
|
const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
|
|
5080
5247
|
onEvent: (event, channel) => {
|
|
5248
|
+
let invalidatedMissingHostSession = false;
|
|
5081
5249
|
this.runRegistry.update(run.id, (current) => {
|
|
5082
5250
|
if (event.sessionId) {
|
|
5083
|
-
|
|
5084
|
-
current
|
|
5251
|
+
this.applyRunHostSessionSignal(current, event.sessionId, hostId);
|
|
5252
|
+
clearCompactionLifecycle(current);
|
|
5085
5253
|
}
|
|
5254
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5086
5255
|
appendHostMessage(current, hostId, event, channel);
|
|
5087
5256
|
if (event.raw) {
|
|
5088
5257
|
const missingSessionId = extractMissingHostSessionId(event.raw);
|
|
5089
5258
|
if (missingSessionId) {
|
|
5090
5259
|
host_session_state_1.hostSessionState.markInvalidRun(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, missingSessionId, 'host-reported-missing-session');
|
|
5260
|
+
invalidatedMissingHostSession = true;
|
|
5091
5261
|
}
|
|
5092
5262
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5093
5263
|
applyReviewProjection(current, event.raw);
|
|
@@ -5102,8 +5272,14 @@ class AiHubServer {
|
|
|
5102
5272
|
applyUsageSignal(current, event.usage);
|
|
5103
5273
|
});
|
|
5104
5274
|
const updated = this.runRegistry.get(run.id);
|
|
5105
|
-
if (updated)
|
|
5106
|
-
|
|
5275
|
+
if (updated) {
|
|
5276
|
+
if (invalidatedMissingHostSession) {
|
|
5277
|
+
this.persistRunConversationNow(updated, updated.conversationId || updated.id);
|
|
5278
|
+
}
|
|
5279
|
+
else {
|
|
5280
|
+
this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5107
5283
|
},
|
|
5108
5284
|
onExit: (exitCode) => {
|
|
5109
5285
|
this.handleRunExit(run.id, exitCode);
|
|
@@ -5139,9 +5315,10 @@ class AiHubServer {
|
|
|
5139
5315
|
onEvent: (event, channel) => {
|
|
5140
5316
|
this.runRegistry.update(run.id, (current) => {
|
|
5141
5317
|
if (event.sessionId) {
|
|
5142
|
-
current.sessionId
|
|
5143
|
-
current
|
|
5318
|
+
this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
|
|
5319
|
+
clearCompactionLifecycle(current);
|
|
5144
5320
|
}
|
|
5321
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5145
5322
|
appendHostMessage(current, run.hostId, event, channel);
|
|
5146
5323
|
if (event.raw)
|
|
5147
5324
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -5733,9 +5910,10 @@ class AiHubServer {
|
|
|
5733
5910
|
onEvent: (event, channel) => {
|
|
5734
5911
|
this.runRegistry.update(run.id, (current) => {
|
|
5735
5912
|
if (event.sessionId) {
|
|
5736
|
-
current
|
|
5737
|
-
current
|
|
5913
|
+
this.applyRunHostSessionSignal(current, event.sessionId, hostId);
|
|
5914
|
+
clearCompactionLifecycle(current);
|
|
5738
5915
|
}
|
|
5916
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5739
5917
|
appendHostMessage(current, hostId, event, channel);
|
|
5740
5918
|
if (event.raw)
|
|
5741
5919
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -5883,9 +6061,10 @@ class AiHubServer {
|
|
|
5883
6061
|
onEvent: (event, channel) => {
|
|
5884
6062
|
this.runRegistry.update(run.id, (current) => {
|
|
5885
6063
|
if (event.sessionId) {
|
|
5886
|
-
current
|
|
5887
|
-
current
|
|
6064
|
+
this.applyRunHostSessionSignal(current, event.sessionId, hostId);
|
|
6065
|
+
clearCompactionLifecycle(current);
|
|
5888
6066
|
}
|
|
6067
|
+
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5889
6068
|
appendHostMessage(current, hostId, event, channel);
|
|
5890
6069
|
if (event.raw) {
|
|
5891
6070
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -5937,7 +6116,9 @@ class AiHubServer {
|
|
|
5937
6116
|
current.recoveryAttempts = (current.recoveryAttempts ?? 0) + 1;
|
|
5938
6117
|
current.lastRecoveryAt = new Date().toISOString();
|
|
5939
6118
|
current.pauseReason = 'working';
|
|
5940
|
-
current.events.push(
|
|
6119
|
+
current.events.push(classification.recoveryKind === 'compaction'
|
|
6120
|
+
? createHubCompactionRecoveryEvent(current, exitCode, current.recoveryAttempts)
|
|
6121
|
+
: createHubRecoveryEvent(current, exitCode, current.recoveryAttempts));
|
|
5941
6122
|
});
|
|
5942
6123
|
const refreshed = this.runRegistry.get(runId);
|
|
5943
6124
|
if (!refreshed?.sessionId) {
|
|
@@ -5958,12 +6139,17 @@ class AiHubServer {
|
|
|
5958
6139
|
const current = this.runRegistry.get(runId);
|
|
5959
6140
|
if (!current || current.status !== 'running')
|
|
5960
6141
|
return;
|
|
5961
|
-
const message =
|
|
6142
|
+
const message = classification.recoveryKind === 'compaction'
|
|
6143
|
+
? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
|
|
6144
|
+
: buildHubRecoveryContinueMessage(current, exitCode, attempt);
|
|
5962
6145
|
const child = this.hostRuntime.continueRun(current.hostId, current.projectPath, current.sessionId, message, {
|
|
5963
6146
|
onEvent: (event, channel) => {
|
|
5964
6147
|
this.runRegistry.update(runId, (r) => {
|
|
5965
|
-
if (event.sessionId)
|
|
5966
|
-
r.sessionId
|
|
6148
|
+
if (event.sessionId) {
|
|
6149
|
+
this.applyRunHostSessionSignal(r, event.sessionId, current.hostId);
|
|
6150
|
+
clearCompactionLifecycle(r);
|
|
6151
|
+
}
|
|
6152
|
+
applyHostLifecycleSignal(r, event.hostLifecycle);
|
|
5967
6153
|
appendHostMessage(r, current.hostId, event, channel);
|
|
5968
6154
|
if (event.raw) {
|
|
5969
6155
|
r.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
@@ -6002,6 +6188,7 @@ class AiHubServer {
|
|
|
6002
6188
|
: (exitCode === 0 ? 'completed' : 'failed');
|
|
6003
6189
|
}
|
|
6004
6190
|
current.pauseReason = classification.pauseReason;
|
|
6191
|
+
clearCompactionLifecycle(current);
|
|
6005
6192
|
if (!current.stoppedByUser) {
|
|
6006
6193
|
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
6007
6194
|
}
|
|
@@ -53,8 +53,8 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
53
53
|
pam: {
|
|
54
54
|
personaKey: 'pam',
|
|
55
55
|
bundleId: 'persona-pam-core',
|
|
56
|
-
catalogMetadata: buildCatalogMetadata('pam', ['feature-specification', '
|
|
57
|
-
protectedJobs: ['feature-specification', '
|
|
56
|
+
catalogMetadata: buildCatalogMetadata('pam', ['feature-specification', 'project-plan-creation', 'implementation-feature-review']),
|
|
57
|
+
protectedJobs: ['feature-specification', 'send-newsletter', 'send-thank-you-notes', 'experiment-tracking', 'project-plan-creation', 'implementation-feature-review', 'scrum-sprint-planning', 'mvp-validation-plan', 'sprint-planning', 'customer-prospect-discovery', 'interview-preparation', 'participant-recruitment', 'process-interview-notes', 'review-customer-development', 'triage-customer-needs'],
|
|
58
58
|
protectedAliases: ['product-management', 'product-spec'],
|
|
59
59
|
defaultHireMode: 'job',
|
|
60
60
|
lockCopy: 'Hire PaM to unlock product-management work for this request.'
|
|
@@ -63,7 +63,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
63
63
|
personaKey: 'swen',
|
|
64
64
|
bundleId: 'persona-swen-core',
|
|
65
65
|
catalogMetadata: buildCatalogMetadata('swen', ['feature-implementation', 'technical-design', 'code-refactoring']),
|
|
66
|
-
protectedJobs: ['feature-implementation', 'implementation-design-review', 'code-refactoring', 'pr-iteration', 'mobile-app-development', 'mcp-server-creation', 'cloud-application-deployment', 'cloud-cost-optimization', 'cloud-performance-diagnosis', 'route-llm-spend-to-cloud-credits', 'set-up-cloud-cost-alerts', 'gitlabs-to-github', 'system-migration', 'cross-cloud-migration', 'data-pipeline-design', 'data-quality-monitoring', 'data-platform-architecture', 'write-dev-docs', 'database-schema-design', 'create-architecture', 'project-scaffolding', 'codebase-analysis-and-ideation', 'github-org-setup', 'google-workspace-setup', 'mobile-app-rejection-response', 'mobile-app-submission', 'publish-mcp-app', 'application-replication-workflow'],
|
|
66
|
+
protectedJobs: ['feature-implementation', 'technical-design', 'implementation-design-review', 'code-refactoring', 'pr-iteration', 'mobile-app-development', 'mcp-server-creation', 'cloud-application-deployment', 'cloud-cost-optimization', 'cloud-performance-diagnosis', 'route-llm-spend-to-cloud-credits', 'set-up-cloud-cost-alerts', 'gitlabs-to-github', 'system-migration', 'cross-cloud-migration', 'data-pipeline-design', 'data-quality-monitoring', 'data-platform-architecture', 'write-dev-docs', 'database-schema-design', 'create-architecture', 'project-scaffolding', 'codebase-analysis-and-ideation', 'github-org-setup', 'google-workspace-setup', 'mobile-app-rejection-response', 'mobile-app-submission', 'publish-mcp-app', 'application-replication-workflow'],
|
|
67
67
|
protectedAliases: ['software-engineering', 'implementation'],
|
|
68
68
|
defaultHireMode: 'job',
|
|
69
69
|
lockCopy: 'Hire SWEn to unlock software-engineering delivery for this request.'
|
|
@@ -117,7 +117,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
117
117
|
personaKey: 'ashley',
|
|
118
118
|
bundleId: 'persona-ashley-core',
|
|
119
119
|
catalogMetadata: buildCatalogMetadata('ashley', ['chief-of-staff-briefing', 'executive-assistant', 'analyze-transcript']),
|
|
120
|
-
protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', '
|
|
120
|
+
protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'analyze-transcript', 'travel-planning', 'travel-disruption-rebooking'],
|
|
121
121
|
protectedAliases: ['executive-assistant', 'operations-assistant'],
|
|
122
122
|
defaultHireMode: 'job',
|
|
123
123
|
lockCopy: 'Hire AshLey to unlock executive-assistant work for this request.'
|
|
@@ -273,6 +273,7 @@ for (const bundle of Object.values(exports.PERSONA_CAPABILITY_BUNDLES)) {
|
|
|
273
273
|
// as "free") so the Hub attributes them to FRAIMworker, but they are never
|
|
274
274
|
// hire-gated because FRAIMworker is not a purchasable persona.
|
|
275
275
|
const GENERIC_WORKER_OWNED_JOBS = new Set([
|
|
276
|
+
'create-hub-configured-agent',
|
|
276
277
|
'contribute-to-fraim',
|
|
277
278
|
'file-fraim-issue',
|
|
278
279
|
'praise-fraim',
|
|
@@ -90,6 +90,13 @@ function getLearningRoots(workspaceRoot) {
|
|
|
90
90
|
repoLearningsBase: (0, project_fraim_paths_1.getWorkspaceLearningsDir)(workspaceRoot)
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
function sameResolvedPath(a, b) {
|
|
94
|
+
return (0, path_1.resolve)(a) === (0, path_1.resolve)(b);
|
|
95
|
+
}
|
|
96
|
+
function portablePersonalBaseIsLive(globalBase) {
|
|
97
|
+
return sameResolvedPath((0, path_1.join)((0, pack_home_1.resolvePackHome)('manager').contentRoot, 'learnings'), globalBase)
|
|
98
|
+
|| !sameResolvedPath(globalBase, (0, project_fraim_paths_1.getUserFraimLearningsDir)());
|
|
99
|
+
}
|
|
93
100
|
function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDisplayBase, globalBase, globalDisplayBase, fileName) {
|
|
94
101
|
const repoPath = (0, path_1.join)(repoBase, fileName);
|
|
95
102
|
if ((0, fs_1.existsSync)(repoPath)) {
|
|
@@ -100,14 +107,14 @@ function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDis
|
|
|
100
107
|
};
|
|
101
108
|
}
|
|
102
109
|
const globalPath = (0, path_1.join)(globalBase, fileName);
|
|
103
|
-
|
|
110
|
+
const managerCachePath = (0, path_1.join)(managerCacheBase, fileName);
|
|
111
|
+
if (portablePersonalBaseIsLive(globalBase) && (0, fs_1.existsSync)(globalPath)) {
|
|
104
112
|
return {
|
|
105
113
|
present: true,
|
|
106
114
|
path: globalPath,
|
|
107
115
|
displayPath: `${globalDisplayBase.replace(/\/$/, '')}/${fileName}`
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
|
-
const managerCachePath = (0, path_1.join)(managerCacheBase, fileName);
|
|
111
118
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
112
119
|
return {
|
|
113
120
|
present: true,
|
|
@@ -140,7 +147,9 @@ function resolvePersonalLearningFileTiers(repoBase, managerCacheBase, managerCac
|
|
|
140
147
|
});
|
|
141
148
|
}
|
|
142
149
|
const globalPath = (0, path_1.join)(globalBase, fileName);
|
|
143
|
-
if ((
|
|
150
|
+
if (portablePersonalBaseIsLive(globalBase) &&
|
|
151
|
+
(0, fs_1.existsSync)(globalPath) &&
|
|
152
|
+
!tiers.some((tier) => sameResolvedPath(tier.path, globalPath))) {
|
|
144
153
|
tiers.push({
|
|
145
154
|
level: 'manager',
|
|
146
155
|
path: globalPath,
|
|
@@ -405,10 +414,25 @@ function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
|
|
|
405
414
|
}
|
|
406
415
|
return sources.sort((a, b) => b.sortKey.localeCompare(a.sortKey) || a.displayPath.localeCompare(b.displayPath));
|
|
407
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Issue #1078 fix: derive the org display path from contentRoot rather than
|
|
419
|
+
* hardcoding the pre-migration flat layout. After migrateOrgFlatContent runs,
|
|
420
|
+
* contentRoot is ~/.fraim/org/personalized-employee/ — not ~/.fraim/org/.
|
|
421
|
+
* The relative portion after the ~/.fraim/ prefix is computed dynamically so
|
|
422
|
+
* it cannot drift from the read path again.
|
|
423
|
+
*/
|
|
424
|
+
function orgCacheDisplayPath(fileName) {
|
|
425
|
+
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
426
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
|
|
427
|
+
const rel = cachePath.startsWith(fraimDir)
|
|
428
|
+
? cachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, '')
|
|
429
|
+
: cachePath.replace(/\\/g, '/');
|
|
430
|
+
return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
|
|
431
|
+
}
|
|
408
432
|
/**
|
|
409
433
|
* Resolve an L2 org-scope learning file (issue #563): a repo-local override
|
|
410
434
|
* (`fraim/personalized-employee/learnings/`) wins, then the synced org cache
|
|
411
|
-
* (
|
|
435
|
+
* (contentRoot/learnings/). Org learnings are part of the shared org pack,
|
|
412
436
|
* so a synced copy must be readable by the agent even with no repo-local copy.
|
|
413
437
|
* Mirrors the org context/rules resolution order. Returns `present:false` with
|
|
414
438
|
* the repo path/display when neither tier has the file.
|
|
@@ -420,7 +444,7 @@ function resolveOrgLearningFile(repoLearningsBase, fileName) {
|
|
|
420
444
|
}
|
|
421
445
|
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
|
|
422
446
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
423
|
-
return { present: true, path: cachePath, displayPath: (
|
|
447
|
+
return { present: true, path: cachePath, displayPath: orgCacheDisplayPath(fileName) };
|
|
424
448
|
}
|
|
425
449
|
return { present: false, path: repoPath, displayPath: `${REPO_LEARNINGS_REL}/${fileName}` };
|
|
426
450
|
}
|
|
@@ -436,7 +460,7 @@ function resolveOrgLearningFileTiers(repoLearningsBase, fileName) {
|
|
|
436
460
|
tiers.push({
|
|
437
461
|
level: 'org',
|
|
438
462
|
path: cachePath,
|
|
439
|
-
displayPath: (
|
|
463
|
+
displayPath: orgCacheDisplayPath(fileName)
|
|
440
464
|
});
|
|
441
465
|
}
|
|
442
466
|
const repoPath = (0, path_1.join)(repoLearningsBase, fileName);
|
|
@@ -1158,11 +1182,20 @@ function collectBrainLearningDots(workspaceRoot, userId) {
|
|
|
1158
1182
|
if (coldEntries.length) {
|
|
1159
1183
|
processed.push({ type: fileType, typeLabel, region, scope: 'cold', displayPath: coldResolved.displayPath, entries: coldEntries });
|
|
1160
1184
|
}
|
|
1161
|
-
// L2 org
|
|
1162
|
-
|
|
1163
|
-
const
|
|
1164
|
-
if (
|
|
1165
|
-
|
|
1185
|
+
// L2 org — issue #1078 fix: use tiered resolver + merge so synced org home
|
|
1186
|
+
// and repo-local org-*.md are both included, same as buildLearningContextSection.
|
|
1187
|
+
const orgTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, `org-${fileType}.md`);
|
|
1188
|
+
if (orgTiers.length > 0) {
|
|
1189
|
+
// Merge entries across tiers; deduplicate by title (last tier wins = repo-local wins).
|
|
1190
|
+
const allOrgEntries = orgTiers.flatMap(t => parseBrainLearningEntries(t.path, fileType));
|
|
1191
|
+
const byTitle = new Map();
|
|
1192
|
+
for (const e of allOrgEntries)
|
|
1193
|
+
byTitle.set(e.title.trim().toLowerCase(), e);
|
|
1194
|
+
const mergedOrgEntries = [...byTitle.values()];
|
|
1195
|
+
if (mergedOrgEntries.length) {
|
|
1196
|
+
// Use the first (highest-precedence = synced org home) tier's displayPath.
|
|
1197
|
+
processed.push({ type: fileType, typeLabel, region, scope: 'org', displayPath: orgTiers[0].displayPath, entries: mergedOrgEntries });
|
|
1198
|
+
}
|
|
1166
1199
|
}
|
|
1167
1200
|
}
|
|
1168
1201
|
return { pending: { count: pendingSignals.length, signals: pendingSignals }, processed };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.266",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"electron": "^41.2.2",
|
|
164
164
|
"electron-updater": "^6.8.9",
|
|
165
165
|
"express": "^5.2.1",
|
|
166
|
-
"fraim": "2.0.
|
|
166
|
+
"fraim": "2.0.266",
|
|
167
167
|
"mongodb": "^7.0.0",
|
|
168
168
|
"node-cron": "4.2.1",
|
|
169
169
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -204,19 +204,6 @@
|
|
|
204
204
|
</div>
|
|
205
205
|
</section>
|
|
206
206
|
|
|
207
|
-
<!-- Issue #512: Brain area (reached from the account menu) -->
|
|
208
|
-
<section class="hub-area" id="area-brain" hidden>
|
|
209
|
-
<div class="hub-area-page">
|
|
210
|
-
<div class="eyebrow">FRAIM · Brain</div>
|
|
211
|
-
<div class="area-h1">Brain</div>
|
|
212
|
-
<p class="area-lede">The full picture of what your team knows: every preserved learning across scopes, plus the skills and rules they draw on.</p>
|
|
213
|
-
<div class="sec-label" style="margin-top:22px;">Preserved learnings</div>
|
|
214
|
-
<div class="stat-grid" id="brain-learnings"></div>
|
|
215
|
-
<div class="sec-label" style="margin-top:22px;">What your team draws on</div>
|
|
216
|
-
<div class="stat-grid" id="brain-catalog"></div>
|
|
217
|
-
</div>
|
|
218
|
-
</section>
|
|
219
|
-
|
|
220
207
|
<section class="hub-area" id="area-connected" hidden>
|
|
221
208
|
<div class="connected-shell">
|
|
222
209
|
<div class="connected-toolbar">
|
package/public/ai-hub/script.js
CHANGED
|
@@ -343,7 +343,6 @@ function tfRefreshPersonaDependentSurfaces() {
|
|
|
343
343
|
renderManagerTeamPool();
|
|
344
344
|
if (tf.area === 'company') tfRenderCompany();
|
|
345
345
|
else if (tf.area === 'manager') tfRenderManager();
|
|
346
|
-
else if (tf.area === 'brain') tfRenderBrain();
|
|
347
346
|
if (tf.area === 'projects') {
|
|
348
347
|
if (tf.projectView === 'overview') tfRenderOverview();
|
|
349
348
|
else {
|
|
@@ -2532,13 +2531,15 @@ function statusLabel(s) {
|
|
|
2532
2531
|
function conversationUiState(conv) {
|
|
2533
2532
|
if (!conv) return 'idle';
|
|
2534
2533
|
if (conv.blocked) return 'blocked';
|
|
2534
|
+
// Issue #1081: a submit-phase review handoff is a manager gate even if the
|
|
2535
|
+
// employee process is still alive for post-submit bookkeeping.
|
|
2536
|
+
if (conv.pauseReason === 'awaiting_review') return 'waiting';
|
|
2535
2537
|
if (conv.status === 'running') return 'working';
|
|
2536
2538
|
// Issue #904: read pauseReason first for non-running records so the pill
|
|
2537
2539
|
// reflects the exit classification rather than mapping all completed -> 'waiting'.
|
|
2538
2540
|
if (conv.pauseReason === 'working') return 'working';
|
|
2539
2541
|
if (conv.pauseReason === 'done') return 'complete';
|
|
2540
2542
|
if (conv.pauseReason === 'error') return 'error';
|
|
2541
|
-
if (conv.pauseReason === 'awaiting_review') return 'waiting';
|
|
2542
2543
|
if (conv.pauseReason === 'awaiting_user') return 'waiting';
|
|
2543
2544
|
if (conv.pauseReason === 'stopped') return 'stopped';
|
|
2544
2545
|
// Legacy fallback for records written before #904 (no pauseReason field).
|
|
@@ -4833,6 +4834,9 @@ function defaultCoachNote(conv) {
|
|
|
4833
4834
|
if (uiState === 'error') {
|
|
4834
4835
|
return 'The employee encountered an error. Review the run log and send a corrective instruction to retry.';
|
|
4835
4836
|
}
|
|
4837
|
+
if (conv && conv.pauseReason === 'awaiting_user') {
|
|
4838
|
+
return 'The employee is waiting on you. Send the next instruction to continue this run.';
|
|
4839
|
+
}
|
|
4836
4840
|
if (conv && conv.status === 'running') {
|
|
4837
4841
|
return 'The employee is still working. Add coaching here to tighten the next step without losing context.';
|
|
4838
4842
|
}
|
|
@@ -4939,6 +4943,10 @@ function convAwaitingReview(conv) {
|
|
|
4939
4943
|
if (!conv) return false;
|
|
4940
4944
|
if (conv.pauseReason === 'done') return false;
|
|
4941
4945
|
if (conv.reviewApproved) return false;
|
|
4946
|
+
const handoff = reviewHandoffForConversation(conv);
|
|
4947
|
+
if (conv.pauseReason === 'awaiting_review') {
|
|
4948
|
+
return handoff ? handoff.reviewRequired === true : false;
|
|
4949
|
+
}
|
|
4942
4950
|
// #770 R2/R4: never ask for a decision while the run is actively working a
|
|
4943
4951
|
// (possibly later, no-decision) phase. conv.status is maintained by
|
|
4944
4952
|
// foldRunIntoConversation. While 'running' the manager is coaching, not
|
|
@@ -4950,7 +4958,6 @@ function convAwaitingReview(conv) {
|
|
|
4950
4958
|
// happens to have produced a file (it appears in the Deliverables panel, not
|
|
4951
4959
|
// an approve/reject bar).
|
|
4952
4960
|
if (conv.jobId === '__freeform__') return false;
|
|
4953
|
-
const handoff = reviewHandoffForConversation(conv);
|
|
4954
4961
|
if (handoff) return handoff.reviewRequired === true;
|
|
4955
4962
|
// Delegation orchestration runs (fully-delegate with an active ledger) only enter review
|
|
4956
4963
|
// via an explicit review_handoff — Mandy's delegation artifacts are orchestration working
|
|
@@ -7647,6 +7654,7 @@ function foldRunIntoConversation(conv, run) {
|
|
|
7647
7654
|
conv.title = conversationTitle(conv);
|
|
7648
7655
|
// Track session for resumption.
|
|
7649
7656
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
7657
|
+
if (run.resumeCommand !== undefined) conv.resumeCommand = run.resumeCommand;
|
|
7650
7658
|
if (run.hostSessions && typeof run.hostSessions === 'object') {
|
|
7651
7659
|
conv.hostSessions = { ...(conv.hostSessions || {}), ...run.hostSessions };
|
|
7652
7660
|
}
|
|
@@ -10079,7 +10087,6 @@ function tfShowArea(area) {
|
|
|
10079
10087
|
}
|
|
10080
10088
|
if (area === 'company') tfRenderCompany();
|
|
10081
10089
|
if (area === 'manager') tfRenderManager();
|
|
10082
|
-
if (area === 'brain') tfRenderBrain();
|
|
10083
10090
|
// Issue #1065: the scope is derived from the active surface, so it changes with the tab (R3).
|
|
10084
10091
|
if (typeof srchOnSurfaceChanged === 'function') srchOnSurfaceChanged();
|
|
10085
10092
|
window.scrollTo(0, 0);
|
|
@@ -13468,44 +13475,6 @@ async function tfPromoteProposal(originScope, proposal, targetScope) {
|
|
|
13468
13475
|
showStatus(`Promoted “${proposal.title}” to ${label} learnings. Run Share learnings to let sleep-on-learnings persist it.`, false);
|
|
13469
13476
|
}
|
|
13470
13477
|
}
|
|
13471
|
-
function tfStatCard(num, label, color) {
|
|
13472
|
-
const card = document.createElement('div');
|
|
13473
|
-
card.className = 'stat-card';
|
|
13474
|
-
const n = document.createElement('div');
|
|
13475
|
-
n.className = 'stat-num';
|
|
13476
|
-
n.textContent = String(num);
|
|
13477
|
-
if (color) n.style.color = color;
|
|
13478
|
-
const l = document.createElement('div');
|
|
13479
|
-
l.className = 'stat-lbl';
|
|
13480
|
-
l.textContent = label;
|
|
13481
|
-
card.appendChild(n); card.appendChild(l);
|
|
13482
|
-
return card;
|
|
13483
|
-
}
|
|
13484
|
-
function tfRenderBrain() {
|
|
13485
|
-
const brain = state.bootstrap && state.bootstrap.brain;
|
|
13486
|
-
const learnHost = document.getElementById('brain-learnings');
|
|
13487
|
-
const catHost = document.getElementById('brain-catalog');
|
|
13488
|
-
if (learnHost) {
|
|
13489
|
-
learnHost.innerHTML = '';
|
|
13490
|
-
// Issue #1002 R10/R11: three tiles for the three LEVELS, in one vocabulary,
|
|
13491
|
-
// then the two things that are not levels. "Manager" used to label the
|
|
13492
|
-
// reverse-mentoring count, which collided with the manager level once that
|
|
13493
|
-
// became a real level; it now has its own tile.
|
|
13494
|
-
const l = (brain && brain.learnings) || { organization: 0, manager: 0, project: 0, reverseMentoring: 0, rawSignals: 0 };
|
|
13495
|
-
learnHost.appendChild(tfStatCard(l.organization || 0, 'Company', 'var(--accent)'));
|
|
13496
|
-
learnHost.appendChild(tfStatCard(l.manager || 0, 'Manager', '#6b4c92'));
|
|
13497
|
-
learnHost.appendChild(tfStatCard(l.project || 0, 'Project', 'var(--done)'));
|
|
13498
|
-
learnHost.appendChild(tfStatCard(l.reverseMentoring || 0, 'Reverse mentoring', '#6b4c92'));
|
|
13499
|
-
learnHost.appendChild(tfStatCard(l.rawSignals || 0, 'Pending signals', '#f57c00'));
|
|
13500
|
-
}
|
|
13501
|
-
if (catHost) {
|
|
13502
|
-
catHost.innerHTML = '';
|
|
13503
|
-
const c = (brain && brain.catalog) || { skills: 0, rules: 0, jobs: 0 };
|
|
13504
|
-
catHost.appendChild(tfStatCard(c.skills || 0, 'Skills available'));
|
|
13505
|
-
catHost.appendChild(tfStatCard(c.rules || 0, 'Rules & guardrails'));
|
|
13506
|
-
catHost.appendChild(tfStatCard(c.jobs || 0, 'Jobs in catalog'));
|
|
13507
|
-
}
|
|
13508
|
-
}
|
|
13509
13478
|
|
|
13510
13479
|
// ---------------------------------------------------------------------------
|
|
13511
13480
|
// Account menu
|
|
@@ -15081,7 +15050,6 @@ function tfRefreshAfterBootstrap() {
|
|
|
15081
15050
|
tfRenderProjectTabs();
|
|
15082
15051
|
if (tf.area === 'company') tfRenderCompany();
|
|
15083
15052
|
else if (tf.area === 'manager') tfRenderManager();
|
|
15084
|
-
else if (tf.area === 'brain') tfRenderBrain();
|
|
15085
15053
|
if (tf.projectView === 'overview') tfRenderOverview();
|
|
15086
15054
|
else tfRenderTree();
|
|
15087
15055
|
// Issue #1005: the bootstrap we just applied missed the first-paint persona budget and
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -3451,7 +3451,8 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
3451
3451
|
.pending-banner { background: var(--warn-soft); border: 1px solid rgba(176,132,66,.25); border-radius: 12px; padding: 12px 16px; display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
|
|
3452
3452
|
|
|
3453
3453
|
/* Projects sub-tabs — HTML uses .proj-tabs (not .proj-tabs-bar) and .ptab-add (not .ptab-add-btn) */
|
|
3454
|
-
.proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow
|
|
3454
|
+
.proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow: hidden; }
|
|
3455
|
+
#proj-tab-list { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; overflow-x: auto; overflow-y: hidden; white-space: nowrap; }
|
|
3455
3456
|
.ptab { padding: 9px 16px; font-size: 13px; font-weight: 500; color: var(--muted); border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; margin-bottom: -1px; }
|
|
3456
3457
|
.ptab.on { color: var(--text); border-bottom-color: var(--text); font-weight: 600; }
|
|
3457
3458
|
.ptab:hover { color: var(--text); }
|