ragent-cli 1.11.17 → 1.11.18

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/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "ragent-cli",
34
- version: "1.11.17",
34
+ version: "1.11.18",
35
35
  description: "CLI agent for rAgent Live \u2014 browser-first terminal control plane for AI coding agents",
36
36
  main: "dist/index.js",
37
37
  bin: {
@@ -4185,7 +4185,7 @@ var TranscriptWatcherManager = class {
4185
4185
  clearTranscriptState(sessionId) {
4186
4186
  this.sendSnapshotFn(sessionId, [], 0);
4187
4187
  if (this.sendV2SnapshotFn) {
4188
- this.sendV2SnapshotFn(sessionId, createEmptySnapshot(sessionId), 0);
4188
+ this.sendV2SnapshotFn(sessionId, createEmptySnapshot(sessionId), 0, true);
4189
4189
  }
4190
4190
  }
4191
4191
  /**
@@ -4271,7 +4271,7 @@ var TranscriptWatcherManager = class {
4271
4271
  }
4272
4272
  this.sendSnapshotFn(sessionId, [], 0);
4273
4273
  if (this.sendV2SnapshotFn) {
4274
- this.sendV2SnapshotFn(sessionId, createEmptySnapshot(sessionId), 0);
4274
+ this.sendV2SnapshotFn(sessionId, createEmptySnapshot(sessionId), 0, true);
4275
4275
  }
4276
4276
  newWatcher.replayFromStart();
4277
4277
  this.active.set(sessionId, { watcher: newWatcher, filePath: newPath, agentType: agentType ?? "" });
@@ -10857,6 +10857,9 @@ var import_node_string_decoder = require("string_decoder");
10857
10857
  var pty = __toESM(require("node-pty"));
10858
10858
  var log17 = createLogger("session-streamer");
10859
10859
  var BACKPRESSURE_RESUME_CHECK_MS = 100;
10860
+ var LIVENESS_CHECK_TICKS = 30;
10861
+ var DEAD_PIPE_STRIKES = 2;
10862
+ var REESTABLISH_BACKOFF_MS = 3e4;
10860
10863
  var STOP_DEBOUNCE_MS = 2e3;
10861
10864
  var MAX_CONCURRENT_STREAMS = 20;
10862
10865
  var MAX_SESSION_BUFFER_BYTES = 64 * 1024;
@@ -10979,11 +10982,19 @@ var SessionStreamer = class {
10979
10982
  lastSeenDroppedFrames = 0;
10980
10983
  /** Sessions whose viewers need a capture-pane resync after frame drops. */
10981
10984
  pendingDropResync = /* @__PURE__ */ new Set();
10982
- constructor(sendFn, onStreamStopped, sendResetFn, onFramesDropped) {
10985
+ /** Whether the relay socket is currently OPEN. Used to (a) gate resync delivery
10986
+ * so resync bytes aren't buffered-then-discarded under reconnect churn, and
10987
+ * (b) gate the liveness watchdog's re-establish so its capture burst lands. */
10988
+ isReadyFn;
10989
+ /** Tick counter for the resume timer; the pipe-liveness check runs every
10990
+ * LIVENESS_CHECK_TICKS ticks rather than on every 100ms tick. */
10991
+ livenessTick = 0;
10992
+ constructor(sendFn, onStreamStopped, sendResetFn, onFramesDropped, isReadyFn) {
10983
10993
  this.sendFn = sendFn;
10984
10994
  this.onStreamStopped = onStreamStopped;
10985
10995
  this.sendResetFn = sendResetFn;
10986
10996
  this.onFramesDropped = onFramesDropped;
10997
+ this.isReadyFn = isReadyFn ?? (() => true);
10987
10998
  this.cleanupStaleStreams();
10988
10999
  this.startBackpressureResumeTimer();
10989
11000
  }
@@ -11037,8 +11048,110 @@ var SessionStreamer = class {
11037
11048
  this.resyncStream(next.value);
11038
11049
  }
11039
11050
  }
11051
+ if (this.isReadyFn()) {
11052
+ for (const stream of this.active.values()) {
11053
+ if (stream.needsResync && !stream.stopped && stream.streamType === "tmux-pipe" && !stream.initializing) {
11054
+ stream.needsResync = false;
11055
+ this.resyncStream(stream.sessionId);
11056
+ break;
11057
+ }
11058
+ }
11059
+ }
11060
+ if (++this.livenessTick >= LIVENESS_CHECK_TICKS) {
11061
+ this.livenessTick = 0;
11062
+ if (this.isReadyFn()) this.checkPipeLiveness();
11063
+ }
11040
11064
  }, BACKPRESSURE_RESUME_CHECK_MS);
11041
11065
  }
11066
+ /**
11067
+ * Re-establish a tmux-pipe stream whose `pane_pid` indicates a program swap
11068
+ * (tmux-resurrect restore, pane reuse, exec) or whose pipe-pane is alive in
11069
+ * tmux's view (`pane_pipe=1`) but no longer feeds our FIFO.
11070
+ *
11071
+ * Why this exists: `pipe-pane` is set up exactly once at stream start. tmux
11072
+ * reports `pipe=1` afterwards even when it has stopped delivering bytes (e.g.
11073
+ * the pane was recreated by a restore racing the connector restart). The
11074
+ * `cat` reader blocks forever rather than exiting, so the normal exit cleanup
11075
+ * never fires, and `resyncStream` deliberately does NOT restart pipe-pane —
11076
+ * so without this the pane is frozen until the stream is torn down by hand.
11077
+ *
11078
+ * Batched into one `tmux list-panes -a` call so liveness costs one fork.
11079
+ */
11080
+ checkPipeLiveness() {
11081
+ const tmuxStreams = [...this.active.values()].filter(
11082
+ (s) => s.streamType === "tmux-pipe" && !s.stopped && !s.initializing
11083
+ );
11084
+ if (tmuxStreams.length === 0) return;
11085
+ const panes = this.readAllPaneInfo();
11086
+ if (!panes) return;
11087
+ const now2 = Date.now();
11088
+ const dead = [];
11089
+ for (const stream of tmuxStreams) {
11090
+ const info = panes.get(stream.paneTarget);
11091
+ if (!info) continue;
11092
+ const backedOff = stream.lastReestablishAt !== void 0 && now2 - stream.lastReestablishAt < REESTABLISH_BACKOFF_MS;
11093
+ if (stream.panePid && info.panePid !== stream.panePid) {
11094
+ if (!backedOff) {
11095
+ dead.push({
11096
+ sessionId: stream.sessionId,
11097
+ reason: "pane program swapped",
11098
+ detail: { oldPid: stream.panePid, newPid: info.panePid }
11099
+ });
11100
+ }
11101
+ continue;
11102
+ }
11103
+ const bytesNow = stream.bytesReceived ?? 0;
11104
+ const paneMoved = info.fingerprint !== stream.lastLivenessFingerprint;
11105
+ const fifoSilent = bytesNow === (stream.lastLivenessBytes ?? 0);
11106
+ if (paneMoved && fifoSilent) {
11107
+ stream.deadPipeStrikes = (stream.deadPipeStrikes ?? 0) + 1;
11108
+ if (stream.deadPipeStrikes >= DEAD_PIPE_STRIKES && !backedOff) {
11109
+ dead.push({
11110
+ sessionId: stream.sessionId,
11111
+ reason: "pipe-pane silent while pane active",
11112
+ detail: { strikes: stream.deadPipeStrikes }
11113
+ });
11114
+ }
11115
+ } else {
11116
+ stream.deadPipeStrikes = 0;
11117
+ }
11118
+ stream.lastLivenessBytes = bytesNow;
11119
+ stream.lastLivenessFingerprint = info.fingerprint;
11120
+ }
11121
+ if (dead.length === 0) return;
11122
+ const [first, ...rest] = dead;
11123
+ log17.warn(`${first.reason}; re-establishing stream`, { sessionId: first.sessionId, ...first.detail });
11124
+ if (rest.length > 0) {
11125
+ log17.info("more dead streams queued for re-establish (one per tick)", { remaining: rest.length });
11126
+ }
11127
+ this.reestablishTmuxStream(first.sessionId);
11128
+ }
11129
+ /**
11130
+ * Tear down a tmux-pipe stream's pipe-pane/FIFO/cat and start a fresh one for
11131
+ * the same sessionId, preserving its viewer set. Sends a new stream-reset +
11132
+ * capture so the portal hard-repaints the pane. Uses a LEAN re-establish (no
11133
+ * 5000-line scrollback re-send) to keep the post-reconnect burst small.
11134
+ */
11135
+ reestablishTmuxStream(sessionId) {
11136
+ const old = this.active.get(sessionId);
11137
+ if (!old || old.streamType !== "tmux-pipe") return;
11138
+ const viewerIds = new Set(old.viewerIds);
11139
+ this.cleanupStream(old);
11140
+ this.active.delete(sessionId);
11141
+ this.disconnectBuffers.delete(sessionId);
11142
+ this.disconnectBufferBytes.delete(sessionId);
11143
+ const restarted = this.startTmuxStream(sessionId, "", { skipScrollback: true });
11144
+ if (restarted) {
11145
+ const fresh = this.active.get(sessionId);
11146
+ if (fresh) {
11147
+ for (const v of viewerIds) fresh.viewerIds.add(v);
11148
+ fresh.lastReestablishAt = Date.now();
11149
+ }
11150
+ } else {
11151
+ log17.warn("failed to re-establish tmux stream", { sessionId });
11152
+ this.onStreamStopped?.(sessionId);
11153
+ }
11154
+ }
11042
11155
  /**
11043
11156
  * Pause a PTY-attach stream when WS queue pressure is high. Only applies
11044
11157
  * to `pty-attach` streams — tmux-pipe (FIFO cat) and process-trace
@@ -11285,6 +11398,7 @@ var SessionStreamer = class {
11285
11398
  if (stream.stopped) continue;
11286
11399
  if (stream.streamType === "tmux-pipe") {
11287
11400
  this.drainBuffer(stream.sessionId);
11401
+ stream.needsResync = true;
11288
11402
  this.resyncStream(stream.sessionId);
11289
11403
  } else {
11290
11404
  for (const chunk of this.drainBuffer(stream.sessionId)) {
@@ -11329,6 +11443,13 @@ var SessionStreamer = class {
11329
11443
  stream.initBuffer = [];
11330
11444
  return;
11331
11445
  }
11446
+ if (!this.isReadyFn()) {
11447
+ stream.needsResync = true;
11448
+ stream.initializing = false;
11449
+ stream.initBuffer = [];
11450
+ return;
11451
+ }
11452
+ stream.needsResync = false;
11332
11453
  const send = (data) => {
11333
11454
  if (targetViewerId) {
11334
11455
  this.sendFn(sessionId, data, targetViewerId);
@@ -11384,6 +11505,57 @@ var SessionStreamer = class {
11384
11505
  return false;
11385
11506
  }
11386
11507
  }
11508
+ /** Build a tmux-safe env (TMUX/TMUX_PANE stripped) for liveness queries. */
11509
+ tmuxEnv() {
11510
+ const env = { ...process.env };
11511
+ delete env.TMUX;
11512
+ delete env.TMUX_PANE;
11513
+ return env;
11514
+ }
11515
+ /** Pane pid + a cheap activity fingerprint (cursor + history) for one pane. */
11516
+ readPaneInfo(paneTarget, cleanEnv) {
11517
+ try {
11518
+ const out = (0, import_node_child_process2.execFileSync)(
11519
+ "tmux",
11520
+ ["display-message", "-t", paneTarget, "-p", "#{pane_pid}|#{history_size}:#{cursor_x}:#{cursor_y}:#{pane_in_mode}"],
11521
+ { env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
11522
+ ).trim();
11523
+ const sep2 = out.indexOf("|");
11524
+ if (sep2 < 0) return null;
11525
+ return { panePid: out.slice(0, sep2), fingerprint: out.slice(sep2 + 1) };
11526
+ } catch {
11527
+ return null;
11528
+ }
11529
+ }
11530
+ /**
11531
+ * One batched tmux call → pane pid + activity fingerprint for every pane,
11532
+ * keyed by `session:window.pane` (matching parsePaneTarget). Returns null on
11533
+ * tmux failure so the watchdog skips this tick rather than acting on no data.
11534
+ */
11535
+ readAllPaneInfo() {
11536
+ try {
11537
+ const out = (0, import_node_child_process2.execFileSync)(
11538
+ "tmux",
11539
+ [
11540
+ "list-panes",
11541
+ "-a",
11542
+ "-F",
11543
+ "#{session_name}:#{window_index}.#{pane_index}|#{pane_pid}|#{history_size}:#{cursor_x}:#{cursor_y}:#{pane_in_mode}"
11544
+ ],
11545
+ { env: this.tmuxEnv(), timeout: 5e3, encoding: "utf-8" }
11546
+ );
11547
+ const map = /* @__PURE__ */ new Map();
11548
+ for (const line of out.split("\n")) {
11549
+ if (!line) continue;
11550
+ const parts = line.split("|");
11551
+ if (parts.length < 3) continue;
11552
+ map.set(parts[0], { panePid: parts[1], fingerprint: parts[2] });
11553
+ }
11554
+ return map;
11555
+ } catch {
11556
+ return null;
11557
+ }
11558
+ }
11387
11559
  /**
11388
11560
  * Capture and send the visible pane content. Converts bare \n to \r\n for
11389
11561
  * xterm.js and strips the trailing \r\n to prevent an extra scroll that
@@ -11435,7 +11607,7 @@ var SessionStreamer = class {
11435
11607
  // ---------------------------------------------------------------------------
11436
11608
  // tmux streaming (pipe-pane with cursor sync)
11437
11609
  // ---------------------------------------------------------------------------
11438
- startTmuxStream(sessionId, viewerKey = "") {
11610
+ startTmuxStream(sessionId, viewerKey = "", opts) {
11439
11611
  const paneTarget = parsePaneTarget(sessionId);
11440
11612
  if (!paneTarget) return false;
11441
11613
  try {
@@ -11485,10 +11657,19 @@ var SessionStreamer = class {
11485
11657
  log17.warn("failed pipe-pane", { sessionId, error: message });
11486
11658
  return false;
11487
11659
  }
11660
+ const paneInfo = this.readPaneInfo(paneTarget, cleanEnv);
11661
+ if (paneInfo) {
11662
+ stream.panePid = paneInfo.panePid;
11663
+ stream.lastLivenessFingerprint = paneInfo.fingerprint;
11664
+ }
11665
+ stream.bytesReceived = 0;
11666
+ stream.lastLivenessBytes = 0;
11667
+ stream.deadPipeStrikes = 0;
11488
11668
  const catProc = (0, import_node_child_process2.spawn)("cat", [fifoPath], { env: cleanEnv, stdio: ["ignore", "pipe", "ignore"] });
11489
11669
  stream.catProc = catProc;
11490
11670
  catProc.stdout.on("data", (chunk) => {
11491
11671
  if (stream.stopped) return;
11672
+ stream.bytesReceived = (stream.bytesReceived ?? 0) + chunk.length;
11492
11673
  const data = stream.utf8Decoder.write(chunk);
11493
11674
  if (!data) return;
11494
11675
  if (stream.initializing) {
@@ -11517,17 +11698,19 @@ var SessionStreamer = class {
11517
11698
  this.sendResetFn?.(sessionId, "full");
11518
11699
  const alternateScreen = this.detectAlternateScreen(paneTarget, cleanEnv);
11519
11700
  stream.alternateScreen = alternateScreen;
11520
- this.sendFn(sessionId, "\x1B[3J");
11521
- try {
11522
- const scrollback = (0, import_node_child_process2.execFileSync)(
11523
- "tmux",
11524
- ["capture-pane", "-t", paneTarget, "-p", "-e", "-S", "-5000", "-E", "-1"],
11525
- { env: cleanEnv, timeout: 1e4, encoding: "utf-8" }
11526
- );
11527
- if (scrollback && scrollback.length > 0) {
11528
- this.sendFn(sessionId, scrollback.replace(/\n/g, "\r\n"));
11701
+ if (!opts?.skipScrollback) {
11702
+ this.sendFn(sessionId, "\x1B[3J");
11703
+ try {
11704
+ const scrollback = (0, import_node_child_process2.execFileSync)(
11705
+ "tmux",
11706
+ ["capture-pane", "-t", paneTarget, "-p", "-e", "-S", "-5000", "-E", "-1"],
11707
+ { env: cleanEnv, timeout: 1e4, encoding: "utf-8" }
11708
+ );
11709
+ if (scrollback && scrollback.length > 0) {
11710
+ this.sendFn(sessionId, scrollback.replace(/\n/g, "\r\n"));
11711
+ }
11712
+ } catch {
11529
11713
  }
11530
- } catch {
11531
11714
  }
11532
11715
  this.sendFn(sessionId, "\x1B[?1049l\x1B[0m\x1B[2J\x1B[H");
11533
11716
  const sendBroadcast = (data) => this.sendFn(sessionId, data);
@@ -14544,7 +14727,11 @@ async function runAgent(rawOptions) {
14544
14727
  },
14545
14728
  () => {
14546
14729
  transcriptWatcher.resyncMarkdownAfterDrop();
14547
- }
14730
+ },
14731
+ // #556 follow-up: lets the streamer gate resync delivery + the pipe-liveness
14732
+ // watchdog on a confirmed-OPEN socket, so resync bytes aren't buffered then
14733
+ // discarded under reconnect churn and re-establish captures actually land.
14734
+ () => conn.isReady()
14548
14735
  );
14549
14736
  const conn = new ConnectionManager(sessionStreamer, outputBuffer);
14550
14737
  const sendOutput = (chunk) => {
@@ -14620,8 +14807,11 @@ async function runAgent(rawOptions) {
14620
14807
  sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
14621
14808
  }
14622
14809
  },
14623
- // V2: send full snapshot
14624
- (sessionId, snapshot, seq) => {
14810
+ // V2: send full snapshot. `reset` marks an explicit clear (new conversation
14811
+ // / transcript file rotation on pane reuse) so the portal's snapshot guard
14812
+ // lets an empty snapshot wipe stale content instead of rejecting it. See the
14813
+ // `reset` field on TranscriptSnapshotMessage (#538) and shouldApplyTranscriptSnapshot.
14814
+ (sessionId, snapshot, seq, reset) => {
14625
14815
  if (!conn.isReady()) return;
14626
14816
  const payload = {
14627
14817
  type: "markdown",
@@ -14629,7 +14819,8 @@ async function runAgent(rawOptions) {
14629
14819
  schemaVersion: 2,
14630
14820
  sessionId,
14631
14821
  seq,
14632
- snapshot
14822
+ snapshot,
14823
+ ...reset ? { reset: true } : {}
14633
14824
  };
14634
14825
  if (conn.sessionKey) {
14635
14826
  const contentStr = JSON.stringify(payload);
@@ -14724,6 +14915,7 @@ async function runAgent(rawOptions) {
14724
14915
  await new Promise((resolve) => {
14725
14916
  const ws = new import_ws5.default(negotiated.url, "json.webpubsub.azure.v1");
14726
14917
  conn.setConnection(ws, groups, negotiated.sessionKey ?? null);
14918
+ let connectedAt = 0;
14727
14919
  if (negotiated.sessionKey) {
14728
14920
  const enforcer = new ApprovalEnforcer({
14729
14921
  secret: negotiated.sessionKey,
@@ -14744,6 +14936,7 @@ async function runAgent(rawOptions) {
14744
14936
  }
14745
14937
  ws.on("open", async () => {
14746
14938
  log25.info("connector connected to relay");
14939
+ connectedAt = Date.now();
14747
14940
  conn.resetReconnectDelay();
14748
14941
  ws.send(JSON.stringify({ type: "joinGroup", group: groups.privateGroup }));
14749
14942
  ws.send(JSON.stringify({ type: "joinGroup", group: groups.registryGroup }));
@@ -14910,6 +15103,7 @@ async function runAgent(rawOptions) {
14910
15103
  const fields = {};
14911
15104
  if (code) fields.code = code;
14912
15105
  if (reason.length > 0) fields.reason = reason.toString();
15106
+ if (connectedAt > 0) fields.uptimeMs = Date.now() - connectedAt;
14913
15107
  log25.info("relay disconnected; output will be buffered until reconnect", fields);
14914
15108
  conn.cleanup();
14915
15109
  resolve();
package/dist/sbom.json CHANGED
@@ -1299,8 +1299,8 @@
1299
1299
  {
1300
1300
  "type": "library",
1301
1301
  "name": "ragent-cli",
1302
- "version": "1.11.17",
1303
- "bom-ref": "ragent-live|ragent-cli@1.11.17",
1302
+ "version": "1.11.18",
1303
+ "bom-ref": "ragent-live|ragent-cli@1.11.18",
1304
1304
  "author": "Intellimetrics",
1305
1305
  "description": "CLI agent for rAgent Live — browser-first terminal control plane for AI coding agents",
1306
1306
  "licenses": [
@@ -1311,7 +1311,7 @@
1311
1311
  }
1312
1312
  }
1313
1313
  ],
1314
- "purl": "pkg:npm/ragent-cli@1.11.17",
1314
+ "purl": "pkg:npm/ragent-cli@1.11.18",
1315
1315
  "externalReferences": [
1316
1316
  {
1317
1317
  "url": "https://github.com/chadlindell/ragent-live/issues",
@@ -1436,7 +1436,7 @@
1436
1436
  "ragent-live|@emnapi/wasi-threads@1.2.1",
1437
1437
  "ragent-live|@pkgjs/parseargs@0.11.0",
1438
1438
  "ragent-live|@tybys/wasm-util@0.10.1",
1439
- "ragent-live|ragent-cli@1.11.17"
1439
+ "ragent-live|ragent-cli@1.11.18"
1440
1440
  ]
1441
1441
  },
1442
1442
  {
@@ -1584,7 +1584,7 @@
1584
1584
  ]
1585
1585
  },
1586
1586
  {
1587
- "ref": "ragent-live|ragent-cli@1.11.17",
1587
+ "ref": "ragent-live|ragent-cli@1.11.18",
1588
1588
  "dependsOn": [
1589
1589
  "ragent-live|@azure/web-pubsub-client@1.0.4",
1590
1590
  "ragent-live|@openai/codex-sdk@0.130.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ragent-cli",
3
- "version": "1.11.17",
3
+ "version": "1.11.18",
4
4
  "description": "CLI agent for rAgent Live — browser-first terminal control plane for AI coding agents",
5
5
  "main": "dist/index.js",
6
6
  "bin": {