@triflux/remote 10.39.0 → 10.40.0
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/cto/status.mjs +11 -0
- package/hub/pipe.mjs +39 -1
- package/hub/public/tray.html +195 -98
- package/hub/server.mjs +39 -5
- package/hub/team/execution-mode.mjs +14 -1
- package/hub/tools.mjs +21 -1
- package/hub/tray-state.mjs +24 -2
- package/package.json +1 -1
- package/scripts/lib/codex-profile-config.mjs +83 -2
package/cto/status.mjs
CHANGED
|
@@ -55,6 +55,17 @@ export function deriveRepoRootFromCwd(cwd) {
|
|
|
55
55
|
if (cwd.includes("\\") || /^[A-Za-z]:/u.test(cwd)) return cwd;
|
|
56
56
|
|
|
57
57
|
const normalized = cwd.replace(/\/+$/u, "");
|
|
58
|
+
const gitWorktreeMarker = "/.worktrees/";
|
|
59
|
+
const gitWorktreeIndex = normalized.indexOf(gitWorktreeMarker);
|
|
60
|
+
if (gitWorktreeIndex > 0) {
|
|
61
|
+
const suffix = normalized.slice(
|
|
62
|
+
gitWorktreeIndex + gitWorktreeMarker.length,
|
|
63
|
+
);
|
|
64
|
+
if (/^[^/]+(?:\/|$)/u.test(suffix)) {
|
|
65
|
+
return normalized.slice(0, gitWorktreeIndex);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
const claudeMarker = "/.claude/worktrees/";
|
|
59
70
|
const claudeIndex = normalized.indexOf(claudeMarker);
|
|
60
71
|
if (claudeIndex > 0) {
|
package/hub/pipe.mjs
CHANGED
|
@@ -29,6 +29,17 @@ function normalizeTopics(topics) {
|
|
|
29
29
|
return topics.map((topic) => String(topic || "").trim()).filter(Boolean);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function normalizeReplayTopics(topics) {
|
|
33
|
+
if (Array.isArray(topics)) return normalizeTopics(topics);
|
|
34
|
+
if (typeof topics === "string") {
|
|
35
|
+
return topics
|
|
36
|
+
.split(/[,\s]+/u)
|
|
37
|
+
.map((topic) => topic.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
function getTeamBridgeMethod(methodName) {
|
|
33
44
|
const method = getTeamBridge()?.[methodName];
|
|
34
45
|
return typeof method === "function" ? method : null;
|
|
@@ -266,6 +277,12 @@ export function createPipeServer({
|
|
|
266
277
|
return result;
|
|
267
278
|
}
|
|
268
279
|
|
|
280
|
+
case "takeover_role": {
|
|
281
|
+
const result = router.takeoverRole(payload);
|
|
282
|
+
if (client) touchClient(client);
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
269
286
|
case "handoff": {
|
|
270
287
|
const result = router.handleHandoff(payload);
|
|
271
288
|
if (client) touchClient(client);
|
|
@@ -454,13 +471,34 @@ export function createPipeServer({
|
|
|
454
471
|
return pending.slice(0, maxMessages);
|
|
455
472
|
}
|
|
456
473
|
|
|
474
|
+
const requestedTopics = normalizeReplayTopics(payload.topics);
|
|
475
|
+
const auditTopics = new Set(requestedTopics);
|
|
476
|
+
if (auditTopics.size === 0) {
|
|
477
|
+
const persistedTopics = store?.getAgent?.(agentId)?.topics || [];
|
|
478
|
+
for (const topic of persistedTopics) auditTopics.add(topic);
|
|
479
|
+
if (
|
|
480
|
+
typeof router.canReplayMessageForAgent === "function" &&
|
|
481
|
+
router.canReplayMessageForAgent(agentId, {
|
|
482
|
+
to_agent: "topic:cto",
|
|
483
|
+
topic: "cto",
|
|
484
|
+
})
|
|
485
|
+
) {
|
|
486
|
+
auditTopics.add("cto");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
457
490
|
const audit = store.getAuditMessagesForAgent(agentId, {
|
|
458
491
|
max_messages: maxMessages,
|
|
459
|
-
include_topics:
|
|
492
|
+
include_topics: Array.from(auditTopics),
|
|
460
493
|
});
|
|
461
494
|
const byId = new Map();
|
|
462
495
|
for (const message of [...pending, ...audit]) {
|
|
463
496
|
if (!message?.id || byId.has(message.id)) continue;
|
|
497
|
+
if (
|
|
498
|
+
typeof router.canReplayMessageForAgent === "function" &&
|
|
499
|
+
!router.canReplayMessageForAgent(agentId, message)
|
|
500
|
+
)
|
|
501
|
+
continue;
|
|
464
502
|
byId.set(message.id, message);
|
|
465
503
|
}
|
|
466
504
|
return Array.from(byId.values())
|
package/hub/public/tray.html
CHANGED
|
@@ -259,27 +259,31 @@
|
|
|
259
259
|
`;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
262
|
+
function liveFallbackRows(live) {
|
|
263
|
+
return live.map(session => ({
|
|
264
|
+
sessionId: session.sessionId,
|
|
265
|
+
status: session.phase || 'active',
|
|
266
|
+
agent_id: session.agent_id || session.agent?.id || '',
|
|
267
|
+
agent: session.agent || null,
|
|
268
|
+
role: session.role || '',
|
|
269
|
+
roles: session.roles || [],
|
|
270
|
+
metadata: session.metadata || {},
|
|
271
|
+
promptText: session.promptText || '',
|
|
272
|
+
prompt: session.prompt || '',
|
|
273
|
+
userPrompt: session.userPrompt || '',
|
|
274
|
+
handoffPrompt: session.handoffPrompt || '',
|
|
275
|
+
command: session.command || '',
|
|
273
276
|
source: session.source || '',
|
|
274
277
|
conversationId: session.conversationId || '',
|
|
275
278
|
focusable: session.focusable !== false,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
taskSummary: session.taskSummary || session.command || (session.pid ? `PID ${session.pid}` : ''),
|
|
280
|
+
pid: session.pid || '',
|
|
281
|
+
cwd: session.cwd || session.host || 'local runtime',
|
|
282
|
+
worktreePath: session.worktreePath || '',
|
|
283
|
+
elapsed: session.elapsed || '',
|
|
284
|
+
started_at: session.started_at || null,
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
283
287
|
|
|
284
288
|
function compactText(value, fallback = '') {
|
|
285
289
|
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
@@ -293,12 +297,20 @@
|
|
|
293
297
|
return path.replace(/^\/Users\/[^/]+/, '~');
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
function normalizeProjectPath(value) {
|
|
301
|
+
const path = String(value || '').trim();
|
|
302
|
+
if (!path) return '';
|
|
303
|
+
if (path === '/' || /^\/+$/u.test(path) || /^[A-Za-z]:[\\/]$/u.test(path)) return path;
|
|
304
|
+
return path.replace(/[\\/]+$/u, '') || path;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function worktreeFamilyRoot(path) {
|
|
308
|
+
const normalized = normalizeProjectPath(path);
|
|
309
|
+
const parts = normalized.split(/[\\/]/u);
|
|
310
|
+
const index = parts.lastIndexOf('.worktrees');
|
|
311
|
+
if (index <= 0) return '';
|
|
312
|
+
return parts.slice(0, index).join(normalized.includes('\\') ? '\\' : '/') || '';
|
|
313
|
+
}
|
|
302
314
|
|
|
303
315
|
function projectName(value) {
|
|
304
316
|
const path = normalizeProjectPath(value);
|
|
@@ -306,12 +318,14 @@
|
|
|
306
318
|
return path.split(/[\\/]/u).filter(Boolean).pop() || path;
|
|
307
319
|
}
|
|
308
320
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
321
|
+
function projectPathFor(row, hub, fallback = '') {
|
|
322
|
+
const cwd = normalizeProjectPath(String(row?.cwd || row?.worktreePath || '').trim());
|
|
323
|
+
const rawRoot = normalizeProjectPath(String(hub?.projectRoot || fallback || '').trim());
|
|
324
|
+
const familyRoot = worktreeFamilyRoot(rawRoot) || rawRoot;
|
|
325
|
+
if (familyRoot && cwd && (cwd === familyRoot || cwd.startsWith(`${familyRoot}/.worktrees/`) || cwd.startsWith(`${familyRoot}/worktrees/`))) return familyRoot;
|
|
326
|
+
if (cwd && cwd !== 'local' && cwd !== 'local runtime') return worktreeFamilyRoot(cwd) || cwd;
|
|
327
|
+
return normalizeProjectPath(familyRoot || cwd || 'local');
|
|
328
|
+
}
|
|
315
329
|
|
|
316
330
|
function getProjectGroup(groups, projectPath) {
|
|
317
331
|
const key = normalizeProjectPath(projectPath) || 'local';
|
|
@@ -319,33 +333,86 @@
|
|
|
319
333
|
return groups.get(key);
|
|
320
334
|
}
|
|
321
335
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
for (const session of activeSessions) {
|
|
326
|
-
getProjectGroup(groups, projectPathFor(session, hub, fallbackProject)).ctos.push(session);
|
|
327
|
-
}
|
|
336
|
+
function ctoRoleFromPayload(cto) {
|
|
337
|
+
return cto?.roles?.cto || cto?.succession?.cto || null;
|
|
338
|
+
}
|
|
328
339
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
getProjectGroup(groups, projectPathFor(worker, hub, fallbackProject)).workers.push(worker);
|
|
333
|
-
}
|
|
340
|
+
function normalizedRoleValue(value) {
|
|
341
|
+
return String(value || '').trim().toLowerCase();
|
|
342
|
+
}
|
|
334
343
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
344
|
+
function rowHasCtoRole(row, ctoRole) {
|
|
345
|
+
const sid = String(row?.sessionId || '').trim();
|
|
346
|
+
const agentId = String(row?.agent_id || row?.agent?.id || '').trim();
|
|
347
|
+
const leader = String(ctoRole?.leader_agent_id || '').trim();
|
|
348
|
+
if (leader) return Boolean(agentId === leader || sid === leader || sid === `cto:${leader}`);
|
|
349
|
+
if (ctoRole) return false;
|
|
350
|
+
const role = normalizedRoleValue(row?.role || row?.metadata?.role || row?.sessionKind);
|
|
351
|
+
const roles = Array.isArray(row?.roles || row?.metadata?.roles)
|
|
352
|
+
? (row.roles || row.metadata.roles).map(normalizedRoleValue)
|
|
353
|
+
: String(row?.roles || row?.metadata?.roles || '').split(/[,\s]+/u).map(normalizedRoleValue);
|
|
354
|
+
if (role === 'cto' || roles.includes('cto') || row?.metadata?.cto === true) return true;
|
|
355
|
+
if (agentId === 'cto') return true;
|
|
356
|
+
if (/^cto[:._-]/iu.test(sid)) return true;
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function ctoRoleRow(ctoRole, hub, fallbackProject) {
|
|
361
|
+
if (!ctoRole) return null;
|
|
362
|
+
const status = ctoRole.status === 'active' ? 'active' : 'offline';
|
|
363
|
+
const leader = ctoRole.leader_agent_id || ctoRole.previous_leader_agent_id || 'unassigned';
|
|
364
|
+
return {
|
|
365
|
+
sessionId: ctoRole.leader_agent_id ? `cto:${ctoRole.leader_agent_id}` : `cto:${shortId(hub.id || fallbackProject || 'role')}`,
|
|
366
|
+
status,
|
|
367
|
+
cwd: hub.projectRoot || fallbackProject || 'local',
|
|
368
|
+
taskSummary: status === 'active'
|
|
369
|
+
? `CTO leader ${leader} · epoch ${ctoRole.leader_epoch || 0} · pending ${ctoRole.pending_count || 0}`
|
|
370
|
+
: `CTO offline · previous ${leader} · candidates ${ctoRole.live_candidate_count || 0}`,
|
|
343
371
|
synthetic: true,
|
|
344
|
-
|
|
372
|
+
roleStatus: ctoRole,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buildProjectGroups(activeSessions, liveRows, hub, ctoRole = null) {
|
|
377
|
+
const groups = new Map();
|
|
378
|
+
const fallbackProject = hub.projectRoot || activeSessions[0]?.cwd || activeSessions[0]?.worktreePath || 'local';
|
|
379
|
+
for (const session of activeSessions) {
|
|
380
|
+
const group = getProjectGroup(groups, projectPathFor(session, hub, fallbackProject));
|
|
381
|
+
if (rowHasCtoRole(session, ctoRole)) group.ctos.push(session);
|
|
382
|
+
else group.workers.push(session);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const activeSessionIds = new Set(
|
|
386
|
+
activeSessions
|
|
387
|
+
.map(session => session.sessionId)
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
);
|
|
390
|
+
for (const worker of liveRows) {
|
|
391
|
+
if (activeSessionIds.has(worker.sessionId)) continue;
|
|
392
|
+
const group = getProjectGroup(groups, projectPathFor(worker, hub, fallbackProject));
|
|
393
|
+
if (rowHasCtoRole(worker, ctoRole)) group.ctos.push(worker);
|
|
394
|
+
else group.workers.push(worker);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (groups.size === 0 && fallbackProject) getProjectGroup(groups, fallbackProject);
|
|
398
|
+
const roleRow = ctoRoleRow(ctoRole, hub, fallbackProject);
|
|
399
|
+
if (roleRow) {
|
|
400
|
+
const project = projectPathFor(roleRow, hub, fallbackProject);
|
|
401
|
+
const group = getProjectGroup(groups, project);
|
|
402
|
+
const leader = String(ctoRole?.leader_agent_id || '').trim();
|
|
403
|
+
const existing = group.ctos.find(row => {
|
|
404
|
+
const sid = String(row?.sessionId || '').trim();
|
|
405
|
+
const agentId = String(row?.agent_id || row?.agent?.id || '').trim();
|
|
406
|
+
return sid === roleRow.sessionId || (leader && (sid === leader || agentId === leader));
|
|
407
|
+
});
|
|
408
|
+
if (existing) {
|
|
409
|
+
existing.roleStatus = ctoRole;
|
|
410
|
+
} else {
|
|
411
|
+
group.ctos.unshift(roleRow);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return [...groups.values()].sort((a, b) => a.projectPath.localeCompare(b.projectPath));
|
|
345
415
|
}
|
|
346
|
-
}
|
|
347
|
-
return [...groups.values()].sort((a, b) => a.projectPath.localeCompare(b.projectPath));
|
|
348
|
-
}
|
|
349
416
|
|
|
350
417
|
function explicitPromptText(row) {
|
|
351
418
|
return String(row?.promptText || row?.prompt || row?.userPrompt || row?.handoffPrompt || '').trim();
|
|
@@ -443,20 +510,24 @@
|
|
|
443
510
|
`;
|
|
444
511
|
}
|
|
445
512
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
513
|
+
function renderCtoChat(ctoRow, workers) {
|
|
514
|
+
const status = ctoRow.status || ctoRow.phase || 'active';
|
|
515
|
+
const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || 'Session', 'Session');
|
|
516
|
+
const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : displayPath(ctoRow.cwd || ctoRow.worktreePath || ''), '');
|
|
517
|
+
const role = ctoRow.roleStatus || null;
|
|
518
|
+
const roleHtml = role
|
|
519
|
+
? `<div class="detail-section"><div class="detail-label">Succession</div><div class="path-text">leader ${escapeHtml(role.leader_agent_id || 'none')} · epoch ${escapeHtml(role.leader_epoch || 0)} · pending ${escapeHtml(role.pending_count || 0)} · candidates ${escapeHtml(role.live_candidate_count || 0)}/${escapeHtml(role.candidate_count || 0)}</div></div>`
|
|
520
|
+
: '';
|
|
521
|
+
const promptHtml = hasPromptText(ctoRow)
|
|
522
|
+
? `<div class="detail-section"><div class="detail-label">Prompt</div>${renderChatBubble(ctoRow, { side: 'cto' })}</div>`
|
|
523
|
+
: '';
|
|
524
|
+
const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
|
|
525
|
+
const agentsHtml = workers.length
|
|
526
|
+
? `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`
|
|
527
|
+
: '';
|
|
528
|
+
const emptyHtml = roleHtml || promptHtml || agentsHtml ? '' : '<div class="detail-empty">No session details yet</div>';
|
|
529
|
+
return `
|
|
530
|
+
<details class="session-card" data-open-key="cto:${dataAttr(ctoRow.sessionId || title)}">
|
|
460
531
|
<summary class="session-summary">
|
|
461
532
|
<span class="agent-badge">T</span>
|
|
462
533
|
<div style="min-width:0;">
|
|
@@ -465,10 +536,11 @@
|
|
|
465
536
|
</div>
|
|
466
537
|
<div class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</div>
|
|
467
538
|
</summary>
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
539
|
+
<div class="session-detail">
|
|
540
|
+
${roleHtml}
|
|
541
|
+
${promptHtml}
|
|
542
|
+
${agentsHtml}
|
|
543
|
+
${emptyHtml}
|
|
472
544
|
</div>
|
|
473
545
|
</details>
|
|
474
546
|
`;
|
|
@@ -478,14 +550,15 @@
|
|
|
478
550
|
return `${count} ${count === 1 ? one : many}`;
|
|
479
551
|
}
|
|
480
552
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
553
|
+
function renderProjectGroup(group) {
|
|
554
|
+
const projectDisplayPath = displayPath(group.projectPath);
|
|
555
|
+
const projectLabel = projectName(group.projectPath);
|
|
556
|
+
const sessionCount = group.ctos.filter(row => !isSyntheticCto(row)).length;
|
|
557
|
+
const agentCount = group.workers.length;
|
|
558
|
+
const ctoCount = group.ctos.length;
|
|
559
|
+
const projectCount = [ctoCount ? pluralize(ctoCount, 'CTO') : '', sessionCount ? pluralize(sessionCount, 'session') : '', agentCount ? pluralize(agentCount, 'agent') : '']
|
|
560
|
+
.filter(Boolean)
|
|
561
|
+
.join(' · ') || 'empty';
|
|
489
562
|
let html = `
|
|
490
563
|
<details class="project-group" data-open-key="project:${dataAttr(group.projectPath)}">
|
|
491
564
|
<summary class="project-summary">
|
|
@@ -496,26 +569,49 @@
|
|
|
496
569
|
<div class="project-path">${escapeHtml(projectDisplayPath)}</div>
|
|
497
570
|
<div class="project-body">
|
|
498
571
|
`;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
572
|
+
const ctos = group.ctos.slice(0, 6);
|
|
573
|
+
const workers = group.workers.slice(0, 10);
|
|
574
|
+
for (const [index, ctoRow] of ctos.entries()) {
|
|
575
|
+
html += renderCtoChat(ctoRow, index === 0 ? workers : []);
|
|
576
|
+
}
|
|
577
|
+
if (ctos.length === 0 && workers.length > 0) {
|
|
578
|
+
const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
|
|
579
|
+
html += `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`;
|
|
580
|
+
}
|
|
581
|
+
html += '</div></details>';
|
|
582
|
+
return html;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderCtoRoleBanner(ctoRole) {
|
|
586
|
+
if (!ctoRole) return '';
|
|
587
|
+
const status = ctoRole.status || 'offline';
|
|
588
|
+
const leader = ctoRole.leader_agent_id || ctoRole.previous_leader_agent_id || 'none';
|
|
589
|
+
return `
|
|
590
|
+
<div class="section">
|
|
591
|
+
<div class="section-title">CTO Succession</div>
|
|
592
|
+
<div class="mini-card">
|
|
593
|
+
<div style="display:flex;align-items:center;gap:8px;justify-content:space-between;">
|
|
594
|
+
<div class="mini-title"><span class="agent-badge">T</span> ${escapeHtml(leader)}</div>
|
|
595
|
+
<div class="value-pill status-text ${escapeHtml(status)}">${escapeHtml(status)}</div>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="path-text">epoch ${escapeHtml(ctoRole.leader_epoch || 0)} · pending ${escapeHtml(ctoRole.pending_count || 0)} · candidates ${escapeHtml(ctoRole.live_candidate_count || 0)}/${escapeHtml(ctoRole.candidate_count || 0)}${ctoRole.candidate_source ? ` · ${escapeHtml(ctoRole.candidate_source)}` : ''}</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
`;
|
|
601
|
+
}
|
|
507
602
|
|
|
508
603
|
function renderSessions(data) {
|
|
509
604
|
const container = document.getElementById('tab-sessions');
|
|
510
605
|
const openKeys = collectOpenDetailKeys(container);
|
|
511
606
|
const hub = data?.hub || {};
|
|
512
607
|
const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
608
|
+
const cto = data?.cto || {};
|
|
609
|
+
const ctoRole = ctoRoleFromPayload(cto);
|
|
610
|
+
const live = Array.isArray(cto.live_sessions) ? cto.live_sessions : [];
|
|
611
|
+
const runtime = data?.runtime || {};
|
|
612
|
+
const activeSessions = sessions.filter(s => s.status !== 'stale');
|
|
613
|
+
const liveRows = liveFallbackRows(live);
|
|
614
|
+
const projectGroups = buildProjectGroups(activeSessions.slice(0, 10), liveRows, hub, ctoRole);
|
|
519
615
|
const activeCount = activeSessions.filter(s => s.status === 'active').length;
|
|
520
616
|
const idleCount = activeSessions.filter(s => s.status === 'idle').length;
|
|
521
617
|
const staleCount = sessions.filter(s => s.status === 'stale').length;
|
|
@@ -530,11 +626,12 @@
|
|
|
530
626
|
</div>
|
|
531
627
|
<div class="path-text">${projectGroups.length} ${projectGroups.length === 1 ? 'workspace' : 'workspaces'} · ${cto.active_shards?.length || 0} shards · ${renderCopyChip('Hub', hub.id || String(hub.pid || ''))}</div>
|
|
532
628
|
</div>
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
629
|
+
<div class="section">
|
|
630
|
+
<div class="section-title">Agent Mix</div>
|
|
631
|
+
<div class="models-list">${renderRuntimeClients(runtime)}</div>
|
|
632
|
+
</div>
|
|
633
|
+
${renderCtoRoleBanner(ctoRole)}
|
|
634
|
+
${renderCtoHygiene(cto.hygiene)}
|
|
538
635
|
<div class="section">
|
|
539
636
|
<div class="section-title">Workspaces</div>
|
|
540
637
|
<div class="agent-view">
|
package/hub/server.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
ListToolsRequestSchema,
|
|
23
23
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
24
24
|
import { runCollect } from "../cto/collect.mjs";
|
|
25
|
+
import { resolveLakeRootDir } from "../cto/lake-root.mjs";
|
|
25
26
|
import { runStatus as runCtoStatus } from "../cto/status.mjs";
|
|
26
27
|
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
27
28
|
import {
|
|
@@ -95,6 +96,7 @@ const LOOPBACK_REMOTE_ADDRESSES = new Set([
|
|
|
95
96
|
const ALLOWED_ORIGIN_RE =
|
|
96
97
|
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
97
98
|
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
99
|
+
const CANONICAL_PROJECT_ROOT = resolveLakeRootDir(PROJECT_ROOT) || PROJECT_ROOT;
|
|
98
100
|
const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
|
|
99
101
|
const CACHE_DIR = join(homedir(), ".claude", "cache");
|
|
100
102
|
const HUB_DEFAULT_PORT = 27888;
|
|
@@ -865,7 +867,7 @@ function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
|
|
|
865
867
|
async function getTrayCtoStatus() {
|
|
866
868
|
try {
|
|
867
869
|
return await runCtoStatus(["--json"], {
|
|
868
|
-
rootDir:
|
|
870
|
+
rootDir: CANONICAL_PROJECT_ROOT,
|
|
869
871
|
stdout: { write() {} },
|
|
870
872
|
});
|
|
871
873
|
} catch (error) {
|
|
@@ -1446,12 +1448,22 @@ export async function startHub({
|
|
|
1446
1448
|
const synapseSnapshot = synapseRegistry.snapshot();
|
|
1447
1449
|
const broker = getBrokerPublicSnapshot(brokerInstance);
|
|
1448
1450
|
const runtimeStatus = getRuntimeStatus({
|
|
1449
|
-
projectRoots: [
|
|
1451
|
+
projectRoots: [
|
|
1452
|
+
...new Set([CANONICAL_PROJECT_ROOT, PROJECT_ROOT].filter(Boolean)),
|
|
1453
|
+
],
|
|
1450
1454
|
});
|
|
1451
|
-
const [ctoStatus, mcpStatus] = await Promise.all([
|
|
1455
|
+
const [ctoStatus, mcpStatus, hubStatus] = await Promise.all([
|
|
1452
1456
|
getTrayCtoStatus(),
|
|
1453
1457
|
Promise.resolve(getTrayMcpStatus()),
|
|
1458
|
+
pipe.executeQuery("status", {
|
|
1459
|
+
scope: "hub",
|
|
1460
|
+
include_metrics: true,
|
|
1461
|
+
}),
|
|
1454
1462
|
]);
|
|
1463
|
+
const mergedCtoStatus = {
|
|
1464
|
+
...(ctoStatus || {}),
|
|
1465
|
+
roles: hubStatus?.data?.roles || ctoStatus?.roles || {},
|
|
1466
|
+
};
|
|
1455
1467
|
|
|
1456
1468
|
return writeJson(
|
|
1457
1469
|
res,
|
|
@@ -1462,12 +1474,12 @@ export async function startHub({
|
|
|
1462
1474
|
pid: process.pid,
|
|
1463
1475
|
port,
|
|
1464
1476
|
url: buildHubUrl(host, port),
|
|
1465
|
-
projectRoot:
|
|
1477
|
+
projectRoot: CANONICAL_PROJECT_ROOT,
|
|
1466
1478
|
},
|
|
1467
1479
|
qos,
|
|
1468
1480
|
synapseSnapshot,
|
|
1469
1481
|
broker,
|
|
1470
|
-
ctoStatus,
|
|
1482
|
+
ctoStatus: mergedCtoStatus,
|
|
1471
1483
|
mcpStatus,
|
|
1472
1484
|
runtimeStatus,
|
|
1473
1485
|
}),
|
|
@@ -1652,6 +1664,28 @@ export async function startHub({
|
|
|
1652
1664
|
return writeJson(res, result.ok ? 200 : 400, result);
|
|
1653
1665
|
}
|
|
1654
1666
|
|
|
1667
|
+
if (path === "/bridge/takeover-role" && req.method === "POST") {
|
|
1668
|
+
const {
|
|
1669
|
+
role = "cto",
|
|
1670
|
+
agent_id,
|
|
1671
|
+
reason = "manual",
|
|
1672
|
+
requested_by = "http",
|
|
1673
|
+
} = body;
|
|
1674
|
+
if (!role || !agent_id) {
|
|
1675
|
+
return writeJson(res, 400, {
|
|
1676
|
+
ok: false,
|
|
1677
|
+
error: "role, agent_id 필수",
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
const result = await pipe.executeCommand("takeover_role", {
|
|
1681
|
+
role,
|
|
1682
|
+
agent_id,
|
|
1683
|
+
reason,
|
|
1684
|
+
requested_by,
|
|
1685
|
+
});
|
|
1686
|
+
return writeJson(res, result.ok ? 200 : 409, result);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1655
1689
|
if (path === "/bridge/register" && req.method === "POST") {
|
|
1656
1690
|
const {
|
|
1657
1691
|
agent_id,
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, resolve as pathResolve } from "node:path";
|
|
5
5
|
|
|
6
|
+
// scripts/lib is bundled into @triflux/remote itself (package.json files), so
|
|
7
|
+
// import the local mirror relatively rather than via @triflux/core. The core
|
|
8
|
+
// peer range (^10.0.0-alpha.1) could resolve to a build that predates this
|
|
9
|
+
// export, which would fail at import time; the bundled copy is version-skew
|
|
10
|
+
// safe and always carries the matching codexProfileConfigOverrides.
|
|
11
|
+
import { codexProfileConfigOverrides } from "../../scripts/lib/codex-profile-config.mjs";
|
|
6
12
|
import { whichCommand } from "@triflux/core/hub/platform.mjs";
|
|
7
13
|
|
|
8
14
|
const WIN32_EXT_PRECEDENCE = [".cmd", ".exe", ".bat", ".ps1"];
|
|
@@ -147,7 +153,6 @@ export function buildSpawnSpecForMode(mode, opts = {}) {
|
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
const args = [];
|
|
150
|
-
pushFlag(args, "--profile", opts.profile);
|
|
151
156
|
args.push(
|
|
152
157
|
"exec",
|
|
153
158
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
@@ -155,6 +160,14 @@ export function buildSpawnSpecForMode(mode, opts = {}) {
|
|
|
155
160
|
"--color",
|
|
156
161
|
"never",
|
|
157
162
|
);
|
|
163
|
+
// Select the effort profile via `-c` config overrides instead of
|
|
164
|
+
// `--profile <name>`: codex 0.134+ rejects `--profile X` whenever config.toml
|
|
165
|
+
// still contains an inline [profiles.X] table (which codex re-injects on
|
|
166
|
+
// config rewrite). These go straight to spawn argv (shell: false), so push
|
|
167
|
+
// the raw TOML-scalar overrides without shell-quoting.
|
|
168
|
+
for (const override of codexProfileConfigOverrides(opts.profile)) {
|
|
169
|
+
args.push("-c", override);
|
|
170
|
+
}
|
|
158
171
|
if (Array.isArray(opts.mcpServers)) {
|
|
159
172
|
const excludedMcpServers = new Set(
|
|
160
173
|
(Array.isArray(opts.excludeMcpServers) ? opts.excludeMcpServers : [])
|
package/hub/tools.mjs
CHANGED
|
@@ -183,6 +183,26 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
183
183
|
}),
|
|
184
184
|
},
|
|
185
185
|
|
|
186
|
+
// ── 2-1. takeover_role ──
|
|
187
|
+
{
|
|
188
|
+
name: "takeover_role",
|
|
189
|
+
description:
|
|
190
|
+
"CTO 같은 허브 역할의 active leader를 명시적으로 승계하고 미처리 역할 메시지를 새 leader에게 이전합니다",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
required: ["role", "agent_id"],
|
|
194
|
+
properties: {
|
|
195
|
+
role: { type: "string", enum: ["cto"], default: "cto" },
|
|
196
|
+
agent_id: { type: "string", pattern: "^[a-zA-Z0-9._:-]{3,64}$" },
|
|
197
|
+
reason: { type: "string" },
|
|
198
|
+
requested_by: { type: "string" },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
handler: wrap("TAKEOVER_ROLE_FAILED", (args) => {
|
|
202
|
+
return router.takeoverRole(args);
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
|
|
186
206
|
// ── 3. publish ──
|
|
187
207
|
{
|
|
188
208
|
name: "publish",
|
|
@@ -841,7 +861,7 @@ export function createTools(store, router, hitl, pipe = null) {
|
|
|
841
861
|
}),
|
|
842
862
|
},
|
|
843
863
|
|
|
844
|
-
// ──
|
|
864
|
+
// ── 22. send_input ──
|
|
845
865
|
{
|
|
846
866
|
name: "send_input",
|
|
847
867
|
description: "Send input text to a worker in INPUT_WAIT state",
|
package/hub/tray-state.mjs
CHANGED
|
@@ -49,6 +49,10 @@ function normalizeAgentId(value) {
|
|
|
49
49
|
return "";
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function rawAgentId(value) {
|
|
53
|
+
return String(value ?? "").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
function inferAgentId(value = {}) {
|
|
53
57
|
const direct = normalizeAgentId(
|
|
54
58
|
value.agent_id ??
|
|
@@ -84,7 +88,11 @@ function agentMeta(agentId) {
|
|
|
84
88
|
export function normalizeTraySessions(snapshot) {
|
|
85
89
|
return readSynapseSessions(snapshot)
|
|
86
90
|
.map((session) => {
|
|
87
|
-
const
|
|
91
|
+
const directAgentId = session?.agent_id ?? session?.agentId;
|
|
92
|
+
const agentId =
|
|
93
|
+
normalizeAgentId(directAgentId) ||
|
|
94
|
+
rawAgentId(directAgentId) ||
|
|
95
|
+
inferAgentId(session);
|
|
88
96
|
return {
|
|
89
97
|
sessionId: String(session?.sessionId || ""),
|
|
90
98
|
host: typeof session?.host === "string" ? session.host : "local",
|
|
@@ -368,7 +376,11 @@ function runtimeToLiveOverlay(runtime) {
|
|
|
368
376
|
}
|
|
369
377
|
|
|
370
378
|
function normalizeLiveSession(session = {}) {
|
|
371
|
-
const
|
|
379
|
+
const directAgentId = session.agent_id ?? session.agentId;
|
|
380
|
+
const agentId =
|
|
381
|
+
normalizeAgentId(directAgentId) ||
|
|
382
|
+
rawAgentId(directAgentId) ||
|
|
383
|
+
inferAgentId(session);
|
|
372
384
|
return {
|
|
373
385
|
...session,
|
|
374
386
|
sessionId: String(session.sessionId || ""),
|
|
@@ -460,11 +472,21 @@ function normalizeCtoStatus(ctoStatus = {}, sessions = [], runtime = {}) {
|
|
|
460
472
|
? ctoStatus.active_shards
|
|
461
473
|
: [];
|
|
462
474
|
const hygiene = normalizeCtoHygiene(ctoStatus);
|
|
475
|
+
const roles =
|
|
476
|
+
ctoStatus?.roles && typeof ctoStatus.roles === "object"
|
|
477
|
+
? ctoStatus.roles
|
|
478
|
+
: null;
|
|
479
|
+
const succession =
|
|
480
|
+
ctoStatus?.succession && typeof ctoStatus.succession === "object"
|
|
481
|
+
? ctoStatus.succession
|
|
482
|
+
: null;
|
|
463
483
|
return {
|
|
464
484
|
schema_version: String(ctoStatus?.schema_version || "cto-lake.v1"),
|
|
465
485
|
generated_at: ctoStatus?.generated_at || null,
|
|
466
486
|
live_sessions: normalizedLiveSessions,
|
|
467
487
|
active_shards: activeShards,
|
|
488
|
+
...(roles ? { roles } : {}),
|
|
489
|
+
...(succession ? { succession } : {}),
|
|
468
490
|
...(hygiene ? { hygiene } : {}),
|
|
469
491
|
summary: {
|
|
470
492
|
live_session_count: normalizedLiveSessions.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
2
9
|
import { dirname, join } from "node:path";
|
|
3
10
|
|
|
11
|
+
const EFFORT_BY_SUFFIX = {
|
|
12
|
+
xhigh: "xhigh",
|
|
13
|
+
high: "high",
|
|
14
|
+
med: "medium",
|
|
15
|
+
medium: "medium",
|
|
16
|
+
low: "low",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function readProfileScalar(raw, key) {
|
|
20
|
+
const match = String(raw).match(
|
|
21
|
+
new RegExp(
|
|
22
|
+
`^\\s*${key}\\s*=\\s*(?:"([^"\\n]*)"|'([^'\\n]*)'|([^#\\n]*?))\\s*(?:#.*)?$`,
|
|
23
|
+
"m",
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
28
|
+
return value || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a Codex effort profile name (e.g. "gpt55_high") to the `-c key=value`
|
|
33
|
+
* config overrides that `codex exec` accepts, so the headless CLI lanes can
|
|
34
|
+
* select a profile WITHOUT `codex exec --profile <name>`. codex 0.134+ rejects
|
|
35
|
+
* `--profile X` whenever config.toml still contains an inline `[profiles.X]`
|
|
36
|
+
* table (which codex itself re-injects when it rewrites config.toml), so the
|
|
37
|
+
* `-c` form is immune and needs no config mutation / sanitize race.
|
|
38
|
+
*
|
|
39
|
+
* SSOT = the per-profile file `~/.codex/<name>.config.toml` written by
|
|
40
|
+
* `tfx setup`; falls back to deriving the effort from the `<...>_<effort>`
|
|
41
|
+
* naming convention when the file is absent. Returns the keys that resolve, in
|
|
42
|
+
* `key="value"` TOML-scalar form ready to be shell-quoted by the caller.
|
|
43
|
+
*
|
|
44
|
+
* NOTE: the Codex MCP transport does the structured-arg equivalent in
|
|
45
|
+
* hub/workers/codex-mcp.mjs (resolveCodexProfileConfig → model + config object);
|
|
46
|
+
* this returns the CLI `-c` string form for the same per-profile file.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} profileName
|
|
49
|
+
* @param {{ codexHome?: string }} [opts]
|
|
50
|
+
* @returns {string[]} e.g. ['model="gpt-5.5"', 'model_reasoning_effort="high"']
|
|
51
|
+
*/
|
|
52
|
+
export function codexProfileConfigOverrides(profileName, opts = {}) {
|
|
53
|
+
if (typeof profileName !== "string" || !profileName) return [];
|
|
54
|
+
const codexHome =
|
|
55
|
+
opts.codexHome || process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
56
|
+
let model = null;
|
|
57
|
+
let effort = null;
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(
|
|
60
|
+
join(codexHome, `${profileName}.config.toml`),
|
|
61
|
+
"utf8",
|
|
62
|
+
);
|
|
63
|
+
model = readProfileScalar(raw, "model");
|
|
64
|
+
effort = readProfileScalar(raw, "model_reasoning_effort");
|
|
65
|
+
} catch {
|
|
66
|
+
const suffix = /_(xhigh|high|med|medium|low)$/.exec(profileName);
|
|
67
|
+
if (suffix) effort = EFFORT_BY_SUFFIX[suffix[1]] || null;
|
|
68
|
+
}
|
|
69
|
+
const overrides = [];
|
|
70
|
+
if (model) overrides.push(`model="${model}"`);
|
|
71
|
+
if (effort) overrides.push(`model_reasoning_effort="${effort}"`);
|
|
72
|
+
return overrides;
|
|
73
|
+
}
|
|
74
|
+
|
|
4
75
|
const PROFILE_SECTION_PREFIX = "profiles.";
|
|
5
76
|
const SAFE_PROFILE_FILE_RE = /^[A-Za-z0-9_.-]+$/;
|
|
6
77
|
|
|
@@ -137,6 +208,16 @@ export function sanitizeCodexProfileConfigFile(
|
|
|
137
208
|
const sanitized = sanitizeCodexProfileConfig(source, { codexHome });
|
|
138
209
|
if (!sanitized.changed) return sanitized;
|
|
139
210
|
writeFileSync(`${configPath}.${backupPrefix}-${stamp(now)}`, source, "utf8");
|
|
140
|
-
|
|
211
|
+
// Atomic replace. config.toml is shared global state: a codex session may be
|
|
212
|
+
// reading it while another entry point (tfx-route, the codex session hook, or
|
|
213
|
+
// a parallel swarm worker) sanitizes it. Write the sanitized content to a
|
|
214
|
+
// unique temp file in the same directory, then rename — rename is atomic on
|
|
215
|
+
// the same filesystem, so no reader ever observes a torn/half-written config.
|
|
216
|
+
// Concurrent sanitizers each rename their own complete temp; last writer wins
|
|
217
|
+
// without corruption (the content is identical anyway since the migration is
|
|
218
|
+
// deterministic). The pid keeps temp names unique across separate processes.
|
|
219
|
+
const tmpPath = `${configPath}.tmp-${process.pid}-${stamp(now)}`;
|
|
220
|
+
writeFileSync(tmpPath, sanitized.toml, "utf8");
|
|
221
|
+
renameSync(tmpPath, configPath);
|
|
141
222
|
return sanitized;
|
|
142
223
|
}
|