@triflux/remote 10.50.2 → 10.52.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.
Files changed (134) hide show
  1. package/cto/brief.mjs +0 -20
  2. package/cto/collect.mjs +1 -200
  3. package/cto/current.schema.json +2 -23
  4. package/cto/hygiene.mjs +2 -61
  5. package/cto/lake-root.mjs +0 -56
  6. package/cto/status.mjs +5 -187
  7. package/hub/index.mjs +0 -17
  8. package/hub/team/agent-map.json +0 -1
  9. package/hub/team/agents-row-attach.py +120 -0
  10. package/hub/team/agents-row.mjs +200 -0
  11. package/hub/team/ansi.mjs +1 -35
  12. package/hub/team/backend.mjs +9 -145
  13. package/hub/team/claude-daemon-control.mjs +1 -2
  14. package/hub/team/claude-session-projection.mjs +5 -73
  15. package/hub/team/cli/commands/attach.mjs +1 -17
  16. package/hub/team/cli/commands/control.mjs +12 -38
  17. package/hub/team/cli/commands/debug.mjs +2 -39
  18. package/hub/team/cli/commands/focus.mjs +5 -31
  19. package/hub/team/cli/commands/interrupt.mjs +6 -25
  20. package/hub/team/cli/commands/kill.mjs +1 -30
  21. package/hub/team/cli/commands/list.mjs +0 -16
  22. package/hub/team/cli/commands/send.mjs +6 -26
  23. package/hub/team/cli/commands/start/index.mjs +42 -133
  24. package/hub/team/cli/commands/start/parse-args.mjs +15 -40
  25. package/hub/team/cli/commands/start/start-headless.mjs +138 -105
  26. package/hub/team/cli/commands/start/start-mux.mjs +2 -7
  27. package/hub/team/cli/commands/status.mjs +2 -57
  28. package/hub/team/cli/commands/stop.mjs +14 -19
  29. package/hub/team/cli/help.mjs +4 -5
  30. package/hub/team/cli/manifest.mjs +5 -1
  31. package/hub/team/cli/render.mjs +0 -12
  32. package/hub/team/cli/services/runtime-mode.mjs +27 -98
  33. package/hub/team/cli/services/state-store.mjs +5 -20
  34. package/hub/team/handoff.mjs +3 -34
  35. package/hub/team/headless.mjs +108 -1296
  36. package/hub/team/orchestrator.mjs +4 -30
  37. package/hub/team/pane.mjs +6 -28
  38. package/hub/team/psmux.mjs +3 -34
  39. package/hub/team/remote-session.mjs +1 -2
  40. package/hub/team/session-context.mjs +1 -1
  41. package/hub/team/session.mjs +7 -91
  42. package/hub/team/tui-core.mjs +1 -1
  43. package/hub/team/tui-viewer.mjs +11 -58
  44. package/hub/team/tui.mjs +8 -306
  45. package/hub/team/uds-orchestrator.mjs +2 -580
  46. package/hub/team/wt-manager.mjs +2 -2
  47. package/hub/workers/factory.mjs +6 -84
  48. package/hub/workers/interface.mjs +1 -7
  49. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  50. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
  51. package/package.json +3 -8
  52. package/scripts/lib/agent-route-policy.mjs +143 -62
  53. package/scripts/lib/cli-claude.mjs +0 -14
  54. package/scripts/lib/env-probe.mjs +2 -131
  55. package/scripts/lib/gemini-profiles.mjs +2 -69
  56. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  57. package/scripts/lib/machine-profile.mjs +3 -17
  58. package/scripts/lib/mcp-filter.mjs +109 -53
  59. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  60. package/scripts/lib/mcp-health.mjs +2 -2
  61. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  62. package/scripts/lib/psmux-info.mjs +11 -59
  63. package/scripts/lib/skill-template.mjs +0 -245
  64. package/hub/hub-lifecycle.mjs +0 -248
  65. package/hub/pipe.mjs +0 -825
  66. package/hub/promote-penalties.mjs +0 -138
  67. package/hub/public/dashboard.html +0 -416
  68. package/hub/schema.sql +0 -209
  69. package/hub/server.mjs +0 -3048
  70. package/hub/store-adapter.mjs +0 -182
  71. package/hub/store.mjs +0 -1736
  72. package/hub/team/build-worker-prompt.mjs +0 -92
  73. package/hub/team/check-mcp-hub.mjs +0 -49
  74. package/hub/team/claude-native-bridge.mjs +0 -987
  75. package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
  76. package/hub/team/cli/commands/start/start-wt.mjs +0 -86
  77. package/hub/team/cli/services/hub-client.mjs +0 -264
  78. package/hub/team/cli/services/native-control.mjs +0 -145
  79. package/hub/team/codex-review.mjs +0 -520
  80. package/hub/team/conductor-mesh-bridge.mjs +0 -122
  81. package/hub/team/conductor-registry.mjs +0 -121
  82. package/hub/team/conductor.mjs +0 -1529
  83. package/hub/team/consensus-meta.mjs +0 -176
  84. package/hub/team/cto-auto-collect.mjs +0 -115
  85. package/hub/team/cto-pull.mjs +0 -31
  86. package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
  87. package/hub/team/dashboard.mjs +0 -322
  88. package/hub/team/event-log.mjs +0 -84
  89. package/hub/team/execution-mode.mjs +0 -324
  90. package/hub/team/extract-completion-payload.mjs +0 -110
  91. package/hub/team/headless-bridge-session.mjs +0 -17
  92. package/hub/team/health-probe.mjs +0 -435
  93. package/hub/team/interactive-native-launcher.mjs +0 -119
  94. package/hub/team/interactive-native-worker.mjs +0 -80
  95. package/hub/team/interactive-tui-transport.mjs +0 -479
  96. package/hub/team/launcher-template.mjs +0 -144
  97. package/hub/team/lead-control.mjs +0 -191
  98. package/hub/team/mcp-selector.mjs +0 -145
  99. package/hub/team/native-supervisor.mjs +0 -460
  100. package/hub/team/native.mjs +0 -727
  101. package/hub/team/recovery-store.mjs +0 -149
  102. package/hub/team/remote-probe.mjs +0 -292
  103. package/hub/team/remote-watcher.mjs +0 -506
  104. package/hub/team/runtime-strategy.mjs +0 -200
  105. package/hub/team/schemas/worker-completion.json +0 -39
  106. package/hub/team/sentinel-capture.mjs +0 -127
  107. package/hub/team/session-sync.mjs +0 -316
  108. package/hub/team/swarm-cli.mjs +0 -390
  109. package/hub/team/swarm-hypervisor.mjs +0 -2206
  110. package/hub/team/swarm-intent.mjs +0 -216
  111. package/hub/team/swarm-locks.mjs +0 -335
  112. package/hub/team/swarm-planner.mjs +0 -371
  113. package/hub/team/swarm-preflight.mjs +0 -366
  114. package/hub/team/swarm-reconciler.mjs +0 -242
  115. package/hub/team/synapse-cli.mjs +0 -458
  116. package/hub/team/synapse-http.mjs +0 -311
  117. package/hub/team/synapse-registry.mjs +0 -618
  118. package/hub/team/tui-remote-adapter.mjs +0 -377
  119. package/hub/team/tui-synapse.mjs +0 -223
  120. package/hub/team/worker-completion-validator.mjs +0 -79
  121. package/hub/team/worker-sandbox.mjs +0 -125
  122. package/hub/team/worktree-lifecycle.mjs +0 -639
  123. package/hub/team/wt-templates.json +0 -43
  124. package/hub/tools.mjs +0 -900
  125. package/hub/workers/antigravity-route-worker.mjs +0 -179
  126. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  127. package/hub/workers/delegator-mcp.mjs +0 -1098
  128. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  129. package/scripts/lib/claudemd-scanner.mjs +0 -223
  130. package/scripts/lib/context.mjs +0 -67
  131. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  132. package/scripts/lib/handoff.mjs +0 -187
  133. package/scripts/lib/logger.mjs +0 -122
  134. package/scripts/lib/stealth-fetch.mjs +0 -176
package/cto/status.mjs CHANGED
@@ -2,16 +2,9 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { compactHygieneCounts, projectCtoHygiene } from "./hygiene.mjs";
5
- import {
6
- normalizeLiveSession,
7
- pathLabel,
8
- readSynapseSnapshot,
9
- resolveLakeRootDir,
10
- shortHash,
11
- } from "./lake-root.mjs";
5
+ import { resolveLakeRootDir } from "./lake-root.mjs";
12
6
 
13
7
  const SCHEMA_VERSION = "cto-lake.v1";
14
- const SYNAPSE_TIMEOUT_MS = 1500;
15
8
 
16
9
  function hasFlag(args, flag) {
17
10
  return Array.isArray(args) && args.includes(flag);
@@ -25,174 +18,7 @@ function readJson(filePath) {
25
18
  return JSON.parse(readFileSync(filePath, "utf8"));
26
19
  }
27
20
 
28
- export function deriveRepoRootFromCwd(cwd) {
29
- if (typeof cwd !== "string" || !cwd) return "";
30
- if (cwd.includes("\\") || /^[A-Za-z]:/u.test(cwd)) return cwd;
31
-
32
- const normalized = cwd.replace(/\/+$/u, "");
33
- const gitWorktreeMarker = "/.worktrees/";
34
- const gitWorktreeIndex = normalized.indexOf(gitWorktreeMarker);
35
- if (gitWorktreeIndex > 0) {
36
- const suffix = normalized.slice(
37
- gitWorktreeIndex + gitWorktreeMarker.length,
38
- );
39
- if (/^[^/]+(?:\/|$)/u.test(suffix)) {
40
- return normalized.slice(0, gitWorktreeIndex);
41
- }
42
- }
43
-
44
- const claudeMarker = "/.claude/worktrees/";
45
- const claudeIndex = normalized.indexOf(claudeMarker);
46
- if (claudeIndex > 0) {
47
- const suffix = normalized.slice(claudeIndex + claudeMarker.length);
48
- if (/^[^/]+(?:\/|$)/u.test(suffix)) {
49
- return normalized.slice(0, claudeIndex);
50
- }
51
- }
52
-
53
- const codexMarker = "/.codex-swarm/";
54
- const codexIndex = normalized.indexOf(codexMarker);
55
- if (codexIndex > 0) {
56
- const suffix = normalized.slice(codexIndex + codexMarker.length);
57
- if (/^wt-[^/]+(?:\/|$)/u.test(suffix)) {
58
- return normalized.slice(0, codexIndex);
59
- }
60
- }
61
-
62
- return cwd;
63
- }
64
-
65
- function redactedCwdFields(cwd) {
66
- if (typeof cwd !== "string" || !cwd) return {};
67
- const repoRoot = deriveRepoRootFromCwd(cwd);
68
- return {
69
- cwdLabel: pathLabel(cwd),
70
- cwdHash: shortHash(cwd),
71
- repoRootLabel: pathLabel(repoRoot),
72
- repoRootHash: shortHash(repoRoot),
73
- };
74
- }
75
-
76
- function normalizeActiveShard(shard) {
77
- return {
78
- shard_name:
79
- typeof shard?.shard_name === "string"
80
- ? shard.shard_name
81
- : typeof shard?.shardName === "string"
82
- ? shard.shardName
83
- : typeof shard?.name === "string"
84
- ? shard.name
85
- : typeof shard?.id === "string"
86
- ? shard.id
87
- : null,
88
- phase:
89
- typeof shard?.phase === "string"
90
- ? shard.phase
91
- : typeof shard?.status === "string"
92
- ? shard.status
93
- : "active",
94
- members: Array.isArray(shard?.members)
95
- ? shard.members.map((member) => String(member))
96
- : [],
97
- };
98
- }
99
-
100
- function normalizeSynapseOverlay(value) {
101
- const sessions = Array.isArray(value)
102
- ? value
103
- : Array.isArray(value?.sessions)
104
- ? value.sessions
105
- : Array.isArray(value?.live_sessions)
106
- ? value.live_sessions
107
- : [];
108
- const activeShards = Array.isArray(value?.active_shards)
109
- ? value.active_shards
110
- : Array.isArray(value?.activeShards)
111
- ? value.activeShards
112
- : [];
113
-
114
- return {
115
- live_sessions: sessions
116
- .map((session) => ({
117
- ...normalizeLiveSession(session),
118
- host: typeof session?.host === "string" ? session.host : "local",
119
- ...redactedCwdFields(session?.cwd),
120
- }))
121
- .filter((session) => session.sessionId),
122
- active_shards: activeShards.map(normalizeActiveShard),
123
- };
124
- }
125
-
126
- function withTimeout(promise, timeoutMs = SYNAPSE_TIMEOUT_MS) {
127
- let timer = null;
128
- const timeout = new Promise((_, reject) => {
129
- timer = setTimeout(
130
- () => reject(new Error("synapse read timeout")),
131
- timeoutMs,
132
- );
133
- if (typeof timer.unref === "function") timer.unref();
134
- });
135
- return Promise.race([Promise.resolve(promise), timeout]).finally(() => {
136
- if (timer) clearTimeout(timer);
137
- });
138
- }
139
-
140
- async function readSynapseOverlay(opts) {
141
- try {
142
- const reader = opts.synapseReader || readSynapseSnapshot;
143
- const raw = await withTimeout(
144
- reader({
145
- rootDir: opts.rootDir,
146
- lakeRoot: opts.lakeRoot,
147
- synapsePersistPath: opts.synapsePersistPath,
148
- }),
149
- opts.synapseTimeoutMs || SYNAPSE_TIMEOUT_MS,
150
- );
151
- return normalizeSynapseOverlay(raw);
152
- } catch {
153
- return { live_sessions: [], active_shards: [] };
154
- }
155
- }
156
-
157
- // active_shards 의 source 는 live_sessions 와 다르다: live_sessions 는 synapse
158
- // overlay(실시간 세션)에서, active_shards 는 collect 가 .triflux/swarm-locks.json 을
159
- // snapshot 한 current.json(sources.tfx_swarm.detail.shards)에서 온다. synapse
160
- // overlay 가 shard 를 채우는 경로가 생기면 그쪽을 우선하고, 없으면 snapshot 으로
161
- // fallback 한다(현재 overlay 의 active_shards 는 항상 비어 snapshot 을 쓴다).
162
- function deriveActiveShards(current, overlay) {
163
- // overlay(synapse)가 채운 shard 중 식별 가능한 것만 authoritative 로 본다.
164
- // normalizeSynapseOverlay 가 shard_name 을 null 로 정규화한 무효 행이 snapshot
165
- // fallback 을 가로채지 않도록 먼저 거른다.
166
- const overlayShards = Array.isArray(overlay?.active_shards)
167
- ? overlay.active_shards.filter((shard) => shard?.shard_name)
168
- : [];
169
- if (overlayShards.length > 0) return overlayShards;
170
- const shards = current?.sources?.tfx_swarm?.detail?.shards;
171
- if (!Array.isArray(shards)) return [];
172
- return shards.map(normalizeActiveShard).filter((shard) => shard.shard_name);
173
- }
174
-
175
- function deriveLiveSessionGroups(liveSessions) {
176
- const groups = new Map();
177
- for (const session of liveSessions || []) {
178
- if (!session?.repoRootHash) continue;
179
- if (!groups.has(session.repoRootHash)) {
180
- groups.set(session.repoRootHash, {
181
- repoRootLabel: session.repoRootLabel || "",
182
- repoRootHash: session.repoRootHash,
183
- session_count: 0,
184
- sessions: [],
185
- });
186
- }
187
- const group = groups.get(session.repoRootHash);
188
- group.session_count += 1;
189
- group.sessions.push(session.sessionId);
190
- }
191
- return [...groups.values()];
192
- }
193
-
194
- function projectStatus(current, overlay) {
195
- const liveSessions = overlay.live_sessions;
21
+ function projectStatus(current) {
196
22
  return {
197
23
  schema_version: current?.schema_version || SCHEMA_VERSION,
198
24
  generated_at: current?.generated_at || null,
@@ -200,10 +26,7 @@ function projectStatus(current, overlay) {
200
26
  sources: current?.sources || {},
201
27
  summary: current?.summary || {},
202
28
  ledger_tail: Array.isArray(current?.ledger_tail) ? current.ledger_tail : [],
203
- live_sessions: liveSessions,
204
- live_session_groups: deriveLiveSessionGroups(liveSessions),
205
- active_shards: deriveActiveShards(current, overlay),
206
- hygiene: compactHygieneCounts(projectCtoHygiene({ current, overlay })),
29
+ hygiene: compactHygieneCounts(projectCtoHygiene({ current })),
207
30
  };
208
31
  }
209
32
 
@@ -262,7 +85,7 @@ function renderHumanStatus(status, now) {
262
85
  `generated_at: ${status.generated_at || "unknown"} (${formatSnapshotAge(status.generated_at, now)})`,
263
86
  `repo: ${formatRepo(status.repo)}`,
264
87
  `sources: ${available}/${total} available`,
265
- `active: ${status.live_sessions.length} sessions, ${status.active_shards.length} shards, ${activeGoals} goals`,
88
+ `active: ${activeGoals} goals`,
266
89
  ];
267
90
  if (recent.length) {
268
91
  lines.push("ledger:");
@@ -292,12 +115,7 @@ export async function runStatus(args = [], opts = {}) {
292
115
  }
293
116
 
294
117
  const current = readJson(currentPath);
295
- const overlay = await readSynapseOverlay({
296
- ...opts,
297
- rootDir,
298
- lakeRoot,
299
- });
300
- const status = projectStatus(current, overlay);
118
+ const status = projectStatus(current);
301
119
 
302
120
  if (jsonOut) writeJson(stdout, status);
303
121
  else stdout.write(renderHumanStatus(status, opts.now ?? Date.now()));
package/hub/index.mjs CHANGED
@@ -1,21 +1,4 @@
1
1
  // @triflux/remote — barrel export (auto-generated by pack.mjs)
2
- // Server
3
- export { startHub, getHubInfo, getOrCreateServer } from './server.mjs';
4
-
5
- // Store
6
- export { createStoreAdapter } from './store-adapter.mjs';
7
-
8
- // Pipe
9
- export { createPipeServer, getPipePath } from './pipe.mjs';
10
-
11
2
  // Team
12
3
  export { orchestrate, decomposeTask, buildLeadPrompt, buildPrompt } from './team/orchestrator.mjs';
13
4
  export { createPsmuxSession, startCapture } from './team/psmux.mjs';
14
- export { createConductor } from './team/conductor.mjs';
15
-
16
- // Swarm
17
- export { createSwarmLocks } from './team/swarm-locks.mjs';
18
- export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from './team/swarm-planner.mjs';
19
- export { createSwarmHypervisor, SWARM_STATES } from './team/swarm-hypervisor.mjs';
20
- export { reconcile, buildRedundantIds, shouldRunRedundant } from './team/swarm-reconciler.mjs';
21
- export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree, fetchRemoteShard } from './team/worktree-lifecycle.mjs';
@@ -23,7 +23,6 @@
23
23
  "test-engineer": "codex",
24
24
  "qa-tester": "codex",
25
25
  "codex": "codex",
26
- "gemini": "antigravity",
27
26
  "antigravity": "antigravity",
28
27
  "agy": "antigravity",
29
28
  "claude": "claude"
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ # claude agents 행의 백킹 PTY 안에서 워커 tmux pane 에 붙는다.
3
+ # bg-pty-host 가 출력 이력을 다시 재생하면 바깥 터미널이 tmux 의 오래된 질의에 또 답한다.
4
+ # tmux 는 늦게 온 응답을 키 입력으로 넘기므로 여기서 응답 시퀀스를 걸러 낸다.
5
+ import fcntl
6
+ import os
7
+ import pty
8
+ import re
9
+ import select
10
+ import signal
11
+ import sys
12
+ import termios
13
+ import time
14
+ import tty
15
+
16
+ # DA1/DA2/DA3, 창 크기 보고(CSI ... t), DECRQM, kitty 키보드 플래그 응답, DCS, OSC
17
+ REPLY = re.compile(
18
+ rb"\x1b\[[?>=][0-9;]*c"
19
+ rb"|\x1b\[[0-9]+(?:;[0-9]+)*t"
20
+ rb"|\x1b\[\?[0-9;]*\$y"
21
+ rb"|\x1b\[\?[0-9]*u"
22
+ rb"|\x1bP[^\x1b]*\x1b\\"
23
+ rb"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
24
+ )
25
+ # 응답이 read 경계에서 잘렸을 때 잠깐 붙잡아 둘 접두
26
+ PARTIAL = re.compile(
27
+ rb"(?:\x1b\[[?>=]?[0-9;$]*|\x1bP[^\x1b]*\x1b?|\x1b\][^\x07\x1b]*\x1b?)\Z"
28
+ )
29
+ HOLD_SEC = 0.05
30
+
31
+
32
+ def strip_replies(buf):
33
+ """걸러 낸 바이트와, 다음 read 까지 보류할 꼬리를 돌려준다."""
34
+ cleaned = REPLY.sub(b"", buf)
35
+ m = PARTIAL.search(cleaned)
36
+ if m and cleaned[m.start():] != b"\x1b":
37
+ return cleaned[: m.start()], cleaned[m.start():]
38
+ return cleaned, b""
39
+
40
+
41
+ def copy_winsize(src, dst):
42
+ try:
43
+ size = fcntl.ioctl(src, termios.TIOCGWINSZ, b"\0" * 8)
44
+ fcntl.ioctl(dst, termios.TIOCSWINSZ, size)
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ def relay(argv, env):
50
+ pid, fd = pty.fork()
51
+ if pid == 0:
52
+ os.execvpe(argv[0], argv, env)
53
+ stdin = sys.stdin.fileno()
54
+ stdout = sys.stdout.fileno()
55
+ copy_winsize(stdin, fd)
56
+ signal.signal(signal.SIGWINCH, lambda *_: copy_winsize(stdin, fd))
57
+ saved = termios.tcgetattr(stdin) if os.isatty(stdin) else None
58
+ if saved:
59
+ tty.setraw(stdin)
60
+ pending = b""
61
+ deadline = 0.0
62
+ try:
63
+ while True:
64
+ # 자식 출력이 계속 바빠도 보류한 바이트가 HOLD_SEC 를 넘겨 묶이지 않게 마감을 둔다.
65
+ timeout = max(0.0, deadline - time.monotonic()) if pending else None
66
+ try:
67
+ ready, _, _ = select.select([stdin, fd], [], [], timeout)
68
+ except InterruptedError:
69
+ continue
70
+ if pending and time.monotonic() >= deadline:
71
+ os.write(fd, pending)
72
+ pending = b""
73
+ if fd in ready:
74
+ try:
75
+ data = os.read(fd, 65536)
76
+ except OSError:
77
+ break
78
+ if not data:
79
+ break
80
+ os.write(stdout, data)
81
+ if stdin in ready:
82
+ # 행 PTY 가 닫히면 slave 읽기는 EIO 를 던진다.
83
+ try:
84
+ data = os.read(stdin, 65536)
85
+ except OSError:
86
+ break
87
+ if not data:
88
+ break
89
+ out, held = strip_replies(pending + data)
90
+ if held and not pending:
91
+ deadline = time.monotonic() + HOLD_SEC
92
+ pending = held
93
+ if out:
94
+ os.write(fd, out)
95
+ finally:
96
+ if saved:
97
+ try:
98
+ termios.tcsetattr(stdin, termios.TCSADRAIN, saved)
99
+ except termios.error:
100
+ pass
101
+ # 행 PTY 가 닫히면 attach client 도 끝낸다. 워커 방은 그대로 남는다.
102
+ os.close(fd)
103
+ _, status = os.waitpid(pid, 0)
104
+ return os.waitstatus_to_exitcode(status)
105
+
106
+
107
+ def main():
108
+ # 사용법: agents-row-attach.py -- <tmux attach 명령>
109
+ argv = sys.argv[1:]
110
+ if argv[:1] == ["--"]:
111
+ argv = argv[1:]
112
+ if not argv:
113
+ print("usage: agents-row-attach.py -- <command>", file=sys.stderr)
114
+ return 2
115
+ env = {k: v for k, v in os.environ.items() if k not in ("TMUX", "TMUX_PANE")}
116
+ return relay(argv, env)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main())
@@ -0,0 +1,200 @@
1
+ // hub/team/agents-row.mjs: 워커 tmux pane 을 `claude agents` 행으로 노출한다.
2
+ // 행의 백킹 job 은 공식 `claude --bg --exec` 로 띄운 tmux attach client 다.
3
+ // Enter 를 누르면 그 PTY, 곧 워커가 도는 tmux 방이 열린다(ADR-0026).
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { accessSync, constants } from "node:fs";
7
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { promisify } from "node:util";
10
+ import { getMultiplexerType, psmuxExec } from "./psmux.mjs";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const ATTACH_HELPER = join(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ "agents-row-attach.py",
16
+ );
17
+ const READ_ONLY_SUFFIX = " [read-only]";
18
+
19
+ function shellQuote(value) {
20
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
21
+ }
22
+
23
+ // daemon 이 띄운 셸의 PATH 는 호출자와 다를 수 있어 절대 경로로 넘긴다.
24
+ export function resolveExecutable(name, envPath = process.env.PATH || "") {
25
+ if (isAbsolute(name)) return name;
26
+ for (const dir of envPath.split(delimiter)) {
27
+ if (!dir) continue;
28
+ const candidate = join(dir, name);
29
+ try {
30
+ accessSync(candidate, constants.X_OK);
31
+ return candidate;
32
+ } catch {
33
+ /* 다음 후보 */
34
+ }
35
+ }
36
+ return "";
37
+ }
38
+
39
+ /** tmux target 을 서버 안에서 유일한 socket/session/pane ID 로 고정한다. */
40
+ export function resolveTmuxPane(target, { tmux = psmuxExec } = {}) {
41
+ const out = tmux([
42
+ "display-message",
43
+ "-p",
44
+ "-t",
45
+ String(target),
46
+ "#{socket_path}\t#{session_id}\t#{pane_id}\t#{session_name}",
47
+ ]);
48
+ const [socketPath, sessionId, paneId, sessionName] = String(out)
49
+ .trim()
50
+ .split("\t");
51
+ if (!socketPath || !/^\$\d+$/u.test(sessionId) || !/^%\d+$/u.test(paneId)) {
52
+ throw new Error(`tmux pane을 확인할 수 없습니다: ${target}`);
53
+ }
54
+ return { socketPath, sessionId, paneId, sessionName };
55
+ }
56
+
57
+ /**
58
+ * 행의 백킹 명령. pane 이 등록한 세션에 그대로 있는 동안 attach 를 반복하고,
59
+ * 방이 사라지면 자기 행을 지운다. 대화형은 python3 중계로 터미널 응답 누수를 걸러 내고,
60
+ * 그 밖에는 읽기 전용 attach 로 입력 자체를 막는다.
61
+ */
62
+ export function buildAgentsRowCommand({
63
+ tmuxBin,
64
+ claudeBin = "claude",
65
+ pane,
66
+ interactive = false,
67
+ python = "",
68
+ }) {
69
+ const q = shellQuote;
70
+ const tmux = `${q(tmuxBin)} -S ${q(pane.socketPath)}`;
71
+ const readOnly = !(interactive && python);
72
+ const attach = [
73
+ ...(readOnly ? [] : [q(python), q(ATTACH_HELPER), "--"]),
74
+ "env -u TMUX -u TMUX_PANE",
75
+ tmux,
76
+ `attach-session -t ${q(pane.paneId)}`,
77
+ // -r 은 read-only,ignore-size 와 같고 tmux 3.2 이전에도 있다.
78
+ ...(readOnly ? ["-r"] : []),
79
+ ].join(" ");
80
+ // 같은 번호의 다른 방에 붙지 않도록 pane 의 세션 ID 를 매번 대조한다.
81
+ const alive = `[ "$(${tmux} display-message -p -t ${q(pane.paneId)} '#{session_id}' 2>/dev/null)" = ${q(pane.sessionId)} ]`;
82
+ // CLAUDE_JOB_DIR 의 끝이 이 job 의 short id 다. job 이 끝난 뒤 지우도록 nohup 으로 띄운다.
83
+ const removeSelf = `nohup sh -c 'sleep 1; exec "$0" rm "\${CLAUDE_JOB_DIR##*/}"' ${q(claudeBin)} >/dev/null 2>&1 &`;
84
+ // attach 가 바로 끝나기를 5번 연달아 반복하면 고칠 수 없는 실패로 보고 멈춘다.
85
+ const loop = `n=0; while ${alive}; do t=$(date +%s); ${attach}; if [ $(($(date +%s) - t)) -lt 2 ]; then n=$((n + 1)); else n=0; fi; [ "$n" -ge 5 ] && break; sleep 1; done`;
86
+ return { readOnly, command: `${loop}; ${removeSelf}` };
87
+ }
88
+
89
+ // macOS 에는 CLT 설치 안내만 띄우는 /usr/bin/python3 스텁이 있어 실제로 한 번 돌려 본다.
90
+ async function usablePython(resolve, run) {
91
+ const python = resolve("python3");
92
+ if (!python) return "";
93
+ try {
94
+ await run(python, ["-c", "import pty"], { timeout: 5_000 });
95
+ return python;
96
+ } catch {
97
+ return "";
98
+ }
99
+ }
100
+
101
+ export function parseBackgroundShort(stdout) {
102
+ const match = /backgrounded · ([0-9a-f]{6,}) ·/u.exec(String(stdout));
103
+ return match ? match[1] : "";
104
+ }
105
+
106
+ /**
107
+ * 워커 pane 하나를 `claude agents` 행으로 연다.
108
+ * @returns {Promise<{short:string, name:string, readOnly:boolean, pane:object}>}
109
+ */
110
+ export async function openAgentsRow({
111
+ name,
112
+ target,
113
+ cwd = process.cwd(),
114
+ interactive = false,
115
+ _deps = {},
116
+ } = {}) {
117
+ if (!name) throw new Error("name is required");
118
+ const tmux = _deps.tmux || psmuxExec;
119
+ const run = _deps.execFile || execFileAsync;
120
+ const resolve = _deps.resolveExecutable || resolveExecutable;
121
+ const pane = resolveTmuxPane(target, { tmux });
122
+ const tmuxBin = resolve(getMultiplexerType()) || getMultiplexerType();
123
+ const claudeBin = _deps.claudeBin || resolve("claude") || "claude";
124
+ const python = interactive ? await usablePython(resolve, run) : "";
125
+ const { command, readOnly } = buildAgentsRowCommand({
126
+ tmuxBin,
127
+ claudeBin,
128
+ pane,
129
+ interactive,
130
+ python,
131
+ });
132
+ // 대화형인데 읽기 전용으로 떨어졌으면 행 이름에서 바로 보이게 한다.
133
+ const rowName = interactive && readOnly ? `${name}${READ_ONLY_SUFFIX}` : name;
134
+ const { stdout = "", stderr = "" } = await run(
135
+ claudeBin,
136
+ ["--bg", "--name", rowName, "--exec", command],
137
+ { cwd, timeout: 20_000, encoding: "utf8" },
138
+ );
139
+ const short = parseBackgroundShort(`${stdout}\n${stderr}`);
140
+ if (!short) {
141
+ throw new Error(`claude --bg 출력에서 id를 찾지 못했습니다: ${stdout}`);
142
+ }
143
+ return { short, name: rowName, readOnly, pane };
144
+ }
145
+
146
+ /** 행을 지운다. rm 은 실행 중인 job 도 멈춘다. 워커 tmux 방은 건드리지 않는다. */
147
+ export async function closeAgentsRow(short, { _deps = {} } = {}) {
148
+ if (!short) return;
149
+ const run = _deps.execFile || execFileAsync;
150
+ await run(_deps.claudeBin || "claude", ["rm", short], {
151
+ timeout: 10_000,
152
+ }).catch(() => {});
153
+ }
154
+
155
+ /** 실행이 연 행만 정확히 한 번 닫는다. */
156
+ export function createAgentsRowOwnership(close = closeAgentsRow) {
157
+ const shorts = new Set();
158
+ return {
159
+ get size() {
160
+ return shorts.size;
161
+ },
162
+ add(short) {
163
+ if (short) shorts.add(short);
164
+ },
165
+ async release() {
166
+ const owned = [...shorts];
167
+ shorts.clear();
168
+ await Promise.all(owned.map((short) => close(short)));
169
+ },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * tfx-live start 가 띄운 Claude 외 CLI 세션을 대화형 행으로 노출한다.
175
+ * Claude 세션은 원래 목록에 뜨므로 건너뛴다. 실패해도 start 는 성공으로 두고 경고만 돌려준다.
176
+ */
177
+ export async function exposeLiveSession(
178
+ { cli, session, name, cwd, remote, ready },
179
+ _deps,
180
+ ) {
181
+ if (process.env.TFX_AGENTS_ROW === "0") return {};
182
+ if (remote || cli === "claude" || !ready) return {};
183
+ try {
184
+ const row = await openAgentsRow({
185
+ name: name || session,
186
+ target: session,
187
+ cwd,
188
+ interactive: true,
189
+ _deps,
190
+ });
191
+ return {
192
+ agentsRow: row.short,
193
+ ...(row.readOnly
194
+ ? { agentsRowWarning: "python3 not found; row is read-only" }
195
+ : {}),
196
+ };
197
+ } catch (error) {
198
+ return { agentsRowWarning: error?.message || String(error) };
199
+ }
200
+ }
package/hub/team/ansi.mjs CHANGED
@@ -21,9 +21,6 @@ export function moveTo(row, col) {
21
21
  export function moveUp(n = 1) {
22
22
  return `${ESC}[${n}A`;
23
23
  }
24
- export function moveDown(n = 1) {
25
- return `${ESC}[${n}B`;
26
- }
27
24
 
28
25
  // ── 줄 제어 ──
29
26
  export const clearLine = `${ESC}[2K`;
@@ -47,7 +44,7 @@ export const FG = {
47
44
  gray: `${ESC}[90m`,
48
45
  // triflux 브랜드
49
46
  codex: `${ESC}[38;2;16;163;127m`, // #10a37f codex green
50
- gemini: `${ESC}[38;5;39m`, // blue
47
+ antigravity: `${ESC}[38;5;39m`, // blue
51
48
  claude: `${ESC}[38;2;232;112;64m`, // orange
52
49
  triflux: `${ESC}[38;5;214m`, // amber
53
50
  accent: `${ESC}[38;5;75m`, // light blue (Catppuccin blue)
@@ -439,40 +436,9 @@ export function progressBar(percent, width = 20, time) {
439
436
  return `${fillText}${emptyText}`;
440
437
  }
441
438
 
442
- // ── 애니메이션 진행률 바 (shimmer sweep) ──
443
- export function animatedProgressBar(percent, width = 20, tick = 0) {
444
- const ratio = Math.max(0, Math.min(100, percent)) / 100;
445
- const filled = Math.round(ratio * width);
446
- const empty = width - filled;
447
- if (filled === 0 || percent >= 100) return progressBar(percent, width);
448
- const baseClr = percent >= 50 ? MOCHA.partial : MOCHA.fail;
449
- const pos = tick % (filled + 3);
450
- let bar = "";
451
- for (let i = 0; i < filled; i++) {
452
- const d = Math.abs(i - pos);
453
- if (d === 0) bar += `${ESC}[97m█`;
454
- else if (d === 1) bar += `${baseClr}▓`;
455
- else bar += `${baseClr}█`;
456
- }
457
- return `${bar}${MOCHA.border}${"░".repeat(empty)}${RESET}`;
458
- }
459
-
460
- // ── 상태 아이콘 ──
461
439
  export const STATUS_ICON = {
462
440
  running: `${MOCHA.partial}⏳${RESET}`,
463
441
  completed: `${MOCHA.ok}✓${RESET}`,
464
442
  failed: `${MOCHA.fail}✗${RESET}`,
465
443
  pending: `${FG.gray}⏸${RESET}`,
466
444
  };
467
-
468
- export const CLI_ICON = {
469
- codex: `${FG.codex}⚪${RESET}`,
470
- gemini: `${FG.gemini}🔵${RESET}`,
471
- claude: `${FG.claude}🟠${RESET}`,
472
- };
473
-
474
- // ── 로딩 도트 (braille spinner) ──
475
- const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
476
- export function loadingDots(tick = 0, clr = MOCHA.thinking) {
477
- return `${clr}${BRAILLE_FRAMES[tick % BRAILLE_FRAMES.length]}${RESET}`;
478
- }