@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.
@@ -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 parsePositiveInt(value) {
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 parsePortFromUrl(value) {
30
+ function parsePortFromHubUrl(value) {
25
31
  try {
26
- return parsePositiveInt(new URL(String(value)).port);
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 = parsePositiveInt(port) ?? parsePositiveInt(env?.TFX_HUB_PORT);
61
- const urlPort = parsePortFromUrl(env?.TFX_HUB_URL);
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 = parsePositiveInt(pidFileInfo?.pid);
190
+ const pidFilePid = parsePositivePort(pidFileInfo?.pid);
185
191
  const defaultPortPids = [
186
192
  ...new Set(
187
193
  (typeof findListeningPidsForPortFn === "function"
package/hub/server.mjs CHANGED
@@ -2461,6 +2461,7 @@ export async function startHub({
2461
2461
  hubLog.warn(
2462
2462
  {
2463
2463
  failed: failed.length,
2464
+ failedCount: failed.length,
2464
2465
  processes: failed,
2465
2466
  caller: "startup",
2466
2467
  },
@@ -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
- throw new Error(`Claude daemon dispatch failed for ${name || short}`);
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 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
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 cutoff =
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
@@ -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 { resolveHubPortForContext } from "./hub-lifecycle.mjs";
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.includes(target)) return [];
42
- if (!/\bnode(?:\s|$)|\/node(?:\s|$)/u.test(command)) return [];
46
+ if (!isNodeCommand(command)) return [];
47
+ if (!hasMacTrayScriptArg(command)) return [];
43
48
  return [{ pid, ppid, command }];
44
49
  });
45
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.33.1",
3
+ "version": "10.34.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",