@triflux/core 10.49.0 → 10.50.1
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/hooks/agy-session-hook.mjs +1 -1
- package/hooks/claude-cwd-projection-refresh.mjs +7 -79
- package/hooks/codex-session-hook.mjs +3 -8
- package/hooks/hook-orchestrator.mjs +8 -648
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hub/bridge.mjs +290 -51
- package/hub/team/claude-daemon-control.mjs +54 -6
- package/hub/team/claude-transcript.mjs +240 -0
- package/hub/team/session-context.mjs +203 -0
- package/hud/constants.mjs +10 -0
- package/hud/hud-qos-status.mjs +23 -4
- package/hud/providers/gemini.mjs +4 -1
- package/hud/renderers.mjs +10 -6
- package/package.json +1 -1
- package/scripts/lib/doctor-env-checks.mjs +0 -85
- package/scripts/lib/legacy-hook-cleanup.mjs +230 -0
- package/scripts/lib/legacy-mcp-cleanup.mjs +772 -0
- package/scripts/lib/mcp-guard-engine.mjs +44 -271
- package/scripts/lib/mcp-manifest.mjs +2 -2
- package/scripts/lib/session-presence.mjs +249 -0
- package/hooks/agent-route-guard.mjs +0 -113
- package/hooks/cross-review-tracker.mjs +0 -135
- package/hooks/cto-north-star-brief.mjs +0 -133
- package/hooks/error-context.mjs +0 -185
- package/hooks/hook-adaptive-collector.mjs +0 -88
- package/hooks/hook-manager.mjs +0 -426
- package/hooks/hook-registry.json +0 -367
- package/hooks/hooks.json +0 -149
- package/hooks/keyword-rules.json +0 -676
- package/hooks/mcp-config-watcher.mjs +0 -84
- package/hooks/permission-safe-allow.mjs +0 -267
- package/hooks/pipeline-stop.mjs +0 -435
- package/hooks/post-tool-tips.mjs +0 -306
- package/hooks/pre-compact-snapshot.mjs +0 -181
- package/hooks/safety-guard.mjs +0 -466
- package/hooks/session-end-cleanup.mjs +0 -360
- package/hooks/session-start-fast.mjs +0 -579
- package/hooks/session-start-lake.mjs +0 -81
- package/hooks/subagent-tracker.mjs +0 -281
- package/hooks/subagent-verifier.mjs +0 -85
- package/scripts/lib/cross-review-utils.mjs +0 -59
- package/scripts/lib/hook-utils.mjs +0 -16
- package/scripts/lib/keyword-rules.mjs +0 -296
- package/scripts/lib/mcp-gateway-health-check.mjs +0 -224
- package/scripts/lib/mcp-gateway-servers.mjs +0 -70
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +0 -35
|
@@ -1,360 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// hooks/session-end-cleanup.mjs — SessionEnd lifecycle state reporter
|
|
4
|
-
//
|
|
5
|
-
// Report-only by default. Opt-in cleanup only removes the repo-local
|
|
6
|
-
// .triflux/subagents/subagents.json file when stale running lifecycle state is
|
|
7
|
-
// present. It never kills processes or touches external HOME/MCP configs.
|
|
8
|
-
|
|
9
|
-
import { execFileSync } from "node:child_process";
|
|
10
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { unregisterSynapseSession } from "../hub/team/synapse-http.mjs";
|
|
13
|
-
|
|
14
|
-
const MARKER = "[session-end-cleanup]";
|
|
15
|
-
const STALE_MS = 30 * 60 * 1000;
|
|
16
|
-
const MAX_OUTPUT_BYTES = 2 * 1024;
|
|
17
|
-
const DEFAULT_TMUX_PREFIX = "tfx-*";
|
|
18
|
-
const MAX_TMUX_REPORT_SESSIONS = 8;
|
|
19
|
-
|
|
20
|
-
function readStdin() {
|
|
21
|
-
try {
|
|
22
|
-
return readFileSync(0, "utf8");
|
|
23
|
-
} catch {
|
|
24
|
-
return "";
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function parseJson(raw) {
|
|
29
|
-
if (!raw?.trim()) return null;
|
|
30
|
-
try {
|
|
31
|
-
return JSON.parse(raw);
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function timestampMs(agent) {
|
|
38
|
-
const value =
|
|
39
|
-
agent?.startedAt ??
|
|
40
|
-
agent?.started_at ??
|
|
41
|
-
agent?.createdAt ??
|
|
42
|
-
agent?.created_at ??
|
|
43
|
-
agent?.timestamp;
|
|
44
|
-
const parsed = Date.parse(String(value ?? ""));
|
|
45
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isCompleted(agent) {
|
|
49
|
-
return (
|
|
50
|
-
Boolean(
|
|
51
|
-
agent?.stoppedAt ??
|
|
52
|
-
agent?.stopped_at ??
|
|
53
|
-
agent?.completedAt ??
|
|
54
|
-
agent?.completed_at ??
|
|
55
|
-
agent?.endedAt ??
|
|
56
|
-
agent?.ended_at ??
|
|
57
|
-
agent?.durationMs ??
|
|
58
|
-
agent?.duration_ms ??
|
|
59
|
-
agent?.result,
|
|
60
|
-
) ||
|
|
61
|
-
agent?.status === "completed" ||
|
|
62
|
-
agent?.status === "stopped"
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function agentEntries(state) {
|
|
67
|
-
const agents = state?.agents;
|
|
68
|
-
if (Array.isArray(agents)) return agents;
|
|
69
|
-
if (agents && typeof agents === "object") return Object.values(agents);
|
|
70
|
-
return [];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function compileSimpleGlob(pattern) {
|
|
74
|
-
const source = String(pattern || DEFAULT_TMUX_PREFIX)
|
|
75
|
-
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
76
|
-
.replace(/\*/g, ".*");
|
|
77
|
-
return new RegExp(`^${source}$`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function formatAge(ageSec) {
|
|
81
|
-
if (!Number.isFinite(ageSec) || ageSec < 0) return "unknown";
|
|
82
|
-
if (ageSec < 60) return `${Math.floor(ageSec)}s`;
|
|
83
|
-
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m`;
|
|
84
|
-
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h`;
|
|
85
|
-
return `${Math.floor(ageSec / 86400)}d`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function parseSessionCreated(rawValue) {
|
|
89
|
-
const parsed = Number.parseInt(String(rawValue || "").trim(), 10);
|
|
90
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function parseTmuxListSessions(output, nowSec) {
|
|
94
|
-
const sessions = [];
|
|
95
|
-
for (const line of String(output || "").split(/\r?\n/)) {
|
|
96
|
-
const [name, attachedText, createdText] = line.split("\t");
|
|
97
|
-
if (!name) continue;
|
|
98
|
-
const attached = Number.parseInt(attachedText || "0", 10);
|
|
99
|
-
const created = parseSessionCreated(createdText);
|
|
100
|
-
sessions.push({
|
|
101
|
-
name,
|
|
102
|
-
attached: Number.isFinite(attached) ? attached : 0,
|
|
103
|
-
ageSec: created == null ? null : Math.max(0, nowSec - created),
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
return sessions;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function parseFirstPane(output) {
|
|
110
|
-
const first = String(output || "")
|
|
111
|
-
.split(/\r?\n/)
|
|
112
|
-
.find((line) => line.trim());
|
|
113
|
-
if (!first) return { cwd: null, command: null, pid: null };
|
|
114
|
-
const [cwd, command, pidText] = first.split("\t");
|
|
115
|
-
const pid = Number.parseInt(pidText || "", 10);
|
|
116
|
-
return {
|
|
117
|
-
cwd: cwd || null,
|
|
118
|
-
command: command || null,
|
|
119
|
-
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function queryMemoryEstimateMb(pid, execFile = execFileSync) {
|
|
124
|
-
if (!pid) return null;
|
|
125
|
-
try {
|
|
126
|
-
const output = execFile("ps", ["-o", "rss=", "-p", String(pid)], {
|
|
127
|
-
encoding: "utf8",
|
|
128
|
-
timeout: 400,
|
|
129
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
130
|
-
windowsHide: true,
|
|
131
|
-
});
|
|
132
|
-
const rssKb = Number.parseInt(String(output || "").trim(), 10);
|
|
133
|
-
if (!Number.isFinite(rssKb) || rssKb < 0) return null;
|
|
134
|
-
return Math.round(rssKb / 1024);
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function inspectDetachedTmuxSessions({
|
|
141
|
-
prefix = DEFAULT_TMUX_PREFIX,
|
|
142
|
-
now = Date.now(),
|
|
143
|
-
execFile = execFileSync,
|
|
144
|
-
limit = MAX_TMUX_REPORT_SESSIONS,
|
|
145
|
-
} = {}) {
|
|
146
|
-
const nowSec = Math.floor(now / 1000);
|
|
147
|
-
const matcher = compileSimpleGlob(prefix);
|
|
148
|
-
let output = "";
|
|
149
|
-
try {
|
|
150
|
-
output = execFile(
|
|
151
|
-
"tmux",
|
|
152
|
-
[
|
|
153
|
-
"list-sessions",
|
|
154
|
-
"-F",
|
|
155
|
-
"#{session_name}\t#{session_attached}\t#{session_created}",
|
|
156
|
-
],
|
|
157
|
-
{
|
|
158
|
-
encoding: "utf8",
|
|
159
|
-
timeout: 500,
|
|
160
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
161
|
-
windowsHide: true,
|
|
162
|
-
},
|
|
163
|
-
);
|
|
164
|
-
} catch {
|
|
165
|
-
return { available: false, prefix, sessions: [] };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const sessions = [];
|
|
169
|
-
for (const session of parseTmuxListSessions(output, nowSec)) {
|
|
170
|
-
if (session.attached !== 0 || !matcher.test(session.name)) continue;
|
|
171
|
-
let pane = { cwd: null, command: null, pid: null };
|
|
172
|
-
try {
|
|
173
|
-
pane = parseFirstPane(
|
|
174
|
-
execFile(
|
|
175
|
-
"tmux",
|
|
176
|
-
[
|
|
177
|
-
"list-panes",
|
|
178
|
-
"-t",
|
|
179
|
-
session.name,
|
|
180
|
-
"-F",
|
|
181
|
-
"#{pane_current_path}\t#{pane_current_command}\t#{pane_pid}",
|
|
182
|
-
],
|
|
183
|
-
{
|
|
184
|
-
encoding: "utf8",
|
|
185
|
-
timeout: 500,
|
|
186
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
187
|
-
windowsHide: true,
|
|
188
|
-
},
|
|
189
|
-
),
|
|
190
|
-
);
|
|
191
|
-
} catch {
|
|
192
|
-
pane = { cwd: null, command: null, pid: null };
|
|
193
|
-
}
|
|
194
|
-
sessions.push({
|
|
195
|
-
name: session.name,
|
|
196
|
-
age: formatAge(session.ageSec),
|
|
197
|
-
cwd: pane.cwd,
|
|
198
|
-
command: pane.command,
|
|
199
|
-
memory_estimate_mb: queryMemoryEstimateMb(pane.pid, execFile),
|
|
200
|
-
});
|
|
201
|
-
if (sessions.length >= limit) break;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return { available: true, prefix, sessions };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function summarizeLifecycleState(state, now = Date.now()) {
|
|
208
|
-
const completedEntries = Array.isArray(state?.completed)
|
|
209
|
-
? state.completed
|
|
210
|
-
: [];
|
|
211
|
-
const summary = {
|
|
212
|
-
running: 0,
|
|
213
|
-
completed: completedEntries.length,
|
|
214
|
-
stale: 0,
|
|
215
|
-
total: completedEntries.length,
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
for (const agent of agentEntries(state)) {
|
|
219
|
-
if (!agent || typeof agent !== "object") continue;
|
|
220
|
-
summary.total += 1;
|
|
221
|
-
|
|
222
|
-
if (isCompleted(agent)) {
|
|
223
|
-
summary.completed += 1;
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const started = timestampMs(agent);
|
|
228
|
-
if (started !== null && now - started >= STALE_MS) {
|
|
229
|
-
summary.stale += 1;
|
|
230
|
-
} else {
|
|
231
|
-
summary.running += 1;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return summary;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function pruneStaleRunningAgents(state, now = Date.now()) {
|
|
239
|
-
const agents = state?.agents;
|
|
240
|
-
if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
|
|
241
|
-
return { nextState: state, removed: 0 };
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
let removed = 0;
|
|
245
|
-
const nextAgents = {};
|
|
246
|
-
for (const [key, agent] of Object.entries(agents)) {
|
|
247
|
-
const started = timestampMs(agent);
|
|
248
|
-
const stale =
|
|
249
|
-
!isCompleted(agent) && started !== null && now - started >= STALE_MS;
|
|
250
|
-
if (stale) {
|
|
251
|
-
removed += 1;
|
|
252
|
-
} else {
|
|
253
|
-
nextAgents[key] = agent;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
nextState: { ...state, agents: nextAgents },
|
|
259
|
-
removed,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function buildContext(summary, cleanup, tmuxReport) {
|
|
264
|
-
let context =
|
|
265
|
-
`${MARKER} subagent lifecycle: ` +
|
|
266
|
-
`running=${summary.running} completed=${summary.completed} ` +
|
|
267
|
-
`stale=${summary.stale} total=${summary.total} cleanup=${cleanup}`;
|
|
268
|
-
|
|
269
|
-
if (tmuxReport?.sessions?.length > 0) {
|
|
270
|
-
context += ` detached_tmux_sessions=${JSON.stringify(tmuxReport.sessions)}`;
|
|
271
|
-
}
|
|
272
|
-
return context;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function buildOutput(summary, cleanup, tmuxReport) {
|
|
276
|
-
const context = buildContext(summary, cleanup, tmuxReport);
|
|
277
|
-
const output = {
|
|
278
|
-
systemMessage: context,
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
let json = JSON.stringify(output);
|
|
282
|
-
if (Buffer.byteLength(json, "utf8") >= MAX_OUTPUT_BYTES) {
|
|
283
|
-
const bounded = buildContext(summary, cleanup, {
|
|
284
|
-
sessions: tmuxReport?.sessions?.slice(0, 2) || [],
|
|
285
|
-
});
|
|
286
|
-
json = JSON.stringify({
|
|
287
|
-
systemMessage: bounded,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
return json;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function run(input, env = process.env) {
|
|
294
|
-
if (input?.hook_event_name !== "SessionEnd") return "";
|
|
295
|
-
|
|
296
|
-
// Best-effort Synapse unregister (fire-and-forget). Crash-safe: if this hook
|
|
297
|
-
// never fires, the 5-min interactive TTL eventually marks the row stale, so
|
|
298
|
-
// unregister is an optimization, not a correctness dependency. Never throws,
|
|
299
|
-
// never changes the report-only stdout contract below.
|
|
300
|
-
//
|
|
301
|
-
// Bounded timeout (unref AbortController inside synapse-http) so a stalled hub
|
|
302
|
-
// cannot keep this short-lived SessionEnd hook process alive past its budget.
|
|
303
|
-
try {
|
|
304
|
-
const sessionId = String(input?.session_id || "").trim();
|
|
305
|
-
if (sessionId) unregisterSynapseSession(sessionId, { timeoutMs: 1500 });
|
|
306
|
-
} catch {
|
|
307
|
-
/* swallow — report-only charter preserved */
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const root = env.CLAUDE_CWD || process.cwd();
|
|
311
|
-
const statePath = join(root, ".triflux", "subagents", "subagents.json");
|
|
312
|
-
if (!existsSync(statePath)) return "";
|
|
313
|
-
|
|
314
|
-
const state = parseJson(readFileSync(statePath, "utf8"));
|
|
315
|
-
if (!state) return "";
|
|
316
|
-
|
|
317
|
-
const summary = summarizeLifecycleState(state);
|
|
318
|
-
let cleanup = "report-only";
|
|
319
|
-
const tmuxReport = inspectDetachedTmuxSessions({
|
|
320
|
-
prefix: env.TFX_SESSION_END_TMUX_PREFIX || DEFAULT_TMUX_PREFIX,
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
if (env.TFX_SESSION_END_CLEANUP === "1" && summary.stale > 0) {
|
|
324
|
-
try {
|
|
325
|
-
const { nextState, removed } = pruneStaleRunningAgents(state);
|
|
326
|
-
const remainingAgents = Object.keys(nextState?.agents || {}).length;
|
|
327
|
-
const remainingCompleted = Array.isArray(nextState?.completed)
|
|
328
|
-
? nextState.completed.length
|
|
329
|
-
: 0;
|
|
330
|
-
if (remainingAgents === 0 && remainingCompleted === 0) {
|
|
331
|
-
unlinkSync(statePath);
|
|
332
|
-
cleanup = "removed";
|
|
333
|
-
} else {
|
|
334
|
-
writeFileSync(
|
|
335
|
-
statePath,
|
|
336
|
-
`${JSON.stringify({ ...nextState, updatedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
337
|
-
"utf8",
|
|
338
|
-
);
|
|
339
|
-
cleanup = `pruned-${removed}`;
|
|
340
|
-
}
|
|
341
|
-
} catch {
|
|
342
|
-
cleanup = "failed";
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return buildOutput(summary, cleanup, tmuxReport);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function main() {
|
|
350
|
-
const input = parseJson(readStdin());
|
|
351
|
-
if (!input) return;
|
|
352
|
-
const output = run(input);
|
|
353
|
-
if (output) process.stdout.write(output);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
main();
|
|
358
|
-
} catch {
|
|
359
|
-
process.exit(0);
|
|
360
|
-
}
|