@triflux/core 10.29.0 → 10.30.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/hook-orchestrator.mjs +24 -0
- package/hooks/session-start-fast.mjs +35 -0
- package/hub/team/synapse-http.mjs +12 -7
- package/hud/constants.mjs +7 -0
- package/hud/hud-qos-status.mjs +15 -0
- package/hud/providers/claude.mjs +1 -28
- package/hud/providers/cto.mjs +73 -0
- package/hud/renderers.mjs +3 -37
- package/package.json +1 -1
|
@@ -441,6 +441,30 @@ async function main() {
|
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
// ── UserPromptSubmit: interactive 세션 liveness heartbeat ──
|
|
445
|
+
// SessionStart 가 register, 매 프롬프트가 갱신한다(사용자 활동 = liveness). 이
|
|
446
|
+
// 갱신이 없으면 hub monitor 가 5분 TTL 후 세션을 stale 로 전이시켜 `cto status`
|
|
447
|
+
// 의 live_sessions 가 항상 비어 보인다. fire-and-forget POST 는 process.exit 가
|
|
448
|
+
// 버리므로(cf. [[project_process_exit_drops_fire_and_forget]]) 발사 직후 바로
|
|
449
|
+
// drain 한다 — 아래 registry hook 이 없어 early-exit 하는 경로에서도 flush 를
|
|
450
|
+
// 보장하기 위해서다. drain 은 localhost POST 라 보통 즉시 반환되고, hub stall
|
|
451
|
+
// 시에만 짧은 상한(500ms)으로 프롬프트 지연을 막는다. keyword-detector /
|
|
452
|
+
// north-star 등 registry hook 은 그 뒤로 평소대로 실행된다.
|
|
453
|
+
if (eventName === "UserPromptSubmit") {
|
|
454
|
+
try {
|
|
455
|
+
const { heartbeatInteractiveSession } = await import(
|
|
456
|
+
"./session-start-fast.mjs"
|
|
457
|
+
);
|
|
458
|
+
heartbeatInteractiveSession(stdinRaw);
|
|
459
|
+
const { drainPendingSynapse } = await import(
|
|
460
|
+
"../hub/team/synapse-http.mjs"
|
|
461
|
+
);
|
|
462
|
+
await drainPendingSynapse(500);
|
|
463
|
+
} catch {
|
|
464
|
+
/* best-effort — heartbeat 실패가 프롬프트 턴을 막지 않는다 */
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
444
468
|
// 이벤트에 해당하는 훅 목록
|
|
445
469
|
const hooks = registry.events[eventName];
|
|
446
470
|
if (!hooks || hooks.length === 0) process.exit(0);
|
|
@@ -14,6 +14,7 @@ import { execFile } from "node:child_process";
|
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
16
|
import {
|
|
17
|
+
buildSynapseTaskSummary,
|
|
17
18
|
drainPendingSynapse,
|
|
18
19
|
heartbeatSynapseSession,
|
|
19
20
|
registerSynapseSession,
|
|
@@ -131,6 +132,40 @@ export function registerInteractiveSession(stdinData, seams = {}) {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
/**
|
|
136
|
+
* UserPromptSubmit 마다 interactive 세션의 liveness 를 갱신한다.
|
|
137
|
+
* SessionStart 가 register, 이후 매 프롬프트가 heartbeat — 사용자 활동이 곧
|
|
138
|
+
* liveness 다. 이 갱신이 없으면 hub monitor 가 5분 TTL 후 세션을 stale 로 전이시켜
|
|
139
|
+
* `cto status` 의 live_sessions 가 항상 비어 보인다.
|
|
140
|
+
*
|
|
141
|
+
* heartbeat 는 fire-and-forget POST 이므로 호출측(hook-orchestrator)이
|
|
142
|
+
* process.exit 전에 drainPendingSynapse 로 flush 해야 drop 되지 않는다
|
|
143
|
+
* (cf. SessionStart 의 execute() 자체 drain).
|
|
144
|
+
*
|
|
145
|
+
* pid 는 register 와 동일하게 보내지 않는다(91-92 주석): liveness 는 TTL +
|
|
146
|
+
* heartbeat 가 담당한다.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} stdinData UserPromptSubmit stdin JSON
|
|
149
|
+
* @param {object} [seams] 테스트용 injectable seam
|
|
150
|
+
* @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
|
|
151
|
+
* @returns {void}
|
|
152
|
+
*/
|
|
153
|
+
export function heartbeatInteractiveSession(stdinData, seams = {}) {
|
|
154
|
+
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
155
|
+
try {
|
|
156
|
+
const payload = parseStartPayload(stdinData);
|
|
157
|
+
const sessionId = String(payload?.session_id || "").trim();
|
|
158
|
+
if (!sessionId) return;
|
|
159
|
+
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
160
|
+
const partial = { worktreePath: cwd, host: "local" };
|
|
161
|
+
const prompt = typeof payload?.prompt === "string" ? payload.prompt : "";
|
|
162
|
+
if (prompt) partial.taskSummary = buildSynapseTaskSummary(prompt);
|
|
163
|
+
heartbeat(sessionId, partial);
|
|
164
|
+
} catch {
|
|
165
|
+
/* best-effort — never affects the prompt turn */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
134
169
|
/**
|
|
135
170
|
* BLOCKING 훅을 순차 실행. 하나라도 throw하면 로그만 남기고 계속.
|
|
136
171
|
* @param {string} stdinData
|
|
@@ -124,13 +124,18 @@ export function drainPendingSynapse(timeoutMs = 1000) {
|
|
|
124
124
|
const settled = Promise.allSettled([...inFlightSynapse]);
|
|
125
125
|
const ms = Number(timeoutMs);
|
|
126
126
|
if (!Number.isFinite(ms) || ms <= 0) return settled;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
let timeout = null;
|
|
128
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
129
|
+
// Keep this timer ref'ed: drainPendingSynapse() is awaited specifically
|
|
130
|
+
// to hold the process open until either in-flight POSTs settle or the
|
|
131
|
+
// bounded drain budget expires. An unref'ed timer can let Node's test
|
|
132
|
+
// runner/process exit before the Promise resolves when no other handles
|
|
133
|
+
// remain.
|
|
134
|
+
timeout = setTimeout(resolve, ms);
|
|
135
|
+
});
|
|
136
|
+
return Promise.race([settled, timeoutPromise]).finally(() => {
|
|
137
|
+
if (timeout) clearTimeout(timeout);
|
|
138
|
+
});
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
export function registerSynapseSession(meta, opts = {}) {
|
package/hud/constants.mjs
CHANGED
|
@@ -36,6 +36,13 @@ export const TEAM_STATE_PATH = join(
|
|
|
36
36
|
"tfx-hub",
|
|
37
37
|
"team-state.json",
|
|
38
38
|
);
|
|
39
|
+
export const CTO_LAKE_CURRENT_RELATIVE_PATH = join(
|
|
40
|
+
".triflux",
|
|
41
|
+
"lake",
|
|
42
|
+
"current.md",
|
|
43
|
+
);
|
|
44
|
+
export const CTO_STATUS_STALE_MS = 60 * 60 * 1000; // 1시간
|
|
45
|
+
export const CTO_STATUS_LINE_MAX_CHARS = 140;
|
|
39
46
|
export const CONTEXT_MONITOR_CACHE_PATH = join(
|
|
40
47
|
homedir(),
|
|
41
48
|
".claude",
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
refreshCodexRateLimitsCache,
|
|
48
48
|
scheduleCodexRateLimitRefresh,
|
|
49
49
|
} from "./providers/codex.mjs";
|
|
50
|
+
import { readCtoStatus } from "./providers/cto.mjs";
|
|
50
51
|
// Gemini provider
|
|
51
52
|
import {
|
|
52
53
|
buildGeminiAuthContext,
|
|
@@ -82,6 +83,7 @@ import {
|
|
|
82
83
|
getProviderAccountId,
|
|
83
84
|
readJson,
|
|
84
85
|
readStdinJson,
|
|
86
|
+
stripAnsi,
|
|
85
87
|
} from "./utils.mjs";
|
|
86
88
|
|
|
87
89
|
// ============================================================================
|
|
@@ -290,6 +292,19 @@ async function main() {
|
|
|
290
292
|
),
|
|
291
293
|
];
|
|
292
294
|
|
|
295
|
+
const ctoStatus = readCtoStatus();
|
|
296
|
+
if (ctoStatus) {
|
|
297
|
+
const rightTag =
|
|
298
|
+
CURRENT_TIER === "nano" || CURRENT_TIER === "micro"
|
|
299
|
+
? ""
|
|
300
|
+
: stripAnsi(ctoStatus.rightTag || "").trim();
|
|
301
|
+
rows.push({
|
|
302
|
+
prefix: `${bold(claudeOrange("^"))}:`,
|
|
303
|
+
left: `${dim("cto:")}${stripAnsi(ctoStatus.line)}`,
|
|
304
|
+
right: rightTag ? dim(rightTag) : "",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
293
308
|
// tfx-multi 활성 시 팀 상태 행 추가 (v2.2)
|
|
294
309
|
const teamRow = getTeamRow(CURRENT_TIER);
|
|
295
310
|
if (teamRow) rows.push(teamRow);
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -395,7 +395,7 @@ export function parseClaudeUsageResponse(response) {
|
|
|
395
395
|
// 키 자체가 부재하면 percent=null (HUD --% placeholder), utilization=null 만 0%로 처리
|
|
396
396
|
const fiveHourPresent = response.five_hour != null;
|
|
397
397
|
const sevenDayPresent = response.seven_day != null;
|
|
398
|
-
|
|
398
|
+
return {
|
|
399
399
|
fiveHourPercent: fiveHourPresent
|
|
400
400
|
? clampPercent(response.five_hour.utilization ?? 0)
|
|
401
401
|
: null,
|
|
@@ -405,19 +405,6 @@ export function parseClaudeUsageResponse(response) {
|
|
|
405
405
|
fiveHourResetsAt: response.five_hour?.resets_at || null,
|
|
406
406
|
weeklyResetsAt: response.seven_day?.resets_at || null,
|
|
407
407
|
};
|
|
408
|
-
if (response.seven_day_sonnet != null) {
|
|
409
|
-
result.sonnetWeeklyPercent = clampPercent(
|
|
410
|
-
response.seven_day_sonnet.utilization ?? 0,
|
|
411
|
-
);
|
|
412
|
-
result.sonnetWeeklyResetsAt = response.seven_day_sonnet.resets_at || null;
|
|
413
|
-
}
|
|
414
|
-
if (response.seven_day_opus != null) {
|
|
415
|
-
result.opusWeeklyPercent = clampPercent(
|
|
416
|
-
response.seven_day_opus.utilization ?? 0,
|
|
417
|
-
);
|
|
418
|
-
result.opusWeeklyResetsAt = response.seven_day_opus.resets_at || null;
|
|
419
|
-
}
|
|
420
|
-
return result;
|
|
421
408
|
}
|
|
422
409
|
|
|
423
410
|
// stale 캐시의 과거 resetsAt → 다음 주기로 순환 추정 (null 대신 다음 reset 시간 계산)
|
|
@@ -438,20 +425,6 @@ export function stripStaleResets(data) {
|
|
|
438
425
|
advanceToNextCycle(t, SEVEN_DAY_MS),
|
|
439
426
|
).toISOString();
|
|
440
427
|
}
|
|
441
|
-
if (copy.sonnetWeeklyResetsAt) {
|
|
442
|
-
const t = new Date(copy.sonnetWeeklyResetsAt).getTime();
|
|
443
|
-
if (!Number.isNaN(t))
|
|
444
|
-
copy.sonnetWeeklyResetsAt = new Date(
|
|
445
|
-
advanceToNextCycle(t, SEVEN_DAY_MS),
|
|
446
|
-
).toISOString();
|
|
447
|
-
}
|
|
448
|
-
if (copy.opusWeeklyResetsAt) {
|
|
449
|
-
const t = new Date(copy.opusWeeklyResetsAt).getTime();
|
|
450
|
-
if (!Number.isNaN(t))
|
|
451
|
-
copy.opusWeeklyResetsAt = new Date(
|
|
452
|
-
advanceToNextCycle(t, SEVEN_DAY_MS),
|
|
453
|
-
).toISOString();
|
|
454
|
-
}
|
|
455
428
|
return copy;
|
|
456
429
|
}
|
|
457
430
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
CTO_LAKE_CURRENT_RELATIVE_PATH,
|
|
5
|
+
CTO_STATUS_LINE_MAX_CHARS,
|
|
6
|
+
CTO_STATUS_STALE_MS,
|
|
7
|
+
} from "../constants.mjs";
|
|
8
|
+
|
|
9
|
+
function oneLine(value) {
|
|
10
|
+
return String(value || "")
|
|
11
|
+
.replace(/\x1b\[[0-9;]*m/g, "")
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function capLine(line) {
|
|
17
|
+
const text = oneLine(line);
|
|
18
|
+
if (text.length <= CTO_STATUS_LINE_MAX_CHARS) return text;
|
|
19
|
+
return `${text.slice(0, CTO_STATUS_LINE_MAX_CHARS - 3)}...`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveCurrentPath({ currentPath, rootDir } = {}) {
|
|
23
|
+
if (currentPath) return currentPath;
|
|
24
|
+
return join(rootDir || process.cwd(), CTO_LAKE_CURRENT_RELATIVE_PATH);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractBriefVersion(lines) {
|
|
28
|
+
const versionLine = lines.find((line) => /^brief_version:\s*/i.test(line));
|
|
29
|
+
return oneLine(versionLine?.replace(/^brief_version:\s*/i, "")) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractStatusLine(lines) {
|
|
33
|
+
const summaryLine = lines.find((line) => /^summary:\s*/i.test(line));
|
|
34
|
+
const summary = oneLine(summaryLine?.replace(/^summary:\s*/i, ""));
|
|
35
|
+
if (summary) return capLine(summary);
|
|
36
|
+
|
|
37
|
+
const sectionHeaders = new Set([
|
|
38
|
+
"repo_state",
|
|
39
|
+
"active_goals",
|
|
40
|
+
"swarm_shards",
|
|
41
|
+
"hub_status",
|
|
42
|
+
"recent_events",
|
|
43
|
+
"generated_at",
|
|
44
|
+
"injection_note",
|
|
45
|
+
]);
|
|
46
|
+
const fallback = lines.find((line) => {
|
|
47
|
+
const clean = oneLine(line);
|
|
48
|
+
return (
|
|
49
|
+
clean && !/^brief_version:\s*/i.test(clean) && !sectionHeaders.has(clean)
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
return fallback ? capLine(fallback) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readCtoStatus(options = {}) {
|
|
56
|
+
try {
|
|
57
|
+
const currentPath = resolveCurrentPath(options);
|
|
58
|
+
const stat = statSync(currentPath);
|
|
59
|
+
const raw = readFileSync(currentPath, "utf8");
|
|
60
|
+
const lines = raw.split(/\r?\n/u).map(oneLine).filter(Boolean);
|
|
61
|
+
const line = extractStatusLine(lines);
|
|
62
|
+
if (!line) return null;
|
|
63
|
+
|
|
64
|
+
const version = extractBriefVersion(lines);
|
|
65
|
+
const isStale = Date.now() - stat.mtimeMs > CTO_STATUS_STALE_MS;
|
|
66
|
+
return {
|
|
67
|
+
line,
|
|
68
|
+
rightTag: isStale ? "stale" : version || "cto",
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/hud/renderers.mjs
CHANGED
|
@@ -375,34 +375,6 @@ export function getClaudeRows(
|
|
|
375
375
|
: tierDimBar(currentTier);
|
|
376
376
|
const fTime = formatTimeCell(fiveHourReset);
|
|
377
377
|
const wTime = formatTimeCellDH(weeklyReset);
|
|
378
|
-
const modelWeeklyPart = (label, percent, resetsAt, { includeTime }) => {
|
|
379
|
-
if (!hasData || percent == null || percent <= 0) return null;
|
|
380
|
-
const value = colorByProvider(
|
|
381
|
-
percent,
|
|
382
|
-
formatPercentCell(percent),
|
|
383
|
-
claudeOrange,
|
|
384
|
-
);
|
|
385
|
-
const reset = resetsAt
|
|
386
|
-
? formatResetRemainingDayHour(resetsAt, SEVEN_DAY_MS)
|
|
387
|
-
: null;
|
|
388
|
-
const time = includeTime && reset ? ` ${dim(formatTimeCellDH(reset))}` : "";
|
|
389
|
-
return `${dim(`${label}:`)}${value}${time}`;
|
|
390
|
-
};
|
|
391
|
-
const modelWeeklyParts = ({ includeTime = false } = {}) =>
|
|
392
|
-
[
|
|
393
|
-
modelWeeklyPart(
|
|
394
|
-
"Sn",
|
|
395
|
-
claudeUsage?.sonnetWeeklyPercent,
|
|
396
|
-
claudeUsage?.sonnetWeeklyResetsAt,
|
|
397
|
-
{ includeTime },
|
|
398
|
-
),
|
|
399
|
-
modelWeeklyPart(
|
|
400
|
-
"Op",
|
|
401
|
-
claudeUsage?.opusWeeklyPercent,
|
|
402
|
-
claudeUsage?.opusWeeklyResetsAt,
|
|
403
|
-
{ includeTime },
|
|
404
|
-
),
|
|
405
|
-
].filter(Boolean);
|
|
406
378
|
|
|
407
379
|
if (currentTier === "nano" || currentTier === "micro") {
|
|
408
380
|
const fShort =
|
|
@@ -419,18 +391,14 @@ export function getClaudeRows(
|
|
|
419
391
|
|
|
420
392
|
if (currentTier === "minimal") {
|
|
421
393
|
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
422
|
-
const
|
|
423
|
-
const modelSuffix = modelSection.length ? ` ${modelSection.join(" ")}` : "";
|
|
424
|
-
const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}${modelSuffix}${staleTag}`;
|
|
394
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}${staleTag}`;
|
|
425
395
|
const right = `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
|
|
426
396
|
return [{ prefix, left: quotaSection, right }];
|
|
427
397
|
}
|
|
428
398
|
|
|
429
399
|
if (currentTier === "compact") {
|
|
430
400
|
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
431
|
-
const
|
|
432
|
-
const modelSuffix = modelSection.length ? ` ${modelSection.join(" ")}` : "";
|
|
433
|
-
const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}${modelSuffix}${staleTag}`;
|
|
401
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}${staleTag}`;
|
|
434
402
|
const warning = ctxView.warningTag
|
|
435
403
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
436
404
|
: "";
|
|
@@ -440,9 +408,7 @@ export function getClaudeRows(
|
|
|
440
408
|
|
|
441
409
|
// full tier (>= 120 cols)
|
|
442
410
|
const staleTag = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
443
|
-
const
|
|
444
|
-
const modelSuffix = modelSection.length ? ` ${modelSection.join(" ")}` : "";
|
|
445
|
-
const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}${modelSuffix}${staleTag}`;
|
|
411
|
+
const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}${staleTag}`;
|
|
446
412
|
const warning = ctxView.warningTag
|
|
447
413
|
? ` ${dim("|")} ${yellow(ctxView.warningTag)}`
|
|
448
414
|
: "";
|