@triflux/core 10.36.0 → 10.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,12 +30,18 @@ function parsePayload(stdinData) {
30
30
  }
31
31
  }
32
32
 
33
- // Antigravity hook payloads use camelCase system metadata (conversationId,
34
- // workspacePaths) rather than Codex's session_id/cwd. registerInteractiveSession
35
- // and heartbeatInteractiveSession parse the Codex shape, so we adapt the agy
36
- // payload into that shape before delegating. conversationId is the stable
37
- // per-conversation UUID (== session identity); the first mounted workspace path
38
- // is the effective cwd.
33
+ /**
34
+ * Convert an Antigravity hook payload into the Codex-shaped session payload
35
+ * consumed by the shared fast session registration helpers.
36
+ *
37
+ * Antigravity hook payloads use camelCase system metadata (`conversationId`,
38
+ * `workspacePaths`) rather than Codex's `session_id`/`cwd`. The
39
+ * `conversationId` is the stable per-conversation UUID; the first mounted
40
+ * workspace path is the effective cwd.
41
+ *
42
+ * @param {Record<string, unknown> | null | undefined} payload
43
+ * @returns {string} JSON string with `{ session_id, cwd, actor_cli }`.
44
+ */
39
45
  export function toSessionPayload(payload) {
40
46
  const sessionId = String(payload?.conversationId || "").trim();
41
47
  const workspacePaths = Array.isArray(payload?.workspacePaths)
@@ -45,13 +51,21 @@ export function toSessionPayload(payload) {
45
51
  typeof workspacePaths[0] === "string" && workspacePaths[0]
46
52
  ? workspacePaths[0]
47
53
  : process.cwd();
48
- return JSON.stringify({ session_id: sessionId, cwd });
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
- 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
  }
@@ -99,7 +99,7 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
99
99
  await hubEnsureRun(stdinData);
100
100
  } catch {}
101
101
  try {
102
- registerInteractiveSession(stdinData);
102
+ await Promise.resolve(registerInteractiveSession(stdinData));
103
103
  } catch {}
104
104
  try {
105
105
  await drainPendingSynapse(1000);
package/hooks/hooks.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
10
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
11
11
  "timeout": 15
12
12
  }
13
13
  ]
@@ -19,7 +19,7 @@
19
19
  "hooks": [
20
20
  {
21
21
  "type": "command",
22
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
22
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
23
23
  "timeout": 5
24
24
  }
25
25
  ]
@@ -31,7 +31,7 @@
31
31
  "hooks": [
32
32
  {
33
33
  "type": "command",
34
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
34
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
35
35
  "timeout": 10
36
36
  }
37
37
  ]
@@ -43,7 +43,7 @@
43
43
  "hooks": [
44
44
  {
45
45
  "type": "command",
46
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
46
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
47
47
  "timeout": 3
48
48
  }
49
49
  ]
@@ -55,7 +55,7 @@
55
55
  "hooks": [
56
56
  {
57
57
  "type": "command",
58
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
58
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
59
59
  "timeout": 10
60
60
  }
61
61
  ]
@@ -67,7 +67,7 @@
67
67
  "hooks": [
68
68
  {
69
69
  "type": "command",
70
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
70
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
71
71
  "timeout": 5
72
72
  }
73
73
  ]
@@ -79,7 +79,7 @@
79
79
  "hooks": [
80
80
  {
81
81
  "type": "command",
82
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
82
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
83
83
  "timeout": 5
84
84
  }
85
85
  ]
@@ -91,7 +91,7 @@
91
91
  "hooks": [
92
92
  {
93
93
  "type": "command",
94
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
94
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
95
95
  "timeout": 35
96
96
  }
97
97
  ]
@@ -103,7 +103,7 @@
103
103
  "hooks": [
104
104
  {
105
105
  "type": "command",
106
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
106
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
107
107
  "timeout": 5
108
108
  }
109
109
  ]
@@ -115,7 +115,7 @@
115
115
  "hooks": [
116
116
  {
117
117
  "type": "command",
118
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
118
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
119
119
  "timeout": 5
120
120
  }
121
121
  ]
@@ -127,7 +127,7 @@
127
127
  "hooks": [
128
128
  {
129
129
  "type": "command",
130
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
130
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
131
131
  "timeout": 5
132
132
  }
133
133
  ]
@@ -139,7 +139,7 @@
139
139
  "hooks": [
140
140
  {
141
141
  "type": "command",
142
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
142
+ "command": "node -e \"const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');const roots=[];try{roots.push(fs.readFileSync(path.join(os.homedir(),'.claude','scripts','.tfx-pkg-root'),'utf8').trim())}catch{}roots.push(process.env.PLUGIN_ROOT,process.env.CLAUDE_PLUGIN_ROOT);try{roots.push(cp.execFileSync('git',['rev-parse','--show-toplevel'],{encoding:'utf8',stdio:['ignore','pipe','ignore']}).trim())}catch{}roots.push(process.cwd());const root=roots.find(r=>r&&fs.existsSync(path.join(r,'hooks','hook-orchestrator.mjs')));if(!root){console.error('[triflux hook] cannot locate hooks/hook-orchestrator.mjs');process.exit(1)}const child=cp.spawnSync(process.execPath,[path.join(root,'hooks','hook-orchestrator.mjs')],{stdio:'inherit',env:process.env,windowsHide:true});process.exit(child.status??(child.signal?1:0));\"",
143
143
  "timeout": 5
144
144
  }
145
145
  ]
@@ -43,6 +43,9 @@ export function resolvePluginRoot(callerUrl) {
43
43
  const breadcrumbRoot = readBreadcrumbRoot();
44
44
  if (isValidPluginRoot(breadcrumbRoot)) return breadcrumbRoot;
45
45
 
46
+ const pluginRoot = normalizeCandidate(process.env.PLUGIN_ROOT);
47
+ if (isValidPluginRoot(pluginRoot)) return pluginRoot;
48
+
46
49
  const envRoot = normalizeCandidate(process.env.CLAUDE_PLUGIN_ROOT);
47
50
  if (isValidPluginRoot(envRoot)) return envRoot;
48
51
 
@@ -40,6 +40,98 @@ function parseStartPayload(stdinData) {
40
40
  }
41
41
  }
42
42
 
43
+ function inferParticipantCli(payload) {
44
+ const direct = String(
45
+ payload?.actor_cli || payload?.cli || payload?.actor?.cli || "",
46
+ )
47
+ .trim()
48
+ .toLowerCase();
49
+ if (direct) return direct;
50
+
51
+ const eventName = String(payload?.hook_event_name || "").trim();
52
+ if (eventName === "SessionStart") return "claude";
53
+ if (eventName.toLowerCase().replace(/-/g, "_") === "session_start") {
54
+ return "codex";
55
+ }
56
+ return "participant";
57
+ }
58
+
59
+ async function defaultAppendParticipantCtoEvent(lakeRoot, event) {
60
+ const { appendCtoEvent } = await import("../cto/events.mjs");
61
+ return appendCtoEvent(lakeRoot, event, {
62
+ lockRetries: 1,
63
+ lockRetryDelayMs: 0,
64
+ });
65
+ }
66
+
67
+ async function defaultResolveParticipantLakeRoot(cwd) {
68
+ const { resolveLakeRootDir } = await import("../cto/lake-root.mjs");
69
+ const projectRoot = resolveLakeRootDir(cwd, {
70
+ execFileSync: (cmd, args, options = {}) =>
71
+ execFileSync(cmd, args, {
72
+ ...options,
73
+ timeout: 500,
74
+ killSignal: "SIGKILL",
75
+ }),
76
+ });
77
+ return {
78
+ projectRoot,
79
+ lakeRoot: projectRoot ? join(projectRoot, ".triflux", "lake") : "",
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Append a compact CTO `session_started` event for participant hooks.
85
+ * This is intentionally observational only: no cleanup, reconciliation, or
86
+ * summarization policy is performed here. Callers may fire-and-forget it.
87
+ *
88
+ * @param {string} stdinData SessionStart-shaped stdin JSON
89
+ * @param {object} [seams] test seams
90
+ * @returns {Promise<object|null>}
91
+ */
92
+ export async function emitParticipantSessionStarted(stdinData, seams = {}) {
93
+ try {
94
+ const payload = parseStartPayload(stdinData);
95
+ const sessionId = String(payload?.session_id || "").trim();
96
+ if (!sessionId) return null;
97
+ const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
98
+ const resolveLakeRoot =
99
+ seams.resolveLakeRoot || defaultResolveParticipantLakeRoot;
100
+ const resolved = await resolveLakeRoot(cwd);
101
+ const projectRoot =
102
+ typeof resolved === "string"
103
+ ? resolved
104
+ : String(resolved?.projectRoot || "");
105
+ const lakeRoot =
106
+ typeof resolved === "string"
107
+ ? join(resolved, ".triflux", "lake")
108
+ : String(resolved?.lakeRoot || "");
109
+ if (!lakeRoot) return null;
110
+
111
+ const event = {
112
+ event: "session_started",
113
+ source: "tfx_participant_hook",
114
+ session_id: sessionId,
115
+ project_root: projectRoot || cwd,
116
+ worktree_path: cwd,
117
+ branch: typeof payload?.branch === "string" ? payload.branch : "",
118
+ status: "active",
119
+ actor: {
120
+ cli: inferParticipantCli(payload),
121
+ session_id: sessionId,
122
+ host: typeof payload?.host === "string" ? payload.host : "local",
123
+ },
124
+ summary: `${inferParticipantCli(payload)} session_started ${sessionId}`,
125
+ now: seams.now,
126
+ };
127
+
128
+ const append = seams.ctoAppend || defaultAppendParticipantCtoEvent;
129
+ return await append(lakeRoot, event);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
43
135
  function readAncestorCommands(pid = process.ppid, maxDepth = 6) {
44
136
  if (process.platform === "win32") return [];
45
137
  const commands = [];
@@ -146,17 +238,18 @@ function defaultGitRunner(cwd, args, cb) {
146
238
  * @param {Function} [seams.register] registerSynapseSession 대체
147
239
  * @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
148
240
  * @param {Function} [seams.gitRunner] git runner 대체
149
- * @returns {void} (enrich 는 백그라운드에서 완료)
241
+ * @returns {Promise<object|null>|null} CTO append promise; enrich 는 백그라운드에서 완료
150
242
  */
151
243
  export function registerInteractiveSession(stdinData, seams = {}) {
244
+ let ctoEvent = null;
152
245
  const register = seams.register || registerSynapseSession;
153
246
  const heartbeat = seams.heartbeat || heartbeatSynapseSession;
154
247
  const gitRunner = seams.gitRunner || defaultGitRunner;
155
248
  try {
156
249
  const payload = parseStartPayload(stdinData);
157
250
  const sessionId = String(payload?.session_id || "").trim();
158
- if (!sessionId) return;
159
- if (shouldSkipInteractiveRegistration(payload, seams)) return;
251
+ if (!sessionId) return null;
252
+ if (shouldSkipInteractiveRegistration(payload, seams)) return null;
160
253
  const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
161
254
 
162
255
  // 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).
@@ -170,6 +263,10 @@ export function registerInteractiveSession(stdinData, seams = {}) {
170
263
  isRemote: false,
171
264
  });
172
265
 
266
+ ctoEvent = emitParticipantSessionStarted(stdinData, seams).catch(
267
+ () => null,
268
+ );
269
+
173
270
  // 2) worktree/branch 는 블로킹 경로 밖에서 비동기 enrich → heartbeat partial.
174
271
  gitContextAsync(cwd, gitRunner)
175
272
  .then(({ worktreePath, branch }) => {
@@ -180,6 +277,7 @@ export function registerInteractiveSession(stdinData, seams = {}) {
180
277
  } catch {
181
278
  /* best-effort — never affects session start */
182
279
  }
280
+ return ctoEvent;
183
281
  }
184
282
 
185
283
  /**
@@ -371,9 +469,10 @@ function runBackground(stdinData) {
371
469
  .catch(() => {}); // 완전 무시
372
470
 
373
471
  // Synapse: 인터랙티브 세션 self-register (fire-and-forget, hub 미응답 무시)
374
- registerInteractiveSession(stdinData);
472
+ const ctoEvent = registerInteractiveSession(stdinData);
375
473
 
376
474
  // session-vault은 external source — hook-orchestrator가 execFile로 실행
475
+ return { ctoEvent };
377
476
  }
378
477
 
379
478
  /**
@@ -392,7 +491,7 @@ export async function execute(stdinData, externalHooks = []) {
392
491
 
393
492
  // DEFERRED + BACKGROUND: fire-and-forget
394
493
  runDeferred(stdinData);
395
- runBackground(stdinData);
494
+ const background = runBackground(stdinData);
396
495
 
397
496
  // hook-orchestrator process.exit(0)s immediately after we return, which would
398
497
  // drop the just-fired Synapse register POST before it flushes to the loopback
@@ -401,7 +500,10 @@ export async function execute(stdinData, externalHooks = []) {
401
500
  // and the ceiling caps a hub stall so SessionStart never hangs. The async
402
501
  // git-enrich heartbeat stays best-effort (it may not have fired yet) — losing
403
502
  // it only drops worktree/branch enrichment, not the core registration.
404
- await drainPendingSynapse(1000);
503
+ await Promise.allSettled([
504
+ drainPendingSynapse(1000),
505
+ background?.ctoEvent || Promise.resolve(null),
506
+ ]);
405
507
 
406
508
  const totalDur = performance.now() - totalStart;
407
509
  log.info(
@@ -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
 
@@ -164,6 +164,7 @@ export class QLearningRouter {
164
164
  * @param {number} [opts.epsilonMin=0.05] — 최소 엡실론
165
165
  * @param {number} [opts.minConfidence=0.6] — 최소 신뢰도 (이하면 폴백)
166
166
  * @param {string} [opts.modelPath] — Q-table 영속화 경로
167
+ * @param {() => number} [opts.random=Math.random] — 테스트/재현용 RNG seam
167
168
  */
168
169
  constructor(opts = {}) {
169
170
  this._lr = opts.learningRate ?? 0.1;
@@ -176,6 +177,8 @@ export class QLearningRouter {
176
177
  this._minConfidence = opts.minConfidence ?? 0.6;
177
178
  this._modelPath =
178
179
  opts.modelPath ?? join(homedir(), ".omc", "routing-model.json");
180
+ this._random =
181
+ typeof opts.random === "function" ? opts.random : Math.random;
179
182
 
180
183
  /** @type {Map<string, Map<string, number>>} state -> (action -> Q-value) */
181
184
  this._qTable = new Map();
@@ -223,12 +226,12 @@ export class QLearningRouter {
223
226
  const visits = this._visitCounts.get(state) || 0;
224
227
 
225
228
  // 엡실론-그리디: 탐색 vs 활용
226
- const isExploration = Math.random() < this._epsilon;
229
+ const isExploration = this._random() < this._epsilon;
227
230
 
228
231
  let action;
229
232
  if (isExploration) {
230
233
  // 무작위 탐색
231
- action = ACTIONS[Math.floor(Math.random() * ACTIONS.length)];
234
+ action = ACTIONS[Math.floor(this._random() * ACTIONS.length)];
232
235
  } else {
233
236
  // 최적 액션 선택 (최대 Q-value)
234
237
  let maxQ = -Infinity;
@@ -243,6 +243,7 @@ function candidateResultSummary(result) {
243
243
  ...summary,
244
244
  ok: result.ok === true,
245
245
  error: result.error ?? null,
246
+ errorCode: result.errorCode ?? null,
246
247
  sessionCount: Array.isArray(result.sessions) ? result.sessions.length : 0,
247
248
  };
248
249
  }
@@ -270,6 +271,14 @@ function buildProbeResponse({
270
271
  reason = "target-not-found";
271
272
  error = "Claude daemon target not found in reachable candidates";
272
273
  } else if (results.length > 0) {
274
+ const errorCodes = results
275
+ .map((result) => result.errorCode)
276
+ .filter(Boolean);
277
+ if (errorCodes.includes("stale-control-socket")) {
278
+ reason = "stale-control-socket";
279
+ } else if (errorCodes.includes("daemon-dir-missing")) {
280
+ reason = "daemon-dir-missing";
281
+ }
273
282
  error =
274
283
  results
275
284
  .map((result) => result.error)
@@ -341,6 +350,18 @@ export async function probeClaudeDaemonCandidates({
341
350
  error: ok ? null : list?.error || "Claude daemon list failed",
342
351
  });
343
352
  } catch (error) {
353
+ let errorCode = null;
354
+ if (error?.code === "ENOENT") {
355
+ try {
356
+ await fs.stat(candidate.daemonDir);
357
+ errorCode = "stale-control-socket";
358
+ } catch (statError) {
359
+ errorCode =
360
+ statError?.code === "ENOENT"
361
+ ? "daemon-dir-missing"
362
+ : "stale-control-socket";
363
+ }
364
+ }
344
365
  results.push({
345
366
  ok: false,
346
367
  candidate,
@@ -348,6 +369,7 @@ export async function probeClaudeDaemonCandidates({
348
369
  sessions: [],
349
370
  target: null,
350
371
  error: errorMessage(error),
372
+ errorCode,
351
373
  });
352
374
  }
353
375
  }
@@ -446,20 +468,29 @@ export function sendClaudeControlRequest(
446
468
  // 인증 미강제 daemon 으로 보고 auth 필드를 생략한다 (fail-open — 구버전 호환).
447
469
  export async function readDaemonControlKey(
448
470
  configDir = resolveClaudeConfigDir(),
471
+ { diagnostics } = {},
449
472
  ) {
473
+ if (!configDir) return undefined;
474
+ const keyPath = path.join(configDir, "daemon", "control.key");
450
475
  try {
451
- const key = await fs.readFile(
452
- path.join(configDir, "daemon", "control.key"),
453
- "utf8",
454
- );
476
+ const key = await fs.readFile(keyPath, "utf8");
455
477
  return key.trim() || undefined;
456
- } 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
+ }
457
487
  return undefined;
458
488
  }
459
489
  }
460
490
 
461
- export async function buildDaemonControlAuth(configDir) {
462
- const auth = await readDaemonControlKey(configDir);
491
+ export async function buildDaemonControlAuth(configDir, opts = {}) {
492
+ if (!configDir) return {};
493
+ const auth = await readDaemonControlKey(configDir, opts);
463
494
  return auth ? { auth } : {};
464
495
  }
465
496
 
@@ -1223,7 +1254,7 @@ export function attachClaudeDaemonSession({
1223
1254
  cancelCompletionTimer();
1224
1255
  }
1225
1256
  });
1226
- socket.on("close", () => {
1257
+ const finishClosed = () => {
1227
1258
  if (!settled) {
1228
1259
  if (pendingCompletion) {
1229
1260
  finish(pendingCompletion);
@@ -1231,7 +1262,9 @@ export function attachClaudeDaemonSession({
1231
1262
  }
1232
1263
  finish({ timedOut: false, matchedCompletion: false, closed: true });
1233
1264
  }
1234
- });
1265
+ };
1266
+ socket.on("end", finishClosed);
1267
+ socket.on("close", finishClosed);
1235
1268
  });
1236
1269
  }
1237
1270
 
@@ -177,8 +177,10 @@ async function main() {
177
177
  // 세션/누적 토큰 → context 대비 절약 배수 (개별 provider sv%)
178
178
  const ctxCapacity = deriveContextLimit(stdin);
179
179
  let codexSv = null;
180
+ let codexAccumulatorSv = null;
180
181
  if (svAccumulator?.codex?.tokens > 0) {
181
- codexSv = svAccumulator.codex.tokens / ctxCapacity;
182
+ codexAccumulatorSv = svAccumulator.codex.tokens / ctxCapacity;
183
+ codexSv = codexAccumulatorSv;
182
184
  } else if (codexBuckets) {
183
185
  const main =
184
186
  codexBuckets.codex || codexBuckets[Object.keys(codexBuckets)[0]];
@@ -186,8 +188,10 @@ async function main() {
186
188
  codexSv = main.tokens.total_tokens / ctxCapacity;
187
189
  }
188
190
  let geminiSv = null;
191
+ let geminiAccumulatorSv = null;
189
192
  if (svAccumulator?.gemini?.tokens > 0) {
190
- geminiSv = svAccumulator.gemini.tokens / ctxCapacity;
193
+ geminiAccumulatorSv = svAccumulator.gemini.tokens / ctxCapacity;
194
+ geminiSv = geminiAccumulatorSv;
191
195
  } else {
192
196
  const geminiTokens = geminiSession?.total || null;
193
197
  geminiSv = geminiTokens ? geminiTokens / ctxCapacity : null;
@@ -216,8 +220,15 @@ async function main() {
216
220
  const antigravitySlot1Bucket =
217
221
  antigravityModelFamily === "gemini" ? antigravityFamilyBucket : null;
218
222
 
219
- // 합산 절약: Codex+Gemini sv% 합산 (컨텍스트 대비 위임 토큰 비율)
220
- const combinedSvPct = Math.round(((codexSv ?? 0) + (geminiSv ?? 0)) * 100);
223
+ // 합산 절약은 svAccumulator 기반일 때만 표시한다.
224
+ // Codex bucket fallback은 account-window 누적 토큰이고 Gemini fallback은 latest
225
+ // session 토큰이라 서로 의미가 달라 합산하면 misleading cross-provider total이 된다.
226
+ const hasComparableSvAccumulator = Boolean(
227
+ codexAccumulatorSv != null || geminiAccumulatorSv != null,
228
+ );
229
+ const combinedSvPct = hasComparableSvAccumulator
230
+ ? Math.round(((codexAccumulatorSv ?? 0) + (geminiAccumulatorSv ?? 0)) * 100)
231
+ : null;
221
232
 
222
233
  // 인디케이터 인식 tier 선택 (stdin + Claude 사용량 기반)
223
234
  const CURRENT_TIER = selectTier(stdin, claudeUsageSnapshot.data);
@@ -230,7 +241,7 @@ async function main() {
230
241
  codexBuckets,
231
242
  antigravityReady ? null : geminiSession,
232
243
  antigravityReady ? null : geminiBucket,
233
- antigravityReady ? Math.round((codexSv ?? 0) * 100) : combinedSvPct,
244
+ combinedSvPct,
234
245
  antigravityReady ? "a" : "g",
235
246
  );
236
247
  process.stdout.write(`\x1b[0m${microLine}\n`);
@@ -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/hud/renderers.mjs CHANGED
@@ -326,7 +326,7 @@ export function getMicroLine(
326
326
  }
327
327
 
328
328
  // sv
329
- const sv = formatSvPct(combinedSvPct || 0).trim();
329
+ const sv = formatSvPct(combinedSvPct).trim();
330
330
 
331
331
  const cols = getTerminalColumns() || 120;
332
332
  const line =
@@ -350,7 +350,7 @@ export function getClaudeRows(
350
350
  const ctxView = contextView || buildContextUsageView({}, null);
351
351
  const prefix = `${bold(claudeOrange("c"))}:`;
352
352
  // 절약 퍼센트
353
- const svStr = formatSvPct(combinedSvPct || 0);
353
+ const svStr = formatSvPct(combinedSvPct);
354
354
  const svSuffix = `${dim("sv:")}${svStr}`;
355
355
 
356
356
  // API 실측 데이터
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.36.0",
3
+ "version": "10.38.0",
4
4
  "description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -10,6 +10,8 @@ export const id = "agy";
10
10
  export const cliType = "antigravity";
11
11
  export const command = "agy";
12
12
 
13
+ // #310: agent-map.json normalizes upstream callers to antigravity,
14
+ // but this adapter also accepts the direct `agy` route for compatibility.
13
15
  const AGENT_PROFILES = {
14
16
  antigravity: {
15
17
  profile: "agy_v1",
@@ -0,0 +1,142 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ const PROFILE_SECTION_PREFIX = "profiles.";
5
+ const SAFE_PROFILE_FILE_RE = /^[A-Za-z0-9_.-]+$/;
6
+
7
+ function lineChunks(source = "") {
8
+ return String(source).match(/[^\n]*\n|[^\n]+$/g) || [];
9
+ }
10
+
11
+ function parseSectionHeader(line) {
12
+ const match = String(line)
13
+ .trimEnd()
14
+ .match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
15
+ return match ? match[1].trim() : null;
16
+ }
17
+
18
+ function profileNameFromSection(section) {
19
+ if (!section?.startsWith(PROFILE_SECTION_PREFIX)) return null;
20
+ return section.slice(PROFILE_SECTION_PREFIX.length).trim();
21
+ }
22
+
23
+ function isTopLevelProfileSelector(line) {
24
+ return /^\s*profile\s*=/.test(line);
25
+ }
26
+
27
+ function normalizeProfileBody(lines) {
28
+ const body = lines.join("").replace(/^\s+/, "").replace(/\s+$/, "");
29
+ return body ? `${body}\n` : "";
30
+ }
31
+
32
+ export function listLegacyCodexProfileSections(source = "") {
33
+ const names = [];
34
+ for (const line of lineChunks(source)) {
35
+ const name = profileNameFromSection(parseSectionHeader(line));
36
+ if (name && !names.includes(name)) names.push(name);
37
+ }
38
+ return names;
39
+ }
40
+
41
+ export function sanitizeCodexProfileConfig(
42
+ source = "",
43
+ { codexHome = null, migrateProfileFiles = true } = {},
44
+ ) {
45
+ const output = [];
46
+ const removedProfiles = [];
47
+ const migratedProfiles = [];
48
+ const skippedProfileFiles = [];
49
+ const captured = new Map();
50
+ let currentProfile = null;
51
+ let seenSection = false;
52
+ let removedTopLevelProfileSelector = false;
53
+
54
+ for (const line of lineChunks(source)) {
55
+ const section = parseSectionHeader(line);
56
+ if (section) {
57
+ seenSection = true;
58
+ const profileName = profileNameFromSection(section);
59
+ if (profileName) {
60
+ currentProfile = profileName;
61
+ if (!removedProfiles.includes(profileName))
62
+ removedProfiles.push(profileName);
63
+ if (!captured.has(profileName)) captured.set(profileName, []);
64
+ continue;
65
+ }
66
+ currentProfile = null;
67
+ output.push(line);
68
+ continue;
69
+ }
70
+
71
+ if (!seenSection && isTopLevelProfileSelector(line)) {
72
+ removedTopLevelProfileSelector = true;
73
+ continue;
74
+ }
75
+
76
+ if (currentProfile) {
77
+ captured.get(currentProfile)?.push(line);
78
+ continue;
79
+ }
80
+
81
+ output.push(line);
82
+ }
83
+
84
+ if (codexHome && migrateProfileFiles) {
85
+ mkdirSync(codexHome, { recursive: true });
86
+ for (const [profileName, bodyLines] of captured.entries()) {
87
+ if (!SAFE_PROFILE_FILE_RE.test(profileName)) {
88
+ skippedProfileFiles.push(profileName);
89
+ continue;
90
+ }
91
+ const body = normalizeProfileBody(bodyLines);
92
+ if (!body) {
93
+ skippedProfileFiles.push(profileName);
94
+ continue;
95
+ }
96
+ const profilePath = join(codexHome, `${profileName}.config.toml`);
97
+ if (existsSync(profilePath)) {
98
+ skippedProfileFiles.push(profileName);
99
+ continue;
100
+ }
101
+ writeFileSync(profilePath, body, "utf8");
102
+ migratedProfiles.push(profileName);
103
+ }
104
+ }
105
+
106
+ const toml = output.join("");
107
+ return {
108
+ changed: toml !== String(source),
109
+ toml,
110
+ removedProfiles,
111
+ migratedProfiles,
112
+ skippedProfileFiles,
113
+ removedTopLevelProfileSelector,
114
+ };
115
+ }
116
+
117
+ function stamp(now = () => new Date()) {
118
+ return now()
119
+ .toISOString()
120
+ .replace(/[-:]/g, "")
121
+ .replace(/\..*$/, "")
122
+ .replace("T", "-");
123
+ }
124
+
125
+ export function sanitizeCodexProfileConfigFile(
126
+ configPath,
127
+ {
128
+ codexHome = dirname(configPath),
129
+ backupPrefix = "bak-codex-profile-sanitize",
130
+ now = () => new Date(),
131
+ } = {},
132
+ ) {
133
+ if (!configPath || !existsSync(configPath)) {
134
+ return { changed: false, removedProfiles: [], migratedProfiles: [] };
135
+ }
136
+ const source = readFileSync(configPath, "utf8");
137
+ const sanitized = sanitizeCodexProfileConfig(source, { codexHome });
138
+ if (!sanitized.changed) return sanitized;
139
+ writeFileSync(`${configPath}.${backupPrefix}-${stamp(now)}`, source, "utf8");
140
+ writeFileSync(configPath, sanitized.toml, "utf8");
141
+ return sanitized;
142
+ }
@@ -182,6 +182,15 @@ function isAntigravityConfig(filePath) {
182
182
  return normalized.endsWith("/.gemini/config/mcp_config.json");
183
183
  }
184
184
 
185
+ function isMigratedAntigravityConfig(filePath, raw = null) {
186
+ if (!isAntigravityConfig(filePath)) return false;
187
+ const resolvedPath = resolveFilePath(filePath);
188
+ const markerPath = join(dirname(resolvedPath), ".migrated");
189
+ if (!existsSync(markerPath)) return false;
190
+ const contents = raw === null ? readFileSync(resolvedPath, "utf8") : raw;
191
+ return String(contents || "").trim() === "";
192
+ }
193
+
185
194
  function plaintextSecretHeaderClient(filePath) {
186
195
  const client = detectClient(filePath);
187
196
  return client === "gemini" || client === "antigravity" ? client : null;
@@ -970,7 +979,23 @@ function scanJsonConfig(filePath) {
970
979
  }
971
980
 
972
981
  try {
973
- const parsed = readJsonFile(filePath);
982
+ const raw = readFileSync(filePath, "utf8");
983
+ if (isMigratedAntigravityConfig(filePath, raw)) {
984
+ return {
985
+ filePath,
986
+ client: detectClient(filePath),
987
+ label: detectLabel(filePath),
988
+ exists: true,
989
+ parseError: null,
990
+ servers: [],
991
+ stdioServers: [],
992
+ migrated: true,
993
+ message:
994
+ "Antigravity migrated MCP config; deprecated mcp_config.json left empty",
995
+ };
996
+ }
997
+
998
+ const parsed = JSON.parse(raw);
974
999
  const mcpServers = parsed?.mcpServers;
975
1000
  const servers =
976
1001
  !mcpServers || typeof mcpServers !== "object"
@@ -1705,6 +1730,8 @@ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
1705
1730
  status = "missing-file";
1706
1731
  } else if (config.parseError) {
1707
1732
  status = "invalid-config";
1733
+ } else if (config.migrated) {
1734
+ status = "skipped";
1708
1735
  } else if (!actual) {
1709
1736
  status = "missing";
1710
1737
  } else if (expectedCommand) {
@@ -1733,6 +1760,8 @@ export function inspectRegistryStatus(registry = loadRegistryOrDefault()) {
1733
1760
  actualUrl: actual?.url || "",
1734
1761
  actualCommand: actual?.command || "",
1735
1762
  status,
1763
+ migrated: Boolean(config.migrated),
1764
+ message: config.message || "",
1736
1765
  headerStatus: currentHeaderStatus,
1737
1766
  expectedHeaderNames: headerNames(expectedHeaders),
1738
1767
  actualHeaderNames: headerNames(actualHeaders),
@@ -2015,6 +2044,19 @@ export function removeServerFromTargets(name, options = {}) {
2015
2044
  if (targetsFilter && !targetsFilter.has(target.client)) continue;
2016
2045
 
2017
2046
  const snapshot = scanConfig(target.filePath);
2047
+ if (snapshot.migrated) {
2048
+ actions.push({
2049
+ type: "remove",
2050
+ name: trimmedName,
2051
+ filePath: target.filePath,
2052
+ label: target.label,
2053
+ status: "skipped",
2054
+ migrated: true,
2055
+ message: snapshot.message,
2056
+ });
2057
+ continue;
2058
+ }
2059
+
2018
2060
  if (snapshot.parseError) {
2019
2061
  actions.push({
2020
2062
  type: "remove",
@@ -2058,10 +2100,24 @@ export function syncRegistryTargets(options = {}) {
2058
2100
  );
2059
2101
  const actions = [];
2060
2102
  const invalidConfigs = new Set();
2103
+ const skippedConfigs = new Set();
2061
2104
  const denylist = syncDenylistEntries(registry.policies);
2062
2105
 
2063
2106
  for (const target of syncTargets) {
2064
2107
  const snapshot = scanConfig(target.filePath);
2108
+ if (snapshot.migrated) {
2109
+ skippedConfigs.add(normalizeForMatch(target.filePath));
2110
+ actions.push({
2111
+ type: "sync",
2112
+ filePath: target.filePath,
2113
+ label: target.label,
2114
+ status: "skipped",
2115
+ migrated: true,
2116
+ message: snapshot.message,
2117
+ });
2118
+ continue;
2119
+ }
2120
+
2065
2121
  if (snapshot.parseError) {
2066
2122
  invalidConfigs.add(normalizeForMatch(target.filePath));
2067
2123
  actions.push({
@@ -2097,6 +2153,10 @@ export function syncRegistryTargets(options = {}) {
2097
2153
  for (const target of primaryTargets) {
2098
2154
  const snapshot = scanConfig(target.filePath);
2099
2155
  const targetKey = normalizeForMatch(target.filePath);
2156
+ if (snapshot.migrated || skippedConfigs.has(targetKey)) {
2157
+ continue;
2158
+ }
2159
+
2100
2160
  if (snapshot.parseError) {
2101
2161
  if (!invalidConfigs.has(targetKey)) {
2102
2162
  actions.push({
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
+ import { dirname } from "node:path";
4
+ import { sanitizeCodexProfileConfig } from "./codex-profile-config.mjs";
3
5
 
4
6
  const require = createRequire(import.meta.url);
5
7
  let TOML = null;
@@ -100,14 +102,31 @@ export function patchMcpApprovalMode(source) {
100
102
  export function patchCodexConfigFile(path, { now = () => new Date() } = {}) {
101
103
  if (!path || !existsSync(path)) return { changed: false, count: 0 };
102
104
  const source = readFileSync(path, "utf8");
103
- const patched = patchMcpApprovalMode(source);
104
- if (!patched.changed) return patched;
105
+ const approvalPatched = patchMcpApprovalMode(source);
106
+ const profileSanitized = sanitizeCodexProfileConfig(approvalPatched.toml, {
107
+ codexHome: dirname(path),
108
+ });
109
+ const changed = approvalPatched.changed || profileSanitized.changed;
110
+ if (!changed) {
111
+ return {
112
+ changed: false,
113
+ count: 0,
114
+ removedProfiles: [],
115
+ migratedProfiles: [],
116
+ };
117
+ }
105
118
  const stamp = now()
106
119
  .toISOString()
107
120
  .replace(/[-:]/g, "")
108
121
  .replace(/\..*$/, "")
109
122
  .replace("T", "-");
110
123
  writeFileSync(`${path}.bak-${stamp}`, source);
111
- writeFileSync(path, patched.toml);
112
- return patched;
124
+ writeFileSync(path, profileSanitized.toml);
125
+ return {
126
+ changed: true,
127
+ count: approvalPatched.count,
128
+ removedProfiles: profileSanitized.removedProfiles,
129
+ migratedProfiles: profileSanitized.migratedProfiles,
130
+ toml: profileSanitized.toml,
131
+ };
113
132
  }