fraim 2.0.185 → 2.0.186
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/hosts.js +127 -15
- package/dist/src/ai-hub/openclaw-bridge.js +17 -6
- package/dist/src/ai-hub/server.js +135 -28
- package/package.json +1 -1
- package/public/ai-hub/script.js +246 -17
- package/public/ai-hub/styles.css +11 -0
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -5,6 +5,7 @@ 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 = void 0;
|
|
7
7
|
exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
|
|
8
|
+
exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
|
|
8
9
|
exports.parseUsageSignal = parseUsageSignal;
|
|
9
10
|
exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
|
|
10
11
|
exports.detectEmployees = detectEmployees;
|
|
@@ -91,6 +92,58 @@ function parseSeekMentoringSignal(line) {
|
|
|
91
92
|
}
|
|
92
93
|
return null;
|
|
93
94
|
}
|
|
95
|
+
// Issue #710: extract the job name from a get_fraim_job tool call. This
|
|
96
|
+
// is structured known-job evidence; the server still validates the job id
|
|
97
|
+
// against the catalog before promoting any run.
|
|
98
|
+
function parseFraimJobLoadSignal(line) {
|
|
99
|
+
if (!line.includes('get_fraim_job'))
|
|
100
|
+
return null;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(line);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
109
|
+
return null;
|
|
110
|
+
const obj = parsed;
|
|
111
|
+
// Codex shape: { type: 'item.started' | 'item.completed', item:
|
|
112
|
+
// { type: 'mcp_tool_call', tool: 'get_fraim_job', arguments: { job } } }.
|
|
113
|
+
if ((obj.type === 'item.started' || obj.type === 'item.completed') &&
|
|
114
|
+
typeof obj.item === 'object' && obj.item !== null) {
|
|
115
|
+
const item = obj.item;
|
|
116
|
+
const tool = typeof item.tool === 'string' ? item.tool : '';
|
|
117
|
+
if (item.type === 'mcp_tool_call' && isGetFraimJobTool(tool)) {
|
|
118
|
+
const sig = readFraimJobFromArgs(item.arguments);
|
|
119
|
+
if (sig)
|
|
120
|
+
return sig;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Claude Code shape: tool_use blocks live inside message.content with
|
|
124
|
+
// an MCP-prefixed name such as mcp__fraim__get_fraim_job.
|
|
125
|
+
const candidates = [obj];
|
|
126
|
+
if (Array.isArray(obj.content))
|
|
127
|
+
candidates.push(...obj.content);
|
|
128
|
+
if (typeof obj.message === 'object' && obj.message !== null) {
|
|
129
|
+
const msg = obj.message;
|
|
130
|
+
if (Array.isArray(msg.content))
|
|
131
|
+
candidates.push(...msg.content);
|
|
132
|
+
}
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
135
|
+
continue;
|
|
136
|
+
const c = candidate;
|
|
137
|
+
const isToolUse = c.type === 'tool_use' || c.type === 'function_call';
|
|
138
|
+
const nameField = typeof c.name === 'string' ? c.name : (typeof c.tool_name === 'string' ? c.tool_name : '');
|
|
139
|
+
if (!isToolUse || !isGetFraimJobTool(nameField))
|
|
140
|
+
continue;
|
|
141
|
+
const sig = readFraimJobFromArgs(c.input || c.arguments || c.parameters);
|
|
142
|
+
if (sig)
|
|
143
|
+
return sig;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
94
147
|
// Issue #347 — extract per-turn usage from the host's JSON stream.
|
|
95
148
|
// Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
|
|
96
149
|
// Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
|
|
@@ -256,6 +309,41 @@ function readAgentFromArgs(args) {
|
|
|
256
309
|
return null;
|
|
257
310
|
return { agentName, agentModel };
|
|
258
311
|
}
|
|
312
|
+
function isGetFraimJobTool(toolName) {
|
|
313
|
+
return toolName === 'get_fraim_job' ||
|
|
314
|
+
toolName === 'mcp__fraim__get_fraim_job' ||
|
|
315
|
+
toolName.endsWith('get_fraim_job');
|
|
316
|
+
}
|
|
317
|
+
function readFraimJobFromArgs(rawArgs) {
|
|
318
|
+
const args = normalizeToolArgs(rawArgs);
|
|
319
|
+
if (!args)
|
|
320
|
+
return null;
|
|
321
|
+
const rawJob = stringValue(args.job) ||
|
|
322
|
+
stringValue(args.jobName) ||
|
|
323
|
+
stringValue(args.job_id) ||
|
|
324
|
+
stringValue(args.name);
|
|
325
|
+
const jobId = rawJob.trim().toLowerCase();
|
|
326
|
+
if (!jobId)
|
|
327
|
+
return null;
|
|
328
|
+
return { jobId, source: 'get_fraim_job' };
|
|
329
|
+
}
|
|
330
|
+
function normalizeToolArgs(rawArgs) {
|
|
331
|
+
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
|
332
|
+
return rawArgs;
|
|
333
|
+
}
|
|
334
|
+
if (typeof rawArgs !== 'string' || rawArgs.trim().length === 0)
|
|
335
|
+
return null;
|
|
336
|
+
try {
|
|
337
|
+
const parsed = JSON.parse(rawArgs);
|
|
338
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
339
|
+
return parsed;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
259
347
|
function numberOrNull(v) {
|
|
260
348
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
261
349
|
}
|
|
@@ -987,17 +1075,24 @@ function parseHostLine(hostId, line) {
|
|
|
987
1075
|
const trimmed = line.trim();
|
|
988
1076
|
if (!trimmed)
|
|
989
1077
|
return {};
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
1078
|
+
// Scan every line for structured signals the Hub UI cares about:
|
|
1079
|
+
// seekMentoring (tracker), get_fraim_job (job identity promotion),
|
|
1080
|
+
// turn-level usage (totals), and fraim_connect agent identity
|
|
1081
|
+
// (cost lookup when the host doesn't emit it).
|
|
994
1082
|
const seekMentoring = parseSeekMentoringSignal(trimmed);
|
|
1083
|
+
const fraimJob = parseFraimJobLoadSignal(trimmed);
|
|
995
1084
|
const usage = parseUsageSignal(trimmed);
|
|
996
1085
|
const agentIdentity = parseAgentIdentitySignal(trimmed);
|
|
997
1086
|
const withSignal = (event) => {
|
|
998
|
-
if (!seekMentoring && !usage && !agentIdentity)
|
|
1087
|
+
if (!seekMentoring && !fraimJob && !usage && !agentIdentity)
|
|
999
1088
|
return event;
|
|
1000
|
-
return {
|
|
1089
|
+
return {
|
|
1090
|
+
...event,
|
|
1091
|
+
...(seekMentoring ? { seekMentoring } : {}),
|
|
1092
|
+
...(fraimJob ? { fraimJob } : {}),
|
|
1093
|
+
...(usage ? { usage } : {}),
|
|
1094
|
+
...(agentIdentity ? { agentIdentity } : {}),
|
|
1095
|
+
};
|
|
1001
1096
|
};
|
|
1002
1097
|
if (hostId === 'codex') {
|
|
1003
1098
|
try {
|
|
@@ -1339,9 +1434,10 @@ class ScriptedHostRuntime {
|
|
|
1339
1434
|
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1340
1435
|
{ id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1341
1436
|
];
|
|
1342
|
-
// Track each active run so the test can emit signals at it.
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1437
|
+
// Track each active run so the test can emit signals at it. The Hub
|
|
1438
|
+
// passes the run id as the requested start session id in test/demo paths;
|
|
1439
|
+
// real resume flows still use the host session id. Resolving by the
|
|
1440
|
+
// supplied id first lets concurrent scripted runs receive targeted events.
|
|
1345
1441
|
this.handlersBySession = new Map();
|
|
1346
1442
|
// Tracks runDiscriminant per sessionId so consecutive emitPhase calls
|
|
1347
1443
|
// can resolve onSuccess routing without each test having to repeat it.
|
|
@@ -1352,8 +1448,8 @@ class ScriptedHostRuntime {
|
|
|
1352
1448
|
}
|
|
1353
1449
|
startRun(_hostId, _projectPath, _message, handlers, requestedSessionId) {
|
|
1354
1450
|
const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
|
|
1355
|
-
handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
|
|
1356
1451
|
this.handlersBySession.set(sessionId, handlers);
|
|
1452
|
+
handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
|
|
1357
1453
|
return this.spawnDouble();
|
|
1358
1454
|
}
|
|
1359
1455
|
continueRun(_hostId, _projectPath, sessionId, _message, handlers) {
|
|
@@ -1363,6 +1459,7 @@ class ScriptedHostRuntime {
|
|
|
1363
1459
|
}
|
|
1364
1460
|
startDirectRun(_hostId, _message, _projectPath, handlers, requestedSessionId) {
|
|
1365
1461
|
const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
|
|
1462
|
+
this.handlersBySession.set(sessionId, handlers);
|
|
1366
1463
|
handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
|
|
1367
1464
|
return this.spawnDouble();
|
|
1368
1465
|
}
|
|
@@ -1420,6 +1517,18 @@ class ScriptedHostRuntime {
|
|
|
1420
1517
|
},
|
|
1421
1518
|
}, 'stdout');
|
|
1422
1519
|
}
|
|
1520
|
+
// Test API — emit the structured job-load signal that get_fraim_job
|
|
1521
|
+
// produces before the first seekMentoring phase call.
|
|
1522
|
+
emitFraimJobLoad(runId, jobId) {
|
|
1523
|
+
const target = this.resolveSession(runId);
|
|
1524
|
+
if (!target)
|
|
1525
|
+
return;
|
|
1526
|
+
target.handlers.onEvent({
|
|
1527
|
+
sessionId: target.sessionId,
|
|
1528
|
+
raw: `scripted-get_fraim_job:${jobId}`,
|
|
1529
|
+
fraimJob: { jobId, source: 'get_fraim_job' },
|
|
1530
|
+
}, 'stdout');
|
|
1531
|
+
}
|
|
1423
1532
|
// Test API — emit a per-turn usage signal in the normalized shape
|
|
1424
1533
|
// the server expects. Mirrors what parseUsageSignal would produce
|
|
1425
1534
|
// from a real host's stream.
|
|
@@ -1464,13 +1573,16 @@ class ScriptedHostRuntime {
|
|
|
1464
1573
|
this.handlersBySession.clear();
|
|
1465
1574
|
this.discriminantBySession.clear();
|
|
1466
1575
|
}
|
|
1467
|
-
// The Hub
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
resolveSession(
|
|
1576
|
+
// The Hub passes run.id as the requested session id for scripted starts,
|
|
1577
|
+
// so tests/demo controls can target a specific run even when multiple runs
|
|
1578
|
+
// are active. Fall back to the latest handler for older callers that do not
|
|
1579
|
+
// provide a known id.
|
|
1580
|
+
resolveSession(runId) {
|
|
1472
1581
|
if (this.handlersBySession.size === 0)
|
|
1473
1582
|
return null;
|
|
1583
|
+
const direct = this.handlersBySession.get(runId);
|
|
1584
|
+
if (direct)
|
|
1585
|
+
return { sessionId: runId, handlers: direct };
|
|
1474
1586
|
const entries = [...this.handlersBySession.entries()];
|
|
1475
1587
|
const [sessionId, handlers] = entries[entries.length - 1];
|
|
1476
1588
|
return { sessionId, handlers };
|
|
@@ -134,6 +134,7 @@ class OpenClawAiHubBridge {
|
|
|
134
134
|
jobId: run.jobId,
|
|
135
135
|
projectPath,
|
|
136
136
|
lastSeenAt: new Date().toISOString(),
|
|
137
|
+
managerTexts: [inbound.text],
|
|
137
138
|
};
|
|
138
139
|
this.threads.set(key, state);
|
|
139
140
|
this.persistThreadState();
|
|
@@ -147,6 +148,7 @@ class OpenClawAiHubBridge {
|
|
|
147
148
|
}
|
|
148
149
|
run = continued.data;
|
|
149
150
|
state.lastSeenAt = new Date().toISOString();
|
|
151
|
+
state.managerTexts = [...(state.managerTexts ?? []), inbound.text];
|
|
150
152
|
this.persistThreadState();
|
|
151
153
|
}
|
|
152
154
|
const settled = await this.waitForSettlement(run.id);
|
|
@@ -193,6 +195,19 @@ class OpenClawAiHubBridge {
|
|
|
193
195
|
return this.fetchRun(runId);
|
|
194
196
|
}
|
|
195
197
|
presentThread(state, run) {
|
|
198
|
+
// The Hub records the invocation it sent as each manager bubble (#696). For a channel
|
|
199
|
+
// transcript we substitute the user's own words (state.managerTexts, in turn order)
|
|
200
|
+
// for manager-role messages, so the thread reflects what the user actually typed.
|
|
201
|
+
const managerTexts = state.managerTexts ?? [];
|
|
202
|
+
let managerIdx = 0;
|
|
203
|
+
const transcript = run.messages.map((message) => ({
|
|
204
|
+
role: message.role,
|
|
205
|
+
text: message.role === 'manager' && managerIdx < managerTexts.length
|
|
206
|
+
? managerTexts[managerIdx++]
|
|
207
|
+
: message.text,
|
|
208
|
+
createdAt: message.createdAt,
|
|
209
|
+
}));
|
|
210
|
+
const latestManagerMessage = [...transcript].reverse().find((m) => m.role === 'manager')?.text || null;
|
|
196
211
|
return {
|
|
197
212
|
channelId: state.channelId,
|
|
198
213
|
threadId: state.threadId,
|
|
@@ -204,13 +219,9 @@ class OpenClawAiHubBridge {
|
|
|
204
219
|
currentPhase: run.currentPhase || null,
|
|
205
220
|
stages: run.stages || [],
|
|
206
221
|
totals: run.totals || null,
|
|
207
|
-
latestManagerMessage
|
|
222
|
+
latestManagerMessage,
|
|
208
223
|
latestEmployeeMessage: lastMessage(run.messages, 'employee')?.text || null,
|
|
209
|
-
transcript
|
|
210
|
-
role: message.role,
|
|
211
|
-
text: message.text,
|
|
212
|
-
createdAt: message.createdAt,
|
|
213
|
-
})),
|
|
224
|
+
transcript,
|
|
214
225
|
};
|
|
215
226
|
}
|
|
216
227
|
threadKey(channelId, threadId) {
|
|
@@ -227,6 +227,9 @@ function safeHttpUrl(value) {
|
|
|
227
227
|
}
|
|
228
228
|
// ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
|
|
229
229
|
const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
230
|
+
function startSessionSeedForHost(hostId, runId) {
|
|
231
|
+
return hostId === 'gemini' ? undefined : runId;
|
|
232
|
+
}
|
|
230
233
|
class DeploymentStore {
|
|
231
234
|
constructor(filePath) {
|
|
232
235
|
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
|
|
@@ -587,6 +590,42 @@ function emptyTotals() {
|
|
|
587
590
|
},
|
|
588
591
|
};
|
|
589
592
|
}
|
|
593
|
+
const PHASE_STATUSES = new Set(['starting', 'complete', 'incomplete', 'failure']);
|
|
594
|
+
function normalizePersistedPhaseHistory(value) {
|
|
595
|
+
if (!Array.isArray(value))
|
|
596
|
+
return [];
|
|
597
|
+
return value
|
|
598
|
+
.map((entry) => {
|
|
599
|
+
if (!entry || typeof entry !== 'object')
|
|
600
|
+
return null;
|
|
601
|
+
const raw = entry;
|
|
602
|
+
if (typeof raw.phaseId !== 'string' || !raw.phaseId)
|
|
603
|
+
return null;
|
|
604
|
+
const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
|
|
605
|
+
? raw.latestStatus
|
|
606
|
+
: null;
|
|
607
|
+
return {
|
|
608
|
+
phaseId: raw.phaseId,
|
|
609
|
+
enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
|
|
610
|
+
latestStatus,
|
|
611
|
+
latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
|
|
612
|
+
};
|
|
613
|
+
})
|
|
614
|
+
.filter((entry) => Boolean(entry));
|
|
615
|
+
}
|
|
616
|
+
function readPersistedRunProjection(conversation) {
|
|
617
|
+
const rawRun = conversation?.run;
|
|
618
|
+
if (!rawRun || typeof rawRun !== 'object')
|
|
619
|
+
return null;
|
|
620
|
+
const value = rawRun;
|
|
621
|
+
return {
|
|
622
|
+
currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
|
|
623
|
+
phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
|
|
624
|
+
stages: Array.isArray(value.stages) ? value.stages : [],
|
|
625
|
+
totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
|
|
626
|
+
runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
590
629
|
// Issue #347 — apply per-turn usage from the host stream into the
|
|
591
630
|
// run's tokenTotals. Idempotent on the same turn (we replace, not add)
|
|
592
631
|
// because Codex's `turn.completed.usage` is cumulative, not delta.
|
|
@@ -766,20 +805,17 @@ function deriveStages(run, projectPath) {
|
|
|
766
805
|
}
|
|
767
806
|
return declaredPath.map((phase, index) => {
|
|
768
807
|
let state;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}
|
|
772
|
-
else if (index < currentIndex) {
|
|
773
|
-
state = 'done';
|
|
774
|
-
}
|
|
775
|
-
else if (index === currentIndex) {
|
|
808
|
+
const entry = historyMap.get(phase.id);
|
|
809
|
+
if (index === currentIndex) {
|
|
776
810
|
// If the agent has already reported this phase as 'complete', advance
|
|
777
811
|
// its visual state to 'done' so the tracker doesn't look frozen while
|
|
778
812
|
// waiting for the agent to start the next phase (e.g. after
|
|
779
813
|
// implement-submission completes but before address-feedback starts).
|
|
780
|
-
const entry = historyMap.get(phase.id);
|
|
781
814
|
state = entry?.latestStatus === 'complete' ? 'done' : 'current';
|
|
782
815
|
}
|
|
816
|
+
else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
|
|
817
|
+
state = 'done';
|
|
818
|
+
}
|
|
783
819
|
else {
|
|
784
820
|
state = 'upcoming';
|
|
785
821
|
}
|
|
@@ -1399,6 +1435,7 @@ class AiHubServer {
|
|
|
1399
1435
|
}
|
|
1400
1436
|
conversationRecordFromRun(run) {
|
|
1401
1437
|
const lastUpdatedAt = run.updatedAt || new Date().toISOString();
|
|
1438
|
+
const stages = deriveStages(run, run.projectPath);
|
|
1402
1439
|
return {
|
|
1403
1440
|
id: run.conversationId || run.id,
|
|
1404
1441
|
projectPath: path_1.default.resolve(run.projectPath),
|
|
@@ -1433,6 +1470,13 @@ class AiHubServer {
|
|
|
1433
1470
|
compareRunId: run.compareRunId || null,
|
|
1434
1471
|
// Issue #578: preserve trigger source so the UI can render the chip.
|
|
1435
1472
|
sourceTrigger: run.sourceTrigger,
|
|
1473
|
+
run: {
|
|
1474
|
+
stages,
|
|
1475
|
+
currentPhase: run.currentPhase || null,
|
|
1476
|
+
phaseHistory: run.phaseHistory || [],
|
|
1477
|
+
totals: run.totals || null,
|
|
1478
|
+
runDiscriminant: run.runDiscriminant || null,
|
|
1479
|
+
},
|
|
1436
1480
|
};
|
|
1437
1481
|
}
|
|
1438
1482
|
persistRunConversation(run, activeId) {
|
|
@@ -1544,8 +1588,10 @@ class AiHubServer {
|
|
|
1544
1588
|
}
|
|
1545
1589
|
if (event.agentIdentity)
|
|
1546
1590
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1591
|
+
if (event.fraimJob)
|
|
1592
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
1547
1593
|
if (event.seekMentoring)
|
|
1548
|
-
|
|
1594
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
1549
1595
|
if (event.usage)
|
|
1550
1596
|
applyUsageSignal(current, event.usage);
|
|
1551
1597
|
});
|
|
@@ -1567,7 +1613,7 @@ class AiHubServer {
|
|
|
1567
1613
|
}
|
|
1568
1614
|
this.runRegistry.dispose(childRun.id);
|
|
1569
1615
|
},
|
|
1570
|
-
}, childRun.
|
|
1616
|
+
}, startSessionSeedForHost(managerRun.hostId, childRun.id));
|
|
1571
1617
|
this.runRegistry.attachChildIfRunning(childRun.id, child);
|
|
1572
1618
|
}
|
|
1573
1619
|
notifyManagerOfDelegatedChild(managerRunId, childRun) {
|
|
@@ -1691,8 +1737,10 @@ class AiHubServer {
|
|
|
1691
1737
|
}
|
|
1692
1738
|
if (event.agentIdentity)
|
|
1693
1739
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1740
|
+
if (event.fraimJob)
|
|
1741
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
1694
1742
|
if (event.seekMentoring)
|
|
1695
|
-
|
|
1743
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
1696
1744
|
if (event.usage)
|
|
1697
1745
|
applyUsageSignal(current, event.usage);
|
|
1698
1746
|
});
|
|
@@ -1764,14 +1812,48 @@ class AiHubServer {
|
|
|
1764
1812
|
};
|
|
1765
1813
|
}
|
|
1766
1814
|
resolveHubJob(projectPath, jobId) {
|
|
1815
|
+
if (!jobId || jobId === '__freeform__')
|
|
1816
|
+
return null;
|
|
1767
1817
|
const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
|
|
1768
|
-
if (employeeJob)
|
|
1769
|
-
return {
|
|
1818
|
+
if (employeeJob) {
|
|
1819
|
+
return {
|
|
1820
|
+
id: employeeJob.id,
|
|
1821
|
+
title: employeeJob.title,
|
|
1822
|
+
stubPath: employeeJob.stubPath,
|
|
1823
|
+
personaKey: employeeJob.requiredPersonaKey ?? getProtectedPersonaForHubJob(employeeJob.id),
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1770
1826
|
const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
|
|
1771
|
-
if (managerTemplate)
|
|
1772
|
-
return {
|
|
1827
|
+
if (managerTemplate) {
|
|
1828
|
+
return {
|
|
1829
|
+
id: managerTemplate.id,
|
|
1830
|
+
title: managerTemplate.title,
|
|
1831
|
+
stubPath: managerTemplate.stubPath,
|
|
1832
|
+
personaKey: getProtectedPersonaForHubJob(managerTemplate.id),
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1773
1835
|
return null;
|
|
1774
1836
|
}
|
|
1837
|
+
applySeekMentoringSignalToRun(run, signal) {
|
|
1838
|
+
this.maybePromoteFreeformRunToJob(run, signal.jobId || signal.jobName);
|
|
1839
|
+
applySeekMentoringSignal(run, signal);
|
|
1840
|
+
}
|
|
1841
|
+
applyFraimJobSignalToRun(run, signal) {
|
|
1842
|
+
this.maybePromoteFreeformRunToJob(run, signal.jobId);
|
|
1843
|
+
}
|
|
1844
|
+
maybePromoteFreeformRunToJob(run, signalJobId) {
|
|
1845
|
+
const normalizedJobId = (signalJobId || '').trim().toLowerCase();
|
|
1846
|
+
if (run.jobId !== '__freeform__' || !normalizedJobId)
|
|
1847
|
+
return;
|
|
1848
|
+
const metadata = this.resolveHubJob(run.projectPath, normalizedJobId);
|
|
1849
|
+
if (!metadata)
|
|
1850
|
+
return;
|
|
1851
|
+
run.jobId = metadata.id;
|
|
1852
|
+
run.jobTitle = metadata.title;
|
|
1853
|
+
run.personaKey = metadata.personaKey;
|
|
1854
|
+
run.updatedAt = new Date().toISOString();
|
|
1855
|
+
run.events.push((0, hosts_1.createHubEvent)('system', `Recognized FRAIM job ${metadata.id} from structured telemetry.`));
|
|
1856
|
+
}
|
|
1775
1857
|
// Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
|
|
1776
1858
|
// content) export routes so a conversational deliverable with no on-disk file
|
|
1777
1859
|
// can still be downloaded for Word annotation.
|
|
@@ -2355,11 +2437,15 @@ class AiHubServer {
|
|
|
2355
2437
|
throw new Error('Choose a FRAIM job before starting a run.');
|
|
2356
2438
|
}
|
|
2357
2439
|
const startTimestamp = new Date().toISOString();
|
|
2440
|
+
const jobMetadata = this.resolveHubJob(projectPath, jobId);
|
|
2441
|
+
const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
|
|
2442
|
+
? req.body.jobTitle.trim()
|
|
2443
|
+
: jobId;
|
|
2358
2444
|
const run = {
|
|
2359
2445
|
id: (0, crypto_1.randomUUID)(),
|
|
2360
2446
|
conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
|
|
2361
2447
|
conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
|
|
2362
|
-
jobTitle:
|
|
2448
|
+
jobTitle: jobMetadata?.title || fallbackJobTitle,
|
|
2363
2449
|
jobId,
|
|
2364
2450
|
hostId,
|
|
2365
2451
|
projectPath,
|
|
@@ -2373,7 +2459,7 @@ class AiHubServer {
|
|
|
2373
2459
|
phaseHistory: [],
|
|
2374
2460
|
totals: emptyTotals(),
|
|
2375
2461
|
lastStatusChangeAt: startTimestamp,
|
|
2376
|
-
personaKey: getProtectedPersonaForHubJob(jobId),
|
|
2462
|
+
personaKey: jobMetadata?.personaKey ?? getProtectedPersonaForHubJob(jobId),
|
|
2377
2463
|
// Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
|
|
2378
2464
|
...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
|
|
2379
2465
|
// #0: trigger source — defaults to 'manager' when not provided by the caller.
|
|
@@ -2426,8 +2512,10 @@ class AiHubServer {
|
|
|
2426
2512
|
}
|
|
2427
2513
|
if (event.agentIdentity)
|
|
2428
2514
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2515
|
+
if (event.fraimJob)
|
|
2516
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
2429
2517
|
if (event.seekMentoring)
|
|
2430
|
-
|
|
2518
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
2431
2519
|
if (event.usage)
|
|
2432
2520
|
applyUsageSignal(current, event.usage);
|
|
2433
2521
|
});
|
|
@@ -2460,7 +2548,7 @@ class AiHubServer {
|
|
|
2460
2548
|
if (latest)
|
|
2461
2549
|
this.drainPendingDelegatedReviews(latest);
|
|
2462
2550
|
},
|
|
2463
|
-
}, run.
|
|
2551
|
+
}, startSessionSeedForHost(hostId, run.id));
|
|
2464
2552
|
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2465
2553
|
// Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
|
|
2466
2554
|
// uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
|
|
@@ -2489,7 +2577,7 @@ class AiHubServer {
|
|
|
2489
2577
|
});
|
|
2490
2578
|
this.runRegistry.dispose(directId);
|
|
2491
2579
|
},
|
|
2492
|
-
}, directRun.
|
|
2580
|
+
}, startSessionSeedForHost(hostId, directRun.id));
|
|
2493
2581
|
this.runRegistry.attachChildIfRunning(directRun.id, directChild);
|
|
2494
2582
|
}
|
|
2495
2583
|
const existingPreferences = this.preferencesStore.load(projectPath);
|
|
@@ -2615,8 +2703,10 @@ class AiHubServer {
|
|
|
2615
2703
|
}
|
|
2616
2704
|
if (event.agentIdentity)
|
|
2617
2705
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2706
|
+
if (event.fraimJob)
|
|
2707
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
2618
2708
|
if (event.seekMentoring)
|
|
2619
|
-
|
|
2709
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
2620
2710
|
if (event.usage)
|
|
2621
2711
|
applyUsageSignal(current, event.usage);
|
|
2622
2712
|
});
|
|
@@ -2677,16 +2767,27 @@ class AiHubServer {
|
|
|
2677
2767
|
if (!employee?.available) {
|
|
2678
2768
|
throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
|
|
2679
2769
|
}
|
|
2770
|
+
const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
|
|
2771
|
+
? body.conversationId.trim()
|
|
2772
|
+
: undefined;
|
|
2773
|
+
const persistedConversation = conversationId
|
|
2774
|
+
? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
|
|
2775
|
+
: undefined;
|
|
2776
|
+
const persistedRun = readPersistedRunProjection(persistedConversation);
|
|
2680
2777
|
const now = new Date().toISOString();
|
|
2681
2778
|
const run = {
|
|
2682
2779
|
id: (0, crypto_1.randomUUID)(),
|
|
2683
|
-
conversationId
|
|
2780
|
+
conversationId,
|
|
2684
2781
|
conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
|
|
2685
2782
|
jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
|
|
2686
2783
|
jobId, hostId, projectPath, status: 'running', sessionId,
|
|
2687
2784
|
createdAt: now, updatedAt: now, messages: [],
|
|
2688
2785
|
events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
|
|
2689
|
-
currentPhase:
|
|
2786
|
+
currentPhase: persistedRun?.currentPhase || null,
|
|
2787
|
+
phaseHistory: persistedRun?.phaseHistory || [],
|
|
2788
|
+
totals: persistedRun?.totals || emptyTotals(),
|
|
2789
|
+
lastStatusChangeAt: now,
|
|
2790
|
+
runDiscriminant: persistedRun?.runDiscriminant || undefined,
|
|
2690
2791
|
personaKey: getProtectedPersonaForHubJob(jobId),
|
|
2691
2792
|
};
|
|
2692
2793
|
// Continue-turn message (FRAIM invocation for the job + instructions) plus
|
|
@@ -2710,8 +2811,10 @@ class AiHubServer {
|
|
|
2710
2811
|
}
|
|
2711
2812
|
if (event.agentIdentity)
|
|
2712
2813
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2814
|
+
if (event.fraimJob)
|
|
2815
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
2713
2816
|
if (event.seekMentoring)
|
|
2714
|
-
|
|
2817
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
2715
2818
|
if (event.usage)
|
|
2716
2819
|
applyUsageSignal(current, event.usage);
|
|
2717
2820
|
});
|
|
@@ -3139,8 +3242,10 @@ class AiHubServer {
|
|
|
3139
3242
|
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3140
3243
|
if (event.agentIdentity)
|
|
3141
3244
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3245
|
+
if (event.fraimJob)
|
|
3246
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
3142
3247
|
if (event.seekMentoring)
|
|
3143
|
-
|
|
3248
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
3144
3249
|
if (event.usage)
|
|
3145
3250
|
applyUsageSignal(current, event.usage);
|
|
3146
3251
|
});
|
|
@@ -3153,7 +3258,7 @@ class AiHubServer {
|
|
|
3153
3258
|
});
|
|
3154
3259
|
this.runRegistry.dispose(run.id);
|
|
3155
3260
|
},
|
|
3156
|
-
}, run.
|
|
3261
|
+
}, startSessionSeedForHost(hostId, run.id));
|
|
3157
3262
|
// Update the registry entry with the real child process handle.
|
|
3158
3263
|
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
3159
3264
|
return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
|
|
@@ -3259,8 +3364,10 @@ class AiHubServer {
|
|
|
3259
3364
|
}
|
|
3260
3365
|
if (event.agentIdentity)
|
|
3261
3366
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3367
|
+
if (event.fraimJob)
|
|
3368
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
3262
3369
|
if (event.seekMentoring)
|
|
3263
|
-
|
|
3370
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
3264
3371
|
if (event.usage)
|
|
3265
3372
|
applyUsageSignal(current, event.usage);
|
|
3266
3373
|
});
|
|
@@ -3280,7 +3387,7 @@ class AiHubServer {
|
|
|
3280
3387
|
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
|
|
3281
3388
|
this.runRegistry.dispose(run.id);
|
|
3282
3389
|
},
|
|
3283
|
-
});
|
|
3390
|
+
}, startSessionSeedForHost(deployment.hostId, run.id));
|
|
3284
3391
|
this.runRegistry.create(run, child);
|
|
3285
3392
|
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
|
|
3286
3393
|
return run;
|
package/package.json
CHANGED
package/public/ai-hub/script.js
CHANGED
|
@@ -16,6 +16,9 @@ const TREE_WIDTH_MAX = 380;
|
|
|
16
16
|
const TREE_WIDTH_DEFAULT = 216;
|
|
17
17
|
const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis']);
|
|
18
18
|
// Jobs scoped to the Company/Manager area — never shown in the Projects workspace rail.
|
|
19
|
+
// These are the truly area-only jobs. Issue #702: persona jobs (e.g. Ashley's) are NOT
|
|
20
|
+
// listed here — a persona can run a job from a project OR from the Manager tab, and a
|
|
21
|
+
// run's placement is decided per-invocation by conv.invokedArea, not by job id.
|
|
19
22
|
const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
|
|
20
23
|
|
|
21
24
|
const state = {
|
|
@@ -509,9 +512,10 @@ async function bgRefreshConversations() {
|
|
|
509
512
|
existing.push(conv);
|
|
510
513
|
changed = true;
|
|
511
514
|
} else {
|
|
512
|
-
// Update
|
|
515
|
+
// Update known conversations when the server projection changes. This
|
|
516
|
+
// includes job/persona recognition for running ad-hoc jobs.
|
|
513
517
|
const idx = existing.findIndex((c) => c.id === conv.id);
|
|
514
|
-
if (idx !== -1 && existing[idx]
|
|
518
|
+
if (idx !== -1 && conversationProjectionChanged(existing[idx], conv)) {
|
|
515
519
|
existing[idx] = conv;
|
|
516
520
|
changed = true;
|
|
517
521
|
}
|
|
@@ -533,7 +537,7 @@ function stopBgConvPoll() {
|
|
|
533
537
|
if (_bgConvPollHandle) { window.clearInterval(_bgConvPollHandle); _bgConvPollHandle = null; }
|
|
534
538
|
}
|
|
535
539
|
|
|
536
|
-
function
|
|
540
|
+
function needsDelegatedConversationRefresh() {
|
|
537
541
|
const active = activeConversation();
|
|
538
542
|
if (!active) return false;
|
|
539
543
|
if (isManagedDelegationChild(active) && active.status === 'running') return true;
|
|
@@ -548,12 +552,49 @@ function needsConversationRefresh() {
|
|
|
548
552
|
});
|
|
549
553
|
}
|
|
550
554
|
|
|
555
|
+
function needsConversationRefresh() {
|
|
556
|
+
return runningProjectConversations().length > 0 || needsDelegatedConversationRefresh();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function refreshRunningConversations() {
|
|
560
|
+
const running = runningProjectConversations();
|
|
561
|
+
if (!running.length) return false;
|
|
562
|
+
let changed = false;
|
|
563
|
+
for (const conv of running) {
|
|
564
|
+
try {
|
|
565
|
+
const run = await requestJson(`/api/ai-hub/runs/${encodeURIComponent(conv.runId)}`);
|
|
566
|
+
foldRunIntoConversation(conv, run);
|
|
567
|
+
upsertConversation(conv);
|
|
568
|
+
changed = true;
|
|
569
|
+
} catch (_) {
|
|
570
|
+
// The run may have just completed and left the in-memory registry before
|
|
571
|
+
// the conversation store hydrated. The next conversation refresh catches it.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (changed) {
|
|
575
|
+
renderRail();
|
|
576
|
+
renderActive();
|
|
577
|
+
if (tf.area === 'company') tfRenderCompany();
|
|
578
|
+
else if (tf.area === 'manager') tfRenderManager();
|
|
579
|
+
}
|
|
580
|
+
return changed;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function refreshConversationProjections() {
|
|
584
|
+
await refreshRunningConversations();
|
|
585
|
+
if (needsDelegatedConversationRefresh()) {
|
|
586
|
+
await hydrateConversationsFromServer();
|
|
587
|
+
} else {
|
|
588
|
+
syncConversationRefreshPolling();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
551
592
|
function syncConversationRefreshPolling() {
|
|
552
593
|
const shouldPoll = needsConversationRefresh();
|
|
553
594
|
if (shouldPoll && !state.conversationRefreshHandle) {
|
|
554
595
|
state.conversationRefreshHandle = window.setInterval(() => {
|
|
555
|
-
|
|
556
|
-
console.warn('Could not refresh
|
|
596
|
+
refreshConversationProjections().catch((error) =>
|
|
597
|
+
console.warn('Could not refresh conversations:', error));
|
|
557
598
|
}, 1500);
|
|
558
599
|
} else if (!shouldPoll && state.conversationRefreshHandle) {
|
|
559
600
|
window.clearInterval(state.conversationRefreshHandle);
|
|
@@ -578,6 +619,32 @@ function projectConversations() {
|
|
|
578
619
|
return state.conversations[key] || [];
|
|
579
620
|
}
|
|
580
621
|
|
|
622
|
+
function conversationProjectionChanged(existing, incoming) {
|
|
623
|
+
if (!existing || !incoming) return true;
|
|
624
|
+
const fields = [
|
|
625
|
+
'jobId',
|
|
626
|
+
'jobTitle',
|
|
627
|
+
'personaKey',
|
|
628
|
+
'runId',
|
|
629
|
+
'sessionId',
|
|
630
|
+
'status',
|
|
631
|
+
'managedReviewStatus',
|
|
632
|
+
'compareRunId',
|
|
633
|
+
'sourceTrigger',
|
|
634
|
+
];
|
|
635
|
+
if (fields.some((field) => (existing[field] ?? null) !== (incoming[field] ?? null))) return true;
|
|
636
|
+
const existingMessages = Array.isArray(existing.messages) ? existing.messages.length : 0;
|
|
637
|
+
const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages.length : 0;
|
|
638
|
+
if (existingMessages !== incomingMessages) return true;
|
|
639
|
+
const existingEvents = Array.isArray(existing.events) ? existing.events.length : 0;
|
|
640
|
+
const incomingEvents = Array.isArray(incoming.events) ? incoming.events.length : 0;
|
|
641
|
+
return existingEvents !== incomingEvents;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function runningProjectConversations() {
|
|
645
|
+
return projectConversations().filter((conv) => conv && conv.status === 'running' && conv.runId);
|
|
646
|
+
}
|
|
647
|
+
|
|
581
648
|
function setProjectConversations(list) {
|
|
582
649
|
const key = state.projectPath || '';
|
|
583
650
|
state.conversations[key] = list;
|
|
@@ -854,7 +921,9 @@ function renderRail() {
|
|
|
854
921
|
const list = allProjectConversations.filter((conv) =>
|
|
855
922
|
(!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
|
|
856
923
|
!isManagedDelegationChild(conv) &&
|
|
857
|
-
!AREA_SCOPED_JOBS.has(conv.jobId)
|
|
924
|
+
!AREA_SCOPED_JOBS.has(conv.jobId) &&
|
|
925
|
+
// #702: runs invoked from the Manager/Company tab surface there, not in a project.
|
|
926
|
+
conv.invokedArea !== 'manager' && conv.invokedArea !== 'company'
|
|
858
927
|
);
|
|
859
928
|
|
|
860
929
|
// Issue #550: Two-path routing for ad-hoc (freeform) conversations.
|
|
@@ -2034,11 +2103,29 @@ function renderTracker(conv) {
|
|
|
2034
2103
|
|
|
2035
2104
|
const stages = Array.isArray(conv.run.stages) ? conv.run.stages : [];
|
|
2036
2105
|
if (stages.length === 0) {
|
|
2037
|
-
|
|
2106
|
+
// Issue #711 — adhoc runs (jobId '__freeform__') declare no phases, so
|
|
2107
|
+
// there is no pizza tracker. We still surface the run totals (tokens,
|
|
2108
|
+
// cost, duration) that the server derives for every run. Keep the tracker
|
|
2109
|
+
// container visible in "totals-only" mode so renderTotals has a place to
|
|
2110
|
+
// render; otherwise hide the whole section.
|
|
2111
|
+
if (conv.run.totals) {
|
|
2112
|
+
tracker.hidden = false;
|
|
2113
|
+
tracker.classList.add('tracker--totals-only');
|
|
2114
|
+
tracker.classList.remove('tracker-compact');
|
|
2115
|
+
const rowsHost = els['tracker-rows'];
|
|
2116
|
+
if (rowsHost) rowsHost.innerHTML = '';
|
|
2117
|
+
const activeLabel = tracker.querySelector('.tracker-active-label');
|
|
2118
|
+
if (activeLabel) activeLabel.textContent = '';
|
|
2119
|
+
const note = els['tracker-note'];
|
|
2120
|
+
if (note) { note.hidden = true; note.textContent = ''; }
|
|
2121
|
+
} else {
|
|
2122
|
+
tracker.hidden = true;
|
|
2123
|
+
}
|
|
2038
2124
|
renderedTrackerKey = null;
|
|
2039
2125
|
return;
|
|
2040
2126
|
}
|
|
2041
2127
|
|
|
2128
|
+
tracker.classList.remove('tracker--totals-only');
|
|
2042
2129
|
tracker.hidden = false;
|
|
2043
2130
|
tracker.dataset.stageCount = String(stages.length);
|
|
2044
2131
|
const rowCapacity = trackerRowCapacity(tracker, stages);
|
|
@@ -4753,7 +4840,7 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
4753
4840
|
// The browser sends raw manager instructions. AI Hub normalizes assigned
|
|
4754
4841
|
// FRAIM jobs into host-facing invocations so the Hub UI and channel callers
|
|
4755
4842
|
// share one start/continue contract.
|
|
4756
|
-
async function startRun(job, instructions, employeeId, preassignedConvId) {
|
|
4843
|
+
async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
|
|
4757
4844
|
const isFreeform = job.id === '__freeform__';
|
|
4758
4845
|
// In task-pane/extension mode: get a fresh selection snapshot and prepend
|
|
4759
4846
|
// document context to the instructions so the agent knows what the user is
|
|
@@ -4792,6 +4879,10 @@ async function startRun(job, instructions, employeeId, preassignedConvId) {
|
|
|
4792
4879
|
compareRun: null,
|
|
4793
4880
|
// Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
|
|
4794
4881
|
wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
|
|
4882
|
+
// Issue #702: remember which area this run was launched from so its run surfaces
|
|
4883
|
+
// where it was invoked (project-invoked → that project; manager-invoked → Manager tab).
|
|
4884
|
+
// Defaults to the current area.
|
|
4885
|
+
invokedArea: invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects',
|
|
4795
4886
|
};
|
|
4796
4887
|
upsertConversation(conv);
|
|
4797
4888
|
state.activeId = conv.id;
|
|
@@ -4958,6 +5049,18 @@ async function continueRun(text) {
|
|
|
4958
5049
|
}
|
|
4959
5050
|
|
|
4960
5051
|
function foldRunIntoConversation(conv, run) {
|
|
5052
|
+
// Issue #710: server run identity is canonical. The browser may have
|
|
5053
|
+
// created this conversation as provisional __freeform__ state, but every
|
|
5054
|
+
// server snapshot can promote it to a real catalog job.
|
|
5055
|
+
if (run.jobId) {
|
|
5056
|
+
conv.jobId = run.jobId;
|
|
5057
|
+
}
|
|
5058
|
+
if (run.jobTitle !== undefined && run.jobTitle !== null) {
|
|
5059
|
+
conv.jobTitle = run.jobTitle;
|
|
5060
|
+
}
|
|
5061
|
+
if (run.personaKey !== undefined) {
|
|
5062
|
+
conv.personaKey = run.personaKey;
|
|
5063
|
+
}
|
|
4961
5064
|
// Issue #347: keep the latest server snapshot under conv.run so the
|
|
4962
5065
|
// tracker / totals renderers can read it without re-doing work. Stages,
|
|
4963
5066
|
// currentPhase, phaseHistory, and totals are all server-derived per
|
|
@@ -4967,6 +5070,7 @@ function foldRunIntoConversation(conv, run) {
|
|
|
4967
5070
|
currentPhase: run.currentPhase || null,
|
|
4968
5071
|
phaseHistory: run.phaseHistory || [],
|
|
4969
5072
|
totals: run.totals || null,
|
|
5073
|
+
runDiscriminant: run.runDiscriminant || null,
|
|
4970
5074
|
};
|
|
4971
5075
|
// Replace the conversation's events with the run's events (single source of truth on the server).
|
|
4972
5076
|
// If this conversation was restarted on a different agent, prepend the prior run's events so
|
|
@@ -4984,10 +5088,6 @@ function foldRunIntoConversation(conv, run) {
|
|
|
4984
5088
|
conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
|
|
4985
5089
|
// Track session for resumption.
|
|
4986
5090
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
4987
|
-
// R4: persist the persona key from the server-side run record.
|
|
4988
|
-
if (run.personaKey !== undefined && conv.personaKey == null) {
|
|
4989
|
-
conv.personaKey = run.personaKey;
|
|
4990
|
-
}
|
|
4991
5091
|
const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
|
|
4992
5092
|
if (runHandoff) {
|
|
4993
5093
|
conv.reviewHandoff = runHandoff;
|
|
@@ -6233,11 +6333,18 @@ function tfConvForAssignment(assignment) {
|
|
|
6233
6333
|
function tfConversationsForJob(jobId, opts) {
|
|
6234
6334
|
const out = [];
|
|
6235
6335
|
const projectPath = opts && opts.projectPath;
|
|
6336
|
+
// #702: optional invokedArea filter so the Manager rail shows only manager-invoked
|
|
6337
|
+
// runs of a persona job (project-invoked runs of the same job stay in the project).
|
|
6338
|
+
const invokedArea = opts && opts.invokedArea;
|
|
6236
6339
|
const lists = projectPath
|
|
6237
6340
|
? [state.conversations[projectPath] || []]
|
|
6238
6341
|
: Object.values(state.conversations || {});
|
|
6239
6342
|
for (const list of lists) {
|
|
6240
|
-
for (const c of (list || []))
|
|
6343
|
+
for (const c of (list || [])) {
|
|
6344
|
+
if (!c || c.jobId !== jobId) continue;
|
|
6345
|
+
if (invokedArea && (c.invokedArea || 'projects') !== invokedArea) continue;
|
|
6346
|
+
out.push(c);
|
|
6347
|
+
}
|
|
6241
6348
|
}
|
|
6242
6349
|
out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
|
|
6243
6350
|
return out;
|
|
@@ -8009,10 +8116,12 @@ function tfActiveOrgConv() {
|
|
|
8009
8116
|
);
|
|
8010
8117
|
}
|
|
8011
8118
|
|
|
8012
|
-
// #594 R2: return the
|
|
8119
|
+
// #594 R2: return the manager-scoped conversation to show in Manager area.
|
|
8120
|
+
// #702: manager-tab runs are those invoked from the manager area (any persona job),
|
|
8121
|
+
// plus the area-only manager-agreements job (older runs predate invokedArea). An active
|
|
8122
|
+
// manager-invoked run surfaces in the Manager conversation host (Coach panel included).
|
|
8013
8123
|
function tfActiveMgrConv() {
|
|
8014
|
-
const
|
|
8015
|
-
const convs = Object.values(state.conversations || {}).flat().filter((c) => mgrJobs.has(c.jobId));
|
|
8124
|
+
const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (c.invokedArea === 'manager' || c.jobId === 'manager-agreements'));
|
|
8016
8125
|
return (
|
|
8017
8126
|
convs.find((c) => c.status === 'running') ||
|
|
8018
8127
|
convs.find((c) => c.status === 'waiting') ||
|
|
@@ -8021,6 +8130,93 @@ function tfActiveMgrConv() {
|
|
|
8021
8130
|
);
|
|
8022
8131
|
}
|
|
8023
8132
|
|
|
8133
|
+
// #702: is a persona hired (has a company seat) so its manager-tab jobs should show?
|
|
8134
|
+
// (MANAGER_ASHLEY_JOBS is defined once at the top of this file as the single source of truth.)
|
|
8135
|
+
function tfIsPersonaHired(personaKey) {
|
|
8136
|
+
const personas = (state.bootstrap && state.bootstrap.personas) || [];
|
|
8137
|
+
const p = personas.find((x) => x && x.key === personaKey);
|
|
8138
|
+
return !!(p && (p.status === 'hired' || (p.seatCount || 0) > 0));
|
|
8139
|
+
}
|
|
8140
|
+
|
|
8141
|
+
// #702: launch a persona job from the Manager tab. Reuses the area pre-flight modal +
|
|
8142
|
+
// run routing (targetArea 'manager'), so the run opens in #manager-conv-host with the
|
|
8143
|
+
// Coach panel and is tagged invokedArea='manager' — exactly like manager-agreements.
|
|
8144
|
+
function tfStartManagerPersonaJob(jobId) {
|
|
8145
|
+
tfOpenAreaOnboardModal(jobId, 'Run ' + jobId + ' across the projects I manage and my calendar.', 'manager');
|
|
8146
|
+
}
|
|
8147
|
+
|
|
8148
|
+
// #702: run-item button matching the Projects rail (conv-item), for the manager
|
|
8149
|
+
// employee group. Mirrors the core of renderRail's buildRunButton so runs look
|
|
8150
|
+
// identical on both tabs.
|
|
8151
|
+
function tfBuildManagerRunItem(conv) {
|
|
8152
|
+
const btn = document.createElement('button');
|
|
8153
|
+
btn.className = 'conv-item';
|
|
8154
|
+
btn.type = 'button';
|
|
8155
|
+
btn.dataset.conv = conv.id;
|
|
8156
|
+
const body = document.createElement('span'); body.className = 'conv-body';
|
|
8157
|
+
const title = document.createElement('span'); title.className = 'conv-title';
|
|
8158
|
+
title.textContent = conv.title || conv.jobTitle || conv.jobId || 'Run';
|
|
8159
|
+
body.appendChild(title); btn.appendChild(body);
|
|
8160
|
+
const dotClass = conversationStateDotClass(conv);
|
|
8161
|
+
const dot = document.createElement('span'); dot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
8162
|
+
if (typeof tfDotTitle === 'function') dot.title = tfDotTitle(dotClass);
|
|
8163
|
+
btn.appendChild(dot);
|
|
8164
|
+
const status = document.createElement('span'); status.className = 'conv-status';
|
|
8165
|
+
status.textContent = conversationStateLabel(conv);
|
|
8166
|
+
status.classList.add(conversationUiState(conv));
|
|
8167
|
+
btn.appendChild(status);
|
|
8168
|
+
tfAttachRunDelete(btn, conv, { tree: false });
|
|
8169
|
+
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
8170
|
+
return btn;
|
|
8171
|
+
}
|
|
8172
|
+
|
|
8173
|
+
// #702: render a hired persona as an employee group on the Manager rail — the SAME
|
|
8174
|
+
// visual as the Projects rail employee groups (avatar + name + role + count + "+" that
|
|
8175
|
+
// opens the job palette), with the persona's MANAGER-invoked runs listed under it.
|
|
8176
|
+
// Keeps a persona looking like an employee, consistent across tabs.
|
|
8177
|
+
function tfRenderManagerPersonaGroup(rail, persona) {
|
|
8178
|
+
const sample = { personaKey: persona.key };
|
|
8179
|
+
const runs = Object.values(state.conversations || {}).flat()
|
|
8180
|
+
.filter((c) => c && c.personaKey === persona.key && c.invokedArea === 'manager')
|
|
8181
|
+
.sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
|
|
8182
|
+
const details = document.createElement('details');
|
|
8183
|
+
details.className = 'conv-employee-group';
|
|
8184
|
+
details.open = true;
|
|
8185
|
+
const summary = document.createElement('summary');
|
|
8186
|
+
summary.className = 'conv-employee-tab';
|
|
8187
|
+
const avatar = buildConversationEmployeeAvatar(sample, 'conv-employee-avatar');
|
|
8188
|
+
const copy = document.createElement('span'); copy.className = 'conv-employee-tab-copy';
|
|
8189
|
+
const label = document.createElement('strong'); label.className = 'conv-employee-tab-label';
|
|
8190
|
+
label.textContent = persona.displayName || persona.key;
|
|
8191
|
+
const detail = document.createElement('small'); detail.className = 'conv-employee-tab-detail';
|
|
8192
|
+
detail.textContent = persona.role || 'AI Employee';
|
|
8193
|
+
copy.appendChild(label); copy.appendChild(detail);
|
|
8194
|
+
const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'conv-employee-add';
|
|
8195
|
+
addBtn.textContent = '+';
|
|
8196
|
+
addBtn.title = 'Launch a job for ' + (persona.displayName || persona.key);
|
|
8197
|
+
addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
|
|
8198
|
+
addBtn.addEventListener('click', (e) => {
|
|
8199
|
+
e.preventDefault(); e.stopPropagation();
|
|
8200
|
+
// Launch from the Manager tab: palette prefiltered to this persona. Runs started
|
|
8201
|
+
// while the manager tab is active default to invokedArea='manager'.
|
|
8202
|
+
openPalette({ employeeId: (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude', prefixSearch: '/' + persona.key });
|
|
8203
|
+
});
|
|
8204
|
+
const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
|
|
8205
|
+
count.textContent = String(runs.length);
|
|
8206
|
+
summary.appendChild(avatar); summary.appendChild(copy); summary.appendChild(addBtn); summary.appendChild(count);
|
|
8207
|
+
details.appendChild(summary);
|
|
8208
|
+
const listDiv = document.createElement('div'); listDiv.className = 'conv-employee-list';
|
|
8209
|
+
if (!runs.length) {
|
|
8210
|
+
const hint = document.createElement('div'); hint.className = 'eh-empty';
|
|
8211
|
+
hint.textContent = 'No runs yet — + to launch a job.';
|
|
8212
|
+
listDiv.appendChild(hint);
|
|
8213
|
+
} else {
|
|
8214
|
+
for (const c of runs) listDiv.appendChild(tfBuildManagerRunItem(c));
|
|
8215
|
+
}
|
|
8216
|
+
details.appendChild(listDiv);
|
|
8217
|
+
rail.appendChild(details);
|
|
8218
|
+
}
|
|
8219
|
+
|
|
8024
8220
|
// Move the shared .page conversation panel into the specified area's workspace-conv host.
|
|
8025
8221
|
// All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
|
|
8026
8222
|
// because the area-conv-host elements carry the workspace-conv class.
|
|
@@ -8216,6 +8412,30 @@ function tfRenderManager() {
|
|
|
8216
8412
|
rail.appendChild(infoBtn);
|
|
8217
8413
|
// #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
|
|
8218
8414
|
// Manager agreements now runs from the "Context & rules" section it populates.
|
|
8415
|
+
// Issue #702: for each hired persona with catalog jobs, render an EMPLOYEE GROUP on
|
|
8416
|
+
// the Manager rail — the same avatar + name + runs + "+" presentation as the Projects
|
|
8417
|
+
// rail (tfRenderManagerPersonaGroup reuses conv-employee-group), so a persona looks
|
|
8418
|
+
// identical on both tabs. Data-driven from bootstrap job metadata (job.requiredPersonaKey),
|
|
8419
|
+
// not a hardcoded list. Runs launched here are tagged invokedArea='manager'; the same
|
|
8420
|
+
// employee's job launched inside a project stays in that project.
|
|
8421
|
+
const mgrPersonas = (state.bootstrap && state.bootstrap.personas) || [];
|
|
8422
|
+
const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
|
|
8423
|
+
let anyMgrEmployee = false;
|
|
8424
|
+
for (const persona of mgrPersonas) {
|
|
8425
|
+
if (!tfIsPersonaHired(persona.key)) continue;
|
|
8426
|
+
if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
|
|
8427
|
+
if (!anyMgrEmployee) {
|
|
8428
|
+
const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
|
|
8429
|
+
rail.appendChild(head);
|
|
8430
|
+
anyMgrEmployee = true;
|
|
8431
|
+
}
|
|
8432
|
+
tfRenderManagerPersonaGroup(rail, persona);
|
|
8433
|
+
}
|
|
8434
|
+
if (anyMgrEmployee) {
|
|
8435
|
+
const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
|
|
8436
|
+
pNote.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work — its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
|
|
8437
|
+
rail.appendChild(pNote);
|
|
8438
|
+
}
|
|
8219
8439
|
}
|
|
8220
8440
|
const profile = document.getElementById('manager-profile');
|
|
8221
8441
|
const learn = document.getElementById('manager-learnings');
|
|
@@ -8847,6 +9067,15 @@ const AREA_ONBOARD_CONFIG = {
|
|
|
8847
9067
|
title: 'Manager agreements',
|
|
8848
9068
|
desc: 'FRAIM will learn how you work and write manager_context.md and manager_rules.md. Add any specific direction below.',
|
|
8849
9069
|
},
|
|
9070
|
+
// Issue #702: Ashley's manager-tab jobs.
|
|
9071
|
+
'executive-assistant': {
|
|
9072
|
+
title: 'Ashley — what needs you today',
|
|
9073
|
+
desc: "Ashley reviews the projects you manage plus your calendar and briefs you on what needs your attention. Add any specific focus below.",
|
|
9074
|
+
},
|
|
9075
|
+
'weekly-operating-review': {
|
|
9076
|
+
title: 'Ashley — weekly operating review',
|
|
9077
|
+
desc: 'Ashley produces your weekly portfolio operating brief. Add any specific focus areas below.',
|
|
9078
|
+
},
|
|
8850
9079
|
};
|
|
8851
9080
|
|
|
8852
9081
|
function tfOpenAreaOnboardModal(jobId, baseMessage, area) {
|
|
@@ -8892,7 +9121,7 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
|
|
|
8892
9121
|
const finalMessage = (userContext && userContext.trim()) ? userContext.trim() : jobMessage;
|
|
8893
9122
|
if (job && typeof startRun === 'function') {
|
|
8894
9123
|
if (targetArea) tfShowArea(targetArea);
|
|
8895
|
-
await startRun(job, finalMessage, employeeId);
|
|
9124
|
+
await startRun(job, finalMessage, employeeId, undefined, targetArea);
|
|
8896
9125
|
// After run creation refresh the area panel so the conversation becomes visible.
|
|
8897
9126
|
if (targetArea === 'company') tfRenderCompany();
|
|
8898
9127
|
else if (targetArea === 'manager') tfRenderManager();
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -2157,6 +2157,17 @@ button.small { padding: 4px 10px; font-size: 12px; }
|
|
|
2157
2157
|
.totals .sep { color: var(--line); }
|
|
2158
2158
|
.totals strong { color: var(--text); font-weight: 600; }
|
|
2159
2159
|
|
|
2160
|
+
/* Issue #711 — adhoc runs have no pizza tracker; render the run totals line on
|
|
2161
|
+
its own without the empty stage rows or a redundant top divider. */
|
|
2162
|
+
.tracker--totals-only {
|
|
2163
|
+
padding: 10px 4px;
|
|
2164
|
+
}
|
|
2165
|
+
.tracker--totals-only .totals {
|
|
2166
|
+
border-top: none;
|
|
2167
|
+
padding-top: 0;
|
|
2168
|
+
margin-top: 0;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2160
2171
|
|
|
2161
2172
|
/* ── Issue #385: Team roster, Employee selector, Lock badges, Hire notice ── */
|
|
2162
2173
|
|