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 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
  }
@@ -142,7 +142,7 @@ function contextPercentsFromObject(value) {
142
142
  ];
143
143
 
144
144
  const currentUsage = value.current_usage ?? value.currentUsage ?? {};
145
- const maxTokens =
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;
@@ -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, monitorLimit, modelHintLimit)
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
- const limitTokens = Number(options.limitTokens || DEFAULT_CONTEXT_LIMIT);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.28.0",
3
+ "version": "10.28.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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(STALE_LOCK)) return;
18
+ export function cleanupStaleTestLock(lockPath = STALE_LOCK) {
19
+ if (!existsSync(lockPath)) return;
20
20
  try {
21
- rmSync(STALE_LOCK, { force: true });
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
- cleanupStaleTestLock();
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 });
@@ -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 살아있나?' 같은 도구·기능 존재/사용법/상태 의문문에도 사용. 기본값은 3-CLI 멀티소스(Exa+Brave+Tavily) 합의 딥 리서치. 빠른 Antigravity Google Search 는 --quick. 자율 쿼리생성+보고서 모드는 --auto."
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/실용) + Antigravity(Tavily/DX) 3-CLI 멀티소스 교차검증 합의.
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** (기본) | 3-CLI 멀티소스 교차검증, consensus score |
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: 3-CLI 독립 병렬 검색 (Anti-Herding) — Bash + Agent 동시 호출
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, Antigravity + Tavily):**
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-CLI 멀티소스(Exa+Brave+Tavily) 합의 딥 리서치. 빠른 Antigravity Google Search 는 --quick. 자율 쿼리생성+보고서 모드는 --auto.",
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
  "리서치",