@triflux/remote 10.33.1 → 10.34.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/hub-lifecycle.mjs +18 -12
- package/hub/server.mjs +1 -0
- package/hub/team/claude-daemon-control.mjs +34 -2
- package/hub/team/synapse-registry.mjs +32 -5
- package/hub/tray-lifecycle.mjs +7 -1
- package/hub/tray.mjs +13 -8
- package/package.json +1 -1
package/hub/hub-lifecycle.mjs
CHANGED
|
@@ -15,15 +15,21 @@ const EPHEMERAL_ENV_KEYS = [
|
|
|
15
15
|
"TFX_TEAM_AGENT_NAME",
|
|
16
16
|
"TFX_EPHEMERAL",
|
|
17
17
|
];
|
|
18
|
+
const WORKTREE_CWD_PATTERNS = [
|
|
19
|
+
/\/\.claude\/worktrees\//u,
|
|
20
|
+
/\/\.worktrees\//u,
|
|
21
|
+
/\/\.codex-swarm\/wt-[^/]+(?:\/|$)/u,
|
|
22
|
+
/(^|\/)wt-[^/]+(?:\/|$)/u,
|
|
23
|
+
];
|
|
18
24
|
|
|
19
|
-
function
|
|
25
|
+
function parsePositivePort(value) {
|
|
20
26
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
21
27
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
function
|
|
30
|
+
function parsePortFromHubUrl(value) {
|
|
25
31
|
try {
|
|
26
|
-
return
|
|
32
|
+
return parsePositivePort(new URL(String(value)).port);
|
|
27
33
|
} catch {
|
|
28
34
|
return null;
|
|
29
35
|
}
|
|
@@ -40,12 +46,7 @@ export function isWorktreeOrEphemeralHubContext({
|
|
|
40
46
|
env = process.env,
|
|
41
47
|
} = {}) {
|
|
42
48
|
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
|
+
if (WORKTREE_CWD_PATTERNS.some((pattern) => pattern.test(normalizedCwd))) {
|
|
49
50
|
return true;
|
|
50
51
|
}
|
|
51
52
|
return EPHEMERAL_ENV_KEYS.some((key) => String(env?.[key] || "").length > 0);
|
|
@@ -57,11 +58,16 @@ export function resolveHubPortForContext({
|
|
|
57
58
|
cwd = process.cwd(),
|
|
58
59
|
defaultPort = HUB_DEFAULT_PORT,
|
|
59
60
|
} = {}) {
|
|
60
|
-
const envPort =
|
|
61
|
-
|
|
61
|
+
const envPort =
|
|
62
|
+
parsePositivePort(port) ?? parsePositivePort(env?.TFX_HUB_PORT);
|
|
63
|
+
const urlPort = parsePortFromHubUrl(env?.TFX_HUB_URL);
|
|
62
64
|
const resolvedPort = envPort ?? urlPort ?? defaultPort;
|
|
63
65
|
if (
|
|
64
66
|
resolvedPort !== defaultPort &&
|
|
67
|
+
// Test-only opt-in seam: TFX_HUB_ALLOW_EPHEMERAL_PORT=1 lets an ephemeral
|
|
68
|
+
// context (worktree cwd / TFX_TEAM_* env) honor the resolved port instead
|
|
69
|
+
// of clamping to the canonical default. Default-off — production unchanged.
|
|
70
|
+
String(env?.TFX_HUB_ALLOW_EPHEMERAL_PORT ?? "") !== "1" &&
|
|
65
71
|
isWorktreeOrEphemeralHubContext({ cwd, env })
|
|
66
72
|
) {
|
|
67
73
|
return defaultPort;
|
|
@@ -181,7 +187,7 @@ export async function reapExistingHubProcesses({
|
|
|
181
187
|
typeof readPidFileFn === "function"
|
|
182
188
|
? readPidFileFn()
|
|
183
189
|
: { pid: readPidFilePid({ pidFilePath }) };
|
|
184
|
-
const pidFilePid =
|
|
190
|
+
const pidFilePid = parsePositivePort(pidFileInfo?.pid);
|
|
185
191
|
const defaultPortPids = [
|
|
186
192
|
...new Set(
|
|
187
193
|
(typeof findListeningPidsForPortFn === "function"
|
package/hub/server.mjs
CHANGED
|
@@ -113,6 +113,29 @@ export function sendClaudeControlRequest(
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// claude daemon 은 control.sock 의 mutating op(dispatch 등)에 control-key
|
|
117
|
+
// 인증을 강제한다 (미제시 시 EAUTH "didn't present the daemon control key").
|
|
118
|
+
// key 는 <configDir>/daemon/control.key (configDir 스코프별). 파일이 없으면
|
|
119
|
+
// 인증 미강제 daemon 으로 보고 auth 필드를 생략한다 (fail-open — 구버전 호환).
|
|
120
|
+
export async function readDaemonControlKey(
|
|
121
|
+
configDir = resolveClaudeConfigDir(),
|
|
122
|
+
) {
|
|
123
|
+
try {
|
|
124
|
+
const key = await fs.readFile(
|
|
125
|
+
path.join(configDir, "daemon", "control.key"),
|
|
126
|
+
"utf8",
|
|
127
|
+
);
|
|
128
|
+
return key.trim() || undefined;
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function buildDaemonControlAuth(configDir) {
|
|
135
|
+
const auth = await readDaemonControlKey(configDir);
|
|
136
|
+
return auth ? { auth } : {};
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
export function buildDaemonAttachRequest({
|
|
117
140
|
short,
|
|
118
141
|
cols = DEFAULT_DAEMON_ATTACH_COLS,
|
|
@@ -1146,11 +1169,12 @@ export async function resolveDaemonBridgeSessionId({
|
|
|
1146
1169
|
}
|
|
1147
1170
|
}
|
|
1148
1171
|
|
|
1149
|
-
export async function killDaemonJob(controlSock, short) {
|
|
1172
|
+
export async function killDaemonJob(controlSock, short, { auth } = {}) {
|
|
1150
1173
|
return await sendClaudeControlRequest(controlSock, {
|
|
1151
1174
|
proto: 1,
|
|
1152
1175
|
op: "kill",
|
|
1153
1176
|
short,
|
|
1177
|
+
...(auth ? { auth } : {}),
|
|
1154
1178
|
});
|
|
1155
1179
|
}
|
|
1156
1180
|
|
|
@@ -1237,6 +1261,7 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1237
1261
|
const writeProjection =
|
|
1238
1262
|
_deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
|
|
1239
1263
|
const readProcStart = _deps.getProcStart || getProcStart;
|
|
1264
|
+
const buildAuth = _deps.buildDaemonControlAuth || buildDaemonControlAuth;
|
|
1240
1265
|
|
|
1241
1266
|
const short = payload.short;
|
|
1242
1267
|
const sessionsDir =
|
|
@@ -1246,6 +1271,7 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1246
1271
|
// native-bridge 는 control.sock 존재를 미리 점검한다 (없으면 fast-fail).
|
|
1247
1272
|
if (accessControlSock) await accessControlSock(resolvedControlSock);
|
|
1248
1273
|
|
|
1274
|
+
const controlAuth = await buildAuth(paths?.configDir);
|
|
1249
1275
|
const dispatch = await sendControl(
|
|
1250
1276
|
resolvedControlSock,
|
|
1251
1277
|
{
|
|
@@ -1253,11 +1279,17 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1253
1279
|
op: "dispatch",
|
|
1254
1280
|
d: payload,
|
|
1255
1281
|
timeoutMs: dispatchTimeoutMs,
|
|
1282
|
+
...controlAuth,
|
|
1256
1283
|
},
|
|
1257
1284
|
{ timeoutMs: dispatchTimeoutMs },
|
|
1258
1285
|
);
|
|
1259
1286
|
if (dispatch?.ok !== true) {
|
|
1260
|
-
|
|
1287
|
+
// daemon 거부 사유를 보존한다 — generic 메시지만 던지면 EAUTH 같은
|
|
1288
|
+
// 실원인이 묻혀 진단이 어려워진다.
|
|
1289
|
+
const reason = dispatch?.error ? `: ${dispatch.error}` : "";
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
`Claude daemon dispatch failed for ${name || short}${reason}`,
|
|
1292
|
+
);
|
|
1261
1293
|
}
|
|
1262
1294
|
|
|
1263
1295
|
const pidOpts =
|
|
@@ -6,6 +6,7 @@ const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
|
|
|
6
6
|
const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
7
7
|
const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
|
|
8
8
|
const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
9
10
|
// Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
|
|
10
11
|
// timeout produces stale false-positives. 5-minute TTL + an `idle` state
|
|
11
12
|
// distinguishes "alive but inactive" from "presumed dead".
|
|
@@ -23,6 +24,27 @@ function normalizeSessionKind(raw) {
|
|
|
23
24
|
return VALID_SESSION_KINDS.has(raw) ? raw : "headless";
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
function normalizeDurationMs(raw, fallback) {
|
|
28
|
+
if (raw == null) return fallback;
|
|
29
|
+
const str = String(raw).trim();
|
|
30
|
+
if (!str) return fallback;
|
|
31
|
+
const parsed = Number(str);
|
|
32
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultCleanExpireTimeoutMs() {
|
|
36
|
+
return normalizeDurationMs(
|
|
37
|
+
process.env.TFX_SYNAPSE_CLEAN_EXPIRE_MS,
|
|
38
|
+
DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasDirtyFiles(session) {
|
|
43
|
+
return Array.isArray(session?.dirtyFiles)
|
|
44
|
+
? session.dirtyFiles.some((file) => typeof file === "string" && file)
|
|
45
|
+
: false;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// A session is "live" while active OR idle. Idle is an interactive session that
|
|
27
49
|
// missed its heartbeat interval but is still under the TTL — alive but inactive,
|
|
28
50
|
// not presumed dead. getActive() and querySessions() share this single predicate
|
|
@@ -135,6 +157,7 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
135
157
|
interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
|
|
136
158
|
interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
|
|
137
159
|
expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
|
|
160
|
+
cleanExpireTimeoutMs = defaultCleanExpireTimeoutMs(),
|
|
138
161
|
} = opts;
|
|
139
162
|
|
|
140
163
|
const sessions = new Map();
|
|
@@ -357,19 +380,23 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
357
380
|
return true;
|
|
358
381
|
}
|
|
359
382
|
|
|
360
|
-
// stale/expired 세션이
|
|
383
|
+
// stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
|
|
361
384
|
// live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
|
|
385
|
+
// Dirty stale rows keep the historical 24h window because same-id resume
|
|
386
|
+
// revives their dirty-file guard; clean stale rows expire quickly to avoid
|
|
387
|
+
// daily dummy rows after overnight operator gaps.
|
|
362
388
|
function pruneExpired(opts2 = {}) {
|
|
363
389
|
if (destroyed) return { removed: [], count: 0 };
|
|
364
390
|
|
|
365
|
-
const
|
|
366
|
-
typeof opts2.olderThanMs === "number"
|
|
367
|
-
? opts2.olderThanMs
|
|
368
|
-
: expireTimeoutMs;
|
|
391
|
+
const explicitCutoff =
|
|
392
|
+
typeof opts2.olderThanMs === "number" ? opts2.olderThanMs : null;
|
|
369
393
|
const removed = [];
|
|
370
394
|
const currentTime = now();
|
|
371
395
|
|
|
372
396
|
for (const [sessionId, session] of sessions) {
|
|
397
|
+
const cutoff =
|
|
398
|
+
explicitCutoff ??
|
|
399
|
+
(hasDirtyFiles(session) ? expireTimeoutMs : cleanExpireTimeoutMs);
|
|
373
400
|
if (
|
|
374
401
|
isLiveStatus(session.status) ||
|
|
375
402
|
currentTime - session.lastHeartbeat <= cutoff
|
package/hub/tray-lifecycle.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// hub/tray-lifecycle.mjs — Hub/Tray bidirectional auto-start helpers
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isWorktreeOrEphemeralHubContext,
|
|
6
|
+
resolveHubPortForContext,
|
|
7
|
+
} from "./hub-lifecycle.mjs";
|
|
5
8
|
|
|
6
9
|
const DEFAULT_HUB_PORT = "27888";
|
|
7
10
|
const DEFAULT_HEALTH_TIMEOUT_MS = 1_000;
|
|
@@ -117,6 +120,9 @@ export function spawnTrayForHub({
|
|
|
117
120
|
spawnFn = spawn,
|
|
118
121
|
} = {}) {
|
|
119
122
|
if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
|
|
123
|
+
if (isWorktreeOrEphemeralHubContext({ env })) {
|
|
124
|
+
return { status: "disabled", reason: "ephemeral-or-worktree-context" };
|
|
125
|
+
}
|
|
120
126
|
if (platform !== "darwin") return { status: "unsupported-platform" };
|
|
121
127
|
if (!trayPath) return { status: "missing-tray-path" };
|
|
122
128
|
|
package/hub/tray.mjs
CHANGED
|
@@ -19,15 +19,20 @@ function sleep(ms) {
|
|
|
19
19
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function isNodeCommand(command) {
|
|
23
|
+
return /\bnode(?:\s|$)|[/\\]node(?:\s|$)/u.test(command);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasMacTrayScriptArg(command) {
|
|
27
|
+
return /(?:^|[\s"'])[^"'<>|]*[/\\]hub[/\\]tray\.mjs(?:$|[\s"'])/u.test(
|
|
28
|
+
command,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export function collectMacTrayProcesses(
|
|
23
33
|
psOutput = "",
|
|
24
|
-
{
|
|
25
|
-
scriptPath = fileURLToPath(import.meta.url),
|
|
26
|
-
currentPid = process.pid,
|
|
27
|
-
} = {},
|
|
34
|
+
{ currentPid = process.pid } = {},
|
|
28
35
|
) {
|
|
29
|
-
const target = String(scriptPath || "");
|
|
30
|
-
if (!target) return [];
|
|
31
36
|
const ownPid = Number(currentPid);
|
|
32
37
|
return String(psOutput)
|
|
33
38
|
.split(/\r?\n/u)
|
|
@@ -38,8 +43,8 @@ export function collectMacTrayProcesses(
|
|
|
38
43
|
const ppid = Number.parseInt(match[2], 10);
|
|
39
44
|
const command = match[3].trim();
|
|
40
45
|
if (!Number.isFinite(pid) || pid === ownPid) return [];
|
|
41
|
-
if (!command
|
|
42
|
-
if (
|
|
46
|
+
if (!isNodeCommand(command)) return [];
|
|
47
|
+
if (!hasMacTrayScriptArg(command)) return [];
|
|
43
48
|
return [{ pid, ppid, command }];
|
|
44
49
|
});
|
|
45
50
|
}
|