triflux 10.28.0 → 10.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tfx-live.mjs +45 -0
- package/hooks/pipeline-stop.mjs +13 -1
- package/hub/team/uds-orchestrator.mjs +259 -0
- package/hub/workers/codex-app-server-worker.mjs +4 -1
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +686 -0
- package/hud/context-monitor.mjs +19 -2
- package/package.json +1 -1
- package/scripts/release/prepare.mjs +7 -4
- package/skills/tfx-live/SKILL.md +19 -0
- package/skills/tfx-research/SKILL.md +5 -7
- package/skills/tfx-research/skill.json +1 -1
package/bin/tfx-live.mjs
CHANGED
|
@@ -42,6 +42,8 @@ function usage() {
|
|
|
42
42
|
" tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
|
|
43
43
|
" tfx-live goal-driven --session NAME --goal TEXT [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500] [--max-rounds 8] [--done-token DONE]",
|
|
44
44
|
" tfx-live peer [--cli-a codex] [--cli-b claude] [--session-a peerA] [--session-b peerB] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--bridge ABS] [--remote HOST] [--cwd DIR] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
|
|
45
|
+
" tfx-live orchestrate --task TEXT [--mode peer|codex-led|claude-led] [--codex-transport exec|app-server-uds] [--cwd DIR] [--timeout 120]",
|
|
46
|
+
" Runs the Claude(UDS)+Codex orchestration engine. --codex-transport app-server-uds drives a real `codex app-server` over WebSocket-over-UDS (experimental); default exec keeps the codex stdio one-shot path.",
|
|
45
47
|
].join("\n");
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -1690,6 +1692,47 @@ async function peer(flags) {
|
|
|
1690
1692
|
printJson(output);
|
|
1691
1693
|
}
|
|
1692
1694
|
|
|
1695
|
+
const ORCHESTRATION_MODES = ["peer", "codex-led", "claude-led"];
|
|
1696
|
+
const CODEX_ORCH_TRANSPORTS = ["exec", "app-server-uds"];
|
|
1697
|
+
|
|
1698
|
+
async function orchestrate(flags) {
|
|
1699
|
+
const mode = flags.mode ?? "peer";
|
|
1700
|
+
if (!ORCHESTRATION_MODES.includes(mode)) {
|
|
1701
|
+
throw new Error(`--mode must be one of: ${ORCHESTRATION_MODES.join(", ")}`);
|
|
1702
|
+
}
|
|
1703
|
+
const task = requireFlag(flags, "task");
|
|
1704
|
+
const codexTransport = flags["codex-transport"] ?? "exec";
|
|
1705
|
+
if (!CODEX_ORCH_TRANSPORTS.includes(codexTransport)) {
|
|
1706
|
+
throw new Error(
|
|
1707
|
+
`--codex-transport must be one of: ${CODEX_ORCH_TRANSPORTS.join(", ")}`,
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
const cwd = flags.cwd ?? process.cwd();
|
|
1711
|
+
const timeoutMs = secondsFlag(flags, "timeout", 120_000);
|
|
1712
|
+
|
|
1713
|
+
// Lazy import keeps the orchestration engine (and its hub/team deps) off the
|
|
1714
|
+
// hot path for every other thin-CLI verb; only `orchestrate` pays the cost.
|
|
1715
|
+
const orchestratorUrl = new URL(
|
|
1716
|
+
"../hub/team/uds-orchestrator.mjs",
|
|
1717
|
+
import.meta.url,
|
|
1718
|
+
).href;
|
|
1719
|
+
const {
|
|
1720
|
+
runUdsOrchestration,
|
|
1721
|
+
createClaudeUdsEndpoint,
|
|
1722
|
+
createCodexExecEndpoint,
|
|
1723
|
+
createCodexAppServerUdsEndpoint,
|
|
1724
|
+
} = await import(orchestratorUrl);
|
|
1725
|
+
|
|
1726
|
+
const claude = createClaudeUdsEndpoint({ cwd, timeoutMs });
|
|
1727
|
+
const codex =
|
|
1728
|
+
codexTransport === "app-server-uds"
|
|
1729
|
+
? createCodexAppServerUdsEndpoint({ cwd, timeoutMs })
|
|
1730
|
+
: createCodexExecEndpoint({ workdir: cwd, timeout: timeoutMs });
|
|
1731
|
+
|
|
1732
|
+
const result = await runUdsOrchestration({ mode, task, claude, codex });
|
|
1733
|
+
printJson({ codexTransport, ...result });
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1693
1736
|
function printJson(value) {
|
|
1694
1737
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
1695
1738
|
}
|
|
@@ -1718,6 +1761,8 @@ async function main() {
|
|
|
1718
1761
|
await goalDriven(flags);
|
|
1719
1762
|
} else if (command === "peer") {
|
|
1720
1763
|
await peer(flags);
|
|
1764
|
+
} else if (command === "orchestrate") {
|
|
1765
|
+
await orchestrate(flags);
|
|
1721
1766
|
} else {
|
|
1722
1767
|
throw new Error(`Unknown subcommand: ${command}\n${usage()}`);
|
|
1723
1768
|
}
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -142,7 +142,7 @@ function contextPercentsFromObject(value) {
|
|
|
142
142
|
];
|
|
143
143
|
|
|
144
144
|
const currentUsage = value.current_usage ?? value.currentUsage ?? {};
|
|
145
|
-
const
|
|
145
|
+
const explicitMaxTokens =
|
|
146
146
|
value.context_window_size ??
|
|
147
147
|
value.contextWindowSize ??
|
|
148
148
|
value.max_context_tokens ??
|
|
@@ -151,6 +151,18 @@ function contextPercentsFromObject(value) {
|
|
|
151
151
|
value.maxTokens ??
|
|
152
152
|
value.total_tokens ??
|
|
153
153
|
value.totalTokens;
|
|
154
|
+
// Fall back to a 1M context window when the payload omits one — Opus 4.x
|
|
155
|
+
// [1M] and Codex gpt-5.5 both run on a 1M window, and assuming 200K there
|
|
156
|
+
// false-positives at ~15% real usage. Override via
|
|
157
|
+
// TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
|
|
158
|
+
const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
|
|
159
|
+
const fallbackMaxTokens =
|
|
160
|
+
Number.isFinite(envFallback) && envFallback > 0 ? envFallback : 1_000_000;
|
|
161
|
+
const explicitNumeric = Number(explicitMaxTokens);
|
|
162
|
+
const maxTokens =
|
|
163
|
+
Number.isFinite(explicitNumeric) && explicitNumeric > 0
|
|
164
|
+
? explicitNumeric
|
|
165
|
+
: fallbackMaxTokens;
|
|
154
166
|
candidates.push(
|
|
155
167
|
tokenPercent(value.used_tokens ?? value.usedTokens, maxTokens),
|
|
156
168
|
tokenPercent(
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
2
4
|
import net from "node:net";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join as pathJoin } from "node:path";
|
|
3
7
|
|
|
4
8
|
import { execute as defaultExecuteCodex } from "../codex-adapter.mjs";
|
|
9
|
+
import { JsonRpcWsUdsClient } from "../workers/lib/jsonrpc-ws-uds.mjs";
|
|
5
10
|
import {
|
|
6
11
|
buildClaudePromptDispatchPayload,
|
|
7
12
|
deriveClaudeDaemonPaths,
|
|
@@ -10,6 +15,8 @@ import {
|
|
|
10
15
|
} from "./claude-daemon-control.mjs";
|
|
11
16
|
|
|
12
17
|
const DEFAULT_TIMEOUT_MS = 90_000;
|
|
18
|
+
const DEFAULT_CODEX_APP_SERVER_UDS_TIMEOUT_MS = 120_000;
|
|
19
|
+
const DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS = 12_000;
|
|
13
20
|
|
|
14
21
|
function requireEndpoint(endpoint, name) {
|
|
15
22
|
if (!endpoint || typeof endpoint.ask !== "function") {
|
|
@@ -346,3 +353,255 @@ export function createCodexExecEndpoint({
|
|
|
346
353
|
},
|
|
347
354
|
};
|
|
348
355
|
}
|
|
356
|
+
|
|
357
|
+
function extractCodexThreadId(result) {
|
|
358
|
+
if (!result || typeof result !== "object") return null;
|
|
359
|
+
if (typeof result.threadId === "string") return result.threadId;
|
|
360
|
+
if (result.thread && typeof result.thread.id === "string") {
|
|
361
|
+
return result.thread.id;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function waitForSocketFile(sockPath, deadlineMs) {
|
|
367
|
+
const start = Date.now();
|
|
368
|
+
while (Date.now() - start < deadlineMs) {
|
|
369
|
+
try {
|
|
370
|
+
if ((await stat(sockPath)).isSocket()) return true;
|
|
371
|
+
} catch {
|
|
372
|
+
/* not bound yet */
|
|
373
|
+
}
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* SIGTERM a child, wait up to graceMs for it to actually exit, then SIGKILL if
|
|
381
|
+
* still alive. Liveness is `exitCode === null && signalCode === null` — NOT
|
|
382
|
+
* `child.killed`, which only means "a signal was sent" and would suppress the
|
|
383
|
+
* SIGKILL fallback when the child ignores SIGTERM.
|
|
384
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
385
|
+
* @param {number} [graceMs]
|
|
386
|
+
*/
|
|
387
|
+
async function terminateChild(child, graceMs = 1000) {
|
|
388
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
389
|
+
const exited = new Promise((resolve) => {
|
|
390
|
+
child.once("exit", () => resolve("exited"));
|
|
391
|
+
});
|
|
392
|
+
try {
|
|
393
|
+
child.kill("SIGTERM");
|
|
394
|
+
} catch {}
|
|
395
|
+
const outcome = await Promise.race([
|
|
396
|
+
exited,
|
|
397
|
+
new Promise((resolve) => setTimeout(() => resolve("timeout"), graceMs)),
|
|
398
|
+
]);
|
|
399
|
+
if (
|
|
400
|
+
outcome !== "exited" &&
|
|
401
|
+
child.exitCode === null &&
|
|
402
|
+
child.signalCode === null
|
|
403
|
+
) {
|
|
404
|
+
try {
|
|
405
|
+
child.kill("SIGKILL");
|
|
406
|
+
} catch {}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Experimental Codex endpoint that drives a real `codex app-server` over its
|
|
412
|
+
* WebSocket-over-Unix-Domain-Socket control plane (the symmetric peer of the
|
|
413
|
+
* Claude daemon `control.sock` path). Same `ask(prompt) -> {endpoint,text,done}`
|
|
414
|
+
* contract as `createCodexExecEndpoint`, so it is a drop-in for
|
|
415
|
+
* `runUdsOrchestration`'s `codex` slot — but it is NOT the default.
|
|
416
|
+
*
|
|
417
|
+
* Two modes:
|
|
418
|
+
* - spawnServer (default): mkdtemp a private socket, spawn
|
|
419
|
+
* `codex app-server --listen unix://PATH`, attach, run one turn, tear down.
|
|
420
|
+
* - attach (socketPath + spawnServer=false): connect to an already-running
|
|
421
|
+
* daemon socket (e.g. $CODEX_HOME/app-server-control/app-server-control.sock).
|
|
422
|
+
*
|
|
423
|
+
* Wire transport proven against codex-cli 0.135.0 — see
|
|
424
|
+
* experiments/native-bridge-feasibility/codex-app-server-uds-smoke.mjs.
|
|
425
|
+
*
|
|
426
|
+
* @param {object} [options]
|
|
427
|
+
* @param {string|null} [options.socketPath] Existing daemon socket to attach to.
|
|
428
|
+
* @param {boolean} [options.spawnServer] Spawn a private app-server (default true).
|
|
429
|
+
* @param {string} [options.codexBin]
|
|
430
|
+
* @param {string} [options.cwd]
|
|
431
|
+
* @param {string} [options.model]
|
|
432
|
+
* @param {number} [options.timeoutMs] Turn timeout.
|
|
433
|
+
* @param {number} [options.bootstrapTimeoutMs] Connect + handshake timeout.
|
|
434
|
+
* @param {(opts: object) => JsonRpcWsUdsClient} [options.clientFactory]
|
|
435
|
+
* @param {(command: string, args: string[], options: object) => import('node:child_process').ChildProcess} [options.spawnFn]
|
|
436
|
+
*/
|
|
437
|
+
export function createCodexAppServerUdsEndpoint({
|
|
438
|
+
socketPath = null,
|
|
439
|
+
spawnServer = true,
|
|
440
|
+
codexBin = process.env.CODEX_BIN || "codex",
|
|
441
|
+
cwd = process.cwd(),
|
|
442
|
+
model,
|
|
443
|
+
timeoutMs = DEFAULT_CODEX_APP_SERVER_UDS_TIMEOUT_MS,
|
|
444
|
+
bootstrapTimeoutMs = DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
|
|
445
|
+
clientFactory = (opts) => new JsonRpcWsUdsClient(opts),
|
|
446
|
+
spawnFn = spawn,
|
|
447
|
+
} = {}) {
|
|
448
|
+
return {
|
|
449
|
+
name: "codex-app-server-uds",
|
|
450
|
+
async ask(prompt, _meta = {}) {
|
|
451
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
"codex-app-server-uds endpoint requires a non-empty prompt",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let dir = null;
|
|
458
|
+
let child = null;
|
|
459
|
+
let client = null;
|
|
460
|
+
let resolvedSock = socketPath;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
if (spawnServer && !socketPath) {
|
|
464
|
+
dir = await mkdtemp(pathJoin(tmpdir(), "tfx-codex-appserver-uds-"));
|
|
465
|
+
resolvedSock = pathJoin(dir, "app-server.sock");
|
|
466
|
+
child = spawnFn(
|
|
467
|
+
codexBin,
|
|
468
|
+
["app-server", "--listen", `unix://${resolvedSock}`],
|
|
469
|
+
{ stdio: ["ignore", "pipe", "pipe"], cwd, env: process.env },
|
|
470
|
+
);
|
|
471
|
+
let stderr = "";
|
|
472
|
+
child.stderr?.on("data", (chunk) => {
|
|
473
|
+
stderr += String(chunk);
|
|
474
|
+
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
475
|
+
});
|
|
476
|
+
const earlyExit = new Promise((resolve) => {
|
|
477
|
+
child.once("error", (err) =>
|
|
478
|
+
resolve(`spawn error: ${err.message}`),
|
|
479
|
+
);
|
|
480
|
+
child.once("exit", (code, sig) =>
|
|
481
|
+
resolve(`exited early code=${code} sig=${sig || ""}`),
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
const ready = await Promise.race([
|
|
485
|
+
waitForSocketFile(resolvedSock, bootstrapTimeoutMs).then((ok) =>
|
|
486
|
+
ok ? "ready" : "no-socket",
|
|
487
|
+
),
|
|
488
|
+
earlyExit,
|
|
489
|
+
]);
|
|
490
|
+
if (ready !== "ready") {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`codex app-server did not bind socket: ${ready}${
|
|
493
|
+
stderr ? ` — ${stderr.slice(-400)}` : ""
|
|
494
|
+
}`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (!resolvedSock) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
"codex-app-server-uds endpoint needs socketPath or spawnServer=true",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
client = clientFactory({
|
|
505
|
+
socketPath: resolvedSock,
|
|
506
|
+
connectTimeoutMs: bootstrapTimeoutMs,
|
|
507
|
+
});
|
|
508
|
+
await client.connect();
|
|
509
|
+
await client.request(
|
|
510
|
+
"initialize",
|
|
511
|
+
{ clientInfo: { name: "triflux-tfx-live", version: "1.0.0" } },
|
|
512
|
+
bootstrapTimeoutMs,
|
|
513
|
+
);
|
|
514
|
+
client.notify("initialized", {});
|
|
515
|
+
|
|
516
|
+
const threadParams = {
|
|
517
|
+
sandbox: "read-only",
|
|
518
|
+
approvalPolicy: "never",
|
|
519
|
+
ephemeral: true,
|
|
520
|
+
};
|
|
521
|
+
if (typeof model === "string" && model) threadParams.model = model;
|
|
522
|
+
if (cwd) threadParams.cwd = cwd;
|
|
523
|
+
const threadStart = await client.request(
|
|
524
|
+
"thread/start",
|
|
525
|
+
threadParams,
|
|
526
|
+
bootstrapTimeoutMs,
|
|
527
|
+
);
|
|
528
|
+
const threadId = extractCodexThreadId(threadStart);
|
|
529
|
+
if (!threadId) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
"codex app-server thread/start returned no thread id",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const parts = [];
|
|
536
|
+
let offDelta = () => {};
|
|
537
|
+
let offError = () => {};
|
|
538
|
+
let offDone = () => {};
|
|
539
|
+
const cleanup = () => {
|
|
540
|
+
offDelta();
|
|
541
|
+
offError();
|
|
542
|
+
offDone();
|
|
543
|
+
};
|
|
544
|
+
const completion = new Promise((resolve) => {
|
|
545
|
+
offDelta = client.onNotification(
|
|
546
|
+
"item/agentMessage/delta",
|
|
547
|
+
(params) => {
|
|
548
|
+
if (typeof params?.delta === "string") parts.push(params.delta);
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
offError = client.onNotification("error", (params) => {
|
|
552
|
+
cleanup();
|
|
553
|
+
resolve({
|
|
554
|
+
status: "failed",
|
|
555
|
+
message: params?.message || "codex app-server error",
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
offDone = client.onNotification("turn/completed", (params) => {
|
|
559
|
+
cleanup();
|
|
560
|
+
resolve({ status: params?.turn?.status || "unknown" });
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await client.request(
|
|
565
|
+
"turn/start",
|
|
566
|
+
{ threadId, input: [{ type: "text", text: prompt }] },
|
|
567
|
+
timeoutMs,
|
|
568
|
+
);
|
|
569
|
+
let timeoutHandle = null;
|
|
570
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
571
|
+
timeoutHandle = setTimeout(() => {
|
|
572
|
+
cleanup();
|
|
573
|
+
resolve({ status: "timeout" });
|
|
574
|
+
}, timeoutMs);
|
|
575
|
+
});
|
|
576
|
+
let settled;
|
|
577
|
+
try {
|
|
578
|
+
settled = await Promise.race([completion, timeoutPromise]);
|
|
579
|
+
} finally {
|
|
580
|
+
// Clear the timer whichever side won, so a completed turn does not
|
|
581
|
+
// keep the event loop alive until timeoutMs.
|
|
582
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
endpoint: "codex-app-server-uds",
|
|
587
|
+
text: parts.join("").trim(),
|
|
588
|
+
done: settled.status === "completed",
|
|
589
|
+
meta: {
|
|
590
|
+
threadId,
|
|
591
|
+
status: settled.status,
|
|
592
|
+
socketPath: resolvedSock,
|
|
593
|
+
spawned: Boolean(child),
|
|
594
|
+
...(settled.message ? { error: settled.message } : {}),
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
} finally {
|
|
598
|
+
try {
|
|
599
|
+
client?.close();
|
|
600
|
+
} catch {}
|
|
601
|
+
if (child) await terminateChild(child);
|
|
602
|
+
if (dir)
|
|
603
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
@@ -160,8 +160,11 @@ export const KIND_MAP = Object.freeze({
|
|
|
160
160
|
configWarning: "error",
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// NOTE: codex 0.135.0 rejects `--skip-git-repo-check` as a global option (exit 2:
|
|
164
|
+
// "unexpected argument"); it lives at `codex exec` subcommand scope, and the
|
|
165
|
+
// `app-server` subcommand does not need it. Empirically verified against
|
|
166
|
+
// codex-cli 0.135.0. Do not re-add it as a global flag here.
|
|
163
167
|
export const DEFAULT_CODEX_APP_SERVER_ARGS = Object.freeze([
|
|
164
|
-
"--skip-git-repo-check",
|
|
165
168
|
"app-server",
|
|
166
169
|
"--listen",
|
|
167
170
|
"stdio://",
|
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// hub/workers/lib/jsonrpc-ws-uds.mjs
|
|
2
|
+
// JSON-RPC 2.0 client over WebSocket-over-Unix-Domain-Socket.
|
|
3
|
+
//
|
|
4
|
+
// codex `app-server --listen unix://PATH` does NOT speak newline-delimited JSON
|
|
5
|
+
// on the socket — it speaks WebSocket (RFC 6455) after a standard HTTP/1.1
|
|
6
|
+
// Upgrade handshake, one JSON-RPC message per text frame. Confirmed live against
|
|
7
|
+
// codex-cli 0.135.0 (see experiments/native-bridge-feasibility/
|
|
8
|
+
// codex-app-server-uds-probe.mjs). This is the UDS sibling of the stdio
|
|
9
|
+
// `JsonRpcStdioClient`: same public surface (request/notify/onNotification/
|
|
10
|
+
// close/isOpen), different framing.
|
|
11
|
+
//
|
|
12
|
+
// Wire framing notes:
|
|
13
|
+
// - client -> server frames MUST be masked (RFC 6455 §5.3).
|
|
14
|
+
// - server -> client frames are unmasked.
|
|
15
|
+
// - one JSON-RPC object per text frame; fragmented frames are reassembled.
|
|
16
|
+
// - outbound JSON-RPC omits the `"jsonrpc":"2.0"` header (OpenAI App Server
|
|
17
|
+
// JSONL variant), matching JsonRpcStdioClient.
|
|
18
|
+
//
|
|
19
|
+
// Zero new dependencies — minimal hand-rolled WS client (no `ws` package).
|
|
20
|
+
|
|
21
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import net from "node:net";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
JsonRpcProtocolError,
|
|
26
|
+
JsonRpcTransportError,
|
|
27
|
+
} from "./jsonrpc-stdio.mjs";
|
|
28
|
+
|
|
29
|
+
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
30
|
+
const DEFAULT_MAX_FRAME_SIZE = 16 * 1024 * 1024; // 16 MiB
|
|
31
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
32
|
+
// After a graceful close("closing") we wait this long for the peer's close/EOF
|
|
33
|
+
// before force-destroying the socket so a silent peer cannot pin it open.
|
|
34
|
+
const CLOSING_DEADLINE_MS = 2_000;
|
|
35
|
+
const MAX_CONTROL_PAYLOAD = 125; // RFC 6455 §5.5: control frames <=125 bytes
|
|
36
|
+
const CLOSED_MESSAGE = "JsonRpcWsUdsClient closed";
|
|
37
|
+
|
|
38
|
+
// RFC 6455 opcodes
|
|
39
|
+
const OP_CONTINUATION = 0x0;
|
|
40
|
+
const OP_TEXT = 0x1;
|
|
41
|
+
const OP_BINARY = 0x2;
|
|
42
|
+
const OP_CLOSE = 0x8;
|
|
43
|
+
const OP_PING = 0x9;
|
|
44
|
+
const OP_PONG = 0xa;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encode a single client->server WebSocket frame (always masked).
|
|
48
|
+
* @param {number} opcode
|
|
49
|
+
* @param {Buffer} payload
|
|
50
|
+
* @returns {Buffer}
|
|
51
|
+
*/
|
|
52
|
+
function encodeClientFrame(opcode, payload) {
|
|
53
|
+
const len = payload.length;
|
|
54
|
+
const mask = randomBytes(4);
|
|
55
|
+
let header;
|
|
56
|
+
if (len < 126) {
|
|
57
|
+
header = Buffer.from([0x80 | opcode, 0x80 | len]);
|
|
58
|
+
} else if (len < 65536) {
|
|
59
|
+
header = Buffer.alloc(4);
|
|
60
|
+
header[0] = 0x80 | opcode;
|
|
61
|
+
header[1] = 0x80 | 126;
|
|
62
|
+
header.writeUInt16BE(len, 2);
|
|
63
|
+
} else {
|
|
64
|
+
header = Buffer.alloc(10);
|
|
65
|
+
header[0] = 0x80 | opcode;
|
|
66
|
+
header[1] = 0x80 | 127;
|
|
67
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
68
|
+
}
|
|
69
|
+
const masked = Buffer.allocUnsafe(len);
|
|
70
|
+
for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4];
|
|
71
|
+
return Buffer.concat([header, mask, masked]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* JSON-RPC 2.0 client over WebSocket-over-UDS.
|
|
76
|
+
*/
|
|
77
|
+
export class JsonRpcWsUdsClient {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {string} options.socketPath Absolute path to the AF_UNIX socket.
|
|
81
|
+
* @param {(err: Error) => void} [options.onError]
|
|
82
|
+
* @param {number} [options.maxFrameSize] Max bytes per inbound frame payload.
|
|
83
|
+
* @param {number} [options.connectTimeoutMs]
|
|
84
|
+
*/
|
|
85
|
+
constructor({
|
|
86
|
+
socketPath,
|
|
87
|
+
onError,
|
|
88
|
+
maxFrameSize = DEFAULT_MAX_FRAME_SIZE,
|
|
89
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
90
|
+
} = {}) {
|
|
91
|
+
if (typeof socketPath !== "string" || !socketPath) {
|
|
92
|
+
throw new TypeError("JsonRpcWsUdsClient requires a socketPath string");
|
|
93
|
+
}
|
|
94
|
+
this._socketPath = socketPath;
|
|
95
|
+
this._onError = typeof onError === "function" ? onError : null;
|
|
96
|
+
this._maxFrameSize =
|
|
97
|
+
Number.isFinite(maxFrameSize) && maxFrameSize > 0
|
|
98
|
+
? maxFrameSize
|
|
99
|
+
: DEFAULT_MAX_FRAME_SIZE;
|
|
100
|
+
this._connectTimeoutMs = Number.isFinite(connectTimeoutMs)
|
|
101
|
+
? connectTimeoutMs
|
|
102
|
+
: DEFAULT_CONNECT_TIMEOUT_MS;
|
|
103
|
+
|
|
104
|
+
/** @type {'idle'|'running'|'closing'|'closed'} */
|
|
105
|
+
this._state = "idle";
|
|
106
|
+
this._socket = null;
|
|
107
|
+
this._nextRequestId = 1;
|
|
108
|
+
/** @type {Map<number, { resolve: Function, reject: Function, timer: any, method: string }>} */
|
|
109
|
+
this._pendingRequests = new Map();
|
|
110
|
+
/** @type {Map<string, Set<Function>>} */
|
|
111
|
+
this._notificationHandlers = new Map();
|
|
112
|
+
|
|
113
|
+
// RX framing state
|
|
114
|
+
this._rxBuf = Buffer.alloc(0);
|
|
115
|
+
/** @type {Buffer[]} accumulated payloads of a fragmented message */
|
|
116
|
+
this._fragmentChunks = [];
|
|
117
|
+
/** @type {number|null} opcode of the in-progress fragmented message */
|
|
118
|
+
this._fragmentOpcode = null;
|
|
119
|
+
this._fragmentSize = 0;
|
|
120
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
121
|
+
this._closingTimer = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Open the socket and complete the WebSocket HTTP Upgrade handshake.
|
|
126
|
+
* Resolves once the connection is in the `running` state.
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
connect() {
|
|
130
|
+
if (this._state !== "idle") {
|
|
131
|
+
return Promise.reject(
|
|
132
|
+
new Error(
|
|
133
|
+
`JsonRpcWsUdsClient.connect() invalid in state ${this._state}`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const key = randomBytes(16).toString("base64");
|
|
139
|
+
const expectedAccept = createHash("sha1")
|
|
140
|
+
.update(key + WS_GUID)
|
|
141
|
+
.digest("base64");
|
|
142
|
+
let handshakeBuf = Buffer.alloc(0);
|
|
143
|
+
let settled = false;
|
|
144
|
+
|
|
145
|
+
const socket = net.connect(this._socketPath);
|
|
146
|
+
this._socket = socket;
|
|
147
|
+
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
settled = true;
|
|
151
|
+
try {
|
|
152
|
+
socket.destroy();
|
|
153
|
+
} catch {}
|
|
154
|
+
this._state = "closed";
|
|
155
|
+
reject(
|
|
156
|
+
new JsonRpcTransportError(
|
|
157
|
+
`WebSocket-over-UDS connect timed out after ${this._connectTimeoutMs}ms: ${this._socketPath}`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}, this._connectTimeoutMs);
|
|
161
|
+
|
|
162
|
+
const onConnectError = (err) => {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
settled = true;
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
this._state = "closed";
|
|
167
|
+
reject(
|
|
168
|
+
new JsonRpcTransportError(
|
|
169
|
+
`WebSocket-over-UDS connect error: ${err?.message || err}`,
|
|
170
|
+
{ cause: err },
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
socket.once("error", onConnectError);
|
|
176
|
+
socket.on("connect", () => {
|
|
177
|
+
const req =
|
|
178
|
+
"GET / HTTP/1.1\r\n" +
|
|
179
|
+
"Host: localhost\r\n" +
|
|
180
|
+
"Upgrade: websocket\r\n" +
|
|
181
|
+
"Connection: Upgrade\r\n" +
|
|
182
|
+
`Sec-WebSocket-Key: ${key}\r\n` +
|
|
183
|
+
"Sec-WebSocket-Version: 13\r\n\r\n";
|
|
184
|
+
socket.write(req);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const onHandshakeData = (chunk) => {
|
|
188
|
+
if (settled) return;
|
|
189
|
+
handshakeBuf = Buffer.concat([handshakeBuf, chunk]);
|
|
190
|
+
const sep = handshakeBuf.indexOf("\r\n\r\n");
|
|
191
|
+
if (sep < 0) {
|
|
192
|
+
if (handshakeBuf.length > 64 * 1024) {
|
|
193
|
+
settled = true;
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
socket.removeListener("error", onConnectError);
|
|
196
|
+
socket.removeListener("data", onHandshakeData);
|
|
197
|
+
try {
|
|
198
|
+
socket.destroy();
|
|
199
|
+
} catch {}
|
|
200
|
+
this._state = "closed";
|
|
201
|
+
reject(
|
|
202
|
+
new JsonRpcProtocolError(
|
|
203
|
+
"WebSocket handshake response headers exceeded 64 KiB",
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const rawHeaders = handshakeBuf.subarray(0, sep).toString("utf8");
|
|
210
|
+
const leftover = handshakeBuf.subarray(sep + 4);
|
|
211
|
+
|
|
212
|
+
const lines = rawHeaders.split("\r\n");
|
|
213
|
+
const ok101 = /^HTTP\/1\.\d 101/.test(lines[0] || "");
|
|
214
|
+
const headerMap = new Map();
|
|
215
|
+
for (let i = 1; i < lines.length; i++) {
|
|
216
|
+
const idx = lines[i].indexOf(":");
|
|
217
|
+
if (idx < 0) continue;
|
|
218
|
+
headerMap.set(
|
|
219
|
+
lines[i].slice(0, idx).trim().toLowerCase(),
|
|
220
|
+
lines[i].slice(idx + 1).trim(),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const acceptOk =
|
|
224
|
+
headerMap.get("sec-websocket-accept") === expectedAccept;
|
|
225
|
+
|
|
226
|
+
if (!ok101 || !acceptOk) {
|
|
227
|
+
settled = true;
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
socket.removeListener("error", onConnectError);
|
|
230
|
+
socket.removeListener("data", onHandshakeData);
|
|
231
|
+
try {
|
|
232
|
+
socket.destroy();
|
|
233
|
+
} catch {}
|
|
234
|
+
this._state = "closed";
|
|
235
|
+
reject(
|
|
236
|
+
new JsonRpcProtocolError(
|
|
237
|
+
`WebSocket upgrade rejected (status101=${ok101}, acceptValid=${acceptOk})`,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Handshake complete — switch to running and wire frame handling.
|
|
244
|
+
settled = true;
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
socket.removeListener("error", onConnectError);
|
|
247
|
+
socket.removeListener("data", onHandshakeData);
|
|
248
|
+
this._state = "running";
|
|
249
|
+
|
|
250
|
+
socket.on("data", (buf) => this._onSocketData(buf));
|
|
251
|
+
socket.on("error", (err) => this._handleStreamError("socket", err));
|
|
252
|
+
socket.on("close", () => {
|
|
253
|
+
if (this._state === "running") {
|
|
254
|
+
const err = new JsonRpcTransportError(
|
|
255
|
+
"WebSocket-over-UDS closed unexpectedly (EOF during running)",
|
|
256
|
+
);
|
|
257
|
+
this._emitError(err);
|
|
258
|
+
this._closeWith("closed", err);
|
|
259
|
+
} else if (this._state !== "closed") {
|
|
260
|
+
this._closeWith("closed");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (leftover.length > 0) {
|
|
265
|
+
this._rxBuf = Buffer.concat([this._rxBuf, leftover]);
|
|
266
|
+
this._drainFrames();
|
|
267
|
+
}
|
|
268
|
+
resolve();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
socket.on("data", onHandshakeData);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Issue a JSON-RPC request and resolve with the server's `result`.
|
|
277
|
+
* @param {string} method
|
|
278
|
+
* @param {unknown} params
|
|
279
|
+
* @param {number} [timeoutMs=60000]
|
|
280
|
+
* @returns {Promise<any>}
|
|
281
|
+
*/
|
|
282
|
+
request(method, params, timeoutMs = 60000) {
|
|
283
|
+
if (this._state !== "running") {
|
|
284
|
+
return Promise.reject(new Error(CLOSED_MESSAGE));
|
|
285
|
+
}
|
|
286
|
+
const id = this._nextRequestId++;
|
|
287
|
+
const frame = { id, method };
|
|
288
|
+
if (params !== undefined) frame.params = params;
|
|
289
|
+
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
let timer = null;
|
|
292
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
const pending = this._pendingRequests.get(id);
|
|
295
|
+
if (!pending) return;
|
|
296
|
+
this._pendingRequests.delete(id);
|
|
297
|
+
reject(
|
|
298
|
+
new Error(
|
|
299
|
+
`JSON-RPC request timed out after ${timeoutMs}ms: ${method}`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}, timeoutMs);
|
|
303
|
+
}
|
|
304
|
+
this._pendingRequests.set(id, { resolve, reject, timer, method });
|
|
305
|
+
try {
|
|
306
|
+
this._sendText(JSON.stringify(frame));
|
|
307
|
+
} catch (err) {
|
|
308
|
+
this._pendingRequests.delete(id);
|
|
309
|
+
if (timer) clearTimeout(timer);
|
|
310
|
+
reject(err);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Send a JSON-RPC notification (no id, no response).
|
|
317
|
+
* @param {string} method
|
|
318
|
+
* @param {unknown} [params]
|
|
319
|
+
*/
|
|
320
|
+
notify(method, params) {
|
|
321
|
+
if (this._state !== "running") return;
|
|
322
|
+
const frame = { method };
|
|
323
|
+
if (params !== undefined) frame.params = params;
|
|
324
|
+
try {
|
|
325
|
+
this._sendText(JSON.stringify(frame));
|
|
326
|
+
} catch (err) {
|
|
327
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Subscribe to inbound notifications. `"*"` = catch-all receiving
|
|
333
|
+
* `(params, method)`; targeted handlers receive `(params)`.
|
|
334
|
+
* @param {string} method
|
|
335
|
+
* @param {(params: any, method?: string) => void} callback
|
|
336
|
+
* @returns {() => void} unsubscribe
|
|
337
|
+
*/
|
|
338
|
+
onNotification(method, callback) {
|
|
339
|
+
if (typeof callback !== "function") {
|
|
340
|
+
throw new TypeError("onNotification requires a callback function");
|
|
341
|
+
}
|
|
342
|
+
let set = this._notificationHandlers.get(method);
|
|
343
|
+
if (!set) {
|
|
344
|
+
set = new Set();
|
|
345
|
+
this._notificationHandlers.set(method, set);
|
|
346
|
+
}
|
|
347
|
+
set.add(callback);
|
|
348
|
+
return () => {
|
|
349
|
+
const handlers = this._notificationHandlers.get(method);
|
|
350
|
+
if (!handlers) return;
|
|
351
|
+
handlers.delete(callback);
|
|
352
|
+
if (handlers.size === 0) this._notificationHandlers.delete(method);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Close the client. `reason === "closing"` transitions to an intermediate
|
|
358
|
+
* graceful state where a subsequent EOF is treated as normal. Idempotent.
|
|
359
|
+
* @param {string} [reason]
|
|
360
|
+
*/
|
|
361
|
+
close(reason) {
|
|
362
|
+
if (this._state === "closed") return;
|
|
363
|
+
if (reason === "closing" && this._state === "running") {
|
|
364
|
+
this._state = "closing";
|
|
365
|
+
// best-effort WS close frame
|
|
366
|
+
try {
|
|
367
|
+
this._socket?.write(encodeClientFrame(OP_CLOSE, Buffer.alloc(0)));
|
|
368
|
+
} catch {}
|
|
369
|
+
// Arm a deadline so a peer that never replies with close/EOF cannot pin
|
|
370
|
+
// the socket + pending requests open forever.
|
|
371
|
+
this._closingTimer = setTimeout(() => {
|
|
372
|
+
if (this._state === "closing") this._closeWith("closed");
|
|
373
|
+
}, CLOSING_DEADLINE_MS);
|
|
374
|
+
if (typeof this._closingTimer.unref === "function") {
|
|
375
|
+
this._closingTimer.unref();
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this._closeWith("closed");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** @returns {boolean} */
|
|
383
|
+
isOpen() {
|
|
384
|
+
return this._state === "running";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** @returns {'idle'|'running'|'closing'|'closed'} */
|
|
388
|
+
getState() {
|
|
389
|
+
return this._state;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- internals ---------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
_sendText(text) {
|
|
395
|
+
if (this._state === "closed") throw new Error(CLOSED_MESSAGE);
|
|
396
|
+
const frame = encodeClientFrame(OP_TEXT, Buffer.from(text, "utf8"));
|
|
397
|
+
this._socket.write(frame, (err) => {
|
|
398
|
+
if (err) this._handleStreamError("socket-write", err);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_onSocketData(chunk) {
|
|
403
|
+
if (this._state === "closed") return;
|
|
404
|
+
this._rxBuf = Buffer.concat([this._rxBuf, chunk]);
|
|
405
|
+
this._drainFrames();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse as many complete WebSocket frames as are buffered. Control frames
|
|
410
|
+
* (ping/pong/close) are handled inline; text/continuation frames are
|
|
411
|
+
* reassembled into a full JSON-RPC message before dispatch.
|
|
412
|
+
*/
|
|
413
|
+
_drainFrames() {
|
|
414
|
+
while (this._rxBuf.length >= 2) {
|
|
415
|
+
const b0 = this._rxBuf[0];
|
|
416
|
+
const b1 = this._rxBuf[1];
|
|
417
|
+
const fin = (b0 & 0x80) !== 0;
|
|
418
|
+
const opcode = b0 & 0x0f;
|
|
419
|
+
const masked = (b1 & 0x80) !== 0;
|
|
420
|
+
let len = b1 & 0x7f;
|
|
421
|
+
let cursor = 2;
|
|
422
|
+
|
|
423
|
+
if (len === 126) {
|
|
424
|
+
if (this._rxBuf.length < cursor + 2) return;
|
|
425
|
+
len = this._rxBuf.readUInt16BE(cursor);
|
|
426
|
+
cursor += 2;
|
|
427
|
+
} else if (len === 127) {
|
|
428
|
+
if (this._rxBuf.length < cursor + 8) return;
|
|
429
|
+
const big = this._rxBuf.readBigUInt64BE(cursor);
|
|
430
|
+
if (big > BigInt(this._maxFrameSize)) {
|
|
431
|
+
const err = new JsonRpcProtocolError(
|
|
432
|
+
`WebSocket frame payload ${big} exceeds max ${this._maxFrameSize}`,
|
|
433
|
+
);
|
|
434
|
+
this._emitError(err);
|
|
435
|
+
this._closeWith("closed", err);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
len = Number(big);
|
|
439
|
+
cursor += 8;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (len > this._maxFrameSize) {
|
|
443
|
+
const err = new JsonRpcProtocolError(
|
|
444
|
+
`WebSocket frame payload ${len} exceeds max ${this._maxFrameSize}`,
|
|
445
|
+
);
|
|
446
|
+
this._emitError(err);
|
|
447
|
+
this._closeWith("closed", err);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// RFC 6455 §5.1: server -> client frames MUST NOT be masked.
|
|
452
|
+
if (masked) {
|
|
453
|
+
this._protocolClose("WebSocket server frame is masked (RFC 6455 §5.1)");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (this._rxBuf.length < cursor + len) return; // wait for full payload
|
|
457
|
+
|
|
458
|
+
// Detach from rxBuf before we slice it off.
|
|
459
|
+
const payload = Buffer.from(this._rxBuf.subarray(cursor, cursor + len));
|
|
460
|
+
this._rxBuf = this._rxBuf.subarray(cursor + len);
|
|
461
|
+
|
|
462
|
+
this._handleFrame(fin, opcode, payload);
|
|
463
|
+
if (this._state === "closed") return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_handleFrame(fin, opcode, payload) {
|
|
468
|
+
// Control frames (>=0x8): must be FIN and <=125 bytes (RFC 6455 §5.5).
|
|
469
|
+
if (opcode >= 0x8) {
|
|
470
|
+
if (!fin || payload.length > MAX_CONTROL_PAYLOAD) {
|
|
471
|
+
this._protocolClose(
|
|
472
|
+
`Invalid control frame (opcode 0x${opcode.toString(16)}, fin=${fin}, len=${payload.length})`,
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (opcode === OP_PING) {
|
|
477
|
+
try {
|
|
478
|
+
this._socket?.write(encodeClientFrame(OP_PONG, payload));
|
|
479
|
+
} catch {}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (opcode === OP_PONG) return;
|
|
483
|
+
if (opcode === OP_CLOSE) {
|
|
484
|
+
if (this._state === "running") {
|
|
485
|
+
const err = new JsonRpcTransportError(
|
|
486
|
+
"WebSocket server sent close frame",
|
|
487
|
+
);
|
|
488
|
+
this._emitError(err);
|
|
489
|
+
this._closeWith("closed", err);
|
|
490
|
+
} else {
|
|
491
|
+
this._closeWith("closed");
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
this._protocolClose(`Unknown control opcode 0x${opcode.toString(16)}`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Data frames: text / binary (message start) or continuation.
|
|
500
|
+
if (opcode === OP_TEXT || opcode === OP_BINARY) {
|
|
501
|
+
if (this._fragmentOpcode !== null) {
|
|
502
|
+
this._protocolClose(
|
|
503
|
+
"New data frame received while a fragmented message is active",
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (opcode === OP_BINARY) {
|
|
508
|
+
this._protocolClose(
|
|
509
|
+
"Binary frames are not supported (expected text JSON-RPC)",
|
|
510
|
+
);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (fin) {
|
|
514
|
+
this._handleMessage(payload.toString("utf8"));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this._fragmentOpcode = opcode;
|
|
518
|
+
this._fragmentChunks = [payload];
|
|
519
|
+
this._fragmentSize = payload.length;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (opcode === OP_CONTINUATION) {
|
|
524
|
+
if (this._fragmentOpcode === null) {
|
|
525
|
+
this._protocolClose(
|
|
526
|
+
"Continuation frame with no active fragmented message",
|
|
527
|
+
);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this._fragmentSize += payload.length;
|
|
531
|
+
if (this._fragmentSize > this._maxFrameSize) {
|
|
532
|
+
this._protocolClose(
|
|
533
|
+
`Fragmented message ${this._fragmentSize} exceeds max ${this._maxFrameSize}`,
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
this._fragmentChunks.push(payload);
|
|
538
|
+
if (!fin) return;
|
|
539
|
+
const full = Buffer.concat(this._fragmentChunks);
|
|
540
|
+
this._fragmentChunks = [];
|
|
541
|
+
this._fragmentOpcode = null;
|
|
542
|
+
this._fragmentSize = 0;
|
|
543
|
+
this._handleMessage(full.toString("utf8"));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this._protocolClose(`Unknown WebSocket opcode 0x${opcode.toString(16)}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
_protocolClose(message) {
|
|
551
|
+
const err = new JsonRpcProtocolError(message);
|
|
552
|
+
this._emitError(err);
|
|
553
|
+
this._closeWith("closed", err);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
_handleMessage(line) {
|
|
557
|
+
if (this._state === "closed") return;
|
|
558
|
+
if (line.length === 0) return;
|
|
559
|
+
|
|
560
|
+
let frame;
|
|
561
|
+
try {
|
|
562
|
+
frame = JSON.parse(line);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
const pErr = new JsonRpcProtocolError(
|
|
565
|
+
`JSON-RPC parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
566
|
+
{ cause: err },
|
|
567
|
+
);
|
|
568
|
+
this._emitError(pErr);
|
|
569
|
+
if (this._state === "running") this._closeWith("closed", pErr);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!frame || typeof frame !== "object") {
|
|
574
|
+
const pErr = new JsonRpcProtocolError(
|
|
575
|
+
"JSON-RPC protocol error: frame is not an object",
|
|
576
|
+
);
|
|
577
|
+
this._emitError(pErr);
|
|
578
|
+
if (this._state === "running") this._closeWith("closed", pErr);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (
|
|
583
|
+
Object.hasOwn(frame, "id") &&
|
|
584
|
+
frame.id !== null &&
|
|
585
|
+
(Object.hasOwn(frame, "result") || Object.hasOwn(frame, "error"))
|
|
586
|
+
) {
|
|
587
|
+
this._dispatchResponse(frame);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (typeof frame.method === "string" && !Object.hasOwn(frame, "id")) {
|
|
592
|
+
this._dispatchNotification(frame);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
this._emitError(
|
|
597
|
+
new JsonRpcProtocolError(
|
|
598
|
+
"JSON-RPC protocol error: unrecognized frame shape",
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
_dispatchResponse(frame) {
|
|
604
|
+
const pending = this._pendingRequests.get(frame.id);
|
|
605
|
+
if (!pending) return;
|
|
606
|
+
this._pendingRequests.delete(frame.id);
|
|
607
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
608
|
+
|
|
609
|
+
if (Object.hasOwn(frame, "error") && frame.error) {
|
|
610
|
+
const { code, message, data } = frame.error;
|
|
611
|
+
const err = new Error(
|
|
612
|
+
`JSON-RPC error${typeof code === "number" ? ` ${code}` : ""}: ${message || "unknown"}`,
|
|
613
|
+
);
|
|
614
|
+
if (code !== undefined) err.code = code;
|
|
615
|
+
if (data !== undefined) err.data = data;
|
|
616
|
+
pending.reject(err);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
pending.resolve(frame.result);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
_dispatchNotification(frame) {
|
|
623
|
+
const { method, params } = frame;
|
|
624
|
+
const targeted = this._notificationHandlers.get(method);
|
|
625
|
+
if (targeted && targeted.size > 0) {
|
|
626
|
+
for (const cb of targeted) {
|
|
627
|
+
try {
|
|
628
|
+
cb(params);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const wildcard = this._notificationHandlers.get("*");
|
|
635
|
+
if (wildcard && wildcard.size > 0) {
|
|
636
|
+
for (const cb of wildcard) {
|
|
637
|
+
try {
|
|
638
|
+
cb(params, method);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
this._emitError(err instanceof Error ? err : new Error(String(err)));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
_handleStreamError(which, err) {
|
|
647
|
+
if (this._state === "closed") return;
|
|
648
|
+
const base = err instanceof Error ? err : new Error(String(err));
|
|
649
|
+
const wrapped = new JsonRpcTransportError(
|
|
650
|
+
`WebSocket-over-UDS stream error on ${which}: ${base.message}`,
|
|
651
|
+
{ cause: base },
|
|
652
|
+
);
|
|
653
|
+
this._emitError(wrapped);
|
|
654
|
+
this._closeWith("closed", wrapped);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
_closeWith(target, rejectReason = null) {
|
|
658
|
+
if (this._state === "closed") return;
|
|
659
|
+
this._state = target;
|
|
660
|
+
if (this._closingTimer) {
|
|
661
|
+
clearTimeout(this._closingTimer);
|
|
662
|
+
this._closingTimer = null;
|
|
663
|
+
}
|
|
664
|
+
const rejectErr =
|
|
665
|
+
rejectReason instanceof Error ? rejectReason : new Error(CLOSED_MESSAGE);
|
|
666
|
+
for (const [, pending] of this._pendingRequests) {
|
|
667
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
668
|
+
pending.reject(rejectErr);
|
|
669
|
+
}
|
|
670
|
+
this._pendingRequests.clear();
|
|
671
|
+
try {
|
|
672
|
+
this._socket?.destroy();
|
|
673
|
+
} catch {}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
_emitError(err) {
|
|
677
|
+
if (!this._onError) return;
|
|
678
|
+
try {
|
|
679
|
+
this._onError(err);
|
|
680
|
+
} catch {
|
|
681
|
+
/* never throw out of the dispatch loop */
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export default JsonRpcWsUdsClient;
|
package/hud/context-monitor.mjs
CHANGED
|
@@ -288,11 +288,16 @@ export function buildContextUsageView(stdin, snapshot = null) {
|
|
|
288
288
|
const modelHintLimit = resolveModelLimit(modelId);
|
|
289
289
|
const monitorLimit = Number(monitor?.limitTokens || 0);
|
|
290
290
|
const stdinLimit = stdinUsage?.limitTokens;
|
|
291
|
+
// When a model id is known it is the authoritative per-model ceiling: the
|
|
292
|
+
// monitor's cached limitTokens is just a default-derived accumulator (now 1M
|
|
293
|
+
// by default) and must not override a known model's real window in either
|
|
294
|
+
// direction — it would inflate a 200K model (Sonnet 4.5 / Haiku) up to 1M.
|
|
295
|
+
// The model hint already upgrades a stale-low monitor on its own (#88).
|
|
291
296
|
const limitTokens =
|
|
292
297
|
stdinLimit != null && stdinLimit > 0
|
|
293
298
|
? Math.max(1, stdinLimit)
|
|
294
299
|
: modelId
|
|
295
|
-
? Math.max(1,
|
|
300
|
+
? Math.max(1, modelHintLimit)
|
|
296
301
|
: Math.max(1, monitorLimit || modelHintLimit);
|
|
297
302
|
|
|
298
303
|
const usedTokens = stdinUsage?.usedTokens ?? Number(monitor?.usedTokens || 0);
|
|
@@ -324,7 +329,19 @@ export function buildContextUsageView(stdin, snapshot = null) {
|
|
|
324
329
|
}
|
|
325
330
|
|
|
326
331
|
export function createContextMonitor(options = {}) {
|
|
327
|
-
|
|
332
|
+
// Default to a 1M context window (Opus 4.x [1M] / Codex gpt-5.5 both run on
|
|
333
|
+
// a 1M window) when no explicit limit is supplied, so the cached percent the
|
|
334
|
+
// Stop hook reads is not inflated against a 200K assumption — assuming 200K
|
|
335
|
+
// there false-positives at ~15% real usage and blocks legitimate stops.
|
|
336
|
+
// Narrower setups override via TFX_CONTEXT_DEFAULT_MAX_TOKENS.
|
|
337
|
+
const explicitLimit = Number(options.limitTokens);
|
|
338
|
+
const envLimit = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
|
|
339
|
+
const limitTokens =
|
|
340
|
+
Number.isFinite(explicitLimit) && explicitLimit > 0
|
|
341
|
+
? explicitLimit
|
|
342
|
+
: Number.isFinite(envLimit) && envLimit > 0
|
|
343
|
+
? envLimit
|
|
344
|
+
: MILLION_CONTEXT_LIMIT;
|
|
328
345
|
const cachePath = options.cachePath || CONTEXT_MONITOR_CACHE_PATH;
|
|
329
346
|
const logsDir = options.logsDir || CONTEXT_MONITOR_LOG_DIR;
|
|
330
347
|
const sessionId = options.sessionId || randomUUID().slice(0, 8);
|
package/package.json
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
const TEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
16
|
const STALE_LOCK = join(ROOT, ".test-lock", "pid.lock");
|
|
17
17
|
|
|
18
|
-
export function cleanupStaleTestLock() {
|
|
19
|
-
if (!existsSync(
|
|
18
|
+
export function cleanupStaleTestLock(lockPath = STALE_LOCK) {
|
|
19
|
+
if (!existsSync(lockPath)) return;
|
|
20
20
|
try {
|
|
21
|
-
rmSync(
|
|
21
|
+
rmSync(lockPath, { force: true });
|
|
22
22
|
console.log("[prepare] cleaned stale .test-lock/pid.lock");
|
|
23
23
|
} catch (e) {
|
|
24
24
|
console.warn(`[prepare] failed to clean test-lock: ${e.message}`);
|
|
@@ -41,7 +41,10 @@ export async function prepareRelease({
|
|
|
41
41
|
skipTests = false,
|
|
42
42
|
execFileSyncFn,
|
|
43
43
|
} = {}) {
|
|
44
|
-
|
|
44
|
+
// Derive the preflight lock from rootDir so tests that pass a temp rootDir
|
|
45
|
+
// never delete the live repo-root .test-lock/pid.lock owned by the running
|
|
46
|
+
// test-lock.mjs wrapper. Production (rootDir = ROOT) is unchanged.
|
|
47
|
+
cleanupStaleTestLock(join(rootDir, ".test-lock", "pid.lock"));
|
|
45
48
|
const logStep = createStepLogger();
|
|
46
49
|
logStep("version-sync");
|
|
47
50
|
const sync = assertVersionSync({ rootDir });
|
package/skills/tfx-live/SKILL.md
CHANGED
|
@@ -76,6 +76,25 @@ tfx-live peer --cli-a codex --cli-b claude \
|
|
|
76
76
|
|
|
77
77
|
In `peer`, every hop sends the previous response as the next prompt to the opposite agent; the final JSON is only the transcript.
|
|
78
78
|
|
|
79
|
+
## Orchestrate (Claude UDS + Codex)
|
|
80
|
+
|
|
81
|
+
`orchestrate` exposes the `runUdsOrchestration` engine as a formal verb: Claude is driven over the daemon control socket (UDS) and Codex over a selectable transport, in one of three shapes.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# default: Codex over the stdio one-shot path (codex exec)
|
|
85
|
+
tfx-live orchestrate --task "이 변경의 위험을 한 줄로" --mode peer
|
|
86
|
+
|
|
87
|
+
# experimental: Codex over a real `codex app-server` WebSocket-over-UDS daemon
|
|
88
|
+
tfx-live orchestrate --task "이 변경의 위험을 한 줄로" \
|
|
89
|
+
--mode codex-led --codex-transport app-server-uds --timeout 120
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `--mode` = `peer` (default) | `codex-led` | `claude-led`.
|
|
93
|
+
- `--codex-transport` = `exec` (default, `codex exec` stdio) | `app-server-uds` (experimental).
|
|
94
|
+
- `app-server-uds` spawns a private `codex app-server --listen unix://<tmp>`, attaches over WebSocket-over-UDS (RFC 6455, masked client frames), runs `initialize`→`thread/start`→`turn/start`, and resolves on `turn/completed`. Transport proven against codex-cli 0.135.0 (see `experiments/native-bridge-feasibility/codex-app-server-uds-smoke.mjs`).
|
|
95
|
+
- It is NOT the default — the existing `codex exec` path is unchanged.
|
|
96
|
+
- Requires a live Claude daemon (same as the UDS `ask` path) for the Claude side.
|
|
97
|
+
|
|
79
98
|
## Useful diagnostics
|
|
80
99
|
|
|
81
100
|
```bash
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
internal: true
|
|
3
3
|
name: tfx-research
|
|
4
|
-
description: "웹 검색/리서치가 필요할 때 사용한다. '검색해줘', '찾아봐', '최신 정보', '이거 뭐야', '심층 조사', '자세히 알아봐', 'deep research', '전면 리서치', '자율 리서치', '조사해', 'research and plan' 같은 요청에 반드시 사용. 추가로 'X 있나?', 'X 쓸 수 있나?', 'X 풀려있어?', 'X 어떻게 쓰는지', 'X 가능한가?', '방법 있나?', 'X 살아있나?' 같은 도구·기능 존재/사용법/상태 의문문에도 사용. 기본값은
|
|
4
|
+
description: "웹 검색/리서치가 필요할 때 사용한다. '검색해줘', '찾아봐', '최신 정보', '이거 뭐야', '심층 조사', '자세히 알아봐', 'deep research', '전면 리서치', '자율 리서치', '조사해', 'research and plan' 같은 요청에 반드시 사용. 추가로 'X 있나?', 'X 쓸 수 있나?', 'X 풀려있어?', 'X 어떻게 쓰는지', 'X 가능한가?', '방법 있나?', 'X 살아있나?' 같은 도구·기능 존재/사용법/상태 의문문에도 사용. 기본값은 2-CLI 멀티소스(Exa+Brave) 합의 딥 리서치. 빠른 Antigravity Google Search 는 --quick. 자율 쿼리생성+보고서 모드는 --auto."
|
|
5
5
|
triggers:
|
|
6
6
|
- tfx-research
|
|
7
7
|
- 리서치
|
|
@@ -23,7 +23,7 @@ argument-hint: "<주제> [--quick | --auto] [--depth quick|standard|deep]"
|
|
|
23
23
|
|
|
24
24
|
> **ARGUMENTS 처리**: `--quick` → Quick. `--auto` → Auto. 그 외 → Deep (기본).
|
|
25
25
|
|
|
26
|
-
> AI makes completeness near-free. 기본은 Claude(Exa/학술) + Codex(Brave/실용)
|
|
26
|
+
> AI makes completeness near-free. 기본은 Claude(Exa/학술) + Codex(Brave/실용) 2-CLI 멀티소스 교차검증 합의 (Antigravity/Tavily는 agy --print idle 미도달 행으로 Deep 제외; Quick 단일 검색만 유지).
|
|
27
27
|
> 빠른 단일 Google Search 는 `--quick`. 자율 쿼리생성+구조화 보고서 는 `--auto`.
|
|
28
28
|
|
|
29
29
|
---
|
|
@@ -32,7 +32,7 @@ argument-hint: "<주제> [--quick | --auto] [--depth quick|standard|deep]"
|
|
|
32
32
|
|
|
33
33
|
| 플래그 | 모드 | 특징 |
|
|
34
34
|
|--------|------|------|
|
|
35
|
-
| (없음) | **Deep** (기본) |
|
|
35
|
+
| (없음) | **Deep** (기본) | 2-CLI 멀티소스 교차검증, consensus score |
|
|
36
36
|
| `--quick` | Quick | Antigravity 단일 Google Search |
|
|
37
37
|
| `--auto` | Auto | 자율 쿼리생성(3-5개) + 검색 + 구조화 보고서 |
|
|
38
38
|
|
|
@@ -52,7 +52,6 @@ argument-hint: "<주제> [--quick | --auto] [--depth quick|standard|deep]"
|
|
|
52
52
|
|-----|-----|------|
|
|
53
53
|
| Claude | Exa (neural semantic) | 학술/기술 깊이, 공식 문서, 벤치마크 |
|
|
54
54
|
| Codex | Brave Search | 실용/구현/산업 사례 |
|
|
55
|
-
| Antigravity | Tavily | 비용/운영/DX |
|
|
56
55
|
|
|
57
56
|
### Depth 모드 (`--depth` 플래그)
|
|
58
57
|
|
|
@@ -71,7 +70,7 @@ argument-hint: "<주제> [--quick | --auto] [--depth quick|standard|deep]"
|
|
|
71
70
|
- depth 에 따른 서브쿼리 생성
|
|
72
71
|
- 각 쿼리에 관점(학술/실용/DX) 매핑
|
|
73
72
|
|
|
74
|
-
#### Step 2:
|
|
73
|
+
#### Step 2: 2-CLI 독립 병렬 검색 (Anti-Herding) — Bash + Agent 동시 호출
|
|
75
74
|
|
|
76
75
|
**Agent (Claude + Exa):**
|
|
77
76
|
```
|
|
@@ -83,11 +82,10 @@ Agent(
|
|
|
83
82
|
)
|
|
84
83
|
```
|
|
85
84
|
|
|
86
|
-
**Bash (Codex + Brave
|
|
85
|
+
**Bash (Codex + Brave):** *(Antigravity/agy 제외 — agy --print 는 무거운 리서치에서 idle 미도달 시 5분 행; route 에서 --print-timeout 180s 로 bound)*
|
|
87
86
|
```
|
|
88
87
|
Bash("tfx multi --teammate-mode headless --auto-attach --dashboard \
|
|
89
88
|
--assign 'codex:서브쿼리를 Brave Search 로 검색. 서브쿼리: {sub_queries}. 관점: 실용/산업. brave_web_search + brave_news_search, freshness=pw. 각 쿼리 상위 5개 구조화.:researcher' \
|
|
90
|
-
--assign 'antigravity:서브쿼리를 Tavily 로 검색. {sub_queries}. 관점: 비용/운영/DX. tavily_search search_depth=advanced, max_results=5, include_raw_content=false. 구조화.:researcher' \
|
|
91
89
|
--timeout 600")
|
|
92
90
|
```
|
|
93
91
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tfx-research",
|
|
3
|
-
"description": "웹 검색/리서치가 필요할 때 사용한다. '검색해줘', '찾아봐', '최신 정보', '이거 뭐야', '심층 조사', '자세히 알아봐', 'deep research', '전면 리서치', '자율 리서치', '조사해', 'research and plan' 같은 요청에 반드시 사용. 추가로 'X 있나?', 'X 쓸 수 있나?', 'X 풀려있어?', 'X 어떻게 쓰는지', 'X 가능한가?', '방법 있나?', 'X 살아있나?' 같은 도구·기능 존재/사용법/상태 의문문에도 사용. 기본값은
|
|
3
|
+
"description": "웹 검색/리서치가 필요할 때 사용한다. '검색해줘', '찾아봐', '최신 정보', '이거 뭐야', '심층 조사', '자세히 알아봐', 'deep research', '전면 리서치', '자율 리서치', '조사해', 'research and plan' 같은 요청에 반드시 사용. 추가로 'X 있나?', 'X 쓸 수 있나?', 'X 풀려있어?', 'X 어떻게 쓰는지', 'X 가능한가?', '방법 있나?', 'X 살아있나?' 같은 도구·기능 존재/사용법/상태 의문문에도 사용. 기본값은 2-CLI 멀티소스(Exa+Brave) 합의 딥 리서치. 빠른 Antigravity Google Search 는 --quick. 자율 쿼리생성+보고서 모드는 --auto.",
|
|
4
4
|
"triggers": [
|
|
5
5
|
"tfx-research",
|
|
6
6
|
"리서치",
|