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 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
- // 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
 
@@ -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 { enabled: true };
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 normalizeChannelConfig(name, value, env) {
73
- const base = defaultChannelConfig(name, env);
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(name, source[name], env);
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: opts.platform || process.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(normalizeChannels(opts.channels, env), deps);
381
+ return createNotifierInstance(
382
+ normalizeChannels(opts.channels, env, platform),
383
+ deps,
384
+ );
365
385
  }
@@ -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",
3
- "version": "10.37.0",
3
+ "version": "10.39.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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-only churn 도 여기 포함 (informational warning 만 출력).
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
- return { hooksStateOnly, changedSections };
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 } = classifySectionDiff(
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
- // hooksStateOnly + port drift 없음 = informational warning + pass.
232
+ // External Codex-owned churn + port drift 없음 = informational warning + pass.
210
233
  // port drift 가 같이 잡혔으면 그건 triflux-owned section mutation 이라
211
234
  // 기존 fail path 를 탄다.
212
- if (hooksStateOnly && !portDrift) {
235
+ if (externalChurnOnly && !portDrift) {
213
236
  process.stderr.write(
214
237
  [
215
238
  "",
216
- "[check-codex-config-stable] hooks.state-only churn (whitelist)",
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
- "Reason: oh-my-codex 가 codex CLI hook trust state 를 자동 갱신.",
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: "gemini-3.1-pro-preview",
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: "gemini-3.1-pro-preview",
16
- hint: "3.1 Pro — 플래그십 (1M ctx, 멀티모달)",
33
+ model: "Gemini 3.1 Pro (High)",
34
+ hint: "3.1 Pro (High) — 플래그십 최고 ( 컨텍스트/복잡 추론)",
17
35
  },
18
36
  flash3: {
19
- model: "gemini-3-flash-preview",
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,
@@ -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) {
@@ -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
- pro31: 'gemini-3.1-pro-preview',
1125
- flash3: 'gemini-3-flash-preview',
1126
- pro25: 'gemini-2.5-pro',
1127
- flash25: 'gemini-2.5-flash',
1128
- lite25: 'gemini-2.5-flash-lite'
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.pro25);
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:-gemini-2.5-pro}"
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
- # 모델 선택 옵션 부재 (top-level), Antigravity settings.json 으로 endemic.
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|writer|gemini|antigravity|agy)
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