triflux 10.32.0 → 10.33.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.
package/bin/triflux.mjs CHANGED
@@ -23,6 +23,7 @@ import { homedir, tmpdir } from "os";
23
23
  import { basename, dirname, join, resolve } from "path";
24
24
  import { fileURLToPath } from "url";
25
25
  import { loadDelegatorSchemaBundle } from "../hub/delegator/tool-definitions.mjs";
26
+ import { inspectClaudeRuntimeFlags } from "../hub/diagnostics/claude-runtime-flags.mjs";
26
27
  import {
27
28
  checkNetworkAvailability,
28
29
  validateRuntimeCachePaths,
@@ -2524,6 +2525,15 @@ function addDoctorCheck(report, entry) {
2524
2525
  report.checks.push(entry);
2525
2526
  }
2526
2527
 
2528
+ function readJsonIfExists(filePath) {
2529
+ if (!existsSync(filePath)) return {};
2530
+ try {
2531
+ return JSON.parse(readFileSync(filePath, "utf8"));
2532
+ } catch {
2533
+ return {};
2534
+ }
2535
+ }
2536
+
2527
2537
  function toHookCoverageName(fileName, fallbackId = "") {
2528
2538
  if (typeof fileName === "string" && fileName.trim()) {
2529
2539
  return basename(fileName).replace(/\.mjs$/i, "");
@@ -3453,6 +3463,22 @@ async function cmdDoctor(options = {}) {
3453
3463
  issues++;
3454
3464
  }
3455
3465
 
3466
+ const claudeSettings = readJsonIfExists(join(CLAUDE_DIR, "settings.json"));
3467
+ const runtimeFlags = inspectClaudeRuntimeFlags({
3468
+ env: process.env,
3469
+ settings: claudeSettings,
3470
+ });
3471
+ addDoctorCheck(report, {
3472
+ name: "claude-runtime-flags",
3473
+ status: runtimeFlags.status,
3474
+ safe_mode: runtimeFlags.safeMode,
3475
+ disable_bundled_skills: runtimeFlags.disableBundledSkills,
3476
+ managed_mcp_policy: runtimeFlags.managedMcpPolicy,
3477
+ summary: runtimeFlags.summary,
3478
+ ...(runtimeFlags.fix ? { fix: runtimeFlags.fix } : {}),
3479
+ });
3480
+ if (runtimeFlags.status === "warning") warn(runtimeFlags.summary);
3481
+
3456
3482
  // 7. psmux (Windows only)
3457
3483
  if (process.platform === "win32") {
3458
3484
  section("psmux (터미널 멀티플렉서)");
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { refreshClaudeSessionProjectionCwd } from "../hub/team/claude-session-projection.mjs";
8
+
9
+ function readStdin() {
10
+ try {
11
+ return fs.readFile(0, "utf8");
12
+ } catch {
13
+ return Promise.resolve("");
14
+ }
15
+ }
16
+
17
+ export function resolveCwdChangedPayload(input = {}, env = process.env) {
18
+ const sessionId = String(
19
+ input.session_id ??
20
+ input.sessionId ??
21
+ input.session?.id ??
22
+ env.CLAUDE_CODE_SESSION_ID ??
23
+ env.CLAUDE_SESSION_ID ??
24
+ "",
25
+ ).trim();
26
+ const cwd = String(
27
+ input.cwd ??
28
+ input.new_cwd ??
29
+ input.newCwd ??
30
+ input.current_cwd ??
31
+ input.workspace?.cwd ??
32
+ input.source?.cwd ??
33
+ "",
34
+ ).trim();
35
+ const configDir = path.resolve(
36
+ env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"),
37
+ );
38
+ return {
39
+ eventName: String(input.hook_event_name || ""),
40
+ sessionId,
41
+ cwd,
42
+ sessionsDir: path.join(configDir, "sessions"),
43
+ };
44
+ }
45
+
46
+ export async function handleCwdChangedHook(stdinText, env = process.env) {
47
+ let input = {};
48
+ try {
49
+ input = stdinText.trim() ? JSON.parse(stdinText) : {};
50
+ } catch {
51
+ return { code: 0, stdout: "" };
52
+ }
53
+ const payload = resolveCwdChangedPayload(input, env);
54
+ if (payload.eventName && payload.eventName !== "CwdChanged") {
55
+ return { code: 0, stdout: "" };
56
+ }
57
+ if (!payload.sessionId || !payload.cwd) return { code: 0, stdout: "" };
58
+
59
+ const result = await refreshClaudeSessionProjectionCwd({
60
+ sessionsDir: payload.sessionsDir,
61
+ sessionId: payload.sessionId,
62
+ cwd: payload.cwd,
63
+ });
64
+ if (!result.updated) return { code: 0, stdout: "" };
65
+ return {
66
+ code: 0,
67
+ stdout: JSON.stringify({
68
+ hookSpecificOutput: {
69
+ hookEventName: "CwdChanged",
70
+ additionalContext: `Triflux projection cwd refreshed: ${payload.cwd}`,
71
+ },
72
+ }),
73
+ };
74
+ }
75
+
76
+ if (
77
+ process.argv[1] &&
78
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
79
+ ) {
80
+ const result = await handleCwdChangedHook(await readStdin());
81
+ if (result.stdout) process.stdout.write(result.stdout);
82
+ process.exit(result.code);
83
+ }
@@ -51,6 +51,33 @@ function normalizeMode(mode, payload) {
51
51
  return "";
52
52
  }
53
53
 
54
+ function swallowStdoutWrite(_chunk, encodingOrCallback, callback) {
55
+ const done =
56
+ typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
57
+ if (typeof done === "function") done();
58
+ return true;
59
+ }
60
+
61
+ async function runHookSideEffectsWithStdoutSuppressed(fn) {
62
+ const originalStdoutWrite = stdout.write;
63
+ const originalConsoleDebug = console.debug;
64
+ const originalConsoleInfo = console.info;
65
+ const originalConsoleLog = console.log;
66
+
67
+ stdout.write = swallowStdoutWrite;
68
+ console.debug = () => {};
69
+ console.info = () => {};
70
+ console.log = () => {};
71
+ try {
72
+ return await fn();
73
+ } finally {
74
+ stdout.write = originalStdoutWrite;
75
+ console.debug = originalConsoleDebug;
76
+ console.info = originalConsoleInfo;
77
+ console.log = originalConsoleLog;
78
+ }
79
+ }
80
+
54
81
  export async function runCodexSessionHook(stdinData, opts = {}) {
55
82
  const output = "{}\n";
56
83
  const parsed = parsePayload(stdinData);
@@ -66,24 +93,26 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
66
93
  opts.drainPendingSynapse || defaultDrainPendingSynapse;
67
94
 
68
95
  try {
69
- if (mode === "register") {
70
- try {
71
- await hubEnsureRun(stdinData);
72
- } catch {}
73
- try {
74
- registerInteractiveSession(stdinData);
75
- } catch {}
76
- try {
77
- await drainPendingSynapse(1000);
78
- } catch {}
79
- } else if (mode === "heartbeat") {
80
- try {
81
- heartbeatInteractiveSession(stdinData);
82
- } catch {}
83
- try {
84
- await drainPendingSynapse(500);
85
- } catch {}
86
- }
96
+ await runHookSideEffectsWithStdoutSuppressed(async () => {
97
+ if (mode === "register") {
98
+ try {
99
+ await hubEnsureRun(stdinData);
100
+ } catch {}
101
+ try {
102
+ registerInteractiveSession(stdinData);
103
+ } catch {}
104
+ try {
105
+ await drainPendingSynapse(1000);
106
+ } catch {}
107
+ } else if (mode === "heartbeat") {
108
+ try {
109
+ heartbeatInteractiveSession(stdinData);
110
+ } catch {}
111
+ try {
112
+ await drainPendingSynapse(500);
113
+ } catch {}
114
+ }
115
+ });
87
116
  } catch {
88
117
  // Codex session hooks are observational and must never block the session.
89
118
  }
@@ -465,6 +465,28 @@ async function main() {
465
465
  }
466
466
  }
467
467
 
468
+ // ── Stop: interactive 세션 liveness heartbeat (턴 종료 시) ──
469
+ // UserPromptSubmit 은 턴 *시작* 에, Stop 은 매 어시스턴트 턴 *종료* 에 heartbeat
470
+ // 한다. 둘이 함께 "대화 중이지만 매 분 새 프롬프트를 보내지는 않는" 세션을 5분
471
+ // interactive TTL 위로 유지해, 대화 도중 stale 로 떨어져 `cto status`
472
+ // live_sessions / tray 에서 사라지는 것을 막는다. 진짜 완전 idle(턴 자체가 없음)
473
+ // 은 여전히 TTL 후 stale 로 가며 — 그게 의도된 dead-session 신호다. UserPromptSubmit
474
+ // 과 동일하게 fire-and-forget POST 직후 짧은 상한으로 drain 한다.
475
+ if (eventName === "Stop") {
476
+ try {
477
+ const { heartbeatInteractiveSession } = await import(
478
+ "./session-start-fast.mjs"
479
+ );
480
+ heartbeatInteractiveSession(stdinRaw);
481
+ const { drainPendingSynapse } = await import(
482
+ "../hub/team/synapse-http.mjs"
483
+ );
484
+ await drainPendingSynapse(500);
485
+ } catch {
486
+ /* best-effort — heartbeat 실패가 Stop 을 막지 않는다 */
487
+ }
488
+ }
489
+
468
490
  // 이벤트에 해당하는 훅 목록
469
491
  const hooks = registry.events[eventName];
470
492
  if (!hooks || hooks.length === 0) process.exit(0);
@@ -8,6 +8,19 @@
8
8
  "external_priority": 100
9
9
  },
10
10
  "events": {
11
+ "CwdChanged": [
12
+ {
13
+ "id": "tfx-claude-cwd-projection-refresh",
14
+ "source": "triflux",
15
+ "matcher": "*",
16
+ "command": "node \"${PLUGIN_ROOT}/hooks/claude-cwd-projection-refresh.mjs\"",
17
+ "priority": 0,
18
+ "enabled": true,
19
+ "timeout": 2,
20
+ "blocking": false,
21
+ "description": "Claude /cd CwdChanged 이벤트를 Triflux native-bridge projection cwd에 반영"
22
+ }
23
+ ],
11
24
  "PermissionRequest": [
12
25
  {
13
26
  "id": "tfx-permission-safe-allow",
package/hooks/hooks.json CHANGED
@@ -13,6 +13,18 @@
13
13
  ]
14
14
  }
15
15
  ],
16
+ "CwdChanged": [
17
+ {
18
+ "matcher": "*",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
23
+ "timeout": 5
24
+ }
25
+ ]
26
+ }
27
+ ],
16
28
  "UserPromptSubmit": [
17
29
  {
18
30
  "matcher": "*",
package/hub/bridge.mjs CHANGED
@@ -1207,6 +1207,7 @@ async function cmdDaemonProbe(args) {
1207
1207
  const payload = readBridgePayload(args);
1208
1208
  const {
1209
1209
  deriveClaudeDaemonPaths,
1210
+ extractClaudeAgentSessions,
1210
1211
  findDaemonJobBySessionId,
1211
1212
  findDaemonJobByShort,
1212
1213
  sendClaudeControlRequest,
@@ -1224,11 +1225,12 @@ async function cmdDaemonProbe(args) {
1224
1225
  : payload.short
1225
1226
  ? findDaemonJobByShort(list, payload.short)
1226
1227
  : null;
1228
+ const sessions = extractClaudeAgentSessions(list);
1227
1229
 
1228
1230
  return emitJson({
1229
1231
  ok: list?.ok !== false,
1230
1232
  controlSock: daemonPaths.controlSock,
1231
- sessions: list?.jobs || list?.sessions || [],
1233
+ sessions,
1232
1234
  target: target || undefined,
1233
1235
  error: list?.ok === false ? list?.error : undefined,
1234
1236
  });
@@ -0,0 +1,64 @@
1
+ export function readBooleanRuntimeFlag(value) {
2
+ const text = String(value ?? "")
3
+ .trim()
4
+ .toLowerCase();
5
+ return text === "1" || text === "true" || text === "yes" || text === "on";
6
+ }
7
+
8
+ function countStringArray(value) {
9
+ return Array.isArray(value)
10
+ ? value.filter((entry) => String(entry ?? "").trim()).length
11
+ : 0;
12
+ }
13
+
14
+ export function inspectClaudeRuntimeFlags({
15
+ env = process.env,
16
+ settings = {},
17
+ } = {}) {
18
+ const safeMode = readBooleanRuntimeFlag(env.CLAUDE_CODE_SAFE_MODE);
19
+ const disableBundledSkills =
20
+ readBooleanRuntimeFlag(env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS) ||
21
+ settings?.disableBundledSkills === true;
22
+ const allowedCount = countStringArray(settings?.allowedMcpServers);
23
+ const deniedCount = countStringArray(settings?.deniedMcpServers);
24
+ const managedMcpPolicy = {
25
+ active: allowedCount > 0 || deniedCount > 0,
26
+ allowedCount,
27
+ deniedCount,
28
+ };
29
+
30
+ const messages = [];
31
+ if (safeMode) {
32
+ messages.push(
33
+ "Claude Code safe mode is enabled; CLAUDE.md, plugins, skills, hooks, and MCP servers may be disabled.",
34
+ );
35
+ }
36
+ if (disableBundledSkills) {
37
+ messages.push(
38
+ "Claude bundled skills/workflows are disabled; slash-command and skill discovery may differ from normal sessions.",
39
+ );
40
+ }
41
+ if (managedMcpPolicy.active) {
42
+ messages.push(
43
+ `Claude managed MCP policy detected; allowed=${allowedCount}, denied=${deniedCount}.`,
44
+ );
45
+ }
46
+
47
+ return {
48
+ status: messages.length > 0 ? "warning" : "ok",
49
+ safeMode,
50
+ disableBundledSkills,
51
+ managedMcpPolicy,
52
+ summary:
53
+ messages.length > 0
54
+ ? messages.join(" ")
55
+ : "Claude Code runtime flags look normal.",
56
+ fix: safeMode
57
+ ? "Unset CLAUDE_CODE_SAFE_MODE or restart Claude Code without --safe-mode for normal Triflux hooks/skills/MCP behavior."
58
+ : disableBundledSkills
59
+ ? "Unset CLAUDE_CODE_DISABLE_BUNDLED_SKILLS or set disableBundledSkills=false if bundled skills are expected."
60
+ : managedMcpPolicy.active
61
+ ? "Review allowedMcpServers/deniedMcpServers in Claude managed settings if MCP tools are missing."
62
+ : undefined,
63
+ };
64
+ }
@@ -0,0 +1,242 @@
1
+ // hub/hub-lifecycle.mjs — Hub process lifecycle helpers
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export const HUB_DEFAULT_PORT = 27888;
9
+
10
+ const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
11
+ const EPHEMERAL_ENV_KEYS = [
12
+ "TFX_WORKER_SANDBOX_SCOPE",
13
+ "TFX_WORKER_INDEX",
14
+ "TFX_TEAM_TASK_ID",
15
+ "TFX_TEAM_AGENT_NAME",
16
+ "TFX_EPHEMERAL",
17
+ ];
18
+
19
+ function parsePositiveInt(value) {
20
+ const parsed = Number.parseInt(String(value ?? ""), 10);
21
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
22
+ }
23
+
24
+ function parsePortFromUrl(value) {
25
+ try {
26
+ return parsePositiveInt(new URL(String(value)).port);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function isHubServerCommand(command) {
33
+ return /(^|[\\/,\s])hub[\\/]server\.mjs(?=$|[\s"'`])/i.test(
34
+ String(command || ""),
35
+ );
36
+ }
37
+
38
+ export function isWorktreeOrEphemeralHubContext({
39
+ cwd = process.cwd(),
40
+ env = process.env,
41
+ } = {}) {
42
+ const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
43
+ if (
44
+ normalizedCwd.includes("/.claude/worktrees/") ||
45
+ normalizedCwd.includes("/.worktrees/") ||
46
+ normalizedCwd.includes("/.codex-swarm/wt-") ||
47
+ /(^|\/)wt-[^/]+(?:\/|$)/u.test(normalizedCwd)
48
+ ) {
49
+ return true;
50
+ }
51
+ return EPHEMERAL_ENV_KEYS.some((key) => String(env?.[key] || "").length > 0);
52
+ }
53
+
54
+ export function resolveHubPortForContext({
55
+ port,
56
+ env = process.env,
57
+ cwd = process.cwd(),
58
+ defaultPort = HUB_DEFAULT_PORT,
59
+ } = {}) {
60
+ const envPort = parsePositiveInt(port) ?? parsePositiveInt(env?.TFX_HUB_PORT);
61
+ const urlPort = parsePortFromUrl(env?.TFX_HUB_URL);
62
+ const resolvedPort = envPort ?? urlPort ?? defaultPort;
63
+ if (
64
+ resolvedPort !== defaultPort &&
65
+ isWorktreeOrEphemeralHubContext({ cwd, env })
66
+ ) {
67
+ return defaultPort;
68
+ }
69
+ return resolvedPort;
70
+ }
71
+
72
+ export function collectHubProcesses(
73
+ psOutput = "",
74
+ { currentPid = process.pid } = {},
75
+ ) {
76
+ const ownPid = Number(currentPid);
77
+ return String(psOutput)
78
+ .split(/\r?\n/u)
79
+ .flatMap((line) => {
80
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/u);
81
+ if (!match) return [];
82
+ const pid = Number.parseInt(match[1], 10);
83
+ const ppid = Number.parseInt(match[2], 10);
84
+ const command = match[3].trim();
85
+ if (!Number.isFinite(pid) || pid === ownPid) return [];
86
+ if (!/\bnode(?:\s|$)|\/node(?:\s|$)/u.test(command)) return [];
87
+ if (!isHubServerCommand(command)) return [];
88
+ return [{ pid, ppid, command }];
89
+ });
90
+ }
91
+
92
+ function readPidFilePid({
93
+ pidFilePath = HUB_PID_FILE,
94
+ readFile = readFileSync,
95
+ } = {}) {
96
+ try {
97
+ const info = JSON.parse(readFile(pidFilePath, "utf8"));
98
+ const pid = Number(info?.pid);
99
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function findListeningPidsForPort(
106
+ port = HUB_DEFAULT_PORT,
107
+ { execFileSyncFn = execFileSync } = {},
108
+ ) {
109
+ try {
110
+ const output = execFileSyncFn(
111
+ "lsof",
112
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
113
+ {
114
+ encoding: "utf8",
115
+ timeout: 1000,
116
+ maxBuffer: 1024 * 1024,
117
+ },
118
+ );
119
+ return String(output)
120
+ .split(/\r?\n/u)
121
+ .map((line) => Number.parseInt(line.trim(), 10))
122
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ async function waitForExit(
129
+ pid,
130
+ { killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
131
+ ) {
132
+ const deadline = Date.now() + Math.max(0, graceMs);
133
+ while (Date.now() <= deadline) {
134
+ try {
135
+ killFn(pid, 0);
136
+ } catch {
137
+ return true;
138
+ }
139
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
140
+ }
141
+ try {
142
+ killFn(pid, 0);
143
+ return false;
144
+ } catch {
145
+ return true;
146
+ }
147
+ }
148
+
149
+ export async function reapExistingHubProcesses({
150
+ currentPid = process.pid,
151
+ pidFilePath = HUB_PID_FILE,
152
+ execFileSyncFn = execFileSync,
153
+ killFn = process.kill,
154
+ readPidFileFn,
155
+ findListeningPidsForPortFn,
156
+ waitForExitFn,
157
+ graceMs = 5000,
158
+ pollMs = 100,
159
+ } = {}) {
160
+ let output = "";
161
+ try {
162
+ output = execFileSyncFn("ps", ["-axo", "pid,ppid,command"], {
163
+ encoding: "utf8",
164
+ timeout: 1000,
165
+ maxBuffer: 1024 * 1024,
166
+ });
167
+ } catch {
168
+ return {
169
+ candidates: [],
170
+ reaped: [],
171
+ preserved: {
172
+ currentPid: Number(currentPid),
173
+ pidFilePid: null,
174
+ defaultPortPids: [],
175
+ },
176
+ };
177
+ }
178
+
179
+ const current = Number(currentPid);
180
+ const pidFileInfo =
181
+ typeof readPidFileFn === "function"
182
+ ? readPidFileFn()
183
+ : { pid: readPidFilePid({ pidFilePath }) };
184
+ const pidFilePid = parsePositiveInt(pidFileInfo?.pid);
185
+ const defaultPortPids = [
186
+ ...new Set(
187
+ (typeof findListeningPidsForPortFn === "function"
188
+ ? findListeningPidsForPortFn(HUB_DEFAULT_PORT)
189
+ : findListeningPidsForPort(HUB_DEFAULT_PORT, { execFileSyncFn })
190
+ ).map(Number),
191
+ ),
192
+ ].filter((pid) => Number.isFinite(pid) && pid > 0);
193
+
194
+ const preservedPids = new Set(
195
+ [current, pidFilePid, ...defaultPortPids].filter(
196
+ (pid) => Number.isFinite(pid) && pid > 0,
197
+ ),
198
+ );
199
+ const candidates = collectHubProcesses(output, { currentPid: current });
200
+ const reaped = [];
201
+ const failed = [];
202
+
203
+ for (const processInfo of candidates) {
204
+ if (preservedPids.has(processInfo.pid)) continue;
205
+ try {
206
+ killFn(processInfo.pid, "SIGTERM");
207
+ const exited =
208
+ typeof waitForExitFn === "function"
209
+ ? await waitForExitFn(processInfo.pid, { killFn, graceMs, pollMs })
210
+ : await waitForExit(processInfo.pid, { killFn, graceMs, pollMs });
211
+ if (!exited) {
212
+ try {
213
+ killFn(processInfo.pid, "SIGKILL");
214
+ } catch (error) {
215
+ failed.push({
216
+ pid: processInfo.pid,
217
+ signal: "SIGKILL",
218
+ error: String(error?.message || error),
219
+ });
220
+ }
221
+ }
222
+ reaped.push(processInfo);
223
+ } catch (error) {
224
+ failed.push({
225
+ pid: processInfo.pid,
226
+ signal: "SIGTERM",
227
+ error: String(error?.message || error),
228
+ });
229
+ }
230
+ }
231
+
232
+ return {
233
+ candidates,
234
+ reaped,
235
+ failed,
236
+ preserved: {
237
+ currentPid: current,
238
+ pidFilePid,
239
+ defaultPortPids,
240
+ },
241
+ };
242
+ }
@@ -0,0 +1,94 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ export const FOCUS_SCRIPT_TIMEOUT_MS = 5_000;
4
+
5
+ const DEFAULT_FOCUS_CANDIDATES = [
6
+ "iTerm2",
7
+ "Terminal",
8
+ "Codex",
9
+ "Claude",
10
+ "Antigravity",
11
+ "Code",
12
+ ];
13
+
14
+ function normalizeAgentId(value) {
15
+ const id = String(value || "")
16
+ .trim()
17
+ .toLowerCase();
18
+ if (id === "agy" || id === "anti") return "antigravity";
19
+ return id;
20
+ }
21
+
22
+ export function getFocusCandidates({ agentId } = {}) {
23
+ switch (normalizeAgentId(agentId)) {
24
+ case "codex":
25
+ return ["Codex", "iTerm2", "Terminal", "Code"];
26
+ case "claude":
27
+ return ["Claude", "iTerm2", "Terminal"];
28
+ case "antigravity":
29
+ return ["Antigravity", "iTerm2", "Terminal"];
30
+ default:
31
+ return [...DEFAULT_FOCUS_CANDIDATES];
32
+ }
33
+ }
34
+
35
+ function appleScriptList(values) {
36
+ return `{${values.map((value) => JSON.stringify(String(value))).join(", ")}}`;
37
+ }
38
+
39
+ export function buildFocusScript({ agentId, appCandidates } = {}) {
40
+ const candidates = Array.isArray(appCandidates)
41
+ ? appCandidates.map(String).filter(Boolean)
42
+ : getFocusCandidates({ agentId });
43
+ return `
44
+ set appCandidates to ${appleScriptList(candidates)}
45
+ tell application "System Events"
46
+ set runningNames to name of every process
47
+ end tell
48
+ repeat with appName in appCandidates
49
+ set appText to appName as text
50
+ if runningNames contains appText then
51
+ tell application appText to activate
52
+ return "activated " & appText
53
+ end if
54
+ end repeat
55
+ return "no focus candidate running"
56
+ `;
57
+ }
58
+
59
+ export function focusSessionOnMac(target = {}, maybeUdsId = null) {
60
+ const normalizedTarget =
61
+ target && typeof target === "object"
62
+ ? target
63
+ : { sessionId: target, udsId: maybeUdsId };
64
+ const script = buildFocusScript({ agentId: normalizedTarget.agentId });
65
+ return new Promise((resolve) => {
66
+ execFile(
67
+ "osascript",
68
+ ["-e", script],
69
+ { timeout: FOCUS_SCRIPT_TIMEOUT_MS },
70
+ (err, stdout = "", stderr = "") => {
71
+ const output = String(stdout || stderr || "").trim();
72
+ if (err) {
73
+ console.error("mac-focus error:", err?.message || err);
74
+ resolve({
75
+ ok: false,
76
+ sessionId: normalizedTarget.sessionId || null,
77
+ pid: Number(normalizedTarget.pid) || null,
78
+ agentId: normalizedTarget.agentId || null,
79
+ error: err?.message || String(err),
80
+ output,
81
+ });
82
+ return;
83
+ }
84
+ resolve({
85
+ ok: true,
86
+ sessionId: normalizedTarget.sessionId || null,
87
+ pid: Number(normalizedTarget.pid) || null,
88
+ agentId: normalizedTarget.agentId || null,
89
+ output,
90
+ });
91
+ },
92
+ );
93
+ });
94
+ }