triflux 10.46.0 → 10.47.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.
@@ -13,6 +13,7 @@
13
13
  import { execFile, execFileSync } from "node:child_process";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import { resolveRoleControlSnapshot } from "../hub/lib/cto-env.mjs";
16
17
  import {
17
18
  buildSynapseTaskSummary,
18
19
  drainPendingSynapse,
@@ -95,6 +96,11 @@ async function defaultResolveParticipantLakeRoot(cwd) {
95
96
  */
96
97
  export async function emitParticipantSessionStarted(stdinData, seams = {}) {
97
98
  try {
99
+ if (
100
+ !resolveRoleControlSnapshot(seams.env || process.env).auto_collect_enabled
101
+ ) {
102
+ return null;
103
+ }
98
104
  const payload = parseStartPayload(stdinData);
99
105
  const sessionId = String(payload?.session_id || "").trim();
100
106
  if (!sessionId) return null;
@@ -38,10 +38,9 @@ export function resolveRoleControlSnapshot(env = process.env, options = {}) {
38
38
  const ctoManagerEnabled = masterEnabled && isEnvOn(env, "TFX_CTO_MANAGER");
39
39
  const leadManagerEnabled =
40
40
  ctoManagerEnabled && isEnvOn(env, "TFX_LEAD_MANAGER");
41
- const northStarEnabled =
42
- masterEnabled && !isEnvOff(env, "TFX_CTO_NORTH_STAR");
41
+ const northStarEnabled = masterEnabled && isEnvOn(env, "TFX_CTO_NORTH_STAR");
43
42
  const autoCollectEnabled =
44
- masterEnabled && !isEnvOff(env, "TFX_CTO_AUTO_COLLECT");
43
+ masterEnabled && isEnvOn(env, "TFX_CTO_AUTO_COLLECT");
45
44
  const values = {
46
45
  master_enabled: masterEnabled,
47
46
  cto_manager_enabled: ctoManagerEnabled,
package/hub/server.mjs CHANGED
@@ -72,9 +72,7 @@ import {
72
72
  } from "./state.mjs";
73
73
  import { createStoreAdapter } from "./store-adapter.mjs";
74
74
  import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
75
- import { createGitPreflight } from "./team/git-preflight.mjs";
76
75
  import { nativeProxy } from "./team/nativeProxy.mjs";
77
- import { createSwarmLocks } from "./team/swarm-locks.mjs";
78
76
  import {
79
77
  createSynapseRegistry,
80
78
  projectPeer,
@@ -119,14 +117,6 @@ const AIMD_WINDOW_MS = 30 * 60 * 1000;
119
117
  const AIMD_INITIAL_BATCH_SIZE = 3;
120
118
  const AIMD_MIN_BATCH_SIZE = 1;
121
119
  const AIMD_MAX_BATCH_SIZE = 10;
122
- const SYNAPSE_VALID_OPS = new Set([
123
- "checkout",
124
- "rebase",
125
- "cherry-pick",
126
- "reset",
127
- "stash-pop",
128
- "worktree-remove",
129
- ]);
130
120
  const HUB_IDLE_TIMEOUT_DEFAULT_MS = 30 * 60 * 1000;
131
121
  const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
132
122
  const STATIC_CONTENT_TYPES = Object.freeze({
@@ -1124,21 +1114,13 @@ export async function startHub({
1124
1114
  }
1125
1115
  const delegatorService = new DelegatorService({ worker: delegatorWorker });
1126
1116
 
1127
- // Synapse Layer 4: session registry + git preflight + swarm locks
1117
+ // Synapse Layer 4: session registry
1128
1118
  const synapseEmitter = new EventEmitter();
1129
1119
  synapseEmitter.setMaxListeners(50);
1130
1120
  const synapseRegistry = createSynapseRegistry({
1131
1121
  persistPath: join(CACHE_DIR, "tfx-hub", "synapse-sessions.json"),
1132
1122
  emitter: synapseEmitter,
1133
1123
  });
1134
- const swarmLocks = createSwarmLocks({
1135
- repoRoot: PROJECT_ROOT,
1136
- persistPath: join(CACHE_DIR, "tfx-hub", "swarm-locks.json"),
1137
- });
1138
- const gitPreflight = createGitPreflight({
1139
- registry: synapseRegistry,
1140
- locks: swarmLocks,
1141
- });
1142
1124
  const ctoAutoCollector = createCtoAutoCollector({
1143
1125
  registry: synapseRegistry,
1144
1126
  runCollect,
@@ -1437,7 +1419,7 @@ export async function startHub({
1437
1419
  });
1438
1420
  }
1439
1421
 
1440
- // ── Synapse Layer 5: session registry + locks + preflight routes ──
1422
+ // ── Synapse Layer 5: session registry routes ──
1441
1423
  // Admin/raw snapshot (loopback-only). Returns raw cwd/pid for local
1442
1424
  // admin/HUD use; the redacted peer surface is GET /synapse/peers.
1443
1425
  if (path === "/synapse/sessions" && req.method === "GET") {
@@ -1642,37 +1624,6 @@ export async function startHub({
1642
1624
  }
1643
1625
  }
1644
1626
 
1645
- if (path === "/synapse/locks" && req.method === "GET") {
1646
- return writeJson(res, 200, {
1647
- ok: true,
1648
- locks: swarmLocks.snapshot(),
1649
- ts: Date.now(),
1650
- });
1651
- }
1652
-
1653
- if (path === "/synapse/preflight" && req.method === "POST") {
1654
- try {
1655
- const body = await parseBody(req);
1656
- const { op, args = {}, sessionContext = {} } = body;
1657
- if (!op || typeof op !== "string") {
1658
- return writeJson(res, 400, { ok: false, error: "op 필수" });
1659
- }
1660
- if (!SYNAPSE_VALID_OPS.has(op)) {
1661
- return writeJson(res, 400, {
1662
- ok: false,
1663
- error: `invalid op: ${op}`,
1664
- });
1665
- }
1666
- const result = gitPreflight.check(op, args, sessionContext);
1667
- return writeJson(res, 200, { ok: true, ...result });
1668
- } catch (err) {
1669
- return writeJson(res, 400, {
1670
- ok: false,
1671
- error: String(err?.message || err),
1672
- });
1673
- }
1674
- }
1675
-
1676
1627
  if (path.startsWith("/bridge")) {
1677
1628
  const isBridgeStatusGet =
1678
1629
  path === "/bridge/status" && req.method === "GET";
@@ -1,6 +1,6 @@
1
1
  // hub/team/recovery-store.mjs — Best-effort preservation of worker changes
2
2
  // before a dirty worktree is removed. Uses file-based `.patch` + JSON manifest
3
- // instead of git stash (git-preflight flags stash-pop/worktree-remove as risky).
3
+ // instead of git stash, which is risky to pop under concurrent worktree use.
4
4
 
5
5
  import { execFile } from "node:child_process";
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -48,9 +48,9 @@ function hasDirtyFiles(session) {
48
48
  // A session is "live" while active OR idle. Idle is an interactive session that
49
49
  // missed its heartbeat interval but is still under the TTL — alive but inactive,
50
50
  // not presumed dead. getActive() and querySessions() share this single predicate
51
- // so the liveness contract can't drift: getActive() feeds git-preflight's
52
- // dirty-file conflict guard, so dropping idle there would hide a still-live
53
- // interactive session's claimed paths from that safety check.
51
+ // so the liveness contract can't drift: getActive() feeds peer discovery,
52
+ // CTO status/hygiene and log retention, so dropping idle there would hide a
53
+ // still-live interactive session from every one of those consumers.
54
54
  function isLiveStatus(status) {
55
55
  return status === "active" || status === "idle";
56
56
  }
@@ -337,7 +337,7 @@ export function createSynapseRegistry(opts = {}) {
337
337
  // session resuming: Claude Code re-fires SessionStart with the same
338
338
  // session_id on resume/clear/compact, so fall through and re-register
339
339
  // (revive) it. Otherwise the resumed-but-live session stays stale forever
340
- // and vanishes from peer-discovery AND git-preflight's dirty-file guard.
340
+ // and vanishes from peer-discovery.
341
341
  const existing = sessions.get(sessionId);
342
342
  if (existing && isLiveStatus(existing.status)) {
343
343
  console.warn(
@@ -381,10 +381,13 @@ export function createSynapseRegistry(opts = {}) {
381
381
  }
382
382
 
383
383
  // stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
384
- // live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
385
- // Dirty stale rows keep the historical 24h window because same-id resume
386
- // revives their dirty-file guard; clean stale rows expire quickly to avoid
387
- // daily dummy rows after overnight operator gaps.
384
+ // live(active/idle) rows survive a long-stale lastHeartbeat because peer
385
+ // discovery and CTO status/hygiene still read them.
386
+ // Dirty stale rows keep the historical 24h window. Its original consumer,
387
+ // the git-preflight dirty-file guard, is gone and nothing populates
388
+ // dirtyFiles today, so this split is a candidate for collapsing into one
389
+ // window. Clean stale rows expire quickly to avoid daily dummy rows after
390
+ // overnight operator gaps.
388
391
  function pruneExpired(opts2 = {}) {
389
392
  if (destroyed) return { removed: [], count: 0 };
390
393
 
@@ -510,9 +513,9 @@ export function createSynapseRegistry(opts = {}) {
510
513
  }
511
514
 
512
515
  function getActive() {
513
- // Live = active or idle. git-preflight uses this to detect other live
514
- // sessions whose dirty files would conflict; an idle interactive session is
515
- // still live (process alive, dirty files on disk) and must stay visible.
516
+ // Live = active or idle. Peer discovery, CTO status/hygiene and log
517
+ // retention read this list; an idle interactive session is still live
518
+ // (process alive) and must stay visible to them.
516
519
  return [...sessions.values()]
517
520
  .filter((session) => isLiveStatus(session.status))
518
521
  .map((session) => cloneSession(session));
@@ -273,6 +273,8 @@ export async function ensureWorktree({
273
273
  * @param {string} opts.baseBranch
274
274
  * @param {string} [opts.rootDir=process.cwd()]
275
275
  * @returns {Promise<{ integrationBranch: string, baseCommit: string }>}
276
+ * @throws {Error} when the integration branch is checked out by another
277
+ * worktree; this is an intentional NO-GO and leaves that worktree untouched.
276
278
  */
277
279
  export async function prepareIntegrationBranch({
278
280
  runId,
@@ -299,42 +301,17 @@ export async function prepareIntegrationBranch({
299
301
  * Rebase a shard branch onto the integration branch.
300
302
  * Uses rebase + fast-forward only (no merge commits).
301
303
  *
302
- * Synapse v1: when a `preflight` checker + `sessionContext` are provided,
303
- * the rebase is pre-flighted against active sessions' dirty files/leases.
304
- * Blocked rebases return `{ ok: false, preflight: decision }` without touching
305
- * any branch. If preflight is omitted, behavior is unchanged.
306
- *
307
304
  * @param {object} opts
308
305
  * @param {string} opts.shardBranch — the shard's branch name
309
306
  * @param {string} opts.integrationBranch — target integration branch
310
307
  * @param {string} [opts.rootDir=process.cwd()]
311
- * @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
312
- * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
313
- * @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string, preflight?: object }>}
308
+ * @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string }>}
314
309
  */
315
310
  export async function rebaseShardOntoIntegration({
316
311
  shardBranch,
317
312
  integrationBranch,
318
313
  rootDir = process.cwd(),
319
- preflight = null,
320
- sessionContext = null,
321
314
  }) {
322
- // Synapse v1 pre-flight: bail out before mutating branches when another
323
- // session claims overlapping files.
324
- if (preflight && sessionContext) {
325
- const decision = preflight.checkRebase(
326
- { branch: integrationBranch },
327
- sessionContext,
328
- );
329
- if (!decision.allowed) {
330
- return {
331
- ok: false,
332
- error: `git-preflight blocked rebase: ${decision.reason || "overlap"}`,
333
- preflight: decision,
334
- };
335
- }
336
- }
337
-
338
315
  // #127 BUG-E accident history: this function used to checkout the
339
316
  // integration branch in rootDir, leak rootDir HEAD, and redirect later user
340
317
  // edits/commits into swarm/.../merge. Integration now happens exclusively in
@@ -458,6 +435,11 @@ export async function rebaseShardOntoIntegration({
458
435
  }
459
436
  } else {
460
437
  await rm(integrationWorktree, { recursive: true, force: true });
438
+ try {
439
+ await git(["worktree", "prune"], rootDir);
440
+ } catch {
441
+ /* best-effort metadata cleanup after a partial worktree add */
442
+ }
461
443
  }
462
444
  }
463
445
  }
@@ -524,40 +506,18 @@ export async function cleanupWorktree({
524
506
  * Remove a worktree and its branch.
525
507
  * Follows WT race-guard: sleep between operations.
526
508
  *
527
- * Synapse v1: when `preflight` + `sessionContext` are provided, the removal
528
- * is pre-flighted against active sessions — blocked if another session is
529
- * still registered to this worktree path.
530
- *
531
509
  * @param {object} opts
532
510
  * @param {string} opts.worktreePath
533
511
  * @param {string} [opts.branchName] — optional branch to delete
534
512
  * @param {string} [opts.rootDir=process.cwd()]
535
513
  * @param {boolean} [opts.force=false]
536
- * @param {{ checkWorktreeRemove: Function } | null} [opts.preflight=null]
537
- * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
538
514
  */
539
515
  export async function pruneWorktree({
540
516
  worktreePath,
541
517
  branchName,
542
518
  rootDir = process.cwd(),
543
519
  force = false,
544
- preflight = null,
545
- sessionContext = null,
546
520
  }) {
547
- if (preflight && sessionContext) {
548
- const decision = preflight.checkWorktreeRemove(
549
- { worktreePath },
550
- sessionContext,
551
- );
552
- if (!decision.allowed) {
553
- return {
554
- ok: false,
555
- error: `git-preflight blocked worktree-remove: ${decision.reason || "active_worktree"}`,
556
- preflight: decision,
557
- };
558
- }
559
- }
560
-
561
521
  const cleanupResult = await cleanupWorktree({
562
522
  worktreePath,
563
523
  branchName,
@@ -120,7 +120,7 @@ export function spawnTrayForHub({
120
120
  cwd = process.cwd(),
121
121
  spawnFn = spawn,
122
122
  } = {}) {
123
- if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
123
+ if (env?.TFX_HUB_AUTO_TRAY !== "1") return { status: "disabled" };
124
124
  if (isWorktreeOrEphemeralHubContext({ cwd, env })) {
125
125
  return { status: "disabled", reason: "ephemeral-or-worktree-context" };
126
126
  }
@@ -38,6 +38,8 @@ export function classifyBucket(bucket) {
38
38
 
39
39
  // primary/secondary를 window_minutes 기준으로 정규화
40
40
  // HUD 규약: primary=5h, secondary=1w
41
+ // window_minutes 가 비어 있으면 길이를 모르므로 보고된 칸에 둔다(app-server 스키마에서
42
+ // windowDurationMins 는 선택 필드). 길이를 알지만 5h/1w 가 아닌 버킷은 버린다.
41
43
  export function normalizeBuckets(rl) {
42
44
  let primary = null;
43
45
  let secondary = null;
@@ -46,6 +48,12 @@ export function normalizeBuckets(rl) {
46
48
  if (kind === "five_hour") primary = bucket;
47
49
  else if (kind === "weekly") secondary = bucket;
48
50
  }
51
+ if (!primary && rl.primary && rl.primary.window_minutes == null) {
52
+ primary = rl.primary;
53
+ }
54
+ if (!secondary && rl.secondary && rl.secondary.window_minutes == null) {
55
+ secondary = rl.secondary;
56
+ }
49
57
  return { primary, secondary };
50
58
  }
51
59
 
@@ -104,7 +112,35 @@ function isNewerSnapshot(candidate, current) {
104
112
  );
105
113
  }
106
114
 
107
- function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
115
+ function positiveWindowMinutes(bucket) {
116
+ const minutes = Number(bucket?.window_minutes);
117
+ return minutes > 0 ? minutes : null;
118
+ }
119
+
120
+ // 계정 하나에는 같은 종류의 창이 한 번에 하나만 열린다. 브로커 계정이 없으면
121
+ // ~/.codex/sessions 와 HUD 라벨이 모두 ~/.codex/auth.json 계정 하나의 것이다. 그래서
122
+ // 세션 로그에 더 늦게 리셋되는 창이 나타났고 옛 창이 그 창이 열리기 전에만 관측됐다면,
123
+ // 옛 창은 조기 리셋(2026-09-23 실측)이나 계정 전환으로 끝난 창이다. 프로브로만 본 창은
124
+ // 다른 계정일 수 있어 근거로 쓰지 않는다. 새 창이 열린 뒤에도 관측된 창은 함께 도는
125
+ // 계정이므로 max-used 후보로 남긴다.
126
+ function isReplacedWindowGroup(group, other) {
127
+ if (other === group || !other.seenInSessions) return false;
128
+ if (other.minResetAt <= group.maxResetAt || !other.windowMinutes) {
129
+ return false;
130
+ }
131
+ const lastSeenMs = Date.parse(group.latest.timestamp);
132
+ if (!Number.isFinite(lastSeenMs)) return false;
133
+ // resets_at 은 실측으로 아래로 약 30초, 위로 1초 흔들리므로 가장 늦은 값으로 개시 시각을 잡는다.
134
+ const otherOpenedAtMs = (other.maxResetAt - other.windowMinutes * 60) * 1000;
135
+ return lastSeenMs <= otherOpenedAtMs;
136
+ }
137
+
138
+ function selectCodexWindowSnapshot(
139
+ snapshots,
140
+ nowSec,
141
+ windowKey,
142
+ replaceEndedWindows,
143
+ ) {
108
144
  const groups = [];
109
145
  const ungrouped = [];
110
146
 
@@ -126,19 +162,29 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
126
162
  minResetAt: resetAt,
127
163
  maxResetAt: resetAt,
128
164
  latest: snapshot,
165
+ seenInSessions: !snapshot.probe,
166
+ windowMinutes: positiveWindowMinutes(snapshot[windowKey]),
129
167
  };
130
168
  groups.push(group);
131
169
  continue;
132
170
  }
133
171
 
172
+ if (!snapshot.probe) group.seenInSessions = true;
173
+ group.windowMinutes ??= positiveWindowMinutes(snapshot[windowKey]);
134
174
  group.minResetAt = Math.min(group.minResetAt, resetAt);
135
175
  group.maxResetAt = Math.max(group.maxResetAt, resetAt);
136
176
  if (isNewerSnapshot(snapshot, group.latest)) group.latest = snapshot;
137
177
  }
138
178
 
139
- const activeGroups = groups.filter(
179
+ const openGroups = groups.filter(
140
180
  (group) => Number(group.latest[windowKey]?.resets_at) > nowSec,
141
181
  );
182
+ const activeGroups = replaceEndedWindows
183
+ ? openGroups.filter(
184
+ (group) =>
185
+ !openGroups.some((other) => isReplacedWindowGroup(group, other)),
186
+ )
187
+ : openGroups;
142
188
  let selected = null;
143
189
  if (activeGroups.length > 0) {
144
190
  selected = activeGroups
@@ -168,16 +214,18 @@ function selectCodexWindowSnapshot(snapshots, nowSec, windowKey) {
168
214
  return { snapshot: selected, activeGroupCount: activeGroups.length };
169
215
  }
170
216
 
171
- function selectCodexSnapshot(snapshots, nowSec) {
217
+ function selectCodexSnapshot(snapshots, nowSec, replaceEndedWindows) {
172
218
  const primarySelection = selectCodexWindowSnapshot(
173
219
  snapshots,
174
220
  nowSec,
175
221
  "primary",
222
+ replaceEndedWindows,
176
223
  );
177
224
  const secondarySelection = selectCodexWindowSnapshot(
178
225
  snapshots,
179
226
  nowSec,
180
227
  "secondary",
228
+ replaceEndedWindows,
181
229
  );
182
230
  const primarySnapshot = primarySelection.snapshot;
183
231
  if (!primarySnapshot) return null;
@@ -198,7 +246,7 @@ function selectCodexSnapshot(snapshots, nowSec) {
198
246
  return selected;
199
247
  }
200
248
 
201
- function mergeRateLimitSnapshots(snapshots, nowSec) {
249
+ function mergeRateLimitSnapshots(snapshots, nowSec, replaceEndedWindows) {
202
250
  const merged = {};
203
251
  const codexSnapshots = [];
204
252
  for (const snapshot of snapshots) {
@@ -211,7 +259,11 @@ function mergeRateLimitSnapshots(snapshots, nowSec) {
211
259
  }
212
260
  }
213
261
 
214
- const codex = selectCodexSnapshot(codexSnapshots, nowSec);
262
+ const codex = selectCodexSnapshot(
263
+ codexSnapshots,
264
+ nowSec,
265
+ replaceEndedWindows,
266
+ );
215
267
  if (codex) merged.codex = codex;
216
268
  return merged;
217
269
  }
@@ -227,9 +279,16 @@ export function getCodexRateLimits({
227
279
  now = new Date(),
228
280
  maxLinesPerFile = 800,
229
281
  extraSnapshots = [],
282
+ // 교체된 창 판정(isReplacedWindowGroup). 브로커 Codex 계정이 있으면 끈다.
283
+ replaceEndedWindows = true,
230
284
  } = {}) {
231
285
  let syntheticBucket = null; // 최근 token_count에서 합성 (행 활성화 + 토큰 데이터용)
232
- const recentSnapshots = [...extraSnapshots];
286
+ // 프로브도 세션 로그와 같은 window_minutes 기준으로 5h/1w 슬롯을 정한다.
287
+ // app-server 는 5h 창이 없으면 주간 창을 primary 로 보낸다(2026-09-23 실측).
288
+ const recentSnapshots = extraSnapshots.map((snapshot) => ({
289
+ ...snapshot,
290
+ ...normalizeBuckets(snapshot),
291
+ }));
233
292
  const nowSec = Math.floor(now.getTime() / 1000);
234
293
 
235
294
  // 7일간 스캔: 실제 rate_limits 우선, 합성 버킷은 폴백
@@ -313,7 +372,11 @@ export function getCodexRateLimits({
313
372
  }
314
373
 
315
374
  const snapshots = dayOffset <= 1 ? recentSnapshots : daySnapshots;
316
- const mergedBuckets = mergeRateLimitSnapshots(snapshots, nowSec);
375
+ const mergedBuckets = mergeRateLimitSnapshots(
376
+ snapshots,
377
+ nowSec,
378
+ replaceEndedWindows,
379
+ );
317
380
  if (Object.keys(mergedBuckets).length > 0) {
318
381
  if (syntheticBucket) {
319
382
  const main =
@@ -369,10 +432,30 @@ export async function collectProbeSnapshots(now = new Date()) {
369
432
  );
370
433
  }
371
434
 
435
+ // 브로커 계정은 자기 CODEX_HOME 에서 돌기 때문에, 옛 창이 전환 전 계정의 창이어도 그
436
+ // 계정이 아직 쓰이고 있을 수 있다. accounts.json 의 codex 항목과 프로브용 인증 캐시로 판정한다.
437
+ export function hasBrokerCodexAccounts({
438
+ brokerDir = CODEX_BROKER_AUTH_CACHE_DIR,
439
+ } = {}) {
440
+ const accounts = readJson(join(brokerDir, "accounts.json"), null);
441
+ if (Array.isArray(accounts?.codex) && accounts.codex.length > 0) return true;
442
+ try {
443
+ return readdirSync(brokerDir).some((file) =>
444
+ /^codex-auth-.+\.json$/.test(file),
445
+ );
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+
372
451
  export async function refreshCodexRateLimitsCache() {
373
452
  const now = new Date();
374
453
  const extraSnapshots = await collectProbeSnapshots(now);
375
- const buckets = getCodexRateLimits({ now, extraSnapshots });
454
+ const buckets = getCodexRateLimits({
455
+ now,
456
+ extraSnapshots,
457
+ replaceEndedWindows: !hasBrokerCodexAccounts(),
458
+ });
376
459
  // buckets가 null이어도 캐시 갱신 (stale 데이터 제거)
377
460
  writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
378
461
  return buckets;
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { resolveRoleControlSnapshot } from "../../hub/lib/cto-env.mjs";
3
4
  import {
4
5
  CTO_LAKE_CURRENT_RELATIVE_PATH,
5
6
  CTO_STATUS_LINE_MAX_CHARS,
@@ -53,6 +54,8 @@ function extractStatusLine(lines) {
53
54
  }
54
55
 
55
56
  export function readCtoStatus(options = {}) {
57
+ if (!resolveRoleControlSnapshot(options.env).auto_collect_enabled)
58
+ return null;
56
59
  try {
57
60
  const currentPath = resolveCurrentPath(options);
58
61
  const stat = statSync(currentPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.46.0",
3
+ "version": "10.47.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -636,6 +636,13 @@ const SYNC_MAP = [
636
636
  // 설치본이 남지 않게 한다. hooks/ 는 플러그인 루트에서 직접 import 되는 경로라
637
637
  // 이 복사 목록의 대상이 아니다.
638
638
  ...scanLibFiles(PLUGIN_ROOT, CLAUDE_DIR),
639
+ // hud/providers/cto.mjs 가 ../../hub/lib/cto-env.mjs 를 정적 import 한다(ADR-0018).
640
+ // 의존성 없는 env 판독 모듈이라 이 파일 하나만 hud 보다 먼저 복사한다.
641
+ {
642
+ src: join(PLUGIN_ROOT, "hub", "lib", "cto-env.mjs"),
643
+ dst: join(CLAUDE_DIR, "hub", "lib", "cto-env.mjs"),
644
+ label: "hub/lib/cto-env.mjs",
645
+ },
639
646
  ...scanHudFiles(PLUGIN_ROOT, CLAUDE_DIR),
640
647
  {
641
648
  src: join(PLUGIN_ROOT, "scripts", "notion-read.mjs"),
@@ -3539,7 +3539,7 @@ EOF
3539
3539
  FULL_PROMPT="$PROMPT"
3540
3540
  fi
3541
3541
  # CTO north-star: codex lane(line ~2670)과 동일하게 brief 를 prompt 앞에 주입.
3542
- # prepend_codex_north_star 는 CLI-agnostic (TFX_CTO_NORTH_STAR opt-out +
3542
+ # prepend_codex_north_star 는 CLI-agnostic (TFX_CTO_NORTH_STAR opt-in +
3543
3543
  # .triflux/lake/current.md 만 참조) 하므로 agy 워커에서도 그대로 재사용한다.
3544
3544
  # sentinel 은 command-substitution 이 trailing newline 을 삼키는 것을 막는다.
3545
3545
  local _agy_north_star_file="${WORKDIR:-$PWD}/.triflux/lake/current.md"
@@ -1,286 +0,0 @@
1
- // hub/team/git-preflight.mjs — Pre-flight safety check for dangerous git ops.
2
- // Blocks checkout/rebase/cherry-pick/reset/stash-pop/worktree-remove when they
3
- // would conflict with other active Synapse sessions' dirty files or claimed
4
- // leases. Fail-open by default: if registry/locks are unavailable, allow the op
5
- // with a warning (worktree isolation is the baseline safety net).
6
-
7
- /**
8
- * Factory for git pre-flight check.
9
- *
10
- * @param {object} opts
11
- * @param {{ getActive: () => Array<object> }} opts.registry — synapse-registry duck type
12
- * @param {{ snapshot: () => Array<object> }} opts.locks — swarm-locks duck type
13
- * @param {boolean} [opts.failOpen=true] — allow op when registry/locks throw or are missing
14
- * @param {(level: string, msg: string, data?: object) => void} [opts.logger] — optional logger
15
- * @returns {{
16
- * check: Function,
17
- * checkRebase: Function,
18
- * checkCheckout: Function,
19
- * checkCherryPick: Function,
20
- * checkReset: Function,
21
- * checkStashPop: Function,
22
- * checkWorktreeRemove: Function,
23
- * }}
24
- */
25
- export function createGitPreflight(opts = {}) {
26
- const { registry, locks, failOpen = true, logger = null } = opts;
27
-
28
- function log(level, msg, data) {
29
- if (logger) {
30
- try {
31
- logger(level, msg, data);
32
- } catch {
33
- /* logger failure is non-fatal */
34
- }
35
- return;
36
- }
37
- if (level === "warn") {
38
- console.warn(`[git-preflight] ${msg}`, data ?? "");
39
- } else if (level === "error") {
40
- console.error(`[git-preflight] ${msg}`, data ?? "");
41
- }
42
- }
43
-
44
- // ── helpers ──────────────────────────────────────────────
45
-
46
- function safeGetActive() {
47
- try {
48
- const value = registry?.getActive?.();
49
- if (!Array.isArray(value)) return null;
50
- return value;
51
- } catch (err) {
52
- log("warn", "registry.getActive threw", { error: err?.message });
53
- return null;
54
- }
55
- }
56
-
57
- function safeSnapshot() {
58
- try {
59
- const value = locks?.snapshot?.();
60
- if (!Array.isArray(value)) return null;
61
- return value;
62
- } catch (err) {
63
- log("warn", "locks.snapshot threw", { error: err?.message });
64
- return null;
65
- }
66
- }
67
-
68
- function failOpenDecision(reason) {
69
- log("warn", "hub unavailable, failing open", { reason });
70
- return { allowed: true, reason: "hub_unavailable_fail_open" };
71
- }
72
-
73
- function normalizeFiles(list) {
74
- if (!Array.isArray(list)) return new Set();
75
- return new Set(
76
- list
77
- .filter((f) => typeof f === "string" && f.length > 0)
78
- .map((f) => f.replace(/\\/g, "/")),
79
- );
80
- }
81
-
82
- function otherActiveSessions(active, sessionId) {
83
- return active.filter((s) => s?.sessionId && s.sessionId !== sessionId);
84
- }
85
-
86
- function otherLeases(snapshot, workerId) {
87
- return snapshot.filter(
88
- (entry) => entry?.workerId && entry.workerId !== workerId,
89
- );
90
- }
91
-
92
- function buildConflict(file, session, holder) {
93
- return {
94
- file,
95
- activeSession: session?.sessionId ?? holder ?? "unknown",
96
- activeTask: session?.taskSummary ?? "",
97
- ...(holder ? { leaseHolder: holder } : {}),
98
- };
99
- }
100
-
101
- function recommend(conflicts) {
102
- if (!conflicts.length) return "";
103
- const first = conflicts[0];
104
- const owner = first.leaseHolder || first.activeSession;
105
- const task = first.activeTask ? ` Active task: '${first.activeTask}'` : "";
106
- return `Wait for '${owner}' to finish, or coordinate via HITL.${task}`;
107
- }
108
-
109
- function blockedDecision(op, conflicts, extraReason) {
110
- return {
111
- allowed: false,
112
- reason: extraReason || "overlap_with_active_session",
113
- conflicts,
114
- recommendation: recommend(conflicts),
115
- op,
116
- };
117
- }
118
-
119
- function allowedDecision() {
120
- return { allowed: true };
121
- }
122
-
123
- // ── shared scanners ─────────────────────────────────────
124
-
125
- function findDirtyFileConflicts(sessionId, workerId) {
126
- const active = safeGetActive();
127
- const snapshot = safeSnapshot();
128
-
129
- if (active == null || snapshot == null) {
130
- return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
131
- }
132
-
133
- const conflicts = [];
134
- const seen = new Set();
135
-
136
- for (const session of otherActiveSessions(active, sessionId)) {
137
- const dirty = normalizeFiles(session.dirtyFiles);
138
- for (const file of dirty) {
139
- if (seen.has(file)) continue;
140
- seen.add(file);
141
- conflicts.push(buildConflict(file, session));
142
- }
143
- }
144
-
145
- for (const lease of otherLeases(snapshot, workerId)) {
146
- if (lease.leaseType && lease.leaseType !== "exclusive") continue;
147
- const file = typeof lease.file === "string" ? lease.file : null;
148
- if (!file || seen.has(file)) continue;
149
- seen.add(file);
150
- const sessionMeta = lease.sessionMeta || null;
151
- conflicts.push(
152
- buildConflict(
153
- file,
154
- sessionMeta
155
- ? {
156
- sessionId: sessionMeta.sessionId || lease.workerId,
157
- taskSummary: sessionMeta.taskSummary || "",
158
- }
159
- : null,
160
- lease.workerId,
161
- ),
162
- );
163
- }
164
-
165
- return { conflicts };
166
- }
167
-
168
- function findTargetFileConflicts(targetFiles, sessionId, workerId) {
169
- const active = safeGetActive();
170
- const snapshot = safeSnapshot();
171
-
172
- if (active == null || snapshot == null) {
173
- return failOpen ? { fallOpen: true } : { fallOpen: false, conflicts: [] };
174
- }
175
-
176
- const targets = normalizeFiles(targetFiles);
177
- if (targets.size === 0) return { conflicts: [] };
178
-
179
- const conflicts = [];
180
- const seen = new Set();
181
-
182
- for (const session of otherActiveSessions(active, sessionId)) {
183
- const dirty = normalizeFiles(session.dirtyFiles);
184
- for (const file of dirty) {
185
- if (!targets.has(file) || seen.has(file)) continue;
186
- seen.add(file);
187
- conflicts.push(buildConflict(file, session));
188
- }
189
- }
190
-
191
- for (const lease of otherLeases(snapshot, workerId)) {
192
- if (lease.leaseType && lease.leaseType !== "exclusive") continue;
193
- const file = typeof lease.file === "string" ? lease.file : null;
194
- if (!file || !targets.has(file) || seen.has(file)) continue;
195
- seen.add(file);
196
- const sessionMeta = lease.sessionMeta || null;
197
- conflicts.push(
198
- buildConflict(
199
- file,
200
- sessionMeta
201
- ? {
202
- sessionId: sessionMeta.sessionId || lease.workerId,
203
- taskSummary: sessionMeta.taskSummary || "",
204
- }
205
- : null,
206
- lease.workerId,
207
- ),
208
- );
209
- }
210
-
211
- return { conflicts };
212
- }
213
-
214
- // ── public API ──────────────────────────────────────────
215
-
216
- /**
217
- * Core pre-flight check.
218
- *
219
- * @param {"checkout"|"rebase"|"cherry-pick"|"reset"|"stash-pop"|"worktree-remove"} op
220
- * @param {{ targetFiles?: string[], branch?: string, ref?: string, worktreePath?: string }} args
221
- * @param {{ sessionId: string, workerId?: string }} sessionContext
222
- * @returns {{ allowed: boolean, reason?: string, conflicts?: object[], recommendation?: string, op?: string }}
223
- */
224
- function check(op, args = {}, sessionContext = {}) {
225
- const sessionId = sessionContext.sessionId || "";
226
- const workerId = sessionContext.workerId || sessionId;
227
-
228
- switch (op) {
229
- case "checkout":
230
- case "rebase":
231
- case "reset":
232
- case "stash-pop": {
233
- const res = findDirtyFileConflicts(sessionId, workerId);
234
- if (res.fallOpen) return failOpenDecision(op);
235
- if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
236
- return allowedDecision();
237
- }
238
- case "cherry-pick": {
239
- const targets = Array.isArray(args.targetFiles) ? args.targetFiles : [];
240
- const res = findTargetFileConflicts(targets, sessionId, workerId);
241
- if (res.fallOpen) return failOpenDecision(op);
242
- if (res.conflicts.length > 0) return blockedDecision(op, res.conflicts);
243
- return allowedDecision();
244
- }
245
- case "worktree-remove": {
246
- const active = safeGetActive();
247
- if (active == null) return failOpenDecision(op);
248
- const target =
249
- typeof args.worktreePath === "string"
250
- ? args.worktreePath.replace(/\\/g, "/")
251
- : "";
252
- if (!target) return allowedDecision();
253
- const conflicts = [];
254
- for (const session of otherActiveSessions(active, sessionId)) {
255
- const sessionPath =
256
- typeof session.worktreePath === "string"
257
- ? session.worktreePath.replace(/\\/g, "/")
258
- : "";
259
- if (sessionPath && sessionPath === target) {
260
- conflicts.push({
261
- file: target,
262
- activeSession: session.sessionId,
263
- activeTask: session.taskSummary || "",
264
- });
265
- }
266
- }
267
- if (conflicts.length > 0) {
268
- return blockedDecision(op, conflicts, "active_worktree");
269
- }
270
- return allowedDecision();
271
- }
272
- default:
273
- return allowedDecision();
274
- }
275
- }
276
-
277
- return Object.freeze({
278
- check,
279
- checkRebase: (args, ctx) => check("rebase", args, ctx),
280
- checkCheckout: (args, ctx) => check("checkout", args, ctx),
281
- checkCherryPick: (args, ctx) => check("cherry-pick", args, ctx),
282
- checkReset: (args, ctx) => check("reset", args, ctx),
283
- checkStashPop: (args, ctx) => check("stash-pop", args, ctx),
284
- checkWorktreeRemove: (args, ctx) => check("worktree-remove", args, ctx),
285
- });
286
- }