@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.7

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.
@@ -1,5 +1,18 @@
1
1
  import { TerminalRegistry } from "../terminal/registry.js";
2
2
  import { toWireError } from "./protocol.js";
3
+ const TRAEX_STARTUP_WATCH_MS = 20_000;
4
+ const TRAEX_STARTUP_POLL_MS = 100;
5
+ const TRAEX_PROMPT_RETRY_MS = 500;
6
+ function normalizeTraexPane(pane) {
7
+ return pane.toLowerCase().replace(/\s+/g, " ").trim();
8
+ }
9
+ function isTerminalProtocolResponse(input) {
10
+ // xterm answers terminal queries through the same onData channel as real
11
+ // keystrokes. CSI carries device/focus/position reports; OSC carries color
12
+ // query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
13
+ // Neither is evidence that the user has taken over startup prompt handling.
14
+ return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
15
+ }
3
16
  export class RunnerSession {
4
17
  transport;
5
18
  executor;
@@ -48,8 +61,17 @@ export class RunnerSession {
48
61
  });
49
62
  return;
50
63
  case "term.input":
51
- this.cancelTraexStartupWatcher(this.attachmentThreadIds.get(msg.attachId));
52
- this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
64
+ {
65
+ const localThreadId = this.attachmentThreadIds.get(msg.attachId);
66
+ const input = Buffer.from(msg.dataB64, "base64").toString("utf8");
67
+ // xterm sends device/focus reports through the same onData channel as
68
+ // keystrokes. They must reach the TUI without pretending the user has
69
+ // taken over startup prompt handling.
70
+ if (!isTerminalProtocolResponse(input)) {
71
+ this.cancelTraexStartupWatcher(localThreadId);
72
+ }
73
+ this.attachments.get(msg.attachId)?.write(input);
74
+ }
53
75
  return;
54
76
  case "term.resize":
55
77
  this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
@@ -215,8 +237,8 @@ export class RunnerSession {
215
237
  try {
216
238
  const input = msg.input ?? msg.text;
217
239
  const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
218
- if (outcome === "injected")
219
- this.cancelTraexStartupWatcher(msg.localThreadId);
240
+ // App-server injection is independent of the Terminal TUI startup, so it
241
+ // must not cancel prompt handling for the pane that is still starting.
220
242
  this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
221
243
  }
222
244
  catch (error) {
@@ -255,7 +277,6 @@ export class RunnerSession {
255
277
  if (!spec)
256
278
  return;
257
279
  const terminalId = `${localThreadId}-main`;
258
- const coldStart = !this.terminals.has(terminalId);
259
280
  const term = this.terminals.getOrCreate(terminalId, {
260
281
  cwd: spec.cwd,
261
282
  command: spec.command,
@@ -264,7 +285,7 @@ export class RunnerSession {
264
285
  rows: rows ?? 40,
265
286
  ...(spec.env ? { env: spec.env } : {}),
266
287
  });
267
- if (coldStart && spec.skipTraexStartupPrompts) {
288
+ if (spec.skipTraexStartupPrompts) {
268
289
  const watcher = Symbol(localThreadId);
269
290
  this.traexStartupWatchers.set(localThreadId, watcher);
270
291
  void this.skipTraexStartupPrompts(localThreadId, term, watcher);
@@ -279,46 +300,48 @@ export class RunnerSession {
279
300
  const prompts = [
280
301
  {
281
302
  id: "welcome",
282
- matches: (pane) => pane.includes("Welcome to TRAE CLI") && pane.includes("Press enter to continue"),
303
+ matches: (pane) => pane.includes("welcome to trae cli") && pane.includes("press enter to continue"),
283
304
  dismiss: () => terminal.sendEnter(),
284
305
  },
285
306
  {
286
307
  id: "migration",
287
- matches: (pane) => pane.includes("Legacy TRAE CLI data detected") &&
288
- pane.includes("Would you like to migrate detected data to the new TRAE CLI storage?") &&
289
- pane.includes("Select what to import:"),
308
+ matches: (pane) => pane.includes("legacy trae cli data detected") &&
309
+ pane.includes("select what to import") &&
310
+ (pane.includes("skip for now") || pane.includes("don't ask again")),
290
311
  dismiss: () => terminal.interrupt(),
291
312
  },
292
313
  {
293
314
  id: "hooks",
294
- matches: (pane) => pane.includes("Hooks need review") &&
295
- pane.includes("hooks are new or changed.") &&
296
- pane.includes("Trust all and continue") &&
297
- pane.includes("Continue without trusting"),
315
+ matches: (pane) => pane.includes("hooks need review") &&
316
+ pane.includes("trust all and continue") &&
317
+ pane.includes("continue without trusting"),
298
318
  dismiss: () => terminal.interrupt(),
299
319
  },
300
320
  ];
301
- const dismissed = new Set();
302
- const deadline = Date.now() + 20_000;
321
+ let activePromptId;
322
+ let lastDismissedAt = 0;
323
+ const deadline = Date.now() + TRAEX_STARTUP_WATCH_MS;
303
324
  const terminalId = `${localThreadId}-main`;
304
325
  while (!this.shuttingDown &&
305
326
  Date.now() < deadline &&
306
327
  this.traexStartupWatchers.get(localThreadId) === watcher &&
307
328
  this.terminals.get(terminalId) === terminal) {
308
- const pane = terminal.capturePane();
309
- // Once the normal composer is visible, startup is complete and there is
310
- // no reason to keep polling the tmux pane for the remainder of the window.
311
- if (pane.includes("TRAE CLI Next"))
312
- break;
329
+ // Do not treat a composer frame as completion: Traex can render it before
330
+ // the startup modals arrive. The bounded deadline stops this watcher.
331
+ const pane = normalizeTraexPane(terminal.capturePane());
313
332
  const prompt = prompts.find((candidate) => candidate.matches(pane));
314
- if (prompt && !dismissed.has(prompt.id)) {
333
+ const now = Date.now();
334
+ if (!prompt) {
335
+ activePromptId = undefined;
336
+ }
337
+ else if (prompt.id !== activePromptId ||
338
+ now - lastDismissedAt >= TRAEX_PROMPT_RETRY_MS) {
315
339
  prompt.dismiss();
316
- dismissed.add(prompt.id);
317
- if (dismissed.size === prompts.length)
318
- break;
340
+ activePromptId = prompt.id;
341
+ lastDismissedAt = now;
319
342
  }
320
343
  await new Promise((resolve) => {
321
- const timer = setTimeout(resolve, 100);
344
+ const timer = setTimeout(resolve, TRAEX_STARTUP_POLL_MS);
322
345
  timer.unref();
323
346
  });
324
347
  }
@@ -43,6 +43,9 @@ export interface RunnerManagerOptions {
43
43
  runnerEntry?: string;
44
44
  /** Idle TTL (ms) after which an unused runner is reaped. */
45
45
  idleTtlMs?: number;
46
+ /** Maximum time without user/control activity that an active response may
47
+ * protect a detached runner from idle reaping. */
48
+ staleActiveTtlMs?: number;
46
49
  /** Background reap sweep interval (ms); `0` disables the timer (tests). */
47
50
  reapIntervalMs?: number;
48
51
  /** Grace period between SIGTERM and SIGKILL during runner shutdown. */
@@ -136,6 +139,7 @@ export declare class RunnerManager implements AgentCapabilities {
136
139
  private readonly sessionStore;
137
140
  private readonly runnerEntry;
138
141
  private readonly idleTtlMs;
142
+ private readonly staleActiveTtlMs;
139
143
  private readonly spawn;
140
144
  private readonly childEnv;
141
145
  private readonly sessionContextProvider;
@@ -312,5 +316,13 @@ export declare class RunnerManager implements AgentCapabilities {
312
316
  private failHandle;
313
317
  private terminateHandle;
314
318
  private reapIdle;
319
+ /** Close server-side runtime state before fencing a Provider that stayed
320
+ * active past its hard inactivity deadline. The child transport is closed by
321
+ * terminateHandle immediately afterwards, so these are the final events for
322
+ * the abandoned responses. */
323
+ private failStaleActiveResponses;
324
+ /** Track only response lifecycle, not output volume. Output deltas from a
325
+ * runaway TUI/Provider must not refresh the stale-active deadline. */
326
+ private observeHandleRuntimeEvent;
315
327
  private openSessionContext;
316
328
  }
@@ -44,6 +44,12 @@ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
44
44
  /** Retry transient terminal-server cleanup without spinning forever. */
45
45
  const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
46
46
  const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
47
+ /** A Provider that never closes its active response must not keep a detached
48
+ * runner + tmux server alive forever. This is intentionally much longer than
49
+ * the normal idle TTL so legitimate long-running turns are not treated as
50
+ * stale. User input/control activity refreshes the deadline; Provider output
51
+ * does not, because a runaway redraw loop is the failure mode this bounds. */
52
+ const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
47
53
  /** Keep per-Session spawn context small enough to remain an environment handoff,
48
54
  * not an unbounded transport. */
49
55
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -144,6 +150,7 @@ export class RunnerManager {
144
150
  sessionStore;
145
151
  runnerEntry;
146
152
  idleTtlMs;
153
+ staleActiveTtlMs;
147
154
  spawn;
148
155
  childEnv;
149
156
  sessionContextProvider;
@@ -198,6 +205,7 @@ export class RunnerManager {
198
205
  this.sessionStore = opts.sessionStore;
199
206
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
200
207
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
208
+ this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
201
209
  this.spawn = opts.spawn ?? nodeSpawn;
202
210
  this.childEnv = opts.childEnv ?? {};
203
211
  this.sessionContextProvider = opts.sessionContextProvider;
@@ -261,6 +269,8 @@ export class RunnerManager {
261
269
  pendingEscape: "",
262
270
  };
263
271
  const terminal = new ManagedTerminal(attachId, (msg) => {
272
+ if (msg.t === "term.input")
273
+ handle.lastUsedAt = this.now();
264
274
  const handoffs = msg.t === "term.input" && opts.role === "owner"
265
275
  ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
276
  : [];
@@ -1011,6 +1021,7 @@ export class RunnerManager {
1011
1021
  if (handle.dead)
1012
1022
  return;
1013
1023
  if (message.t === "mirror") {
1024
+ this.observeHandleRuntimeEvent(handle, message.event);
1014
1025
  this.mirrorListener?.(message.sessionId, message.event);
1015
1026
  if (message.event.type === "response.created") {
1016
1027
  this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
@@ -1078,6 +1089,7 @@ export class RunnerManager {
1078
1089
  this.settleTerminalRotationHandoff(handle, message.from, message.to);
1079
1090
  if (handle.dead)
1080
1091
  return;
1092
+ handle.activeResponseIds.clear();
1081
1093
  this.handles.set(message.to, handle);
1082
1094
  this.liveSessionKeys.add(message.to);
1083
1095
  this.liveOptions.set(message.to, {
@@ -1144,6 +1156,7 @@ export class RunnerManager {
1144
1156
  transport,
1145
1157
  stderr: [],
1146
1158
  lastUsedAt: this.now(),
1159
+ activeResponseIds: new Set(),
1147
1160
  dead: false,
1148
1161
  completion,
1149
1162
  terminalCleanupRetryFailures: 0,
@@ -1210,6 +1223,7 @@ export class RunnerManager {
1210
1223
  return;
1211
1224
  if (this.bufferForkTargetMessage(handle, msg))
1212
1225
  return;
1226
+ this.observeHandleRuntimeEvent(handle, msg.event);
1213
1227
  this.mirrorListener?.(msg.sessionId, msg.event);
1214
1228
  if (msg.event.type === "response.created") {
1215
1229
  // The listener projects response.created into SessionRuntimeIndex
@@ -1331,22 +1345,86 @@ export class RunnerManager {
1331
1345
  reapIdle() {
1332
1346
  const now = this.now();
1333
1347
  for (const [key, handle] of this.handles) {
1334
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
1348
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1335
1349
  continue;
1336
1350
  }
1337
- // A live codex forwarder must outlive terminal detach / idle turns — it is
1338
- // the session's single event writer, so never reap a session that has one.
1339
- if (this.liveSessionKeys.has(key)) {
1351
+ const idleForMs = now - handle.lastUsedAt;
1352
+ if (idleForMs < this.idleTtlMs) {
1340
1353
  continue;
1341
1354
  }
1342
- if (now - handle.lastUsedAt < this.idleTtlMs) {
1355
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
1356
+ if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
1343
1357
  continue;
1344
1358
  }
1345
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
1359
+ const reason = hasActiveResponse
1360
+ ? "stale active runner reaped"
1361
+ : this.liveSessionKeys.has(key)
1362
+ ? "idle live runner reaped"
1363
+ : "idle runner reaped";
1364
+ if (hasActiveResponse) {
1365
+ this.failStaleActiveResponses(handle, idleForMs);
1366
+ }
1367
+ void this.terminateHandle(handle, reason).catch((error) => {
1346
1368
  logTerminationFailure(handle, error);
1347
1369
  });
1348
1370
  }
1349
1371
  }
1372
+ /** Close server-side runtime state before fencing a Provider that stayed
1373
+ * active past its hard inactivity deadline. The child transport is closed by
1374
+ * terminateHandle immediately afterwards, so these are the final events for
1375
+ * the abandoned responses. */
1376
+ failStaleActiveResponses(handle, idleForMs) {
1377
+ const responseIds = [...handle.activeResponseIds];
1378
+ handle.activeResponseIds.clear();
1379
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1380
+ for (const responseId of responseIds) {
1381
+ this.mirrorListener?.(sessionId, {
1382
+ type: "response.failed",
1383
+ responseId,
1384
+ error: {
1385
+ source: "execution",
1386
+ code: "stale_runner_reaped",
1387
+ message: `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`,
1388
+ },
1389
+ });
1390
+ }
1391
+ }
1392
+ /** Track only response lifecycle, not output volume. Output deltas from a
1393
+ * runaway TUI/Provider must not refresh the stale-active deadline. */
1394
+ observeHandleRuntimeEvent(handle, event) {
1395
+ switch (event.type) {
1396
+ case "response.created":
1397
+ case "response.output_text.delta":
1398
+ case "response.reasoning_summary_text.delta":
1399
+ case "response.function_call_output.delta":
1400
+ case "response.output_item.done":
1401
+ handle.activeResponseIds.add(event.responseId);
1402
+ return;
1403
+ case "session.interaction.requested":
1404
+ handle.activeResponseIds.add(event.responseId);
1405
+ return;
1406
+ case "response.completed":
1407
+ case "response.failed":
1408
+ handle.activeResponseIds.delete(event.responseId);
1409
+ return;
1410
+ case "session.status":
1411
+ if (event.status === "running" && event.responseId) {
1412
+ handle.activeResponseIds.add(event.responseId);
1413
+ }
1414
+ else if (event.status !== "running") {
1415
+ if (event.responseId)
1416
+ handle.activeResponseIds.delete(event.responseId);
1417
+ else
1418
+ handle.activeResponseIds.clear();
1419
+ }
1420
+ return;
1421
+ case "session.rotated":
1422
+ handle.activeResponseIds.clear();
1423
+ return;
1424
+ default:
1425
+ return;
1426
+ }
1427
+ }
1350
1428
  openSessionContext(sessionId) {
1351
1429
  if (!this.sessionContextProvider)
1352
1430
  return undefined;
@@ -146,7 +146,9 @@ export declare class TmuxTerminal {
146
146
  paste(text: string, bufferName?: string): void;
147
147
  /**
148
148
  * Attach a client. `role: "read-only"` passes tmux `-r` so the viewer cannot
149
- * type (defense-in-depth on top of the WS bridge dropping input frames).
149
+ * type and `ignore-size` so even its initial PTY dimensions cannot resize the
150
+ * owner's pane (defense-in-depth on top of the WS bridge dropping input and
151
+ * resize frames).
150
152
  */
151
153
  attach(role: "owner" | "read-only", dims?: {
152
154
  cols?: number;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFile, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync } from "node:fs";
19
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  const TMUX_TARGET = "main";
@@ -54,10 +54,28 @@ export function terminateTmuxServer(name, tmuxBin = "tmux") {
54
54
  return false;
55
55
  }
56
56
  catch (error) {
57
- return !(error &&
58
- typeof error === "object" &&
59
- "code" in error &&
60
- error.code === "ENOENT");
57
+ // Only a real tmux exit status proves the server/session is absent. Spawn
58
+ // failures (missing/unexecutable tmux) leave liveness unproven and must not
59
+ // unlink a socket that could still belong to a live server.
60
+ if (!error ||
61
+ typeof error !== "object" ||
62
+ !("status" in error) ||
63
+ typeof error.status !== "number") {
64
+ return false;
65
+ }
66
+ try {
67
+ // tmux custom-socket servers can exit while leaving the filesystem entry
68
+ // behind. Remove that verified-stale socket so process/socket inventories
69
+ // do not accumulate dead terminals forever.
70
+ unlinkSync(socketPath);
71
+ return true;
72
+ }
73
+ catch (unlinkError) {
74
+ return Boolean(unlinkError &&
75
+ typeof unlinkError === "object" &&
76
+ "code" in unlinkError &&
77
+ unlinkError.code === "ENOENT");
78
+ }
61
79
  }
62
80
  }
63
81
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
@@ -319,14 +337,17 @@ export class TmuxTerminal {
319
337
  }
320
338
  /**
321
339
  * Attach a client. `role: "read-only"` passes tmux `-r` so the viewer cannot
322
- * type (defense-in-depth on top of the WS bridge dropping input frames).
340
+ * type and `ignore-size` so even its initial PTY dimensions cannot resize the
341
+ * owner's pane (defense-in-depth on top of the WS bridge dropping input and
342
+ * resize frames).
323
343
  */
324
344
  async attach(role, dims) {
325
345
  this.start();
326
346
  const spawn = this.injectedSpawn ?? (await loadPtySpawn());
327
- const args = [...this.base(), "attach-session", "-t", TMUX_TARGET];
347
+ const args = [...this.base(), "attach-session"];
328
348
  if (role === "read-only")
329
- args.push("-r");
349
+ args.push("-r", "-f", "ignore-size");
350
+ args.push("-t", TMUX_TARGET);
330
351
  const proc = spawn(this.tmuxBin, args, {
331
352
  name: "xterm-256color",
332
353
  cols: dims?.cols ?? this.cols,
@@ -386,12 +407,7 @@ export class TmuxTerminal {
386
407
  }
387
408
  /** Kill the tmux server (ends the session and all attaches). */
388
409
  kill() {
389
- try {
390
- execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
391
- }
392
- catch {
393
- // Already gone — nothing to clean up.
394
- }
410
+ terminateTmuxServer(this.name, this.tmuxBin);
395
411
  this.started = false;
396
412
  }
397
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.5",
3
+ "version": "0.1.11-beta.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -24,9 +24,9 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "node-pty": "^1.0.0",
27
+ "node-pty": "1.2.0-beta.15",
28
28
  "ws": "^8.21.0",
29
- "@rynx-ai/core": "0.1.11-beta.5"
29
+ "@rynx-ai/core": "0.1.11-beta.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/ws": "^8.18.1"