triflux 3.2.0-dev.8 → 3.3.0-dev.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 (52) hide show
  1. package/bin/triflux.mjs +1296 -1055
  2. package/hooks/hooks.json +17 -0
  3. package/hooks/keyword-rules.json +20 -4
  4. package/hooks/pipeline-stop.mjs +54 -0
  5. package/hub/bridge.mjs +517 -318
  6. package/hub/hitl.mjs +45 -31
  7. package/hub/pipe.mjs +457 -0
  8. package/hub/pipeline/index.mjs +121 -0
  9. package/hub/pipeline/state.mjs +164 -0
  10. package/hub/pipeline/transitions.mjs +114 -0
  11. package/hub/router.mjs +422 -161
  12. package/hub/schema.sql +14 -0
  13. package/hub/server.mjs +499 -424
  14. package/hub/store.mjs +388 -314
  15. package/hub/team/cli-team-common.mjs +348 -0
  16. package/hub/team/cli-team-control.mjs +393 -0
  17. package/hub/team/cli-team-start.mjs +516 -0
  18. package/hub/team/cli-team-status.mjs +269 -0
  19. package/hub/team/cli.mjs +75 -1475
  20. package/hub/team/dashboard.mjs +1 -9
  21. package/hub/team/native.mjs +190 -130
  22. package/hub/team/nativeProxy.mjs +165 -78
  23. package/hub/team/orchestrator.mjs +15 -20
  24. package/hub/team/pane.mjs +137 -103
  25. package/hub/team/psmux.mjs +506 -0
  26. package/hub/team/session.mjs +393 -330
  27. package/hub/team/shared.mjs +13 -0
  28. package/hub/team/staleState.mjs +299 -0
  29. package/hub/tools.mjs +105 -31
  30. package/hub/workers/claude-worker.mjs +446 -0
  31. package/hub/workers/codex-mcp.mjs +414 -0
  32. package/hub/workers/factory.mjs +18 -0
  33. package/hub/workers/gemini-worker.mjs +349 -0
  34. package/hub/workers/interface.mjs +41 -0
  35. package/hud/hud-qos-status.mjs +1790 -1788
  36. package/package.json +4 -1
  37. package/scripts/__tests__/keyword-detector.test.mjs +8 -8
  38. package/scripts/keyword-detector.mjs +15 -0
  39. package/scripts/lib/keyword-rules.mjs +4 -1
  40. package/scripts/preflight-cache.mjs +72 -0
  41. package/scripts/psmux-steering-prototype.sh +368 -0
  42. package/scripts/setup.mjs +136 -71
  43. package/scripts/tfx-route-worker.mjs +161 -0
  44. package/scripts/tfx-route.sh +485 -91
  45. package/skills/tfx-auto/SKILL.md +90 -564
  46. package/skills/tfx-auto-codex/SKILL.md +1 -3
  47. package/skills/tfx-codex/SKILL.md +1 -4
  48. package/skills/tfx-doctor/SKILL.md +1 -0
  49. package/skills/tfx-gemini/SKILL.md +1 -4
  50. package/skills/tfx-multi/SKILL.md +378 -0
  51. package/skills/tfx-setup/SKILL.md +1 -4
  52. package/skills/tfx-team/SKILL.md +0 -304
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.2.0-dev.8",
3
+ "version": "3.3.0-dev.1",
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": {
@@ -27,6 +27,9 @@
27
27
  "scripts": {
28
28
  "setup": "node scripts/setup.mjs",
29
29
  "postinstall": "node scripts/setup.mjs",
30
+ "test": "node --test tests/**/*.test.mjs",
31
+ "test:unit": "node --test tests/unit/**/*.test.mjs",
32
+ "test:integration": "node --test tests/integration/**/*.test.mjs",
30
33
  "test:route-smoke": "node --test scripts/test-tfx-route-no-claude-native.mjs"
31
34
  },
32
35
  "engines": {
@@ -79,7 +79,7 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
79
79
  const input = [
80
80
  "정상 문장",
81
81
  "```sh",
82
- "tfx team",
82
+ "tfx multi",
83
83
  "```",
84
84
  "https://example.com/path?q=1",
85
85
  "C:\\Users\\SSAFY\\Desktop\\Projects\\tools\\triflux",
@@ -90,7 +90,7 @@ test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제
90
90
  const sanitized = sanitizeForKeywordDetection(input);
91
91
 
92
92
  assert.ok(sanitized.includes("정상 문장"));
93
- assert.ok(!sanitized.includes("tfx team"));
93
+ assert.ok(!sanitized.includes("tfx multi"));
94
94
  assert.ok(!sanitized.includes("https://"));
95
95
  assert.ok(!sanitized.includes("C:\\Users\\"));
96
96
  assert.ok(!sanitized.includes("./hooks/keyword-rules.json"));
@@ -138,7 +138,7 @@ test("compileRules: 정규식 컴파일 실패", () => {
138
138
  id: "bad-pattern",
139
139
  priority: 1,
140
140
  patterns: [{ source: "[", flags: "" }],
141
- skill: "tfx-team",
141
+ skill: "tfx-multi",
142
142
  supersedes: [],
143
143
  exclusive: false,
144
144
  state: null,
@@ -152,7 +152,7 @@ test("compileRules: 정규식 컴파일 실패", () => {
152
152
  test("matchRules: tfx 키워드 매칭", () => {
153
153
  const compiledRules = loadCompiledRules();
154
154
  const cases = [
155
- { text: "tfx team 세션 시작", expectedId: "tfx-team" },
155
+ { text: "tfx multi 세션 시작", expectedId: "tfx-multi" },
156
156
  { text: "tfx auto 돌려줘", expectedId: "tfx-auto" },
157
157
  { text: "tfx codex 로 실행", expectedId: "tfx-codex" },
158
158
  { text: "tfx gemini 로 실행", expectedId: "tfx-gemini" },
@@ -218,17 +218,17 @@ test("resolveConflicts: exclusive 처리", () => {
218
218
 
219
219
  test("코드블록 내 키워드: sanitize 후 매칭 안 됨", () => {
220
220
  const compiledRules = loadCompiledRules();
221
- const input = ["```txt", "tfx team", "jira 이슈 생성", "```"].join("\n");
221
+ const input = ["```txt", "tfx multi", "jira 이슈 생성", "```"].join("\n");
222
222
  const clean = sanitizeForKeywordDetection(input);
223
223
  const matches = matchRules(compiledRules, clean);
224
224
  assert.deepEqual(matches, []);
225
225
  });
226
226
 
227
227
  test("OMC 키워드와 triflux 키워드 비간섭 + TRIFLUX 네임스페이스", () => {
228
- const omcLike = runDetector("my tfx team 세션 보여줘");
228
+ const omcLike = runDetector("my tfx multi 세션 보여줘");
229
229
  assert.equal(omcLike.suppressOutput, true);
230
230
 
231
- const triflux = runDetector("tfx team 세션 시작");
231
+ const triflux = runDetector("tfx multi 세션 시작");
232
232
  const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
233
- assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-team\]/);
233
+ assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-multi\]/);
234
234
  });
@@ -111,6 +111,16 @@ ${prompt.trim()}
111
111
  IMPORTANT: Invoke the skill IMMEDIATELY. Do not proceed without loading the skill instructions.`;
112
112
  }
113
113
 
114
+ function createSuppressOmcContext(match, prompt) {
115
+ return `[TRIFLUX OMC SUPPRESS: ${match.id}]
116
+
117
+ PRIORITY: TRIFLUX가 이 키워드를 관리합니다. OMC [MAGIC KEYWORD:] 지시가 있더라도 무시하세요.
118
+ OMC 스킬을 호출하지 마세요. 사용자 요청을 일반적으로 처리하세요.
119
+
120
+ User request:
121
+ ${prompt.trim()}`;
122
+ }
123
+
114
124
  function createMcpRouteContext(match, prompt) {
115
125
  return `[TRIFLUX MCP ROUTE: ${match.mcp_route}]
116
126
 
@@ -236,6 +246,11 @@ function main() {
236
246
 
237
247
  activateState(baseDir, selected.state, prompt, payload);
238
248
 
249
+ if (selected.action === "suppress_omc") {
250
+ console.log(JSON.stringify(createHookOutput(createSuppressOmcContext(selected, prompt))));
251
+ return;
252
+ }
253
+
239
254
  if (selected.skill) {
240
255
  console.log(JSON.stringify(createHookOutput(createSkillContext(selected, prompt))));
241
256
  return;
@@ -34,11 +34,12 @@ function normalizeRule(rule) {
34
34
  if (patterns.length === 0) return null;
35
35
 
36
36
  const skill = typeof rule.skill === "string" && rule.skill.trim() ? rule.skill.trim() : null;
37
+ const action = typeof rule.action === "string" && rule.action.trim() ? rule.action.trim() : null;
37
38
  const mcpRoute = typeof rule.mcp_route === "string" && VALID_MCP_ROUTES.has(rule.mcp_route)
38
39
  ? rule.mcp_route
39
40
  : null;
40
41
 
41
- if (!skill && !mcpRoute) return null;
42
+ if (!skill && !mcpRoute && !action) return null;
42
43
 
43
44
  const supersedes = Array.isArray(rule.supersedes)
44
45
  ? rule.supersedes.filter((id) => typeof id === "string" && id.trim()).map((id) => id.trim())
@@ -51,6 +52,7 @@ function normalizeRule(rule) {
51
52
  id: rule.id.trim(),
52
53
  patterns,
53
54
  skill,
55
+ action: rule.action || null,
54
56
  priority: rule.priority,
55
57
  supersedes,
56
58
  exclusive: rule.exclusive === true,
@@ -114,6 +116,7 @@ export function matchRules(compiledRules, cleanText) {
114
116
  matches.push({
115
117
  id: rule.id,
116
118
  skill: rule.skill,
119
+ action: rule.action || null,
117
120
  priority: rule.priority,
118
121
  supersedes: rule.supersedes || [],
119
122
  exclusive: rule.exclusive === true,
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // scripts/preflight-cache.mjs — 세션 시작 시 preflight 점검 캐싱
3
+
4
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { execSync } from "node:child_process";
8
+
9
+ const CACHE_DIR = join(homedir(), ".claude", "cache");
10
+ const CACHE_FILE = join(CACHE_DIR, "tfx-preflight.json");
11
+ const CACHE_TTL_MS = 30_000; // 30초
12
+
13
+ function checkHub() {
14
+ try {
15
+ const res = execSync("curl -sf http://127.0.0.1:27888/status", { timeout: 3000, encoding: "utf8" });
16
+ const data = JSON.parse(res);
17
+ return { ok: true, state: data?.hub?.state || "unknown", pid: data?.pid };
18
+ } catch {
19
+ return { ok: false, state: "unreachable" };
20
+ }
21
+ }
22
+
23
+ function checkRoute() {
24
+ const routePath = join(homedir(), ".claude", "scripts", "tfx-route.sh");
25
+ return { ok: existsSync(routePath), path: routePath };
26
+ }
27
+
28
+ function checkCli(name) {
29
+ try {
30
+ const path = execSync(`which ${name} 2>/dev/null || where ${name} 2>nul`, { encoding: "utf8", timeout: 2000 }).trim();
31
+ return { ok: !!path, path };
32
+ } catch {
33
+ return { ok: false };
34
+ }
35
+ }
36
+
37
+ function runPreflight() {
38
+ const result = {
39
+ timestamp: Date.now(),
40
+ hub: checkHub(),
41
+ route: checkRoute(),
42
+ codex: checkCli("codex"),
43
+ gemini: checkCli("gemini"),
44
+ ok: false,
45
+ };
46
+ result.ok = result.hub.ok && result.route.ok;
47
+ return result;
48
+ }
49
+
50
+ // 캐시 읽기 (TTL 검증 포함)
51
+ export function readPreflightCache() {
52
+ try {
53
+ const data = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
54
+ if (Date.now() - data.timestamp < CACHE_TTL_MS) return data;
55
+ } catch {}
56
+ return null;
57
+ }
58
+
59
+ // 메인 실행
60
+ if (process.argv[1]?.endsWith("preflight-cache.mjs")) {
61
+ const result = runPreflight();
62
+ mkdirSync(CACHE_DIR, { recursive: true });
63
+ writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2));
64
+ // 간결 출력 (hook stdout)
65
+ const summary = result.ok ? "preflight: ok" : "preflight: FAIL";
66
+ const details = [];
67
+ if (!result.hub.ok) details.push("hub:" + result.hub.state);
68
+ if (!result.route.ok) details.push("route:missing");
69
+ console.log(details.length ? `${summary} (${details.join(", ")})` : summary);
70
+ }
71
+
72
+ export { runPreflight, CACHE_FILE, CACHE_TTL_MS };
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env bash
2
+ # psmux-steering-prototype.sh
3
+ # Windows psmux 환경에서 lead/codex-worker/gemini-worker pane을 만들고
4
+ # send-keys + pipe-pane 기반으로 실시간 CLI 스티어링을 실험하는 프로토타입.
5
+
6
+ set -euo pipefail
7
+
8
+ PSMUX_BIN="${PSMUX_BIN:-psmux}"
9
+ SESSION_NAME="${PSMUX_SESSION_NAME:-triflux-steering}"
10
+ WINDOW_NAME="${PSMUX_WINDOW_NAME:-control}"
11
+ PANE_LEAD="lead"
12
+ PANE_CODEX="codex-worker"
13
+ PANE_GEMINI="gemini-worker"
14
+ SHELL_COMMAND="${PSMUX_SHELL_COMMAND:-powershell.exe -NoLogo}"
15
+ CAPTURE_ROOT="${PSMUX_CAPTURE_ROOT:-${TMPDIR:-/tmp}/psmux-steering}"
16
+ CAPTURE_DIR="${CAPTURE_ROOT}/${SESSION_NAME}"
17
+ CAPTURE_HELPER_PATH="${CAPTURE_ROOT}/pipe-pane-capture.ps1"
18
+ COMPLETION_PREFIX="__TRIFLUX_DONE__:"
19
+ POLL_INTERVAL_SEC="${PSMUX_POLL_INTERVAL_SEC:-1}"
20
+
21
+ usage() {
22
+ cat <<'EOF'
23
+ Usage:
24
+ scripts/psmux-steering-prototype.sh start
25
+ scripts/psmux-steering-prototype.sh demo
26
+ scripts/psmux-steering-prototype.sh attach
27
+ scripts/psmux-steering-prototype.sh send <pane-name> <command text>
28
+ scripts/psmux-steering-prototype.sh send-no-enter <pane-name> <text>
29
+ scripts/psmux-steering-prototype.sh steer-ps <pane-name> <powershell command>
30
+ scripts/psmux-steering-prototype.sh wait <pane-name> <regex> [timeout-sec]
31
+ scripts/psmux-steering-prototype.sh logs
32
+ scripts/psmux-steering-prototype.sh cleanup
33
+
34
+ Pane names:
35
+ lead | codex-worker | gemini-worker
36
+
37
+ Environment overrides:
38
+ PSMUX_BIN
39
+ PSMUX_SESSION_NAME
40
+ PSMUX_WINDOW_NAME
41
+ PSMUX_SHELL_COMMAND
42
+ PSMUX_CAPTURE_ROOT
43
+ PSMUX_POLL_INTERVAL_SEC
44
+ EOF
45
+ }
46
+
47
+ log() {
48
+ printf '[psmux-steering] %s\n' "$*"
49
+ }
50
+
51
+ die() {
52
+ printf '[psmux-steering] ERROR: %s\n' "$*" >&2
53
+ exit 1
54
+ }
55
+
56
+ require_psmux() {
57
+ command -v "$PSMUX_BIN" >/dev/null 2>&1 || die "Cannot find '$PSMUX_BIN' in PATH."
58
+ }
59
+
60
+ session_target() {
61
+ printf '%s:%s' "$SESSION_NAME" "$WINDOW_NAME"
62
+ }
63
+
64
+ pane_target_from_index() {
65
+ local pane_index="$1"
66
+ printf '%s.%s' "$(session_target)" "$pane_index"
67
+ }
68
+
69
+ log_file_for() {
70
+ local pane_name="$1"
71
+ printf '%s/%s.log' "$CAPTURE_DIR" "$pane_name"
72
+ }
73
+
74
+ to_windows_path() {
75
+ local path_value="$1"
76
+
77
+ if command -v cygpath >/dev/null 2>&1; then
78
+ cygpath -aw "$path_value"
79
+ return 0
80
+ fi
81
+
82
+ printf '%s\n' "$path_value"
83
+ }
84
+
85
+ ensure_capture_helper() {
86
+ mkdir -p "$CAPTURE_ROOT"
87
+
88
+ cat >"$CAPTURE_HELPER_PATH" <<'EOF'
89
+ param(
90
+ [Parameter(Mandatory = $true)][string]$Path
91
+ )
92
+
93
+ $parent = Split-Path -Parent $Path
94
+ if ($parent) {
95
+ New-Item -ItemType Directory -Force -Path $parent | Out-Null
96
+ }
97
+
98
+ $reader = [Console]::In
99
+ while (($line = $reader.ReadLine()) -ne $null) {
100
+ Add-Content -LiteralPath $Path -Value $line -Encoding utf8
101
+ }
102
+ EOF
103
+ }
104
+
105
+ session_exists() {
106
+ "$PSMUX_BIN" has-session -t "$SESSION_NAME" >/dev/null 2>&1
107
+ }
108
+
109
+ resolve_pane_target() {
110
+ local pane_name="$1"
111
+ local pane_index
112
+
113
+ pane_index="$("$PSMUX_BIN" list-panes -t "$(session_target)" -F '#{pane_index} #{pane_title}' \
114
+ | awk -v wanted="$pane_name" '$2 == wanted { print $1; exit }')"
115
+
116
+ [[ -n "$pane_index" ]] || return 1
117
+ pane_target_from_index "$pane_index"
118
+ }
119
+
120
+ require_pane_target() {
121
+ local pane_name="$1"
122
+ local pane_target
123
+
124
+ pane_target="$(resolve_pane_target "$pane_name")"
125
+ [[ -n "$pane_target" ]] || die "Pane '$pane_name' not found in session '$SESSION_NAME'."
126
+ printf '%s\n' "$pane_target"
127
+ }
128
+
129
+ set_pane_title() {
130
+ local pane_target="$1"
131
+ local pane_name="$2"
132
+
133
+ "$PSMUX_BIN" select-pane -t "$pane_target" -T "$pane_name" >/dev/null
134
+ }
135
+
136
+ start_capture_for_pane() {
137
+ local pane_name="$1"
138
+ local pane_target log_file helper_windows_path log_windows_path
139
+
140
+ pane_target="$(require_pane_target "$pane_name")"
141
+ log_file="$(log_file_for "$pane_name")"
142
+ ensure_capture_helper
143
+ helper_windows_path="$(to_windows_path "$CAPTURE_HELPER_PATH")"
144
+ log_windows_path="$(to_windows_path "$log_file")"
145
+
146
+ mkdir -p "$CAPTURE_DIR"
147
+ : >"$log_file"
148
+
149
+ "$PSMUX_BIN" pipe-pane -t "$pane_target" >/dev/null 2>&1 || true
150
+ "$PSMUX_BIN" pipe-pane -t "$pane_target" powershell.exe -NoLogo -NoProfile -File "$helper_windows_path" "$log_windows_path" >/dev/null
151
+ refresh_snapshot_for_pane "$pane_name"
152
+ }
153
+
154
+ start_capture_for_all_panes() {
155
+ start_capture_for_pane "$PANE_LEAD"
156
+ start_capture_for_pane "$PANE_CODEX"
157
+ start_capture_for_pane "$PANE_GEMINI"
158
+ }
159
+
160
+ stop_capture_for_pane() {
161
+ local pane_name="$1"
162
+ local pane_target
163
+
164
+ pane_target="$(resolve_pane_target "$pane_name" || true)"
165
+ [[ -n "$pane_target" ]] || return 0
166
+ "$PSMUX_BIN" pipe-pane -t "$pane_target" >/dev/null 2>&1 || true
167
+ }
168
+
169
+ refresh_snapshot_for_pane() {
170
+ local pane_name="$1"
171
+ local pane_target log_file
172
+
173
+ pane_target="$(require_pane_target "$pane_name")"
174
+ log_file="$(log_file_for "$pane_name")"
175
+ mkdir -p "$CAPTURE_DIR"
176
+
177
+ # Detached Windows sessions may not flush pipe-pane reliably yet.
178
+ # Overwriting the log with a fresh capture-pane snapshot keeps
179
+ # completion detection deterministic for the prototype.
180
+ "$PSMUX_BIN" capture-pane -t "$pane_target" -p >"$log_file"
181
+ }
182
+
183
+ send_keys_to_pane() {
184
+ local pane_name="$1"
185
+ local text="$2"
186
+ local submit="${3:-1}"
187
+ local pane_target
188
+
189
+ pane_target="$(require_pane_target "$pane_name")"
190
+ "$PSMUX_BIN" send-keys -t "$pane_target" -l "$text"
191
+ if [[ "$submit" != "0" ]]; then
192
+ "$PSMUX_BIN" send-keys -t "$pane_target" C-m
193
+ fi
194
+ }
195
+
196
+ dispatch_powershell_command() {
197
+ local pane_name="$1"
198
+ local command_text="$2"
199
+ local token wrapped
200
+
201
+ token="${pane_name}-$(date +%s)-$RANDOM"
202
+ wrapped="${command_text}; \$trifluxExit = if (\$null -ne \$LASTEXITCODE) { [int]\$LASTEXITCODE } else { 0 }; Write-Output \"${COMPLETION_PREFIX}${token}:\$trifluxExit\""
203
+
204
+ send_keys_to_pane "$pane_name" "$wrapped" 1
205
+ printf '%s\n' "$token"
206
+ }
207
+
208
+ wait_for_pattern() {
209
+ local pane_name="$1"
210
+ local pattern="$2"
211
+ local timeout_sec="${3:-300}"
212
+ local log_file deadline
213
+
214
+ log_file="$(log_file_for "$pane_name")"
215
+ [[ -f "$log_file" ]] || die "Log file for pane '$pane_name' does not exist. Start capture first."
216
+
217
+ deadline=$((SECONDS + timeout_sec))
218
+ while (( SECONDS <= deadline )); do
219
+ refresh_snapshot_for_pane "$pane_name"
220
+ if grep -Eq -- "$pattern" "$log_file"; then
221
+ return 0
222
+ fi
223
+ sleep "$POLL_INTERVAL_SEC"
224
+ done
225
+
226
+ return 1
227
+ }
228
+
229
+ wait_for_completion_token() {
230
+ local pane_name="$1"
231
+ local token="$2"
232
+ local timeout_sec="${3:-300}"
233
+ local pattern
234
+
235
+ pattern="${COMPLETION_PREFIX}${token}:[0-9]+"
236
+ wait_for_pattern "$pane_name" "$pattern" "$timeout_sec"
237
+ }
238
+
239
+ print_log_locations() {
240
+ mkdir -p "$CAPTURE_DIR"
241
+ printf '%s\t%s\n' "$PANE_LEAD" "$(log_file_for "$PANE_LEAD")"
242
+ printf '%s\t%s\n' "$PANE_CODEX" "$(log_file_for "$PANE_CODEX")"
243
+ printf '%s\t%s\n' "$PANE_GEMINI" "$(log_file_for "$PANE_GEMINI")"
244
+ }
245
+
246
+ create_session_layout() {
247
+ local lead_index codex_index gemini_index
248
+
249
+ require_psmux
250
+
251
+ if session_exists; then
252
+ die "Session '$SESSION_NAME' already exists. Run cleanup first or set PSMUX_SESSION_NAME."
253
+ fi
254
+
255
+ mkdir -p "$CAPTURE_DIR"
256
+
257
+ lead_index="$("$PSMUX_BIN" new-session -d -P -F '#{pane_index}' -s "$SESSION_NAME" -n "$WINDOW_NAME" -- $SHELL_COMMAND)"
258
+ codex_index="$("$PSMUX_BIN" split-window -h -P -F '#{pane_index}' -t "$(session_target)" -- $SHELL_COMMAND)"
259
+ gemini_index="$("$PSMUX_BIN" split-window -v -P -F '#{pane_index}' -t "$(pane_target_from_index "$codex_index")" -- $SHELL_COMMAND)"
260
+
261
+ set_pane_title "$(pane_target_from_index "$lead_index")" "$PANE_LEAD"
262
+ set_pane_title "$(pane_target_from_index "$codex_index")" "$PANE_CODEX"
263
+ set_pane_title "$(pane_target_from_index "$gemini_index")" "$PANE_GEMINI"
264
+
265
+ "$PSMUX_BIN" select-layout -t "$(session_target)" tiled >/dev/null
266
+ "$PSMUX_BIN" select-pane -t "$(pane_target_from_index "$lead_index")" >/dev/null
267
+
268
+ start_capture_for_all_panes
269
+ }
270
+
271
+ show_start_summary() {
272
+ log "Session created: $SESSION_NAME"
273
+ log "Window: $WINDOW_NAME"
274
+ log "Attach with: $PSMUX_BIN attach -t $SESSION_NAME"
275
+ print_log_locations
276
+ }
277
+
278
+ run_demo() {
279
+ local lead_token codex_token gemini_token
280
+
281
+ create_session_layout
282
+
283
+ lead_token="$(dispatch_powershell_command "$PANE_LEAD" 'Write-Host "lead pane ready"')"
284
+ codex_token="$(dispatch_powershell_command "$PANE_CODEX" 'Write-Host "codex-worker pane ready"')"
285
+ gemini_token="$(dispatch_powershell_command "$PANE_GEMINI" 'Write-Host "gemini-worker pane ready"')"
286
+
287
+ wait_for_completion_token "$PANE_LEAD" "$lead_token" 30 || die "Lead pane demo command timed out."
288
+ wait_for_completion_token "$PANE_CODEX" "$codex_token" 30 || die "Codex pane demo command timed out."
289
+ wait_for_completion_token "$PANE_GEMINI" "$gemini_token" 30 || die "Gemini pane demo command timed out."
290
+
291
+ show_start_summary
292
+ }
293
+
294
+ cleanup() {
295
+ stop_capture_for_pane "$PANE_LEAD"
296
+ stop_capture_for_pane "$PANE_CODEX"
297
+ stop_capture_for_pane "$PANE_GEMINI"
298
+
299
+ if session_exists; then
300
+ "$PSMUX_BIN" kill-session -t "$SESSION_NAME" >/dev/null 2>&1 || true
301
+ fi
302
+ }
303
+
304
+ main() {
305
+ local action="${1:-demo}"
306
+
307
+ case "$action" in
308
+ start)
309
+ create_session_layout
310
+ show_start_summary
311
+ ;;
312
+ demo)
313
+ run_demo
314
+ ;;
315
+ attach)
316
+ require_psmux
317
+ "$PSMUX_BIN" attach -t "$SESSION_NAME"
318
+ ;;
319
+ send)
320
+ [[ $# -ge 3 ]] || die "Usage: $0 send <pane-name> <command text>"
321
+ shift
322
+ local pane_name="$1"
323
+ shift
324
+ send_keys_to_pane "$pane_name" "$*" 1
325
+ ;;
326
+ send-no-enter)
327
+ [[ $# -ge 3 ]] || die "Usage: $0 send-no-enter <pane-name> <text>"
328
+ shift
329
+ local pane_name="$1"
330
+ shift
331
+ send_keys_to_pane "$pane_name" "$*" 0
332
+ ;;
333
+ steer-ps)
334
+ [[ $# -ge 3 ]] || die "Usage: $0 steer-ps <pane-name> <powershell command>"
335
+ shift
336
+ local pane_name="$1"
337
+ shift
338
+ dispatch_powershell_command "$pane_name" "$*"
339
+ ;;
340
+ wait)
341
+ [[ $# -ge 3 ]] || die "Usage: $0 wait <pane-name> <regex> [timeout-sec]"
342
+ shift
343
+ local pane_name="$1"
344
+ local pattern="$2"
345
+ local timeout_sec="${3:-300}"
346
+ if wait_for_pattern "$pane_name" "$pattern" "$timeout_sec"; then
347
+ log "Matched pattern for pane '$pane_name': $pattern"
348
+ else
349
+ die "Timed out waiting for pane '$pane_name' pattern: $pattern"
350
+ fi
351
+ ;;
352
+ logs)
353
+ print_log_locations
354
+ ;;
355
+ cleanup)
356
+ cleanup
357
+ ;;
358
+ -h|--help|help)
359
+ usage
360
+ ;;
361
+ *)
362
+ usage
363
+ die "Unknown action: $action"
364
+ ;;
365
+ esac
366
+ }
367
+
368
+ main "$@"