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.
- package/bin/tfx-live.mjs +62 -19
- package/config/mcp-registry.json +0 -9
- package/cto/status.mjs +77 -1
- package/hooks/agy-session-hook.mjs +140 -0
- package/hooks/session-start-fast.mjs +51 -1
- package/hub/bridge.mjs +130 -70
- package/hub/hub-lifecycle.mjs +18 -12
- package/hub/server.mjs +5 -2
- package/hub/team/build-worker-prompt.mjs +23 -4
- package/hub/team/claude-daemon-control.mjs +382 -6
- package/hub/team/claude-native-bridge.mjs +6 -8
- package/hub/team/conductor.mjs +1 -0
- package/hub/team/handoff.mjs +8 -4
- package/hub/team/headless.mjs +33 -11
- package/hub/team/native-supervisor.mjs +4 -0
- package/hub/team/orchestrator.mjs +7 -2
- package/hub/team/swarm-hypervisor.mjs +230 -44
- package/hub/team/synapse-registry.mjs +32 -5
- package/hub/team/worker-completion-validator.mjs +11 -16
- package/hub/team/worker-sandbox.mjs +29 -1
- package/hub/tray-lifecycle.mjs +8 -1
- package/hub/tray.mjs +13 -8
- package/hub/workers/delegator-mcp.mjs +1 -0
- package/hud/constants.mjs +2 -0
- package/hud/providers/gemini.mjs +135 -3
- package/hud/renderers.mjs +37 -35
- package/package.json +1 -1
- package/scripts/__tests__/ensure-codex-hooks.test.mjs +183 -0
- package/scripts/ensure-agy-hooks.mjs +134 -0
- package/scripts/ensure-codex-hooks.mjs +79 -27
- package/scripts/lib/mcp-manifest.mjs +2 -2
- package/scripts/mcp-gateway-start.ps1 +0 -1
- package/scripts/preflight-cache.mjs +230 -55
- package/scripts/release/check-packages-mirror.mjs +1 -1
- package/scripts/setup.mjs +19 -0
- package/scripts/test-lock.mjs +54 -10
- package/scripts/tfx-route.sh +53 -11
- package/skills/star-prompt/SKILL.md +0 -2
- package/skills/tfx-analysis/SKILL.md +1 -9
- package/skills/tfx-auto/SKILL.md +4 -36
- package/skills/tfx-doctor/SKILL.md +0 -2
- package/skills/tfx-find/SKILL.md +0 -15
- package/skills/tfx-forge/SKILL.md +0 -10
- package/skills/tfx-goal-clarify/SKILL.md +0 -9
- package/skills/tfx-hooks/SKILL.md +0 -2
- package/skills/tfx-hub/SKILL.md +0 -2
- package/skills/tfx-index/SKILL.md +0 -12
- package/skills/tfx-interview/SKILL.md +0 -11
- package/skills/tfx-live/SKILL.md +0 -4
- package/skills/tfx-plan/SKILL.md +1 -11
- package/skills/tfx-profile/SKILL.md +0 -2
- package/skills/tfx-prune/SKILL.md +0 -6
- package/skills/tfx-qa/SKILL.md +1 -10
- package/skills/tfx-remote/SKILL.md +0 -2
- package/skills/tfx-research/SKILL.md +1 -15
- package/skills/tfx-review/SKILL.md +1 -16
- package/skills/tfx-setup/SKILL.md +6 -8
- package/skills/tfx-ship/SKILL.md +0 -9
- 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 {
|
|
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
|
|
1072
|
-
|
|
1073
|
-
|
|
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(
|
package/config/mcp-registry.json
CHANGED
|
@@ -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:
|
|
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).
|