@triflux/core 10.37.0 → 10.38.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.
@@ -30,12 +30,18 @@ function parsePayload(stdinData) {
30
30
  }
31
31
  }
32
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.
33
+ /**
34
+ * Convert an Antigravity hook payload into the Codex-shaped session payload
35
+ * consumed by the shared fast session registration helpers.
36
+ *
37
+ * Antigravity hook payloads use camelCase system metadata (`conversationId`,
38
+ * `workspacePaths`) rather than Codex's `session_id`/`cwd`. The
39
+ * `conversationId` is the stable per-conversation UUID; the first mounted
40
+ * workspace path is the effective cwd.
41
+ *
42
+ * @param {Record<string, unknown> | null | undefined} payload
43
+ * @returns {string} JSON string with `{ session_id, cwd, actor_cli }`.
44
+ */
39
45
  export function toSessionPayload(payload) {
40
46
  const sessionId = String(payload?.conversationId || "").trim();
41
47
  const workspacePaths = Array.isArray(payload?.workspacePaths)
@@ -48,10 +54,18 @@ export function toSessionPayload(payload) {
48
54
  return JSON.stringify({ session_id: sessionId, cwd, actor_cli: "agy" });
49
55
  }
50
56
 
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.
57
+ /**
58
+ * Decide whether a PreInvocation payload should register or heartbeat.
59
+ *
60
+ * agy has no distinct SessionStart/UserPromptSubmit events. PreInvocation fires
61
+ * before every model call, so per-conversation `invocationNum` gates register
62
+ * (first call) vs heartbeat (later calls). An explicit argv mode overrides this,
63
+ * mirroring the Codex hook.
64
+ *
65
+ * @param {string | null | undefined} argvMode
66
+ * @param {Record<string, unknown> | null | undefined} payload
67
+ * @returns {"register" | "heartbeat"}
68
+ */
55
69
  export function normalizeMode(argvMode, payload) {
56
70
  const direct = String(argvMode || "")
57
71
  .trim()
@@ -67,6 +81,50 @@ export function normalizeMode(argvMode, payload) {
67
81
  return "register";
68
82
  }
69
83
 
84
+ function swallowStdoutWrite(_chunk, encodingOrCallback, callback) {
85
+ const done =
86
+ typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
87
+ if (typeof done === "function") done();
88
+ return true;
89
+ }
90
+
91
+ async function runHookSideEffectsWithStdoutSuppressed(fn) {
92
+ const originalStdoutWrite = stdout.write;
93
+ const originalConsoleDebug = console.debug;
94
+ const originalConsoleInfo = console.info;
95
+ const originalConsoleLog = console.log;
96
+
97
+ stdout.write = swallowStdoutWrite;
98
+ console.debug = () => {};
99
+ console.info = () => {};
100
+ console.log = () => {};
101
+ try {
102
+ return await fn();
103
+ } finally {
104
+ stdout.write = originalStdoutWrite;
105
+ console.debug = originalConsoleDebug;
106
+ console.info = originalConsoleInfo;
107
+ console.log = originalConsoleLog;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Execute the observational Antigravity session hook.
113
+ *
114
+ * The hook always returns/writes an empty JSON object so hook stdout remains
115
+ * JSON-only and hook failures never block the user session.
116
+ *
117
+ * @param {string} stdinData
118
+ * @param {{
119
+ * argvMode?: string,
120
+ * writeStdout?: boolean,
121
+ * hubEnsureRun?: (stdinData: string) => Promise<unknown> | unknown,
122
+ * registerInteractiveSession?: (stdinData: string) => Promise<unknown> | unknown,
123
+ * heartbeatInteractiveSession?: (stdinData: string) => Promise<unknown> | unknown,
124
+ * drainPendingSynapse?: (timeoutMs?: number) => Promise<unknown> | unknown,
125
+ * }} [opts]
126
+ * @returns {Promise<string>}
127
+ */
70
128
  export async function runAgySessionHook(stdinData, opts = {}) {
71
129
  const output = "{}\n";
72
130
  const parsed = parsePayload(stdinData);
@@ -93,24 +151,26 @@ export async function runAgySessionHook(stdinData, opts = {}) {
93
151
  opts.drainPendingSynapse || defaultDrainPendingSynapse;
94
152
 
95
153
  try {
96
- if (mode === "register") {
97
- try {
98
- await hubEnsureRun(sessionPayload);
99
- } catch {}
100
- try {
101
- await Promise.resolve(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
- }
154
+ await runHookSideEffectsWithStdoutSuppressed(async () => {
155
+ if (mode === "register") {
156
+ try {
157
+ await hubEnsureRun(sessionPayload);
158
+ } catch {}
159
+ try {
160
+ await Promise.resolve(registerInteractiveSession(sessionPayload));
161
+ } catch {}
162
+ try {
163
+ await drainPendingSynapse(1000);
164
+ } catch {}
165
+ } else if (mode === "heartbeat") {
166
+ try {
167
+ heartbeatInteractiveSession(sessionPayload);
168
+ } catch {}
169
+ try {
170
+ await drainPendingSynapse(500);
171
+ } catch {}
172
+ }
173
+ });
114
174
  } catch {
115
175
  // agy session hooks are observational and must never block the session.
116
176
  }
@@ -16,7 +16,11 @@ import { dirname, join } from "node:path";
16
16
  const STATE_VERSION = 1;
17
17
  const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
18
18
  const MAX_COMPLETED = 20;
19
- const DEFAULT_LOCK_TIMEOUT_MS = 750;
19
+ // Lifecycle hooks are best-effort, but under CI/Linux process contention a
20
+ // 30-way SubagentStart burst can legitimately take around a second to drain.
21
+ // Keep this bounded so hooks do not hang indefinitely, while avoiding silent
22
+ // lifecycle drops during normal high-concurrency fan-out.
23
+ const DEFAULT_LOCK_TIMEOUT_MS = 3000;
20
24
  const LOCK_RETRY_MS = 20;
21
25
 
22
26
  function readStdin() {
@@ -96,6 +100,16 @@ function withStateLock(statePath, fn, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
96
100
  }
97
101
  }
98
102
 
103
+ function lockTimeoutMsFromOptions(opts = {}) {
104
+ if (Number.isFinite(opts.lockTimeoutMs)) return opts.lockTimeoutMs;
105
+ const raw = opts.env?.TRIFLUX_SUBAGENT_LOCK_TIMEOUT_MS;
106
+ if (raw === undefined || raw === "") return DEFAULT_LOCK_TIMEOUT_MS;
107
+ const parsed = Number(raw);
108
+ return Number.isFinite(parsed) && parsed > 0
109
+ ? Math.trunc(parsed)
110
+ : DEFAULT_LOCK_TIMEOUT_MS;
111
+ }
112
+
99
113
  function lifecycleKey(input) {
100
114
  if (typeof input.agent_id === "string" && input.agent_id.trim()) {
101
115
  return input.agent_id;
@@ -241,7 +255,7 @@ export function recordLifecycle(input, opts = {}) {
241
255
  writeState(statePath, state);
242
256
  return output;
243
257
  },
244
- opts.lockTimeoutMs,
258
+ lockTimeoutMsFromOptions(opts),
245
259
  );
246
260
  }
247
261
 
@@ -468,21 +468,29 @@ export function sendClaudeControlRequest(
468
468
  // 인증 미강제 daemon 으로 보고 auth 필드를 생략한다 (fail-open — 구버전 호환).
469
469
  export async function readDaemonControlKey(
470
470
  configDir = resolveClaudeConfigDir(),
471
+ { diagnostics } = {},
471
472
  ) {
473
+ if (!configDir) return undefined;
474
+ const keyPath = path.join(configDir, "daemon", "control.key");
472
475
  try {
473
- const key = await fs.readFile(
474
- path.join(configDir, "daemon", "control.key"),
475
- "utf8",
476
- );
476
+ const key = await fs.readFile(keyPath, "utf8");
477
477
  return key.trim() || undefined;
478
- } catch {
478
+ } catch (error) {
479
+ if (error?.code === "ENOENT") return undefined;
480
+ if (Array.isArray(diagnostics)) {
481
+ diagnostics.push({
482
+ code: error?.code || "UNKNOWN",
483
+ path: keyPath,
484
+ message: error?.message || String(error),
485
+ });
486
+ }
479
487
  return undefined;
480
488
  }
481
489
  }
482
490
 
483
- export async function buildDaemonControlAuth(configDir) {
491
+ export async function buildDaemonControlAuth(configDir, opts = {}) {
484
492
  if (!configDir) return {};
485
- const auth = await readDaemonControlKey(configDir);
493
+ const auth = await readDaemonControlKey(configDir, opts);
486
494
  return auth ? { auth } : {};
487
495
  }
488
496
 
@@ -197,6 +197,8 @@ export function deriveGeminiFamilyBucket(buckets) {
197
197
 
198
198
  export function buildGeminiAuthContext(accountId) {
199
199
  let oauth = readJson(GEMINI_OAUTH_PATH, null);
200
+ let authSource = oauth?.access_token ? "gemini-file" : "none";
201
+ let expiryMissing = oauth?.access_token ? oauth.expiry_date == null : false;
200
202
  const fileExpired =
201
203
  oauth?.expiry_date != null && oauth.expiry_date < Date.now();
202
204
  // Preserve a valid Gemini file token; agy Keychain is only a missing/expired fallback.
@@ -209,13 +211,15 @@ export function buildGeminiAuthContext(accountId) {
209
211
  ...fileRest
210
212
  } = oauth || {};
211
213
  oauth = { ...fileRest, ...keychainOAuth };
214
+ authSource = "antigravity-keychain";
215
+ expiryMissing = keychainOAuth.expiry_date == null;
212
216
  }
213
217
  }
214
218
  const tokenSource =
215
219
  oauth?.refresh_token || oauth?.id_token || oauth?.access_token || "";
216
220
  const tokenFingerprint = tokenSource ? makeHash(tokenSource) : "none";
217
221
  const cacheKey = `${accountId || "gemini-main"}::${tokenFingerprint}`;
218
- return { oauth, tokenFingerprint, cacheKey };
222
+ return { oauth, tokenFingerprint, cacheKey, authSource, expiryMissing };
219
223
  }
220
224
 
221
225
  function firstPresent(...values) {
@@ -305,12 +309,76 @@ function getAntigravityTokenFromKeychain() {
305
309
  }
306
310
  }
307
311
 
312
+ export function classifyGeminiQuotaFailure(response, authContext = {}) {
313
+ if (!response) return "network";
314
+ const error = response?.error || {};
315
+ const code = Number(error.code ?? response.code ?? response.status);
316
+ const status = String(error.status || response.status || "").toUpperCase();
317
+ const message = String(
318
+ error.message || error.code || response.error || response.message || "",
319
+ );
320
+ if (
321
+ code === 401 ||
322
+ code === 403 ||
323
+ status === "UNAUTHENTICATED" ||
324
+ status === "PERMISSION_DENIED" ||
325
+ /(unauthorized|forbidden|invalid authentication|invalid credentials|oauth|token|credential|auth)/i.test(
326
+ message,
327
+ )
328
+ ) {
329
+ return "auth";
330
+ }
331
+ if (
332
+ authContext.authSource === "antigravity-keychain" &&
333
+ authContext.expiryMissing === true &&
334
+ /(expired|invalid|unauthenticated|permission denied)/i.test(message)
335
+ ) {
336
+ return "auth";
337
+ }
338
+ return "api";
339
+ }
340
+
341
+ function formatGeminiQuotaFailure(response, authContext = {}, stage = "quota") {
342
+ const error =
343
+ response?.error?.message ||
344
+ response?.error?.status ||
345
+ response?.error?.code ||
346
+ response?.error ||
347
+ response?.message ||
348
+ "no buckets in response";
349
+ const base = String(error);
350
+ if (
351
+ authContext.authSource === "antigravity-keychain" &&
352
+ authContext.expiryMissing === true
353
+ ) {
354
+ return `expiry-less Antigravity Keychain token failed bounded ${stage} freshness probe: ${base}`;
355
+ }
356
+ return base;
357
+ }
358
+
359
+ function writeGeminiQuotaErrorCache(cache, authContext, errorType, errorHint) {
360
+ const sameKey = cache?.cacheKey === authContext.cacheKey;
361
+ writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
362
+ ...(sameKey ? cache || {} : {}),
363
+ timestamp: sameKey && cache?.timestamp ? cache.timestamp : Date.now(),
364
+ cacheKey: authContext.cacheKey,
365
+ accountId: authContext.accountId || "gemini-main",
366
+ tokenFingerprint: authContext.tokenFingerprint,
367
+ authSource: authContext.authSource,
368
+ expiryMissing: authContext.expiryMissing === true,
369
+ error: true,
370
+ errorType,
371
+ errorHint,
372
+ });
373
+ }
374
+
308
375
  // ============================================================================
309
376
  // Gemini 쿼터 API 호출 (5분 캐시)
310
377
  // ============================================================================
311
378
  export async function fetchGeminiQuota(accountId, options = {}) {
312
379
  const authContext = options.authContext || buildGeminiAuthContext(accountId);
313
380
  const { oauth, tokenFingerprint, cacheKey } = authContext;
381
+ authContext.accountId = accountId || "gemini-main";
314
382
  const forceRefresh = options.forceRefresh === true;
315
383
 
316
384
  // 1. 캐시 확인 (계정/토큰별)
@@ -330,35 +398,34 @@ export async function fetchGeminiQuota(accountId, options = {}) {
330
398
 
331
399
  if (!oauth?.access_token) {
332
400
  // access_token 없음: 에러 힌트를 캐시에 기록하고 stale 캐시 반환
333
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
334
- ...(cache || {}),
335
- timestamp: cache?.timestamp || Date.now(),
336
- error: true,
337
- errorType: "auth",
338
- errorHint: "no access_token in oauth_creds.json",
339
- });
401
+ writeGeminiQuotaErrorCache(
402
+ cache,
403
+ authContext,
404
+ "auth",
405
+ "no access_token in oauth_creds.json",
406
+ );
340
407
  return cache;
341
408
  }
342
409
  if (oauth.expiry_date && oauth.expiry_date < Date.now()) {
343
410
  // OAuth 토큰 만료: 에러 힌트를 캐시에 기록 (refresh_token 갱신은 Gemini CLI 담당)
344
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
345
- ...(cache || {}),
346
- timestamp: cache?.timestamp || Date.now(),
347
- error: true,
348
- errorType: "auth",
349
- errorHint: `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
350
- });
411
+ writeGeminiQuotaErrorCache(
412
+ cache,
413
+ authContext,
414
+ "auth",
415
+ `token expired at ${new Date(oauth.expiry_date).toISOString()}`,
416
+ );
351
417
  return cache;
352
418
  }
353
419
 
354
420
  // 3. projectId (캐시 or API)
421
+ let loadCodeAssistResponse = null;
355
422
  const fetchProjectId = async () => {
356
- const loadRes = await httpsPost(
423
+ loadCodeAssistResponse = await httpsPost(
357
424
  "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
358
425
  { metadata: { pluginType: "GEMINI" } },
359
426
  oauth.access_token,
360
427
  );
361
- const id = loadRes?.cloudaicompanionProject;
428
+ const id = loadCodeAssistResponse?.cloudaicompanionProject;
362
429
  if (id)
363
430
  writeJsonSafe(GEMINI_PROJECT_CACHE_PATH, {
364
431
  cacheKey,
@@ -376,7 +443,19 @@ export async function fetchGeminiQuota(accountId, options = {}) {
376
443
  let projectId =
377
444
  projCache?.cacheKey === cacheKey ? projCache?.projectId : null;
378
445
  if (!projectId) projectId = await fetchProjectId();
379
- if (!projectId) return cache;
446
+ if (!projectId) {
447
+ writeGeminiQuotaErrorCache(
448
+ cache,
449
+ authContext,
450
+ classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
451
+ formatGeminiQuotaFailure(
452
+ loadCodeAssistResponse,
453
+ authContext,
454
+ "loadCodeAssist",
455
+ ),
456
+ );
457
+ return cache;
458
+ }
380
459
 
381
460
  // 4. retrieveUserQuota 호출
382
461
  let quotaRes = await httpsPost(
@@ -388,7 +467,19 @@ export async function fetchGeminiQuota(accountId, options = {}) {
388
467
  // projectId 캐시가 만료/변경된 경우 1회 재시도
389
468
  if (!quotaRes?.buckets && projCache?.projectId) {
390
469
  projectId = await fetchProjectId();
391
- if (!projectId) return cache;
470
+ if (!projectId) {
471
+ writeGeminiQuotaErrorCache(
472
+ cache,
473
+ authContext,
474
+ classifyGeminiQuotaFailure(loadCodeAssistResponse, authContext),
475
+ formatGeminiQuotaFailure(
476
+ loadCodeAssistResponse,
477
+ authContext,
478
+ "loadCodeAssist",
479
+ ),
480
+ );
481
+ return cache;
482
+ }
392
483
  quotaRes = await httpsPost(
393
484
  "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
394
485
  { project: projectId },
@@ -398,18 +489,12 @@ export async function fetchGeminiQuota(accountId, options = {}) {
398
489
 
399
490
  if (!quotaRes?.buckets) {
400
491
  // API 응답에 buckets 없음: 에러 코드 또는 응답 내용을 캐시에 기록
401
- const apiError =
402
- quotaRes?.error?.message ||
403
- quotaRes?.error?.code ||
404
- quotaRes?.error ||
405
- "no buckets in response";
406
- writeJsonSafe(GEMINI_QUOTA_CACHE_PATH, {
407
- ...(cache || {}),
408
- timestamp: cache?.timestamp || Date.now(),
409
- error: true,
410
- errorType: "api",
411
- errorHint: String(apiError),
412
- });
492
+ writeGeminiQuotaErrorCache(
493
+ cache,
494
+ authContext,
495
+ classifyGeminiQuotaFailure(quotaRes, authContext),
496
+ formatGeminiQuotaFailure(quotaRes, authContext, "quota"),
497
+ );
413
498
  return cache;
414
499
  }
415
500
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.37.0",
3
+ "version": "10.38.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",