@triflux/remote 10.1.0 → 10.25.1

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 (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2165 -708
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +125 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +44 -16
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +20 -118
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1020 -279
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +68 -52
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +61 -33
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1522 -193
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +18 -34
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -44
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +927 -437
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +517 -63
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +662 -335
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. package/scripts/lib/toml.mjs +113 -0
@@ -1,8 +1,12 @@
1
1
  // hub/team/swarm-reconciler.mjs — Redundant execution + result reconciliation
2
2
  // For critical shards: launches primary + verifier sessions, compares results,
3
3
  // applies conservative adoption (fewer changes wins) or HITL fallback.
4
+ // v2 (Synapse): parses X-Intent trailers to route complementary/contradictory
5
+ // commits through intent-aware paths before falling back to conservative logic.
4
6
 
5
- import { execFile } from 'node:child_process';
7
+ import { execFile } from "node:child_process";
8
+
9
+ import { classifyIntentPair, parseIntentTrailer } from "./swarm-intent.mjs";
6
10
 
7
11
  /**
8
12
  * Compare two shard results and decide which to accept.
@@ -21,14 +25,23 @@ export async function reconcile(primaryResult, verifierResult, opts = {}) {
21
25
  const { rootDir = process.cwd(), maxDivergenceFiles = 5 } = opts;
22
26
 
23
27
  // If either failed, pick the one that succeeded
24
- if (primaryResult.status !== 'completed' && verifierResult.status === 'completed') {
25
- return decision('verifier', 'primary_failed', verifierResult);
28
+ if (
29
+ primaryResult.status !== "completed" &&
30
+ verifierResult.status === "completed"
31
+ ) {
32
+ return decision("verifier", "primary_failed", verifierResult);
26
33
  }
27
- if (verifierResult.status !== 'completed' && primaryResult.status === 'completed') {
28
- return decision('primary', 'verifier_failed', primaryResult);
34
+ if (
35
+ verifierResult.status !== "completed" &&
36
+ primaryResult.status === "completed"
37
+ ) {
38
+ return decision("primary", "verifier_failed", primaryResult);
29
39
  }
30
- if (primaryResult.status !== 'completed' && verifierResult.status !== 'completed') {
31
- return decision('none', 'both_failed', null);
40
+ if (
41
+ primaryResult.status !== "completed" &&
42
+ verifierResult.status !== "completed"
43
+ ) {
44
+ return decision("none", "both_failed", null);
32
45
  }
33
46
 
34
47
  // Both completed — compare diffs
@@ -37,29 +50,97 @@ export async function reconcile(primaryResult, verifierResult, opts = {}) {
37
50
 
38
51
  // Identical diffs → accept primary (no divergence)
39
52
  if (primaryDiff.hash === verifierDiff.hash) {
40
- return decision('primary', 'identical', primaryResult);
53
+ return decision("primary", "identical", primaryResult);
54
+ }
55
+
56
+ // Intent-aware classification: parse X-Intent trailers from both commits.
57
+ // When both commits carry an intent, route contradictory pairs to HITL and
58
+ // complementary (non-overlapping) pairs to a merge-friendly path. Missing or
59
+ // malformed trailers fall through to the existing divergence/conservative
60
+ // adoption logic below.
61
+ const [primaryMsg, verifierMsg] = await Promise.all([
62
+ getCommitMessage(primaryResult.branchName, rootDir),
63
+ getCommitMessage(verifierResult.branchName, rootDir),
64
+ ]);
65
+
66
+ const primaryIntent = parseIntentTrailer(primaryMsg);
67
+ const verifierIntent = parseIntentTrailer(verifierMsg);
68
+
69
+ if (primaryIntent && verifierIntent) {
70
+ const classification = classifyIntentPair(primaryIntent, verifierIntent);
71
+
72
+ if (classification.relation === "contradictory") {
73
+ return {
74
+ selected: "hitl",
75
+ reason: `intent_contradictory: ${classification.reason}`,
76
+ result: null,
77
+ requiresManualReview: true,
78
+ intentClassification: classification,
79
+ primaryIntent,
80
+ verifierIntent,
81
+ primary: {
82
+ filesChanged: primaryDiff.filesChanged,
83
+ linesChanged: primaryDiff.linesChanged,
84
+ },
85
+ verifier: {
86
+ filesChanged: verifierDiff.filesChanged,
87
+ linesChanged: verifierDiff.linesChanged,
88
+ },
89
+ };
90
+ }
91
+
92
+ if (classification.relation === "complementary") {
93
+ return {
94
+ selected: "complementary",
95
+ reason: `intent_complementary: ${classification.reason}`,
96
+ result: { primary: primaryResult, verifier: verifierResult },
97
+ requiresManualReview: false,
98
+ intentClassification: classification,
99
+ primaryIntent,
100
+ verifierIntent,
101
+ shouldAttemptMerge: true,
102
+ primary: {
103
+ filesChanged: primaryDiff.filesChanged,
104
+ linesChanged: primaryDiff.linesChanged,
105
+ },
106
+ verifier: {
107
+ filesChanged: verifierDiff.filesChanged,
108
+ linesChanged: verifierDiff.linesChanged,
109
+ },
110
+ };
111
+ }
112
+
113
+ // complementary-risky / independent → fall through to existing logic.
41
114
  }
42
115
 
43
116
  // Compute divergence
44
- const divergence = Math.abs(primaryDiff.filesChanged - verifierDiff.filesChanged);
117
+ const divergence = Math.abs(
118
+ primaryDiff.filesChanged - verifierDiff.filesChanged,
119
+ );
45
120
 
46
121
  // High divergence → HITL
47
122
  if (divergence > maxDivergenceFiles) {
48
123
  return {
49
- selected: 'hitl',
124
+ selected: "hitl",
50
125
  reason: `divergence_too_high (${divergence} files differ)`,
51
126
  result: null,
52
127
  requiresManualReview: true,
53
- primary: { filesChanged: primaryDiff.filesChanged, linesChanged: primaryDiff.linesChanged },
54
- verifier: { filesChanged: verifierDiff.filesChanged, linesChanged: verifierDiff.linesChanged },
128
+ primary: {
129
+ filesChanged: primaryDiff.filesChanged,
130
+ linesChanged: primaryDiff.linesChanged,
131
+ },
132
+ verifier: {
133
+ filesChanged: verifierDiff.filesChanged,
134
+ linesChanged: verifierDiff.linesChanged,
135
+ },
55
136
  };
56
137
  }
57
138
 
58
139
  // Conservative adoption: fewer changes wins
59
140
  if (primaryDiff.linesChanged <= verifierDiff.linesChanged) {
60
- return decision('primary', 'conservative_adoption', primaryResult);
141
+ return decision("primary", "conservative_adoption", primaryResult);
61
142
  }
62
- return decision('verifier', 'conservative_adoption', verifierResult);
143
+ return decision("verifier", "conservative_adoption", verifierResult);
63
144
  }
64
145
 
65
146
  function decision(selected, reason, result) {
@@ -67,12 +148,28 @@ function decision(selected, reason, result) {
67
148
  selected,
68
149
  reason,
69
150
  result,
70
- requiresManualReview: selected === 'hitl' || selected === 'none',
151
+ requiresManualReview: selected === "hitl" || selected === "none",
71
152
  primary: null,
72
153
  verifier: null,
73
154
  };
74
155
  }
75
156
 
157
+ /**
158
+ * Read the HEAD commit message of a branch.
159
+ *
160
+ * @param {string} branch
161
+ * @param {string} cwd
162
+ * @returns {Promise<string>}
163
+ */
164
+ async function getCommitMessage(branch, cwd) {
165
+ try {
166
+ const msg = await gitExec(["log", "-1", "--format=%B", branch], cwd);
167
+ return msg;
168
+ } catch {
169
+ return "";
170
+ }
171
+ }
172
+
76
173
  /**
77
174
  * Get diff statistics for a branch relative to its merge-base.
78
175
  *
@@ -82,8 +179,11 @@ function decision(selected, reason, result) {
82
179
  */
83
180
  async function getDiffStat(branch, cwd) {
84
181
  try {
85
- const stat = await gitExec(['diff', '--stat', '--numstat', `${branch}~1..${branch}`], cwd);
86
- const lines = stat.split('\n').filter(Boolean);
182
+ const stat = await gitExec(
183
+ ["diff", "--stat", "--numstat", `${branch}~1..${branch}`],
184
+ cwd,
185
+ );
186
+ const lines = stat.split("\n").filter(Boolean);
87
187
  let filesChanged = 0;
88
188
  let linesChanged = 0;
89
189
 
@@ -96,20 +196,25 @@ async function getDiffStat(branch, cwd) {
96
196
  }
97
197
 
98
198
  // Get tree hash for identity comparison
99
- const hash = await gitExec(['rev-parse', `${branch}^{tree}`], cwd);
199
+ const hash = await gitExec(["rev-parse", `${branch}^{tree}`], cwd);
100
200
 
101
201
  return { filesChanged, linesChanged, hash: hash.trim() };
102
202
  } catch {
103
- return { filesChanged: 0, linesChanged: 0, hash: '' };
203
+ return { filesChanged: 0, linesChanged: 0, hash: "" };
104
204
  }
105
205
  }
106
206
 
107
207
  function gitExec(args, cwd) {
108
208
  return new Promise((res, rej) => {
109
- execFile('git', args, { cwd, windowsHide: true, timeout: 15_000 }, (err, stdout) => {
110
- if (err) rej(err);
111
- else res(stdout);
112
- });
209
+ execFile(
210
+ "git",
211
+ args,
212
+ { cwd, windowsHide: true, timeout: 15_000 },
213
+ (err, stdout) => {
214
+ if (err) rej(err);
215
+ else res(stdout);
216
+ },
217
+ );
113
218
  });
114
219
  }
115
220
 
@@ -0,0 +1,458 @@
1
+ // hub/team/synapse-cli.mjs — CLI surface for Synapse v1.
2
+ // Reads persisted registry state + git log for the "status" and "why" commands.
3
+ // Hub integration comes later; for v1 we go straight to the JSON persist files
4
+ // so the CLI works even when Hub is offline.
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+
11
+ import { parseIntentTrailer } from "./swarm-intent.mjs";
12
+
13
+ const DEFAULT_REGISTRY_CANDIDATES = [
14
+ ".triflux/synapse-registry.json",
15
+ ".triflux/synapse/registry.json",
16
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
17
+ ];
18
+ const DEFAULT_SWARM_LOGS_DIR = ".triflux/swarm-logs";
19
+ const STALE_AFTER_MS = 30 * 60 * 1000;
20
+ const STATUS_PRIORITY = {
21
+ active: 3,
22
+ running: 3,
23
+ completed: 2,
24
+ failed: 2,
25
+ stale: 1,
26
+ };
27
+
28
+ function gitExec(args, cwd) {
29
+ return new Promise((res, rej) => {
30
+ execFile(
31
+ "git",
32
+ args,
33
+ { cwd, windowsHide: true, timeout: 15_000 },
34
+ (err, stdout) => {
35
+ if (err) rej(err);
36
+ else res(stdout);
37
+ },
38
+ );
39
+ });
40
+ }
41
+
42
+ function locateRegistryPath(explicit) {
43
+ if (explicit && existsSync(explicit)) return explicit;
44
+ for (const candidate of DEFAULT_REGISTRY_CANDIDATES) {
45
+ if (existsSync(candidate)) return candidate;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function loadRegistrySnapshot(path) {
51
+ if (!path) return [];
52
+ try {
53
+ const raw = readFileSync(path, "utf8");
54
+ const parsed = JSON.parse(raw);
55
+ if (Array.isArray(parsed)) return parsed;
56
+ if (parsed && typeof parsed === "object") {
57
+ // Accept both array-form and object-form persist layouts.
58
+ if (Array.isArray(parsed.sessions)) return parsed.sessions;
59
+ return Object.values(parsed);
60
+ }
61
+ return [];
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function parseJsonLines(path) {
68
+ try {
69
+ return readFileSync(path, "utf8")
70
+ .split(/\r?\n/)
71
+ .map((line) => line.trim())
72
+ .filter(Boolean)
73
+ .flatMap((line) => {
74
+ try {
75
+ return [JSON.parse(line)];
76
+ } catch {
77
+ return [];
78
+ }
79
+ });
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function normalizeRunId(name) {
86
+ return String(name || "").replace(/^run-/, "");
87
+ }
88
+
89
+ function eventTimeMs(event) {
90
+ const ms = Date.parse(String(event?.ts || ""));
91
+ return Number.isFinite(ms) ? ms : 0;
92
+ }
93
+
94
+ function deriveLogStatus(events, lastHeartbeatMs, nowMs) {
95
+ const stateEvents = events.filter((e) => e.event === "swarm_state");
96
+ const lastState = stateEvents[stateEvents.length - 1]?.to;
97
+ if (lastState === "completed" || lastState === "failed") return lastState;
98
+ if (events.some((e) => e.event === "integration_complete")) {
99
+ const lastIntegration = [...events]
100
+ .reverse()
101
+ .find((e) => e.event === "integration_complete");
102
+ if (
103
+ Array.isArray(lastIntegration?.failed) &&
104
+ lastIntegration.failed.length
105
+ ) {
106
+ return "failed";
107
+ }
108
+ return "completed";
109
+ }
110
+ if (nowMs - lastHeartbeatMs > STALE_AFTER_MS) return "stale";
111
+ return "active";
112
+ }
113
+
114
+ function buildLogSession(runDir, nowMs = Date.now()) {
115
+ const eventPath = join(runDir, "swarm-events.jsonl");
116
+ if (!existsSync(eventPath)) return null;
117
+ const events = parseJsonLines(eventPath);
118
+ if (events.length === 0) return null;
119
+
120
+ let statMs = 0;
121
+ try {
122
+ statMs = statSync(eventPath).mtimeMs;
123
+ } catch {}
124
+
125
+ const eventMs = events.map(eventTimeMs).filter((ms) => ms > 0);
126
+ const lastEventMs = eventMs.length ? Math.max(...eventMs) : statMs;
127
+ const launched = new Set();
128
+ const completed = new Set();
129
+ const failed = new Set();
130
+ for (const event of events) {
131
+ if (event.event === "shard_launched" && event.shard) {
132
+ launched.add(event.shard);
133
+ } else if (event.event === "shard_completed" && event.shard) {
134
+ completed.add(event.shard);
135
+ } else if (
136
+ (event.event === "shard_failed" ||
137
+ event.event === "shard_launch_failed") &&
138
+ event.shard
139
+ ) {
140
+ failed.add(event.shard);
141
+ }
142
+ }
143
+
144
+ const runId = normalizeRunId(runDir.split(/[\\/]/).pop());
145
+ const totalShards = launched.size || completed.size + failed.size;
146
+ const aliveWorkers = [...launched].filter(
147
+ (name) => !completed.has(name) && !failed.has(name),
148
+ ).length;
149
+
150
+ return {
151
+ sessionId: runId,
152
+ runId,
153
+ host: "-",
154
+ branch: "-",
155
+ dirtyFiles: [],
156
+ status: deriveLogStatus(events, lastEventMs || nowMs, nowMs),
157
+ taskSummary: "swarm log run",
158
+ source: "logs",
159
+ shards: totalShards ? `${completed.size}/${totalShards}` : "-",
160
+ workers: totalShards ? `${aliveWorkers} alive` : "-",
161
+ lastHeartbeat: lastEventMs || null,
162
+ logDir: runDir,
163
+ };
164
+ }
165
+
166
+ export function loadSwarmLogSessions(logsDir, opts = {}) {
167
+ const nowMs = opts.nowMs ?? Date.now();
168
+ const root = logsDir || DEFAULT_SWARM_LOGS_DIR;
169
+ if (!existsSync(root)) return [];
170
+ try {
171
+ return readdirSync(root, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
173
+ .map((entry) => buildLogSession(join(root, entry.name), nowMs))
174
+ .filter(Boolean);
175
+ } catch {
176
+ return [];
177
+ }
178
+ }
179
+
180
+ function statusPriority(status) {
181
+ return STATUS_PRIORITY[status] || 0;
182
+ }
183
+
184
+ function mergeSession(existing, next) {
185
+ const status =
186
+ statusPriority(next.status) > statusPriority(existing.status)
187
+ ? next.status
188
+ : existing.status;
189
+ const source =
190
+ existing.source && next.source && existing.source !== next.source
191
+ ? "synapse + logs"
192
+ : existing.source || next.source || "synapse";
193
+ return {
194
+ ...next,
195
+ ...existing,
196
+ status,
197
+ source,
198
+ shards: next.shards || existing.shards,
199
+ workers: next.workers || existing.workers,
200
+ lastHeartbeat: Math.max(
201
+ Number(existing.lastHeartbeat || 0),
202
+ Number(next.lastHeartbeat || 0),
203
+ ),
204
+ logDir: existing.logDir || next.logDir,
205
+ };
206
+ }
207
+
208
+ export function mergeRegistryAndLogSessions(registrySessions, logSessions) {
209
+ const merged = new Map();
210
+ for (const session of registrySessions) {
211
+ const key = String(session.runId || session.sessionId || "");
212
+ if (!key) continue;
213
+ merged.set(key, {
214
+ ...session,
215
+ source: session.source || "synapse",
216
+ lastHeartbeat: session.lastHeartbeat || null,
217
+ });
218
+ }
219
+ for (const session of logSessions) {
220
+ const key = String(session.runId || session.sessionId || "");
221
+ if (!key) continue;
222
+ const existing = merged.get(key);
223
+ merged.set(key, existing ? mergeSession(existing, session) : session);
224
+ }
225
+ return [...merged.values()].sort((left, right) => {
226
+ const rightTs = Number(right.lastHeartbeat || 0);
227
+ const leftTs = Number(left.lastHeartbeat || 0);
228
+ return rightTs - leftTs;
229
+ });
230
+ }
231
+
232
+ function formatAge(ts, nowMs = Date.now()) {
233
+ if (!Number.isFinite(Number(ts)) || Number(ts) <= 0) return "-";
234
+ const seconds = Math.max(0, Math.round((nowMs - Number(ts)) / 1000));
235
+ if (seconds < 60) return `${seconds}s ago`;
236
+ const minutes = Math.round(seconds / 60);
237
+ if (minutes < 60) return `${minutes}m ago`;
238
+ const hours = Math.round(minutes / 60);
239
+ return `${hours}h ago`;
240
+ }
241
+
242
+ function hasSwarmLogFields(sessions) {
243
+ return sessions.some(
244
+ (s) =>
245
+ s.source === "logs" ||
246
+ s.source === "synapse + logs" ||
247
+ s.runId ||
248
+ s.shards ||
249
+ s.workers,
250
+ );
251
+ }
252
+
253
+ function formatSwarmStatus(sessions) {
254
+ const nowMs = Date.now();
255
+ const rows = [];
256
+ rows.push(
257
+ "RUN_ID STATUS SHARDS WORKERS LAST_HEARTBEAT SOURCE",
258
+ );
259
+ rows.push(
260
+ "───────────────────── ───────── ─────── ─────── ────────────── ──────────────",
261
+ );
262
+ for (const s of sessions) {
263
+ const id = String(s.runId || s.sessionId || "?")
264
+ .padEnd(21)
265
+ .slice(0, 21);
266
+ const status = String(s.status || "active")
267
+ .padEnd(9)
268
+ .slice(0, 9);
269
+ const shards = String(s.shards || "-")
270
+ .padEnd(7)
271
+ .slice(0, 7);
272
+ const workers = String(s.workers || "-")
273
+ .padEnd(7)
274
+ .slice(0, 7);
275
+ const heartbeat = formatAge(s.lastHeartbeat, nowMs).padEnd(14).slice(0, 14);
276
+ const source = String(s.source || "synapse").slice(0, 20);
277
+ rows.push(
278
+ `${id} ${status} ${shards} ${workers} ${heartbeat} ${source}`,
279
+ );
280
+ }
281
+ return rows.join("\n");
282
+ }
283
+
284
+ function formatStatus(sessions) {
285
+ if (!sessions.length) {
286
+ return "no active sessions (synapse-registry empty)";
287
+ }
288
+ if (hasSwarmLogFields(sessions)) return formatSwarmStatus(sessions);
289
+ const rows = [];
290
+ rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
291
+ rows.push(
292
+ "───────────────────── ────── ──────── ────── ─────── ─────────────",
293
+ );
294
+ for (const s of sessions) {
295
+ const id = (s.sessionId || "?").padEnd(21).slice(0, 21);
296
+ const host = (s.host || "?").padEnd(6).slice(0, 6);
297
+ const branch = (s.branch || "?").padEnd(8).slice(0, 8);
298
+ const dirty = String((s.dirtyFiles || []).length)
299
+ .padEnd(6)
300
+ .slice(0, 6);
301
+ const state = (s.status || "active").padEnd(7).slice(0, 7);
302
+ const task = (s.taskSummary || "").slice(0, 40);
303
+ rows.push(`${id} ${host} ${branch} ${dirty} ${state} ${task}`);
304
+ }
305
+ return rows.join("\n");
306
+ }
307
+
308
+ /**
309
+ * `tfx synapse status` — list active swarm sessions from persisted registry.
310
+ */
311
+ export async function cmdSynapseStatus(args = [], opts = {}) {
312
+ const jsonOut = args.includes("--json") || opts.json;
313
+ const explicit = extractFlag(args, "--registry");
314
+ const explicitLogsDir = extractFlag(args, "--logs-dir");
315
+ const path = locateRegistryPath(explicit);
316
+ const registrySessions = loadRegistrySnapshot(path);
317
+ const logsDir = explicitLogsDir || DEFAULT_SWARM_LOGS_DIR;
318
+ const logSessions = loadSwarmLogSessions(logsDir);
319
+ const sessions = mergeRegistryAndLogSessions(registrySessions, logSessions);
320
+
321
+ if (jsonOut) {
322
+ process.stdout.write(
323
+ JSON.stringify(
324
+ {
325
+ registry: path,
326
+ logsDir: existsSync(logsDir) ? logsDir : null,
327
+ count: sessions.length,
328
+ sessions,
329
+ },
330
+ null,
331
+ 2,
332
+ ) + "\n",
333
+ );
334
+ return;
335
+ }
336
+
337
+ if (!path && logSessions.length === 0) {
338
+ process.stdout.write(
339
+ "no registry or swarm logs found (looked for .triflux/synapse-registry.json and .triflux/swarm-logs)\n",
340
+ );
341
+ return;
342
+ }
343
+ if (path) process.stdout.write(`registry: ${path}\n`);
344
+ if (logSessions.length > 0) process.stdout.write(`swarm logs: ${logsDir}\n`);
345
+ process.stdout.write(`${formatStatus(sessions)}\n`);
346
+ }
347
+
348
+ function extractFlag(args, name) {
349
+ const idx = args.indexOf(name);
350
+ if (idx < 0) return null;
351
+ return args[idx + 1] || null;
352
+ }
353
+
354
+ async function resolveCommitForPath(path, cwd) {
355
+ try {
356
+ const out = await gitExec(["log", "-1", "--format=%H", "--", path], cwd);
357
+ return out.trim();
358
+ } catch {
359
+ return "";
360
+ }
361
+ }
362
+
363
+ async function readCommitMessage(sha, cwd) {
364
+ try {
365
+ return await gitExec(["log", "-1", "--format=%B", sha], cwd);
366
+ } catch {
367
+ return "";
368
+ }
369
+ }
370
+
371
+ async function readCommitMeta(sha, cwd) {
372
+ try {
373
+ const out = await gitExec(
374
+ ["log", "-1", "--format=%h%x1f%an%x1f%ad%x1f%s", "--date=iso", sha],
375
+ cwd,
376
+ );
377
+ const [short, author, date, subject] = out.trim().split("\x1f");
378
+ return { short, author, date, subject };
379
+ } catch {
380
+ return null;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * `tfx why <path>` — show the X-Intent of the last commit touching <path>.
386
+ */
387
+ export async function cmdSynapseWhy(args = [], opts = {}) {
388
+ const jsonOut = args.includes("--json") || opts.json;
389
+ const cwd = process.cwd();
390
+ const positional = args.filter((a) => !a.startsWith("--"));
391
+ const target = positional[0];
392
+
393
+ if (!target) {
394
+ const err = { error: "path required", usage: "tfx why <path>" };
395
+ if (jsonOut) process.stdout.write(JSON.stringify(err) + "\n");
396
+ else process.stderr.write("tfx why <path> — path 인자 필요\n");
397
+ process.exitCode = 1;
398
+ return;
399
+ }
400
+
401
+ const resolved = resolve(cwd, target);
402
+ if (!existsSync(resolved)) {
403
+ const err = { error: "path not found", path: target };
404
+ if (jsonOut) process.stdout.write(JSON.stringify(err) + "\n");
405
+ else process.stderr.write(`tfx why: 파일 없음: ${target}\n`);
406
+ process.exitCode = 1;
407
+ return;
408
+ }
409
+
410
+ const sha = await resolveCommitForPath(target, cwd);
411
+ if (!sha) {
412
+ const result = { path: target, commit: null, intent: null };
413
+ if (jsonOut) process.stdout.write(JSON.stringify(result) + "\n");
414
+ else process.stdout.write(`no commit touches ${target}\n`);
415
+ return;
416
+ }
417
+
418
+ const [message, meta] = await Promise.all([
419
+ readCommitMessage(sha, cwd),
420
+ readCommitMeta(sha, cwd),
421
+ ]);
422
+ const intent = parseIntentTrailer(message);
423
+
424
+ if (jsonOut) {
425
+ process.stdout.write(
426
+ JSON.stringify(
427
+ { path: target, commit: { sha, ...meta }, intent },
428
+ null,
429
+ 2,
430
+ ) + "\n",
431
+ );
432
+ return;
433
+ }
434
+
435
+ const header = meta
436
+ ? `${meta.short} ${meta.author} ${meta.date} ${meta.subject}`
437
+ : sha;
438
+ process.stdout.write(`${target}\n`);
439
+ process.stdout.write(` last commit: ${header}\n`);
440
+ if (intent) {
441
+ process.stdout.write(` intent.scope: ${intent.scope || "-"}\n`);
442
+ process.stdout.write(` intent.action: ${intent.action || "-"}\n`);
443
+ process.stdout.write(` intent.reason: ${intent.reason || "-"}\n`);
444
+ if (Array.isArray(intent.touches) && intent.touches.length) {
445
+ process.stdout.write(
446
+ ` intent.touches: ${intent.touches.join(", ")}\n`,
447
+ );
448
+ }
449
+ if (intent.invariant) {
450
+ process.stdout.write(` intent.invariant: ${intent.invariant}\n`);
451
+ }
452
+ if (intent.conflictsWith) {
453
+ process.stdout.write(` intent.conflicts: ${intent.conflictsWith}\n`);
454
+ }
455
+ } else {
456
+ process.stdout.write(" intent: (no X-Intent trailer)\n");
457
+ }
458
+ }