@triflux/core 10.29.0 → 10.30.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.
@@ -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
- return Promise.race([
128
- settled,
129
- new Promise((resolve) => {
130
- const t = setTimeout(resolve, ms);
131
- if (typeof t.unref === "function") t.unref();
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",
@@ -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);
@@ -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
- const result = {
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 modelSection = modelWeeklyParts();
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 modelSection = modelWeeklyParts();
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 modelSection = modelWeeklyParts({ includeTime: true });
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
  : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.29.0",
3
+ "version": "10.30.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",