triflux 10.33.1 → 10.35.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.
Files changed (59) hide show
  1. package/bin/tfx-live.mjs +62 -19
  2. package/config/mcp-registry.json +0 -9
  3. package/cto/status.mjs +77 -1
  4. package/hooks/agy-session-hook.mjs +140 -0
  5. package/hooks/session-start-fast.mjs +51 -1
  6. package/hub/bridge.mjs +130 -70
  7. package/hub/hub-lifecycle.mjs +18 -12
  8. package/hub/server.mjs +5 -2
  9. package/hub/team/build-worker-prompt.mjs +23 -4
  10. package/hub/team/claude-daemon-control.mjs +382 -6
  11. package/hub/team/claude-native-bridge.mjs +6 -8
  12. package/hub/team/conductor.mjs +1 -0
  13. package/hub/team/handoff.mjs +8 -4
  14. package/hub/team/headless.mjs +33 -11
  15. package/hub/team/native-supervisor.mjs +4 -0
  16. package/hub/team/orchestrator.mjs +7 -2
  17. package/hub/team/swarm-hypervisor.mjs +230 -44
  18. package/hub/team/synapse-registry.mjs +32 -5
  19. package/hub/team/worker-completion-validator.mjs +11 -16
  20. package/hub/team/worker-sandbox.mjs +29 -1
  21. package/hub/tray-lifecycle.mjs +8 -1
  22. package/hub/tray.mjs +13 -8
  23. package/hub/workers/delegator-mcp.mjs +1 -0
  24. package/hud/constants.mjs +2 -0
  25. package/hud/providers/gemini.mjs +135 -3
  26. package/hud/renderers.mjs +37 -35
  27. package/package.json +1 -1
  28. package/scripts/__tests__/ensure-codex-hooks.test.mjs +183 -0
  29. package/scripts/ensure-agy-hooks.mjs +134 -0
  30. package/scripts/ensure-codex-hooks.mjs +79 -27
  31. package/scripts/lib/mcp-manifest.mjs +2 -2
  32. package/scripts/mcp-gateway-start.ps1 +0 -1
  33. package/scripts/preflight-cache.mjs +230 -55
  34. package/scripts/release/check-packages-mirror.mjs +1 -1
  35. package/scripts/setup.mjs +19 -0
  36. package/scripts/test-lock.mjs +54 -10
  37. package/scripts/tfx-route.sh +53 -11
  38. package/skills/star-prompt/SKILL.md +0 -2
  39. package/skills/tfx-analysis/SKILL.md +1 -9
  40. package/skills/tfx-auto/SKILL.md +4 -36
  41. package/skills/tfx-doctor/SKILL.md +0 -2
  42. package/skills/tfx-find/SKILL.md +0 -15
  43. package/skills/tfx-forge/SKILL.md +0 -10
  44. package/skills/tfx-goal-clarify/SKILL.md +0 -9
  45. package/skills/tfx-hooks/SKILL.md +0 -2
  46. package/skills/tfx-hub/SKILL.md +0 -2
  47. package/skills/tfx-index/SKILL.md +0 -12
  48. package/skills/tfx-interview/SKILL.md +0 -11
  49. package/skills/tfx-live/SKILL.md +0 -4
  50. package/skills/tfx-plan/SKILL.md +1 -11
  51. package/skills/tfx-profile/SKILL.md +0 -2
  52. package/skills/tfx-prune/SKILL.md +0 -6
  53. package/skills/tfx-qa/SKILL.md +1 -10
  54. package/skills/tfx-remote/SKILL.md +0 -2
  55. package/skills/tfx-research/SKILL.md +1 -15
  56. package/skills/tfx-review/SKILL.md +1 -16
  57. package/skills/tfx-setup/SKILL.md +6 -8
  58. package/skills/tfx-ship/SKILL.md +0 -9
  59. package/skills/tfx-wt/SKILL.md +0 -6
package/bin/tfx-live.mjs CHANGED
@@ -34,11 +34,11 @@ function usage() {
34
34
  "Usage:",
35
35
  " tfx-live start --session NAME [--cli codex|claude] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
36
36
  " tfx-live ask --session NAME --prompt TEXT [--cli codex|claude] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
37
- " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
37
+ " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--config-dir DIR] [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
38
38
  " transport: auto is the default for Claude when --short/--session-id is present; otherwise tmux. bridge path: --bridge > $TFX_BRIDGE > $TFX_REPO_ROOT/hub/bridge.mjs > bundled Triflux hub/bridge.mjs.",
39
- " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--bridge ABS] [--timeout 5]",
39
+ " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 5]",
40
40
  " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
41
- " tfx-live probe [--short SHORT] [--session-id ID] [--bridge ABS] [--timeout 10]",
41
+ " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
42
42
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
43
43
  " tfx-live goal-driven --session NAME --goal TEXT [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500] [--max-rounds 8] [--done-token DONE]",
44
44
  " tfx-live peer [--cli-a codex] [--cli-b claude] [--session-a peerA] [--session-b peerB] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--bridge ABS] [--remote HOST] [--cwd DIR] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
@@ -787,13 +787,19 @@ async function probeDaemon(bridgePath, ref, timeoutMs) {
787
787
  const payload = {};
788
788
  if (ref?.short) payload.short = ref.short;
789
789
  if (ref?.sessionId) payload.sessionId = ref.sessionId;
790
+ if (ref?.configDir) payload.configDir = ref.configDir;
790
791
  const result = await callBridgeVerb(
791
792
  bridgePath,
792
793
  "daemon-probe",
793
794
  payload,
794
795
  timeoutMs,
795
796
  );
796
- return { ok: result?.ok === true, sessions: result?.sessions, raw: result };
797
+ return {
798
+ ok: result?.ok === true,
799
+ reason: result?.reason,
800
+ sessions: result?.sessions,
801
+ raw: result,
802
+ };
797
803
  } catch (error) {
798
804
  return { ok: false, reason: error.message };
799
805
  }
@@ -931,10 +937,11 @@ async function doAsk(adapter, opts) {
931
937
  }
932
938
 
933
939
  async function doAskViaDaemon(opts, meta = {}) {
934
- const { bridgePath, prompt, timeoutMs, short, sessionId } = opts;
940
+ const { bridgePath, prompt, timeoutMs, short, sessionId, configDir } = opts;
935
941
  const payload = { prompt, timeoutMs };
936
942
  if (short) payload.short = short;
937
943
  if (sessionId) payload.sessionId = sessionId;
944
+ if (configDir) payload.configDir = configDir;
938
945
 
939
946
  const result = await callBridgeVerb(
940
947
  bridgePath,
@@ -956,12 +963,35 @@ async function doAskViaDaemon(opts, meta = {}) {
956
963
  timedOut: result?.timedOut === true,
957
964
  closed: result?.closed === true,
958
965
  inputSent: result?.inputSent === true,
966
+ daemon: result?.daemon ?? null,
967
+ daemons: result?.daemons ?? [],
968
+ matches: result?.matches ?? [],
969
+ candidateResults: result?.candidateResults ?? [],
970
+ callerProvenance: result?.callerProvenance ?? null,
959
971
  done: matchedCompletion,
960
972
  ...(result?.error ? { error: result.error } : {}),
961
973
  ...meta,
962
974
  };
963
975
  }
964
976
 
977
+ function daemonProbeTargetAttachable(probe, opts) {
978
+ if (!probe?.ok) return false;
979
+ // New bridge responses carry `target`; daemon-control owns selection policy.
980
+ if (probe.raw?.target) return true;
981
+ // Compatibility only for older bridge binaries that list sessions but do not
982
+ // expose `target` yet. Do not add new selection semantics here.
983
+ if (!Array.isArray(probe.sessions)) return false;
984
+ return probe.sessions.some(
985
+ (entry) =>
986
+ (opts.short && entry?.short === opts.short) ||
987
+ (opts.sessionId &&
988
+ (entry?.sessionId === opts.sessionId ||
989
+ entry?.session_id === opts.sessionId ||
990
+ entry?.dispatch?.sessionId === opts.sessionId ||
991
+ entry?.d?.sessionId === opts.sessionId)),
992
+ );
993
+ }
994
+
965
995
  async function ensureTmuxSession(adapter, opts) {
966
996
  if (!opts.session) return false;
967
997
  try {
@@ -1004,6 +1034,13 @@ function writeUdsBugReport(reason, context) {
1004
1034
  .map((entry) => entry?.short)
1005
1035
  .filter(Boolean)
1006
1036
  : [],
1037
+ // Diagnostic-only bridge metadata; callers should not depend on this
1038
+ // shape as a versioned control contract.
1039
+ daemon: context.probe.raw?.daemon ?? null,
1040
+ daemons: context.probe.raw?.daemons ?? [],
1041
+ matches: context.probe.raw?.matches ?? [],
1042
+ candidateResults: context.probe.raw?.candidateResults ?? [],
1043
+ callerProvenance: context.probe.raw?.callerProvenance ?? null,
1007
1044
  }
1008
1045
  : null,
1009
1046
  attachError: context.attachError ?? null,
@@ -1052,26 +1089,23 @@ async function runTmuxFallback(adapter, opts, reason, udsResult) {
1052
1089
  async function doAskAuto(adapter, opts) {
1053
1090
  const probe = await probeDaemon(
1054
1091
  opts.bridgePath,
1055
- { short: opts.short, sessionId: opts.sessionId },
1092
+ { short: opts.short, sessionId: opts.sessionId, configDir: opts.configDir },
1056
1093
  opts.timeoutMs,
1057
1094
  );
1058
1095
  // daemon-probe returns the full session list (it does not filter by the
1059
1096
  // requested short), so confirm the target is actually attachable here rather
1060
1097
  // than firing a doomed attach at a missing short.
1061
- const targetAttachable =
1062
- probe.ok &&
1063
- Array.isArray(probe.sessions) &&
1064
- probe.sessions.some(
1065
- (entry) =>
1066
- (opts.short && entry?.short === opts.short) ||
1067
- (opts.sessionId && entry?.sessionId === opts.sessionId),
1068
- );
1098
+ const targetAttachable = daemonProbeTargetAttachable(probe, opts);
1069
1099
 
1070
1100
  if (targetAttachable) {
1071
- const udsResult = await doAskViaDaemon(opts, {
1072
- transportSelected: "uds",
1073
- transportProbe: "ok",
1074
- });
1101
+ const daemonConfigDir = opts.configDir ?? probe.raw?.daemon?.configDir;
1102
+ const udsResult = await doAskViaDaemon(
1103
+ { ...opts, configDir: daemonConfigDir },
1104
+ {
1105
+ transportSelected: "uds",
1106
+ transportProbe: "ok",
1107
+ },
1108
+ );
1075
1109
  if (udsResult.matchedCompletion) {
1076
1110
  return udsResult;
1077
1111
  }
@@ -1139,10 +1173,11 @@ async function doInterruptViaTmux(adapter, opts) {
1139
1173
  }
1140
1174
 
1141
1175
  async function doInterruptViaDaemon(opts, meta = {}) {
1142
- const { bridgePath, timeoutMs, short, sessionId } = opts;
1176
+ const { bridgePath, timeoutMs, short, sessionId, configDir } = opts;
1143
1177
  const payload = { timeoutMs };
1144
1178
  if (short) payload.short = short;
1145
1179
  if (sessionId) payload.sessionId = sessionId;
1180
+ if (configDir) payload.configDir = configDir;
1146
1181
  const result = await callBridgeVerb(
1147
1182
  bridgePath,
1148
1183
  "daemon-interrupt",
@@ -1161,6 +1196,11 @@ async function doInterruptViaDaemon(opts, meta = {}) {
1161
1196
  inputSent: result?.inputSent === true,
1162
1197
  timedOut: result?.timedOut === true,
1163
1198
  closed: result?.closed === true,
1199
+ daemon: result?.daemon ?? null,
1200
+ daemons: result?.daemons ?? [],
1201
+ matches: result?.matches ?? [],
1202
+ candidateResults: result?.candidateResults ?? [],
1203
+ callerProvenance: result?.callerProvenance ?? null,
1164
1204
  ...(result?.error ? { error: result.error } : {}),
1165
1205
  ...meta,
1166
1206
  };
@@ -1269,6 +1309,7 @@ function askOpts(flags, adapter) {
1269
1309
  transport === "tmux" ? requireFlag(flags, "session") : flags.session,
1270
1310
  short,
1271
1311
  sessionId,
1312
+ configDir: flags["config-dir"],
1272
1313
  transport,
1273
1314
  bridgePath: resolveBridgePath(flags),
1274
1315
  prompt: requireFlag(flags, "prompt"),
@@ -1295,6 +1336,7 @@ function interruptOpts(flags, adapter) {
1295
1336
  transport === "tmux" ? requireFlag(flags, "session") : flags.session,
1296
1337
  short,
1297
1338
  sessionId,
1339
+ configDir: flags["config-dir"],
1298
1340
  transport,
1299
1341
  bridgePath: resolveBridgePath(flags),
1300
1342
  remote: flags.remote,
@@ -1326,6 +1368,7 @@ async function probe(flags) {
1326
1368
  const payload = {};
1327
1369
  if (flags.short) payload.short = flags.short;
1328
1370
  if (flags["session-id"]) payload.sessionId = flags["session-id"];
1371
+ if (flags["config-dir"]) payload.configDir = flags["config-dir"];
1329
1372
  const timeoutMs = secondsFlag(flags, "timeout", 10_000);
1330
1373
  printJson(
1331
1374
  await callBridgeVerb(
@@ -41,15 +41,6 @@
41
41
  "targets": ["claude", "gemini", "codex", "antigravity"],
42
42
  "description": "Exa neural/semantic web search — 학술/기술 깊이. Key 발급: https://exa.ai/dashboard → secrets.env의 EXA_API_KEY"
43
43
  },
44
- "serena": {
45
- "policy": "gateway-http",
46
- "transport": "http",
47
- "gateway_port": 8105,
48
- "gateway_path": "/mcp",
49
- "safe": true,
50
- "targets": ["claude", "gemini", "codex", "antigravity"],
51
- "description": "Serena MCP — local supergateway stateful Streamable HTTP endpoint (:8105/mcp)"
52
- },
53
44
  "brave-search": {
54
45
  "policy": "gateway-http",
55
46
  "transport": "http",
package/cto/status.mjs CHANGED
@@ -29,7 +29,61 @@ function toIsoTime(value) {
29
29
  return null;
30
30
  }
31
31
 
32
+ function shortHash(value) {
33
+ const str = String(value ?? "");
34
+ let h = 5381;
35
+ for (let i = 0; i < str.length; i++) {
36
+ h = (h * 33) ^ str.charCodeAt(i);
37
+ }
38
+ return (h >>> 0).toString(36);
39
+ }
40
+
41
+ function pathLabel(value) {
42
+ const str = String(value ?? "").replace(/[/\\]+$/u, "");
43
+ if (!str) return "";
44
+ const segments = str.split(/[/\\]+/u);
45
+ return segments[segments.length - 1] || "";
46
+ }
47
+
48
+ export function deriveRepoRootFromCwd(cwd) {
49
+ if (typeof cwd !== "string" || !cwd) return "";
50
+ if (cwd.includes("\\") || /^[A-Za-z]:/u.test(cwd)) return cwd;
51
+
52
+ const normalized = cwd.replace(/\/+$/u, "");
53
+ const claudeMarker = "/.claude/worktrees/";
54
+ const claudeIndex = normalized.indexOf(claudeMarker);
55
+ if (claudeIndex > 0) {
56
+ const suffix = normalized.slice(claudeIndex + claudeMarker.length);
57
+ if (/^[^/]+(?:\/|$)/u.test(suffix)) {
58
+ return normalized.slice(0, claudeIndex);
59
+ }
60
+ }
61
+
62
+ const codexMarker = "/.codex-swarm/";
63
+ const codexIndex = normalized.indexOf(codexMarker);
64
+ if (codexIndex > 0) {
65
+ const suffix = normalized.slice(codexIndex + codexMarker.length);
66
+ if (/^wt-[^/]+(?:\/|$)/u.test(suffix)) {
67
+ return normalized.slice(0, codexIndex);
68
+ }
69
+ }
70
+
71
+ return cwd;
72
+ }
73
+
74
+ function redactedCwdFields(cwd) {
75
+ if (typeof cwd !== "string" || !cwd) return {};
76
+ const repoRoot = deriveRepoRootFromCwd(cwd);
77
+ return {
78
+ cwdLabel: pathLabel(cwd),
79
+ cwdHash: shortHash(cwd),
80
+ repoRootLabel: pathLabel(repoRoot),
81
+ repoRootHash: shortHash(repoRoot),
82
+ };
83
+ }
84
+
32
85
  function normalizeLiveSession(session) {
86
+ const cwd = typeof session?.cwd === "string" ? session.cwd : "";
33
87
  return {
34
88
  sessionId: String(session?.sessionId || ""),
35
89
  host: typeof session?.host === "string" ? session.host : "local",
@@ -48,6 +102,7 @@ function normalizeLiveSession(session) {
48
102
  started_at: toIsoTime(
49
103
  session?.started_at ?? session?.startedAt ?? session?.lastHeartbeat,
50
104
  ),
105
+ ...redactedCwdFields(cwd),
51
106
  };
52
107
  }
53
108
 
@@ -164,7 +219,27 @@ function deriveActiveShards(current, overlay) {
164
219
  return shards.map(normalizeActiveShard).filter((shard) => shard.shard_name);
165
220
  }
166
221
 
222
+ function deriveLiveSessionGroups(liveSessions) {
223
+ const groups = new Map();
224
+ for (const session of liveSessions || []) {
225
+ if (!session?.repoRootHash) continue;
226
+ if (!groups.has(session.repoRootHash)) {
227
+ groups.set(session.repoRootHash, {
228
+ repoRootLabel: session.repoRootLabel || "",
229
+ repoRootHash: session.repoRootHash,
230
+ session_count: 0,
231
+ sessions: [],
232
+ });
233
+ }
234
+ const group = groups.get(session.repoRootHash);
235
+ group.session_count += 1;
236
+ group.sessions.push(session.sessionId);
237
+ }
238
+ return [...groups.values()];
239
+ }
240
+
167
241
  function projectStatus(current, overlay) {
242
+ const liveSessions = overlay.live_sessions;
168
243
  return {
169
244
  schema_version: current?.schema_version || SCHEMA_VERSION,
170
245
  generated_at: current?.generated_at || null,
@@ -172,7 +247,8 @@ function projectStatus(current, overlay) {
172
247
  sources: current?.sources || {},
173
248
  summary: current?.summary || {},
174
249
  ledger_tail: Array.isArray(current?.ledger_tail) ? current.ledger_tail : [],
175
- live_sessions: overlay.live_sessions,
250
+ live_sessions: liveSessions,
251
+ live_session_groups: deriveLiveSessionGroups(liveSessions),
176
252
  active_shards: deriveActiveShards(current, overlay),
177
253
  };
178
254
  }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { argv, exit, stdin, stdout } from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+ import { drainPendingSynapse as defaultDrainPendingSynapse } from "../hub/team/synapse-http.mjs";
6
+ import {
7
+ heartbeatInteractiveSession as defaultHeartbeatInteractiveSession,
8
+ registerInteractiveSession as defaultRegisterInteractiveSession,
9
+ } from "./session-start-fast.mjs";
10
+
11
+ // hub-ensure is loaded lazily so the byte-identical packages/core mirror of this
12
+ // file loads cleanly. packages/core mirrors scripts/lib only (not scripts/*), so a
13
+ // static `../scripts/hub-ensure.mjs` import would make the core copy throw
14
+ // ERR_MODULE_NOT_FOUND at load time. Mirrors codex-session-hook.mjs's pattern.
15
+ async function defaultHubEnsureRun(stdinData) {
16
+ const { run } = await import(
17
+ new URL("../scripts/hub-ensure.mjs", import.meta.url).href
18
+ );
19
+ return run(stdinData);
20
+ }
21
+
22
+ function parsePayload(stdinData) {
23
+ try {
24
+ const raw = typeof stdinData === "string" ? stdinData : "";
25
+ return raw.trim()
26
+ ? { ok: true, payload: JSON.parse(raw) }
27
+ : { ok: false, payload: {} };
28
+ } catch {
29
+ return { ok: false, payload: {} };
30
+ }
31
+ }
32
+
33
+ // Antigravity hook payloads use camelCase system metadata (conversationId,
34
+ // workspacePaths) rather than Codex's session_id/cwd. registerInteractiveSession
35
+ // and heartbeatInteractiveSession parse the Codex shape, so we adapt the agy
36
+ // payload into that shape before delegating. conversationId is the stable
37
+ // per-conversation UUID (== session identity); the first mounted workspace path
38
+ // is the effective cwd.
39
+ export function toSessionPayload(payload) {
40
+ const sessionId = String(payload?.conversationId || "").trim();
41
+ const workspacePaths = Array.isArray(payload?.workspacePaths)
42
+ ? payload.workspacePaths
43
+ : [];
44
+ const cwd =
45
+ typeof workspacePaths[0] === "string" && workspacePaths[0]
46
+ ? workspacePaths[0]
47
+ : process.cwd();
48
+ return JSON.stringify({ session_id: sessionId, cwd });
49
+ }
50
+
51
+ // agy has no distinct SessionStart / UserPromptSubmit events. PreInvocation
52
+ // fires before every model call, so the per-conversation invocationNum gates
53
+ // register (first call == session start) vs heartbeat (subsequent calls).
54
+ // An explicit argv mode (register|heartbeat) overrides, mirroring the codex hook.
55
+ export function normalizeMode(argvMode, payload) {
56
+ const direct = String(argvMode || "")
57
+ .trim()
58
+ .toLowerCase();
59
+ if (direct === "register" || direct === "heartbeat") return direct;
60
+
61
+ const invocationNum = Number(payload?.invocationNum);
62
+ if (Number.isFinite(invocationNum)) {
63
+ return invocationNum <= 1 ? "register" : "heartbeat";
64
+ }
65
+ // Unknown invocation index: register is idempotent and also ensures the hub,
66
+ // so it is the safe default for a first-contact payload.
67
+ return "register";
68
+ }
69
+
70
+ export async function runAgySessionHook(stdinData, opts = {}) {
71
+ const output = "{}\n";
72
+ const parsed = parsePayload(stdinData);
73
+ if (!parsed.ok) {
74
+ if (opts.writeStdout !== false) stdout.write(output);
75
+ return output;
76
+ }
77
+
78
+ const sessionPayload = toSessionPayload(parsed.payload);
79
+ const sessionId = JSON.parse(sessionPayload).session_id;
80
+ // No conversation id means nothing to register; stay a silent no-op.
81
+ if (!sessionId) {
82
+ if (opts.writeStdout !== false) stdout.write(output);
83
+ return output;
84
+ }
85
+
86
+ const mode = normalizeMode(opts.argvMode ?? argv[2], parsed.payload);
87
+ const hubEnsureRun = opts.hubEnsureRun || defaultHubEnsureRun;
88
+ const registerInteractiveSession =
89
+ opts.registerInteractiveSession || defaultRegisterInteractiveSession;
90
+ const heartbeatInteractiveSession =
91
+ opts.heartbeatInteractiveSession || defaultHeartbeatInteractiveSession;
92
+ const drainPendingSynapse =
93
+ opts.drainPendingSynapse || defaultDrainPendingSynapse;
94
+
95
+ try {
96
+ if (mode === "register") {
97
+ try {
98
+ await hubEnsureRun(sessionPayload);
99
+ } catch {}
100
+ try {
101
+ registerInteractiveSession(sessionPayload);
102
+ } catch {}
103
+ try {
104
+ await drainPendingSynapse(1000);
105
+ } catch {}
106
+ } else if (mode === "heartbeat") {
107
+ try {
108
+ heartbeatInteractiveSession(sessionPayload);
109
+ } catch {}
110
+ try {
111
+ await drainPendingSynapse(500);
112
+ } catch {}
113
+ }
114
+ } catch {
115
+ // agy session hooks are observational and must never block the session.
116
+ }
117
+
118
+ if (opts.writeStdout !== false) {
119
+ stdout.write(output);
120
+ }
121
+ return output;
122
+ }
123
+
124
+ function readStdin() {
125
+ return new Promise((resolve) => {
126
+ let data = "";
127
+ stdin.setEncoding("utf8");
128
+ stdin.on("data", (chunk) => {
129
+ data += chunk;
130
+ });
131
+ stdin.on("end", () => resolve(data));
132
+ stdin.on("error", () => resolve(data));
133
+ });
134
+ }
135
+
136
+ if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
137
+ const stdinData = await readStdin();
138
+ await runAgySessionHook(stdinData);
139
+ exit(0);
140
+ }
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // external source 훅 (session-vault 등)은 여전히 execFile로 실행된다.
12
12
 
13
- import { execFile } from "node:child_process";
13
+ import { execFile, execFileSync } from "node:child_process";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import {
@@ -40,6 +40,55 @@ function parseStartPayload(stdinData) {
40
40
  }
41
41
  }
42
42
 
43
+ function readAncestorCommands(pid = process.ppid, maxDepth = 6) {
44
+ if (process.platform === "win32") return [];
45
+ const commands = [];
46
+ let currentPid = Number(pid);
47
+ for (let depth = 0; depth < maxDepth; depth++) {
48
+ if (!Number.isInteger(currentPid) || currentPid <= 1) break;
49
+ try {
50
+ const output = execFileSync(
51
+ "ps",
52
+ ["-o", "ppid=", "-o", "command=", "-p", String(currentPid)],
53
+ {
54
+ encoding: "utf8",
55
+ timeout: 200,
56
+ windowsHide: true,
57
+ },
58
+ ).trim();
59
+ const match = output.match(/^(\d+)\s+([\s\S]+)$/);
60
+ if (!match) break;
61
+ commands.push(match[2]);
62
+ currentPid = Number(match[1]);
63
+ } catch {
64
+ break;
65
+ }
66
+ }
67
+ return commands;
68
+ }
69
+
70
+ function commandUsesClaudePrintMode(command) {
71
+ return /\bclaude(?:\s+\S+)*\s+(?:--print|-p)(?:\s|=|$)/u.test(
72
+ String(command || ""),
73
+ );
74
+ }
75
+
76
+ function shouldSkipInteractiveRegistration(payload, seams = {}) {
77
+ const declaredKind = String(
78
+ payload?.sessionKind || payload?.session_kind || "",
79
+ )
80
+ .trim()
81
+ .toLowerCase();
82
+ if (declaredKind === "headless") return true;
83
+
84
+ const ancestorCommands = Array.isArray(seams.ancestorCommands)
85
+ ? seams.ancestorCommands
86
+ : readAncestorCommands(seams.parentPid);
87
+ return ancestorCommands.some((command) =>
88
+ commandUsesClaudePrintMode(command),
89
+ );
90
+ }
91
+
43
92
  /**
44
93
  * cwd 기준 git 컨텍스트(worktree root / branch)를 best-effort, 비동기로 수집.
45
94
  * execFileSync 와 달리 호출자(BACKGROUND)를 블로킹하지 않는다. 각 git 호출은
@@ -107,6 +156,7 @@ export function registerInteractiveSession(stdinData, seams = {}) {
107
156
  const payload = parseStartPayload(stdinData);
108
157
  const sessionId = String(payload?.session_id || "").trim();
109
158
  if (!sessionId) return;
159
+ if (shouldSkipInteractiveRegistration(payload, seams)) return;
110
160
  const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
111
161
 
112
162
  // 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).