triflux 10.39.1 → 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/bridge.mjs CHANGED
@@ -119,6 +119,11 @@ const HUB_OPERATIONS = Object.freeze({
119
119
  action: "publish",
120
120
  httpPath: "/bridge/publish",
121
121
  },
122
+ takeoverRole: {
123
+ transport: "command",
124
+ action: "takeover_role",
125
+ httpPath: "/bridge/takeover-role",
126
+ },
122
127
  sendInput: {
123
128
  transport: "command",
124
129
  action: "send_input",
@@ -365,6 +370,7 @@ export function parseArgs(argv) {
365
370
  args: argv,
366
371
  options: {
367
372
  agent: { type: "string" },
373
+ "agent-id": { type: "string" },
368
374
  cli: { type: "string" },
369
375
  timeout: { type: "string" },
370
376
  topics: { type: "string" },
@@ -387,6 +393,7 @@ export function parseArgs(argv) {
387
393
  claim: { type: "boolean" },
388
394
  actor: { type: "string" },
389
395
  command: { type: "string" },
396
+ role: { type: "string" },
390
397
  "session-id": { type: "string" },
391
398
  reason: { type: "string" },
392
399
  type: { type: "string" },
@@ -703,6 +710,19 @@ async function cmdPublish(args) {
703
710
  return emitJson(result || unavailableResult());
704
711
  }
705
712
 
713
+ async function cmdTakeoverRole(args) {
714
+ const role = args.role || args[1] || "cto";
715
+ const agentId = args.agent || args["agent-id"] || args[2];
716
+ const outcome = await requestHub(HUB_OPERATIONS.takeoverRole, {
717
+ role,
718
+ agent_id: agentId,
719
+ reason: args.reason || "manual",
720
+ requested_by: args["requested-by"] || "bridge",
721
+ });
722
+ const result = outcome?.result;
723
+ return emitJson(result || unavailableResult());
724
+ }
725
+
706
726
  async function cmdSendInput(args) {
707
727
  const outcome = await requestHub(HUB_OPERATIONS.sendInput, {
708
728
  session_id: args["session-id"],
@@ -1554,6 +1574,8 @@ export async function main(argv = process.argv.slice(2)) {
1554
1574
  return await cmdHandoff(args);
1555
1575
  case "publish":
1556
1576
  return await cmdPublish(args);
1577
+ case "takeover-role":
1578
+ return await cmdTakeoverRole(args);
1557
1579
  case "send-input":
1558
1580
  return await cmdSendInput(args);
1559
1581
  case "context":
@@ -1610,7 +1632,7 @@ export async function main(argv = process.argv.slice(2)) {
1610
1632
  return await cmdRetryStatus(args);
1611
1633
  default:
1612
1634
  console.error(
1613
- "사용법: bridge.mjs <register|result|control|handoff|publish|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|daemon-probe|daemon-attach|daemon-interrupt|retry-run|retry-status> [--옵션]",
1635
+ "사용법: bridge.mjs <register|result|control|handoff|publish|takeover-role|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|daemon-probe|daemon-attach|daemon-interrupt|retry-run|retry-status> [--옵션]",
1614
1636
  );
1615
1637
  process.exit(1);
1616
1638
  }
@@ -4,6 +4,7 @@
4
4
  import { execSync, spawn } from "node:child_process";
5
5
  import { existsSync, readFileSync, statSync } from "node:fs";
6
6
 
7
+ import { codexProfileConfigOverrides } from "../scripts/lib/codex-profile-config.mjs";
7
8
  import { writePromptToTmpFile } from "./lib/prompt-tmp.mjs";
8
9
  import { IS_WINDOWS, killProcess } from "./platform.mjs";
9
10
 
@@ -172,7 +173,12 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
172
173
  } = opts;
173
174
 
174
175
  const parts = ["codex"];
175
- if (profile) parts.push("--profile", profile);
176
+ // Select the effort profile via `-c` config overrides instead of
177
+ // `--profile <name>`. codex 0.134+ rejects `--profile X` whenever config.toml
178
+ // still contains an inline [profiles.X] table (and codex re-injects such
179
+ // tables when it rewrites config.toml), so the `-c model=.. -c
180
+ // model_reasoning_effort=..` form is immune and mutates no config.
181
+ const profileOverrides = profile ? codexProfileConfigOverrides(profile) : [];
176
182
 
177
183
  if (FEATURES.execSubcommand) {
178
184
  parts.push("exec");
@@ -182,6 +188,8 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
182
188
  parts.push("--output-last-message", resultFile);
183
189
  }
184
190
  if (FEATURES.colorNever) parts.push("--color", "never");
191
+ for (const override of profileOverrides)
192
+ parts.push("-c", shellQuote(override));
185
193
  // NOTE: `codex exec`는 --cwd 플래그를 지원하지 않는다. Node spawn의 cwd
186
194
  // 옵션으로 child process의 working directory를 제어한다 (conductor.mjs 참조).
187
195
  // opts.cwd는 기록용으로만 받아두고 CLI command에는 반영하지 않는다.
@@ -194,6 +202,8 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
194
202
  } else {
195
203
  parts.push("--dangerously-bypass-approvals-and-sandbox");
196
204
  if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
205
+ for (const override of profileOverrides)
206
+ parts.push("-c", shellQuote(override));
197
207
  }
198
208
 
199
209
  const useStdin = resolveStdinPromptMode(stdinPrompt);
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { basename, join } from "node:path";
4
+ import { codexProfileConfigOverrides } from "../scripts/lib/codex-profile-config.mjs";
4
5
  import {
5
6
  buildExecCommand,
6
7
  createResult,
@@ -72,14 +73,21 @@ function buildAttempts(opts, preflight) {
72
73
  // ── Launch script ───────────────────────────────────────────────
73
74
 
74
75
  function createLaunchScriptText(opts) {
75
- const parts = ["codex"];
76
- if (opts.profile) parts.push("--profile", shellQuote(opts.profile));
77
- parts.push(
76
+ const parts = [
77
+ "codex",
78
78
  "exec",
79
79
  "--dangerously-bypass-approvals-and-sandbox",
80
80
  "--skip-git-repo-check",
81
- '$(cat "$PROMPT_FILE")',
82
- );
81
+ ];
82
+ // Select the effort profile via `-c` config overrides instead of
83
+ // `--profile <name>` (see buildExecCommand): codex 0.134+ rejects `--profile
84
+ // X` whenever config.toml still contains an inline [profiles.X] table.
85
+ if (opts.profile) {
86
+ for (const override of codexProfileConfigOverrides(opts.profile)) {
87
+ parts.push("-c", shellQuote(override));
88
+ }
89
+ }
90
+ parts.push('$(cat "$PROMPT_FILE")');
83
91
  return [
84
92
  "#!/usr/bin/env bash",
85
93
  "set -euo pipefail",
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">