@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 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: payload.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())
@@ -259,27 +259,31 @@
259
259
  `;
260
260
  }
261
261
 
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
- promptText: session.promptText || '',
269
- prompt: session.prompt || '',
270
- userPrompt: session.userPrompt || '',
271
- handoffPrompt: session.handoffPrompt || '',
272
- command: session.command || '',
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
- taskSummary: session.taskSummary || session.command || (session.pid ? `PID ${session.pid}` : ''),
277
- pid: session.pid || '',
278
- cwd: session.cwd || session.host || 'local runtime',
279
- elapsed: session.elapsed || '',
280
- started_at: session.started_at || null,
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
- function normalizeProjectPath(value) {
297
- const path = String(value || '').trim();
298
- if (!path) return '';
299
- if (path === '/' || /^\/+$/u.test(path) || /^[A-Za-z]:[\\/]$/u.test(path)) return path;
300
- return path.replace(/[\\/]+$/u, '') || path;
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
- function projectPathFor(row, hub, fallback = '') {
310
- const cwd = String(row?.cwd || row?.worktreePath || '').trim();
311
- if (cwd && cwd !== 'local' && cwd !== 'local runtime') return normalizeProjectPath(cwd);
312
- const root = String(hub?.projectRoot || fallback || '').trim();
313
- return normalizeProjectPath(root || cwd || 'local');
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
- function buildProjectGroups(activeSessions, liveRows, hub) {
323
- const groups = new Map();
324
- const fallbackProject = hub.projectRoot || activeSessions[0]?.cwd || activeSessions[0]?.worktreePath || 'local';
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
- const ctoIds = new Set(activeSessions.map(session => session.sessionId).filter(Boolean));
330
- for (const worker of liveRows) {
331
- if (ctoIds.has(worker.sessionId)) continue;
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
- if (groups.size === 0 && fallbackProject) getProjectGroup(groups, fallbackProject);
336
- for (const group of groups.values()) {
337
- if (group.ctos.length === 0 && group.workers.length > 0) {
338
- group.ctos.push({
339
- sessionId: `cto-${shortId(hub.id || group.projectPath)}`,
340
- status: 'active',
341
- cwd: group.projectPath,
342
- taskSummary: 'CTO runtime overlay',
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
- function renderCtoChat(ctoRow, workers) {
447
- const status = ctoRow.status || ctoRow.phase || 'active';
448
- const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || 'Session', 'Session');
449
- const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : displayPath(ctoRow.cwd || ctoRow.worktreePath || ''), '');
450
- const promptHtml = hasPromptText(ctoRow)
451
- ? `<div class="detail-section"><div class="detail-label">Prompt</div>${renderChatBubble(ctoRow, { side: 'cto' })}</div>`
452
- : '';
453
- const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
454
- const agentsHtml = workers.length
455
- ? `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`
456
- : '';
457
- const emptyHtml = promptHtml || agentsHtml ? '' : '<div class="detail-empty">No session details yet</div>';
458
- return `
459
- <details class="session-card" data-open-key="cto:${dataAttr(ctoRow.sessionId || title)}">
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
- <div class="session-detail">
469
- ${promptHtml}
470
- ${agentsHtml}
471
- ${emptyHtml}
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
- function renderProjectGroup(group) {
482
- const projectDisplayPath = displayPath(group.projectPath);
483
- const projectLabel = projectName(group.projectPath);
484
- const sessionCount = group.ctos.filter(row => !isSyntheticCto(row)).length;
485
- const agentCount = group.workers.length;
486
- const projectCount = [sessionCount ? pluralize(sessionCount, 'session') : '', agentCount ? pluralize(agentCount, 'agent') : '']
487
- .filter(Boolean)
488
- .join(' · ') || 'empty';
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
- const ctos = group.ctos.slice(0, 6);
500
- const workers = group.workers.slice(0, 10);
501
- for (const [index, ctoRow] of ctos.entries()) {
502
- html += renderCtoChat(ctoRow, index === 0 ? workers : []);
503
- }
504
- html += '</div></details>';
505
- return html;
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
- const cto = data?.cto || {};
514
- const live = Array.isArray(cto.live_sessions) ? cto.live_sessions : [];
515
- const runtime = data?.runtime || {};
516
- const activeSessions = sessions.filter(s => s.status !== 'stale');
517
- const liveRows = liveFallbackRows(live);
518
- const projectGroups = buildProjectGroups(activeSessions.slice(0, 10), liveRows, hub);
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
- <div class="section">
534
- <div class="section-title">Agent Mix</div>
535
- <div class="models-list">${renderRuntimeClients(runtime)}</div>
536
- </div>
537
- ${renderCtoHygiene(cto.hygiene)}
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: PROJECT_ROOT,
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: [PROJECT_ROOT],
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: PROJECT_ROOT,
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
- // ── 21. send_input ──
864
+ // ── 22. send_input ──
845
865
  {
846
866
  name: "send_input",
847
867
  description: "Send input text to a worker in INPUT_WAIT state",
@@ -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 agentId = inferAgentId(session);
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 agentId = inferAgentId(session);
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,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.39.0",
3
+ "version": "10.40.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -1,6 +1,77 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- writeFileSync(configPath, sanitized.toml, "utf8");
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
  }