triflux 10.9.13 → 10.9.15

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.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +34 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/config/mcp-registry.json +29 -0
  4. package/hub/lib/spawn-trace.mjs +1 -1
  5. package/hub/server.mjs +62 -0
  6. package/hub/team/conductor.mjs +69 -26
  7. package/hub/team/synapse-registry.mjs +51 -22
  8. package/package.json +56 -21
  9. package/scripts/tfx-route.sh +8 -11
  10. package/tui/codex-profile.mjs +457 -0
  11. package/tui/core.mjs +266 -0
  12. package/tui/doctor.mjs +375 -0
  13. package/tui/gemini-profile.mjs +299 -0
  14. package/tui/monitor-data.mjs +152 -0
  15. package/tui/monitor.mjs +339 -0
  16. package/tui/setup.mjs +598 -0
  17. package/CLAUDE.md +0 -212
  18. package/references/hosts.json +0 -46
  19. package/skills/tfx-workspace/async-tests/run-tests.sh +0 -203
  20. package/skills/tfx-workspace/evals/evals.json +0 -79
  21. package/skills/tfx-workspace/iteration-1/benchmark.json +0 -524
  22. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/eval_metadata.json +0 -11
  23. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/grading.json +0 -25
  24. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/outputs/analysis.md +0 -154
  25. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/old_skill/timing.json +0 -5
  26. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/grading.json +0 -25
  27. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/outputs/analysis.md +0 -126
  28. package/skills/tfx-workspace/iteration-1/codex-gemini-remap/with_skill/timing.json +0 -5
  29. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/eval_metadata.json +0 -11
  30. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/grading.json +0 -25
  31. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/outputs/analysis.md +0 -119
  32. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/old_skill/timing.json +0 -5
  33. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/grading.json +0 -25
  34. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/outputs/analysis.md +0 -115
  35. package/skills/tfx-workspace/iteration-1/doctor-diagnosis/with_skill/timing.json +0 -5
  36. package/skills/tfx-workspace/iteration-1/hub-start-sequence/eval_metadata.json +0 -10
  37. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/grading.json +0 -20
  38. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/outputs/analysis.md +0 -86
  39. package/skills/tfx-workspace/iteration-1/hub-start-sequence/old_skill/timing.json +0 -5
  40. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/grading.json +0 -20
  41. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/outputs/analysis.md +0 -81
  42. package/skills/tfx-workspace/iteration-1/hub-start-sequence/with_skill/timing.json +0 -5
  43. package/skills/tfx-workspace/iteration-1/multi-team-creation/eval_metadata.json +0 -12
  44. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/grading.json +0 -30
  45. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/outputs/analysis.md +0 -316
  46. package/skills/tfx-workspace/iteration-1/multi-team-creation/old_skill/timing.json +0 -5
  47. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/grading.json +0 -30
  48. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/outputs/analysis.md +0 -352
  49. package/skills/tfx-workspace/iteration-1/multi-team-creation/with_skill/timing.json +0 -5
  50. package/skills/tfx-workspace/iteration-1/review.html +0 -1325
  51. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/eval_metadata.json +0 -12
  52. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/grading.json +0 -30
  53. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/outputs/analysis.md +0 -97
  54. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/old_skill/timing.json +0 -5
  55. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/grading.json +0 -30
  56. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/outputs/analysis.md +0 -94
  57. package/skills/tfx-workspace/iteration-1/routing-implement-shortcut/with_skill/timing.json +0 -5
  58. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/eval_metadata.json +0 -12
  59. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/grading.json +0 -30
  60. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/outputs/analysis.md +0 -209
  61. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/old_skill/timing.json +0 -5
  62. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/grading.json +0 -30
  63. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/outputs/analysis.md +0 -193
  64. package/skills/tfx-workspace/iteration-1/routing-multi-task-triage/with_skill/timing.json +0 -5
  65. package/skills/tfx-workspace/iteration-2/benchmark.json +0 -144
  66. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/eval_metadata.json +0 -13
  67. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/grading.json +0 -35
  68. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/outputs/analysis.md +0 -382
  69. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/old_skill/timing.json +0 -5
  70. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/grading.json +0 -35
  71. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/outputs/analysis.md +0 -333
  72. package/skills/tfx-workspace/iteration-2/multi-team-creation-refactored/with_skill/timing.json +0 -5
  73. package/skills/tfx-workspace/iteration-2/review.html +0 -1325
  74. package/skills/tfx-workspace/skill-snapshot/tfx-auto/SKILL.md +0 -217
  75. package/skills/tfx-workspace/skill-snapshot/tfx-auto-codex/SKILL.md +0 -77
  76. package/skills/tfx-workspace/skill-snapshot/tfx-codex/SKILL.md +0 -65
  77. package/skills/tfx-workspace/skill-snapshot/tfx-doctor/SKILL.md +0 -94
  78. package/skills/tfx-workspace/skill-snapshot/tfx-gemini/SKILL.md +0 -82
  79. package/skills/tfx-workspace/skill-snapshot/tfx-hub/SKILL.md +0 -133
  80. package/skills/tfx-workspace/skill-snapshot/tfx-multi/SKILL.md +0 -426
  81. package/skills/tfx-workspace/skill-snapshot/tfx-setup/SKILL.md +0 -101
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "triflux",
4
+ "description": "CLI-first multi-model orchestrator — Codex/Gemini/Claude routing with DAG execution, auto-triage, and cost optimization",
5
+ "owner": {
6
+ "name": "tellang"
7
+ },
8
+ "plugins": [
9
+ {
10
+ "name": "triflux",
11
+ "description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
12
+ "version": "10.9.6",
13
+ "author": {
14
+ "name": "tellang"
15
+ },
16
+ "source": {
17
+ "source": "npm",
18
+ "package": "triflux"
19
+ },
20
+ "category": "productivity",
21
+ "homepage": "https://github.com/tellang/triflux",
22
+ "tags": [
23
+ "multi-model",
24
+ "codex",
25
+ "gemini",
26
+ "cli-routing",
27
+ "orchestration",
28
+ "cost-optimization",
29
+ "dag-execution"
30
+ ]
31
+ }
32
+ ],
33
+ "version": "10.9.14"
34
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "triflux",
3
+ "version": "10.3.4",
4
+ "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
+ "author": {
6
+ "name": "tellang"
7
+ },
8
+ "repository": "https://github.com/tellang/triflux",
9
+ "homepage": "https://github.com/tellang/triflux",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "claude-code",
13
+ "plugin",
14
+ "codex",
15
+ "gemini",
16
+ "cli-routing",
17
+ "orchestration",
18
+ "multi-model"
19
+ ],
20
+ "skills": "./skills/",
21
+ "hooks": "./hooks/hooks.json"
22
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "mcp-registry-schema",
3
+ "version": 1,
4
+ "description": "MCP 서버 중앙 레지스트리 — 진실의 원천",
5
+ "defaults": {
6
+ "transport": "hub-url",
7
+ "hub_base": "http://127.0.0.1:27888"
8
+ },
9
+ "servers": {
10
+ "tfx-hub": {
11
+ "transport": "hub-url",
12
+ "url": "http://127.0.0.1:27888/mcp",
13
+ "safe": true,
14
+ "targets": ["claude", "gemini", "codex"],
15
+ "description": "triflux Hub MCP 서버"
16
+ }
17
+ },
18
+ "policies": {
19
+ "stdio_action": "replace-with-hub",
20
+ "unknown_server_action": "warn",
21
+ "watched_paths": [
22
+ "~/.gemini/settings.json",
23
+ "~/.codex/config.toml",
24
+ "~/.claude/settings.json",
25
+ "~/.claude/settings.local.json",
26
+ ".mcp.json"
27
+ ]
28
+ }
29
+ }
@@ -9,7 +9,7 @@ const DEDUPE_WINDOW_MS = 5_000;
9
9
  const RATE_WINDOW_MS = 1_000;
10
10
  export const MAX_SPAWN_PER_SEC = resolvePositiveInteger(
11
11
  process.env.TRIFLUX_MAX_SPAWN_RATE,
12
- 10,
12
+ 30,
13
13
  );
14
14
  export const MAX_TOTAL_DESCENDANTS = resolvePositiveInteger(
15
15
  process.env.TRIFLUX_MAX_DESCENDANTS,
package/hub/server.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { execSync as execSyncHub } from "node:child_process";
4
4
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import { EventEmitter } from "node:events";
5
6
  import {
6
7
  existsSync,
7
8
  mkdirSync,
@@ -40,7 +41,10 @@ import {
40
41
  writeState,
41
42
  } from "./state.mjs";
42
43
  import { createStoreAdapter } from "./store-adapter.mjs";
44
+ import { createGitPreflight } from "./team/git-preflight.mjs";
43
45
  import { nativeProxy } from "./team/nativeProxy.mjs";
46
+ import { createSwarmLocks } from "./team/swarm-locks.mjs";
47
+ import { createSynapseRegistry } from "./team/synapse-registry.mjs";
44
48
  import { registerTeamBridge } from "./team-bridge.mjs";
45
49
  import { createTools } from "./tools.mjs";
46
50
  import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
@@ -573,6 +577,36 @@ export async function startHub({
573
577
  }
574
578
  const delegatorService = new DelegatorService({ worker: delegatorWorker });
575
579
 
580
+ // Synapse Layer 4: session registry + git preflight + swarm locks
581
+ const synapseEmitter = new EventEmitter();
582
+ synapseEmitter.setMaxListeners(50);
583
+ const synapseRegistry = createSynapseRegistry({
584
+ persistPath: join(CACHE_DIR, "tfx-hub", "synapse-sessions.json"),
585
+ emitter: synapseEmitter,
586
+ });
587
+ const swarmLocks = createSwarmLocks({
588
+ repoRoot: PROJECT_ROOT,
589
+ persistPath: join(CACHE_DIR, "tfx-hub", "swarm-locks.json"),
590
+ });
591
+ const gitPreflight = createGitPreflight({
592
+ registry: synapseRegistry,
593
+ locks: swarmLocks,
594
+ });
595
+
596
+ // Synapse Layer 5: emitter subscribers — bridge events to hub logging
597
+ synapseEmitter.on("synapse.session.started", ({ sessionId }) => {
598
+ hubLog.info({ sessionId }, "synapse.session.started");
599
+ });
600
+ synapseEmitter.on("synapse.session.heartbeat", ({ sessionId }) => {
601
+ hubLog.debug({ sessionId }, "synapse.session.heartbeat");
602
+ });
603
+ synapseEmitter.on("synapse.session.stale", ({ sessionId }) => {
604
+ hubLog.warn({ sessionId }, "synapse.session.stale");
605
+ });
606
+ synapseEmitter.on("synapse.session.removed", ({ sessionId }) => {
607
+ hubLog.info({ sessionId }, "synapse.session.removed");
608
+ });
609
+
576
610
  const hitl = createHitlManager(store, router);
577
611
  const pipe = createPipeServer({
578
612
  router,
@@ -740,6 +774,33 @@ export async function startHub({
740
774
  return writeJson(res, 200, { ok: true, accounts });
741
775
  }
742
776
 
777
+ // ── Synapse Layer 5: session registry + locks + preflight routes ──
778
+ if (path === "/synapse/sessions" && req.method === "GET") {
779
+ return writeJson(res, 200, { ok: true, ...synapseRegistry.snapshot(), ts: Date.now() });
780
+ }
781
+
782
+ if (path === "/synapse/locks" && req.method === "GET") {
783
+ return writeJson(res, 200, { ok: true, locks: swarmLocks.snapshot(), ts: Date.now() });
784
+ }
785
+
786
+ if (path === "/synapse/preflight" && req.method === "POST") {
787
+ const VALID_OPS = new Set(["checkout", "rebase", "cherry-pick", "reset", "stash-pop", "worktree-remove"]);
788
+ try {
789
+ const body = await parseBody(req);
790
+ const { op, args = {}, sessionContext = {} } = body;
791
+ if (!op || typeof op !== "string") {
792
+ return writeJson(res, 400, { ok: false, error: "op 필수" });
793
+ }
794
+ if (!VALID_OPS.has(op)) {
795
+ return writeJson(res, 400, { ok: false, error: `invalid op: ${op}` });
796
+ }
797
+ const result = gitPreflight.check(op, args, sessionContext);
798
+ return writeJson(res, 200, { ok: true, ...result });
799
+ } catch (err) {
800
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
801
+ }
802
+ }
803
+
743
804
  if (path.startsWith("/bridge")) {
744
805
  const isBridgeStatusGet =
745
806
  path === "/bridge/status" && req.method === "GET";
@@ -1521,6 +1582,7 @@ export async function startHub({
1521
1582
  await pipe.stop();
1522
1583
  await assignCallbacks.stop();
1523
1584
  await delegatorWorker.stop().catch(() => {});
1585
+ try { synapseRegistry.destroy(); } catch {}
1524
1586
  store.close();
1525
1587
  try {
1526
1588
  unlinkSync(PID_FILE);
@@ -66,6 +66,40 @@ const TERMINAL_STATES = new Set([STATES.DEAD, STATES.COMPLETED]);
66
66
  const DEFAULT_MAX_RESTARTS = 3;
67
67
  const DEFAULT_GRACE_MS = 10_000;
68
68
 
69
+ /**
70
+ * Auth 파일을 lease 소스에서 CLI 대상 경로로 복사.
71
+ * @param {object} lease — broker lease 객체 (mode, authFile 필수)
72
+ * @param {'codex'|'gemini'} agent
73
+ * @param {string} sessionId — 로깅용
74
+ * @param {object} eventLog — createEventLog 인스턴스
75
+ */
76
+ function swapAuthFile(lease, agent, sessionId, eventLog) {
77
+ if (lease?.mode !== "auth" || !lease.authFile) return true;
78
+ const dests =
79
+ agent === "codex"
80
+ ? [join(homedir(), ".codex", "auth.json")]
81
+ : [
82
+ join(homedir(), ".gemini", "oauth_creds.json"),
83
+ join(homedir(), ".gemini", "gemini-credentials.json"),
84
+ ];
85
+ let allOk = true;
86
+ for (const dest of dests) {
87
+ try {
88
+ mkdirSync(dirname(dest), { recursive: true });
89
+ copyFileSync(lease.authFile, dest);
90
+ eventLog.append("auth_copy", { session: sessionId, agent, dest });
91
+ } catch (err) {
92
+ allOk = false;
93
+ eventLog.append("auth_copy_error", {
94
+ session: sessionId,
95
+ dest,
96
+ error: err.message,
97
+ });
98
+ }
99
+ }
100
+ return allOk;
101
+ }
102
+
69
103
  /**
70
104
  * Conductor 팩토리.
71
105
  * @param {object} opts
@@ -73,6 +107,7 @@ const DEFAULT_GRACE_MS = 10_000;
73
107
  * @param {number} [opts.maxRestarts=3]
74
108
  * @param {number} [opts.graceMs=10000] — shutdown grace period
75
109
  * @param {object} [opts.probeOpts] — health-probe 옵션 오버라이드
110
+ * @param {object} [opts.broker] — AccountBroker 인스턴스 (tierFallback 구독용)
76
111
  * @returns {Conductor}
77
112
  */
78
113
  export function createConductor(opts = {}) {
@@ -81,6 +116,7 @@ export function createConductor(opts = {}) {
81
116
  maxRestarts = DEFAULT_MAX_RESTARTS,
82
117
  graceMs = DEFAULT_GRACE_MS,
83
118
  probeOpts = {},
119
+ broker: optsBroker,
84
120
  } = opts;
85
121
 
86
122
  if (!logsDir) throw new Error("logsDir is required");
@@ -739,32 +775,7 @@ export function createConductor(opts = {}) {
739
775
  : config;
740
776
 
741
777
  // auth file copy — broker resolved absolute path, conductor does the actual copy
742
- if (lease?.mode === "auth" && lease.authFile) {
743
- const dests =
744
- config.agent === "codex"
745
- ? [join(homedir(), ".codex", "auth.json")]
746
- : [
747
- join(homedir(), ".gemini", "oauth_creds.json"),
748
- join(homedir(), ".gemini", "gemini-credentials.json"),
749
- ];
750
- for (const dest of dests) {
751
- try {
752
- mkdirSync(dirname(dest), { recursive: true });
753
- copyFileSync(lease.authFile, dest);
754
- eventLog.append("auth_copy", {
755
- session: config.id,
756
- agent: config.agent,
757
- dest,
758
- });
759
- } catch (err) {
760
- eventLog.append("auth_copy_error", {
761
- session: config.id,
762
- dest,
763
- error: err.message,
764
- });
765
- }
766
- }
767
- }
778
+ swapAuthFile(lease, config.agent, config.id, eventLog);
768
779
 
769
780
  // 원격 세션은 launcher 불필요 (이미 원격에서 실행 중)
770
781
  const launcher = resolvedConfig.remote
@@ -892,12 +903,44 @@ export function createConductor(opts = {}) {
892
903
  });
893
904
 
894
905
  await Promise.allSettled(cleanups);
906
+ process.removeListener("SIGINT", onSignal);
907
+ process.removeListener("SIGTERM", onSignal);
908
+ if (brokerInstance && onTierFallback) {
909
+ brokerInstance.removeListener("tierFallback", onTierFallback);
910
+ }
895
911
  if (conductor._meshBridge) conductor._meshBridge.detach();
896
912
  await eventLog.flush();
897
913
  await eventLog.close();
898
914
  emitter.emit("shutdown");
899
915
  }
900
916
 
917
+ // ── broker tierFallback 구독 ──────────────────────────────────
918
+ // 토큰 만료 등으로 상위 tier 계정이 불가능해지면, 활성 세션의 auth를 새 lease로 교체.
919
+ const brokerInstance = optsBroker ?? null;
920
+ const onTierFallback = brokerInstance
921
+ ? ({ provider }) => {
922
+ for (const session of sessions.values()) {
923
+ if (TERMINAL_STATES.has(session.state)) continue;
924
+ if (session.config.agent !== provider) continue;
925
+ if (session.config.remote) continue;
926
+
927
+ const newLease = brokerInstance.lease({ provider });
928
+ if (!newLease) continue;
929
+
930
+ swapAuthFile(newLease, provider, session.id, eventLog);
931
+ eventLog.append("tier_fallback_swap", {
932
+ session: session.id,
933
+ agent: provider,
934
+ newAccount: newLease.id,
935
+ });
936
+ }
937
+ }
938
+ : null;
939
+
940
+ if (brokerInstance && onTierFallback) {
941
+ brokerInstance.on("tierFallback", onTierFallback);
942
+ }
943
+
901
944
  // Shutdown traps
902
945
  const onSignal = () => {
903
946
  void shutdown("signal");
@@ -42,6 +42,7 @@ function sanitizeSession(raw, fallbackSessionId = "") {
42
42
  export function createSynapseRegistry(opts = {}) {
43
43
  const {
44
44
  persistPath,
45
+ emitter = null,
45
46
  localHeartbeatIntervalMs = DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS,
46
47
  localTimeoutMs = DEFAULT_LOCAL_TIMEOUT_MS,
47
48
  remoteHeartbeatIntervalMs = DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS,
@@ -67,6 +68,9 @@ export function createSynapseRegistry(opts = {}) {
67
68
  return session.isRemote ? remoteTimeoutMs : localTimeoutMs;
68
69
  }
69
70
 
71
+ let persistTimer = null;
72
+ let destroyed = false;
73
+
70
74
  function persist() {
71
75
  if (!persistPath) return;
72
76
  try {
@@ -80,6 +84,15 @@ export function createSynapseRegistry(opts = {}) {
80
84
  }
81
85
  }
82
86
 
87
+ function schedulePersist() {
88
+ if (destroyed || persistTimer) return;
89
+ persistTimer = setTimeout(() => {
90
+ persistTimer = null;
91
+ if (!destroyed) persist();
92
+ }, 200);
93
+ if (typeof persistTimer.unref === "function") persistTimer.unref();
94
+ }
95
+
83
96
  function restore() {
84
97
  if (!persistPath || !existsSync(persistPath)) return;
85
98
  try {
@@ -101,10 +114,11 @@ export function createSynapseRegistry(opts = {}) {
101
114
  }
102
115
 
103
116
  function notifyStale(session) {
104
- // TODO: emit synapse.session.stale via Hub deliveryEmitter
117
+ const clone = cloneSession(session);
118
+ emitter?.emit("synapse.session.stale", { sessionId: session.sessionId, session: clone });
105
119
  for (const callback of staleCallbacks) {
106
120
  try {
107
- callback(cloneSession(session));
121
+ callback(clone);
108
122
  } catch {
109
123
  /* no-op */
110
124
  }
@@ -112,10 +126,11 @@ export function createSynapseRegistry(opts = {}) {
112
126
  }
113
127
 
114
128
  function notifyRemoved(session) {
115
- // TODO: emit synapse.session.removed via Hub deliveryEmitter
129
+ const clone = cloneSession(session);
130
+ emitter?.emit("synapse.session.removed", { sessionId: session.sessionId, session: clone });
116
131
  for (const callback of removedCallbacks) {
117
132
  try {
118
- callback(cloneSession(session));
133
+ callback(clone);
119
134
  } catch {
120
135
  /* no-op */
121
136
  }
@@ -134,8 +149,10 @@ export function createSynapseRegistry(opts = {}) {
134
149
 
135
150
  const elapsedMs = now() - current.lastHeartbeat;
136
151
  if (elapsedMs > timeoutFor(current) && current.status !== "stale") {
137
- current.status = "stale";
138
- notifyStale(current);
152
+ const staled = { ...current, status: "stale" };
153
+ sessions.set(sessionId, staled);
154
+ schedulePersist();
155
+ setImmediate(() => notifyStale(staled));
139
156
  }
140
157
  }, intervalFor(session));
141
158
 
@@ -150,8 +167,13 @@ export function createSynapseRegistry(opts = {}) {
150
167
 
151
168
  function register(meta) {
152
169
  const sessionId = normalizeSessionId(meta?.sessionId);
153
- if (!sessionId || sessions.has(sessionId)) {
154
- return { ok: false, sessionId };
170
+ if (!sessionId) {
171
+ return { ok: false, sessionId, reason: "invalid_id" };
172
+ }
173
+
174
+ if (sessions.has(sessionId)) {
175
+ console.warn("[synapse-registry] duplicate registration rejected:", sessionId);
176
+ return { ok: false, sessionId, reason: "duplicate" };
155
177
  }
156
178
 
157
179
  const session = sanitizeSession(
@@ -168,7 +190,7 @@ export function createSynapseRegistry(opts = {}) {
168
190
  startMonitor(sessionId);
169
191
  persist();
170
192
 
171
- // TODO: emit synapse.session.started via Hub deliveryEmitter
193
+ emitter?.emit("synapse.session.started", { sessionId, session: cloneSession(session) });
172
194
  return { ok: true, sessionId };
173
195
  }
174
196
 
@@ -190,33 +212,35 @@ export function createSynapseRegistry(opts = {}) {
190
212
  if (!session) return false;
191
213
 
192
214
  const wasRemote = session.isRemote;
193
-
194
- session.lastHeartbeat = now();
195
- session.status = "active";
215
+ const updated = { ...session, lastHeartbeat: now(), status: "active" };
196
216
 
197
217
  if (partialMeta && typeof partialMeta === "object") {
198
- if (typeof partialMeta.host === "string") session.host = partialMeta.host;
218
+ if (typeof partialMeta.host === "string") updated.host = partialMeta.host;
199
219
  if (typeof partialMeta.worktreePath === "string") {
200
- session.worktreePath = partialMeta.worktreePath;
220
+ updated.worktreePath = partialMeta.worktreePath;
201
221
  }
202
- if (typeof partialMeta.branch === "string") session.branch = partialMeta.branch;
222
+ if (typeof partialMeta.branch === "string") updated.branch = partialMeta.branch;
203
223
  if (Array.isArray(partialMeta.dirtyFiles)) {
204
- session.dirtyFiles = [...partialMeta.dirtyFiles];
224
+ updated.dirtyFiles = partialMeta.dirtyFiles.filter(
225
+ (f) => typeof f === "string" && f.length > 0,
226
+ );
205
227
  }
206
228
  if (typeof partialMeta.taskSummary === "string") {
207
- session.taskSummary = partialMeta.taskSummary;
229
+ updated.taskSummary = partialMeta.taskSummary;
208
230
  }
209
231
  if (typeof partialMeta.isRemote === "boolean") {
210
- session.isRemote = partialMeta.isRemote;
232
+ updated.isRemote = partialMeta.isRemote;
211
233
  }
212
234
  }
213
235
 
214
- if (session.isRemote !== wasRemote) {
236
+ sessions.set(normalized, updated);
237
+
238
+ if (updated.isRemote !== wasRemote) {
215
239
  startMonitor(normalized);
216
240
  }
217
241
 
218
- persist();
219
- // TODO: emit synapse.session.heartbeat via Hub deliveryEmitter
242
+ schedulePersist();
243
+ emitter?.emit("synapse.session.heartbeat", { sessionId: normalized, session: cloneSession(updated), partial: partialMeta });
220
244
  return true;
221
245
  }
222
246
 
@@ -254,9 +278,14 @@ export function createSynapseRegistry(opts = {}) {
254
278
  }
255
279
 
256
280
  function destroy() {
281
+ destroyed = true;
257
282
  for (const sessionId of monitors.keys()) {
258
283
  stopMonitor(sessionId);
259
284
  }
285
+ if (persistTimer) {
286
+ clearTimeout(persistTimer);
287
+ persistTimer = null;
288
+ }
260
289
  persist();
261
290
  }
262
291
 
@@ -272,4 +301,4 @@ export function createSynapseRegistry(opts = {}) {
272
301
  snapshot,
273
302
  destroy,
274
303
  });
275
- }
304
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.13",
3
+ "version": "10.9.15",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,26 +13,69 @@
13
13
  "tfx-doctor-tui": "bin/tfx-doctor-tui.mjs",
14
14
  "tfx-setup-tui": "bin/tfx-setup-tui.mjs"
15
15
  },
16
- "engines": {
17
- "node": ">=18.0.0"
18
- },
19
- "dependencies": {
20
- "@triflux/core": "10.0.1",
21
- "@triflux/remote": "^10.0.0-alpha.1"
22
- },
23
16
  "files": [
24
17
  "bin",
18
+ "tui",
19
+ "hub",
20
+ "config",
25
21
  "skills",
22
+ "!skills/tfx-workspace",
23
+ "!**/failure-reports",
24
+ "scripts",
26
25
  "hooks",
27
26
  "hud",
28
- "scripts",
29
- "hub",
30
27
  "mesh",
31
- "references",
32
- "CLAUDE.md",
28
+ ".claude-plugin",
33
29
  "README.md",
30
+ "README.ko.md",
34
31
  "LICENSE"
35
32
  ],
33
+ "workspaces": [
34
+ "packages/core",
35
+ "packages/remote",
36
+ "packages/triflux"
37
+ ],
38
+ "scripts": {
39
+ "pack": "node scripts/pack.mjs all",
40
+ "pack:core": "node scripts/pack.mjs core",
41
+ "pack:remote": "node scripts/pack.mjs remote",
42
+ "setup": "node scripts/setup.mjs",
43
+ "preinstall": "node scripts/preinstall.mjs",
44
+ "postinstall": "node scripts/setup.mjs",
45
+ "lint": "biome check .",
46
+ "lint:fix": "biome check --fix .",
47
+ "health": "npm test && npm run lint",
48
+ "test": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 \"tests/**/*.test.mjs\" \"scripts/__tests__/**/*.test.mjs\"",
49
+ "test:unit": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/unit/**/*.test.mjs",
50
+ "test:integration": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/integration/**/*.test.mjs",
51
+ "test:route-smoke": "node scripts/test-lock.mjs --test scripts/test-tfx-route-no-claude-native.mjs",
52
+ "test:contract": "node scripts/test-lock.mjs --test --test-force-exit --test-concurrency=8 tests/contract/**/*.test.mjs",
53
+ "test:coverage": "node --experimental-test-coverage --test-coverage-lines=60 --test-coverage-functions=60 --test --test-force-exit --test-concurrency=8 \"tests/**/*.test.mjs\"",
54
+ "gen:skill-docs": "node scripts/gen-skill-docs.mjs",
55
+ "gen:skill-manifest": "node scripts/gen-skill-manifest.mjs"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/tellang/triflux.git"
63
+ },
64
+ "homepage": "https://github.com/tellang/triflux#readme",
65
+ "author": "tellang",
66
+ "license": "MIT",
67
+ "dependencies": {
68
+ "@modelcontextprotocol/sdk": "^1.27.1",
69
+ "better-sqlite3": "^12.6.2",
70
+ "pino": "^10.3.1",
71
+ "pino-pretty": "^13.1.3",
72
+ "systray2": "^2.1.4",
73
+ "zod": "^4.0.0"
74
+ },
75
+ "devDependencies": {
76
+ "@biomejs/biome": "^2.0.0",
77
+ "knip": "^6.3.0"
78
+ },
36
79
  "keywords": [
37
80
  "claude-code",
38
81
  "plugin",
@@ -43,13 +86,5 @@
43
86
  "multi-model",
44
87
  "triflux",
45
88
  "tfx"
46
- ],
47
- "author": "tellang",
48
- "license": "MIT",
49
- "homepage": "https://github.com/tellang/triflux#readme",
50
- "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/tellang/triflux.git",
53
- "directory": "packages/triflux"
54
- }
89
+ ]
55
90
  }
@@ -105,17 +105,14 @@ if [[ -f "$_CODEX_CONFIG" ]] && awk '
105
105
  fi
106
106
 
107
107
  build_codex_base() {
108
- # Escape hatch: TFX_FORCE_CODEX_BYPASS=1이면 감지 결과와 무관하게 항상 bypass.
109
- # CI/디버깅/긴급 상황에서 config.toml 상태에 상관없이 non-TTY codex exec를 보장.
110
- if [[ "${TFX_FORCE_CODEX_BYPASS:-0}" == "1" ]]; then
111
- echo "--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
112
- return
113
- fi
114
- if [[ -n "$_CODEX_HAS_SANDBOX" ]]; then
115
- echo "--skip-git-repo-check"
116
- else
117
- echo "--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
118
- fi
108
+ # codex exec는 항상 non-TTY subprocess에서 실행되므로 --dangerously-bypass 필수.
109
+ # --dangerously-bypass는 config.toml approval_mode/sandbox와 충돌하지 않음
110
+ # (--full-auto와 달리 bypass는 config 값을 override할 뿐 에러를 던지지 않음).
111
+ # 검증: approval_mode="auto" config에서 --dangerously-bypass 동시 사용 → exit 0 확인.
112
+ #
113
+ # Note: 위의 _CODEX_HAS_SANDBOX awk 감지는 현재 미사용이지만, 향후 codex가
114
+ # bypass와 config.toml 충돌을 감지하면 분기 로직을 재활성화할 수 있으므로 유지.
115
+ echo "--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
119
116
  }
120
117
 
121
118
  # ── Async Job 디렉토리 ──