triflux 10.28.0 → 10.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tfx-live.mjs +45 -0
- package/hooks/pipeline-stop.mjs +13 -1
- package/hooks/session-end-cleanup.mjs +15 -0
- package/hooks/session-start-fast.mjs +123 -0
- package/hub/server.mjs +44 -1
- package/hub/team/synapse-http.mjs +114 -13
- package/hub/team/synapse-registry.mjs +187 -10
- package/hub/team/uds-orchestrator.mjs +259 -0
- package/hub/workers/codex-app-server-worker.mjs +4 -1
- package/hub/workers/lib/jsonrpc-core.mjs +299 -0
- package/hub/workers/lib/jsonrpc-stdio.mjs +17 -276
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +479 -0
- package/hud/constants.mjs +15 -0
- package/hud/context-monitor.mjs +19 -2
- package/hud/hud-qos-status.mjs +3 -1
- package/hud/providers/claude.mjs +116 -14
- package/hud/renderers.mjs +39 -3
- package/package.json +1 -1
- package/scripts/pack.mjs +4 -0
- package/scripts/release/check-packages-mirror.mjs +47 -0
- 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
|
@@ -5,12 +5,59 @@ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
|
5
5
|
const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
|
|
6
6
|
const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
7
7
|
const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
|
|
8
|
+
// Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
|
|
9
|
+
// timeout produces stale false-positives. 5-minute TTL + an `idle` state
|
|
10
|
+
// distinguishes "alive but inactive" from "presumed dead".
|
|
11
|
+
const DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
12
|
+
const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300_000;
|
|
13
|
+
|
|
14
|
+
const VALID_SESSION_KINDS = new Set(["interactive", "headless"]);
|
|
8
15
|
|
|
9
16
|
function normalizeSessionId(sessionId) {
|
|
10
17
|
if (sessionId == null) return "";
|
|
11
18
|
return String(sessionId).trim();
|
|
12
19
|
}
|
|
13
20
|
|
|
21
|
+
function normalizeSessionKind(raw) {
|
|
22
|
+
return VALID_SESSION_KINDS.has(raw) ? raw : "headless";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A session is "live" while active OR idle. Idle is an interactive session that
|
|
26
|
+
// missed its heartbeat interval but is still under the TTL — alive but inactive,
|
|
27
|
+
// not presumed dead. getActive() and querySessions() share this single predicate
|
|
28
|
+
// so the liveness contract can't drift: getActive() feeds git-preflight's
|
|
29
|
+
// dirty-file conflict guard, so dropping idle there would hide a still-live
|
|
30
|
+
// interactive session's claimed paths from that safety check.
|
|
31
|
+
function isLiveStatus(status) {
|
|
32
|
+
return status === "active" || status === "idle";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Non-secret co-location fingerprint: a short, stable, non-reversible hash of a
|
|
36
|
+
// path so peers can correlate co-located sessions without leaking the raw
|
|
37
|
+
// filesystem path (cf. AccountBroker redaction). This is NOT a security boundary
|
|
38
|
+
// — the boundary is `cwdLabel` (basename only). Treat the hash as an opaque
|
|
39
|
+
// correlation token, not as a secret.
|
|
40
|
+
function shortHash(value) {
|
|
41
|
+
const str = String(value ?? "");
|
|
42
|
+
let h = 5381;
|
|
43
|
+
for (let i = 0; i < str.length; i++) {
|
|
44
|
+
h = (h * 33) ^ str.charCodeAt(i);
|
|
45
|
+
}
|
|
46
|
+
return (h >>> 0).toString(36);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Last path segment of a directory, used as a human-readable label in redacted
|
|
50
|
+
// peer projections. Separator-agnostic on purpose: `node:path`.basename() is
|
|
51
|
+
// platform-dependent, so on POSIX a Windows path like `C:\Users\Alice\secret`
|
|
52
|
+
// would NOT be split and the full path would leak as the label. Splitting on
|
|
53
|
+
// either separator redacts both path styles regardless of host platform.
|
|
54
|
+
function pathLabel(value) {
|
|
55
|
+
const str = String(value ?? "").replace(/[/\\]+$/, "");
|
|
56
|
+
if (!str) return "";
|
|
57
|
+
const segments = str.split(/[/\\]+/);
|
|
58
|
+
return segments[segments.length - 1] || "";
|
|
59
|
+
}
|
|
60
|
+
|
|
14
61
|
function cloneSession(session) {
|
|
15
62
|
return {
|
|
16
63
|
...session,
|
|
@@ -24,6 +71,13 @@ function sanitizeSession(raw, fallbackSessionId = "") {
|
|
|
24
71
|
const sessionId = normalizeSessionId(raw?.sessionId ?? fallbackSessionId);
|
|
25
72
|
if (!sessionId) return null;
|
|
26
73
|
|
|
74
|
+
const status =
|
|
75
|
+
raw?.status === "stale" ||
|
|
76
|
+
raw?.status === "expired" ||
|
|
77
|
+
raw?.status === "idle"
|
|
78
|
+
? raw.status
|
|
79
|
+
: "active";
|
|
80
|
+
|
|
27
81
|
return {
|
|
28
82
|
sessionId,
|
|
29
83
|
host: typeof raw?.host === "string" ? raw.host : "local",
|
|
@@ -33,11 +87,39 @@ function sanitizeSession(raw, fallbackSessionId = "") {
|
|
|
33
87
|
taskSummary: typeof raw?.taskSummary === "string" ? raw.taskSummary : "",
|
|
34
88
|
lastHeartbeat:
|
|
35
89
|
typeof raw?.lastHeartbeat === "number" ? raw.lastHeartbeat : Date.now(),
|
|
36
|
-
status
|
|
37
|
-
raw?.status === "stale" || raw?.status === "expired"
|
|
38
|
-
? raw.status
|
|
39
|
-
: "active",
|
|
90
|
+
status,
|
|
40
91
|
isRemote: Boolean(raw?.isRemote),
|
|
92
|
+
// Additive fields (peer-discovery). Defaults keep pre-existing persist rows
|
|
93
|
+
// (no cwd/pid/sessionKind) loading cleanly — no persist version bump needed.
|
|
94
|
+
cwd: typeof raw?.cwd === "string" ? raw.cwd : "",
|
|
95
|
+
pid: typeof raw?.pid === "number" ? raw.pid : null,
|
|
96
|
+
sessionKind: normalizeSessionKind(raw?.sessionKind),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Pure redacted projection of a session for the peer-discovery route. Raw
|
|
101
|
+
// `cwd`/`pid` and `dirtyFiles` are NEVER exposed; only label/hash + booleans
|
|
102
|
+
// computed against the caller's query args (cf. AccountBroker.publicSnapshot).
|
|
103
|
+
export function projectPeer(session, query = {}) {
|
|
104
|
+
const cwd = typeof session?.cwd === "string" ? session.cwd : "";
|
|
105
|
+
const worktreePath =
|
|
106
|
+
typeof session?.worktreePath === "string" ? session.worktreePath : "";
|
|
107
|
+
const queryCwd = typeof query?.cwd === "string" ? query.cwd : "";
|
|
108
|
+
const queryWorktree =
|
|
109
|
+
typeof query?.worktree === "string" ? query.worktree : "";
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
sessionId: typeof session?.sessionId === "string" ? session.sessionId : "",
|
|
113
|
+
branch: typeof session?.branch === "string" ? session.branch : "",
|
|
114
|
+
sessionKind: normalizeSessionKind(session?.sessionKind),
|
|
115
|
+
status: typeof session?.status === "string" ? session.status : "active",
|
|
116
|
+
host: typeof session?.host === "string" ? session.host : "local",
|
|
117
|
+
isRemote: Boolean(session?.isRemote),
|
|
118
|
+
sameCwd: Boolean(queryCwd) && cwd === queryCwd,
|
|
119
|
+
sameWorktree: Boolean(queryWorktree) && worktreePath === queryWorktree,
|
|
120
|
+
cwdLabel: pathLabel(cwd),
|
|
121
|
+
cwdHash: cwd ? shortHash(cwd) : "",
|
|
122
|
+
worktreeLabel: pathLabel(worktreePath),
|
|
41
123
|
};
|
|
42
124
|
}
|
|
43
125
|
|
|
@@ -49,11 +131,14 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
49
131
|
localTimeoutMs = DEFAULT_LOCAL_TIMEOUT_MS,
|
|
50
132
|
remoteHeartbeatIntervalMs = DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS,
|
|
51
133
|
remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
|
|
134
|
+
interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
|
|
135
|
+
interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
|
|
52
136
|
} = opts;
|
|
53
137
|
|
|
54
138
|
const sessions = new Map();
|
|
55
139
|
const monitors = new Map();
|
|
56
140
|
const staleCallbacks = new Set();
|
|
141
|
+
const idleCallbacks = new Set();
|
|
57
142
|
const removedCallbacks = new Set();
|
|
58
143
|
|
|
59
144
|
function now() {
|
|
@@ -61,12 +146,16 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
61
146
|
}
|
|
62
147
|
|
|
63
148
|
function intervalFor(session) {
|
|
149
|
+
if (session.sessionKind === "interactive") {
|
|
150
|
+
return interactiveHeartbeatIntervalMs;
|
|
151
|
+
}
|
|
64
152
|
return session.isRemote
|
|
65
153
|
? remoteHeartbeatIntervalMs
|
|
66
154
|
: localHeartbeatIntervalMs;
|
|
67
155
|
}
|
|
68
156
|
|
|
69
157
|
function timeoutFor(session) {
|
|
158
|
+
if (session.sessionKind === "interactive") return interactiveTimeoutMs;
|
|
70
159
|
return session.isRemote ? remoteTimeoutMs : localTimeoutMs;
|
|
71
160
|
}
|
|
72
161
|
|
|
@@ -130,6 +219,21 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
130
219
|
}
|
|
131
220
|
}
|
|
132
221
|
|
|
222
|
+
function notifyIdle(session) {
|
|
223
|
+
const clone = cloneSession(session);
|
|
224
|
+
emitter?.emit("synapse.session.idle", {
|
|
225
|
+
sessionId: session.sessionId,
|
|
226
|
+
session: clone,
|
|
227
|
+
});
|
|
228
|
+
for (const callback of idleCallbacks) {
|
|
229
|
+
try {
|
|
230
|
+
callback(clone);
|
|
231
|
+
} catch {
|
|
232
|
+
/* no-op */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
133
237
|
function notifyRemoved(session) {
|
|
134
238
|
const clone = cloneSession(session);
|
|
135
239
|
emitter?.emit("synapse.session.removed", {
|
|
@@ -156,12 +260,30 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
156
260
|
if (!current) return;
|
|
157
261
|
|
|
158
262
|
const elapsedMs = now() - current.lastHeartbeat;
|
|
159
|
-
if (elapsedMs > timeoutFor(current)
|
|
160
|
-
|
|
161
|
-
|
|
263
|
+
if (elapsedMs > timeoutFor(current)) {
|
|
264
|
+
if (current.status !== "stale") {
|
|
265
|
+
const staled = { ...current, status: "stale" };
|
|
266
|
+
sessions.set(sessionId, staled);
|
|
267
|
+
schedulePersist();
|
|
268
|
+
setImmediate(() => {
|
|
269
|
+
if (!destroyed) notifyStale(staled);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Interactive sessions that miss the heartbeat interval but are still
|
|
276
|
+
// under the TTL are "alive but inactive" (idle), not "presumed dead".
|
|
277
|
+
if (
|
|
278
|
+
current.sessionKind === "interactive" &&
|
|
279
|
+
current.status === "active" &&
|
|
280
|
+
elapsedMs > intervalFor(current)
|
|
281
|
+
) {
|
|
282
|
+
const idled = { ...current, status: "idle" };
|
|
283
|
+
sessions.set(sessionId, idled);
|
|
162
284
|
schedulePersist();
|
|
163
285
|
setImmediate(() => {
|
|
164
|
-
if (!destroyed)
|
|
286
|
+
if (!destroyed) notifyIdle(idled);
|
|
165
287
|
});
|
|
166
288
|
}
|
|
167
289
|
}, intervalFor(session));
|
|
@@ -185,7 +307,14 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
185
307
|
return { ok: false, sessionId, reason: "invalid_id" };
|
|
186
308
|
}
|
|
187
309
|
|
|
188
|
-
|
|
310
|
+
// A live row (active/idle) means a concurrent session already holds this id
|
|
311
|
+
// — reject (LOCKED #5). A stale/expired row is a dead remnant of the SAME
|
|
312
|
+
// session resuming: Claude Code re-fires SessionStart with the same
|
|
313
|
+
// session_id on resume/clear/compact, so fall through and re-register
|
|
314
|
+
// (revive) it. Otherwise the resumed-but-live session stays stale forever
|
|
315
|
+
// and vanishes from peer-discovery AND git-preflight's dirty-file guard.
|
|
316
|
+
const existing = sessions.get(sessionId);
|
|
317
|
+
if (existing && isLiveStatus(existing.status)) {
|
|
189
318
|
console.warn(
|
|
190
319
|
"[synapse-registry] duplicate registration rejected:",
|
|
191
320
|
sessionId,
|
|
@@ -252,6 +381,9 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
252
381
|
if (typeof partialMeta.isRemote === "boolean") {
|
|
253
382
|
updated.isRemote = partialMeta.isRemote;
|
|
254
383
|
}
|
|
384
|
+
if (typeof partialMeta.cwd === "string") updated.cwd = partialMeta.cwd;
|
|
385
|
+
if (typeof partialMeta.pid === "number") updated.pid = partialMeta.pid;
|
|
386
|
+
// sessionKind is set at register time and immutable on heartbeat.
|
|
255
387
|
}
|
|
256
388
|
|
|
257
389
|
sessions.set(normalized, updated);
|
|
@@ -270,8 +402,11 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
270
402
|
}
|
|
271
403
|
|
|
272
404
|
function getActive() {
|
|
405
|
+
// Live = active or idle. git-preflight uses this to detect other live
|
|
406
|
+
// sessions whose dirty files would conflict; an idle interactive session is
|
|
407
|
+
// still live (process alive, dirty files on disk) and must stay visible.
|
|
273
408
|
return [...sessions.values()]
|
|
274
|
-
.filter((session) => session.status
|
|
409
|
+
.filter((session) => isLiveStatus(session.status))
|
|
275
410
|
.map((session) => cloneSession(session));
|
|
276
411
|
}
|
|
277
412
|
|
|
@@ -286,11 +421,51 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
286
421
|
return session ? cloneSession(session) : null;
|
|
287
422
|
}
|
|
288
423
|
|
|
424
|
+
// Peer-discovery primitive: returns sessions sharing the caller's cwd OR
|
|
425
|
+
// worktree, excluding the caller's own session. Only live peers (active/idle)
|
|
426
|
+
// are returned — stale/expired rows are presumed dead and omitted.
|
|
427
|
+
//
|
|
428
|
+
// SECURITY: at least one non-empty locator (cwd or worktree) is REQUIRED. An
|
|
429
|
+
// empty filter would otherwise return every active/idle session, turning the
|
|
430
|
+
// redacted peer surface into a full-registry enumeration (session ids/branches/
|
|
431
|
+
// labels/hashes). With no usable locator we return [] rather than leak.
|
|
432
|
+
//
|
|
433
|
+
// Matching is disjunctive (cwd OR worktreePath): a peer in the same worktree
|
|
434
|
+
// but a different subdirectory shares the worktree locator even though its cwd
|
|
435
|
+
// differs, and a conjunctive (AND) match would wrongly drop it.
|
|
436
|
+
function querySessions(filter = {}) {
|
|
437
|
+
const wantCwd = typeof filter?.cwd === "string" ? filter.cwd : "";
|
|
438
|
+
const wantWorktree =
|
|
439
|
+
typeof filter?.worktree === "string" ? filter.worktree : "";
|
|
440
|
+
const excludeId = normalizeSessionId(filter?.excludeSessionId);
|
|
441
|
+
|
|
442
|
+
// Require at least one non-empty locator — never enumerate the whole registry.
|
|
443
|
+
if (!wantCwd && !wantWorktree) return [];
|
|
444
|
+
|
|
445
|
+
return [...sessions.values()]
|
|
446
|
+
.filter((session) => {
|
|
447
|
+
if (!isLiveStatus(session.status)) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
if (excludeId && session.sessionId === excludeId) return false;
|
|
451
|
+
const cwdMatch = Boolean(wantCwd) && session.cwd === wantCwd;
|
|
452
|
+
const worktreeMatch =
|
|
453
|
+
Boolean(wantWorktree) && session.worktreePath === wantWorktree;
|
|
454
|
+
return cwdMatch || worktreeMatch;
|
|
455
|
+
})
|
|
456
|
+
.map((session) => cloneSession(session));
|
|
457
|
+
}
|
|
458
|
+
|
|
289
459
|
function onStale(callback) {
|
|
290
460
|
if (typeof callback !== "function") return;
|
|
291
461
|
staleCallbacks.add(callback);
|
|
292
462
|
}
|
|
293
463
|
|
|
464
|
+
function onIdle(callback) {
|
|
465
|
+
if (typeof callback !== "function") return;
|
|
466
|
+
idleCallbacks.add(callback);
|
|
467
|
+
}
|
|
468
|
+
|
|
294
469
|
function onRemoved(callback) {
|
|
295
470
|
if (typeof callback !== "function") return;
|
|
296
471
|
removedCallbacks.add(callback);
|
|
@@ -321,7 +496,9 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
321
496
|
getActive,
|
|
322
497
|
getAll,
|
|
323
498
|
getSession,
|
|
499
|
+
querySessions,
|
|
324
500
|
onStale,
|
|
501
|
+
onIdle,
|
|
325
502
|
onRemoved,
|
|
326
503
|
snapshot,
|
|
327
504
|
destroy,
|
|
@@ -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://",
|