@triflux/remote 10.48.0 → 10.49.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.
- package/hub/server.mjs +0 -122
- package/package.json +2 -3
- package/scripts/lib/keyword-rules.mjs +83 -6
- package/hub/mac-focus.mjs +0 -94
- package/hub/mac-tray.swift +0 -144
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/public/tray.html +0 -756
- package/hub/tray-lifecycle.mjs +0 -138
- package/hub/tray-runtime.mjs +0 -265
- package/hub/tray-state.mjs +0 -524
- package/hub/tray.mjs +0 -527
package/hub/server.mjs
CHANGED
|
@@ -22,13 +22,7 @@ import {
|
|
|
22
22
|
ListToolsRequestSchema,
|
|
23
23
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
24
24
|
import { runCollect } from "../cto/collect.mjs";
|
|
25
|
-
import { resolveLakeRootDir } from "../cto/lake-root.mjs";
|
|
26
|
-
import { runStatus as runCtoStatus } from "../cto/status.mjs";
|
|
27
25
|
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
28
|
-
import {
|
|
29
|
-
inspectRegistry,
|
|
30
|
-
inspectRegistryStatus,
|
|
31
|
-
} from "../scripts/lib/mcp-guard-engine.mjs";
|
|
32
26
|
import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
|
|
33
27
|
import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
|
|
34
28
|
import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
|
|
@@ -56,7 +50,6 @@ import {
|
|
|
56
50
|
recordWorker,
|
|
57
51
|
snapshot as traceSnapshot,
|
|
58
52
|
} from "@triflux/core/hub/lib/trace-recorder.mjs";
|
|
59
|
-
import { focusSessionOnMac } from "./mac-focus.mjs";
|
|
60
53
|
import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
|
|
61
54
|
import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
|
|
62
55
|
import { createPipeServer } from "./pipe.mjs";
|
|
@@ -79,9 +72,6 @@ import {
|
|
|
79
72
|
} from "./team/synapse-registry.mjs";
|
|
80
73
|
import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
|
|
81
74
|
import { createTools } from "./tools.mjs";
|
|
82
|
-
import { spawnTrayForHub } from "./tray-lifecycle.mjs";
|
|
83
|
-
import { getRuntimeStatus } from "./tray-runtime.mjs";
|
|
84
|
-
import { buildTrayStatePayload } from "./tray-state.mjs";
|
|
85
75
|
import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
|
|
86
76
|
|
|
87
77
|
registerTeamBridge(nativeProxy);
|
|
@@ -101,7 +91,6 @@ const LOOPBACK_REMOTE_ADDRESSES = new Set([
|
|
|
101
91
|
const ALLOWED_ORIGIN_RE =
|
|
102
92
|
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
103
93
|
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
104
|
-
const CANONICAL_PROJECT_ROOT = resolveLakeRootDir(PROJECT_ROOT) || PROJECT_ROOT;
|
|
105
94
|
const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
|
|
106
95
|
const CACHE_DIR = join(homedir(), ".claude", "cache");
|
|
107
96
|
const HUB_DEFAULT_PORT = 27888;
|
|
@@ -498,7 +487,6 @@ function isPublicPath(path) {
|
|
|
498
487
|
return (
|
|
499
488
|
PUBLIC_PATHS.has(path) ||
|
|
500
489
|
path === "/dashboard" ||
|
|
501
|
-
path === "/tray.html" ||
|
|
502
490
|
path === "/api/qos-stats" ||
|
|
503
491
|
path.startsWith("/public/")
|
|
504
492
|
);
|
|
@@ -861,41 +849,10 @@ function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
|
|
|
861
849
|
return currentBroker.publicSnapshot();
|
|
862
850
|
}
|
|
863
851
|
|
|
864
|
-
async function getTrayCtoStatus() {
|
|
865
|
-
try {
|
|
866
|
-
return await runCtoStatus(["--json"], {
|
|
867
|
-
rootDir: CANONICAL_PROJECT_ROOT,
|
|
868
|
-
stdout: { write() {} },
|
|
869
|
-
});
|
|
870
|
-
} catch (error) {
|
|
871
|
-
return { error: error?.message || String(error) };
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function getTrayMcpStatus() {
|
|
876
|
-
const registryState = inspectRegistry();
|
|
877
|
-
if (!registryState.exists || !registryState.valid) {
|
|
878
|
-
return {
|
|
879
|
-
registry_path: registryState.path,
|
|
880
|
-
server_count: 0,
|
|
881
|
-
rows: [],
|
|
882
|
-
error: registryState.errors?.join("; ") || "MCP registry unavailable",
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
const status = inspectRegistryStatus(registryState.registry);
|
|
886
|
-
return {
|
|
887
|
-
registry_path: registryState.path,
|
|
888
|
-
server_count: Object.keys(registryState.registry?.servers || {}).length,
|
|
889
|
-
rows: status.rows,
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
|
|
893
852
|
function resolvePublicFilePath(path) {
|
|
894
853
|
let relativePath = null;
|
|
895
854
|
if (path === "/dashboard") {
|
|
896
855
|
relativePath = "dashboard.html";
|
|
897
|
-
} else if (path === "/tray.html") {
|
|
898
|
-
relativePath = "tray.html";
|
|
899
856
|
} else if (path.startsWith("/public/")) {
|
|
900
857
|
relativePath = path.slice("/public/".length);
|
|
901
858
|
}
|
|
@@ -1441,76 +1398,6 @@ export async function startHub({
|
|
|
1441
1398
|
});
|
|
1442
1399
|
}
|
|
1443
1400
|
|
|
1444
|
-
if (path === "/api/tray-state" && req.method === "GET") {
|
|
1445
|
-
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
|
|
1446
|
-
return writeJson(res, 403, { ok: false, error: "Loopback only" });
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
const qos = getQosStatsPayload();
|
|
1450
|
-
const synapseSnapshot = synapseRegistry.snapshot();
|
|
1451
|
-
const broker = getBrokerPublicSnapshot(brokerInstance);
|
|
1452
|
-
const runtimeStatus = getRuntimeStatus({
|
|
1453
|
-
projectRoots: [
|
|
1454
|
-
...new Set([CANONICAL_PROJECT_ROOT, PROJECT_ROOT].filter(Boolean)),
|
|
1455
|
-
],
|
|
1456
|
-
});
|
|
1457
|
-
const [ctoStatus, mcpStatus, hubStatus] = await Promise.all([
|
|
1458
|
-
getTrayCtoStatus(),
|
|
1459
|
-
Promise.resolve(getTrayMcpStatus()),
|
|
1460
|
-
pipe.executeQuery("status", {
|
|
1461
|
-
scope: "hub",
|
|
1462
|
-
include_metrics: true,
|
|
1463
|
-
}),
|
|
1464
|
-
]);
|
|
1465
|
-
const mergedCtoStatus = {
|
|
1466
|
-
...(ctoStatus || {}),
|
|
1467
|
-
roles: hubStatus?.data?.roles || ctoStatus?.roles || {},
|
|
1468
|
-
};
|
|
1469
|
-
|
|
1470
|
-
return writeJson(
|
|
1471
|
-
res,
|
|
1472
|
-
200,
|
|
1473
|
-
buildTrayStatePayload({
|
|
1474
|
-
hub: {
|
|
1475
|
-
id: String(sessionId),
|
|
1476
|
-
pid: process.pid,
|
|
1477
|
-
port,
|
|
1478
|
-
url: buildHubUrl(host, port),
|
|
1479
|
-
projectRoot: CANONICAL_PROJECT_ROOT,
|
|
1480
|
-
},
|
|
1481
|
-
qos,
|
|
1482
|
-
synapseSnapshot,
|
|
1483
|
-
broker,
|
|
1484
|
-
ctoStatus: mergedCtoStatus,
|
|
1485
|
-
mcpStatus,
|
|
1486
|
-
runtimeStatus,
|
|
1487
|
-
}),
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
if (path === "/api/focus-session" && req.method === "POST") {
|
|
1492
|
-
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
|
|
1493
|
-
return writeJson(res, 403, { ok: false, error: "Loopback only" });
|
|
1494
|
-
}
|
|
1495
|
-
let body;
|
|
1496
|
-
try {
|
|
1497
|
-
body = await parseBody(req);
|
|
1498
|
-
} catch (_e) {
|
|
1499
|
-
return writeJson(res, 400, {
|
|
1500
|
-
error: "Invalid JSON or Body too large",
|
|
1501
|
-
});
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const { sessionId, udsId } = body || {};
|
|
1505
|
-
const result = await focusSessionOnMac({
|
|
1506
|
-
sessionId,
|
|
1507
|
-
udsId,
|
|
1508
|
-
pid: body?.pid,
|
|
1509
|
-
agentId: body?.agentId ?? body?.agent_id,
|
|
1510
|
-
});
|
|
1511
|
-
return writeJson(res, 200, { ok: result.ok !== false, focus: result });
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
1401
|
// Redacted peer-discovery surface. Returns co-located live peers (same
|
|
1515
1402
|
// cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
|
|
1516
1403
|
if (path === "/synapse/peers" && req.method === "GET") {
|
|
@@ -3110,15 +2997,6 @@ if (selfRun) {
|
|
|
3110
2997
|
process.exit(0);
|
|
3111
2998
|
return;
|
|
3112
2999
|
}
|
|
3113
|
-
try {
|
|
3114
|
-
const trayPath = fileURLToPath(new URL("./tray.mjs", import.meta.url));
|
|
3115
|
-
const tray = spawnTrayForHub({ trayPath });
|
|
3116
|
-
if (tray.status === "started") {
|
|
3117
|
-
hubLog.info({ pid: tray.pid }, "tray.auto_started");
|
|
3118
|
-
}
|
|
3119
|
-
} catch (error) {
|
|
3120
|
-
hubLog.warn({ err: error }, "tray.auto_start_failed");
|
|
3121
|
-
}
|
|
3122
3000
|
const shutdown = async (signal) => {
|
|
3123
3001
|
hubLog.info({ signal }, "hub.stopping");
|
|
3124
3002
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@triflux/remote",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.49.0",
|
|
4
4
|
"description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "hub/index.mjs",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./hub/*": "./hub/*"
|
|
10
10
|
},
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=
|
|
12
|
+
"node": ">=20"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
15
|
"@triflux/core": "^10.0.0-alpha.1"
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
"better-sqlite3": "^12.10.0",
|
|
21
21
|
"pino": "^10.3.1",
|
|
22
22
|
"pino-pretty": "^13.1.3",
|
|
23
|
-
"systray2": "^2.1.4",
|
|
24
23
|
"zod": "^4.0.0"
|
|
25
24
|
},
|
|
26
25
|
"files": [
|
|
@@ -2,6 +2,11 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
const VALID_MCP_ROUTES = new Set(["codex", "antigravity", "gemini", "claude"]);
|
|
4
4
|
|
|
5
|
+
// 주입 강도. explicit = 스킬명 직접 언급(명시 토큰) → MUST 호출 문구,
|
|
6
|
+
// suggest = 자연어 매칭 → "실행 요청이면 고려하라" 수준의 제안 문구 (ADR-0021).
|
|
7
|
+
const VALID_STRENGTHS = new Set(["explicit", "suggest"]);
|
|
8
|
+
const DEFAULT_STRENGTH = "explicit";
|
|
9
|
+
|
|
5
10
|
function logRuleError(message, error) {
|
|
6
11
|
if (error) {
|
|
7
12
|
console.error(`[triflux-keyword-rules] ${message}: ${error.message}`);
|
|
@@ -13,7 +18,11 @@ function logRuleError(message, error) {
|
|
|
13
18
|
function normalizePattern(pattern) {
|
|
14
19
|
if (!pattern || typeof pattern.source !== "string") return null;
|
|
15
20
|
if (typeof pattern.flags !== "string") return null;
|
|
16
|
-
|
|
21
|
+
if (pattern.strength != null && !VALID_STRENGTHS.has(pattern.strength))
|
|
22
|
+
return null;
|
|
23
|
+
const normalized = { source: pattern.source, flags: pattern.flags };
|
|
24
|
+
if (pattern.strength != null) normalized.strength = pattern.strength;
|
|
25
|
+
return normalized;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
function normalizeState(state) {
|
|
@@ -32,13 +41,43 @@ function normalizeRule(rule) {
|
|
|
32
41
|
if (typeof rule.priority !== "number" || !Number.isFinite(rule.priority))
|
|
33
42
|
return null;
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
// strength: 규칙 기본 주입 강도. 패턴별 strength 가 있으면 그 패턴만 덮어쓴다.
|
|
45
|
+
// 값이 틀리면 규칙 자체를 무효로 본다 (platform 과 같은 규칙).
|
|
46
|
+
if (rule.strength != null && !VALID_STRENGTHS.has(rule.strength)) return null;
|
|
47
|
+
const strength = rule.strength ?? DEFAULT_STRENGTH;
|
|
48
|
+
|
|
49
|
+
const patterns = rule.patterns
|
|
50
|
+
.map(normalizePattern)
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((p) => ({ ...p, strength: p.strength ?? strength }));
|
|
36
53
|
if (patterns.length === 0) return null;
|
|
37
54
|
|
|
55
|
+
// exclude_patterns: 하나라도 맞으면 그 규칙의 suggest 매칭을 버린다.
|
|
56
|
+
// 명시 토큰(explicit) 매칭은 사용자가 스킬을 직접 부른 것이라 제외하지 않는다.
|
|
57
|
+
if (rule.exclude_patterns != null && !Array.isArray(rule.exclude_patterns))
|
|
58
|
+
return null;
|
|
59
|
+
const excludePatterns = Array.isArray(rule.exclude_patterns)
|
|
60
|
+
? rule.exclude_patterns
|
|
61
|
+
.map(normalizePattern)
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map(({ source, flags }) => ({ source, flags }))
|
|
64
|
+
: [];
|
|
65
|
+
|
|
66
|
+
// skill_candidates: 머신마다 설치 이름이 다른 외부 스킬(gstack 의 `ship` /
|
|
67
|
+
// `gstack-ship`)의 후보 목록. detector 가 실행 시점에 설치된 첫 이름을 고른다.
|
|
68
|
+
// skill 이 없으면 첫 후보를 대표 이름으로 둔다.
|
|
69
|
+
if (rule.skill_candidates != null && !Array.isArray(rule.skill_candidates))
|
|
70
|
+
return null;
|
|
71
|
+
const skillCandidates = Array.isArray(rule.skill_candidates)
|
|
72
|
+
? rule.skill_candidates
|
|
73
|
+
.filter((name) => typeof name === "string" && name.trim())
|
|
74
|
+
.map((name) => name.trim())
|
|
75
|
+
: [];
|
|
76
|
+
|
|
38
77
|
const skill =
|
|
39
78
|
typeof rule.skill === "string" && rule.skill.trim()
|
|
40
79
|
? rule.skill.trim()
|
|
41
|
-
: null;
|
|
80
|
+
: (skillCandidates[0] ?? null);
|
|
42
81
|
const action =
|
|
43
82
|
typeof rule.action === "string" && rule.action.trim()
|
|
44
83
|
? rule.action.trim()
|
|
@@ -53,6 +92,12 @@ function normalizeRule(rule) {
|
|
|
53
92
|
const hint =
|
|
54
93
|
typeof rule.hint === "string" && rule.hint.trim() ? rule.hint.trim() : null;
|
|
55
94
|
|
|
95
|
+
// suggest_when: suggest 주입문에서 "…이면 <skill> 을 고려하라"의 조건 문구.
|
|
96
|
+
const suggestWhen =
|
|
97
|
+
typeof rule.suggest_when === "string" && rule.suggest_when.trim()
|
|
98
|
+
? rule.suggest_when.trim()
|
|
99
|
+
: null;
|
|
100
|
+
|
|
56
101
|
const supersedes = Array.isArray(rule.supersedes)
|
|
57
102
|
? rule.supersedes
|
|
58
103
|
.filter((id) => typeof id === "string" && id.trim())
|
|
@@ -82,7 +127,11 @@ function normalizeRule(rule) {
|
|
|
82
127
|
return {
|
|
83
128
|
id: rule.id.trim(),
|
|
84
129
|
patterns,
|
|
130
|
+
exclude_patterns: excludePatterns,
|
|
131
|
+
strength,
|
|
132
|
+
suggest_when: suggestWhen,
|
|
85
133
|
skill,
|
|
134
|
+
skill_candidates: skillCandidates,
|
|
86
135
|
action,
|
|
87
136
|
hint,
|
|
88
137
|
priority: rule.priority,
|
|
@@ -124,6 +173,9 @@ export function compileRules(rules) {
|
|
|
124
173
|
compiledPatterns: rule.patterns.map(
|
|
125
174
|
(p) => new RegExp(p.source, p.flags),
|
|
126
175
|
),
|
|
176
|
+
compiledExcludes: (rule.exclude_patterns || []).map(
|
|
177
|
+
(p) => new RegExp(p.source, p.flags),
|
|
178
|
+
),
|
|
127
179
|
};
|
|
128
180
|
} catch (error) {
|
|
129
181
|
logRuleError(`정규식 컴파일 실패: ${rule.id}`, error);
|
|
@@ -153,12 +205,27 @@ export function matchRules(compiledRules, cleanText) {
|
|
|
153
205
|
continue;
|
|
154
206
|
}
|
|
155
207
|
|
|
156
|
-
|
|
208
|
+
// compiledPatterns[i] 와 patterns[i] 는 같은 순서다 — 패턴별 strength 를 읽는다.
|
|
209
|
+
const ruleStrength = rule.strength ?? DEFAULT_STRENGTH;
|
|
210
|
+
let explicitHit = false;
|
|
211
|
+
let suggestHit = false;
|
|
212
|
+
rule.compiledPatterns.forEach((pattern, index) => {
|
|
157
213
|
pattern.lastIndex = 0;
|
|
158
|
-
|
|
214
|
+
if (!pattern.test(cleanText)) return;
|
|
215
|
+
const strength = rule.patterns?.[index]?.strength ?? ruleStrength;
|
|
216
|
+
if (strength === "suggest") suggestHit = true;
|
|
217
|
+
else explicitHit = true;
|
|
159
218
|
});
|
|
160
219
|
|
|
161
|
-
if (!
|
|
220
|
+
if (!explicitHit && !suggestHit) continue;
|
|
221
|
+
|
|
222
|
+
if (!explicitHit) {
|
|
223
|
+
const excluded = (rule.compiledExcludes || []).some((pattern) => {
|
|
224
|
+
pattern.lastIndex = 0;
|
|
225
|
+
return pattern.test(cleanText);
|
|
226
|
+
});
|
|
227
|
+
if (excluded) continue;
|
|
228
|
+
}
|
|
162
229
|
|
|
163
230
|
matches.push({
|
|
164
231
|
id: rule.id,
|
|
@@ -173,6 +240,9 @@ export function matchRules(compiledRules, cleanText) {
|
|
|
173
240
|
repo_scope: rule.repo_scope || [],
|
|
174
241
|
platform: rule.platform || [],
|
|
175
242
|
explicit: rule.explicit === true,
|
|
243
|
+
strength: explicitHit ? "explicit" : "suggest",
|
|
244
|
+
suggest_when: rule.suggest_when || null,
|
|
245
|
+
skill_candidates: rule.skill_candidates || [],
|
|
176
246
|
});
|
|
177
247
|
}
|
|
178
248
|
|
|
@@ -197,13 +267,20 @@ export function resolveConflicts(matches) {
|
|
|
197
267
|
seen.add(match.id);
|
|
198
268
|
}
|
|
199
269
|
|
|
270
|
+
// strength 우선순위를 충돌 해소에도 적용한다 — suggest 매칭의 supersedes 는
|
|
271
|
+
// 사용자가 직접 부른 explicit 매칭을 지우지 못한다.
|
|
272
|
+
const strengthById = new Map(
|
|
273
|
+
deduped.map((match) => [match.id, match.strength ?? DEFAULT_STRENGTH]),
|
|
274
|
+
);
|
|
200
275
|
const superseded = new Set();
|
|
201
276
|
const resolved = [];
|
|
202
277
|
|
|
203
278
|
for (const match of deduped) {
|
|
204
279
|
if (superseded.has(match.id)) continue;
|
|
205
280
|
resolved.push(match);
|
|
281
|
+
const fromSuggest = (match.strength ?? DEFAULT_STRENGTH) === "suggest";
|
|
206
282
|
for (const targetId of match.supersedes || []) {
|
|
283
|
+
if (fromSuggest && strengthById.get(targetId) === "explicit") continue;
|
|
207
284
|
superseded.add(targetId);
|
|
208
285
|
}
|
|
209
286
|
}
|
package/hub/mac-focus.mjs
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
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
|
-
}
|
package/hub/mac-tray.swift
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import Cocoa
|
|
2
|
-
import WebKit
|
|
3
|
-
|
|
4
|
-
class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|
5
|
-
var statusItem: NSStatusItem!
|
|
6
|
-
var popover: NSPopover!
|
|
7
|
-
var webVC: WebViewController!
|
|
8
|
-
var port: String = "27888"
|
|
9
|
-
|
|
10
|
-
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
|
11
|
-
if CommandLine.arguments.count > 1 {
|
|
12
|
-
port = CommandLine.arguments[1]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
16
|
-
if let button = statusItem.button {
|
|
17
|
-
button.title = "T"
|
|
18
|
-
button.target = self
|
|
19
|
-
button.action = #selector(togglePopover(_:))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
webVC = WebViewController()
|
|
23
|
-
webVC.port = port
|
|
24
|
-
webVC.onFocusComplete = { [weak self] in
|
|
25
|
-
self?.popover.performClose(nil)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
popover = NSPopover()
|
|
29
|
-
popover.contentViewController = webVC
|
|
30
|
-
popover.behavior = .transient
|
|
31
|
-
popover.animates = false
|
|
32
|
-
popover.delegate = self
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
@objc func togglePopover(_ sender: Any?) {
|
|
36
|
-
if popover.isShown {
|
|
37
|
-
popover.performClose(sender)
|
|
38
|
-
} else {
|
|
39
|
-
if let button = statusItem.button {
|
|
40
|
-
webVC.reloadForPopover()
|
|
41
|
-
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
|
|
48
|
-
var webView: WKWebView!
|
|
49
|
-
var port: String = "27888"
|
|
50
|
-
let canonicalPort = "27888"
|
|
51
|
-
var consecutiveFailures = 0
|
|
52
|
-
var retryWorkItem: DispatchWorkItem?
|
|
53
|
-
var onFocusComplete: (() -> Void)?
|
|
54
|
-
|
|
55
|
-
func logError(_ msg: String) {
|
|
56
|
-
let path = "/tmp/mac-tray.log"
|
|
57
|
-
if let outputStream = OutputStream(toFileAtPath: path, append: true) {
|
|
58
|
-
outputStream.open()
|
|
59
|
-
let text = "\(Date()): \(msg)\n"
|
|
60
|
-
let bytesWritten = outputStream.write(text, maxLength: text.utf8.count)
|
|
61
|
-
outputStream.close()
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
override func loadView() {
|
|
66
|
-
let webConfiguration = WKWebViewConfiguration()
|
|
67
|
-
webConfiguration.userContentController.add(self, name: "tray")
|
|
68
|
-
let initialFrame = NSRect(x: 0, y: 0, width: 460, height: 720)
|
|
69
|
-
webView = WKWebView(frame: initialFrame, configuration: webConfiguration)
|
|
70
|
-
|
|
71
|
-
// Transparent background for Glassmorphism
|
|
72
|
-
webView.setValue(false, forKey: "drawsBackground")
|
|
73
|
-
|
|
74
|
-
self.view = NSView(frame: initialFrame)
|
|
75
|
-
webView.frame = self.view.bounds
|
|
76
|
-
webView.autoresizingMask = [.width, .height]
|
|
77
|
-
self.view.addSubview(webView)
|
|
78
|
-
|
|
79
|
-
self.preferredContentSize = initialFrame.size
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
override func viewDidLoad() {
|
|
83
|
-
super.viewDidLoad()
|
|
84
|
-
webView.navigationDelegate = self
|
|
85
|
-
loadTray()
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
func loadTray() {
|
|
89
|
-
retryWorkItem?.cancel()
|
|
90
|
-
if let url = URL(string: "http://127.0.0.1:\(port)/tray.html?t=\(Date().timeIntervalSince1970)") {
|
|
91
|
-
let request = URLRequest(url: url)
|
|
92
|
-
webView.load(request)
|
|
93
|
-
} else {
|
|
94
|
-
logError("Invalid URL")
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
func reloadForPopover() {
|
|
99
|
-
if isViewLoaded {
|
|
100
|
-
loadTray()
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
105
|
-
logError("Failed to load: \(error.localizedDescription)")
|
|
106
|
-
consecutiveFailures += 1
|
|
107
|
-
if consecutiveFailures >= 3 && port != canonicalPort {
|
|
108
|
-
port = canonicalPort
|
|
109
|
-
consecutiveFailures = 0
|
|
110
|
-
logError("Switching to canonical port \(canonicalPort) after repeated failures")
|
|
111
|
-
loadTray()
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
let retry = DispatchWorkItem { [weak self] in
|
|
115
|
-
self?.loadTray()
|
|
116
|
-
}
|
|
117
|
-
retryWorkItem = retry
|
|
118
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: retry)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
122
|
-
retryWorkItem?.cancel()
|
|
123
|
-
retryWorkItem = nil
|
|
124
|
-
consecutiveFailures = 0
|
|
125
|
-
logError("Successfully loaded URL")
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
129
|
-
guard message.name == "tray" else { return }
|
|
130
|
-
if let body = message.body as? [String: Any],
|
|
131
|
-
let type = body["type"] as? String,
|
|
132
|
-
type == "focus-complete" {
|
|
133
|
-
DispatchQueue.main.async { [weak self] in
|
|
134
|
-
self?.onFocusComplete?()
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let app = NSApplication.shared
|
|
141
|
-
let delegate = AppDelegate()
|
|
142
|
-
app.delegate = delegate
|
|
143
|
-
app.setActivationPolicy(.accessory)
|
|
144
|
-
app.run()
|
package/hub/public/tray-icon.ico
DELETED
|
Binary file
|
package/hub/public/tray-icon.png
DELETED
|
Binary file
|