triflux 10.18.2 → 10.20.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/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +22 -0
- package/config/mcp-registry.json +44 -0
- package/hub/account-broker.mjs +33 -9
- package/hub/cli-adapter-base.mjs +19 -2
- package/hub/team/dashboard-open.mjs +24 -115
- package/hub/team/headless.mjs +1 -0
- package/hub/team/notify.mjs +9 -2
- package/hub/team/runtime-strategy.mjs +75 -17
- package/hub/team/terminal-opener.mjs +178 -0
- package/hub/team/worktree-lifecycle.mjs +16 -6
- package/hub/team/wt-manager.mjs +23 -1
- package/hub/workers/codex-mcp.mjs +49 -4
- package/package.json +67 -23
- package/scripts/mcp-gateway-ensure.mjs +14 -5
- package/scripts/mcp-gateway-integration-test.mjs +27 -11
- package/scripts/mcp-gateway-start.mjs +86 -34
- package/scripts/tfx-route-worker.mjs +14 -4
- package/scripts/tfx-route.sh +2 -2
- package/skills/tfx-research/SKILL.md +1 -1
- package/skills/tfx-wt/SKILL.md +212 -0
- package/tui/codex-profile.mjs +459 -0
- package/tui/core.mjs +266 -0
- package/tui/doctor.mjs +375 -0
- package/tui/gemini-profile.mjs +299 -0
- package/tui/monitor-data.mjs +152 -0
- package/tui/monitor.mjs +317 -0
- package/tui/setup.mjs +599 -0
- package/CLAUDE.md +0 -170
- package/references/cli-parameter-reference.md +0 -240
- package/references/codex-plugin-cc-analysis.md +0 -706
- package/references/codex-plugin-cc-code-patterns.md +0 -468
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
|
+
"name": "triflux",
|
|
4
|
+
"description": "CLI-first multi-model orchestrator — Codex/Gemini/Claude routing with DAG execution, auto-triage, and cost optimization",
|
|
5
|
+
"owner": {
|
|
6
|
+
"name": "tellang"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "triflux",
|
|
11
|
+
"description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
|
|
12
|
+
"version": "10.20.0",
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "tellang"
|
|
15
|
+
},
|
|
16
|
+
"source": {
|
|
17
|
+
"source": "npm",
|
|
18
|
+
"package": "triflux"
|
|
19
|
+
},
|
|
20
|
+
"category": "productivity",
|
|
21
|
+
"homepage": "https://github.com/tellang/triflux",
|
|
22
|
+
"tags": [
|
|
23
|
+
"multi-model",
|
|
24
|
+
"codex",
|
|
25
|
+
"gemini",
|
|
26
|
+
"cli-routing",
|
|
27
|
+
"orchestration",
|
|
28
|
+
"cost-optimization",
|
|
29
|
+
"dag-execution"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"version": "10.20.0"
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "triflux",
|
|
3
|
+
"version": "10.20.0",
|
|
4
|
+
"description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "tellang"
|
|
7
|
+
},
|
|
8
|
+
"repository": "https://github.com/tellang/triflux",
|
|
9
|
+
"homepage": "https://github.com/tellang/triflux",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude-code",
|
|
13
|
+
"plugin",
|
|
14
|
+
"codex",
|
|
15
|
+
"gemini",
|
|
16
|
+
"cli-routing",
|
|
17
|
+
"orchestration",
|
|
18
|
+
"multi-model"
|
|
19
|
+
],
|
|
20
|
+
"skills": "./skills/",
|
|
21
|
+
"hooks": "./hooks/hooks.json"
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "mcp-registry-schema",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"description": "MCP 서버 중앙 레지스트리 — 진실의 원천",
|
|
5
|
+
"defaults": {
|
|
6
|
+
"transport": "hub-url",
|
|
7
|
+
"hub_base": "http://127.0.0.1:27888"
|
|
8
|
+
},
|
|
9
|
+
"policy_notes": {
|
|
10
|
+
"transport": "Server transport accepts \"hub-url\" for the existing triflux Hub URL flow or \"http\" for direct Streamable HTTP MCP endpoints. Direct stdio registration via command/args is intentionally unsupported.",
|
|
11
|
+
"headers": "Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {\"value\":\"literal\"} for non-secret static values, {\"env\":\"ENV_VAR_NAME\"} for secrets resolved at sync/runtime, or {\"env\":\"ENV_VAR_NAME\",\"prefix\":\"Bearer \"} for common authorization formats.",
|
|
12
|
+
"secret_safety": "Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
|
|
13
|
+
"sync_denylist": "Array of client:server strings skipped by proactive registry sync, for example gemini:tfx-hub."
|
|
14
|
+
},
|
|
15
|
+
"servers": {
|
|
16
|
+
"tfx-hub": {
|
|
17
|
+
"transport": "hub-url",
|
|
18
|
+
"url": "http://127.0.0.1:27888/mcp",
|
|
19
|
+
"safe": true,
|
|
20
|
+
"targets": ["claude", "gemini", "codex"],
|
|
21
|
+
"description": "triflux Hub MCP 서버"
|
|
22
|
+
},
|
|
23
|
+
"context7": {
|
|
24
|
+
"transport": "http",
|
|
25
|
+
"url": "https://mcp.context7.com/mcp",
|
|
26
|
+
"safe": true,
|
|
27
|
+
"targets": ["claude", "gemini", "codex"],
|
|
28
|
+
"description": "Upstash Context7 — 라이브러리 문서/코드 컨텍스트 (HTTP MCP, API key 불필요)"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"policies": {
|
|
32
|
+
"stdio_action": "replace-with-hub",
|
|
33
|
+
"unknown_server_action": "warn",
|
|
34
|
+
"sync_denylist": [],
|
|
35
|
+
"watched_paths": [
|
|
36
|
+
"~/.gemini/settings.json",
|
|
37
|
+
"~/.codex/config.toml",
|
|
38
|
+
"~/.claude/settings.json",
|
|
39
|
+
"~/.claude/settings.local.json",
|
|
40
|
+
".claude/mcp.json",
|
|
41
|
+
".mcp.json"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
package/hub/account-broker.mjs
CHANGED
|
@@ -66,9 +66,17 @@ const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
|
|
|
66
66
|
const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
67
67
|
const CIRCUIT_WINDOW_MS = 10 * 60_000; // 10 minutes
|
|
68
68
|
const CIRCUIT_MAX_FAILURES = 3;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
function getAuthBasePath() {
|
|
70
|
+
return join(homedir(), ".claude", "cache", "tfx-hub");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getCodexAuthSourcePath() {
|
|
74
|
+
return join(homedir(), ".codex", "auth.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getStatePersistPath() {
|
|
78
|
+
return join(getAuthBasePath(), "broker-state.json");
|
|
79
|
+
}
|
|
72
80
|
const AUTH_SYNC_LOCK_TIMEOUT_MS = 5_000;
|
|
73
81
|
const AUTH_SYNC_LOCK_RETRY_MS = 25;
|
|
74
82
|
const AUTH_SYNC_LOCK_STALE_MS = 30_000;
|
|
@@ -78,6 +86,8 @@ const AUTH_SYNC_SAB = new Int32Array(new SharedArrayBuffer(4));
|
|
|
78
86
|
|
|
79
87
|
function persistState(stateMap) {
|
|
80
88
|
try {
|
|
89
|
+
const authBasePath = getAuthBasePath();
|
|
90
|
+
const statePersistPath = getStatePersistPath();
|
|
81
91
|
const now = Date.now();
|
|
82
92
|
const entries = {};
|
|
83
93
|
for (const [id, acct] of stateMap) {
|
|
@@ -96,8 +106,8 @@ function persistState(stateMap) {
|
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
|
-
mkdirSync(
|
|
100
|
-
writeFileSync(
|
|
109
|
+
mkdirSync(authBasePath, { recursive: true });
|
|
110
|
+
writeFileSync(statePersistPath, JSON.stringify({ ts: now, entries }));
|
|
101
111
|
} catch (err) {
|
|
102
112
|
try {
|
|
103
113
|
console.error("[account-broker] persistState failed:", err.message);
|
|
@@ -107,8 +117,9 @@ function persistState(stateMap) {
|
|
|
107
117
|
|
|
108
118
|
function loadPersistedState() {
|
|
109
119
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
const statePersistPath = getStatePersistPath();
|
|
121
|
+
if (!existsSync(statePersistPath)) return null;
|
|
122
|
+
return JSON.parse(readFileSync(statePersistPath, "utf8"));
|
|
112
123
|
} catch {
|
|
113
124
|
return null;
|
|
114
125
|
}
|
|
@@ -239,6 +250,19 @@ function withLockFile(lockPath, opts, task) {
|
|
|
239
250
|
|
|
240
251
|
// ── AccountBroker ────────────────────────────────────────────────
|
|
241
252
|
|
|
253
|
+
/**
|
|
254
|
+
* AccountBroker는 codex/gemini 계정 풀 + lease/cooldown/circuit breaker 를 관리한다.
|
|
255
|
+
*
|
|
256
|
+
* **Resolved-at-construction 정책 (lazy/eager mix 주의)**
|
|
257
|
+
*
|
|
258
|
+
* `getAuthBasePath()` / `getCodexAuthSourcePath()` 는 호출 시점에 `homedir()` 를
|
|
259
|
+
* 다시 평가하는 lazy getter 다. 그러나 default 파라미터 (`_authBasePath = getAuthBasePath()`)
|
|
260
|
+
* 는 생성자 호출 시점에 한 번만 평가되고 결과가 `#authBasePath` 등 private field 에 박힌다.
|
|
261
|
+
*
|
|
262
|
+
* 결과: 생성자 이후 process.env.HOME 변경은 broker 가 보는 경로에 전파되지 않는다.
|
|
263
|
+
* 테스트에서 HOME 을 갈아끼우려면 `new AccountBroker(config, { _authBasePath, _codexAuthSourcePath })`
|
|
264
|
+
* 로 오버라이드를 직접 주입하거나, broker 자체를 재생성해야 한다.
|
|
265
|
+
*/
|
|
242
266
|
class AccountBroker extends EventEmitter {
|
|
243
267
|
#config;
|
|
244
268
|
#state; // Map<accountId, accountState>
|
|
@@ -253,8 +277,8 @@ class AccountBroker extends EventEmitter {
|
|
|
253
277
|
config,
|
|
254
278
|
{
|
|
255
279
|
_skipPersistence = false,
|
|
256
|
-
_authBasePath =
|
|
257
|
-
_codexAuthSourcePath =
|
|
280
|
+
_authBasePath = getAuthBasePath(),
|
|
281
|
+
_codexAuthSourcePath = getCodexAuthSourcePath(),
|
|
258
282
|
_authSyncLockTimeoutMs = AUTH_SYNC_LOCK_TIMEOUT_MS,
|
|
259
283
|
_authSyncLockRetryMs = AUTH_SYNC_LOCK_RETRY_MS,
|
|
260
284
|
_authSyncLockStaleMs = AUTH_SYNC_LOCK_STALE_MS,
|
package/hub/cli-adapter-base.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Phase 2: codex-adapter.mjs에서 추출한 재사용 가능 유틸리티
|
|
3
3
|
|
|
4
4
|
import { execSync, spawn } from "node:child_process";
|
|
5
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
|
|
7
7
|
import { IS_WINDOWS, killProcess } from "./platform.mjs";
|
|
8
8
|
|
|
@@ -391,7 +391,18 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
|
|
|
391
391
|
});
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
const resultFileSignature = () => {
|
|
395
|
+
if (!resultFile) return "";
|
|
396
|
+
try {
|
|
397
|
+
const info = statSync(resultFile);
|
|
398
|
+
return `${info.size}:${info.mtimeMs}`;
|
|
399
|
+
} catch {
|
|
400
|
+
return "";
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
394
404
|
let lastBytes = 0;
|
|
405
|
+
let lastResultFileSignature = resultFileSignature();
|
|
395
406
|
let lastChange = Date.now();
|
|
396
407
|
const touch = () => {
|
|
397
408
|
lastChange = Date.now();
|
|
@@ -420,8 +431,14 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
|
|
|
420
431
|
}, timeout);
|
|
421
432
|
const stallTimer = setInterval(() => {
|
|
422
433
|
const size = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
|
|
423
|
-
|
|
434
|
+
const currentResultFileSignature = resultFileSignature();
|
|
435
|
+
if (
|
|
436
|
+
size !== lastBytes ||
|
|
437
|
+
currentResultFileSignature !== lastResultFileSignature
|
|
438
|
+
) {
|
|
424
439
|
lastBytes = size;
|
|
440
|
+
lastResultFileSignature = currentResultFileSignature;
|
|
441
|
+
touch();
|
|
425
442
|
return;
|
|
426
443
|
}
|
|
427
444
|
if (Date.now() - lastChange >= stallThresholdMs)
|
|
@@ -1,25 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { detectMultiplexer, hasWindowsTerminal, tmuxExec } from "./session.mjs";
|
|
3
|
-
import { createWtManager } from "./wt-manager.mjs";
|
|
4
|
-
|
|
5
|
-
function sanitizeWindowTitle(value, fallback = "triflux") {
|
|
6
|
-
const text = String(value || "")
|
|
7
|
-
.replace(/[\r\n]+/g, " ")
|
|
8
|
-
.trim();
|
|
9
|
-
return text || fallback;
|
|
10
|
-
}
|
|
1
|
+
import { createTerminalOpener } from "./terminal-opener.mjs";
|
|
11
2
|
|
|
12
3
|
function sanitizeSessionName(value) {
|
|
13
4
|
return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
|
|
14
5
|
}
|
|
15
6
|
|
|
16
|
-
function sanitizeWorkingDirectory(value) {
|
|
17
|
-
const text = String(value || "")
|
|
18
|
-
.replace(/[\r\n\x00-\x1f]/g, "")
|
|
19
|
-
.trim();
|
|
20
|
-
return text || process.cwd();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
7
|
export function parseWorkerNumber(value) {
|
|
24
8
|
const text = String(value || "").trim();
|
|
25
9
|
const workerMatch = text.match(/^worker-(\d+)$/i);
|
|
@@ -29,118 +13,43 @@ export function parseWorkerNumber(value) {
|
|
|
29
13
|
return null;
|
|
30
14
|
}
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
hasWtSession = !!process.env.WT_SESSION,
|
|
35
|
-
} = {}) {
|
|
36
|
-
if (openAll) return hasWtSession ? "tab" : "window";
|
|
37
|
-
return hasWtSession ? "split" : "window";
|
|
16
|
+
function ignoreAsyncFailure(value) {
|
|
17
|
+
if (value && typeof value.then === "function") void value.catch(() => {});
|
|
38
18
|
}
|
|
39
19
|
|
|
40
|
-
async function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
title = "triflux",
|
|
47
|
-
cwd = process.cwd(),
|
|
48
|
-
split = { orientation: "H", size: 0.5 },
|
|
49
|
-
} = opts;
|
|
50
|
-
|
|
51
|
-
const safeTitle = sanitizeWindowTitle(title);
|
|
52
|
-
const safeCwd = sanitizeWorkingDirectory(cwd);
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
if (mode === "split") {
|
|
56
|
-
await wt.splitPane({
|
|
57
|
-
direction: split?.orientation === "V" ? "V" : "H",
|
|
58
|
-
size: (split?.size || 0.5) * 100,
|
|
59
|
-
title: safeTitle,
|
|
60
|
-
cwd: safeCwd,
|
|
61
|
-
command: spec.args
|
|
62
|
-
? `${spec.command} ${spec.args.join(" ")}`
|
|
63
|
-
: spec.command,
|
|
64
|
-
profile: "triflux",
|
|
65
|
-
});
|
|
66
|
-
} else {
|
|
67
|
-
await wt.createTab({
|
|
68
|
-
title: safeTitle,
|
|
69
|
-
cwd: safeCwd,
|
|
70
|
-
command: spec.args
|
|
71
|
-
? `${spec.command} ${spec.args.join(" ")}`
|
|
72
|
-
: spec.command,
|
|
73
|
-
profile: "triflux",
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
return true;
|
|
77
|
-
} catch {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
20
|
+
export async function openHeadlessDashboardTarget(sessionName, opts = {}) {
|
|
21
|
+
const { openAll = false, cwd = process.cwd(), title } = opts;
|
|
22
|
+
const safeSession = sanitizeSessionName(sessionName);
|
|
23
|
+
const workerNumber =
|
|
24
|
+
opts.workerNumber ??
|
|
25
|
+
(opts.worker == null ? null : parseWorkerNumber(opts.worker));
|
|
81
26
|
|
|
82
|
-
|
|
83
|
-
const mux = detectMultiplexer();
|
|
84
|
-
if (mux === "tmux") {
|
|
85
|
-
try {
|
|
86
|
-
const title = sanitizeWindowTitle(opts.title);
|
|
87
|
-
const command = spec.args
|
|
88
|
-
? `${spec.command} ${spec.args.join(" ")}`
|
|
89
|
-
: spec.command;
|
|
90
|
-
tmuxExec(`new-window -n "${title}" "${command}"`);
|
|
91
|
-
return true;
|
|
92
|
-
} catch {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// tmux 없으면 기본 터미널
|
|
27
|
+
let opener;
|
|
97
28
|
try {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
29
|
+
const deps = opts._deps ?? {};
|
|
30
|
+
const openerFactory = deps.createTerminalOpener ?? createTerminalOpener;
|
|
31
|
+
opener = openerFactory(deps);
|
|
101
32
|
} catch {
|
|
102
|
-
return
|
|
33
|
+
return !openAll && workerNumber != null;
|
|
103
34
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function openHeadlessDashboardTarget(sessionName, opts = {}) {
|
|
107
|
-
const { worker = null, openAll = false, cwd = process.cwd(), title } = opts;
|
|
108
|
-
|
|
109
|
-
const safeSession = sanitizeSessionName(sessionName);
|
|
110
|
-
const workerNumber = worker == null ? null : parseWorkerNumber(worker);
|
|
111
35
|
|
|
112
36
|
// 선택 워커 → pane focus만 (새 창 열지 않음)
|
|
113
37
|
if (!openAll && workerNumber != null) {
|
|
114
38
|
try {
|
|
115
|
-
|
|
116
|
-
if (mux === "psmux") {
|
|
117
|
-
psmuxExec(["select-pane", "-t", `${safeSession}:0.${workerNumber}`]);
|
|
118
|
-
} else if (
|
|
119
|
-
mux === "tmux" ||
|
|
120
|
-
mux === "wsl-tmux" ||
|
|
121
|
-
mux === "git-bash-tmux"
|
|
122
|
-
) {
|
|
123
|
-
tmuxExec(`select-pane -t ${safeSession}:0.${workerNumber}`);
|
|
124
|
-
}
|
|
39
|
+
ignoreAsyncFailure(opener.focusPane(safeSession, workerNumber));
|
|
125
40
|
} catch {}
|
|
126
41
|
return true;
|
|
127
42
|
}
|
|
128
43
|
|
|
129
44
|
// 전체 열기 (Shift+Enter) → 새 창으로 세션 attach
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
} else {
|
|
140
|
-
void spawnMacTerminal(
|
|
141
|
-
{ command: "tmux", args: ["attach-session", "-t", safeSession] },
|
|
142
|
-
{ title: title || `▲ ${safeSession}`, cwd },
|
|
143
|
-
);
|
|
45
|
+
try {
|
|
46
|
+
const opened = opener.openSession(safeSession, {
|
|
47
|
+
title: title || `▲ ${safeSession}`,
|
|
48
|
+
cwd,
|
|
49
|
+
profile: opts.profile ?? "triflux",
|
|
50
|
+
});
|
|
51
|
+
return await opened;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
144
54
|
}
|
|
145
|
-
return true;
|
|
146
55
|
}
|
package/hub/team/headless.mjs
CHANGED
package/hub/team/notify.mjs
CHANGED
|
@@ -18,6 +18,13 @@ function escapePowerShellSingleQuoted(value) {
|
|
|
18
18
|
return String(value ?? "").replaceAll("'", "''");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function escapeAppleScriptString(value) {
|
|
22
|
+
return String(value ?? "")
|
|
23
|
+
.replaceAll("\\", "\\\\")
|
|
24
|
+
.replaceAll('"', '\\"')
|
|
25
|
+
.replace(/\r?\n/g, " ");
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
function normalizeTimestamp(value) {
|
|
22
29
|
if (value instanceof Date) return value.toISOString();
|
|
23
30
|
if (value == null || value === "") return new Date().toISOString();
|
|
@@ -215,8 +222,8 @@ async function sendToast(event, config, deps) {
|
|
|
215
222
|
if ((deps.platform || process.platform) === "darwin") {
|
|
216
223
|
const title = formatEventTitle(event);
|
|
217
224
|
const body = formatEventBody(event);
|
|
218
|
-
const safeTitle = title
|
|
219
|
-
const safeBody = body
|
|
225
|
+
const safeTitle = escapeAppleScriptString(title);
|
|
226
|
+
const safeBody = escapeAppleScriptString(body);
|
|
220
227
|
try {
|
|
221
228
|
await execFileAsync(
|
|
222
229
|
"osascript",
|
|
@@ -60,9 +60,25 @@ export function createPsmuxRuntime(adapter = defaultPsmuxAdapter) {
|
|
|
60
60
|
// tmux 어댑터
|
|
61
61
|
// ---------------------------------------------------------------------------
|
|
62
62
|
|
|
63
|
+
// tmux 세션 이름은 tmuxExec 명령 문자열에 직접 보간되므로 shell 메타문자가 들어오면 위험.
|
|
64
|
+
// 현 호출처는 모두 safe identifier (slug/uuid 류) 이지만 어댑터 export 가 contract 를
|
|
65
|
+
// 강제하지 않아 외부 호출자가 임의 문자열을 주입할 수 있다 → 보수적으로 검증.
|
|
66
|
+
const TMUX_SAFE_SESSION_NAME = /^[A-Za-z0-9_.:-]+$/;
|
|
67
|
+
|
|
68
|
+
function assertSafeTmuxSessionName(sessionName) {
|
|
69
|
+
const value = String(sessionName ?? "");
|
|
70
|
+
if (!TMUX_SAFE_SESSION_NAME.test(value)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Unsafe tmux session name: ${JSON.stringify(value)} (allowed: A-Za-z0-9_.:-)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
function tmuxSessionExists(sessionName) {
|
|
79
|
+
const safe = assertSafeTmuxSessionName(sessionName);
|
|
64
80
|
try {
|
|
65
|
-
tmuxExec(`has-session -t ${
|
|
81
|
+
tmuxExec(`has-session -t ${safe}`);
|
|
66
82
|
return true;
|
|
67
83
|
} catch {
|
|
68
84
|
return false;
|
|
@@ -70,12 +86,25 @@ function tmuxSessionExists(sessionName) {
|
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
function createTmuxSession(sessionName, opts = {}) {
|
|
73
|
-
|
|
89
|
+
const safe = assertSafeTmuxSessionName(sessionName);
|
|
90
|
+
tmuxExec(`new-session -d -s ${safe} -x 220 -y 55`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sendPromptToTmuxSession(sessionName, prompt) {
|
|
94
|
+
const safe = assertSafeTmuxSessionName(sessionName);
|
|
95
|
+
const quotedPrompt = String(prompt).replace(/'/g, "'\\''");
|
|
96
|
+
tmuxExec(`send-keys -t ${safe}:0.0 '${quotedPrompt}' Enter`);
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
function killTmuxSessionByName(sessionName) {
|
|
100
|
+
let safe;
|
|
77
101
|
try {
|
|
78
|
-
|
|
102
|
+
safe = assertSafeTmuxSessionName(sessionName);
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
tmuxExec(`kill-session -t ${safe}`);
|
|
79
108
|
} catch {
|
|
80
109
|
// 이미 종료된 세션 — 무시
|
|
81
110
|
}
|
|
@@ -83,6 +112,7 @@ function killTmuxSessionByName(sessionName) {
|
|
|
83
112
|
|
|
84
113
|
const defaultTmuxAdapter = {
|
|
85
114
|
createSession: createTmuxSession,
|
|
115
|
+
sendPrompt: sendPromptToTmuxSession,
|
|
86
116
|
killSession: killTmuxSessionByName,
|
|
87
117
|
hasSession: tmuxSessionExists,
|
|
88
118
|
};
|
|
@@ -90,28 +120,55 @@ const defaultTmuxAdapter = {
|
|
|
90
120
|
/**
|
|
91
121
|
* @param {{
|
|
92
122
|
* createSession: typeof createTmuxSession,
|
|
123
|
+
* sendPrompt: typeof sendPromptToTmuxSession,
|
|
93
124
|
* killSession: typeof killTmuxSessionByName,
|
|
94
125
|
* hasSession: typeof tmuxSessionExists,
|
|
95
|
-
* }} [adapter]
|
|
96
|
-
* @
|
|
126
|
+
* }} [opts.adapter]
|
|
127
|
+
* @param {string} [opts.sessionName]
|
|
128
|
+
* @returns {{
|
|
129
|
+
* name: "tmux",
|
|
130
|
+
* kind: "tmux",
|
|
131
|
+
* sessionName: string,
|
|
132
|
+
* createSession: (opts?: object) => unknown,
|
|
133
|
+
* sendPrompt: (prompt: string) => unknown,
|
|
134
|
+
* start: (sessionName: string, opts?: object) => unknown,
|
|
135
|
+
* stop: (sessionName: string) => void,
|
|
136
|
+
* isAlive: (sessionName: string) => boolean,
|
|
137
|
+
* getStatus: (sessionName: string) => RuntimeStatus,
|
|
138
|
+
* }}
|
|
97
139
|
*/
|
|
98
|
-
export function createTmuxRuntime(
|
|
140
|
+
export function createTmuxRuntime(opts = {}) {
|
|
141
|
+
const { adapter = defaultTmuxAdapter, sessionName = "" } = opts;
|
|
142
|
+
|
|
99
143
|
return {
|
|
100
144
|
name: "tmux",
|
|
101
|
-
|
|
102
|
-
|
|
145
|
+
kind: "tmux",
|
|
146
|
+
sessionName,
|
|
147
|
+
// start() 와 동일하게 첫 인자로 sessionName override 를 받는다.
|
|
148
|
+
// 첫 인자가 string 이 아니면 (옵션 객체 등) closure sessionName 을 사용하는 legacy 형태로 폴백.
|
|
149
|
+
createSession(sessionNameArg, opts) {
|
|
150
|
+
if (typeof sessionNameArg === "string") {
|
|
151
|
+
return adapter.createSession(sessionNameArg, opts ?? {});
|
|
152
|
+
}
|
|
153
|
+
return adapter.createSession(sessionName, sessionNameArg ?? {});
|
|
103
154
|
},
|
|
104
|
-
|
|
105
|
-
adapter.
|
|
155
|
+
sendPrompt(prompt, sessionNameArg = sessionName) {
|
|
156
|
+
return adapter.sendPrompt(sessionNameArg, prompt);
|
|
106
157
|
},
|
|
107
|
-
|
|
108
|
-
return adapter.
|
|
158
|
+
start(sessionNameArg = sessionName, opts = {}) {
|
|
159
|
+
return adapter.createSession(sessionNameArg, opts);
|
|
109
160
|
},
|
|
110
|
-
|
|
161
|
+
stop(sessionNameArg = sessionName) {
|
|
162
|
+
adapter.killSession(sessionNameArg);
|
|
163
|
+
},
|
|
164
|
+
isAlive(sessionNameArg = sessionName) {
|
|
165
|
+
return adapter.hasSession(sessionNameArg);
|
|
166
|
+
},
|
|
167
|
+
getStatus(sessionNameArg = sessionName) {
|
|
111
168
|
return {
|
|
112
169
|
name: "tmux",
|
|
113
|
-
sessionName,
|
|
114
|
-
alive: adapter.hasSession(
|
|
170
|
+
sessionName: sessionNameArg,
|
|
171
|
+
alive: adapter.hasSession(sessionNameArg),
|
|
115
172
|
};
|
|
116
173
|
},
|
|
117
174
|
};
|
|
@@ -119,9 +176,10 @@ export function createTmuxRuntime(adapter = defaultTmuxAdapter) {
|
|
|
119
176
|
|
|
120
177
|
/**
|
|
121
178
|
* @param {string} mode
|
|
179
|
+
* @param {object} [opts]
|
|
122
180
|
* @returns {TeamRuntime & { name: string }}
|
|
123
181
|
*/
|
|
124
|
-
export function createRuntime(mode) {
|
|
182
|
+
export function createRuntime(mode, opts = {}) {
|
|
125
183
|
const normalizedMode = String(mode || "")
|
|
126
184
|
.trim()
|
|
127
185
|
.toLowerCase();
|
|
@@ -131,7 +189,7 @@ export function createRuntime(mode) {
|
|
|
131
189
|
}
|
|
132
190
|
|
|
133
191
|
if (normalizedMode === "tmux") {
|
|
134
|
-
return createTmuxRuntime();
|
|
192
|
+
return createTmuxRuntime(opts);
|
|
135
193
|
}
|
|
136
194
|
|
|
137
195
|
if (normalizedMode === "native" || normalizedMode === "wt") {
|