@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.
- package/hooks/agy-session-hook.mjs +88 -28
- package/hooks/subagent-tracker.mjs +16 -2
- package/hub/team/claude-daemon-control.mjs +15 -7
- package/hud/providers/gemini.mjs +116 -31
- package/package.json +1 -1
|
@@ -30,12 +30,18 @@ function parsePayload(stdinData) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/hud/providers/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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)
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
quotaRes
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|