@rynx-ai/runtime 0.1.11-beta.19 → 0.1.11-beta.20
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/dist/claude/native-integration.d.ts +2 -0
- package/dist/claude/native-integration.js +7 -0
- package/dist/codex-app-server/forwarder.d.ts +5 -0
- package/dist/codex-app-server/forwarder.js +21 -0
- package/dist/codex-app-server/protocol.d.ts +7 -4
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/host.d.ts +22 -4
- package/dist/host.js +151 -15
- package/dist/runner/child.d.ts +15 -6
- package/dist/runner/child.js +123 -35
- package/dist/runner/manager.d.ts +6 -5
- package/dist/runner/manager.js +32 -14
- package/dist/runner/protocol.d.ts +6 -4
- package/dist/terminal/tmux.d.ts +1 -1
- package/package.json +2 -2
|
@@ -283,6 +283,8 @@ export declare class ClaudeLiveSession {
|
|
|
283
283
|
* sent an Escape — an Escape into idle claude submits an empty turn, which
|
|
284
284
|
* claude answers with a stray "No response requested." bubble. */
|
|
285
285
|
isTurnOpen(): boolean;
|
|
286
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
287
|
+
failOpenTurn(error: Error): boolean;
|
|
286
288
|
private closeTurn;
|
|
287
289
|
private closeTurnError;
|
|
288
290
|
private resetMessageCorrelation;
|
|
@@ -1086,6 +1086,13 @@ export class ClaudeLiveSession {
|
|
|
1086
1086
|
isTurnOpen() {
|
|
1087
1087
|
return this.turnOpen;
|
|
1088
1088
|
}
|
|
1089
|
+
/** Fail an open turn exactly once when its native terminal/runner disappears. */
|
|
1090
|
+
failOpenTurn(error) {
|
|
1091
|
+
if (!this.turnOpen)
|
|
1092
|
+
return false;
|
|
1093
|
+
this.closeTurnError(error);
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1089
1096
|
closeTurn() {
|
|
1090
1097
|
if (!this.turnOpen)
|
|
1091
1098
|
return;
|
|
@@ -120,6 +120,11 @@ export declare class CodexSessionForwarder {
|
|
|
120
120
|
* not doubled.
|
|
121
121
|
*/
|
|
122
122
|
replayBackfill(turns: ResumedTurn[]): void;
|
|
123
|
+
/** Reconcile only the exact active turn after an observer resume. Historical
|
|
124
|
+
* items are intentionally not replayed on an existing-thread reconnect. */
|
|
125
|
+
reconcileActiveTurn(turn: ResumedTurn | undefined): boolean;
|
|
126
|
+
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
127
|
+
failOpenTurn(error: Error): boolean;
|
|
123
128
|
private handle;
|
|
124
129
|
private scheduleCompletion;
|
|
125
130
|
private refreshCompletionGrace;
|
|
@@ -175,6 +175,27 @@ export class CodexSessionForwarder {
|
|
|
175
175
|
this.sink.onTurnEnd(mapped.usage);
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
/** Reconcile only the exact active turn after an observer resume. Historical
|
|
179
|
+
* items are intentionally not replayed on an existing-thread reconnect. */
|
|
180
|
+
reconcileActiveTurn(turn) {
|
|
181
|
+
if (!this.turnOpen || !turn || turn.status === "inProgress")
|
|
182
|
+
return false;
|
|
183
|
+
const resumedTurnId = turn.id ?? turn.turnId;
|
|
184
|
+
if (!this.currentTurnIdValue || resumedTurnId !== this.currentTurnIdValue)
|
|
185
|
+
return false;
|
|
186
|
+
const mapped = mapCodexNotification("turn/completed", { turn });
|
|
187
|
+
this.settle(mapped.fatalError
|
|
188
|
+
? { kind: "error", error: mapped.fatalError }
|
|
189
|
+
: { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
193
|
+
failOpenTurn(error) {
|
|
194
|
+
if (!this.turnOpen)
|
|
195
|
+
return false;
|
|
196
|
+
this.settle({ kind: "error", error });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
178
199
|
handle(method, params) {
|
|
179
200
|
if (method === "thread/started" || method === "thread.started") {
|
|
180
201
|
const tid = threadIdFrom(params);
|
|
@@ -105,11 +105,14 @@ export interface ThreadStartParams {
|
|
|
105
105
|
}
|
|
106
106
|
export interface ThreadResumeParams extends ThreadStartParams {
|
|
107
107
|
threadId: string;
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
* carries `thread.turns[].items[]` — the backfill the forwarder replays for a
|
|
111
|
-
* fresh thread's first turn (reference implementation's `_replay_resume_response`). */
|
|
108
|
+
/** Legacy whole-backlog suppression. New callers should prefer
|
|
109
|
+
* `initialTurnsPage` so recovery can fetch one summarized terminal status. */
|
|
112
110
|
excludeTurns?: boolean;
|
|
111
|
+
initialTurnsPage?: {
|
|
112
|
+
limit?: number | null;
|
|
113
|
+
sortDirection?: "asc" | "desc" | null;
|
|
114
|
+
itemsView?: "notLoaded" | "summary" | "full" | null;
|
|
115
|
+
} | null;
|
|
113
116
|
}
|
|
114
117
|
/** One turn in a resumed thread's backlog (`thread/resume` response). */
|
|
115
118
|
export interface ResumedTurn {
|
|
@@ -28,6 +28,7 @@ async function freeLoopbackPort() {
|
|
|
28
28
|
});
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
31
32
|
export class WsRpcChannel {
|
|
32
33
|
opts;
|
|
33
34
|
child = null;
|
|
@@ -40,7 +41,7 @@ export class WsRpcChannel {
|
|
|
40
41
|
url = "";
|
|
41
42
|
constructor(opts) {
|
|
42
43
|
this.opts = opts;
|
|
43
|
-
this.readyTimeoutMs = opts.readyTimeoutMs ??
|
|
44
|
+
this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
44
45
|
}
|
|
45
46
|
onLine(cb) {
|
|
46
47
|
this.lineCb = cb;
|
|
@@ -114,7 +115,7 @@ export class WsRpcChannel {
|
|
|
114
115
|
// eslint-disable-next-line no-constant-condition
|
|
115
116
|
while (Date.now() < deadline) {
|
|
116
117
|
try {
|
|
117
|
-
return await this.tryConnect(url);
|
|
118
|
+
return await this.tryConnect(url, Math.max(1, deadline - Date.now()));
|
|
118
119
|
}
|
|
119
120
|
catch (error) {
|
|
120
121
|
lastError = error;
|
|
@@ -123,9 +124,9 @@ export class WsRpcChannel {
|
|
|
123
124
|
}
|
|
124
125
|
throw new Error(`codex app-server ws did not become ready at ${url}: ${lastError?.message ?? "timeout"}`);
|
|
125
126
|
}
|
|
126
|
-
tryConnect(url) {
|
|
127
|
+
tryConnect(url, handshakeTimeout) {
|
|
127
128
|
return new Promise((resolve, reject) => {
|
|
128
|
-
const ws = new WebSocket(url);
|
|
129
|
+
const ws = new WebSocket(url, { handshakeTimeout });
|
|
129
130
|
const onOpen = () => {
|
|
130
131
|
ws.off("error", onError);
|
|
131
132
|
resolve(ws);
|
|
@@ -166,7 +167,7 @@ export class ExternalWsChannel {
|
|
|
166
167
|
/** The `ws://IP:PORT` of the already-running app-server to attach to. */
|
|
167
168
|
url, opts = {}) {
|
|
168
169
|
this.url = url;
|
|
169
|
-
this.readyTimeoutMs = opts.readyTimeoutMs ??
|
|
170
|
+
this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
170
171
|
}
|
|
171
172
|
onLine(cb) {
|
|
172
173
|
this.lineCb = cb;
|
|
@@ -178,20 +179,16 @@ export class ExternalWsChannel {
|
|
|
178
179
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
179
180
|
}
|
|
180
181
|
async start() {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
catch (error) {
|
|
189
|
-
lastError = error;
|
|
190
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
191
|
-
}
|
|
182
|
+
// A channel instance is reusable after an observer disconnect. Do not let
|
|
183
|
+
// the prior closed socket make a failed reconnect look successful, and arm
|
|
184
|
+
// close delivery for the newly connected socket.
|
|
185
|
+
this.ws = null;
|
|
186
|
+
this.closedEmitted = false;
|
|
187
|
+
try {
|
|
188
|
+
this.ws = await this.connect(this.url);
|
|
192
189
|
}
|
|
193
|
-
|
|
194
|
-
throw new Error(`could not attach to app-server ws ${this.url}: ${
|
|
190
|
+
catch (error) {
|
|
191
|
+
throw new Error(`could not attach to app-server ws ${this.url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
195
192
|
}
|
|
196
193
|
this.ws.on("message", (data) => this.lineCb?.(data.toString()));
|
|
197
194
|
this.ws.on("close", (code) => this.emitClose(code ?? null, null, null));
|
|
@@ -227,7 +224,10 @@ export class ExternalWsChannel {
|
|
|
227
224
|
}
|
|
228
225
|
connect(url) {
|
|
229
226
|
return new Promise((resolve, reject) => {
|
|
230
|
-
|
|
227
|
+
// The app-server owner has already completed its readiness probe. Use one
|
|
228
|
+
// bounded attach, then reuse this exact connection as the forwarder
|
|
229
|
+
// instead of running a second startup retry stage.
|
|
230
|
+
const ws = new WebSocket(url, { handshakeTimeout: this.readyTimeoutMs });
|
|
231
231
|
const onOpen = () => {
|
|
232
232
|
ws.off("error", onError);
|
|
233
233
|
resolve(ws);
|
package/dist/host.d.ts
CHANGED
|
@@ -125,6 +125,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
125
125
|
private readonly clock;
|
|
126
126
|
private readonly backendIdleTtlMs;
|
|
127
127
|
private readonly forwarderClientFactory;
|
|
128
|
+
private readonly observerReconnectTimeoutMs;
|
|
128
129
|
private readonly backends;
|
|
129
130
|
private readonly sessionId;
|
|
130
131
|
private readonly runtimeHomeSessionId;
|
|
@@ -133,6 +134,10 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
133
134
|
private readonly runtimeHomes;
|
|
134
135
|
private readonly liveSessions;
|
|
135
136
|
private readonly liveClaudeSessions;
|
|
137
|
+
/** Exact pre-live startup failure, retained after the partial handle has been
|
|
138
|
+
* cleaned up so Core/plugin callers receive the failed phase, not a generic
|
|
139
|
+
* `live session unavailable` wrapper. */
|
|
140
|
+
private readonly liveStartupErrors;
|
|
136
141
|
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
137
142
|
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
138
143
|
private readonly pendingClaudeFinalizers;
|
|
@@ -141,7 +146,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
141
146
|
/** Short-lived dedupe for managed fork notifications delivered after the
|
|
142
147
|
* `thread/fork` response. Values are expected source Provider thread ids. */
|
|
143
148
|
private readonly managedForkThreadStarts;
|
|
144
|
-
constructor({ config, commandRunner, sessionStore, allowedRoots, appServerClient, now, backendIdleTtlMs, forwarderClientFactory, sessionId, runtimeHomeSessionId, }: {
|
|
149
|
+
constructor({ config, commandRunner, sessionStore, allowedRoots, appServerClient, now, backendIdleTtlMs, forwarderClientFactory, observerReconnectTimeoutMs, sessionId, runtimeHomeSessionId, }: {
|
|
145
150
|
config: AppConfig;
|
|
146
151
|
commandRunner?: CodexCommandRunner;
|
|
147
152
|
sessionStore?: CodexSessionStore;
|
|
@@ -153,6 +158,8 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
153
158
|
backendIdleTtlMs?: number;
|
|
154
159
|
/** Override the live-session forwarder connection factory (tests inject a fake). */
|
|
155
160
|
forwarderClientFactory?: (appServerUrl: string) => CodexAppServerClient;
|
|
161
|
+
/** Grace window for observer reconnect before an active response is failed. */
|
|
162
|
+
observerReconnectTimeoutMs?: number;
|
|
156
163
|
/** The rynx session (localThreadId) this host serves — a per-session runner
|
|
157
164
|
* child sets it from `RYNX_RUNNER_SESSION` so the private CODEX_HOME is
|
|
158
165
|
* session-scoped. The shared `__cap__` child / tests fall back to a sentinel
|
|
@@ -218,11 +225,16 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
218
225
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
219
226
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
220
227
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
221
|
-
* thread active, then retry
|
|
222
|
-
*
|
|
223
|
-
* succeeds, subsequent turns arrive
|
|
228
|
+
* thread active, then retry. Resume only fetches the newest summarized Turn:
|
|
229
|
+
* recovery reconciles the exact active Turn's terminal status and never replays
|
|
230
|
+
* historical items. Once resume succeeds, subsequent turns arrive live.
|
|
224
231
|
*/
|
|
225
232
|
private subscribeUntilReady;
|
|
233
|
+
/** Restore the independent observer after an unexpected exit. The active turn
|
|
234
|
+
* remains open during the bounded grace so resume can reconcile its exact id. */
|
|
235
|
+
private reconnectForwarder;
|
|
236
|
+
private armObserverReconnectDeadline;
|
|
237
|
+
private clearObserverReconnectDeadline;
|
|
226
238
|
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
227
239
|
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
228
240
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
@@ -233,6 +245,10 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
233
245
|
waitTerminalReady(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
234
246
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
235
247
|
liveSessionError(localThreadId: string): string | undefined;
|
|
248
|
+
/** Publish the background TUI/thread discovery failure so an executor
|
|
249
|
+
* already waiting in the 60s bridge window exits immediately with the exact
|
|
250
|
+
* 30s discovery cause. */
|
|
251
|
+
failLiveStartup(localThreadId: string, error: Error): boolean;
|
|
236
252
|
/**
|
|
237
253
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
238
254
|
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
@@ -292,6 +308,8 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
292
308
|
* terminal registry — the host does not hold tmux). Ignored for codex sessions
|
|
293
309
|
* (they inject via the app-server). Idempotent. */
|
|
294
310
|
attachTerminalInjector(localThreadId: string, injector: TerminalInjector): void;
|
|
311
|
+
/** Close an active native response when its terminal or runner disappears. */
|
|
312
|
+
failLiveSession(localThreadId: string, error: Error): boolean;
|
|
295
313
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
296
314
|
listModels(runtime?: AgentRuntimeId): Promise<ModelListResponse | null>;
|
|
297
315
|
/**
|
package/dist/host.js
CHANGED
|
@@ -182,6 +182,8 @@ export class SpawnCodexCommandRunner {
|
|
|
182
182
|
* budget (set proactively); claude has no programmatic compaction, so this
|
|
183
183
|
* signal is the lever consumers surface to the user. */
|
|
184
184
|
const CONTEXT_WARN_RATIO = 0.8;
|
|
185
|
+
/** Wait for native bridge/thread binding before injection gives up. */
|
|
186
|
+
const CODEX_BRIDGE_READY_TIMEOUT_MS = 60_000;
|
|
185
187
|
function providerPluginSkillName(namespace, name) {
|
|
186
188
|
const candidate = `${namespace}-${name}`;
|
|
187
189
|
if (candidate.length <= 128)
|
|
@@ -245,6 +247,7 @@ export class LocalAgentHost {
|
|
|
245
247
|
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
246
248
|
// the backend's app-server; tests inject a fake.
|
|
247
249
|
forwarderClientFactory;
|
|
250
|
+
observerReconnectTimeoutMs;
|
|
248
251
|
// Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
|
|
249
252
|
// a `${runtime}::${retryHash}` composite for a per-agent retry budget.
|
|
250
253
|
backends = new Map();
|
|
@@ -264,6 +267,10 @@ export class LocalAgentHost {
|
|
|
264
267
|
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
265
268
|
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
266
269
|
liveClaudeSessions = new Map();
|
|
270
|
+
/** Exact pre-live startup failure, retained after the partial handle has been
|
|
271
|
+
* cleaned up so Core/plugin callers receive the failed phase, not a generic
|
|
272
|
+
* `live session unavailable` wrapper. */
|
|
273
|
+
liveStartupErrors = new Map();
|
|
267
274
|
/** Claude forwarders stopped before their terminal is killed. Runner shutdown
|
|
268
275
|
* finalizes these synchronously afterwards to scrub raw interaction answers. */
|
|
269
276
|
pendingClaudeFinalizers = new Set();
|
|
@@ -274,7 +281,7 @@ export class LocalAgentHost {
|
|
|
274
281
|
/** Short-lived dedupe for managed fork notifications delivered after the
|
|
275
282
|
* `thread/fork` response. Values are expected source Provider thread ids. */
|
|
276
283
|
managedForkThreadStarts = new Map();
|
|
277
|
-
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, sessionId, runtimeHomeSessionId, }) {
|
|
284
|
+
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, observerReconnectTimeoutMs = 15_000, sessionId, runtimeHomeSessionId, }) {
|
|
278
285
|
this.config = config;
|
|
279
286
|
this.sessionId = sessionId ?? "__default__";
|
|
280
287
|
this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
|
|
@@ -285,6 +292,7 @@ export class LocalAgentHost {
|
|
|
285
292
|
this.injectedAppServerClient = appServerClient;
|
|
286
293
|
this.clock = now;
|
|
287
294
|
this.backendIdleTtlMs = backendIdleTtlMs;
|
|
295
|
+
this.observerReconnectTimeoutMs = observerReconnectTimeoutMs;
|
|
288
296
|
this.forwarderClientFactory =
|
|
289
297
|
forwarderClientFactory ??
|
|
290
298
|
((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
|
|
@@ -564,6 +572,7 @@ export class LocalAgentHost {
|
|
|
564
572
|
}
|
|
565
573
|
}
|
|
566
574
|
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
575
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
567
576
|
// cancel-before-recreate (reference implementation Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
568
577
|
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
569
578
|
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
@@ -587,6 +596,7 @@ export class LocalAgentHost {
|
|
|
587
596
|
snapshotSkills = await this.prepareExecutionSkills(execution);
|
|
588
597
|
}
|
|
589
598
|
catch (err) {
|
|
599
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill preparation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
590
600
|
console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
591
601
|
return false;
|
|
592
602
|
}
|
|
@@ -599,6 +609,7 @@ export class LocalAgentHost {
|
|
|
599
609
|
}
|
|
600
610
|
catch (err) {
|
|
601
611
|
void snapshotSkills.skillsCleanup();
|
|
612
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
602
613
|
console.error(`[session-snapshot] session=${localThreadId} skill persistence failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
603
614
|
return false;
|
|
604
615
|
}
|
|
@@ -618,6 +629,7 @@ export class LocalAgentHost {
|
|
|
618
629
|
};
|
|
619
630
|
const injectClient = this.getBackend(runtime, execution.budget ?? undefined).appServerClient;
|
|
620
631
|
if (!injectClient) {
|
|
632
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server is unavailable for this Session`);
|
|
621
633
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} no app-server client`);
|
|
622
634
|
return abandon();
|
|
623
635
|
}
|
|
@@ -625,11 +637,13 @@ export class LocalAgentHost {
|
|
|
625
637
|
await injectClient.ensureInitialized();
|
|
626
638
|
}
|
|
627
639
|
catch (err) {
|
|
640
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server initialization failed during its 10s readiness phase: ${err instanceof Error ? err.message : String(err)}`);
|
|
628
641
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
629
642
|
return abandon();
|
|
630
643
|
}
|
|
631
644
|
const appServerUrl = injectClient.terminalRemoteUrl();
|
|
632
645
|
if (!appServerUrl) {
|
|
646
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} app-server started without a Terminal remote endpoint`);
|
|
633
647
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} app-server has no remote url`);
|
|
634
648
|
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
635
649
|
}
|
|
@@ -641,6 +655,7 @@ export class LocalAgentHost {
|
|
|
641
655
|
await forwarderClient.ensureInitialized();
|
|
642
656
|
}
|
|
643
657
|
catch (err) {
|
|
658
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} observer could not attach to the ready app-server: ${err instanceof Error ? err.message : String(err)}`);
|
|
644
659
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} forwarder init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
645
660
|
return abandon();
|
|
646
661
|
}
|
|
@@ -650,6 +665,10 @@ export class LocalAgentHost {
|
|
|
650
665
|
const ready = new Promise((resolve) => {
|
|
651
666
|
markReady = resolve;
|
|
652
667
|
});
|
|
668
|
+
let markStartupFailed;
|
|
669
|
+
const startupFailed = new Promise((resolve) => {
|
|
670
|
+
markStartupFailed = resolve;
|
|
671
|
+
});
|
|
653
672
|
let markTerminalReady;
|
|
654
673
|
const terminalReady = new Promise((resolve) => {
|
|
655
674
|
markTerminalReady = resolve;
|
|
@@ -669,6 +688,8 @@ export class LocalAgentHost {
|
|
|
669
688
|
threadId: record?.codexSessionId ?? null,
|
|
670
689
|
ready,
|
|
671
690
|
markReady,
|
|
691
|
+
startupFailed,
|
|
692
|
+
markStartupFailed,
|
|
672
693
|
terminalReady,
|
|
673
694
|
markTerminalReady,
|
|
674
695
|
injectLock: Promise.resolve(),
|
|
@@ -677,6 +698,7 @@ export class LocalAgentHost {
|
|
|
677
698
|
subscribing: false,
|
|
678
699
|
rotationPending: false,
|
|
679
700
|
stopped: false,
|
|
701
|
+
observerAvailable: true,
|
|
680
702
|
skillsCleanup: snapshotSkills.skillsCleanup,
|
|
681
703
|
interactionOwners: new Map(),
|
|
682
704
|
interactionStandbys: new Map(),
|
|
@@ -906,10 +928,19 @@ export class LocalAgentHost {
|
|
|
906
928
|
});
|
|
907
929
|
client.setConnectionListener((state) => {
|
|
908
930
|
if (state === "connected") {
|
|
909
|
-
|
|
931
|
+
// Observer transport recovery alone does not prove that an in-flight
|
|
932
|
+
// interaction request was replayed onto this connection. Its request
|
|
933
|
+
// listener clears disconnectedClients when that duplicate arrives.
|
|
934
|
+
if (client !== live.forwarderClient)
|
|
935
|
+
live.disconnectedClients.delete(client);
|
|
910
936
|
return;
|
|
911
937
|
}
|
|
912
938
|
live.disconnectedClients.add(client);
|
|
939
|
+
if (client === live.forwarderClient && !live.stopped) {
|
|
940
|
+
live.observerAvailable = false;
|
|
941
|
+
this.armObserverReconnectDeadline(live);
|
|
942
|
+
void this.reconnectForwarder(live);
|
|
943
|
+
}
|
|
913
944
|
for (const [interactionId, recovery] of [...live.interactionRecoveries]) {
|
|
914
945
|
if (allInteractionClientsUnavailable(interactionId)) {
|
|
915
946
|
finishRecovery(interactionId, recovery.event);
|
|
@@ -1086,7 +1117,15 @@ export class LocalAgentHost {
|
|
|
1086
1117
|
threadId: record.codexSessionId,
|
|
1087
1118
|
...threadWorkspaceParams(runtime, workspace, sandbox),
|
|
1088
1119
|
approvalPolicy,
|
|
1120
|
+
// The injection connection only needs to bind/subcribe. Codex
|
|
1121
|
+
// 0.146 can ignore initialTurnsPage and return the whole rollout,
|
|
1122
|
+
// so explicitly suppress history here; it is never replayed.
|
|
1089
1123
|
excludeTurns: true,
|
|
1124
|
+
initialTurnsPage: {
|
|
1125
|
+
limit: 1,
|
|
1126
|
+
sortDirection: "desc",
|
|
1127
|
+
itemsView: "summary",
|
|
1128
|
+
},
|
|
1090
1129
|
});
|
|
1091
1130
|
resumedThreadId = resumed.threadId;
|
|
1092
1131
|
break;
|
|
@@ -1134,9 +1173,11 @@ export class LocalAgentHost {
|
|
|
1134
1173
|
live.stopped = true;
|
|
1135
1174
|
forwarder.stop();
|
|
1136
1175
|
void forwarderClient.stop().catch(() => undefined);
|
|
1176
|
+
this.liveStartupErrors.set(localThreadId, `${runtime === "traex" ? "Traex" : "Codex"} native thread binding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1137
1177
|
console.error(`[codex-live] session=${localThreadId} runtime=${runtime} thread bind failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1138
1178
|
return abandon();
|
|
1139
1179
|
}
|
|
1180
|
+
this.liveStartupErrors.delete(localThreadId);
|
|
1140
1181
|
return true;
|
|
1141
1182
|
}
|
|
1142
1183
|
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
@@ -1196,30 +1237,37 @@ export class LocalAgentHost {
|
|
|
1196
1237
|
* Subscribe the forwarder connection to a thread (reference implementation's
|
|
1197
1238
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1198
1239
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1199
|
-
* thread active, then retry
|
|
1200
|
-
*
|
|
1201
|
-
* succeeds, subsequent turns arrive
|
|
1240
|
+
* thread active, then retry. Resume only fetches the newest summarized Turn:
|
|
1241
|
+
* recovery reconciles the exact active Turn's terminal status and never replays
|
|
1242
|
+
* historical items. Once resume succeeds, subsequent turns arrive live.
|
|
1202
1243
|
*/
|
|
1203
1244
|
async subscribeUntilReady(live, threadId) {
|
|
1204
|
-
let sawNotReady = false;
|
|
1205
1245
|
while (!live.stopped) {
|
|
1206
1246
|
try {
|
|
1207
1247
|
const resp = await live.forwarderClient.threadResume({
|
|
1208
1248
|
threadId,
|
|
1209
1249
|
...threadWorkspaceParams(live.runtime, live.workspace, live.sandbox),
|
|
1210
1250
|
approvalPolicy: live.approvalPolicy,
|
|
1211
|
-
|
|
1251
|
+
initialTurnsPage: {
|
|
1252
|
+
limit: 1,
|
|
1253
|
+
sortDirection: "desc",
|
|
1254
|
+
itemsView: "summary",
|
|
1255
|
+
},
|
|
1212
1256
|
});
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1257
|
+
// Codex 0.146.1 accepts initialTurnsPage but can still return the entire
|
|
1258
|
+
// rollout in chronological order. Never infer newest from array position:
|
|
1259
|
+
// recovery may settle only the exact active turn id already owned here.
|
|
1260
|
+
const activeTurnId = live.forwarder.currentTurnId();
|
|
1261
|
+
const activeTurn = activeTurnId && Array.isArray(resp.thread.turns)
|
|
1262
|
+
? resp.thread.turns.find((turn) => (turn.id ?? turn.turnId) === activeTurnId)
|
|
1263
|
+
: undefined;
|
|
1264
|
+
live.forwarder.reconcileActiveTurn(activeTurn);
|
|
1216
1265
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1217
1266
|
}
|
|
1218
1267
|
catch (error) {
|
|
1219
1268
|
if (!isThreadNotReadyError(error)) {
|
|
1220
1269
|
return false; // other failure — injection still works via the backend client
|
|
1221
1270
|
}
|
|
1222
|
-
sawNotReady = true;
|
|
1223
1271
|
// Park until the thread goes active (its first turn materializes the
|
|
1224
1272
|
// rollout); a short poll covers the flush race after "active".
|
|
1225
1273
|
await new Promise((resolve) => {
|
|
@@ -1231,6 +1279,54 @@ export class LocalAgentHost {
|
|
|
1231
1279
|
}
|
|
1232
1280
|
return false;
|
|
1233
1281
|
}
|
|
1282
|
+
/** Restore the independent observer after an unexpected exit. The active turn
|
|
1283
|
+
* remains open during the bounded grace so resume can reconcile its exact id. */
|
|
1284
|
+
reconnectForwarder(live) {
|
|
1285
|
+
if (live.observerReconnect)
|
|
1286
|
+
return live.observerReconnect;
|
|
1287
|
+
const reconnect = (async () => {
|
|
1288
|
+
while (!live.stopped && live.threadId) {
|
|
1289
|
+
try {
|
|
1290
|
+
await live.forwarderClient.ensureInitialized();
|
|
1291
|
+
if (await this.subscribeUntilReady(live, live.threadId)) {
|
|
1292
|
+
live.observerAvailable = true;
|
|
1293
|
+
this.clearObserverReconnectDeadline(live);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
catch {
|
|
1298
|
+
// Retry below. Injection uses its independent client and remains usable.
|
|
1299
|
+
}
|
|
1300
|
+
await new Promise((resolve) => {
|
|
1301
|
+
const timer = setTimeout(resolve, 250);
|
|
1302
|
+
timer.unref?.();
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
})();
|
|
1306
|
+
const settled = reconnect.finally(() => {
|
|
1307
|
+
if (live.observerReconnect === settled)
|
|
1308
|
+
live.observerReconnect = undefined;
|
|
1309
|
+
});
|
|
1310
|
+
live.observerReconnect = settled;
|
|
1311
|
+
return settled;
|
|
1312
|
+
}
|
|
1313
|
+
armObserverReconnectDeadline(live) {
|
|
1314
|
+
if (live.observerReconnectTimer || !live.forwarder.isTurnOpen())
|
|
1315
|
+
return;
|
|
1316
|
+
live.observerReconnectTimer = setTimeout(() => {
|
|
1317
|
+
live.observerReconnectTimer = undefined;
|
|
1318
|
+
if (live.stopped)
|
|
1319
|
+
return;
|
|
1320
|
+
const provider = live.runtime === "traex" ? "Traex" : "Codex";
|
|
1321
|
+
live.forwarder.failOpenTurn(new Error(`${provider} observer did not reconnect within ${this.observerReconnectTimeoutMs}ms`));
|
|
1322
|
+
}, this.observerReconnectTimeoutMs);
|
|
1323
|
+
live.observerReconnectTimer.unref?.();
|
|
1324
|
+
}
|
|
1325
|
+
clearObserverReconnectDeadline(live) {
|
|
1326
|
+
if (live.observerReconnectTimer)
|
|
1327
|
+
clearTimeout(live.observerReconnectTimer);
|
|
1328
|
+
live.observerReconnectTimer = undefined;
|
|
1329
|
+
}
|
|
1234
1330
|
/** Await a live session's thread binding. `null` leaves the deadline to the
|
|
1235
1331
|
* caller; a number keeps the Provider-local bound. Returns false on timeout /
|
|
1236
1332
|
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
@@ -1241,13 +1337,15 @@ export class LocalAgentHost {
|
|
|
1241
1337
|
const live = this.liveSessions.get(localThreadId);
|
|
1242
1338
|
if (!live)
|
|
1243
1339
|
return false;
|
|
1340
|
+
const ready = live.ready.then(() => true);
|
|
1341
|
+
const failed = live.startupFailed.then(() => false);
|
|
1244
1342
|
if (timeoutMs === null)
|
|
1245
|
-
return
|
|
1343
|
+
return Promise.race([ready, failed]);
|
|
1246
1344
|
let timer;
|
|
1247
1345
|
const timeout = new Promise((resolve) => {
|
|
1248
1346
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1249
1347
|
});
|
|
1250
|
-
const ok = await Promise.race([
|
|
1348
|
+
const ok = await Promise.race([ready, failed, timeout]);
|
|
1251
1349
|
if (timer)
|
|
1252
1350
|
clearTimeout(timer);
|
|
1253
1351
|
return ok;
|
|
@@ -1277,7 +1375,21 @@ export class LocalAgentHost {
|
|
|
1277
1375
|
}
|
|
1278
1376
|
/** Diagnostic from the provider adapter when native discovery/resume failed. */
|
|
1279
1377
|
liveSessionError(localThreadId) {
|
|
1280
|
-
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1378
|
+
return this.liveClaudeSessions.get(localThreadId)?.error
|
|
1379
|
+
?? this.liveSessions.get(localThreadId)?.startupError
|
|
1380
|
+
?? this.liveStartupErrors.get(localThreadId);
|
|
1381
|
+
}
|
|
1382
|
+
/** Publish the background TUI/thread discovery failure so an executor
|
|
1383
|
+
* already waiting in the 60s bridge window exits immediately with the exact
|
|
1384
|
+
* 30s discovery cause. */
|
|
1385
|
+
failLiveStartup(localThreadId, error) {
|
|
1386
|
+
const live = this.liveSessions.get(localThreadId);
|
|
1387
|
+
if (!live || live.threadId || live.stopped)
|
|
1388
|
+
return false;
|
|
1389
|
+
live.startupError = error.message;
|
|
1390
|
+
this.liveStartupErrors.set(localThreadId, error.message);
|
|
1391
|
+
live.markStartupFailed();
|
|
1392
|
+
return true;
|
|
1281
1393
|
}
|
|
1282
1394
|
/**
|
|
1283
1395
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -1338,7 +1450,7 @@ export class LocalAgentHost {
|
|
|
1338
1450
|
return "failed";
|
|
1339
1451
|
// Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
|
|
1340
1452
|
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
1341
|
-
const bound = await this.waitLiveReady(localThreadId,
|
|
1453
|
+
const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
|
|
1342
1454
|
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
1343
1455
|
if (!bound || !threadId)
|
|
1344
1456
|
return "notReady";
|
|
@@ -1400,6 +1512,9 @@ export class LocalAgentHost {
|
|
|
1400
1512
|
pendingInput.state = "optimistic";
|
|
1401
1513
|
}
|
|
1402
1514
|
live.forwarder.noteTurnAccepted(steered.turnId);
|
|
1515
|
+
if (!live.observerAvailable) {
|
|
1516
|
+
this.armObserverReconnectDeadline(live);
|
|
1517
|
+
}
|
|
1403
1518
|
return "injected";
|
|
1404
1519
|
}
|
|
1405
1520
|
}
|
|
@@ -1428,6 +1543,9 @@ export class LocalAgentHost {
|
|
|
1428
1543
|
// Do not wait for the independent observer connection's `turn/started`:
|
|
1429
1544
|
// a second message accepted in that window must steer, not double-start.
|
|
1430
1545
|
live.forwarder.noteTurnAccepted(started.turnId);
|
|
1546
|
+
if (!live.observerAvailable) {
|
|
1547
|
+
this.armObserverReconnectDeadline(live);
|
|
1548
|
+
}
|
|
1431
1549
|
return "injected";
|
|
1432
1550
|
}
|
|
1433
1551
|
catch (error) {
|
|
@@ -1535,6 +1653,11 @@ export class LocalAgentHost {
|
|
|
1535
1653
|
return;
|
|
1536
1654
|
this.liveSessions.delete(localThreadId);
|
|
1537
1655
|
live.stopped = true;
|
|
1656
|
+
if (!live.threadId) {
|
|
1657
|
+
live.startupError ??= "native Session stopped before thread discovery completed";
|
|
1658
|
+
live.markStartupFailed();
|
|
1659
|
+
}
|
|
1660
|
+
this.clearObserverReconnectDeadline(live);
|
|
1538
1661
|
live.releaseActive?.();
|
|
1539
1662
|
live.injectClient.cancelInteractions("session_stopped");
|
|
1540
1663
|
live.forwarderClient.cancelInteractions("session_stopped");
|
|
@@ -2076,6 +2199,19 @@ export class LocalAgentHost {
|
|
|
2076
2199
|
if (live)
|
|
2077
2200
|
live.injector = injector;
|
|
2078
2201
|
}
|
|
2202
|
+
/** Close an active native response when its terminal or runner disappears. */
|
|
2203
|
+
failLiveSession(localThreadId, error) {
|
|
2204
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
2205
|
+
if (claude)
|
|
2206
|
+
return claude.forwarder.failOpenTurn(error);
|
|
2207
|
+
const codex = this.liveSessions.get(localThreadId);
|
|
2208
|
+
if (!codex)
|
|
2209
|
+
return false;
|
|
2210
|
+
const failed = codex.forwarder.failOpenTurn(error);
|
|
2211
|
+
if (failed)
|
|
2212
|
+
this.clearObserverReconnectDeadline(codex);
|
|
2213
|
+
return failed;
|
|
2214
|
+
}
|
|
2079
2215
|
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
2080
2216
|
async listModels(runtime) {
|
|
2081
2217
|
const resolved = runtime ?? this.defaultRuntime;
|
package/dist/runner/child.d.ts
CHANGED
|
@@ -38,11 +38,13 @@ interface LiveCodexProvider {
|
|
|
38
38
|
execution: ResolvedExecutionSnapshot;
|
|
39
39
|
retargetMirror?: RetargetMirror;
|
|
40
40
|
}): Promise<boolean>;
|
|
41
|
-
/** `null` delegates the
|
|
41
|
+
/** `null` delegates the deadline to RunnerManager. */
|
|
42
42
|
waitLiveReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
43
|
-
/** `null` delegates the
|
|
43
|
+
/** `null` delegates the deadline to RunnerManager. */
|
|
44
44
|
waitTerminalReady?(localThreadId: string, timeoutMs?: number | null): Promise<boolean>;
|
|
45
45
|
liveSessionError?(localThreadId: string): string | undefined;
|
|
46
|
+
failLiveStartup?(localThreadId: string, error: Error): boolean;
|
|
47
|
+
failLiveSession?(localThreadId: string, error: Error): boolean;
|
|
46
48
|
injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
|
|
47
49
|
interruptLive?(localThreadId: string): Promise<boolean>;
|
|
48
50
|
stopLiveCodexSession?(localThreadId: string, opts?: {
|
|
@@ -76,6 +78,7 @@ export declare class RunnerSession {
|
|
|
76
78
|
private readonly attachments;
|
|
77
79
|
private readonly attachmentThreadIds;
|
|
78
80
|
private readonly traexStartupWatchers;
|
|
81
|
+
private readonly terminalWatchers;
|
|
79
82
|
/** Opens are async; a close received before attach resolves tombstones the id. */
|
|
80
83
|
private readonly pendingTerminalOpens;
|
|
81
84
|
private readonly cancelledTerminalOpens;
|
|
@@ -94,18 +97,24 @@ export declare class RunnerSession {
|
|
|
94
97
|
private mirrorChannel;
|
|
95
98
|
/**
|
|
96
99
|
* Eagerly bring up a session's codex-native live view: start the persistent
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* so the TUI is usable immediately and its turns mirror to chat.
|
|
100
|
+
* observer connection before launching the detached Codex TUI. Fresh-session
|
|
101
|
+
* thread discovery then continues in the background while the first
|
|
102
|
+
* injection waits for the same bridge state.
|
|
101
103
|
*/
|
|
102
104
|
private ensureLive;
|
|
105
|
+
private monitorCodexThreadStartup;
|
|
106
|
+
/** Discovery failure is terminal for this partial native launch. Publish the
|
|
107
|
+
* exact cause first so an in-flight injection wakes, then drop the observer
|
|
108
|
+
* and TUI so a later task retry creates a clean launch instead of reusing an
|
|
109
|
+
* already-rejected readiness promise. */
|
|
110
|
+
private failCodexThreadStartup;
|
|
103
111
|
private inject;
|
|
104
112
|
private interruptLive;
|
|
105
113
|
/** Launch (idempotently) the session's codex TUI pane from the executor's
|
|
106
114
|
* `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
|
|
107
115
|
* so the web attach reuses the same detached pane. */
|
|
108
116
|
private launchCodexPane;
|
|
117
|
+
private watchNativeTerminal;
|
|
109
118
|
private cancelTraexStartupWatcher;
|
|
110
119
|
private skipTraexStartupPrompts;
|
|
111
120
|
private stopLive;
|
package/dist/runner/child.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { TerminalRegistry } from "../terminal/registry.js";
|
|
2
2
|
import { toWireError } from "./protocol.js";
|
|
3
3
|
import { isManagedNativeProvider } from "./startup-policy.js";
|
|
4
|
+
/** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
|
|
5
|
+
const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
|
|
4
6
|
const TRAEX_STARTUP_WATCH_MS = 20_000;
|
|
5
7
|
const TRAEX_STARTUP_POLL_MS = 100;
|
|
6
8
|
const TRAEX_AUTHORIZATION_POLL_MS = 500;
|
|
@@ -30,6 +32,7 @@ export class RunnerSession {
|
|
|
30
32
|
attachments = new Map();
|
|
31
33
|
attachmentThreadIds = new Map();
|
|
32
34
|
traexStartupWatchers = new Map();
|
|
35
|
+
terminalWatchers = new Map();
|
|
33
36
|
/** Opens are async; a close received before attach resolves tombstones the id. */
|
|
34
37
|
pendingTerminalOpens = new Set();
|
|
35
38
|
cancelledTerminalOpens = new Set();
|
|
@@ -157,10 +160,9 @@ export class RunnerSession {
|
|
|
157
160
|
}
|
|
158
161
|
/**
|
|
159
162
|
* Eagerly bring up a session's codex-native live view: start the persistent
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* so the TUI is usable immediately and its turns mirror to chat.
|
|
163
|
+
* observer connection before launching the detached Codex TUI. Fresh-session
|
|
164
|
+
* thread discovery then continues in the background while the first
|
|
165
|
+
* injection waits for the same bridge state.
|
|
164
166
|
*/
|
|
165
167
|
async ensureLive(msg) {
|
|
166
168
|
const provider = this.liveProvider;
|
|
@@ -177,58 +179,59 @@ export class RunnerSession {
|
|
|
177
179
|
reqId: msg.reqId,
|
|
178
180
|
localThreadId: msg.localThreadId,
|
|
179
181
|
ok: false,
|
|
180
|
-
error:
|
|
182
|
+
error: provider.liveSessionError?.(msg.localThreadId)
|
|
183
|
+
?? "live provider did not start",
|
|
181
184
|
});
|
|
182
185
|
return;
|
|
183
186
|
}
|
|
184
187
|
this.liveIds.add(msg.localThreadId);
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
// Codex-lineage startup is phase-bounded: app-server readiness is
|
|
189
|
+
// handled by its channel, then the already-connected
|
|
190
|
+
// observer gets a full window for the TUI's thread/started event.
|
|
191
|
+
// Claude retains the parent-owned SessionStart deadline.
|
|
192
|
+
const runtime = msg.execution?.provider;
|
|
193
|
+
// Do not add another provider-local deadline around resume/preload. The
|
|
194
|
+
// parent control request remains bounded for process safety; fresh-thread
|
|
195
|
+
// discovery owns the separate 30s phase below.
|
|
196
|
+
const terminalReadyTimeoutMs = isManagedNativeProvider(runtime) ? null : undefined;
|
|
192
197
|
// Launch the TUI attached to the already-bound thread. Re-launch when the
|
|
193
198
|
// pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
|
|
194
199
|
// reconnect after the TUI exited restarts it instead of skipping (a
|
|
195
200
|
// launched-once guard would leave a dead "Pane is dead" husk forever).
|
|
196
201
|
if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
|
|
197
202
|
const terminalReady = provider.waitTerminalReady
|
|
198
|
-
? await provider.waitTerminalReady(msg.localThreadId,
|
|
203
|
+
? await provider.waitTerminalReady(msg.localThreadId, terminalReadyTimeoutMs)
|
|
199
204
|
: true;
|
|
200
205
|
if (!terminalReady) {
|
|
206
|
+
const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
|
|
201
207
|
this.transport.send({
|
|
202
208
|
t: "live.ready",
|
|
203
209
|
reqId: msg.reqId,
|
|
204
210
|
localThreadId: msg.localThreadId,
|
|
205
211
|
ok: false,
|
|
206
|
-
error:
|
|
212
|
+
error: runtime === "codex" || runtime === "traex"
|
|
213
|
+
? provider.liveSessionError?.(msg.localThreadId)
|
|
214
|
+
?? `${providerName} app-server was ready, but its existing thread could not be resumed for the Terminal`
|
|
215
|
+
: "Provider thread was not ready for Terminal resume",
|
|
207
216
|
});
|
|
208
217
|
return;
|
|
209
218
|
}
|
|
210
219
|
await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
|
|
211
220
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.slice(-1_000)
|
|
227
|
-
: "";
|
|
228
|
-
const readinessError = provider.liveSessionError?.(msg.localThreadId)
|
|
229
|
-
?? (paneFailure
|
|
230
|
-
? `Provider terminal exited before session discovery: ${paneFailure}`
|
|
231
|
-
: "live session was not ready before timeout");
|
|
221
|
+
let ready = true;
|
|
222
|
+
if (runtime === "codex" || runtime === "traex") {
|
|
223
|
+
// Discover thread/started in the background for 30s while injection
|
|
224
|
+
// waits up to 60s for either bridge readiness or that startup error.
|
|
225
|
+
// Do not serialize the two waits here.
|
|
226
|
+
this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
|
|
227
|
+
}
|
|
228
|
+
else if (msg.waitForReady !== false && provider.waitLiveReady) {
|
|
229
|
+
ready = await provider.waitLiveReady(msg.localThreadId, terminalReadyTimeoutMs);
|
|
230
|
+
}
|
|
231
|
+
const readinessError = ready
|
|
232
|
+
? undefined
|
|
233
|
+
: provider.liveSessionError?.(msg.localThreadId)
|
|
234
|
+
?? "live session was not ready before timeout";
|
|
232
235
|
this.transport.send({
|
|
233
236
|
t: "live.ready",
|
|
234
237
|
reqId: msg.reqId,
|
|
@@ -247,6 +250,53 @@ export class RunnerSession {
|
|
|
247
250
|
});
|
|
248
251
|
}
|
|
249
252
|
}
|
|
253
|
+
monitorCodexThreadStartup(localThreadId, runtime, provider) {
|
|
254
|
+
if (!provider.waitLiveReady)
|
|
255
|
+
return;
|
|
256
|
+
void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
|
|
257
|
+
if (ready)
|
|
258
|
+
return;
|
|
259
|
+
const terminal = this.terminals.get(`${localThreadId}-main`);
|
|
260
|
+
const paneFailure = terminal && !terminal.isAlive()
|
|
261
|
+
? terminal
|
|
262
|
+
.capturePane()
|
|
263
|
+
.split("\n")
|
|
264
|
+
.map((line) => line.trim())
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.slice(-6)
|
|
267
|
+
.join(" ")
|
|
268
|
+
.slice(-1_000)
|
|
269
|
+
: "";
|
|
270
|
+
const detail = provider.liveSessionError?.(localThreadId)
|
|
271
|
+
?? (paneFailure
|
|
272
|
+
? `Provider terminal exited before session discovery: ${paneFailure}`
|
|
273
|
+
: `${runtime === "traex" ? "Traex" : "Codex"} TUI did not publish thread/started within ${CODEX_THREAD_START_TIMEOUT_MS / 1_000}s after app-server and observer readiness`);
|
|
274
|
+
this.failCodexThreadStartup(localThreadId, provider, new Error(detail));
|
|
275
|
+
}).catch((error) => {
|
|
276
|
+
this.failCodexThreadStartup(localThreadId, provider, error instanceof Error ? error : new Error(String(error)));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/** Discovery failure is terminal for this partial native launch. Publish the
|
|
280
|
+
* exact cause first so an in-flight injection wakes, then drop the observer
|
|
281
|
+
* and TUI so a later task retry creates a clean launch instead of reusing an
|
|
282
|
+
* already-rejected readiness promise. */
|
|
283
|
+
failCodexThreadStartup(localThreadId, provider, error) {
|
|
284
|
+
if (!provider.failLiveStartup?.(localThreadId, error))
|
|
285
|
+
return;
|
|
286
|
+
const watcher = this.terminalWatchers.get(localThreadId);
|
|
287
|
+
if (watcher)
|
|
288
|
+
clearInterval(watcher);
|
|
289
|
+
this.terminalWatchers.delete(localThreadId);
|
|
290
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
291
|
+
try {
|
|
292
|
+
this.terminals.close(`${localThreadId}-main`);
|
|
293
|
+
}
|
|
294
|
+
catch (closeError) {
|
|
295
|
+
console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
296
|
+
}
|
|
297
|
+
provider.stopLiveCodexSession?.(localThreadId);
|
|
298
|
+
this.liveIds.delete(localThreadId);
|
|
299
|
+
}
|
|
250
300
|
async inject(msg) {
|
|
251
301
|
const provider = this.liveProvider;
|
|
252
302
|
try {
|
|
@@ -254,7 +304,16 @@ export class RunnerSession {
|
|
|
254
304
|
const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
|
|
255
305
|
// App-server injection is independent of the Terminal TUI startup, so it
|
|
256
306
|
// must not cancel prompt handling for the pane that is still starting.
|
|
257
|
-
|
|
307
|
+
const error = outcome === "injected"
|
|
308
|
+
? undefined
|
|
309
|
+
: provider.liveSessionError?.(msg.localThreadId);
|
|
310
|
+
this.transport.send({
|
|
311
|
+
t: "injected",
|
|
312
|
+
reqId: msg.reqId,
|
|
313
|
+
localThreadId: msg.localThreadId,
|
|
314
|
+
outcome,
|
|
315
|
+
...(error ? { error } : {}),
|
|
316
|
+
});
|
|
258
317
|
}
|
|
259
318
|
catch (error) {
|
|
260
319
|
this.transport.send({
|
|
@@ -306,6 +365,32 @@ export class RunnerSession {
|
|
|
306
365
|
void this.skipTraexStartupPrompts(localThreadId, term, watcher);
|
|
307
366
|
}
|
|
308
367
|
this.liveProvider.attachTerminalInjector?.(localThreadId, term);
|
|
368
|
+
this.watchNativeTerminal(localThreadId, terminalId, term);
|
|
369
|
+
}
|
|
370
|
+
watchNativeTerminal(localThreadId, terminalId, terminal) {
|
|
371
|
+
const previous = this.terminalWatchers.get(localThreadId);
|
|
372
|
+
if (previous)
|
|
373
|
+
clearInterval(previous);
|
|
374
|
+
let checking = false;
|
|
375
|
+
const timer = setInterval(() => {
|
|
376
|
+
if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
|
|
377
|
+
return;
|
|
378
|
+
checking = true;
|
|
379
|
+
const probe = terminal.isAliveAsync?.() ?? Promise.resolve(terminal.isAlive());
|
|
380
|
+
void probe.then((alive) => {
|
|
381
|
+
if (alive || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
|
|
382
|
+
return;
|
|
383
|
+
clearInterval(timer);
|
|
384
|
+
this.terminalWatchers.delete(localThreadId);
|
|
385
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
386
|
+
this.liveProvider.failLiveSession?.(localThreadId, new Error("Provider terminal exited unexpectedly"));
|
|
387
|
+
this.failCodexThreadStartup(localThreadId, this.liveProvider, new Error("Provider terminal exited before native thread discovery completed"));
|
|
388
|
+
}).finally(() => {
|
|
389
|
+
checking = false;
|
|
390
|
+
});
|
|
391
|
+
}, 1_000);
|
|
392
|
+
timer.unref?.();
|
|
393
|
+
this.terminalWatchers.set(localThreadId, timer);
|
|
309
394
|
}
|
|
310
395
|
cancelTraexStartupWatcher(localThreadId) {
|
|
311
396
|
if (localThreadId)
|
|
@@ -375,6 +460,9 @@ export class RunnerSession {
|
|
|
375
460
|
}
|
|
376
461
|
}
|
|
377
462
|
stopLive() {
|
|
463
|
+
for (const timer of this.terminalWatchers.values())
|
|
464
|
+
clearInterval(timer);
|
|
465
|
+
this.terminalWatchers.clear();
|
|
378
466
|
for (const id of this.liveIds) {
|
|
379
467
|
this.liveProvider.stopLiveCodexSession?.(id, {
|
|
380
468
|
deferClaudeInteractionCleanup: true,
|
package/dist/runner/manager.d.ts
CHANGED
|
@@ -56,9 +56,8 @@ export interface RunnerManagerOptions {
|
|
|
56
56
|
liveStartTimeoutMs?: number;
|
|
57
57
|
/** Max wait for a Provider-native thread to become ready. */
|
|
58
58
|
liveReadyTimeoutMs?: number;
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
* requests keep the shorter `liveStartTimeoutMs`. */
|
|
59
|
+
/** Claude SessionStart deadline. Codex/Traex use `liveStartTimeoutMs` for the
|
|
60
|
+
* pane/observer acknowledgement; their thread discovery is asynchronous. */
|
|
62
61
|
nativeLiveStartTimeoutMs?: number;
|
|
63
62
|
/** Max wait for a native interrupt acknowledgement before reporting it
|
|
64
63
|
* unproven. Callers may then use the explicit force-stop path. */
|
|
@@ -236,8 +235,9 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
236
235
|
/**
|
|
237
236
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
238
237
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
239
|
-
* needed. Idempotent.
|
|
240
|
-
*
|
|
238
|
+
* needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
|
|
239
|
+
* app-server/observer and pane are launched; thread discovery continues in
|
|
240
|
+
* parallel with injection. Other providers retain their readiness gate.
|
|
241
241
|
*/
|
|
242
242
|
ensureLiveSession(localThreadId: string, opts: {
|
|
243
243
|
workspace: SessionWorkspaceSnapshot;
|
|
@@ -326,6 +326,7 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
326
326
|
* terminateHandle immediately afterwards, so these are the final events for
|
|
327
327
|
* the abandoned responses. */
|
|
328
328
|
private failStaleActiveResponses;
|
|
329
|
+
private failActiveResponses;
|
|
329
330
|
/** Track only response lifecycle, not output volume. Output deltas from a
|
|
330
331
|
* runaway TUI/Provider must not refresh the stale-active deadline. */
|
|
331
332
|
private observeHandleRuntimeEvent;
|
package/dist/runner/manager.js
CHANGED
|
@@ -34,13 +34,15 @@ const STDERR_TAIL_LINES = 40;
|
|
|
34
34
|
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
35
35
|
/** Time allowed for exit after SIGKILL before shutdown reports failure. */
|
|
36
36
|
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
37
|
-
/** Setup-pane launch
|
|
38
|
-
|
|
37
|
+
/** Setup-pane launch includes the app-server readiness phase but does not wait
|
|
38
|
+
* for native thread discovery. Keep enough headroom around the app-server's
|
|
39
|
+
* own 10s gate so scheduling/IPC overhead cannot win the same deadline. */
|
|
40
|
+
const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
|
|
39
41
|
/** Legacy fallback for a provider without a native startup policy. */
|
|
40
42
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
43
|
+
/** Claude has no app-server bridge and keeps a parent-owned SessionStart
|
|
44
|
+
* deadline. Codex-lineage startup instead acknowledges pane/observer startup
|
|
45
|
+
* and lets thread discovery race injection. */
|
|
44
46
|
const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
|
|
45
47
|
/** A submitted TUI command should become a mirrored turn or native rotation
|
|
46
48
|
* quickly. If it does not, the runner is fenced by a verified process-tree
|
|
@@ -500,8 +502,9 @@ export class RunnerManager {
|
|
|
500
502
|
/**
|
|
501
503
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
502
504
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
503
|
-
* needed. Idempotent.
|
|
504
|
-
*
|
|
505
|
+
* needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
|
|
506
|
+
* app-server/observer and pane are launched; thread discovery continues in
|
|
507
|
+
* parallel with injection. Other providers retain their readiness gate.
|
|
505
508
|
*/
|
|
506
509
|
ensureLiveSession(localThreadId, opts) {
|
|
507
510
|
const admission = this.reserveAdmission();
|
|
@@ -542,14 +545,23 @@ export class RunnerManager {
|
|
|
542
545
|
return new Promise((resolve) => {
|
|
543
546
|
const timeoutMs = !waitForReady
|
|
544
547
|
? this.liveStartTimeoutMs
|
|
545
|
-
:
|
|
548
|
+
: opts.execution.provider === "claude"
|
|
546
549
|
? this.nativeLiveStartTimeoutMs
|
|
547
|
-
:
|
|
550
|
+
: isManagedNativeProvider(opts.execution.provider)
|
|
551
|
+
? this.liveStartTimeoutMs
|
|
552
|
+
: this.liveReadyTimeoutMs;
|
|
548
553
|
const timeout = setTimeout(() => {
|
|
549
554
|
if (!handle.live.delete(reqId))
|
|
550
555
|
return;
|
|
551
556
|
const finishTimeout = () => {
|
|
552
|
-
const
|
|
557
|
+
const provider = opts.execution.provider;
|
|
558
|
+
const reason = !waitForReady
|
|
559
|
+
? `runner did not acknowledge terminal start within ${timeoutMs}ms`
|
|
560
|
+
: provider === "claude"
|
|
561
|
+
? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
|
|
562
|
+
: provider === "codex" || provider === "traex"
|
|
563
|
+
? `${provider === "traex" ? "Traex" : "Codex"} app-server/observer/Terminal launch was not acknowledged within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
|
|
564
|
+
: `runner did not acknowledge live readiness within ${timeoutMs}ms`;
|
|
553
565
|
this.liveErrors.set(localThreadId, reason);
|
|
554
566
|
this.liveSessionKeys.delete(localThreadId);
|
|
555
567
|
// A child that cannot answer a bounded control round-trip is unsafe
|
|
@@ -1270,7 +1282,7 @@ export class RunnerManager {
|
|
|
1270
1282
|
}
|
|
1271
1283
|
}
|
|
1272
1284
|
/** Mark a handle dead and reject every pending run/cap with the exit reason. */
|
|
1273
|
-
failHandle(handle, reason) {
|
|
1285
|
+
failHandle(handle, reason, opts = {}) {
|
|
1274
1286
|
if (handle.dead) {
|
|
1275
1287
|
return;
|
|
1276
1288
|
}
|
|
@@ -1289,6 +1301,9 @@ export class RunnerManager {
|
|
|
1289
1301
|
}
|
|
1290
1302
|
const tail = handle.stderr.join("\n");
|
|
1291
1303
|
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
1304
|
+
if (opts.failActiveResponses !== false) {
|
|
1305
|
+
this.failActiveResponses(handle, "runner_crashed", message);
|
|
1306
|
+
}
|
|
1292
1307
|
const error = new AgentRuntimeError(message, 500, "runner_crashed");
|
|
1293
1308
|
for (const pending of handle.caps.values()) {
|
|
1294
1309
|
pending.reject(error);
|
|
@@ -1319,7 +1334,7 @@ export class RunnerManager {
|
|
|
1319
1334
|
catch {
|
|
1320
1335
|
// The process signal below remains the authoritative shutdown path.
|
|
1321
1336
|
}
|
|
1322
|
-
this.failHandle(handle, reason);
|
|
1337
|
+
this.failHandle(handle, reason, { failActiveResponses: false });
|
|
1323
1338
|
}
|
|
1324
1339
|
this.signalChild(handle.child, "SIGTERM", handle.processGroup);
|
|
1325
1340
|
let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
|
|
@@ -1385,6 +1400,9 @@ export class RunnerManager {
|
|
|
1385
1400
|
* terminateHandle immediately afterwards, so these are the final events for
|
|
1386
1401
|
* the abandoned responses. */
|
|
1387
1402
|
failStaleActiveResponses(handle, idleForMs) {
|
|
1403
|
+
this.failActiveResponses(handle, "stale_runner_reaped", `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`);
|
|
1404
|
+
}
|
|
1405
|
+
failActiveResponses(handle, code, message) {
|
|
1388
1406
|
const responseIds = [...handle.activeResponseIds];
|
|
1389
1407
|
handle.activeResponseIds.clear();
|
|
1390
1408
|
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
@@ -1394,8 +1412,8 @@ export class RunnerManager {
|
|
|
1394
1412
|
responseId,
|
|
1395
1413
|
error: {
|
|
1396
1414
|
source: "execution",
|
|
1397
|
-
code
|
|
1398
|
-
message
|
|
1415
|
+
code,
|
|
1416
|
+
message,
|
|
1399
1417
|
},
|
|
1400
1418
|
});
|
|
1401
1419
|
}
|
|
@@ -70,8 +70,9 @@ export type ToChild = {
|
|
|
70
70
|
}
|
|
71
71
|
/** Eagerly bring up a session's live codex TUI + forwarder (codex-native), so
|
|
72
72
|
* its turns mirror to the bus regardless of whether the web has attached the
|
|
73
|
-
* terminal.
|
|
74
|
-
* the thread
|
|
73
|
+
* terminal. For Codex-lineage fresh sessions, `live.ready` acknowledges that
|
|
74
|
+
* the pane and observer exist; background thread discovery races the
|
|
75
|
+
* executor's bridge wait. */
|
|
75
76
|
| {
|
|
76
77
|
t: "live.ensure";
|
|
77
78
|
reqId: string;
|
|
@@ -160,8 +161,9 @@ export type FromChild = {
|
|
|
160
161
|
execution: ResolvedExecutionSnapshot;
|
|
161
162
|
parentSessionId?: string;
|
|
162
163
|
}
|
|
163
|
-
/** Result of a `live.ensure`: `ok`
|
|
164
|
-
*
|
|
164
|
+
/** Result of a `live.ensure`: for Codex-lineage sessions `ok` means the pane
|
|
165
|
+
* and observer started; thread discovery continues in parallel with
|
|
166
|
+
* injection. Other Providers retain their own readiness gate. */
|
|
165
167
|
| {
|
|
166
168
|
t: "live.ready";
|
|
167
169
|
reqId: string;
|
package/dist/terminal/tmux.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ export declare class TmuxTerminal {
|
|
|
112
112
|
* there stalls the runner child's event loop (freezing the PTY stream → the
|
|
113
113
|
* terminal appears "stuck"). reference implementation's `_tmux_session_alive` uses an async
|
|
114
114
|
* subprocess + timeout for exactly this reason. */
|
|
115
|
-
|
|
115
|
+
isAliveAsync(): Promise<boolean>;
|
|
116
116
|
/** Type literal text into the pane (agent injection / co-drive from a
|
|
117
117
|
* non-PTY caller). `-l` sends the text literally rather than as key names. */
|
|
118
118
|
sendKeys(text: string): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rynx-ai/runtime",
|
|
3
|
-
"version": "0.1.11-beta.
|
|
3
|
+
"version": "0.1.11-beta.20",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/rynx-ai/rynx.git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node-pty": "1.2.0-beta.15",
|
|
28
28
|
"smol-toml": "1.7.1",
|
|
29
29
|
"ws": "^8.21.0",
|
|
30
|
-
"@rynx-ai/core": "0.1.11-beta.
|
|
30
|
+
"@rynx-ai/core": "0.1.11-beta.20"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/ws": "^8.18.1"
|