triflux 10.37.0 → 10.39.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/bin/triflux.mjs +3 -1
- package/cto/events.mjs +267 -0
- package/cto/index.mjs +7 -2
- 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/hub/team/notify.mjs +31 -11
- package/hud/providers/gemini.mjs +116 -31
- package/package.json +1 -1
- package/scripts/check-codex-config-stable.mjs +38 -11
- package/scripts/lib/gemini-profiles.mjs +73 -9
- package/scripts/test-lock.mjs +34 -29
- package/scripts/tfx-route.sh +43 -9
package/bin/triflux.mjs
CHANGED
|
@@ -499,13 +499,15 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
499
499
|
},
|
|
500
500
|
},
|
|
501
501
|
cto: {
|
|
502
|
-
usage: "tfx cto <collect|status|dashboard|hygiene> [options]",
|
|
502
|
+
usage: "tfx cto <collect|status|dashboard|hygiene|event> [options]",
|
|
503
503
|
description: "repo-local authority layer console",
|
|
504
504
|
subcommands: {
|
|
505
505
|
collect: "refresh .triflux/lake/current.json from authority sources",
|
|
506
506
|
status: "print the current authority summary",
|
|
507
507
|
dashboard: "render the CTO console dashboard, optionally with --watch",
|
|
508
508
|
hygiene: "project CTO hygiene counts and actionable dry-run rows",
|
|
509
|
+
event:
|
|
510
|
+
"append wrapper lineage events: context-save, context-restore, pr-created, pr-merged-or-closed",
|
|
509
511
|
},
|
|
510
512
|
},
|
|
511
513
|
multi: {
|
package/cto/events.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
unlinkSync,
|
|
7
7
|
} from "node:fs";
|
|
8
8
|
import { basename, join } from "node:path";
|
|
9
|
+
import { resolveLakeRootDir } from "./lake-root.mjs";
|
|
9
10
|
|
|
10
11
|
export const CTO_EVENT_SCHEMA_VERSION = "cto-event.v1";
|
|
11
12
|
export const DEFAULT_CTO_EVENT_SOURCE = "tfx_cto_event";
|
|
@@ -41,6 +42,98 @@ export const CTO_HYGIENE_STATUSES = Object.freeze([
|
|
|
41
42
|
const EVENT_TYPE_SET = new Set(CTO_EVENT_TYPES);
|
|
42
43
|
const STATUS_SET = new Set(CTO_HYGIENE_STATUSES);
|
|
43
44
|
|
|
45
|
+
const EVENT_PRESETS = Object.freeze({
|
|
46
|
+
"context-save": {
|
|
47
|
+
event: "checkpoint_saved",
|
|
48
|
+
source: "gstack_context_save",
|
|
49
|
+
actor: { cli: "gstack context-save" },
|
|
50
|
+
ownerSurface: "gstack context-save -> tfx cto event context-save",
|
|
51
|
+
},
|
|
52
|
+
"checkpoint-saved": {
|
|
53
|
+
event: "checkpoint_saved",
|
|
54
|
+
ownerSurface: "checkpoint wrapper -> tfx cto event checkpoint-saved",
|
|
55
|
+
},
|
|
56
|
+
"context-restore": {
|
|
57
|
+
event: "checkpoint_restored",
|
|
58
|
+
source: "gstack_context_restore",
|
|
59
|
+
actor: { cli: "gstack context-restore" },
|
|
60
|
+
ownerSurface: "gstack context-restore -> tfx cto event context-restore",
|
|
61
|
+
},
|
|
62
|
+
"checkpoint-restored": {
|
|
63
|
+
event: "checkpoint_restored",
|
|
64
|
+
ownerSurface: "checkpoint wrapper -> tfx cto event checkpoint-restored",
|
|
65
|
+
},
|
|
66
|
+
"pr-created": {
|
|
67
|
+
event: "pr_created",
|
|
68
|
+
source: "tfx_pr_lifecycle",
|
|
69
|
+
actor: { cli: "gh pr create" },
|
|
70
|
+
ownerSurface: "gh pr create/merge/close -> tfx cto event pr-created",
|
|
71
|
+
},
|
|
72
|
+
"pr-merged": {
|
|
73
|
+
event: "pr_merged_or_closed",
|
|
74
|
+
source: "tfx_pr_lifecycle",
|
|
75
|
+
status: "completed",
|
|
76
|
+
actor: { cli: "gh pr merge" },
|
|
77
|
+
ownerSurface:
|
|
78
|
+
"gh pr create/merge/close -> tfx cto event pr-merged-or-closed",
|
|
79
|
+
},
|
|
80
|
+
"pr-closed": {
|
|
81
|
+
event: "pr_merged_or_closed",
|
|
82
|
+
source: "tfx_pr_lifecycle",
|
|
83
|
+
status: "completed",
|
|
84
|
+
actor: { cli: "gh pr close" },
|
|
85
|
+
ownerSurface:
|
|
86
|
+
"gh pr create/merge/close -> tfx cto event pr-merged-or-closed",
|
|
87
|
+
},
|
|
88
|
+
"pr-merged-or-closed": {
|
|
89
|
+
event: "pr_merged_or_closed",
|
|
90
|
+
source: "tfx_pr_lifecycle",
|
|
91
|
+
actor: { cli: "gh pr merge/close" },
|
|
92
|
+
ownerSurface:
|
|
93
|
+
"gh pr create/merge/close -> tfx cto event pr-merged-or-closed",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const OPTION_KEYS = Object.freeze({
|
|
98
|
+
event: "event",
|
|
99
|
+
"event-type": "event",
|
|
100
|
+
source: "source",
|
|
101
|
+
summary: "summary",
|
|
102
|
+
now: "now",
|
|
103
|
+
ts: "ts",
|
|
104
|
+
status: "status",
|
|
105
|
+
"session-id": "session_id",
|
|
106
|
+
"parent-session-id": "parent_session_id",
|
|
107
|
+
"restored-from-session-id": "restored_from_session_id",
|
|
108
|
+
"checkpoint-id": "checkpoint_id",
|
|
109
|
+
"artifact-path": "artifact_path",
|
|
110
|
+
artifact: "artifact_path",
|
|
111
|
+
"task-id": "task_id",
|
|
112
|
+
branch: "branch",
|
|
113
|
+
"last-seen-at": "last_seen_at",
|
|
114
|
+
"stale-reason": "stale_reason",
|
|
115
|
+
"hygiene-key": "hygiene_key",
|
|
116
|
+
"hygiene-kind": "hygiene_kind",
|
|
117
|
+
"hygiene-id": "hygiene_id",
|
|
118
|
+
"hygiene-action": "hygiene_action",
|
|
119
|
+
"project-root": "project_root",
|
|
120
|
+
"worktree-path": "worktree_path",
|
|
121
|
+
worktree: "worktree_path",
|
|
122
|
+
issue: "issue_refs",
|
|
123
|
+
"issue-ref": "issue_refs",
|
|
124
|
+
"issue-refs": "issue_refs",
|
|
125
|
+
pr: "pr_refs",
|
|
126
|
+
"pr-ref": "pr_refs",
|
|
127
|
+
"pr-refs": "pr_refs",
|
|
128
|
+
"actor-cli": "actor.cli",
|
|
129
|
+
"actor-session-id": "actor.session_id",
|
|
130
|
+
"actor-agent-id": "actor.agent_id",
|
|
131
|
+
"actor-host": "actor.host",
|
|
132
|
+
"owner-surface": "owner_surface",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const COLLECTION_KEYS = new Set(["issue_refs", "pr_refs"]);
|
|
136
|
+
|
|
44
137
|
function sleep(ms) {
|
|
45
138
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
139
|
}
|
|
@@ -107,6 +200,124 @@ function normalizeActor(actor) {
|
|
|
107
200
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
108
201
|
}
|
|
109
202
|
|
|
203
|
+
function optionName(raw) {
|
|
204
|
+
return String(raw || "")
|
|
205
|
+
.replace(/^--?/u, "")
|
|
206
|
+
.trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function appendCollection(target, key, value) {
|
|
210
|
+
const values = String(value ?? "")
|
|
211
|
+
.split(",")
|
|
212
|
+
.map((item) => item.trim())
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
if (values.length === 0) return;
|
|
215
|
+
if (!Array.isArray(target[key])) target[key] = [];
|
|
216
|
+
target[key].push(...values);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function putPathValue(target, keyPath, value) {
|
|
220
|
+
if (keyPath.startsWith("actor.")) {
|
|
221
|
+
const key = keyPath.slice("actor.".length);
|
|
222
|
+
target.actor ||= {};
|
|
223
|
+
target.actor[key] = value;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (COLLECTION_KEYS.has(keyPath)) {
|
|
227
|
+
appendCollection(target, keyPath, value);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
target[keyPath] = value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseEventArgs(args = []) {
|
|
234
|
+
const fields = {};
|
|
235
|
+
const positional = [];
|
|
236
|
+
let json = false;
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
239
|
+
const arg = args[i];
|
|
240
|
+
if (arg === "--json") {
|
|
241
|
+
json = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!arg?.startsWith?.("--")) {
|
|
245
|
+
positional.push(arg);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const eqIndex = arg.indexOf("=");
|
|
250
|
+
const rawName = eqIndex >= 0 ? arg.slice(0, eqIndex) : arg;
|
|
251
|
+
const name = optionName(rawName);
|
|
252
|
+
const mapped = OPTION_KEYS[name];
|
|
253
|
+
if (!mapped) {
|
|
254
|
+
throw new Error(`unknown tfx cto event option: --${name}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let value = eqIndex >= 0 ? arg.slice(eqIndex + 1) : null;
|
|
258
|
+
if (value === null) {
|
|
259
|
+
const next = args[i + 1];
|
|
260
|
+
if (typeof next === "string" && !next.startsWith("--")) {
|
|
261
|
+
value = next;
|
|
262
|
+
i += 1;
|
|
263
|
+
} else {
|
|
264
|
+
value = "true";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
putPathValue(fields, mapped, value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
presetName: positional[0] || null,
|
|
272
|
+
extraPositionals: positional.slice(1),
|
|
273
|
+
fields,
|
|
274
|
+
json,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function mergeActor(defaultActor, overrideActor) {
|
|
279
|
+
const actor = { ...(defaultActor || {}), ...(overrideActor || {}) };
|
|
280
|
+
return Object.keys(actor).length > 0 ? actor : undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function defaultEventSummary(input) {
|
|
284
|
+
if (input.event === "checkpoint_saved") {
|
|
285
|
+
return `context-save checkpoint ${input.checkpoint_id}`;
|
|
286
|
+
}
|
|
287
|
+
if (input.event === "checkpoint_restored") {
|
|
288
|
+
return `context-restore checkpoint ${input.checkpoint_id}`;
|
|
289
|
+
}
|
|
290
|
+
if (input.event === "pr_created") {
|
|
291
|
+
const [pr] = normalizeNumericRefs(input.pr_refs);
|
|
292
|
+
return pr ? `PR #${pr} created` : "PR created";
|
|
293
|
+
}
|
|
294
|
+
if (input.event === "pr_merged_or_closed") {
|
|
295
|
+
const [pr] = normalizeNumericRefs(input.pr_refs);
|
|
296
|
+
return pr ? `PR #${pr} merged or closed` : "PR merged or closed";
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function validateRunEventInput(input) {
|
|
302
|
+
if (!input.event) {
|
|
303
|
+
throw new Error("tfx cto event requires a preset or --event <event_type>");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (
|
|
307
|
+
["checkpoint_saved", "checkpoint_restored"].includes(input.event) &&
|
|
308
|
+
!maybeString(input.checkpoint_id)
|
|
309
|
+
) {
|
|
310
|
+
throw new Error(`tfx cto event ${input.event} requires --checkpoint-id`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (
|
|
314
|
+
["pr_created", "pr_merged_or_closed"].includes(input.event) &&
|
|
315
|
+
normalizeNumericRefs(input.pr_refs).length === 0
|
|
316
|
+
) {
|
|
317
|
+
throw new Error(`tfx cto event ${input.event} requires --pr`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
110
321
|
function putString(target, key, value) {
|
|
111
322
|
const normalized = maybeString(value);
|
|
112
323
|
if (normalized) target[key] = normalized;
|
|
@@ -220,3 +431,59 @@ export async function appendCtoEvent(lakeRoot, input, opts = {}) {
|
|
|
220
431
|
} catch {}
|
|
221
432
|
}
|
|
222
433
|
}
|
|
434
|
+
|
|
435
|
+
export async function runEvent(args = [], opts = {}) {
|
|
436
|
+
const parsed = parseEventArgs(args);
|
|
437
|
+
if (parsed.extraPositionals.length > 0) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`unexpected tfx cto event argument: ${parsed.extraPositionals[0]}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const preset = parsed.presetName ? EVENT_PRESETS[parsed.presetName] : null;
|
|
443
|
+
if (parsed.presetName && !preset && !parsed.fields.event) {
|
|
444
|
+
throw new Error(`unknown tfx cto event preset: ${parsed.presetName}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const rootDir = opts.rootDir || resolveLakeRootDir(process.cwd());
|
|
448
|
+
const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
|
|
449
|
+
const stdout = opts.stdout || process.stdout;
|
|
450
|
+
const jsonOut = opts.json === true || parsed.json;
|
|
451
|
+
const ownerSurface =
|
|
452
|
+
parsed.fields.owner_surface ||
|
|
453
|
+
preset?.ownerSurface ||
|
|
454
|
+
"external wrapper -> tfx cto event --event";
|
|
455
|
+
|
|
456
|
+
const input = {
|
|
457
|
+
...(preset || {}),
|
|
458
|
+
...parsed.fields,
|
|
459
|
+
actor: mergeActor(preset?.actor, parsed.fields.actor),
|
|
460
|
+
};
|
|
461
|
+
delete input.ownerSurface;
|
|
462
|
+
delete input.owner_surface;
|
|
463
|
+
if (!input.project_root) input.project_root = rootDir;
|
|
464
|
+
if (!input.summary) input.summary = defaultEventSummary(input);
|
|
465
|
+
|
|
466
|
+
validateRunEventInput(input);
|
|
467
|
+
|
|
468
|
+
const result = await appendCtoEvent(lakeRoot, input, {
|
|
469
|
+
stderr: opts.stderr,
|
|
470
|
+
lockRetries: opts.ledgerLockRetries,
|
|
471
|
+
lockRetryDelayMs: opts.ledgerLockRetryDelayMs,
|
|
472
|
+
});
|
|
473
|
+
const payload = {
|
|
474
|
+
appended: result.appended,
|
|
475
|
+
owner_surface: ownerSurface,
|
|
476
|
+
event: result.event,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
if (jsonOut) {
|
|
480
|
+
stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
481
|
+
} else {
|
|
482
|
+
const status = result.appended ? "appended" : "skipped";
|
|
483
|
+
stdout.write(
|
|
484
|
+
`[tfx cto event] ${status} ${result.event.event} via ${ownerSurface}\n`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return payload;
|
|
489
|
+
}
|
package/cto/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const SUBCOMMANDS = ["collect", "status", "dashboard", "hygiene"];
|
|
1
|
+
const SUBCOMMANDS = ["collect", "status", "dashboard", "hygiene", "event"];
|
|
2
2
|
|
|
3
3
|
function printUsage(subcommand) {
|
|
4
4
|
if (subcommand) {
|
|
@@ -6,13 +6,14 @@ function printUsage(subcommand) {
|
|
|
6
6
|
}
|
|
7
7
|
console.log(`
|
|
8
8
|
Usage
|
|
9
|
-
tfx cto <collect|status|dashboard|hygiene> [options]
|
|
9
|
+
tfx cto <collect|status|dashboard|hygiene|event> [options]
|
|
10
10
|
|
|
11
11
|
Subcommands
|
|
12
12
|
collect Refresh .triflux/lake/current.json from repo-local authority sources
|
|
13
13
|
status Print the current authority summary
|
|
14
14
|
dashboard Render the CTO console dashboard, optionally with --watch
|
|
15
15
|
hygiene Project CTO hygiene counts and actionable dry-run rows
|
|
16
|
+
event Append normalized wrapper lifecycle events to the CTO ledger
|
|
16
17
|
`);
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -36,6 +37,10 @@ export async function cmdCto(cmdArgs, opts = {}) {
|
|
|
36
37
|
const { runHygiene } = await import("./hygiene.mjs");
|
|
37
38
|
return runHygiene(rest, opts);
|
|
38
39
|
}
|
|
40
|
+
case "event": {
|
|
41
|
+
const { runEvent } = await import("./events.mjs");
|
|
42
|
+
return runEvent(rest, opts);
|
|
43
|
+
}
|
|
39
44
|
case undefined:
|
|
40
45
|
case "":
|
|
41
46
|
printUsage();
|
|
@@ -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/hub/team/notify.mjs
CHANGED
|
@@ -54,12 +54,17 @@ function normalizeEvent(event, defaults = {}) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function defaultChannelConfig(name, env) {
|
|
57
|
+
function defaultChannelConfig(name, env, platform = process.platform) {
|
|
58
58
|
switch (name) {
|
|
59
59
|
case "bell":
|
|
60
|
-
return { enabled: true };
|
|
60
|
+
return { enabled: envFlag(env?.TRIFLUX_NOTIFY_BELL, true) };
|
|
61
61
|
case "toast":
|
|
62
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
// macOS `osascript display notification` can surface Script Editor
|
|
64
|
+
// authorization/UI prompts. Keep it opt-in there; Windows keeps the
|
|
65
|
+
// historical toast default, and other platforms skip as unsupported.
|
|
66
|
+
enabled: envFlag(env?.TRIFLUX_NOTIFY_TOAST, platform !== "darwin"),
|
|
67
|
+
};
|
|
63
68
|
case "webhook": {
|
|
64
69
|
const url = String(env?.TRIFLUX_NOTIFY_WEBHOOK || "");
|
|
65
70
|
return { enabled: Boolean(url), url };
|
|
@@ -69,8 +74,13 @@ function defaultChannelConfig(name, env) {
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
function
|
|
73
|
-
|
|
77
|
+
function envFlag(value, fallback) {
|
|
78
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
79
|
+
return !["0", "false", "no", "off"].includes(String(value).toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeChannelConfig(name, value, env, platform) {
|
|
83
|
+
const base = defaultChannelConfig(name, env, platform);
|
|
74
84
|
const patch =
|
|
75
85
|
typeof value === "boolean"
|
|
76
86
|
? { enabled: value }
|
|
@@ -103,16 +113,21 @@ function normalizeChannelConfig(name, value, env) {
|
|
|
103
113
|
return freezeRecord(next);
|
|
104
114
|
}
|
|
105
115
|
|
|
106
|
-
function normalizeChannels(channels, env) {
|
|
116
|
+
function normalizeChannels(channels, env, platform) {
|
|
107
117
|
const source = channels && typeof channels === "object" ? channels : {};
|
|
108
118
|
const normalized = {};
|
|
109
119
|
for (const name of NOTIFY_CHANNELS) {
|
|
110
|
-
normalized[name] = normalizeChannelConfig(
|
|
120
|
+
normalized[name] = normalizeChannelConfig(
|
|
121
|
+
name,
|
|
122
|
+
source[name],
|
|
123
|
+
env,
|
|
124
|
+
platform,
|
|
125
|
+
);
|
|
111
126
|
}
|
|
112
127
|
return Object.freeze(normalized);
|
|
113
128
|
}
|
|
114
129
|
|
|
115
|
-
function updateChannelConfig(channels, channel, config, env) {
|
|
130
|
+
function updateChannelConfig(channels, channel, config, env, platform) {
|
|
116
131
|
if (!NOTIFY_CHANNELS.includes(channel)) {
|
|
117
132
|
throw new TypeError(`Unknown notify channel: ${channel}`);
|
|
118
133
|
}
|
|
@@ -126,6 +141,7 @@ function updateChannelConfig(channels, channel, config, env) {
|
|
|
126
141
|
...(typeof config === "boolean" ? { enabled: config } : config || {}),
|
|
127
142
|
},
|
|
128
143
|
env,
|
|
144
|
+
platform,
|
|
129
145
|
),
|
|
130
146
|
});
|
|
131
147
|
}
|
|
@@ -322,7 +338,7 @@ function createNotifierInstance(channels, deps) {
|
|
|
322
338
|
|
|
323
339
|
function setChannel(channel, config) {
|
|
324
340
|
return createNotifierInstance(
|
|
325
|
-
updateChannelConfig(channels, channel, config, deps.env),
|
|
341
|
+
updateChannelConfig(channels, channel, config, deps.env, deps.platform),
|
|
326
342
|
deps,
|
|
327
343
|
);
|
|
328
344
|
}
|
|
@@ -349,17 +365,21 @@ function createNotifierInstance(channels, deps) {
|
|
|
349
365
|
*/
|
|
350
366
|
export function createNotifier(opts = {}) {
|
|
351
367
|
const env = opts.env || process.env;
|
|
368
|
+
const platform = opts.platform || process.platform;
|
|
352
369
|
const deps = Object.freeze({
|
|
353
370
|
env,
|
|
354
371
|
stdout: opts.stdout || process.stdout,
|
|
355
372
|
execFile: opts.deps?.execFile || execFile,
|
|
356
373
|
fetch: opts.deps?.fetch || globalThis.fetch?.bind(globalThis),
|
|
357
|
-
platform
|
|
374
|
+
platform,
|
|
358
375
|
hostname: opts.hostname || os.hostname(),
|
|
359
376
|
powerShellCandidates: Object.freeze(
|
|
360
377
|
opts.deps?.powerShellCandidates || ["pwsh", "powershell.exe"],
|
|
361
378
|
),
|
|
362
379
|
});
|
|
363
380
|
|
|
364
|
-
return createNotifierInstance(
|
|
381
|
+
return createNotifierInstance(
|
|
382
|
+
normalizeChannels(opts.channels, env, platform),
|
|
383
|
+
deps,
|
|
384
|
+
);
|
|
365
385
|
}
|
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
|
|
package/package.json
CHANGED
|
@@ -14,11 +14,15 @@
|
|
|
14
14
|
// 사용자가 npm test 도중 다른 터미널에서 codex 를 활성 사용 중이면 이
|
|
15
15
|
// 외부 mutation 이 false positive 로 잡힌다. 따라서 hooks.state-only
|
|
16
16
|
// churn 은 informational warning 으로 분류하고 exit 0 으로 통과시킨다.
|
|
17
|
+
// Codex plugin mode also auto-materializes OpenAI primary runtime plugin
|
|
18
|
+
// registry sections (for example pdf@openai-primary-runtime); those are
|
|
19
|
+
// likewise Codex-owned external churn, not triflux-owned MCP drift.
|
|
17
20
|
// triflux 자체 mutation (e.g. tfx-hub URL drift) 은 기존대로 exit 2.
|
|
18
21
|
//
|
|
19
22
|
// Exit codes:
|
|
20
23
|
// 0 = config 안정. wrap 한 명령의 exit code 그대로 반환 (보통 0).
|
|
21
|
-
// hooks.state
|
|
24
|
+
// hooks.state/OpenAI runtime plugin churn 도 여기 포함
|
|
25
|
+
// (informational warning 만 출력).
|
|
22
26
|
// 2 = triflux 가드가 잡아야 할 mutation 감지. wrap 한 명령의 exit code
|
|
23
27
|
// 와 무관하게 강제 fail.
|
|
24
28
|
// N = wrap 한 명령이 N 으로 끝남 (mutation 없음).
|
|
@@ -32,6 +36,10 @@ import { join } from "node:path";
|
|
|
32
36
|
const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
|
|
33
37
|
const EXPECTED_TFX_HUB_URL = "http://127.0.0.1:27888/mcp";
|
|
34
38
|
const HOOKS_STATE_PREFIX = "hooks.state.";
|
|
39
|
+
const OPENAI_PRIMARY_RUNTIME_MARKETPLACE =
|
|
40
|
+
"marketplaces.openai-primary-runtime";
|
|
41
|
+
const OPENAI_PRIMARY_RUNTIME_PLUGIN_RE =
|
|
42
|
+
/^plugins\."[^"]+@openai-primary-runtime"$/u;
|
|
35
43
|
|
|
36
44
|
function readTfxHubUrl(raw) {
|
|
37
45
|
const headerMatch =
|
|
@@ -79,11 +87,23 @@ export function splitTomlSections(raw) {
|
|
|
79
87
|
|
|
80
88
|
// Classify which sections drifted between before / after payloads.
|
|
81
89
|
// Returns:
|
|
82
|
-
// { hooksStateOnly: bool, changedSections: string[] }
|
|
90
|
+
// { hooksStateOnly: bool, externalChurnOnly: bool, changedSections: string[] }
|
|
83
91
|
//
|
|
84
92
|
// hooksStateOnly = true means every changed section header starts with
|
|
85
93
|
// "hooks.state." (the oh-my-codex managed area). Empty diff returns
|
|
86
94
|
// hooksStateOnly=false so callers don't accidentally whitelist "no change".
|
|
95
|
+
//
|
|
96
|
+
// externalChurnOnly additionally permits Codex-owned OpenAI primary runtime
|
|
97
|
+
// plugin registry sections. It remains false for empty diffs and for any
|
|
98
|
+
// triflux-owned or unknown section drift.
|
|
99
|
+
function isExternalChurnSection(header) {
|
|
100
|
+
return (
|
|
101
|
+
header.startsWith(HOOKS_STATE_PREFIX) ||
|
|
102
|
+
header === OPENAI_PRIMARY_RUNTIME_MARKETPLACE ||
|
|
103
|
+
OPENAI_PRIMARY_RUNTIME_PLUGIN_RE.test(header)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
export function classifySectionDiff(beforeRaw, afterRaw) {
|
|
88
108
|
const beforeSections = splitTomlSections(beforeRaw);
|
|
89
109
|
const afterSections = splitTomlSections(afterRaw);
|
|
@@ -109,7 +129,9 @@ export function classifySectionDiff(beforeRaw, afterRaw) {
|
|
|
109
129
|
const hooksStateOnly =
|
|
110
130
|
changedSections.length > 0 &&
|
|
111
131
|
changedSections.every((h) => h.startsWith(HOOKS_STATE_PREFIX));
|
|
112
|
-
|
|
132
|
+
const externalChurnOnly =
|
|
133
|
+
changedSections.length > 0 && changedSections.every(isExternalChurnSection);
|
|
134
|
+
return { hooksStateOnly, externalChurnOnly, changedSections };
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
function snapshotConfig() {
|
|
@@ -145,13 +167,12 @@ export function describeChange(before, after) {
|
|
|
145
167
|
if (before.sha !== after.sha) {
|
|
146
168
|
const beforeRaw = typeof before.raw === "string" ? before.raw : "";
|
|
147
169
|
const afterRaw = typeof after.raw === "string" ? after.raw : "";
|
|
148
|
-
const { hooksStateOnly, changedSections } =
|
|
149
|
-
beforeRaw,
|
|
150
|
-
afterRaw,
|
|
151
|
-
);
|
|
170
|
+
const { hooksStateOnly, externalChurnOnly, changedSections } =
|
|
171
|
+
classifySectionDiff(beforeRaw, afterRaw);
|
|
152
172
|
return {
|
|
153
173
|
kind: "sha-changed",
|
|
154
174
|
hooksStateOnly,
|
|
175
|
+
externalChurnOnly,
|
|
155
176
|
changedSections,
|
|
156
177
|
message: `sha256 differs (size: ${before.size} → ${after.size})`,
|
|
157
178
|
};
|
|
@@ -205,18 +226,24 @@ if (isMain) {
|
|
|
205
226
|
const portDrift = describePortDrift(after);
|
|
206
227
|
const hooksStateOnly =
|
|
207
228
|
change?.kind === "sha-changed" && change.hooksStateOnly === true;
|
|
229
|
+
const externalChurnOnly =
|
|
230
|
+
change?.kind === "sha-changed" && change.externalChurnOnly === true;
|
|
208
231
|
|
|
209
|
-
//
|
|
232
|
+
// External Codex-owned churn + port drift 없음 = informational warning + pass.
|
|
210
233
|
// port drift 가 같이 잡혔으면 그건 triflux-owned section mutation 이라
|
|
211
234
|
// 기존 fail path 를 탄다.
|
|
212
|
-
if (
|
|
235
|
+
if (externalChurnOnly && !portDrift) {
|
|
213
236
|
process.stderr.write(
|
|
214
237
|
[
|
|
215
238
|
"",
|
|
216
|
-
|
|
239
|
+
hooksStateOnly
|
|
240
|
+
? "[check-codex-config-stable] hooks.state-only churn (whitelist)"
|
|
241
|
+
: "[check-codex-config-stable] Codex external plugin churn (whitelist)",
|
|
217
242
|
`Path: ${CODEX_CONFIG}`,
|
|
218
243
|
`Sections: ${(change.changedSections || []).join(", ") || "(none)"}`,
|
|
219
|
-
|
|
244
|
+
hooksStateOnly
|
|
245
|
+
? "Reason: oh-my-codex 가 codex CLI hook trust state 를 자동 갱신."
|
|
246
|
+
: "Reason: Codex plugin runtime 이 OpenAI primary runtime registry 를 자동 갱신.",
|
|
220
247
|
" triflux 외부 mutation 이므로 #193 가드의 false positive 다.",
|
|
221
248
|
"Action: informational only — pass-through.",
|
|
222
249
|
"",
|
|
@@ -8,23 +8,49 @@ import {
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
|
+
// 모델명 SSOT. 값은 agy 1.0.x `agy models` 가 출력하는 display name 형식이다
|
|
12
|
+
// (`--model` 인자가 이 문자열을 받는다). 옛 `gemini-*-preview` ID 형식이 아님.
|
|
11
13
|
const DEFAULT_GEMINI_PROFILES = {
|
|
12
|
-
model: "
|
|
14
|
+
model: "Gemini 3.5 Flash (Medium)",
|
|
13
15
|
profiles: {
|
|
16
|
+
flash35_low: {
|
|
17
|
+
model: "Gemini 3.5 Flash (Low)",
|
|
18
|
+
hint: "3.5 Flash (Low) — 경량·저비용",
|
|
19
|
+
},
|
|
20
|
+
flash35: {
|
|
21
|
+
model: "Gemini 3.5 Flash (Medium)",
|
|
22
|
+
hint: "3.5 Flash (Medium) — 기본, 비용·속도 균형",
|
|
23
|
+
},
|
|
24
|
+
flash35_high: {
|
|
25
|
+
model: "Gemini 3.5 Flash (High)",
|
|
26
|
+
hint: "3.5 Flash (High) — 코드/추론 강화",
|
|
27
|
+
},
|
|
28
|
+
pro31_low: {
|
|
29
|
+
model: "Gemini 3.1 Pro (Low)",
|
|
30
|
+
hint: "3.1 Pro (Low) — 플래그십 경량",
|
|
31
|
+
},
|
|
14
32
|
pro31: {
|
|
15
|
-
model: "
|
|
16
|
-
hint: "3.1 Pro — 플래그십 (
|
|
33
|
+
model: "Gemini 3.1 Pro (High)",
|
|
34
|
+
hint: "3.1 Pro (High) — 플래그십 최고 (긴 컨텍스트/복잡 추론)",
|
|
17
35
|
},
|
|
18
36
|
flash3: {
|
|
19
|
-
model: "
|
|
20
|
-
hint: "3.0 Flash —
|
|
37
|
+
model: "Gemini 3 Flash",
|
|
38
|
+
hint: "3.0 Flash — 레거시 호환",
|
|
21
39
|
},
|
|
22
|
-
pro25: { model: "gemini-2.5-pro", hint: "2.5 Pro — 안정 (추론 강화)" },
|
|
23
|
-
flash25: { model: "gemini-2.5-flash", hint: "2.5 Flash — 경량 범용" },
|
|
24
|
-
lite25: { model: "gemini-2.5-flash-lite", hint: "2.5 Flash Lite — 최경량" },
|
|
25
40
|
},
|
|
26
41
|
};
|
|
27
42
|
|
|
43
|
+
// 1회 마이그레이션 테이블: 옛 ID 형식 모델값 → agy display name.
|
|
44
|
+
// null = deprecated(2.5 계열) → 해당 프로필 제거 대상.
|
|
45
|
+
const LEGACY_MODEL_MIGRATION = {
|
|
46
|
+
"gemini-3.1-pro-preview": "Gemini 3.1 Pro (High)",
|
|
47
|
+
"gemini-3-flash-preview": "Gemini 3 Flash",
|
|
48
|
+
"gemini-2.5-pro": null,
|
|
49
|
+
"gemini-2.5-flash": null,
|
|
50
|
+
"gemini-2.5-flash-lite": null,
|
|
51
|
+
};
|
|
52
|
+
const LEGACY_PROFILE_NAMES = ["pro25", "flash25", "lite25"];
|
|
53
|
+
|
|
28
54
|
const DEFAULT_PROFILE_COUNT = Object.keys(
|
|
29
55
|
DEFAULT_GEMINI_PROFILES.profiles,
|
|
30
56
|
).length;
|
|
@@ -80,6 +106,39 @@ function ensureGeminiProfiles({
|
|
|
80
106
|
)
|
|
81
107
|
cfg.profiles = {};
|
|
82
108
|
|
|
109
|
+
// ── 1회 마이그레이션: deprecated 2.5 프로필 prune + 옛 ID 형식 → display name ──
|
|
110
|
+
// merge-only 로직만으로는 기존 사용자 파일에 남은 stale 프로필/옛 모델 ID 가
|
|
111
|
+
// 정리되지 않으므로, 신규 default 를 채우기 전에 마이그레이션을 먼저 적용한다.
|
|
112
|
+
let migrated = false;
|
|
113
|
+
for (const legacy of LEGACY_PROFILE_NAMES) {
|
|
114
|
+
if (cfg.profiles[legacy]) {
|
|
115
|
+
delete cfg.profiles[legacy];
|
|
116
|
+
migrated = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const [pname, pval] of Object.entries(cfg.profiles)) {
|
|
120
|
+
const mid = typeof pval === "string" ? pval : pval?.model;
|
|
121
|
+
if (mid && Object.hasOwn(LEGACY_MODEL_MIGRATION, mid)) {
|
|
122
|
+
const repl = LEGACY_MODEL_MIGRATION[mid];
|
|
123
|
+
if (repl === null) {
|
|
124
|
+
delete cfg.profiles[pname];
|
|
125
|
+
} else if (typeof pval === "string") {
|
|
126
|
+
cfg.profiles[pname] = repl;
|
|
127
|
+
} else {
|
|
128
|
+
cfg.profiles[pname].model = repl;
|
|
129
|
+
}
|
|
130
|
+
migrated = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
typeof cfg.model === "string" &&
|
|
135
|
+
Object.hasOwn(LEGACY_MODEL_MIGRATION, cfg.model)
|
|
136
|
+
) {
|
|
137
|
+
// 옛 ID 형식 기본값은 구버전 자동 생성값이므로 새 기본(DEFAULT)으로 정규화한다.
|
|
138
|
+
cfg.model = DEFAULT_GEMINI_PROFILES.model;
|
|
139
|
+
migrated = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
83
142
|
let added = 0;
|
|
84
143
|
for (const [name, value] of Object.entries(
|
|
85
144
|
DEFAULT_GEMINI_PROFILES.profiles,
|
|
@@ -91,7 +150,12 @@ function ensureGeminiProfiles({
|
|
|
91
150
|
}
|
|
92
151
|
if (!cfg.model) cfg.model = DEFAULT_GEMINI_PROFILES.model;
|
|
93
152
|
|
|
94
|
-
if (added > 0) {
|
|
153
|
+
if (added > 0 || migrated) {
|
|
154
|
+
if (migrated) {
|
|
155
|
+
try {
|
|
156
|
+
copyFileSync(profilesPath, profilesPath + `.bak.${Date.now()}`);
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
95
159
|
writeFileSync(profilesPath, JSON.stringify(cfg, null, 2) + "\n", {
|
|
96
160
|
encoding: "utf8",
|
|
97
161
|
mode: 0o600,
|
package/scripts/test-lock.mjs
CHANGED
|
@@ -321,27 +321,7 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
321
321
|
const timeoutMs = parseTimeoutMs();
|
|
322
322
|
const lock = acquireLock(timeoutMs);
|
|
323
323
|
const testHubPidDir = mkdtempSync(join(tmpdir(), "tfx-test-hub-pid-"));
|
|
324
|
-
|
|
325
|
-
// forward args after -- to node --test
|
|
326
|
-
const args = preserveForceExitFailures(expandTestArgs(argv));
|
|
327
|
-
// stdio split (issue #192 F1): when prepare.mjs spawns this lock with
|
|
328
|
-
// ["ignore","pipe","pipe"], full inherit cascades the parent stdin=ignore
|
|
329
|
-
// to grand-child node --test, breaking ConPTY assumptions on Windows and
|
|
330
|
-
// surfacing as EXIT=1 (false-failed). Pipe stdin only — stdout/stderr stay
|
|
331
|
-
// inherited so the grand-child still streams to whoever attached to us.
|
|
332
|
-
const child = spawn(process.execPath, args, {
|
|
333
|
-
stdio: ["pipe", "inherit", "inherit"],
|
|
334
|
-
env: {
|
|
335
|
-
...process.env,
|
|
336
|
-
TEST_LOCK_PID: String(process.pid),
|
|
337
|
-
TFX_HUB_PID_DIR: process.env.TFX_HUB_PID_DIR || testHubPidDir,
|
|
338
|
-
TFX_HUB_STATE_DIR: process.env.TFX_HUB_STATE_DIR || testHubPidDir,
|
|
339
|
-
},
|
|
340
|
-
});
|
|
341
|
-
updateChildPid(lock, child.pid);
|
|
342
|
-
// Close stdin immediately so node --test never blocks waiting for input.
|
|
343
|
-
child.stdin?.end();
|
|
344
|
-
|
|
324
|
+
let child = null;
|
|
345
325
|
let finished = false;
|
|
346
326
|
let requestedExitCode = null;
|
|
347
327
|
let timeoutTimer = null;
|
|
@@ -390,14 +370,6 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
390
370
|
finish(requestedExitCode);
|
|
391
371
|
}
|
|
392
372
|
|
|
393
|
-
timeoutTimer = setTimeout(() => {
|
|
394
|
-
console.error(
|
|
395
|
-
`\x1b[31m✗ test-lock child timed out after ${timeoutMs}ms (child PID ${child.pid})\x1b[0m`,
|
|
396
|
-
);
|
|
397
|
-
requestShutdown("SIGTERM", 124);
|
|
398
|
-
}, timeoutMs);
|
|
399
|
-
timeoutTimer.unref?.();
|
|
400
|
-
|
|
401
373
|
process.once("exit", () => {
|
|
402
374
|
if (!finished) terminateChild(child, "SIGTERM");
|
|
403
375
|
releaseLock();
|
|
@@ -407,6 +379,27 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
407
379
|
process.once(signal, () => requestShutdown(signal));
|
|
408
380
|
}
|
|
409
381
|
|
|
382
|
+
// Install termination handlers before spawning/advertising child state. The
|
|
383
|
+
// child can become ready before this wrapper reaches later setup lines on
|
|
384
|
+
// fast Linux CI; without early handlers an external SIGTERM can terminate the
|
|
385
|
+
// wrapper by signal instead of the controlled 128+signal exit path.
|
|
386
|
+
// forward args after -- to node --test
|
|
387
|
+
const args = preserveForceExitFailures(expandTestArgs(argv));
|
|
388
|
+
// stdio split (issue #192 F1): when prepare.mjs spawns this lock with
|
|
389
|
+
// ["ignore","pipe","pipe"], full inherit cascades the parent stdin=ignore
|
|
390
|
+
// to grand-child node --test, breaking ConPTY assumptions on Windows and
|
|
391
|
+
// surfacing as EXIT=1 (false-failed). Pipe stdin only — stdout/stderr stay
|
|
392
|
+
// inherited so the grand-child still streams to whoever attached to us.
|
|
393
|
+
child = spawn(process.execPath, args, {
|
|
394
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
395
|
+
env: {
|
|
396
|
+
...process.env,
|
|
397
|
+
TEST_LOCK_PID: String(process.pid),
|
|
398
|
+
TFX_HUB_PID_DIR: process.env.TFX_HUB_PID_DIR || testHubPidDir,
|
|
399
|
+
TFX_HUB_STATE_DIR: process.env.TFX_HUB_STATE_DIR || testHubPidDir,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
410
403
|
child.on("error", (error) => {
|
|
411
404
|
console.error(`test-lock failed to spawn child: ${error.message}`);
|
|
412
405
|
finish(1);
|
|
@@ -419,6 +412,18 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
419
412
|
}
|
|
420
413
|
finish(code ?? signalToExitCode(signal));
|
|
421
414
|
});
|
|
415
|
+
|
|
416
|
+
updateChildPid(lock, child.pid);
|
|
417
|
+
// Close stdin immediately so node --test never blocks waiting for input.
|
|
418
|
+
child.stdin?.end();
|
|
419
|
+
|
|
420
|
+
timeoutTimer = setTimeout(() => {
|
|
421
|
+
console.error(
|
|
422
|
+
`\x1b[31m✗ test-lock child timed out after ${timeoutMs}ms (child PID ${child?.pid ?? "unknown"})\x1b[0m`,
|
|
423
|
+
);
|
|
424
|
+
requestShutdown("SIGTERM", 124);
|
|
425
|
+
}, timeoutMs);
|
|
426
|
+
timeoutTimer.unref?.();
|
|
422
427
|
}
|
|
423
428
|
|
|
424
429
|
if (process.argv[1] && resolve(process.argv[1]) === SCRIPT_PATH) {
|
package/scripts/tfx-route.sh
CHANGED
|
@@ -1121,11 +1121,12 @@ resolve_gemini_profile() {
|
|
|
1121
1121
|
const primaryRaw = process.argv[2] || '{}';
|
|
1122
1122
|
const settingsRaw = process.argv[3] || '{}';
|
|
1123
1123
|
const defaults = {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1124
|
+
flash35_low: 'Gemini 3.5 Flash (Low)',
|
|
1125
|
+
flash35: 'Gemini 3.5 Flash (Medium)',
|
|
1126
|
+
flash35_high: 'Gemini 3.5 Flash (High)',
|
|
1127
|
+
pro31_low: 'Gemini 3.1 Pro (Low)',
|
|
1128
|
+
pro31: 'Gemini 3.1 Pro (High)',
|
|
1129
|
+
flash3: 'Gemini 3 Flash'
|
|
1129
1130
|
};
|
|
1130
1131
|
|
|
1131
1132
|
if (typeof name === 'string' && name.startsWith('gemini-')) {
|
|
@@ -1193,9 +1194,9 @@ resolve_gemini_profile() {
|
|
|
1193
1194
|
}
|
|
1194
1195
|
}
|
|
1195
1196
|
|
|
1196
|
-
process.stdout.write(defaults[name] || defaults[process.env.TFX_GEMINI_DEFAULT_PROFILE] || defaults.
|
|
1197
|
+
process.stdout.write(defaults[name] || defaults[process.env.TFX_GEMINI_DEFAULT_PROFILE] || defaults.flash35);
|
|
1197
1198
|
" "$profile" "$_GEMINI_PROFILE_CACHE" "$settings_cache" 2>/dev/null)
|
|
1198
|
-
echo "${result:-
|
|
1199
|
+
echo "${result:-Gemini 3.5 Flash (Medium)}"
|
|
1199
1200
|
}
|
|
1200
1201
|
|
|
1201
1202
|
# ── 라우팅 테이블 ──
|
|
@@ -1289,13 +1290,28 @@ route_agent() {
|
|
|
1289
1290
|
CLI_EFFORT="gpt55_xhigh"; DEFAULT_TIMEOUT=3600; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
1290
1291
|
|
|
1291
1292
|
# ─── Antigravity CLI 레인 (Gemini CLI 후속) ───
|
|
1292
|
-
#
|
|
1293
|
+
# effort 차등: agent 별 GEMINI_PROFILE 을 설정하면 run_antigravity_exec() 가
|
|
1294
|
+
# resolve_gemini_profile 로 해석해 `--model "<display name>"`을 agy_args 에
|
|
1295
|
+
# 주입한다. 우선순위는 TFX_GEMINI_PROFILE(env) > GEMINI_PROFILE(agent) > flash35.
|
|
1296
|
+
# CLI_ARGS 는 read -a 로 word-split 되므로 공백 포함 모델명을 여기 넣지 않는다.
|
|
1293
1297
|
# #310: upstream callers are normalized through agent-map.json, but this
|
|
1294
1298
|
# direct route entrypoint intentionally keeps agy as a compatibility alias.
|
|
1295
|
-
designer
|
|
1299
|
+
designer)
|
|
1300
|
+
# 디자인 = 시각/UX 추론 → 3.5 Flash (High)
|
|
1301
|
+
CLI_ARGS="--print --dangerously-skip-permissions"
|
|
1302
|
+
GEMINI_PROFILE="flash35_high"
|
|
1303
|
+
CLI_EFFORT="agy_v1"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
1304
|
+
writer)
|
|
1305
|
+
# 문서 작성 = 균형 → 3.5 Flash (Medium)
|
|
1306
|
+
CLI_ARGS="--print --dangerously-skip-permissions"
|
|
1307
|
+
GEMINI_PROFILE="flash35"
|
|
1308
|
+
CLI_EFFORT="agy_v1"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
1309
|
+
gemini|antigravity|agy)
|
|
1310
|
+
# 직접 호출 alias — 기본 flash35. TFX_GEMINI_PROFILE 로 override 가능.
|
|
1296
1311
|
# agy --print + --dangerously-skip-permissions 조합은 positional prompt에서
|
|
1297
1312
|
# timeout이 재현되므로 wrapper 호출은 stdin pipe로 고정한다.
|
|
1298
1313
|
CLI_ARGS="--print --dangerously-skip-permissions"
|
|
1314
|
+
GEMINI_PROFILE="flash35"
|
|
1299
1315
|
CLI_EFFORT="agy_v1"; DEFAULT_TIMEOUT=900; RUN_MODE="bg"; OPUS_OVERSIGHT="false" ;;
|
|
1300
1316
|
|
|
1301
1317
|
# ─── 탐색 (Claude-native: Glob/Grep/Read 직접 접근) ───
|
|
@@ -1488,6 +1504,12 @@ apply_cli_mode() {
|
|
|
1488
1504
|
CLI_TYPE="antigravity"
|
|
1489
1505
|
CLI_CMD="agy"
|
|
1490
1506
|
CLI_ARGS="--print --dangerously-skip-permissions"
|
|
1507
|
+
# 주 라우팅(route_agent)과 동일하게 designer 만 effort 상향. 나머지는
|
|
1508
|
+
# run_antigravity_exec 폴백(flash35) 또는 TFX_GEMINI_PROFILE override.
|
|
1509
|
+
case "$AGENT_TYPE" in
|
|
1510
|
+
designer) GEMINI_PROFILE="flash35_high" ;;
|
|
1511
|
+
*) GEMINI_PROFILE="flash35" ;;
|
|
1512
|
+
esac
|
|
1491
1513
|
CLI_EFFORT="agy_v1"
|
|
1492
1514
|
DEFAULT_TIMEOUT=900
|
|
1493
1515
|
echo "[tfx-route] TFX_CLI_MODE=antigravity: $AGENT_TYPE → antigravity($CLI_EFFORT)로 리매핑" >&2
|
|
@@ -2180,6 +2202,18 @@ run_antigravity_exec() {
|
|
|
2180
2202
|
local -a agy_args=()
|
|
2181
2203
|
read -r -a agy_args <<< "$CLI_ARGS"
|
|
2182
2204
|
|
|
2205
|
+
# ── 프로필 기반 모델 주입 ──
|
|
2206
|
+
# display name 에 공백/괄호가 있으므로(예: "Gemini 3.5 Flash (Medium)") CLI_ARGS
|
|
2207
|
+
# 문자열이 아니라 agy_args 배열에 두 원소(--model, <display name>)로 append 해야
|
|
2208
|
+
# "${agy_args[@]}" expand 시 단일 인자로 보존된다. 모델 SSOT 는 프로필 설정이다.
|
|
2209
|
+
if [[ -z "${TFX_GEMINI_NO_MODEL:-}" ]]; then
|
|
2210
|
+
local _agy_model
|
|
2211
|
+
_agy_model="$(resolve_gemini_profile "${TFX_GEMINI_PROFILE:-${GEMINI_PROFILE:-flash35}}")"
|
|
2212
|
+
if [[ -n "$_agy_model" ]]; then
|
|
2213
|
+
agy_args+=("--model" "$_agy_model")
|
|
2214
|
+
fi
|
|
2215
|
+
fi
|
|
2216
|
+
|
|
2183
2217
|
if ! agy_supports_headless "$CLI_CMD"; then
|
|
2184
2218
|
echo "[tfx-route] Antigravity CLI headless flags unsupported or missing: $CLI_CMD" >"$STDERR_LOG"
|
|
2185
2219
|
return 127
|