@triflux/core 10.35.3 → 10.37.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.
@@ -45,7 +45,7 @@ export function toSessionPayload(payload) {
45
45
  typeof workspacePaths[0] === "string" && workspacePaths[0]
46
46
  ? workspacePaths[0]
47
47
  : process.cwd();
48
- return JSON.stringify({ session_id: sessionId, cwd });
48
+ return JSON.stringify({ session_id: sessionId, cwd, actor_cli: "agy" });
49
49
  }
50
50
 
51
51
  // agy has no distinct SessionStart / UserPromptSubmit events. PreInvocation
@@ -98,7 +98,7 @@ export async function runAgySessionHook(stdinData, opts = {}) {
98
98
  await hubEnsureRun(sessionPayload);
99
99
  } catch {}
100
100
  try {
101
- registerInteractiveSession(sessionPayload);
101
+ await Promise.resolve(registerInteractiveSession(sessionPayload));
102
102
  } catch {}
103
103
  try {
104
104
  await drainPendingSynapse(1000);
@@ -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(
@@ -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
  }
@@ -459,6 +481,7 @@ export async function readDaemonControlKey(
459
481
  }
460
482
 
461
483
  export async function buildDaemonControlAuth(configDir) {
484
+ if (!configDir) return {};
462
485
  const auth = await readDaemonControlKey(configDir);
463
486
  return auth ? { auth } : {};
464
487
  }
@@ -1223,7 +1246,7 @@ export function attachClaudeDaemonSession({
1223
1246
  cancelCompletionTimer();
1224
1247
  }
1225
1248
  });
1226
- socket.on("close", () => {
1249
+ const finishClosed = () => {
1227
1250
  if (!settled) {
1228
1251
  if (pendingCompletion) {
1229
1252
  finish(pendingCompletion);
@@ -1231,7 +1254,9 @@ export function attachClaudeDaemonSession({
1231
1254
  }
1232
1255
  finish({ timedOut: false, matchedCompletion: false, closed: true });
1233
1256
  }
1234
- });
1257
+ };
1258
+ socket.on("end", finishClosed);
1259
+ socket.on("close", finishClosed);
1235
1260
  });
1236
1261
  }
1237
1262
 
@@ -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`);
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.35.3",
3
+ "version": "10.37.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({
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // SSRF boundary: stealthFetch only accepts http: and https: URLs. It does not
3
+ // guard private IPs, metadata hosts such as 169.254.x.x, or localhost; callers
4
+ // must treat the URL as already inside their trust boundary.
5
+
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ export class CloakBrowserUnavailableError extends Error {}
9
+
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+ const DEFAULT_WAIT_UNTIL = "load";
12
+ const SUPPORTED_PLATFORMS = new Set([
13
+ "linux:x64",
14
+ "linux:arm64",
15
+ "darwin:x64",
16
+ "darwin:arm64",
17
+ "win32:x64",
18
+ ]);
19
+ const EXIT_CODES = {
20
+ not_installed: 3,
21
+ unsupported_platform: 4,
22
+ runtime_error: 5,
23
+ blocked_scheme: 6,
24
+ };
25
+
26
+ function isSupportedPlatform(platform, arch) {
27
+ return SUPPORTED_PLATFORMS.has(`${platform}:${arch}`);
28
+ }
29
+
30
+ function isModuleNotFound(error) {
31
+ return (
32
+ error?.code === "ERR_MODULE_NOT_FOUND" || error?.code === "MODULE_NOT_FOUND"
33
+ );
34
+ }
35
+
36
+ function htmlToText(html) {
37
+ return String(html || "")
38
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ")
39
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ")
40
+ .replace(/<[^>]+>/gu, " ")
41
+ .replace(/&nbsp;/giu, " ")
42
+ .replace(/&amp;/giu, "&")
43
+ .replace(/&lt;/giu, "<")
44
+ .replace(/&gt;/giu, ">")
45
+ .replace(/&quot;/giu, '"')
46
+ .replace(/&#39;/gu, "'")
47
+ .replace(/\s+/gu, " ")
48
+ .trim();
49
+ }
50
+
51
+ function responseStatus(response) {
52
+ if (!response) return null;
53
+ if (typeof response.status === "function") return response.status();
54
+ return response.status ?? null;
55
+ }
56
+
57
+ function responseUrl(response, fallbackUrl) {
58
+ if (!response) return fallbackUrl;
59
+ if (typeof response.url === "function") return response.url();
60
+ return response.url ?? fallbackUrl;
61
+ }
62
+
63
+ function parseAllowedFetchUrl(url) {
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(url);
67
+ } catch {
68
+ return { ok: false, scheme: null };
69
+ }
70
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
71
+ return { ok: false, scheme: parsed.protocol };
72
+ }
73
+ return { ok: true, href: parsed.href };
74
+ }
75
+
76
+ async function closeBrowser(browser) {
77
+ if (!browser || typeof browser.close !== "function") return;
78
+ try {
79
+ await browser.close();
80
+ } catch {
81
+ // best effort: fetch callers only need the primary result signal
82
+ }
83
+ }
84
+
85
+ export async function stealthFetch(url, opts = {}) {
86
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
87
+ const waitUntil = opts.waitUntil ?? DEFAULT_WAIT_UNTIL;
88
+ const loadCloakBrowser = opts._import ?? (() => import("cloakbrowser"));
89
+ const platform = opts._platform ?? process.platform;
90
+ const arch = opts._arch ?? process.arch;
91
+
92
+ if (!isSupportedPlatform(platform, arch)) {
93
+ return {
94
+ ok: false,
95
+ reason: "unsupported_platform",
96
+ platform,
97
+ arch,
98
+ };
99
+ }
100
+
101
+ const parsedUrl = parseAllowedFetchUrl(url);
102
+ if (!parsedUrl.ok) {
103
+ return {
104
+ ok: false,
105
+ reason: "blocked_scheme",
106
+ scheme: parsedUrl.scheme,
107
+ };
108
+ }
109
+
110
+ let cloakbrowser;
111
+ try {
112
+ cloakbrowser = await loadCloakBrowser();
113
+ } catch (error) {
114
+ if (isModuleNotFound(error)) return { ok: false, reason: "not_installed" };
115
+ return { ok: false, reason: "runtime_error", error: String(error) };
116
+ }
117
+
118
+ let browser;
119
+ try {
120
+ const launch = cloakbrowser?.launch;
121
+ if (typeof launch !== "function") {
122
+ throw new CloakBrowserUnavailableError("cloakbrowser launch unavailable");
123
+ }
124
+ browser = await launch();
125
+ const page = await browser.newPage();
126
+ const response = await page.goto(parsedUrl.href, {
127
+ waitUntil,
128
+ timeout: timeoutMs,
129
+ });
130
+ const html = await page.content();
131
+ return {
132
+ ok: true,
133
+ engine: "cloakbrowser",
134
+ url: parsedUrl.href,
135
+ finalUrl: responseUrl(response, parsedUrl.href),
136
+ status: responseStatus(response),
137
+ html,
138
+ text: htmlToText(html),
139
+ };
140
+ } catch (error) {
141
+ return { ok: false, reason: "runtime_error", error: String(error) };
142
+ } finally {
143
+ await closeBrowser(browser);
144
+ }
145
+ }
146
+
147
+ export function exitCodeFor(result) {
148
+ return EXIT_CODES[result?.reason] ?? 5;
149
+ }
150
+
151
+ export async function main(argv = process.argv) {
152
+ const url = argv[2];
153
+ if (!url) {
154
+ console.error("usage: stealth-fetch <url>");
155
+ process.exitCode = 2;
156
+ return;
157
+ }
158
+
159
+ const result = await stealthFetch(url);
160
+ if (result.ok) {
161
+ console.log(JSON.stringify(result));
162
+ process.exitCode = 0;
163
+ return;
164
+ }
165
+
166
+ console.error(`[stealth-fetch] 폴백: ${result.reason}`);
167
+ console.log(JSON.stringify(result));
168
+ process.exitCode = exitCodeFor(result);
169
+ }
170
+
171
+ if (
172
+ process.argv[1] &&
173
+ import.meta.url === pathToFileURL(process.argv[1]).href
174
+ ) {
175
+ await main();
176
+ }
@@ -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
  }