@triflux/core 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/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/router.mjs CHANGED
@@ -4,6 +4,11 @@ import { EventEmitter, once } from "node:events";
4
4
  import { uuidv7 } from "./lib/uuidv7.mjs";
5
5
 
6
6
  const ASSIGN_PENDING_STATUSES = new Set(["queued", "running"]);
7
+ const ROLE_TOPICS = new Set(["cto"]);
8
+ const ROLE_SOURCE_RANK = Object.freeze({
9
+ explicit: 2,
10
+ "topic-fallback": 1,
11
+ });
7
12
 
8
13
  function uniqueStrings(values = []) {
9
14
  return Array.from(
@@ -13,6 +18,37 @@ function uniqueStrings(values = []) {
13
18
  );
14
19
  }
15
20
 
21
+ function normalizeRoleName(value) {
22
+ const role = String(value || "")
23
+ .trim()
24
+ .toLowerCase();
25
+ return ROLE_TOPICS.has(role) ? role : "";
26
+ }
27
+
28
+ function normalizeRoleValues(value) {
29
+ if (Array.isArray(value))
30
+ return uniqueStrings(value).map((item) => item.toLowerCase());
31
+ if (typeof value === "string") {
32
+ return uniqueStrings(value.split(/[,\s]+/u)).map((item) =>
33
+ item.toLowerCase(),
34
+ );
35
+ }
36
+ return [];
37
+ }
38
+
39
+ function rolePriority(metadata = {}, roleName = "") {
40
+ const candidates = [
41
+ metadata?.[`${roleName}_priority`],
42
+ metadata?.role_priority,
43
+ metadata?.priority,
44
+ ];
45
+ for (const value of candidates) {
46
+ const numeric = Number(value);
47
+ if (Number.isFinite(numeric)) return numeric;
48
+ }
49
+ return 0;
50
+ }
51
+
16
52
  function clampAssignDuration(
17
53
  value,
18
54
  fallback = 600000,
@@ -90,6 +126,7 @@ export function createRouter(store) {
90
126
  const runtimeTopics = new Map();
91
127
  const queuesByAgent = new Map();
92
128
  const liveMessages = new Map();
129
+ const roleStates = new Map();
93
130
  const MAX_LATENCY_SAMPLES = 100;
94
131
  let latencyIdx = 0;
95
132
  const deliveryLatencies = new Array(MAX_LATENCY_SAMPLES).fill(0);
@@ -123,6 +160,340 @@ export function createRouter(store) {
123
160
  return normalizeAgentTopics(store, agentId, runtimeTopics.get(agentId));
124
161
  }
125
162
 
163
+ function ensureRoleState(roleName) {
164
+ const role = normalizeRoleName(roleName);
165
+ if (!role) return null;
166
+ if (!roleStates.has(role)) {
167
+ roleStates.set(role, {
168
+ role,
169
+ leaderAgentId: null,
170
+ previousLeaderAgentId: null,
171
+ leaderEpoch: 0,
172
+ leaderSource: null,
173
+ status: "offline",
174
+ candidates: new Map(),
175
+ lastTransitionMs: null,
176
+ lastReason: "init",
177
+ transferredCount: 0,
178
+ });
179
+ }
180
+ return roleStates.get(role);
181
+ }
182
+
183
+ function hasRoleCapability(capabilities = [], roleName) {
184
+ const normalized = normalizeRoleValues(capabilities);
185
+ return normalized.some(
186
+ (capability) =>
187
+ capability === roleName ||
188
+ capability === `role:${roleName}` ||
189
+ capability === `${roleName}:leader` ||
190
+ capability === `${roleName}:candidate`,
191
+ );
192
+ }
193
+
194
+ function getRoleCandidateSource(agent, topics, roleName) {
195
+ if (!agent) return null;
196
+ const metadata = agent.metadata || {};
197
+ const roles = [
198
+ ...normalizeRoleValues(metadata.role),
199
+ ...normalizeRoleValues(metadata.roles),
200
+ ];
201
+ const explicit =
202
+ roles.includes(roleName) ||
203
+ metadata?.[roleName] === true ||
204
+ metadata?.is_cto === true ||
205
+ hasRoleCapability(agent.capabilities, roleName);
206
+ if (explicit) return "explicit";
207
+ return topics.includes(roleName) ? "topic-fallback" : null;
208
+ }
209
+
210
+ function buildRoleCandidate(agentId, roleName) {
211
+ const role = normalizeRoleName(roleName);
212
+ if (!agentId || !role) return null;
213
+ const agent = store.getAgent(agentId);
214
+ if (!agent) return null;
215
+ const topics = listRuntimeTopics(agentId);
216
+ const source = getRoleCandidateSource(agent, topics, role);
217
+ if (!source) return null;
218
+ return {
219
+ agent_id: agent.agent_id,
220
+ status: agent.status,
221
+ source,
222
+ priority: rolePriority(agent.metadata, role),
223
+ last_seen_ms: agent.last_seen_ms || 0,
224
+ lease_expires_ms: agent.lease_expires_ms || 0,
225
+ topics,
226
+ capabilities: agent.capabilities || [],
227
+ };
228
+ }
229
+
230
+ function refreshRoleCandidateForAgent(agentId) {
231
+ if (!agentId) return;
232
+ for (const roleName of ROLE_TOPICS) {
233
+ const role = ensureRoleState(roleName);
234
+ const candidate = buildRoleCandidate(agentId, roleName);
235
+ if (candidate) role.candidates.set(agentId, candidate);
236
+ else role.candidates.delete(agentId);
237
+ }
238
+ }
239
+
240
+ function refreshRoleCandidates(roleName) {
241
+ const role = ensureRoleState(roleName);
242
+ if (!role) return [];
243
+ const ids = new Set(role.candidates.keys());
244
+ for (const [agentId, topics] of runtimeTopics) {
245
+ if (topics.has(role.role)) ids.add(agentId);
246
+ }
247
+ for (const agent of store.getAgentsByTopic(role.role)) {
248
+ ids.add(agent.agent_id);
249
+ }
250
+ if (typeof store.listAllAgents === "function") {
251
+ for (const agent of store.listAllAgents()) ids.add(agent.agent_id);
252
+ }
253
+ for (const agentId of ids) refreshRoleCandidateForAgent(agentId);
254
+ return Array.from(role.candidates.values());
255
+ }
256
+
257
+ function isCandidateLive(candidate, now = Date.now()) {
258
+ return (
259
+ candidate?.status === "online" && Number(candidate.lease_expires_ms) > now
260
+ );
261
+ }
262
+
263
+ function sortRoleCandidates(candidates = []) {
264
+ return [...candidates].sort((left, right) => {
265
+ const sourceDelta =
266
+ (ROLE_SOURCE_RANK[right.source] || 0) -
267
+ (ROLE_SOURCE_RANK[left.source] || 0);
268
+ if (sourceDelta !== 0) return sourceDelta;
269
+ const priorityDelta =
270
+ Number(right.priority || 0) - Number(left.priority || 0);
271
+ if (priorityDelta !== 0) return priorityDelta;
272
+ const seenDelta =
273
+ Number(right.last_seen_ms || 0) - Number(left.last_seen_ms || 0);
274
+ if (seenDelta !== 0) return seenDelta;
275
+ return String(left.agent_id).localeCompare(String(right.agent_id));
276
+ });
277
+ }
278
+
279
+ function chooseRoleLeader(roleName) {
280
+ const now = Date.now();
281
+ return (
282
+ sortRoleCandidates(refreshRoleCandidates(roleName)).find((candidate) =>
283
+ isCandidateLive(candidate, now),
284
+ ) || null
285
+ );
286
+ }
287
+
288
+ function messageMatchesRole(record, roleName) {
289
+ const message = record?.message || {};
290
+ const to = message.to_agent ?? message.to;
291
+ return to === `topic:${roleName}`;
292
+ }
293
+
294
+ function roleTopicForMessage(message = {}) {
295
+ const to = message?.to_agent ?? message?.to;
296
+ if (!String(to || "").startsWith("topic:")) return "";
297
+ return normalizeRoleName(String(to).slice(6));
298
+ }
299
+
300
+ function isReplayAllowedForAgent(agentId, message = {}) {
301
+ const roleName = roleTopicForMessage(message);
302
+ if (!roleName) return true;
303
+ const targetAgentId = String(agentId || "").trim();
304
+ if (!targetAgentId) return false;
305
+ const role = getRoleSnapshot(roleName);
306
+ return role?.leader_agent_id === targetAgentId;
307
+ }
308
+
309
+ function shouldTrackWithoutRecipients(message) {
310
+ const to = message?.to_agent ?? message?.to;
311
+ if (!String(to || "").startsWith("topic:")) return false;
312
+ return ROLE_TOPICS.has(String(to).slice(6));
313
+ }
314
+
315
+ function isRoleMessageFullyHandled(record, roleName) {
316
+ if (!messageMatchesRole(record, roleName)) return true;
317
+ return (
318
+ record.recipients.size > 0 &&
319
+ record.ackedBy.size >= record.recipients.size
320
+ );
321
+ }
322
+
323
+ function isRoleRecipient(agentId, roleName, previousLeaderAgentId = null) {
324
+ if (!agentId) return false;
325
+ if (agentId === previousLeaderAgentId) return true;
326
+ return Boolean(buildRoleCandidate(agentId, roleName));
327
+ }
328
+
329
+ function countPendingForRole(roleName) {
330
+ const now = Date.now();
331
+ let count = 0;
332
+ for (const record of liveMessages.values()) {
333
+ if (!messageMatchesRole(record, roleName)) continue;
334
+ if (record.message.expires_at_ms <= now) continue;
335
+ if (isRoleMessageFullyHandled(record, roleName)) continue;
336
+ count += 1;
337
+ }
338
+ return count;
339
+ }
340
+
341
+ function transferRoleBacklog(
342
+ roleName,
343
+ newLeaderAgentId,
344
+ previousLeaderAgentId,
345
+ ) {
346
+ const role = ensureRoleState(roleName);
347
+ const now = Date.now();
348
+ const stats = { transferred_count: 0, skipped_count: 0, removed_count: 0 };
349
+ if (!role || !newLeaderAgentId) return stats;
350
+
351
+ for (const record of liveMessages.values()) {
352
+ const { message, recipients, ackedBy } = record;
353
+ if (!messageMatchesRole(record, role.role)) continue;
354
+ if (message.expires_at_ms <= now) {
355
+ stats.skipped_count += 1;
356
+ continue;
357
+ }
358
+ if (isRoleMessageFullyHandled(record, role.role)) {
359
+ stats.skipped_count += 1;
360
+ continue;
361
+ }
362
+
363
+ for (const recipient of Array.from(recipients)) {
364
+ if (
365
+ recipient !== newLeaderAgentId &&
366
+ isRoleRecipient(recipient, role.role, previousLeaderAgentId)
367
+ ) {
368
+ queuesByAgent.get(recipient)?.delete(message.id);
369
+ recipients.delete(recipient);
370
+ ackedBy.delete(recipient);
371
+ stats.removed_count += 1;
372
+ }
373
+ }
374
+
375
+ recipients.add(newLeaderAgentId);
376
+ const alreadyQueued = queuesByAgent
377
+ .get(newLeaderAgentId)
378
+ ?.has(message.id);
379
+ if (!alreadyQueued && !ackedBy.has(newLeaderAgentId)) {
380
+ queueMessage(newLeaderAgentId, message);
381
+ stats.transferred_count += 1;
382
+ } else {
383
+ stats.skipped_count += 1;
384
+ }
385
+ message.status = "delivered";
386
+ store.updateMessageStatus(message.id, "delivered");
387
+ }
388
+
389
+ role.transferredCount += stats.transferred_count;
390
+ return stats;
391
+ }
392
+
393
+ function buildRoleSnapshot(roleName, { refreshCandidates = true } = {}) {
394
+ const role = ensureRoleState(roleName);
395
+ if (!role) return null;
396
+ const now = Date.now();
397
+ const candidates = sortRoleCandidates(
398
+ refreshCandidates
399
+ ? refreshRoleCandidates(role.role)
400
+ : Array.from(role.candidates.values()),
401
+ );
402
+ const leader =
403
+ role.leaderAgentId && buildRoleCandidate(role.leaderAgentId, role.role);
404
+ const leaderLive = isCandidateLive(leader, now);
405
+ const liveCandidates = candidates.filter((candidate) =>
406
+ isCandidateLive(candidate, now),
407
+ );
408
+ return {
409
+ role: role.role,
410
+ status: leaderLive
411
+ ? "active"
412
+ : liveCandidates.length
413
+ ? "electable"
414
+ : "offline",
415
+ leader_agent_id: leaderLive ? role.leaderAgentId : null,
416
+ previous_leader_agent_id: role.previousLeaderAgentId || null,
417
+ leader_epoch: role.leaderEpoch,
418
+ lease_expires_ms: leaderLive ? leader.lease_expires_ms : null,
419
+ candidate_source: leaderLive
420
+ ? leader.source
421
+ : liveCandidates[0]?.source || null,
422
+ candidate_count: candidates.length,
423
+ live_candidate_count: liveCandidates.length,
424
+ pending_count: countPendingForRole(role.role),
425
+ transferred_count: role.transferredCount,
426
+ last_transition_ms: role.lastTransitionMs,
427
+ last_reason: role.lastReason,
428
+ candidates: candidates.slice(0, 16).map((candidate) => ({
429
+ agent_id: candidate.agent_id,
430
+ status: isCandidateLive(candidate, now) ? "online" : candidate.status,
431
+ source: candidate.source,
432
+ priority: candidate.priority,
433
+ last_seen_ms: candidate.last_seen_ms,
434
+ lease_expires_ms: candidate.lease_expires_ms,
435
+ })),
436
+ };
437
+ }
438
+
439
+ function ensureRoleLeader(
440
+ roleName,
441
+ { reason = "ensure", transferBacklog = true } = {},
442
+ ) {
443
+ const role = ensureRoleState(roleName);
444
+ if (!role) return null;
445
+ const now = Date.now();
446
+ const current =
447
+ role.leaderAgentId && buildRoleCandidate(role.leaderAgentId, role.role);
448
+ const best = chooseRoleLeader(role.role);
449
+ const currentLive = isCandidateLive(current, now);
450
+ const shouldPreemptFallback =
451
+ currentLive &&
452
+ current?.source === "topic-fallback" &&
453
+ best?.source === "explicit" &&
454
+ best.agent_id !== current.agent_id;
455
+
456
+ if (currentLive && !shouldPreemptFallback) {
457
+ role.status = "active";
458
+ role.leaderSource = current.source;
459
+ return buildRoleSnapshot(role.role);
460
+ }
461
+
462
+ if (!best) {
463
+ if (role.leaderAgentId) {
464
+ role.previousLeaderAgentId = role.leaderAgentId;
465
+ role.leaderAgentId = null;
466
+ role.leaderSource = null;
467
+ role.leaderEpoch += 1;
468
+ role.lastTransitionMs = Date.now();
469
+ role.lastReason = reason;
470
+ }
471
+ role.status = "offline";
472
+ return buildRoleSnapshot(role.role);
473
+ }
474
+
475
+ if (role.leaderAgentId !== best.agent_id) {
476
+ const previousLeaderAgentId = role.leaderAgentId;
477
+ role.previousLeaderAgentId =
478
+ previousLeaderAgentId || role.previousLeaderAgentId;
479
+ role.leaderAgentId = best.agent_id;
480
+ role.leaderSource = best.source;
481
+ role.leaderEpoch += 1;
482
+ role.status = "active";
483
+ role.lastTransitionMs = Date.now();
484
+ role.lastReason = reason;
485
+ if (transferBacklog) {
486
+ transferRoleBacklog(role.role, best.agent_id, previousLeaderAgentId);
487
+ }
488
+ }
489
+
490
+ return buildRoleSnapshot(role.role);
491
+ }
492
+
493
+ function getRoleSnapshot(roleName) {
494
+ return buildRoleSnapshot(roleName, { refreshCandidates: true });
495
+ }
496
+
126
497
  function trackMessage(message, recipients) {
127
498
  liveMessages.set(message.id, {
128
499
  message,
@@ -162,6 +533,14 @@ export function createRouter(store) {
162
533
  }
163
534
 
164
535
  const topic = to.slice(6);
536
+ if (ROLE_TOPICS.has(topic)) {
537
+ const role = ensureRoleLeader(topic, {
538
+ reason: "route",
539
+ transferBacklog: true,
540
+ });
541
+ return role?.leader_agent_id ? [role.leader_agent_id] : [];
542
+ }
543
+
165
544
  const recipients = new Set();
166
545
  for (const [agentId, topics] of runtimeTopics) {
167
546
  if (topics.has(topic)) recipients.add(agentId);
@@ -258,8 +637,10 @@ export function createRouter(store) {
258
637
  correlation_id,
259
638
  });
260
639
  const recipients = uniqueStrings(resolveRecipients(msg));
261
- if (recipients.length) {
640
+ if (recipients.length || shouldTrackWithoutRecipients(msg)) {
262
641
  trackMessage(msg, recipients);
642
+ }
643
+ if (recipients.length) {
263
644
  for (const agentId of recipients) {
264
645
  queueMessage(agentId, msg);
265
646
  }
@@ -422,15 +803,37 @@ export function createRouter(store) {
422
803
  registerAgent(args) {
423
804
  const result = store.registerAgent(args);
424
805
  upsertRuntimeTopics(args.agent_id, args.topics || [], { replace: true });
806
+ refreshRoleCandidateForAgent(args.agent_id);
807
+ for (const roleName of ROLE_TOPICS) {
808
+ ensureRoleLeader(roleName, {
809
+ reason: "register",
810
+ transferBacklog: true,
811
+ });
812
+ }
425
813
  return result;
426
814
  },
427
815
 
428
816
  refreshAgentLease(agentId, ttlMs = 30000) {
429
- return store.refreshLease(agentId, ttlMs);
817
+ const result = store.refreshLease(agentId, ttlMs);
818
+ refreshRoleCandidateForAgent(agentId);
819
+ for (const roleName of ROLE_TOPICS) {
820
+ ensureRoleLeader(roleName, {
821
+ reason: "heartbeat",
822
+ transferBacklog: true,
823
+ });
824
+ }
825
+ return result;
430
826
  },
431
827
 
432
828
  subscribeAgent(agentId, topics, { replace = false } = {}) {
433
829
  const nextTopics = upsertRuntimeTopics(agentId, topics, { replace });
830
+ refreshRoleCandidateForAgent(agentId);
831
+ for (const roleName of ROLE_TOPICS) {
832
+ ensureRoleLeader(roleName, {
833
+ reason: "subscribe",
834
+ transferBacklog: true,
835
+ });
836
+ }
434
837
  return { agent_id: agentId, topics: nextTopics };
435
838
  },
436
839
 
@@ -442,12 +845,25 @@ export function createRouter(store) {
442
845
  if (status === "offline") {
443
846
  runtimeTopics.delete(agentId);
444
847
  }
445
- return store.updateAgentStatus(agentId, status);
848
+ const updated = store.updateAgentStatus(agentId, status);
849
+ refreshRoleCandidateForAgent(agentId);
850
+ for (const roleName of ROLE_TOPICS) {
851
+ ensureRoleLeader(roleName, {
852
+ reason: `status:${status}`,
853
+ transferBacklog: true,
854
+ });
855
+ }
856
+ return updated;
446
857
  },
447
858
 
448
859
  route(msg) {
449
860
  const recipients = uniqueStrings(resolveRecipients(msg));
450
- if (!recipients.length) return 0;
861
+ if (!recipients.length) {
862
+ if (shouldTrackWithoutRecipients(msg) && !getMessageRecord(msg.id)) {
863
+ trackMessage(msg, []);
864
+ }
865
+ return 0;
866
+ }
451
867
  if (!getMessageRecord(msg.id)) {
452
868
  trackMessage(msg, recipients);
453
869
  }
@@ -462,6 +878,10 @@ export function createRouter(store) {
462
878
  return sortedPending(agentId, options);
463
879
  },
464
880
 
881
+ canReplayMessageForAgent(agentId, message) {
882
+ return isReplayAllowedForAgent(agentId, message);
883
+ },
884
+
465
885
  countPendingMessages(agentId) {
466
886
  const queue = ensureAgentQueue(agentId);
467
887
  const now = Date.now();
@@ -610,6 +1030,87 @@ export function createRouter(store) {
610
1030
  message_id: msg.id,
611
1031
  fanout_count: recipients.length,
612
1032
  expires_at_ms: msg.expires_at_ms,
1033
+ role: to === "topic:cto" ? getRoleSnapshot("cto") : undefined,
1034
+ },
1035
+ };
1036
+ },
1037
+
1038
+ takeoverRole({
1039
+ role = "cto",
1040
+ agent_id,
1041
+ reason = "manual",
1042
+ requested_by = "manual",
1043
+ } = {}) {
1044
+ const roleName = normalizeRoleName(role);
1045
+ if (!roleName) {
1046
+ return {
1047
+ ok: false,
1048
+ error: {
1049
+ code: "ROLE_NOT_SUPPORTED",
1050
+ message: `unsupported role: ${role}`,
1051
+ },
1052
+ };
1053
+ }
1054
+ const targetAgentId = String(agent_id || "").trim();
1055
+ if (!targetAgentId) {
1056
+ return {
1057
+ ok: false,
1058
+ error: {
1059
+ code: "AGENT_ID_REQUIRED",
1060
+ message: "agent_id required",
1061
+ },
1062
+ };
1063
+ }
1064
+
1065
+ refreshRoleCandidates(roleName);
1066
+ const roleState = ensureRoleState(roleName);
1067
+ const candidate = buildRoleCandidate(targetAgentId, roleName);
1068
+ if (!isCandidateLive(candidate)) {
1069
+ return {
1070
+ ok: false,
1071
+ error: {
1072
+ code: "ROLE_CANDIDATE_UNAVAILABLE",
1073
+ message: `${targetAgentId} is not a live ${roleName} candidate`,
1074
+ },
1075
+ data: {
1076
+ role: buildRoleSnapshot(roleName),
1077
+ },
1078
+ };
1079
+ }
1080
+
1081
+ const previousLeaderAgentId =
1082
+ roleState.leaderAgentId && roleState.leaderAgentId !== targetAgentId
1083
+ ? roleState.leaderAgentId
1084
+ : roleState.previousLeaderAgentId || null;
1085
+ const changed = roleState.leaderAgentId !== targetAgentId;
1086
+ if (changed) {
1087
+ roleState.previousLeaderAgentId = previousLeaderAgentId || null;
1088
+ roleState.leaderAgentId = targetAgentId;
1089
+ roleState.leaderSource = candidate.source;
1090
+ roleState.leaderEpoch += 1;
1091
+ roleState.status = "active";
1092
+ roleState.lastTransitionMs = Date.now();
1093
+ roleState.lastReason = reason || "manual";
1094
+ }
1095
+
1096
+ const transfer = transferRoleBacklog(
1097
+ roleName,
1098
+ targetAgentId,
1099
+ previousLeaderAgentId,
1100
+ );
1101
+ const snapshot = buildRoleSnapshot(roleName);
1102
+ return {
1103
+ ok: true,
1104
+ data: {
1105
+ role: roleName,
1106
+ requested_by,
1107
+ reason,
1108
+ changed,
1109
+ previous_leader_agent_id: previousLeaderAgentId,
1110
+ leader_agent_id: snapshot.leader_agent_id,
1111
+ leader_epoch: snapshot.leader_epoch,
1112
+ ...transfer,
1113
+ status: snapshot,
613
1114
  },
614
1115
  };
615
1116
  },
@@ -897,6 +1398,9 @@ export function createRouter(store) {
897
1398
  realtime_transport: "named-pipe",
898
1399
  audit_store: store.type || "sqlite",
899
1400
  };
1401
+ data.roles = {
1402
+ cto: getRoleSnapshot("cto"),
1403
+ };
900
1404
  if (include_metrics) {
901
1405
  const depths = router.getQueueDepths();
902
1406
  const stats = router.getDeliveryStats();
@@ -919,6 +1423,7 @@ export function createRouter(store) {
919
1423
  if (scope === "agent" && agent_id) {
920
1424
  const agent = store.getAgent(agent_id);
921
1425
  if (agent) {
1426
+ refreshRoleCandidateForAgent(agent_id);
922
1427
  data.agent = {
923
1428
  agent_id: agent.agent_id,
924
1429
  status: agent.status,
@@ -926,6 +1431,18 @@ export function createRouter(store) {
926
1431
  last_seen_ms: agent.last_seen_ms,
927
1432
  topics: listRuntimeTopics(agent_id),
928
1433
  };
1434
+ const roles = {};
1435
+ for (const roleName of ROLE_TOPICS) {
1436
+ const candidate = buildRoleCandidate(agent_id, roleName);
1437
+ if (candidate) {
1438
+ roles[roleName] = {
1439
+ candidate_source: candidate.source,
1440
+ priority: candidate.priority,
1441
+ leader: getRoleSnapshot(roleName)?.leader_agent_id === agent_id,
1442
+ };
1443
+ }
1444
+ }
1445
+ if (Object.keys(roles).length) data.agent.roles = roles;
929
1446
  }
930
1447
  }
931
1448
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.39.0",
3
+ "version": "10.40.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
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
  }