@rynx-ai/runtime 0.1.11-beta.21 → 0.1.11-beta.23

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.
@@ -79,50 +79,32 @@ export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin()) {
79
79
  });
80
80
  });
81
81
  }
82
- /** Kill one private tmux server and verify it no longer answers. Missing
83
- * sockets are already stopped; an existing socket with an unusable tmux
84
- * command is unproven and returns false. */
82
+ /** Best-effort close of one private tmux server. Mirrors Omnigent's bounded
83
+ * `TerminalInstance.close`: attempt `kill-server`, then retire the private
84
+ * socket regardless of command outcome. The registry has already forgotten
85
+ * the resource, so cleanup failure is diagnostic rather than a second
86
+ * lifecycle state. */
85
87
  export function terminateTmuxServer(name, tmuxBin = resolveTmuxBin()) {
86
88
  const socketPath = tmuxSocketPath(name);
87
89
  if (!existsSync(socketPath))
88
90
  return true;
89
91
  const base = ["-u", "-S", socketPath];
90
92
  try {
91
- execFileSync(tmuxBin, [...base, "kill-server"], { stdio: "ignore" });
93
+ execFileSync(tmuxBin, [...base, "kill-server"], {
94
+ stdio: "ignore",
95
+ timeout: 5_000,
96
+ });
92
97
  }
93
98
  catch {
94
- // A server may have exited between existsSync and kill-server. Verify below.
99
+ // Omnigent treats close as best-effort and still retires the resource.
95
100
  }
96
101
  try {
97
- execFileSync(tmuxBin, [...base, "has-session", "-t", TMUX_TARGET], {
98
- stdio: "ignore",
99
- });
100
- return false;
102
+ unlinkSync(socketPath);
101
103
  }
102
- catch (error) {
103
- // Only a real tmux exit status proves the server/session is absent. Spawn
104
- // failures (missing/unexecutable tmux) leave liveness unproven and must not
105
- // unlink a socket that could still belong to a live server.
106
- if (!error ||
107
- typeof error !== "object" ||
108
- !("status" in error) ||
109
- typeof error.status !== "number") {
110
- return false;
111
- }
112
- try {
113
- // tmux custom-socket servers can exit while leaving the filesystem entry
114
- // behind. Remove that verified-stale socket so process/socket inventories
115
- // do not accumulate dead terminals forever.
116
- unlinkSync(socketPath);
117
- return true;
118
- }
119
- catch (unlinkError) {
120
- return Boolean(unlinkError &&
121
- typeof unlinkError === "object" &&
122
- "code" in unlinkError &&
123
- unlinkError.code === "ENOENT");
124
- }
104
+ catch {
105
+ // Gone already, or best-effort filesystem cleanup failed.
125
106
  }
107
+ return true;
126
108
  }
127
109
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
128
110
  export function isTmuxAvailable(tmuxBin = resolveTmuxBin()) {
@@ -158,6 +140,14 @@ export class TmuxTerminal {
158
140
  tmuxBin;
159
141
  injectedSpawn;
160
142
  started = false;
143
+ lastPaneSnapshot = "";
144
+ /** Shared by every attachment watcher and by the lifecycle watcher's
145
+ * pane-dead stage. One Terminal must never fan the same tmux control probe
146
+ * out once per attached client. */
147
+ paneLivenessFlight;
148
+ /** Shared by lifecycle callers so capture + pane-dead remains one ordered
149
+ * Omnigent-style observation per Terminal. */
150
+ lifecycleLivenessFlight;
161
151
  constructor(opts) {
162
152
  this.name = opts.name;
163
153
  this.cwd = opts.cwd;
@@ -188,15 +178,8 @@ export class TmuxTerminal {
188
178
  start() {
189
179
  if (this.started)
190
180
  return;
191
- // Clear any stale server on this socket (a crashed prior run / a test rerun
192
- // reusing the name); the socket is unique per session, so this only ever
193
- // reaps our own leftover, never a live sibling.
194
- try {
195
- execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
196
- }
197
- catch {
198
- // No stale server — expected on a fresh start.
199
- }
181
+ // Clear any stale server on this deterministic private socket before launch.
182
+ terminateTmuxServer(this.name, this.tmuxBin);
200
183
  // `new-session -d` starts detached; `-x/-y` seed the pane size so output
201
184
  // wraps sanely before the first client attaches and resizes it.
202
185
  execFileSync(this.tmuxBin, [
@@ -302,21 +285,82 @@ export class TmuxTerminal {
302
285
  /** Async pane-liveness probe — MUST NOT block the event loop. The attach
303
286
  * pane-death watcher polls this on an interval; a synchronous `execFileSync`
304
287
  * there stalls the runner child's event loop (freezing the PTY stream → the
305
- * terminal appears "stuck"). reference implementation's `_tmux_session_alive` uses an async
306
- * subprocess + timeout for exactly this reason. */
307
- isAliveAsync() {
288
+ * terminal appears "stuck"). Exactly like Omnigent's definitive pane probe,
289
+ * every command error is `unknown`; only `#{pane_dead}=1` is `dead`. */
290
+ livenessAsync() {
308
291
  if (!this.started)
309
- return Promise.resolve(false);
310
- return new Promise((resolve) => {
292
+ return Promise.resolve("dead");
293
+ if (this.paneLivenessFlight)
294
+ return this.paneLivenessFlight;
295
+ const flight = new Promise((resolve) => {
311
296
  execFile(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_dead}"], { timeout: 2000 }, (err, stdout) => {
312
297
  if (err) {
313
- resolve(false);
298
+ resolve("unknown");
314
299
  return;
315
300
  }
316
301
  const panes = stdout.toString().split(/\s+/).filter(Boolean);
317
- resolve(panes.length > 0 && !panes.includes("1"));
302
+ if (panes.includes("1")) {
303
+ resolve("dead");
304
+ }
305
+ else if (panes.length > 0) {
306
+ resolve("alive");
307
+ }
308
+ else {
309
+ resolve("unknown");
310
+ }
311
+ });
312
+ });
313
+ this.paneLivenessFlight = flight;
314
+ void flight.then(() => {
315
+ if (this.paneLivenessFlight === flight)
316
+ delete this.paneLivenessFlight;
317
+ }, () => {
318
+ if (this.paneLivenessFlight === flight)
319
+ delete this.paneLivenessFlight;
320
+ });
321
+ return flight;
322
+ }
323
+ /** Omnigent's always-on terminal lifecycle watcher first captures the pane:
324
+ * a control command that ran and reports the target missing is terminal exit;
325
+ * a probe that cannot spawn is inconclusive. If capture succeeds, the normal
326
+ * definitive `pane_dead` probe distinguishes live from exited. */
327
+ lifecycleLivenessAsync() {
328
+ if (!this.started)
329
+ return Promise.resolve("dead");
330
+ if (this.lifecycleLivenessFlight)
331
+ return this.lifecycleLivenessFlight;
332
+ const flight = new Promise((resolve) => {
333
+ execFile(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p", "-e"], (error, stdout) => {
334
+ if (error) {
335
+ const code = typeof error === "object" && error && "code" in error
336
+ ? error.code
337
+ : undefined;
338
+ resolve(typeof code === "number" ? "dead" : "unknown");
339
+ return;
340
+ }
341
+ this.lastPaneSnapshot = stdout.toString();
342
+ void this.livenessAsync().then((liveness) => {
343
+ // A successful capture proves the target exists. Omnigent treats a
344
+ // subsequent pane-dead probe failure as non-dead for this tick.
345
+ resolve(liveness === "dead" ? "dead" : "alive");
346
+ });
318
347
  });
319
348
  });
349
+ this.lifecycleLivenessFlight = flight;
350
+ void flight.then(() => {
351
+ if (this.lifecycleLivenessFlight === flight)
352
+ delete this.lifecycleLivenessFlight;
353
+ }, () => {
354
+ if (this.lifecycleLivenessFlight === flight)
355
+ delete this.lifecycleLivenessFlight;
356
+ });
357
+ return flight;
358
+ }
359
+ /** Compatibility boolean for callers that cannot represent an inconclusive
360
+ * probe. Unknown must remain live so a transient tmux failure cannot tear down
361
+ * a healthy native Session. */
362
+ async isAliveAsync() {
363
+ return await this.livenessAsync() !== "dead";
320
364
  }
321
365
  /** PID of the process currently owning the pane. */
322
366
  panePid() {
@@ -344,13 +388,15 @@ export class TmuxTerminal {
344
388
  */
345
389
  capturePane() {
346
390
  try {
347
- return execFileSync(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p"], {
391
+ const snapshot = execFileSync(this.tmuxBin, [...this.base(), "capture-pane", "-t", TMUX_TARGET, "-p"], {
348
392
  env: this.env,
349
393
  encoding: "utf8",
350
394
  });
395
+ this.lastPaneSnapshot = snapshot;
396
+ return snapshot;
351
397
  }
352
398
  catch {
353
- return "";
399
+ return this.lastPaneSnapshot;
354
400
  }
355
401
  }
356
402
  /** Send a submit Enter as a KEY NAME (no `-l`), committing the input line.
@@ -443,8 +489,8 @@ export class TmuxTerminal {
443
489
  return;
444
490
  // ASYNC probe (never execFileSync here — that would block the event loop
445
491
  // and freeze the PTY stream, making the terminal unresponsive).
446
- void this.isAliveAsync().then((alive) => {
447
- if (!alive && !exited)
492
+ void this.livenessAsync().then((liveness) => {
493
+ if (liveness === "dead" && !exited)
448
494
  fireExit(0);
449
495
  });
450
496
  }, 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.21",
3
+ "version": "0.1.11-beta.23",
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.21"
30
+ "@rynx-ai/core": "0.1.11-beta.23"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/ws": "^8.18.1"