ragent-cli 1.11.18 → 1.11.21

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.
Files changed (3) hide show
  1. package/dist/index.js +3155 -490
  2. package/dist/sbom.json +12 -12
  3. package/package.json +2 -2
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.18",
34
+ version: "1.11.21",
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: {
@@ -90,7 +90,7 @@ var require_package = __commonJS({
90
90
  commander: "^14.0.3",
91
91
  figlet: "^1.9.3",
92
92
  "node-pty": "^1.1.0",
93
- ws: "^8.19.0"
93
+ ws: "^8.21.0"
94
94
  },
95
95
  devDependencies: {
96
96
  "@changesets/changelog-github": "^0.5.2",
@@ -421,6 +421,7 @@ var fs2 = __toESM(require("fs"));
421
421
  var path2 = __toESM(require("path"));
422
422
  var os3 = __toESM(require("os"));
423
423
  var import_child_process2 = require("child_process");
424
+ var import_string_decoder = require("string_decoder");
424
425
 
425
426
  // src/transcript-parser-v2.ts
426
427
  var log4 = createLogger("transcript-parser-v2");
@@ -786,6 +787,34 @@ function extractCodexText(content) {
786
787
  (c) => (c.type === "input_text" || c.type === "output_text" || c.type === "text") && typeof c.text === "string"
787
788
  ).map((c) => c.text).join("\n").trim();
788
789
  }
790
+ function normalizeCodexFunctionCallOutput(output) {
791
+ if (typeof output === "string") return output;
792
+ if (output == null) return "";
793
+ const formatPart = (part) => {
794
+ if (typeof part === "string") return part;
795
+ if (part == null) return "";
796
+ if (typeof part !== "object" || Array.isArray(part)) {
797
+ return String(part);
798
+ }
799
+ const content = part;
800
+ const type = typeof content.type === "string" ? content.type : "";
801
+ if ((type === "input_text" || type === "output_text" || type === "text") && typeof content.text === "string") {
802
+ return content.text;
803
+ }
804
+ if (type === "input_image" || type === "output_image" || type === "image" || typeof content.image_url === "string") {
805
+ return "[Image output omitted from transcript]";
806
+ }
807
+ try {
808
+ return JSON.stringify(part);
809
+ } catch {
810
+ return "[Unsupported tool output]";
811
+ }
812
+ };
813
+ if (Array.isArray(output)) {
814
+ return output.map(formatPart).filter((part) => part.length > 0).join("\n");
815
+ }
816
+ return formatPart(output);
817
+ }
789
818
  function parseCodexArguments(args) {
790
819
  if (typeof args !== "string" || !args) return void 0;
791
820
  try {
@@ -1081,7 +1110,7 @@ var CodexCliParserV2 = class {
1081
1110
  if (payload.type === "function_call_output") {
1082
1111
  if (!payload.call_id) return [];
1083
1112
  const pending = this.pendingTools.get(payload.call_id);
1084
- const output = payload.output ?? "";
1113
+ const output = normalizeCodexFunctionCallOutput(payload.output);
1085
1114
  const { state, errorText } = inferCodexFunctionCallOutputState(output);
1086
1115
  if (pending) {
1087
1116
  this.pendingTools.delete(payload.call_id);
@@ -1823,37 +1852,52 @@ function createEmptySnapshot(sessionId) {
1823
1852
  };
1824
1853
  }
1825
1854
  function applyOps(state, ops) {
1826
- const turns = { ...state.turns };
1827
- const turnOrder = [...state.turnOrder];
1855
+ return applyReducedOps(state, coalesceAdjacentAppendOps(ops));
1856
+ }
1857
+ function applyReducedOps(state, ops) {
1858
+ let snapshotFields = state;
1859
+ let turns = { ...state.turns };
1860
+ let turnOrder = [...state.turnOrder];
1861
+ let sizeIndex = getSnapshotSizeIndex(state);
1828
1862
  for (const op of ops) {
1863
+ let changedTurnId;
1864
+ let previousChangedTurn;
1865
+ let addedOrderId;
1829
1866
  switch (op.op) {
1830
1867
  case "upsert_turn": {
1831
1868
  const { turn } = op;
1832
1869
  const existing = turns[turn.turnId];
1870
+ previousChangedTurn = existing;
1833
1871
  if (existing) {
1834
1872
  turns[turn.turnId] = {
1835
1873
  ...existing,
1836
1874
  ...turn,
1837
- blockOrder: turn.blockOrder ?? existing.blockOrder,
1838
- blocks: turn.blocks ? { ...existing.blocks, ...turn.blocks } : existing.blocks
1875
+ blockOrder: turn.blockOrder ? [...turn.blockOrder] : existing.blockOrder,
1876
+ blocks: turn.blocks ? { ...existing.blocks, ...cloneBlocks(turn.blocks) } : existing.blocks
1839
1877
  };
1840
1878
  } else {
1879
+ if (snapshotFields.truncatedTurnIds?.includes(turn.turnId)) break;
1841
1880
  turns[turn.turnId] = {
1842
1881
  ...turn,
1843
- blockOrder: turn.blockOrder ?? [],
1844
- blocks: turn.blocks ?? {}
1882
+ blockOrder: turn.blockOrder ? [...turn.blockOrder] : [],
1883
+ blocks: turn.blocks ? cloneBlocks(turn.blocks) : {}
1845
1884
  };
1846
1885
  if (!turnOrder.includes(turn.turnId)) {
1847
1886
  turnOrder.push(turn.turnId);
1887
+ addedOrderId = turn.turnId;
1848
1888
  }
1849
1889
  }
1890
+ changedTurnId = turn.turnId;
1850
1891
  break;
1851
1892
  }
1852
1893
  case "insert_block": {
1853
1894
  const turn = turns[op.turnId];
1854
1895
  if (!turn) break;
1855
- const newBlocks = { ...turn.blocks, [op.block.blockId]: op.block };
1856
- const newBlockOrder = [...turn.blockOrder];
1896
+ previousChangedTurn = turn;
1897
+ const newBlocks = { ...turn.blocks, [op.block.blockId]: { ...op.block } };
1898
+ const newBlockOrder = turn.blockOrder.filter(
1899
+ (blockId) => blockId !== op.block.blockId
1900
+ );
1857
1901
  if (op.afterBlockId) {
1858
1902
  const idx = newBlockOrder.indexOf(op.afterBlockId);
1859
1903
  if (idx >= 0) {
@@ -1870,28 +1914,33 @@ function applyOps(state, ops) {
1870
1914
  blockOrder: newBlockOrder,
1871
1915
  updatedAt: op.block.updatedAt
1872
1916
  };
1917
+ changedTurnId = op.turnId;
1873
1918
  break;
1874
1919
  }
1875
1920
  case "replace_block": {
1876
1921
  const turn = turns[op.turnId];
1877
1922
  if (!turn || !turn.blocks[op.block.blockId]) break;
1923
+ previousChangedTurn = turn;
1878
1924
  turns[op.turnId] = {
1879
1925
  ...turn,
1880
- blocks: { ...turn.blocks, [op.block.blockId]: op.block },
1926
+ blocks: { ...turn.blocks, [op.block.blockId]: { ...op.block } },
1881
1927
  updatedAt: op.block.updatedAt
1882
1928
  };
1929
+ changedTurnId = op.turnId;
1883
1930
  break;
1884
1931
  }
1885
1932
  case "append_text": {
1886
1933
  const block = findBlock(turns, op.turnId, op.blockId);
1887
1934
  if (!block) break;
1888
1935
  if (block.kind === "text" || block.kind === "reasoning") {
1936
+ previousChangedTurn = turns[op.turnId];
1889
1937
  const updated = {
1890
1938
  ...block,
1891
- text: block.text + op.text,
1892
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1939
+ text: appendProjectedSnapshotText(block, block.text, op.text)
1893
1940
  };
1941
+ refreshTransportTruncation(updated, op.text);
1894
1942
  setBlock(turns, op.turnId, op.blockId, updated);
1943
+ changedTurnId = op.turnId;
1895
1944
  }
1896
1945
  break;
1897
1946
  }
@@ -1900,21 +1949,33 @@ function applyOps(state, ops) {
1900
1949
  if (!block || block.kind !== "tool") {
1901
1950
  const found = findBlockAcrossTurns(turns, op.blockId);
1902
1951
  if (found && found.block.kind === "tool") {
1952
+ previousChangedTurn = turns[found.turnId];
1903
1953
  const updated2 = {
1904
1954
  ...found.block,
1905
- outputText: (found.block.outputText ?? "") + op.text,
1906
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1955
+ outputText: appendProjectedSnapshotText(
1956
+ found.block,
1957
+ found.block.outputText ?? "",
1958
+ op.text
1959
+ )
1907
1960
  };
1961
+ refreshTransportTruncation(updated2, op.text);
1908
1962
  setBlock(turns, found.turnId, op.blockId, updated2);
1963
+ changedTurnId = found.turnId;
1909
1964
  }
1910
1965
  break;
1911
1966
  }
1912
1967
  const updated = {
1913
1968
  ...block,
1914
- outputText: (block.outputText ?? "") + op.text,
1915
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1969
+ outputText: appendProjectedSnapshotText(
1970
+ block,
1971
+ block.outputText ?? "",
1972
+ op.text
1973
+ )
1916
1974
  };
1975
+ previousChangedTurn = turns[op.turnId];
1976
+ refreshTransportTruncation(updated, op.text);
1917
1977
  setBlock(turns, op.turnId, op.blockId, updated);
1978
+ changedTurnId = op.turnId;
1918
1979
  break;
1919
1980
  }
1920
1981
  case "set_tool_state": {
@@ -1934,10 +1995,11 @@ function applyOps(state, ops) {
1934
1995
  state: op.state,
1935
1996
  ...op.exitCode !== void 0 && { exitCode: op.exitCode },
1936
1997
  ...op.errorText !== void 0 && { errorText: op.errorText },
1937
- ...op.isComplete !== void 0 && { isComplete: op.isComplete },
1938
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1998
+ ...op.isComplete !== void 0 && { isComplete: op.isComplete }
1939
1999
  };
2000
+ previousChangedTurn = turns[resolvedTurnId];
1940
2001
  setBlock(turns, resolvedTurnId, op.blockId, updated);
2002
+ changedTurnId = resolvedTurnId;
1941
2003
  break;
1942
2004
  }
1943
2005
  case "complete_block": {
@@ -1957,27 +2019,93 @@ function applyOps(state, ops) {
1957
2019
  isComplete: true,
1958
2020
  updatedAt: op.updatedAt
1959
2021
  };
2022
+ previousChangedTurn = turns[resolvedTurnId];
1960
2023
  setBlock(turns, resolvedTurnId, op.blockId, updated);
2024
+ changedTurnId = resolvedTurnId;
1961
2025
  break;
1962
2026
  }
1963
2027
  case "complete_turn": {
1964
2028
  const turn = turns[op.turnId];
1965
2029
  if (!turn) break;
2030
+ previousChangedTurn = turn;
1966
2031
  turns[op.turnId] = {
1967
2032
  ...turn,
1968
2033
  isComplete: true,
1969
2034
  endedAt: op.endedAt,
1970
2035
  updatedAt: op.updatedAt
1971
2036
  };
2037
+ changedTurnId = op.turnId;
1972
2038
  break;
1973
2039
  }
1974
2040
  }
2041
+ if (changedTurnId) {
2042
+ updateIndexedTurn(
2043
+ sizeIndex,
2044
+ changedTurnId,
2045
+ previousChangedTurn,
2046
+ turns[changedTurnId]
2047
+ );
2048
+ }
2049
+ if (addedOrderId) addIndexedTurnOrder(sizeIndex, addedOrderId);
2050
+ const projected = projectWorkingSnapshot(
2051
+ snapshotFields,
2052
+ turnOrder,
2053
+ turns,
2054
+ sizeIndex
2055
+ );
2056
+ snapshotFields = projected.snapshotFields;
2057
+ turnOrder = projected.turnOrder;
2058
+ turns = projected.turns;
2059
+ sizeIndex = projected.sizeIndex;
1975
2060
  }
1976
- return {
1977
- ...state,
2061
+ const result = {
2062
+ ...snapshotFields,
1978
2063
  turnOrder,
1979
2064
  turns
1980
2065
  };
2066
+ cacheSnapshotSizeIndex(result, sizeIndex);
2067
+ return result;
2068
+ }
2069
+ function cloneBlocks(blocks) {
2070
+ const cloned = {};
2071
+ for (const [blockId, block] of Object.entries(blocks)) {
2072
+ cloned[blockId] = { ...block };
2073
+ }
2074
+ return cloned;
2075
+ }
2076
+ function coalesceAdjacentAppendOps(ops) {
2077
+ const result = [];
2078
+ let pending;
2079
+ const flush = () => {
2080
+ if (!pending) return;
2081
+ result.push({
2082
+ op: pending.op,
2083
+ turnId: pending.turnId,
2084
+ blockId: pending.blockId,
2085
+ text: pending.chunks.join("")
2086
+ });
2087
+ pending = void 0;
2088
+ };
2089
+ for (const op of ops) {
2090
+ if (op.op === "append_text" || op.op === "append_tool_output") {
2091
+ if (pending?.op === op.op && pending.turnId === op.turnId && pending.blockId === op.blockId) {
2092
+ pending.chunks.push(op.text);
2093
+ } else {
2094
+ flush();
2095
+ pending = {
2096
+ op: op.op,
2097
+ turnId: op.turnId,
2098
+ blockId: op.blockId,
2099
+ chunks: [op.text]
2100
+ };
2101
+ }
2102
+ continue;
2103
+ }
2104
+ flush();
2105
+ result.push(op);
2106
+ }
2107
+ flush();
2108
+ return result;
1981
2109
  }
1982
2110
  function findBlock(turns, turnId, blockId) {
1983
2111
  return turns[turnId]?.blocks[blockId];
@@ -1999,50 +2127,944 @@ function setBlock(turns, turnId, blockId, block) {
1999
2127
  };
2000
2128
  }
2001
2129
  var MAX_SNAPSHOT_TURNS = 1e3;
2002
- var MAX_SNAPSHOT_SIZE_CHARS = 45e4;
2003
- var SNAPSHOT_FIELD_RESERVE = 64;
2004
- function trimSnapshot(snapshot, maxTurns = MAX_SNAPSHOT_TURNS) {
2005
- let currentSnapshot = snapshot;
2006
- const originalLength = snapshot.turnOrder.length;
2007
- if (currentSnapshot.turnOrder.length > maxTurns) {
2008
- const keptOrder = currentSnapshot.turnOrder.slice(-maxTurns);
2009
- const keptTurns = {};
2010
- for (const id of keptOrder) {
2011
- if (currentSnapshot.turns[id]) {
2012
- keptTurns[id] = currentSnapshot.turns[id];
2130
+ var MAX_SNAPSHOT_SIZE_BYTES = 448 * 1024;
2131
+ var MAX_TRUNCATED_TURN_ID_BYTES = 16 * 1024;
2132
+ var SNAPSHOT_FIELD_RESERVE = MAX_TRUNCATED_TURN_ID_BYTES + 512;
2133
+ var MAX_SNAPSHOT_BLOCK_FIELD_BYTES = 96 * 1024;
2134
+ var MAX_SNAPSHOT_IDENTIFIER_BYTES = 4 * 1024;
2135
+ var MAX_SNAPSHOT_TIMESTAMP_BYTES = 1024;
2136
+ var SNAPSHOT_TRUNCATION_MARKER = "\n\n[Content truncated for remote display]\n\n";
2137
+ var TRANSPORT_NOTICE_METADATA_KEY = "ragentTransportTruncationNotice";
2138
+ var TRANSPORT_OMITTED_BLOCKS_KEY = "ragentTransportOmittedBlocks";
2139
+ var utf8Encoder = new TextEncoder();
2140
+ function utf8Size(value) {
2141
+ return utf8Encoder.encode(value).byteLength;
2142
+ }
2143
+ function serializedBytes(value) {
2144
+ return utf8Size(JSON.stringify(value));
2145
+ }
2146
+ var snapshotSizeCache = /* @__PURE__ */ new WeakMap();
2147
+ function cloneSnapshotSizeIndex(index) {
2148
+ return { ...index };
2149
+ }
2150
+ function snapshotSizeFromIndex(index) {
2151
+ return index.shellBytes + index.orderBytes + index.turnMapBytes;
2152
+ }
2153
+ function buildSnapshotSizeIndex(snapshot) {
2154
+ const shellBytes = serializedBytes({
2155
+ ...snapshot,
2156
+ turnOrder: [],
2157
+ turns: {}
2158
+ });
2159
+ let orderBytes = 0;
2160
+ for (let index = 0; index < snapshot.turnOrder.length; index++) {
2161
+ orderBytes += utf8Size(JSON.stringify(snapshot.turnOrder[index])) + (index > 0 ? 1 : 0);
2162
+ }
2163
+ let turnMapBytes = 0;
2164
+ let turnIndex = 0;
2165
+ for (const [turnId, turn] of Object.entries(snapshot.turns)) {
2166
+ const bytes = serializedBytes(turn);
2167
+ turnMapBytes += utf8Size(JSON.stringify(turnId)) + 1 + bytes + (turnIndex > 0 ? 1 : 0);
2168
+ turnIndex++;
2169
+ }
2170
+ return {
2171
+ shellBytes,
2172
+ orderBytes,
2173
+ orderCount: snapshot.turnOrder.length,
2174
+ turnMapBytes,
2175
+ turnCount: turnIndex
2176
+ };
2177
+ }
2178
+ function getSnapshotSizeIndex(snapshot) {
2179
+ const cached = snapshotSizeCache.get(snapshot);
2180
+ return cached ? cloneSnapshotSizeIndex(cached) : buildSnapshotSizeIndex(snapshot);
2181
+ }
2182
+ function cacheSnapshotSizeIndex(snapshot, index) {
2183
+ snapshotSizeCache.set(snapshot, cloneSnapshotSizeIndex(index));
2184
+ }
2185
+ function updateIndexedTurn(index, turnId, previousTurn, nextTurn) {
2186
+ const previousBytes = previousTurn ? serializedBytes(previousTurn) : void 0;
2187
+ if (!nextTurn) {
2188
+ if (previousBytes === void 0) return;
2189
+ index.turnMapBytes -= utf8Size(JSON.stringify(turnId)) + 1 + previousBytes + (index.turnCount > 1 ? 1 : 0);
2190
+ index.turnCount--;
2191
+ return;
2192
+ }
2193
+ const nextBytes = serializedBytes(nextTurn);
2194
+ if (previousBytes === void 0) {
2195
+ index.turnMapBytes += utf8Size(JSON.stringify(turnId)) + 1 + nextBytes + (index.turnCount > 0 ? 1 : 0);
2196
+ index.turnCount++;
2197
+ } else {
2198
+ index.turnMapBytes += nextBytes - previousBytes;
2199
+ }
2200
+ }
2201
+ function addIndexedTurnOrder(index, turnId) {
2202
+ index.orderBytes += utf8Size(JSON.stringify(turnId)) + (index.orderCount > 0 ? 1 : 0);
2203
+ index.orderCount++;
2204
+ }
2205
+ function removeIndexedTurnOrder(index, turnId) {
2206
+ if (index.orderCount <= 0) return;
2207
+ index.orderBytes -= utf8Size(JSON.stringify(turnId)) + (index.orderCount > 1 ? 1 : 0);
2208
+ index.orderCount--;
2209
+ }
2210
+ function refreshIndexedShell(index, snapshotFields) {
2211
+ index.shellBytes = serializedBytes({
2212
+ ...snapshotFields,
2213
+ turnOrder: [],
2214
+ turns: {}
2215
+ });
2216
+ }
2217
+ function projectWorkingSnapshot(initialFields, turnOrder, turns, sizeIndex) {
2218
+ let snapshotFields = initialFields;
2219
+ const sizeBudget = MAX_SNAPSHOT_SIZE_BYTES - SNAPSHOT_FIELD_RESERVE;
2220
+ while ((turnOrder.length > MAX_SNAPSHOT_TURNS || snapshotSizeFromIndex(sizeIndex) > sizeBudget) && turnOrder.length > 1) {
2221
+ const dropped = [];
2222
+ while ((turnOrder.length > MAX_SNAPSHOT_TURNS || snapshotSizeFromIndex(sizeIndex) > sizeBudget) && turnOrder.length > 1) {
2223
+ const turnId = turnOrder.shift();
2224
+ if (!turnId) break;
2225
+ removeIndexedTurnOrder(sizeIndex, turnId);
2226
+ if (!turnOrder.includes(turnId)) {
2227
+ updateIndexedTurn(sizeIndex, turnId, turns[turnId], void 0);
2228
+ delete turns[turnId];
2229
+ }
2230
+ dropped.push(turnId);
2231
+ }
2232
+ if (dropped.length === 0) break;
2233
+ snapshotFields = {
2234
+ ...snapshotFields,
2235
+ truncatedTurns: (snapshotFields.truncatedTurns ?? 0) + dropped.length,
2236
+ truncatedTurnIds: mergeTruncatedTurnIds(
2237
+ snapshotFields.truncatedTurnIds,
2238
+ dropped
2239
+ )
2240
+ };
2241
+ refreshIndexedShell(sizeIndex, snapshotFields);
2242
+ }
2243
+ const soleTurnId = turnOrder.length === 1 ? turnOrder[0] : void 0;
2244
+ const soleTurn = soleTurnId ? turns[soleTurnId] : void 0;
2245
+ const omittedBlocks = soleTurn?.metadata?.[TRANSPORT_OMITTED_BLOCKS_KEY];
2246
+ const missingSyntheticNotice = typeof omittedBlocks === "number" && omittedBlocks > 0 && !soleTurn?.blockOrder.some((blockId) => soleTurn.blocks[blockId]?.metadata?.[TRANSPORT_NOTICE_METADATA_KEY] === true);
2247
+ if (soleTurn && (snapshotSizeFromIndex(sizeIndex) > sizeBudget || missingSyntheticNotice)) {
2248
+ let bounded = boundSingleTurnSnapshot({
2249
+ ...snapshotFields,
2250
+ turnOrder,
2251
+ turns
2252
+ });
2253
+ let boundedIndex = buildSnapshotSizeIndex(bounded);
2254
+ if (snapshotSizeFromIndex(boundedIndex) > MAX_SNAPSHOT_SIZE_BYTES) {
2255
+ bounded = minimalTransportSnapshot(bounded, bounded.truncatedTurns);
2256
+ boundedIndex = buildSnapshotSizeIndex(bounded);
2257
+ }
2258
+ return {
2259
+ snapshotFields: bounded,
2260
+ turnOrder: [...bounded.turnOrder],
2261
+ turns: { ...bounded.turns },
2262
+ sizeIndex: boundedIndex
2263
+ };
2264
+ }
2265
+ return { snapshotFields, turnOrder, turns, sizeIndex };
2266
+ }
2267
+ function prefixWithinBytes(value, maxBytes) {
2268
+ let low = 0;
2269
+ let high = value.length;
2270
+ while (low < high) {
2271
+ const mid = Math.ceil((low + high) / 2);
2272
+ if (utf8Size(value.slice(0, mid)) <= maxBytes) low = mid;
2273
+ else high = mid - 1;
2274
+ }
2275
+ if (low > 0 && /[\uD800-\uDBFF]/.test(value[low - 1] ?? "")) low--;
2276
+ return value.slice(0, low);
2277
+ }
2278
+ function suffixWithinBytes(value, maxBytes) {
2279
+ let low = 0;
2280
+ let high = value.length;
2281
+ while (low < high) {
2282
+ const count = Math.ceil((low + high) / 2);
2283
+ if (utf8Size(value.slice(value.length - count)) <= maxBytes) low = count;
2284
+ else high = count - 1;
2285
+ }
2286
+ let start = value.length - low;
2287
+ if (start < value.length && /[\uDC00-\uDFFF]/.test(value[start] ?? "")) start++;
2288
+ return value.slice(start);
2289
+ }
2290
+ function truncateSnapshotText(value, maxBytes) {
2291
+ if (utf8Size(value) <= maxBytes) return value;
2292
+ const markerBytes = utf8Size(SNAPSHOT_TRUNCATION_MARKER);
2293
+ if (maxBytes <= markerBytes) {
2294
+ return prefixWithinBytes(SNAPSHOT_TRUNCATION_MARKER, maxBytes);
2295
+ }
2296
+ const available = maxBytes - markerBytes;
2297
+ const markerIndex = value.indexOf(SNAPSHOT_TRUNCATION_MARKER);
2298
+ const headSource = markerIndex >= 0 ? value.slice(0, markerIndex) : value;
2299
+ const tailSource = markerIndex >= 0 ? value.slice(markerIndex + SNAPSHOT_TRUNCATION_MARKER.length) : value;
2300
+ const head = prefixWithinBytes(headSource, Math.floor(available * 0.75));
2301
+ const tail = suffixWithinBytes(tailSource, available - utf8Size(head));
2302
+ return `${head}${SNAPSHOT_TRUNCATION_MARKER}${tail}`;
2303
+ }
2304
+ function appendProjectedSnapshotText(block, current, addition) {
2305
+ if (block.truncation?.reason === "transport_limit" && current.includes(SNAPSHOT_TRUNCATION_MARKER)) {
2306
+ const markerIndex = current.indexOf(SNAPSHOT_TRUNCATION_MARKER);
2307
+ const head = current.slice(0, markerIndex);
2308
+ const tail = current.slice(markerIndex + SNAPSHOT_TRUNCATION_MARKER.length);
2309
+ const tailBudget = Math.max(
2310
+ 0,
2311
+ utf8Size(current) - utf8Size(head) - utf8Size(SNAPSHOT_TRUNCATION_MARKER)
2312
+ );
2313
+ return `${head}${SNAPSHOT_TRUNCATION_MARKER}${suffixWithinBytes(
2314
+ tail + addition,
2315
+ tailBudget
2316
+ )}`;
2317
+ }
2318
+ return current + addition;
2319
+ }
2320
+ function refreshTransportTruncation(block, _addition) {
2321
+ const projectedText = block.kind === "text" || block.kind === "reasoning" ? block.text : block.kind === "tool" ? block.outputText : void 0;
2322
+ if (block.truncation?.reason !== "transport_limit" || !projectedText?.includes(SNAPSHOT_TRUNCATION_MARKER)) {
2323
+ return;
2324
+ }
2325
+ const { truncation: _truncation, ...withoutTruncation } = block;
2326
+ block.truncation = {
2327
+ reason: "transport_limit",
2328
+ keptBytes: serializedBytes(withoutTruncation),
2329
+ didWarnUser: true
2330
+ };
2331
+ }
2332
+ function boundSnapshotBlock(block, fieldBudget = MAX_SNAPSHOT_BLOCK_FIELD_BYTES) {
2333
+ const { truncation: existingTruncation, ...originalWithoutTruncation } = block;
2334
+ const originalBytes = serializedBytes(originalWithoutTruncation);
2335
+ let bounded;
2336
+ switch (block.kind) {
2337
+ case "text":
2338
+ case "reasoning":
2339
+ bounded = { ...block, metadata: void 0, text: truncateSnapshotText(block.text, fieldBudget) };
2340
+ break;
2341
+ case "tool": {
2342
+ let input = block.input;
2343
+ if (input !== void 0 && serializedBytes(input) > 16 * 1024) {
2344
+ input = "[Tool input truncated for remote display]";
2345
+ }
2346
+ bounded = {
2347
+ ...block,
2348
+ metadata: void 0,
2349
+ callId: truncateSnapshotText(block.callId, MAX_SNAPSHOT_IDENTIFIER_BYTES),
2350
+ toolName: truncateSnapshotText(block.toolName, 2 * 1024),
2351
+ input,
2352
+ outputText: block.outputText === void 0 ? void 0 : truncateSnapshotText(block.outputText, fieldBudget),
2353
+ errorText: block.errorText === void 0 ? void 0 : truncateSnapshotText(block.errorText, 32 * 1024),
2354
+ outputMimeType: block.outputMimeType === void 0 ? void 0 : truncateSnapshotText(block.outputMimeType, 2 * 1024)
2355
+ };
2356
+ break;
2357
+ }
2358
+ case "status":
2359
+ bounded = {
2360
+ ...block,
2361
+ metadata: void 0,
2362
+ statusType: truncateSnapshotText(block.statusType, 2 * 1024),
2363
+ text: block.text === void 0 ? void 0 : truncateSnapshotText(block.text, 32 * 1024)
2364
+ };
2365
+ break;
2366
+ case "unknown":
2367
+ bounded = {
2368
+ ...block,
2369
+ metadata: void 0,
2370
+ rawKind: truncateSnapshotText(block.rawKind, 2 * 1024),
2371
+ rawPayload: block.rawPayload !== void 0 && serializedBytes(block.rawPayload) > 16 * 1024 ? "[Unknown payload truncated for remote display]" : block.rawPayload,
2372
+ fallbackText: block.fallbackText === void 0 ? void 0 : truncateSnapshotText(block.fallbackText, 32 * 1024)
2373
+ };
2374
+ break;
2375
+ case "attachment":
2376
+ bounded = {
2377
+ ...block,
2378
+ metadata: void 0,
2379
+ mediaType: truncateSnapshotText(block.mediaType, 2 * 1024),
2380
+ name: block.name === void 0 ? void 0 : truncateSnapshotText(block.name, 2 * 1024),
2381
+ uri: block.uri === void 0 ? void 0 : truncateSnapshotText(block.uri, 8 * 1024),
2382
+ path: block.path === void 0 ? void 0 : truncateSnapshotText(block.path, 8 * 1024)
2383
+ };
2384
+ break;
2385
+ }
2386
+ bounded = {
2387
+ ...bounded,
2388
+ blockId: truncateSnapshotText(block.blockId, MAX_SNAPSHOT_IDENTIFIER_BYTES),
2389
+ createdAt: truncateSnapshotText(block.createdAt, MAX_SNAPSHOT_TIMESTAMP_BYTES),
2390
+ updatedAt: truncateSnapshotText(block.updatedAt, MAX_SNAPSHOT_TIMESTAMP_BYTES),
2391
+ metadata: void 0
2392
+ };
2393
+ const { truncation: _boundedTruncation, ...boundedWithoutTruncation } = bounded;
2394
+ const keptBytes = serializedBytes(boundedWithoutTruncation);
2395
+ if (keptBytes < originalBytes || existingTruncation?.reason === "transport_limit") {
2396
+ bounded = {
2397
+ ...boundedWithoutTruncation,
2398
+ truncation: {
2399
+ reason: "transport_limit",
2400
+ keptBytes,
2401
+ didWarnUser: true
2402
+ }
2403
+ };
2404
+ } else if (existingTruncation) {
2405
+ bounded = { ...boundedWithoutTruncation, truncation: existingTruncation };
2406
+ } else {
2407
+ bounded = boundedWithoutTruncation;
2408
+ }
2409
+ return bounded;
2410
+ }
2411
+ function boundSingleTurnSnapshot(snapshot) {
2412
+ const originalTurnId = snapshot.turnOrder[0];
2413
+ const originalTurn = originalTurnId ? snapshot.turns[originalTurnId] : void 0;
2414
+ if (!originalTurnId || !originalTurn) return snapshot;
2415
+ const turnId = truncateSnapshotText(originalTurnId, MAX_SNAPSHOT_IDENTIFIER_BYTES);
2416
+ const boundedEntries = [];
2417
+ const usedBlockIds = /* @__PURE__ */ new Set();
2418
+ const storedDroppedBlocks = originalTurn.metadata?.[TRANSPORT_OMITTED_BLOCKS_KEY];
2419
+ let previouslyDroppedBlocks = typeof storedDroppedBlocks === "number" ? storedDroppedBlocks : 0;
2420
+ let collisionDrops = 0;
2421
+ const sourceOrder = originalTurn.blockOrder.length > 0 ? originalTurn.blockOrder : Object.keys(originalTurn.blocks);
2422
+ for (const originalBlockId of sourceOrder) {
2423
+ const block = originalTurn.blocks[originalBlockId];
2424
+ if (!block) continue;
2425
+ if (block.kind === "status" && block.metadata?.[TRANSPORT_NOTICE_METADATA_KEY] === true) {
2426
+ const metadataCount = block.metadata.omittedBlocks;
2427
+ const priorCount = typeof metadataCount === "number" ? metadataCount : Number.parseInt(block.text?.match(/(\d+) earlier blocks omitted/)?.[1] ?? "0", 10);
2428
+ previouslyDroppedBlocks = Math.max(
2429
+ previouslyDroppedBlocks,
2430
+ Number.isFinite(priorCount) ? priorCount : 0
2431
+ );
2432
+ continue;
2433
+ }
2434
+ const bounded = boundSnapshotBlock(block);
2435
+ if (usedBlockIds.has(bounded.blockId)) {
2436
+ collisionDrops++;
2437
+ continue;
2438
+ }
2439
+ usedBlockIds.add(bounded.blockId);
2440
+ boundedEntries.push({ id: bounded.blockId, block: bounded });
2441
+ }
2442
+ const boundedTurn = {
2443
+ ...originalTurn,
2444
+ turnId,
2445
+ startedAt: truncateSnapshotText(originalTurn.startedAt, MAX_SNAPSHOT_TIMESTAMP_BYTES),
2446
+ updatedAt: truncateSnapshotText(originalTurn.updatedAt, MAX_SNAPSHOT_TIMESTAMP_BYTES),
2447
+ endedAt: originalTurn.endedAt === void 0 ? void 0 : truncateSnapshotText(originalTurn.endedAt, MAX_SNAPSHOT_TIMESTAMP_BYTES),
2448
+ agent: originalTurn.agent === void 0 ? void 0 : {
2449
+ vendor: originalTurn.agent.vendor === void 0 ? void 0 : truncateSnapshotText(originalTurn.agent.vendor, 2 * 1024),
2450
+ agentType: originalTurn.agent.agentType === void 0 ? void 0 : truncateSnapshotText(originalTurn.agent.agentType, 2 * 1024),
2451
+ name: originalTurn.agent.name === void 0 ? void 0 : truncateSnapshotText(originalTurn.agent.name, 2 * 1024),
2452
+ version: originalTurn.agent.version === void 0 ? void 0 : truncateSnapshotText(originalTurn.agent.version, 2 * 1024),
2453
+ transcriptSource: originalTurn.agent.transcriptSource
2454
+ },
2455
+ metadata: void 0,
2456
+ blockOrder: [],
2457
+ blocks: {}
2458
+ };
2459
+ const build = (entries, notice2, omittedBlocks = previouslyDroppedBlocks + collisionDrops) => {
2460
+ const blockOrder = notice2 ? [notice2.blockId, ...entries.map((entry) => entry.id)] : entries.map((entry) => entry.id);
2461
+ const blocks = {};
2462
+ if (notice2) blocks[notice2.blockId] = notice2;
2463
+ for (const entry of entries) blocks[entry.id] = entry.block;
2464
+ return {
2465
+ ...snapshot,
2466
+ turnOrder: [turnId],
2467
+ turns: {
2468
+ [turnId]: {
2469
+ ...boundedTurn,
2470
+ metadata: omittedBlocks > 0 ? { [TRANSPORT_OMITTED_BLOCKS_KEY]: omittedBlocks } : void 0,
2471
+ blockOrder,
2472
+ blocks
2473
+ }
2013
2474
  }
2475
+ };
2476
+ };
2477
+ const budget = MAX_SNAPSHOT_SIZE_BYTES - SNAPSHOT_FIELD_RESERVE;
2478
+ const emptyResult = build([]);
2479
+ const emptyBytes = serializedBytes(emptyResult);
2480
+ let allEntriesBytes = emptyBytes;
2481
+ for (let index = 0; index < boundedEntries.length; index++) {
2482
+ const entry = boundedEntries[index];
2483
+ allEntriesBytes += utf8Size(JSON.stringify(entry.id)) + utf8Size(JSON.stringify(entry.id)) + 1 + serializedBytes(entry.block) + (index > 0 ? 2 : 0);
2484
+ }
2485
+ const hasVisibleTruncation = boundedEntries.some(({ block }) => block.truncation?.didWarnUser === true || "text" in block && typeof block.text === "string" && block.text.includes("[Content truncated for remote display]"));
2486
+ const sizeRequiresDrops = allEntriesBytes > budget;
2487
+ const knownDrops = previouslyDroppedBlocks + collisionDrops;
2488
+ const needsNotice = knownDrops > 0 || sizeRequiresDrops || !hasVisibleTruncation;
2489
+ let noticeId = "__transport_truncation_notice__";
2490
+ while (usedBlockIds.has(noticeId)) noticeId = `_${noticeId}`;
2491
+ const makeNotice = (droppedBlocks2) => ({
2492
+ blockId: noticeId,
2493
+ kind: "status",
2494
+ statusType: "notice",
2495
+ text: droppedBlocks2 > 0 ? `[Content truncated for remote display] ${droppedBlocks2} earlier blocks omitted.` : "[Content truncated for remote display]",
2496
+ level: "warning",
2497
+ isComplete: true,
2498
+ createdAt: boundedTurn.startedAt,
2499
+ updatedAt: boundedTurn.updatedAt,
2500
+ metadata: {
2501
+ [TRANSPORT_NOTICE_METADATA_KEY]: true,
2502
+ omittedBlocks: droppedBlocks2
2014
2503
  }
2015
- currentSnapshot = {
2016
- ...currentSnapshot,
2017
- turnOrder: keptOrder,
2018
- turns: keptTurns
2504
+ });
2505
+ const maximumDrops = knownDrops + boundedEntries.length;
2506
+ const reserveNotice = needsNotice ? makeNotice(maximumDrops) : void 0;
2507
+ let retainedBytes = serializedBytes(build([], reserveNotice, maximumDrops));
2508
+ let retainedCount = 0;
2509
+ let retainedStart = boundedEntries.length;
2510
+ for (let index = boundedEntries.length - 1; index >= 0; index--) {
2511
+ const entry = boundedEntries[index];
2512
+ const addedBytes = utf8Size(JSON.stringify(entry.id)) + utf8Size(JSON.stringify(entry.id)) + 1 + serializedBytes(entry.block) + (reserveNotice || retainedCount > 0 ? 2 : 0);
2513
+ if (retainedBytes + addedBytes > budget) break;
2514
+ retainedBytes += addedBytes;
2515
+ retainedCount++;
2516
+ retainedStart = index;
2517
+ }
2518
+ let retained = boundedEntries.slice(retainedStart);
2519
+ if (retained.length === 0 && boundedEntries.length > 0) {
2520
+ const newest = boundedEntries.at(-1);
2521
+ retained = [{ ...newest, block: boundSnapshotBlock(newest.block, 32 * 1024) }];
2522
+ }
2523
+ const droppedBlocks = knownDrops + boundedEntries.length - retained.length;
2524
+ const notice = needsNotice ? makeNotice(droppedBlocks) : void 0;
2525
+ let result = build(retained, notice, droppedBlocks);
2526
+ if (serializedBytes(result) > budget) {
2527
+ result = build([], makeNotice(maximumDrops), maximumDrops);
2528
+ }
2529
+ return result;
2530
+ }
2531
+ function minimalTransportSnapshot(snapshot, truncatedTurns) {
2532
+ const originalTurnId = snapshot.turnOrder.at(-1);
2533
+ const originalTurn = originalTurnId ? snapshot.turns[originalTurnId] : void 0;
2534
+ const sessionId = truncateSnapshotText(snapshot.sessionId, MAX_SNAPSHOT_IDENTIFIER_BYTES);
2535
+ if (!originalTurnId || !originalTurn) {
2536
+ return {
2537
+ schemaVersion: snapshot.schemaVersion,
2538
+ sessionId,
2539
+ turnOrder: [],
2540
+ turns: {},
2541
+ ...truncatedTurns === void 0 ? {} : { truncatedTurns },
2542
+ ...snapshot.truncatedTurnIds === void 0 ? {} : { truncatedTurnIds: snapshot.truncatedTurnIds }
2019
2543
  };
2020
2544
  }
2021
- let serialized = JSON.stringify(currentSnapshot);
2022
- const sizeBudget = MAX_SNAPSHOT_SIZE_CHARS - SNAPSHOT_FIELD_RESERVE;
2023
- while (currentSnapshot.turnOrder.length > 1 && serialized.length > sizeBudget) {
2024
- const keptOrder = currentSnapshot.turnOrder.slice(1);
2545
+ const turnId = truncateSnapshotText(originalTurnId, MAX_SNAPSHOT_IDENTIFIER_BYTES) || "__transport_truncated_turn__";
2546
+ const blockId = "__transport_truncation_notice__";
2547
+ const startedAt = truncateSnapshotText(
2548
+ originalTurn.startedAt,
2549
+ MAX_SNAPSHOT_TIMESTAMP_BYTES
2550
+ );
2551
+ const updatedAt = truncateSnapshotText(
2552
+ originalTurn.updatedAt,
2553
+ MAX_SNAPSHOT_TIMESTAMP_BYTES
2554
+ );
2555
+ return {
2556
+ schemaVersion: snapshot.schemaVersion,
2557
+ sessionId,
2558
+ turnOrder: [turnId],
2559
+ turns: {
2560
+ [turnId]: {
2561
+ turnId,
2562
+ role: originalTurn.role,
2563
+ startedAt,
2564
+ updatedAt,
2565
+ endedAt: originalTurn.endedAt === void 0 ? void 0 : truncateSnapshotText(originalTurn.endedAt, MAX_SNAPSHOT_TIMESTAMP_BYTES),
2566
+ isComplete: originalTurn.isComplete,
2567
+ blockOrder: [blockId],
2568
+ blocks: {
2569
+ [blockId]: {
2570
+ blockId,
2571
+ kind: "status",
2572
+ statusType: "notice",
2573
+ text: "[Content truncated for remote display]",
2574
+ level: "warning",
2575
+ isComplete: true,
2576
+ createdAt: startedAt,
2577
+ updatedAt,
2578
+ metadata: {
2579
+ [TRANSPORT_NOTICE_METADATA_KEY]: true,
2580
+ omittedBlocks: 0
2581
+ },
2582
+ truncation: {
2583
+ reason: "transport_limit",
2584
+ keptBytes: 0,
2585
+ didWarnUser: true
2586
+ }
2587
+ }
2588
+ }
2589
+ }
2590
+ },
2591
+ ...truncatedTurns === void 0 ? {} : { truncatedTurns },
2592
+ ...snapshot.truncatedTurnIds === void 0 ? {} : { truncatedTurnIds: snapshot.truncatedTurnIds }
2593
+ };
2594
+ }
2595
+ function mergeTruncatedTurnIds(existing, dropped) {
2596
+ if ((existing?.length ?? 0) === 0 && dropped.length === 0) return void 0;
2597
+ const newestFirst = [];
2598
+ const seen = /* @__PURE__ */ new Set();
2599
+ const candidates = [...existing ?? [], ...dropped];
2600
+ let bytes = 2;
2601
+ for (let index = candidates.length - 1; index >= 0; index--) {
2602
+ const id = candidates[index];
2603
+ if (seen.has(id)) continue;
2604
+ const idBytes = utf8Size(JSON.stringify(id));
2605
+ const added = idBytes + (newestFirst.length > 0 ? 1 : 0);
2606
+ if (bytes + added > MAX_TRUNCATED_TURN_ID_BYTES) break;
2607
+ bytes += added;
2608
+ seen.add(id);
2609
+ newestFirst.push(id);
2610
+ }
2611
+ return newestFirst.reverse();
2612
+ }
2613
+ function trimSnapshot(snapshot, maxTurns = MAX_SNAPSHOT_TURNS) {
2614
+ let currentSnapshot = snapshot;
2615
+ const originalLength = snapshot.turnOrder.length;
2616
+ const sizeBudget = MAX_SNAPSHOT_SIZE_BYTES - SNAPSHOT_FIELD_RESERVE;
2617
+ const countFloor = Math.max(0, originalLength - Math.max(1, maxTurns));
2618
+ const alreadyWithinCount = countFloor === 0;
2619
+ const originalSize = alreadyWithinCount ? serializedBytes(snapshot) : Number.POSITIVE_INFINITY;
2620
+ let knownCurrentSize = alreadyWithinCount ? originalSize : void 0;
2621
+ if (!alreadyWithinCount || originalSize > sizeBudget) {
2622
+ const shell = {
2623
+ ...snapshot,
2624
+ turnOrder: [],
2625
+ turns: {}
2626
+ };
2627
+ let retainedBytes = serializedBytes(shell);
2628
+ let retainedOrderCount = 0;
2629
+ let retainedTurnCount = 0;
2630
+ let retainedStart = originalLength;
2631
+ for (let index = originalLength - 1; index >= countFloor; index--) {
2632
+ const id = snapshot.turnOrder[index];
2633
+ const turn = snapshot.turns[id];
2634
+ const addedOrderBytes = utf8Size(JSON.stringify(id)) + (retainedOrderCount > 0 ? 1 : 0);
2635
+ const addedTurnBytes = turn ? utf8Size(JSON.stringify(id)) + 1 + serializedBytes(turn) + (retainedTurnCount > 0 ? 1 : 0) : 0;
2636
+ if (retainedBytes + addedOrderBytes + addedTurnBytes > sizeBudget) break;
2637
+ retainedBytes += addedOrderBytes + addedTurnBytes;
2638
+ retainedOrderCount++;
2639
+ if (turn) retainedTurnCount++;
2640
+ retainedStart = index;
2641
+ }
2642
+ if (retainedStart === originalLength && originalLength > 0) {
2643
+ retainedStart = originalLength - 1;
2644
+ }
2645
+ const keptOrder = snapshot.turnOrder.slice(retainedStart);
2025
2646
  const keptTurns = {};
2026
2647
  for (const id of keptOrder) {
2027
- if (currentSnapshot.turns[id]) {
2028
- keptTurns[id] = currentSnapshot.turns[id];
2029
- }
2648
+ const turn = snapshot.turns[id];
2649
+ if (turn) keptTurns[id] = turn;
2030
2650
  }
2031
2651
  currentSnapshot = {
2032
- ...currentSnapshot,
2652
+ ...snapshot,
2033
2653
  turnOrder: keptOrder,
2034
2654
  turns: keptTurns
2035
2655
  };
2036
- serialized = JSON.stringify(currentSnapshot);
2656
+ knownCurrentSize = void 0;
2657
+ }
2658
+ const soleTurnId = currentSnapshot.turnOrder.length === 1 ? currentSnapshot.turnOrder[0] : void 0;
2659
+ const soleTurn = soleTurnId ? currentSnapshot.turns[soleTurnId] : void 0;
2660
+ const omittedBlocks = soleTurn?.metadata?.[TRANSPORT_OMITTED_BLOCKS_KEY];
2661
+ const missingSyntheticNotice = typeof omittedBlocks === "number" && omittedBlocks > 0 && !soleTurn?.blockOrder.some((blockId) => soleTurn.blocks[blockId]?.metadata?.[TRANSPORT_NOTICE_METADATA_KEY] === true);
2662
+ if (currentSnapshot.turnOrder.length === 1 && ((knownCurrentSize ?? serializedBytes(currentSnapshot)) > sizeBudget || missingSyntheticNotice)) {
2663
+ currentSnapshot = boundSingleTurnSnapshot(currentSnapshot);
2664
+ knownCurrentSize = void 0;
2037
2665
  }
2038
2666
  const droppedThisCall = originalLength - currentSnapshot.turnOrder.length;
2039
2667
  const truncatedTurns = (snapshot.truncatedTurns ?? 0) + droppedThisCall;
2040
2668
  if (truncatedTurns > 0 && currentSnapshot.truncatedTurns !== truncatedTurns) {
2041
2669
  currentSnapshot = { ...currentSnapshot, truncatedTurns };
2670
+ knownCurrentSize = void 0;
2671
+ }
2672
+ if (droppedThisCall > 0) {
2673
+ const truncatedTurnIds = mergeTruncatedTurnIds(
2674
+ snapshot.truncatedTurnIds,
2675
+ snapshot.turnOrder.slice(0, droppedThisCall)
2676
+ );
2677
+ currentSnapshot = { ...currentSnapshot, truncatedTurnIds };
2678
+ knownCurrentSize = void 0;
2679
+ }
2680
+ let finalSize = knownCurrentSize ?? serializedBytes(currentSnapshot);
2681
+ if (finalSize > MAX_SNAPSHOT_SIZE_BYTES) {
2682
+ currentSnapshot = minimalTransportSnapshot(
2683
+ currentSnapshot,
2684
+ truncatedTurns > 0 ? truncatedTurns : currentSnapshot.truncatedTurns
2685
+ );
2686
+ finalSize = serializedBytes(currentSnapshot);
2687
+ }
2688
+ if (finalSize > MAX_SNAPSHOT_SIZE_BYTES) {
2689
+ throw new Error("Transcript snapshot exceeded its UTF-8 byte budget");
2042
2690
  }
2043
2691
  return currentSnapshot;
2044
2692
  }
2045
2693
 
2694
+ // src/transcript-wire.ts
2695
+ var import_node_crypto = require("crypto");
2696
+ var MAX_TRANSCRIPT_SNAPSHOT_BYTES = 448 * 1024;
2697
+ var MAX_TRANSCRIPT_OP_BATCH_BYTES = 192 * 1024;
2698
+ var MAX_TRANSCRIPT_TURN_BYTES = 160 * 1024;
2699
+ var MAX_TRANSCRIPT_FIELD_BYTES = 128 * 1024;
2700
+ var MAX_TOOL_RESULT_BYTES = 32 * 1024;
2701
+ var MAX_TOOL_INPUT_BYTES = 16 * 1024;
2702
+ var MAX_IDENTIFIER_BYTES = 4 * 1024;
2703
+ var MAX_TIMESTAMP_BYTES = 1024;
2704
+ var TRUNCATION_MARKER = "\n\n[Content truncated for remote display]\n\n";
2705
+ function utf8Bytes(value) {
2706
+ const serialized = JSON.stringify(value);
2707
+ return Buffer.byteLength(serialized ?? "", "utf8");
2708
+ }
2709
+ function stringBytes(value) {
2710
+ return Buffer.byteLength(value, "utf8");
2711
+ }
2712
+ function prefixWithinBytes2(value, maxBytes) {
2713
+ let low = 0;
2714
+ let high = value.length;
2715
+ while (low < high) {
2716
+ const mid = Math.ceil((low + high) / 2);
2717
+ if (stringBytes(value.slice(0, mid)) <= maxBytes) low = mid;
2718
+ else high = mid - 1;
2719
+ }
2720
+ if (low > 0 && /[\uD800-\uDBFF]/.test(value[low - 1] ?? "")) low--;
2721
+ return value.slice(0, low);
2722
+ }
2723
+ function suffixWithinBytes2(value, maxBytes) {
2724
+ let low = 0;
2725
+ let high = value.length;
2726
+ while (low < high) {
2727
+ const count = Math.ceil((low + high) / 2);
2728
+ if (stringBytes(value.slice(value.length - count)) <= maxBytes) low = count;
2729
+ else high = count - 1;
2730
+ }
2731
+ let start = value.length - low;
2732
+ if (start < value.length && /[\uDC00-\uDFFF]/.test(value[start] ?? "")) start++;
2733
+ return value.slice(start);
2734
+ }
2735
+ function truncateUtf8ForWire(value, maxBytes) {
2736
+ if (stringBytes(value) <= maxBytes) return value;
2737
+ const markerBytes = stringBytes(TRUNCATION_MARKER);
2738
+ if (maxBytes <= markerBytes) return prefixWithinBytes2(TRUNCATION_MARKER, maxBytes);
2739
+ const available = maxBytes - markerBytes;
2740
+ const head = prefixWithinBytes2(value, Math.floor(available * 0.75));
2741
+ const tail = suffixWithinBytes2(value, available - stringBytes(head));
2742
+ let result = `${head}${TRUNCATION_MARKER}${tail}`;
2743
+ if (stringBytes(result) > maxBytes) {
2744
+ result = `${prefixWithinBytes2(head, available)}${TRUNCATION_MARKER}`;
2745
+ }
2746
+ return result;
2747
+ }
2748
+ function boundedToolInput(input) {
2749
+ if (!input) return void 0;
2750
+ try {
2751
+ if (utf8Bytes(input) <= MAX_TOOL_INPUT_BYTES) return input;
2752
+ } catch {
2753
+ }
2754
+ return { notice: "Tool input truncated for remote display" };
2755
+ }
2756
+ function boundTool(tool) {
2757
+ return {
2758
+ ...tool,
2759
+ name: truncateUtf8ForWire(tool.name, 2 * 1024),
2760
+ input: boundedToolInput(tool.input),
2761
+ result: typeof tool.result === "string" ? truncateUtf8ForWire(tool.result, MAX_TOOL_RESULT_BYTES) : void 0
2762
+ };
2763
+ }
2764
+ function minimalMarkdownTurn(turn) {
2765
+ return {
2766
+ turnId: truncateUtf8ForWire(turn.turnId, 1024),
2767
+ role: turn.role,
2768
+ content: "[Content truncated for remote display]",
2769
+ timestamp: truncateUtf8ForWire(turn.timestamp, 256)
2770
+ };
2771
+ }
2772
+ function boundMarkdownTurnForWire(turn) {
2773
+ let bounded = {
2774
+ ...turn,
2775
+ turnId: truncateUtf8ForWire(turn.turnId, MAX_IDENTIFIER_BYTES),
2776
+ content: truncateUtf8ForWire(turn.content, MAX_TRANSCRIPT_FIELD_BYTES),
2777
+ tools: turn.tools?.slice(-12).map(boundTool),
2778
+ timestamp: truncateUtf8ForWire(turn.timestamp, MAX_TIMESTAMP_BYTES)
2779
+ };
2780
+ while (utf8Bytes(bounded) > MAX_TRANSCRIPT_TURN_BYTES && (bounded.tools?.length ?? 0) > 0) {
2781
+ bounded = { ...bounded, tools: bounded.tools.slice(1) };
2782
+ }
2783
+ if (utf8Bytes(bounded) > MAX_TRANSCRIPT_TURN_BYTES) {
2784
+ bounded = {
2785
+ ...bounded,
2786
+ content: truncateUtf8ForWire(bounded.content, 64 * 1024),
2787
+ tools: void 0
2788
+ };
2789
+ }
2790
+ if (utf8Bytes(bounded) > MAX_TRANSCRIPT_TURN_BYTES) {
2791
+ bounded = minimalMarkdownTurn(bounded);
2792
+ }
2793
+ if (utf8Bytes(bounded) > MAX_TRANSCRIPT_TURN_BYTES) {
2794
+ throw new Error("Legacy transcript turn exceeded its UTF-8 byte budget");
2795
+ }
2796
+ return bounded;
2797
+ }
2798
+ function boundMarkdownTurnsForWire(turns, maxBytes = MAX_TRANSCRIPT_SNAPSHOT_BYTES) {
2799
+ const bounded = turns.map(boundMarkdownTurnForWire);
2800
+ const emptySnapshotBytes = utf8Bytes({ turns: [] });
2801
+ let retainedBytes = emptySnapshotBytes;
2802
+ let retainedCount = 0;
2803
+ let start = bounded.length;
2804
+ for (let index = bounded.length - 1; index >= 0; index--) {
2805
+ const addedBytes = utf8Bytes(bounded[index]) + (retainedCount > 0 ? 1 : 0);
2806
+ if (retainedBytes + addedBytes > maxBytes) break;
2807
+ retainedBytes += addedBytes;
2808
+ retainedCount++;
2809
+ start = index;
2810
+ }
2811
+ let retained = bounded.slice(start);
2812
+ if (retained.length === 0 && bounded.length > 0) {
2813
+ retained = [minimalMarkdownTurn(bounded.at(-1))];
2814
+ }
2815
+ if (utf8Bytes({ turns: retained }) > maxBytes) {
2816
+ throw new Error("Legacy transcript snapshot exceeded its UTF-8 byte budget");
2817
+ }
2818
+ return retained;
2819
+ }
2820
+ function withTruncation(block, originalBytes, keptBytes) {
2821
+ return {
2822
+ ...block,
2823
+ truncation: {
2824
+ reason: "transport_limit",
2825
+ originalBytes,
2826
+ keptBytes,
2827
+ didWarnUser: true
2828
+ }
2829
+ };
2830
+ }
2831
+ function boundIdentifier(rawId, context) {
2832
+ const existing = context.identifiers.get(rawId);
2833
+ if (existing !== void 0) return existing;
2834
+ if (stringBytes(rawId) <= MAX_IDENTIFIER_BYTES) {
2835
+ context.identifiers.set(rawId, rawId);
2836
+ return rawId;
2837
+ }
2838
+ const suffix = `~${(0, import_node_crypto.createHash)("sha256").update(rawId).digest("hex").slice(0, 20)}`;
2839
+ const bounded = `${prefixWithinBytes2(
2840
+ rawId,
2841
+ MAX_IDENTIFIER_BYTES - stringBytes(suffix)
2842
+ )}${suffix}`;
2843
+ context.identifiers.set(rawId, bounded);
2844
+ return bounded;
2845
+ }
2846
+ function boundBlock(block, boundedBlockId = truncateUtf8ForWire(block.blockId, MAX_IDENTIFIER_BYTES)) {
2847
+ const originalBytes = utf8Bytes(block);
2848
+ let bounded;
2849
+ switch (block.kind) {
2850
+ case "text":
2851
+ case "reasoning":
2852
+ bounded = { ...block, text: truncateUtf8ForWire(block.text, MAX_TRANSCRIPT_FIELD_BYTES) };
2853
+ break;
2854
+ case "tool": {
2855
+ let input = block.input;
2856
+ if (input !== void 0) {
2857
+ try {
2858
+ if (utf8Bytes(input) > MAX_TOOL_INPUT_BYTES) {
2859
+ input = "[Tool input truncated for remote display]";
2860
+ }
2861
+ } catch {
2862
+ input = "[Tool input truncated for remote display]";
2863
+ }
2864
+ }
2865
+ bounded = {
2866
+ ...block,
2867
+ callId: truncateUtf8ForWire(block.callId, MAX_IDENTIFIER_BYTES),
2868
+ toolName: truncateUtf8ForWire(block.toolName, 2 * 1024),
2869
+ input,
2870
+ outputText: block.outputText === void 0 ? void 0 : truncateUtf8ForWire(block.outputText, MAX_TRANSCRIPT_FIELD_BYTES),
2871
+ errorText: block.errorText === void 0 ? void 0 : truncateUtf8ForWire(block.errorText, MAX_TOOL_RESULT_BYTES),
2872
+ outputMimeType: block.outputMimeType === void 0 ? void 0 : truncateUtf8ForWire(block.outputMimeType, 2 * 1024)
2873
+ };
2874
+ break;
2875
+ }
2876
+ case "status":
2877
+ bounded = {
2878
+ ...block,
2879
+ statusType: truncateUtf8ForWire(block.statusType, 2 * 1024),
2880
+ text: block.text === void 0 ? void 0 : truncateUtf8ForWire(block.text, MAX_TOOL_RESULT_BYTES)
2881
+ };
2882
+ break;
2883
+ case "unknown":
2884
+ bounded = {
2885
+ ...block,
2886
+ rawKind: truncateUtf8ForWire(block.rawKind, 2 * 1024),
2887
+ rawPayload: block.rawPayload === void 0 ? void 0 : "[Unknown payload truncated for remote display]",
2888
+ fallbackText: block.fallbackText === void 0 ? void 0 : truncateUtf8ForWire(block.fallbackText, MAX_TOOL_RESULT_BYTES)
2889
+ };
2890
+ break;
2891
+ case "attachment":
2892
+ bounded = {
2893
+ ...block,
2894
+ mediaType: truncateUtf8ForWire(block.mediaType, 2 * 1024),
2895
+ uri: block.uri === void 0 ? void 0 : truncateUtf8ForWire(block.uri, 8 * 1024),
2896
+ path: block.path === void 0 ? void 0 : truncateUtf8ForWire(block.path, 8 * 1024),
2897
+ name: block.name === void 0 ? void 0 : truncateUtf8ForWire(block.name, 2 * 1024)
2898
+ };
2899
+ break;
2900
+ }
2901
+ bounded = {
2902
+ ...bounded,
2903
+ blockId: boundedBlockId,
2904
+ createdAt: truncateUtf8ForWire(block.createdAt, MAX_TIMESTAMP_BYTES),
2905
+ updatedAt: truncateUtf8ForWire(block.updatedAt, MAX_TIMESTAMP_BYTES),
2906
+ metadata: void 0
2907
+ };
2908
+ if (utf8Bytes(bounded) < originalBytes) {
2909
+ bounded = withTruncation(bounded, originalBytes, utf8Bytes(bounded));
2910
+ }
2911
+ return bounded;
2912
+ }
2913
+ function boundOp(op, context) {
2914
+ switch (op.op) {
2915
+ case "append_text":
2916
+ case "append_tool_output":
2917
+ return {
2918
+ ...op,
2919
+ turnId: boundIdentifier(op.turnId, context),
2920
+ blockId: boundIdentifier(op.blockId, context),
2921
+ text: truncateUtf8ForWire(op.text, MAX_TRANSCRIPT_FIELD_BYTES)
2922
+ };
2923
+ case "insert_block":
2924
+ return {
2925
+ ...op,
2926
+ turnId: boundIdentifier(op.turnId, context),
2927
+ afterBlockId: op.afterBlockId === void 0 ? void 0 : boundIdentifier(op.afterBlockId, context),
2928
+ block: boundBlock(
2929
+ op.block,
2930
+ boundIdentifier(op.block.blockId, context)
2931
+ )
2932
+ };
2933
+ case "replace_block":
2934
+ return {
2935
+ ...op,
2936
+ turnId: boundIdentifier(op.turnId, context),
2937
+ block: boundBlock(
2938
+ op.block,
2939
+ boundIdentifier(op.block.blockId, context)
2940
+ )
2941
+ };
2942
+ case "upsert_turn": {
2943
+ return {
2944
+ ...op,
2945
+ turn: {
2946
+ ...op.turn,
2947
+ turnId: boundIdentifier(op.turn.turnId, context),
2948
+ startedAt: truncateUtf8ForWire(op.turn.startedAt, MAX_TIMESTAMP_BYTES),
2949
+ updatedAt: truncateUtf8ForWire(op.turn.updatedAt, MAX_TIMESTAMP_BYTES),
2950
+ endedAt: op.turn.endedAt === void 0 ? void 0 : truncateUtf8ForWire(op.turn.endedAt, MAX_TIMESTAMP_BYTES),
2951
+ agent: op.turn.agent === void 0 ? void 0 : {
2952
+ vendor: op.turn.agent.vendor === void 0 ? void 0 : truncateUtf8ForWire(op.turn.agent.vendor, 2 * 1024),
2953
+ agentType: op.turn.agent.agentType === void 0 ? void 0 : truncateUtf8ForWire(op.turn.agent.agentType, 2 * 1024),
2954
+ name: op.turn.agent.name === void 0 ? void 0 : truncateUtf8ForWire(op.turn.agent.name, 2 * 1024),
2955
+ version: op.turn.agent.version === void 0 ? void 0 : truncateUtf8ForWire(op.turn.agent.version, 2 * 1024),
2956
+ transcriptSource: op.turn.agent.transcriptSource
2957
+ },
2958
+ metadata: void 0,
2959
+ blocks: void 0,
2960
+ blockOrder: void 0
2961
+ }
2962
+ };
2963
+ }
2964
+ case "set_tool_state":
2965
+ return {
2966
+ ...op,
2967
+ turnId: boundIdentifier(op.turnId, context),
2968
+ blockId: boundIdentifier(op.blockId, context),
2969
+ errorText: op.errorText === void 0 ? void 0 : truncateUtf8ForWire(op.errorText, MAX_TOOL_RESULT_BYTES)
2970
+ };
2971
+ case "complete_block":
2972
+ return {
2973
+ ...op,
2974
+ turnId: boundIdentifier(op.turnId, context),
2975
+ blockId: boundIdentifier(op.blockId, context),
2976
+ updatedAt: truncateUtf8ForWire(op.updatedAt, MAX_TIMESTAMP_BYTES)
2977
+ };
2978
+ case "complete_turn":
2979
+ return {
2980
+ ...op,
2981
+ turnId: boundIdentifier(op.turnId, context),
2982
+ endedAt: truncateUtf8ForWire(op.endedAt, MAX_TIMESTAMP_BYTES),
2983
+ updatedAt: truncateUtf8ForWire(op.updatedAt, MAX_TIMESTAMP_BYTES)
2984
+ };
2985
+ default:
2986
+ return op;
2987
+ }
2988
+ }
2989
+ function expandBoundOp(op, context) {
2990
+ if (op.op !== "upsert_turn" || !op.turn.blocks) return [boundOp(op, context)];
2991
+ const order = op.turn.blockOrder ?? Object.keys(op.turn.blocks);
2992
+ const base = boundOp(op, context);
2993
+ const boundedTurnId = base.op === "upsert_turn" ? base.turn.turnId : truncateUtf8ForWire(op.turn.turnId, MAX_IDENTIFIER_BYTES);
2994
+ const expanded = [base];
2995
+ for (const blockId of order) {
2996
+ const block = op.turn.blocks[blockId];
2997
+ if (!block) continue;
2998
+ expanded.push({
2999
+ op: "insert_block",
3000
+ // Use exactly the same bounded identifier as the base upsert so an
3001
+ // adversarial long ID cannot orphan its expanded blocks.
3002
+ turnId: boundedTurnId,
3003
+ block: boundBlock(
3004
+ block,
3005
+ boundIdentifier(block.blockId, context)
3006
+ )
3007
+ });
3008
+ }
3009
+ return expanded;
3010
+ }
3011
+ function forceBoundOp(op, maxBytes) {
3012
+ if (utf8Bytes(op) <= maxBytes) return op;
3013
+ if (op.op === "insert_block" || op.op === "replace_block") {
3014
+ return {
3015
+ ...op,
3016
+ block: {
3017
+ blockId: truncateUtf8ForWire(op.block.blockId, MAX_IDENTIFIER_BYTES),
3018
+ kind: "status",
3019
+ statusType: "notice",
3020
+ text: "Block content truncated for remote display",
3021
+ level: "warning",
3022
+ isComplete: op.block.isComplete,
3023
+ createdAt: op.block.createdAt,
3024
+ updatedAt: op.block.updatedAt,
3025
+ truncation: {
3026
+ reason: "transport_limit",
3027
+ originalBytes: utf8Bytes(op.block),
3028
+ keptBytes: 0,
3029
+ didWarnUser: true
3030
+ }
3031
+ }
3032
+ };
3033
+ }
3034
+ if (op.op === "append_text" || op.op === "append_tool_output") {
3035
+ return { ...op, text: truncateUtf8ForWire(op.text, 32 * 1024) };
3036
+ }
3037
+ return op;
3038
+ }
3039
+ function boundTranscriptOpsForWire(ops, maxBytes = MAX_TRANSCRIPT_OP_BATCH_BYTES) {
3040
+ const idContext = {
3041
+ identifiers: /* @__PURE__ */ new Map()
3042
+ };
3043
+ const batches = [];
3044
+ let current = [];
3045
+ let currentBytes = 2;
3046
+ for (const rawOp of ops) {
3047
+ for (const expanded of expandBoundOp(rawOp, idContext)) {
3048
+ const op = forceBoundOp(expanded, maxBytes);
3049
+ const opBytes = utf8Bytes(op);
3050
+ if (opBytes + 2 > maxBytes) continue;
3051
+ const addedBytes = opBytes + (current.length > 0 ? 1 : 0);
3052
+ if (current.length > 0 && currentBytes + addedBytes > maxBytes) {
3053
+ batches.push(current);
3054
+ current = [];
3055
+ currentBytes = 2;
3056
+ }
3057
+ current.push(op);
3058
+ currentBytes += opBytes + (current.length > 1 ? 1 : 0);
3059
+ }
3060
+ }
3061
+ if (current.length > 0) batches.push(current);
3062
+ if (batches.some((batch) => utf8Bytes(batch) > maxBytes)) {
3063
+ throw new Error("Transcript wire batch exceeded its UTF-8 byte budget");
3064
+ }
3065
+ return batches;
3066
+ }
3067
+
2046
3068
  // src/native-stream-manager.ts
2047
3069
  var import_child_process = require("child_process");
2048
3070
 
@@ -2580,6 +3602,9 @@ var import_ws = __toESM(require("ws"));
2580
3602
  var BACKPRESSURE_HIGH_WATER = 256 * 1024;
2581
3603
  var BACKPRESSURE_LOW_WATER = 64 * 1024;
2582
3604
  var MAX_PENDING_QUEUE = 500;
3605
+ var MAX_PENDING_BYTES = 4 * 1024 * 1024;
3606
+ var CONTROL_FRAME_RESERVE = 32;
3607
+ var CONTROL_BYTE_RESERVE = 256 * 1024;
2583
3608
  var DRAIN_INTERVAL_MS = 50;
2584
3609
  function sanitizeForJson(str) {
2585
3610
  return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
@@ -2633,6 +3658,7 @@ function flushStreamingRedaction(sessionId) {
2633
3658
  return redactionEnabled ? redactSecrets(tail) : tail;
2634
3659
  }
2635
3660
  var pendingQueue = [];
3661
+ var pendingQueueBytes = 0;
2636
3662
  var drainTimer = null;
2637
3663
  var drainWs = null;
2638
3664
  var droppedFrames = 0;
@@ -2640,10 +3666,17 @@ var PRESSURE_HIGH = 0.75;
2640
3666
  var PRESSURE_LOW = 0.25;
2641
3667
  var pressureIsHigh = false;
2642
3668
  function getQueuePressure() {
2643
- const level = pendingQueue.length / MAX_PENDING_QUEUE;
3669
+ const frameLevel = pendingQueue.length / MAX_PENDING_QUEUE;
3670
+ const byteLevel = pendingQueueBytes / MAX_PENDING_BYTES;
3671
+ const level = Math.max(frameLevel, byteLevel);
2644
3672
  if (level >= PRESSURE_HIGH) pressureIsHigh = true;
2645
3673
  else if (level < PRESSURE_LOW) pressureIsHigh = false;
2646
- return { level, isHigh: pressureIsHigh };
3674
+ return {
3675
+ level,
3676
+ queuedFrames: pendingQueue.length,
3677
+ queuedBytes: pendingQueueBytes,
3678
+ isHigh: pressureIsHigh
3679
+ };
2647
3680
  }
2648
3681
  function getDroppedFrameCount() {
2649
3682
  return droppedFrames;
@@ -2654,13 +3687,133 @@ function drainQueue() {
2654
3687
  return;
2655
3688
  }
2656
3689
  while (pendingQueue.length > 0 && drainWs.bufferedAmount < BACKPRESSURE_LOW_WATER) {
2657
- const frame = pendingQueue.shift();
2658
- drainWs.send(frame);
3690
+ const pending = pendingQueue.shift();
3691
+ pendingQueueBytes -= pending.bytes;
3692
+ drainWs.send(pending.frame);
2659
3693
  }
2660
3694
  if (pendingQueue.length === 0) {
2661
3695
  stopDrainTimer();
2662
3696
  }
2663
3697
  }
3698
+ function classifyPendingFrame(frame, group, data, hints) {
3699
+ const sessionId = hints?.sessionId ?? (typeof data.sessionId === "string" ? data.sessionId : void 0);
3700
+ const isMarkdown = data.type === "markdown" || hints?.markdownSubtype !== void 0;
3701
+ const subtype = hints?.markdownSubtype ?? (typeof data.subtype === "string" ? data.subtype : void 0);
3702
+ const isResetSnapshot = isMarkdown && subtype === "snapshot" && (hints?.reset === true || data.reset === true);
3703
+ const replaceableSnapshot = isMarkdown && subtype === "snapshot" && !isResetSnapshot;
3704
+ const markdownSchemaVersion = isMarkdown ? hints?.markdownSchemaVersion ?? (data.schemaVersion === 2 ? 2 : 1) : void 0;
3705
+ const transcriptOrderBarrier = isMarkdown && !replaceableSnapshot;
3706
+ const protectedOrderBarrier = isResetSnapshot || data.type === "stream-reset" || data.type === "stream-started" || data.type === "stream-stopped";
3707
+ const protectedBarrierKind = isResetSnapshot ? "markdown-reset" : data.type === "stream-reset" ? "stream-reset" : data.type === "stream-started" ? "stream-started" : data.type === "stream-stopped" ? "stream-stopped" : void 0;
3708
+ let priority = 2 /* Control */;
3709
+ if (data.type === "output" || isMarkdown && subtype === "delta") {
3710
+ priority = 0 /* Bulk */;
3711
+ } else if (replaceableSnapshot) {
3712
+ priority = 1 /* Snapshot */;
3713
+ }
3714
+ return {
3715
+ frame,
3716
+ bytes: Buffer.byteLength(frame, "utf8"),
3717
+ group,
3718
+ sessionId,
3719
+ priority,
3720
+ replaceableSnapshot,
3721
+ markdownSchemaVersion,
3722
+ // Encrypted markdown frames have no visible subtype, so treat them as a
3723
+ // barrier. Guessing that one is replaceable could reorder an encrypted
3724
+ // reset, status, or delta and corrupt transcript state.
3725
+ transcriptOrderBarrier,
3726
+ protectedOrderBarrier,
3727
+ protectedBarrierKind
3728
+ };
3729
+ }
3730
+ function obsoleteSnapshotIndices(incoming) {
3731
+ const indices = /* @__PURE__ */ new Set();
3732
+ if (!incoming.replaceableSnapshot || !incoming.sessionId) return indices;
3733
+ for (let i = pendingQueue.length - 1; i >= 0; i--) {
3734
+ const queued = pendingQueue[i];
3735
+ if (queued.group !== incoming.group || queued.sessionId !== incoming.sessionId) {
3736
+ continue;
3737
+ }
3738
+ if (queued.transcriptOrderBarrier) break;
3739
+ if (queued.replaceableSnapshot && queued.markdownSchemaVersion === incoming.markdownSchemaVersion) {
3740
+ indices.add(i);
3741
+ }
3742
+ }
3743
+ return indices;
3744
+ }
3745
+ function supersededProtectedBarrierIndices(incoming) {
3746
+ const indices = /* @__PURE__ */ new Set();
3747
+ if (!incoming.protectedBarrierKind || !incoming.sessionId) return indices;
3748
+ for (let i = pendingQueue.length - 1; i >= 0; i--) {
3749
+ const queued = pendingQueue[i];
3750
+ if (queued.group !== incoming.group || queued.sessionId !== incoming.sessionId) {
3751
+ continue;
3752
+ }
3753
+ if (queued.protectedBarrierKind === incoming.protectedBarrierKind) {
3754
+ indices.add(i);
3755
+ continue;
3756
+ }
3757
+ break;
3758
+ }
3759
+ return indices;
3760
+ }
3761
+ function recordDroppedFrame() {
3762
+ droppedFrames++;
3763
+ if (droppedFrames % 100 === 1) {
3764
+ console.warn(
3765
+ `[rAgent] Backpressure: dropped ${droppedFrames} frames (queue limit ${MAX_PENDING_QUEUE} frames / ${MAX_PENDING_BYTES} bytes)`
3766
+ );
3767
+ }
3768
+ }
3769
+ function findEvictionCandidate(incomingPriority, excluded) {
3770
+ for (let priority = 0 /* Bulk */; priority <= incomingPriority; priority++) {
3771
+ const index = pendingQueue.findIndex(
3772
+ (queued, queuedIndex) => !excluded.has(queuedIndex) && !queued.protectedOrderBarrier && queued.priority === priority
3773
+ );
3774
+ if (index >= 0) return index;
3775
+ }
3776
+ return -1;
3777
+ }
3778
+ function enqueuePendingFrame(pending) {
3779
+ if (pending.bytes > MAX_PENDING_BYTES) {
3780
+ recordDroppedFrame();
3781
+ return false;
3782
+ }
3783
+ const obsolete = obsoleteSnapshotIndices(pending);
3784
+ const supersededBarriers = supersededProtectedBarrierIndices(pending);
3785
+ const coalesced = /* @__PURE__ */ new Set([...obsolete, ...supersededBarriers]);
3786
+ const removable = new Set(coalesced);
3787
+ const frameCapacity = pending.priority === 0 /* Bulk */ ? MAX_PENDING_QUEUE - CONTROL_FRAME_RESERVE : MAX_PENDING_QUEUE;
3788
+ const byteCapacity = pending.priority === 0 /* Bulk */ ? MAX_PENDING_BYTES - CONTROL_BYTE_RESERVE : MAX_PENDING_BYTES;
3789
+ let projectedLength = pendingQueue.length - removable.size;
3790
+ let projectedBytes = pendingQueueBytes;
3791
+ for (const index of removable) projectedBytes -= pendingQueue[index].bytes;
3792
+ while (projectedLength >= frameCapacity || projectedBytes + pending.bytes > byteCapacity) {
3793
+ let candidate = findEvictionCandidate(pending.priority, removable);
3794
+ if (candidate < 0 && pending.protectedOrderBarrier) {
3795
+ candidate = pendingQueue.findIndex(
3796
+ (queued, queuedIndex) => !removable.has(queuedIndex) && queued.protectedOrderBarrier
3797
+ );
3798
+ }
3799
+ if (candidate < 0) {
3800
+ recordDroppedFrame();
3801
+ return false;
3802
+ }
3803
+ removable.add(candidate);
3804
+ projectedLength--;
3805
+ projectedBytes -= pendingQueue[candidate].bytes;
3806
+ }
3807
+ const indices = [...removable].sort((a, b) => b - a);
3808
+ for (const index of indices) {
3809
+ const [removed] = pendingQueue.splice(index, 1);
3810
+ pendingQueueBytes -= removed.bytes;
3811
+ if (!coalesced.has(index)) recordDroppedFrame();
3812
+ }
3813
+ pendingQueue.push(pending);
3814
+ pendingQueueBytes += pending.bytes;
3815
+ return true;
3816
+ }
2664
3817
  function startDrainTimer(ws) {
2665
3818
  drainWs = ws;
2666
3819
  if (!drainTimer) {
@@ -2674,7 +3827,7 @@ function stopDrainTimer() {
2674
3827
  }
2675
3828
  drainWs = null;
2676
3829
  }
2677
- function sendToGroup(ws, group, data) {
3830
+ function sendToGroup(ws, group, data, hints) {
2678
3831
  if (!group || ws.readyState !== import_ws.default.OPEN) return;
2679
3832
  const sanitized = sanitizePayload(data);
2680
3833
  const frame = JSON.stringify({
@@ -2684,20 +3837,20 @@ function sendToGroup(ws, group, data) {
2684
3837
  data: sanitized,
2685
3838
  noEcho: true
2686
3839
  });
3840
+ const pending = classifyPendingFrame(frame, group, sanitized, hints);
3841
+ if (pending.bytes > MAX_PENDING_BYTES) {
3842
+ recordDroppedFrame();
3843
+ return;
3844
+ }
2687
3845
  if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
2688
- if (pendingQueue.length >= MAX_PENDING_QUEUE) {
2689
- pendingQueue.shift();
2690
- droppedFrames++;
2691
- if (droppedFrames % 100 === 1) {
2692
- console.warn(`[rAgent] Backpressure: dropped ${droppedFrames} frames (queue full at ${MAX_PENDING_QUEUE})`);
2693
- }
3846
+ enqueuePendingFrame(pending);
3847
+ if (pendingQueue.length > 0) {
3848
+ startDrainTimer(ws);
2694
3849
  }
2695
- pendingQueue.push(frame);
2696
- startDrainTimer(ws);
2697
3850
  return;
2698
3851
  }
2699
3852
  if (pendingQueue.length > 0) {
2700
- pendingQueue.push(frame);
3853
+ enqueuePendingFrame(pending);
2701
3854
  drainWs = ws;
2702
3855
  drainQueue();
2703
3856
  return;
@@ -2728,6 +3881,36 @@ function sanitizePayload(obj) {
2728
3881
  var log7 = createLogger("transcript-watcher");
2729
3882
  var cachedTicksPerSecond = null;
2730
3883
  var transcriptDiscoveryCache = /* @__PURE__ */ new Map();
3884
+ var codexSubagentTranscriptCache = /* @__PURE__ */ new Map();
3885
+ function createTranscriptDiscoveryIndex() {
3886
+ return { candidates: /* @__PURE__ */ new Map() };
3887
+ }
3888
+ function indexedCandidates(index, key, scan) {
3889
+ const rank = () => scan().map((target) => {
3890
+ try {
3891
+ return { target, mtimeMs: fs2.statSync(target).mtimeMs };
3892
+ } catch {
3893
+ return null;
3894
+ }
3895
+ }).filter((entry) => entry !== null).sort((a, b) => b.mtimeMs - a.mtimeMs);
3896
+ if (!index) return rank();
3897
+ const cached = index.candidates.get(key);
3898
+ if (cached) return cached;
3899
+ const candidates = rank();
3900
+ index.candidates.set(key, candidates);
3901
+ return candidates;
3902
+ }
3903
+ function getNewestIndexedPath(candidates, options) {
3904
+ for (const candidate of candidates) {
3905
+ if (options?.minMtimeMs !== void 0 && candidate.mtimeMs < options.minMtimeMs) {
3906
+ break;
3907
+ }
3908
+ if (options?.excludePaths?.has(candidate.target)) continue;
3909
+ if (options?.isEligible && !options.isEligible(candidate.target)) continue;
3910
+ return candidate.target;
3911
+ }
3912
+ return null;
3913
+ }
2731
3914
  var ClaudeCodeParser = class {
2732
3915
  name = "claude-code";
2733
3916
  /** Maps tool_use id → tool name for matching results back to tools. */
@@ -2903,6 +4086,7 @@ var CodexCliParser = class {
2903
4086
  if (!payload.call_id) return null;
2904
4087
  const toolName = this.pendingTools.get(payload.call_id) ?? "tool";
2905
4088
  this.pendingTools.delete(payload.call_id);
4089
+ const output = normalizeCodexFunctionCallOutput(payload.output);
2906
4090
  return {
2907
4091
  turnId: this.genTurnId(lineTimestamp),
2908
4092
  role: "user",
@@ -2911,7 +4095,7 @@ var CodexCliParser = class {
2911
4095
  {
2912
4096
  name: toolName,
2913
4097
  status: "completed",
2914
- result: payload.output || void 0
4098
+ result: output || void 0
2915
4099
  }
2916
4100
  ],
2917
4101
  timestamp
@@ -3219,7 +4403,48 @@ function getNewestPath(paths, options) {
3219
4403
  return null;
3220
4404
  }
3221
4405
  }).filter((entry) => entry !== null).filter((entry) => options?.minMtimeMs === void 0 || entry.mtimeMs >= options.minMtimeMs).filter((entry) => !options?.excludePaths?.has(entry.target)).sort((a, b) => b.mtimeMs - a.mtimeMs);
3222
- return ranked[0]?.target ?? null;
4406
+ return ranked.find((entry) => options?.isEligible?.(entry.target) ?? true)?.target ?? null;
4407
+ }
4408
+ function isCodexSubagentTranscript(target) {
4409
+ const cached = codexSubagentTranscriptCache.get(target);
4410
+ if (cached !== void 0) return cached;
4411
+ let isSubagent = false;
4412
+ let classified = false;
4413
+ let fd = null;
4414
+ try {
4415
+ fd = fs2.openSync(target, "r");
4416
+ const buffer = Buffer.alloc(256 * 1024);
4417
+ const bytesRead = fs2.readSync(fd, buffer, 0, buffer.length, 0);
4418
+ const content = buffer.subarray(0, bytesRead).toString("utf8");
4419
+ const newline = content.indexOf("\n");
4420
+ if (newline < 0 && bytesRead === buffer.length) {
4421
+ throw new Error("session metadata exceeds bounded read");
4422
+ }
4423
+ const firstLine = newline >= 0 ? content.slice(0, newline) : content;
4424
+ const record = JSON.parse(firstLine);
4425
+ classified = true;
4426
+ const source = record.type === "session_meta" ? record.payload?.source : void 0;
4427
+ isSubagent = Boolean(
4428
+ source && typeof source === "object" && !Array.isArray(source) && Object.prototype.hasOwnProperty.call(source, "subagent")
4429
+ );
4430
+ } catch {
4431
+ isSubagent = false;
4432
+ } finally {
4433
+ if (fd !== null) {
4434
+ try {
4435
+ fs2.closeSync(fd);
4436
+ } catch {
4437
+ }
4438
+ }
4439
+ }
4440
+ if (classified) {
4441
+ if (codexSubagentTranscriptCache.size >= 5e3) {
4442
+ const oldest = codexSubagentTranscriptCache.keys().next().value;
4443
+ if (oldest) codexSubagentTranscriptCache.delete(oldest);
4444
+ }
4445
+ codexSubagentTranscriptCache.set(target, isSubagent);
4446
+ }
4447
+ return isSubagent;
3223
4448
  }
3224
4449
  function discoverTranscriptForPid(pid, agentType) {
3225
4450
  const fdDir = `/proc/${pid}/fd`;
@@ -3235,7 +4460,7 @@ function discoverTranscriptForPid(pid, agentType) {
3235
4460
  } catch {
3236
4461
  }
3237
4462
  }
3238
- return getNewestPath(matches);
4463
+ return getNewestPath(matches, agentType === "Codex CLI" ? { isEligible: (target) => !isCodexSubagentTranscript(target) } : void 0);
3239
4464
  } catch {
3240
4465
  return null;
3241
4466
  }
@@ -3366,10 +4591,10 @@ function discoverViaProc(rootPid, agentType) {
3366
4591
  }
3367
4592
  return getNewestPath(candidates);
3368
4593
  }
3369
- function discoverViaProcessCwd(pid, minMtimeMs, excludePaths) {
4594
+ function discoverViaProcessCwd(pid, minMtimeMs, excludePaths, discoveryIndex) {
3370
4595
  try {
3371
4596
  const processCwd = fs2.readlinkSync(`/proc/${pid}/cwd`);
3372
- return processCwd ? discoverViaCwd(processCwd, pid, minMtimeMs, excludePaths) : null;
4597
+ return processCwd ? discoverViaCwd(processCwd, pid, minMtimeMs, excludePaths, discoveryIndex) : null;
3373
4598
  } catch {
3374
4599
  return null;
3375
4600
  }
@@ -3397,83 +4622,108 @@ function resolveUserHome(pid) {
3397
4622
  return null;
3398
4623
  }
3399
4624
  }
3400
- function discoverCodexTranscriptFromHome(pid, minMtimeMs, excludePaths) {
4625
+ function discoverCodexTranscriptFromHome(pid, minMtimeMs, excludePaths, discoveryIndex) {
3401
4626
  const userHome = resolveUserHome(pid);
3402
4627
  if (!userHome) return null;
3403
4628
  const sessionsDir = path2.join(userHome, ".codex", "sessions");
3404
4629
  if (!fs2.existsSync(sessionsDir)) return null;
3405
- const matches = [];
3406
- const stack = [sessionsDir];
3407
- let visited = 0;
3408
- while (stack.length > 0 && visited < 3e3) {
3409
- const current = stack.pop();
3410
- if (!current) continue;
3411
- visited++;
3412
- let entries;
3413
- try {
3414
- entries = fs2.readdirSync(current, { withFileTypes: true });
3415
- } catch {
3416
- continue;
3417
- }
3418
- for (const entry of entries) {
3419
- const fullPath = path2.join(current, entry.name);
3420
- if (entry.isDirectory()) {
3421
- stack.push(fullPath);
3422
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
3423
- matches.push(fullPath);
4630
+ const matches = indexedCandidates(
4631
+ discoveryIndex,
4632
+ `codex:${sessionsDir}`,
4633
+ () => {
4634
+ const found = [];
4635
+ const stack = [sessionsDir];
4636
+ let visited = 0;
4637
+ while (stack.length > 0 && visited < 3e3) {
4638
+ const current = stack.pop();
4639
+ if (!current) continue;
4640
+ visited++;
4641
+ let entries;
4642
+ try {
4643
+ entries = fs2.readdirSync(current, { withFileTypes: true });
4644
+ } catch {
4645
+ continue;
4646
+ }
4647
+ for (const entry of entries) {
4648
+ const fullPath = path2.join(current, entry.name);
4649
+ if (entry.isDirectory()) {
4650
+ stack.push(fullPath);
4651
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
4652
+ found.push(fullPath);
4653
+ }
4654
+ }
3424
4655
  }
4656
+ return found;
3425
4657
  }
3426
- }
3427
- return getNewestPath(matches, { minMtimeMs, excludePaths });
4658
+ );
4659
+ return getNewestIndexedPath(matches, {
4660
+ minMtimeMs,
4661
+ excludePaths,
4662
+ isEligible: (target) => !isCodexSubagentTranscript(target)
4663
+ });
3428
4664
  }
3429
- function discoverGeminiTranscriptFromHome(pid, minMtimeMs, excludePaths) {
4665
+ function discoverGeminiTranscriptFromHome(pid, minMtimeMs, excludePaths, discoveryIndex) {
3430
4666
  const userHome = resolveUserHome(pid);
3431
4667
  if (!userHome) return null;
3432
4668
  const tmpDir = path2.join(userHome, ".gemini", "tmp");
3433
4669
  if (!fs2.existsSync(tmpDir)) return null;
3434
- const matches = [];
3435
- const stack = [tmpDir];
3436
- let visited = 0;
3437
- while (stack.length > 0 && visited < 2e3) {
3438
- const current = stack.pop();
3439
- if (!current) continue;
3440
- visited++;
3441
- let entries;
3442
- try {
3443
- entries = fs2.readdirSync(current, { withFileTypes: true });
3444
- } catch {
3445
- continue;
3446
- }
3447
- for (const entry of entries) {
3448
- const fullPath = path2.join(current, entry.name);
3449
- if (entry.isDirectory()) {
3450
- stack.push(fullPath);
3451
- } else if (entry.isFile() && entry.name.startsWith("session-") && entry.name.endsWith(".json") && fullPath.includes(`${path2.sep}chats${path2.sep}`)) {
3452
- matches.push(fullPath);
4670
+ const matches = indexedCandidates(
4671
+ discoveryIndex,
4672
+ `gemini:${tmpDir}`,
4673
+ () => {
4674
+ const found = [];
4675
+ const stack = [tmpDir];
4676
+ let visited = 0;
4677
+ while (stack.length > 0 && visited < 2e3) {
4678
+ const current = stack.pop();
4679
+ if (!current) continue;
4680
+ visited++;
4681
+ let entries;
4682
+ try {
4683
+ entries = fs2.readdirSync(current, { withFileTypes: true });
4684
+ } catch {
4685
+ continue;
4686
+ }
4687
+ for (const entry of entries) {
4688
+ const fullPath = path2.join(current, entry.name);
4689
+ if (entry.isDirectory()) {
4690
+ stack.push(fullPath);
4691
+ } else if (entry.isFile() && entry.name.startsWith("session-") && entry.name.endsWith(".json") && fullPath.includes(`${path2.sep}chats${path2.sep}`)) {
4692
+ found.push(fullPath);
4693
+ }
4694
+ }
3453
4695
  }
4696
+ return found;
3454
4697
  }
3455
- }
3456
- return getNewestPath(matches, { minMtimeMs, excludePaths });
4698
+ );
4699
+ return getNewestIndexedPath(matches, { minMtimeMs, excludePaths });
3457
4700
  }
3458
- function discoverAntigravityTranscriptFromHome(pid, minMtimeMs, excludePaths) {
4701
+ function discoverAntigravityTranscriptFromHome(pid, minMtimeMs, excludePaths, discoveryIndex) {
3459
4702
  const userHome = resolveUserHome(pid);
3460
4703
  if (!userHome) return null;
3461
4704
  const brainDir = path2.join(userHome, ".gemini", "antigravity-cli", "brain");
3462
4705
  if (!fs2.existsSync(brainDir)) return null;
3463
- const matches = [];
3464
- try {
3465
- const folders = fs2.readdirSync(brainDir, { withFileTypes: true });
3466
- for (const folder of folders) {
3467
- if (folder.isDirectory()) {
3468
- const transcriptPath = path2.join(brainDir, folder.name, ".system_generated", "logs", "transcript.jsonl");
3469
- if (fs2.existsSync(transcriptPath)) {
3470
- matches.push(transcriptPath);
4706
+ const matches = indexedCandidates(
4707
+ discoveryIndex,
4708
+ `antigravity:${brainDir}`,
4709
+ () => {
4710
+ const found = [];
4711
+ try {
4712
+ const folders = fs2.readdirSync(brainDir, { withFileTypes: true });
4713
+ for (const folder of folders) {
4714
+ if (folder.isDirectory()) {
4715
+ const transcriptPath = path2.join(brainDir, folder.name, ".system_generated", "logs", "transcript.jsonl");
4716
+ if (fs2.existsSync(transcriptPath)) {
4717
+ found.push(transcriptPath);
4718
+ }
4719
+ }
3471
4720
  }
4721
+ } catch {
3472
4722
  }
4723
+ return found;
3473
4724
  }
3474
- } catch {
3475
- }
3476
- return getNewestPath(matches, { minMtimeMs, excludePaths });
4725
+ );
4726
+ return getNewestIndexedPath(matches, { minMtimeMs, excludePaths });
3477
4727
  }
3478
4728
  function discoverAntigravityViaCwd(paneCwd, pid, excludePaths) {
3479
4729
  const userHome = resolveUserHome(pid);
@@ -3521,7 +4771,7 @@ function discoverAntigravityViaCwd(paneCwd, pid, excludePaths) {
3521
4771
  }
3522
4772
  return null;
3523
4773
  }
3524
- function discoverViaCwd(paneCwd, pid, minMtimeMs, excludePaths) {
4774
+ function discoverViaCwd(paneCwd, pid, minMtimeMs, excludePaths, discoveryIndex) {
3525
4775
  const userHome = resolveUserHome(pid);
3526
4776
  if (!userHome) return null;
3527
4777
  const claudeProjectsDir = path2.join(userHome, ".claude", "projects");
@@ -3534,8 +4784,12 @@ function discoverViaCwd(paneCwd, pid, minMtimeMs, excludePaths) {
3534
4784
  const projectDirs = Array.from(possibleCwds).map((cwd) => path2.join(claudeProjectsDir, cwd.replace(/\//g, "-"))).filter((projectDir, index, arr) => arr.indexOf(projectDir) === index).filter((projectDir) => fs2.existsSync(projectDir));
3535
4785
  if (projectDirs.length === 0) return null;
3536
4786
  try {
3537
- const jsonlFiles = projectDirs.flatMap((projectDir) => fs2.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => path2.join(projectDir, f)));
3538
- return getNewestPath(jsonlFiles, { minMtimeMs, excludePaths });
4787
+ const jsonlFiles = projectDirs.flatMap((projectDir) => indexedCandidates(
4788
+ discoveryIndex,
4789
+ `claude:${projectDir}`,
4790
+ () => fs2.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => path2.join(projectDir, f))
4791
+ ));
4792
+ return getNewestIndexedPath(jsonlFiles, { minMtimeMs, excludePaths });
3539
4793
  } catch {
3540
4794
  return null;
3541
4795
  }
@@ -3561,10 +4815,15 @@ function discoverTranscriptFile(sessionId, agentType, forceRefresh = false, excl
3561
4815
  }
3562
4816
  return result;
3563
4817
  }
3564
- function discoverTranscriptFileDetailed(sessionId, agentType, excludePaths) {
3565
- return discoverTranscriptFileImpl(sessionId, agentType, excludePaths);
4818
+ function discoverTranscriptFileDetailed(sessionId, agentType, excludePaths, discoveryIndex) {
4819
+ return discoverTranscriptFileImpl(
4820
+ sessionId,
4821
+ agentType,
4822
+ excludePaths,
4823
+ discoveryIndex
4824
+ );
3566
4825
  }
3567
- function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
4826
+ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths, discoveryIndex) {
3568
4827
  const processPid = parseProcessPid(sessionId);
3569
4828
  if (processPid) {
3570
4829
  const t0 = performance.now();
@@ -3576,7 +4835,12 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3576
4835
  if (agentType === "Claude Code") {
3577
4836
  const t1 = performance.now();
3578
4837
  const startMs = getProcessStartEpochMs(processPid);
3579
- const cwdResult = discoverViaProcessCwd(processPid, startMs ? startMs - 6e4 : void 0, excludePaths);
4838
+ const cwdResult = discoverViaProcessCwd(
4839
+ processPid,
4840
+ startMs ? startMs - 6e4 : void 0,
4841
+ excludePaths,
4842
+ discoveryIndex
4843
+ );
3580
4844
  console.log(`[DISCOVER] Claude Code discovery for PID ${processPid} took ${(performance.now() - t1).toFixed(2)} ms`);
3581
4845
  if (cwdResult) return { path: cwdResult, viaProc: false };
3582
4846
  }
@@ -3585,7 +4849,12 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3585
4849
  const startMs = getProcessStartEpochMs(processPid);
3586
4850
  const command = getProcessCommand(processPid);
3587
4851
  if (commandMatchesAgent(command, agentType)) {
3588
- const codexResult = discoverCodexTranscriptFromHome(processPid, startMs ? startMs - 6e4 : void 0, excludePaths);
4852
+ const codexResult = discoverCodexTranscriptFromHome(
4853
+ processPid,
4854
+ startMs ? startMs - 6e4 : void 0,
4855
+ excludePaths,
4856
+ discoveryIndex
4857
+ );
3589
4858
  console.log(`[DISCOVER] Codex CLI discovery for PID ${processPid} took ${(performance.now() - t1).toFixed(2)} ms`);
3590
4859
  if (codexResult) return { path: codexResult, viaProc: false };
3591
4860
  }
@@ -3593,7 +4862,12 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3593
4862
  if (agentType === "Gemini CLI") {
3594
4863
  const t1 = performance.now();
3595
4864
  const startMs = getProcessStartEpochMs(processPid);
3596
- const geminiResult = discoverGeminiTranscriptFromHome(processPid, startMs ? startMs - 6e4 : void 0, excludePaths);
4865
+ const geminiResult = discoverGeminiTranscriptFromHome(
4866
+ processPid,
4867
+ startMs ? startMs - 6e4 : void 0,
4868
+ excludePaths,
4869
+ discoveryIndex
4870
+ );
3597
4871
  console.log(`[DISCOVER] Gemini CLI discovery for PID ${processPid} took ${(performance.now() - t1).toFixed(2)} ms`);
3598
4872
  if (geminiResult) return { path: geminiResult, viaProc: false };
3599
4873
  }
@@ -3611,7 +4885,12 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3611
4885
  }
3612
4886
  } catch {
3613
4887
  }
3614
- const antigravityResult = discoverAntigravityTranscriptFromHome(processPid, startMs ? startMs - 6e4 : void 0, excludePaths);
4888
+ const antigravityResult = discoverAntigravityTranscriptFromHome(
4889
+ processPid,
4890
+ startMs ? startMs - 6e4 : void 0,
4891
+ excludePaths,
4892
+ discoveryIndex
4893
+ );
3615
4894
  console.log(`[DISCOVER] Antigravity CLI discovery (home path) for PID ${processPid} took ${(performance.now() - t1).toFixed(2)} ms`);
3616
4895
  if (antigravityResult) return { path: antigravityResult, viaProc: false };
3617
4896
  }
@@ -3646,7 +4925,8 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3646
4925
  info.cwd,
3647
4926
  info.pid,
3648
4927
  info.startEpochMs ? info.startEpochMs - 6e4 : void 0,
3649
- excludePaths
4928
+ excludePaths,
4929
+ discoveryIndex
3650
4930
  );
3651
4931
  if (cwdResult) return { path: cwdResult, viaProc: false };
3652
4932
  }
@@ -3657,7 +4937,13 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3657
4937
  { env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
3658
4938
  ).trim();
3659
4939
  if (paneCwd) {
3660
- const cwdResult = discoverViaCwd(paneCwd, panePid ?? void 0, void 0, excludePaths);
4940
+ const cwdResult = discoverViaCwd(
4941
+ paneCwd,
4942
+ panePid ?? void 0,
4943
+ void 0,
4944
+ excludePaths,
4945
+ discoveryIndex
4946
+ );
3661
4947
  if (cwdResult) return { path: cwdResult, viaProc: false };
3662
4948
  }
3663
4949
  } catch {
@@ -3669,14 +4955,20 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3669
4955
  agentInfos.map((info) => discoverCodexTranscriptFromHome(
3670
4956
  info.pid,
3671
4957
  info.startEpochMs ? info.startEpochMs - 6e4 : void 0,
3672
- excludePaths
4958
+ excludePaths,
4959
+ discoveryIndex
3673
4960
  )).filter((value) => Boolean(value)),
3674
4961
  { excludePaths }
3675
4962
  );
3676
4963
  if (newestForAgent) return { path: newestForAgent, viaProc: false };
3677
4964
  }
3678
4965
  if (agentType === "Gemini CLI") {
3679
- const geminiResult = discoverGeminiTranscriptFromHome(panePid ?? void 0, void 0, excludePaths);
4966
+ const geminiResult = discoverGeminiTranscriptFromHome(
4967
+ panePid ?? void 0,
4968
+ void 0,
4969
+ excludePaths,
4970
+ discoveryIndex
4971
+ );
3680
4972
  if (geminiResult) return { path: geminiResult, viaProc: false };
3681
4973
  }
3682
4974
  if (agentType === "Antigravity CLI") {
@@ -3703,7 +4995,8 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3703
4995
  agentInfos.map((info) => discoverAntigravityTranscriptFromHome(
3704
4996
  info.pid,
3705
4997
  info.startEpochMs ? info.startEpochMs - 6e4 : void 0,
3706
- excludePaths
4998
+ excludePaths,
4999
+ discoveryIndex
3707
5000
  )).filter((value) => Boolean(value)),
3708
5001
  { excludePaths }
3709
5002
  );
@@ -3713,11 +5006,17 @@ function discoverTranscriptFileImpl(sessionId, agentType, excludePaths) {
3713
5006
  }
3714
5007
  return { path: null, viaProc: false };
3715
5008
  }
3716
- var MAX_PARTIAL_BUFFER = 256 * 1024;
5009
+ var MAX_JSONL_RECORD_BYTES = 8 * 1024 * 1024;
5010
+ var READ_CHUNK_BYTES = 256 * 1024;
5011
+ var MAX_READ_BYTES_PER_PASS = 4 * 1024 * 1024;
5012
+ var ASYNC_REPLAY_THRESHOLD_BYTES = 1024 * 1024;
5013
+ var ASYNC_REPLAY_YIELD_RECORDS = 64;
3717
5014
  var MAX_REPLAY_TURNS = 1e3;
3718
5015
  var POLL_INTERVAL_MS = 800;
3719
5016
  var DOC_READ_DEBOUNCE_MS = 200;
5017
+ var V2_OP_BATCH_WINDOW_MS = 35;
3720
5018
  var REDISCOVERY_INTERVAL_MS = 5e3;
5019
+ var MIN_REDISCOVERY_STAGGER_MS = 250;
3721
5020
  var MARKDOWN_RESYNC_DRAIN_MS = 300;
3722
5021
  var ENABLE_RETRY_DELAYS_MS = [1e3, 2e3, 5e3];
3723
5022
  var TranscriptWatcher = class {
@@ -3728,6 +5027,8 @@ var TranscriptWatcher = class {
3728
5027
  offset = 0;
3729
5028
  inode = 0;
3730
5029
  partialLine = "";
5030
+ discardingOversizedLine = false;
5031
+ decoder = new import_string_decoder.StringDecoder("utf8");
3731
5032
  seq = 0;
3732
5033
  turns = [];
3733
5034
  /** V2 normalized snapshot state. */
@@ -3735,6 +5036,17 @@ var TranscriptWatcher = class {
3735
5036
  watcher = null;
3736
5037
  pollTimer = null;
3737
5038
  docReadDebounceTimer = null;
5039
+ pendingOpsTimer = null;
5040
+ immediateReadTimer = null;
5041
+ pendingOps = [];
5042
+ pendingOpsSeq = 0;
5043
+ replaying = false;
5044
+ suppressReplayV1Deltas = false;
5045
+ suppressReplayV2Deltas = false;
5046
+ replayInProgress = false;
5047
+ replayReadPending = false;
5048
+ replayGeneration = 0;
5049
+ replayPromise = Promise.resolve();
3738
5050
  stopped = false;
3739
5051
  subscriberCount = 0;
3740
5052
  documentFingerprint = "";
@@ -3780,7 +5092,11 @@ var TranscriptWatcher = class {
3780
5092
  }
3781
5093
  /** Stop watching. */
3782
5094
  stop() {
5095
+ this.flushPendingOps();
3783
5096
  this.stopped = true;
5097
+ this.replayGeneration++;
5098
+ this.replayInProgress = false;
5099
+ this.replayReadPending = false;
3784
5100
  if (this.watcher) {
3785
5101
  this.watcher.close();
3786
5102
  this.watcher = null;
@@ -3793,13 +5109,23 @@ var TranscriptWatcher = class {
3793
5109
  clearTimeout(this.docReadDebounceTimer);
3794
5110
  this.docReadDebounceTimer = null;
3795
5111
  }
5112
+ if (this.pendingOpsTimer) {
5113
+ clearTimeout(this.pendingOpsTimer);
5114
+ this.pendingOpsTimer = null;
5115
+ }
5116
+ if (this.immediateReadTimer) {
5117
+ clearTimeout(this.immediateReadTimer);
5118
+ this.immediateReadTimer = null;
5119
+ }
3796
5120
  }
3797
- /** Get accumulated turns for replay (up to MAX_REPLAY_TURNS). */
5121
+ /** Get accumulated turns for replay (up to MAX_REPLAY_TURNS).
5122
+ * `fromSeq` is retained for API compatibility only: V1 sequence numbers no
5123
+ * longer map to array indexes now that V2 operations are batched. A V1 sync
5124
+ * must therefore use the full bounded, idempotent snapshot. */
3798
5125
  getReplayTurns(fromSeq) {
3799
- if (fromSeq !== void 0) {
3800
- return this.turns.filter((_, i) => i >= fromSeq).slice(-MAX_REPLAY_TURNS);
3801
- }
3802
- return this.turns.slice(-MAX_REPLAY_TURNS);
5126
+ this.flushPendingOps();
5127
+ void fromSeq;
5128
+ return boundMarkdownTurnsForWire(this.turns.slice(-MAX_REPLAY_TURNS));
3803
5129
  }
3804
5130
  /** Get current sequence number. */
3805
5131
  get currentSeq() {
@@ -3807,24 +5133,310 @@ var TranscriptWatcher = class {
3807
5133
  }
3808
5134
  /** Get the V2 normalized snapshot. */
3809
5135
  getSnapshot() {
5136
+ this.flushPendingOps();
3810
5137
  return trimSnapshot(this.snapshot);
3811
5138
  }
5139
+ queueV2Ops(ops) {
5140
+ if (ops.length === 0) return;
5141
+ const wireBatches = boundTranscriptOpsForWire(ops);
5142
+ for (const batch of wireBatches) {
5143
+ if (this.pendingOps.length > 0 && utf8Bytes([...this.pendingOps, ...batch]) > MAX_TRANSCRIPT_OP_BATCH_BYTES) {
5144
+ this.flushPendingOps();
5145
+ }
5146
+ if (this.pendingOps.length === 0) {
5147
+ this.seq++;
5148
+ this.pendingOpsSeq = this.seq;
5149
+ }
5150
+ this.pendingOps.push(...batch);
5151
+ if (wireBatches.length > 1) this.flushPendingOps();
5152
+ }
5153
+ if (ops.some((op) => this.isCompletionBarrier(op))) {
5154
+ this.flushPendingOps();
5155
+ return;
5156
+ }
5157
+ if (!this.pendingOpsTimer) {
5158
+ this.pendingOpsTimer = setTimeout(() => {
5159
+ this.pendingOpsTimer = null;
5160
+ this.flushPendingOps();
5161
+ }, V2_OP_BATCH_WINDOW_MS);
5162
+ if (typeof this.pendingOpsTimer.unref === "function") {
5163
+ this.pendingOpsTimer.unref();
5164
+ }
5165
+ }
5166
+ }
5167
+ isCompletionBarrier(op) {
5168
+ if (op.op === "complete_block" || op.op === "complete_turn") return true;
5169
+ return op.op === "set_tool_state" && (op.isComplete === true || op.state === "completed" || op.state === "error" || op.state === "cancelled");
5170
+ }
5171
+ flushPendingOps() {
5172
+ if (this.pendingOpsTimer) {
5173
+ clearTimeout(this.pendingOpsTimer);
5174
+ this.pendingOpsTimer = null;
5175
+ }
5176
+ if (this.pendingOps.length === 0) return;
5177
+ const ops = this.pendingOps;
5178
+ const seq = this.pendingOpsSeq;
5179
+ this.pendingOps = [];
5180
+ this.pendingOpsSeq = 0;
5181
+ try {
5182
+ this.snapshot = applyOps(this.snapshot, ops);
5183
+ this.callbacks.onOps?.(ops, seq);
5184
+ } catch (error) {
5185
+ this.reportParserFailure(
5186
+ this.parserV2?.name ?? "transcript-v2",
5187
+ "line",
5188
+ error
5189
+ );
5190
+ }
5191
+ }
5192
+ /**
5193
+ * Parser/schema drift must never terminate the connector: terminal transport
5194
+ * and transcript rendering share this process. Report the bad record without
5195
+ * including transcript content, then let the watcher continue with later
5196
+ * records.
5197
+ */
5198
+ reportParserFailure(parserName, phase, error) {
5199
+ const detail = error instanceof Error ? error.message : String(error);
5200
+ const message = `${parserName} failed to parse transcript ${phase}: ${detail}`;
5201
+ try {
5202
+ if (this.callbacks.onError) {
5203
+ this.callbacks.onError(message);
5204
+ } else {
5205
+ log7.warn("transcript parser failure; record skipped", {
5206
+ parser: parserName,
5207
+ phase,
5208
+ error: detail
5209
+ });
5210
+ }
5211
+ } catch (callbackError) {
5212
+ log7.warn("transcript parser failure callback also failed", {
5213
+ parser: parserName,
5214
+ phase,
5215
+ error: detail,
5216
+ callbackError: callbackError instanceof Error ? callbackError.message : String(callbackError)
5217
+ });
5218
+ }
5219
+ }
3812
5220
  /** Read and replay the full transcript from the start of the file. */
3813
5221
  replayFromStart() {
5222
+ this.flushPendingOps();
3814
5223
  if (this.parser.parseDocument) {
3815
5224
  this.readDocument(true);
3816
5225
  return;
3817
5226
  }
5227
+ const wantsV1 = this.callbacks.shouldProcessV1?.() ?? true;
5228
+ const wantsV2 = this.callbacks.shouldProcessV2?.() ?? Boolean(this.callbacks.onOps || this.callbacks.onSnapshot);
5229
+ let stat;
5230
+ try {
5231
+ stat = fs2.statSync(this.filePath);
5232
+ } catch {
5233
+ return;
5234
+ }
5235
+ const canEmitOneSnapshot = (!wantsV1 || Boolean(this.callbacks.onTurnsSnapshot)) && (!wantsV2 || Boolean(this.callbacks.onSnapshot));
5236
+ if (stat.size >= ASYNC_REPLAY_THRESHOLD_BYTES && canEmitOneSnapshot) {
5237
+ const generation = ++this.replayGeneration;
5238
+ this.replayInProgress = true;
5239
+ this.replayPromise = this.replayLineHistoryAsync(
5240
+ stat.size,
5241
+ stat.ino,
5242
+ generation,
5243
+ wantsV1,
5244
+ wantsV2
5245
+ ).catch((error) => {
5246
+ this.reportParserFailure(
5247
+ this.parserV2?.name ?? this.parser.name,
5248
+ "document",
5249
+ error
5250
+ );
5251
+ }).finally(() => {
5252
+ if (generation !== this.replayGeneration) return;
5253
+ this.replayInProgress = false;
5254
+ if (this.replayReadPending && !this.stopped) {
5255
+ this.replayReadPending = false;
5256
+ this.scheduleImmediateRead();
5257
+ }
5258
+ });
5259
+ return;
5260
+ }
5261
+ let content;
5262
+ try {
5263
+ content = fs2.readFileSync(this.filePath, "utf-8");
5264
+ } catch {
5265
+ return;
5266
+ }
3818
5267
  const savedOffset = this.offset;
3819
- this.offset = 0;
5268
+ this.offset = Buffer.byteLength(content);
3820
5269
  this.partialLine = "";
5270
+ this.discardingOversizedLine = false;
5271
+ this.decoder = new import_string_decoder.StringDecoder("utf8");
5272
+ this.turns = [];
3821
5273
  this.snapshot = createEmptySnapshot(this.snapshot.sessionId);
3822
- this.readNewData();
3823
- this.rebuildV2SnapshotFromDocument();
5274
+ this.replaying = true;
5275
+ this.suppressReplayV1Deltas = wantsV1 && Boolean(this.callbacks.onTurnsSnapshot);
5276
+ this.suppressReplayV2Deltas = Boolean(
5277
+ wantsV2 && this.parserV2?.parseDocument && this.callbacks.onSnapshot
5278
+ );
5279
+ try {
5280
+ if (wantsV1) this.processChunk(content);
5281
+ } finally {
5282
+ this.replaying = false;
5283
+ this.suppressReplayV1Deltas = false;
5284
+ this.suppressReplayV2Deltas = false;
5285
+ }
5286
+ let rebuiltV2 = false;
5287
+ if (wantsV2 && this.parserV2?.parseDocument && this.callbacks.onSnapshot) {
5288
+ rebuiltV2 = this.rebuildV2SnapshotFromDocument(content);
5289
+ }
5290
+ const emitV1Snapshot = wantsV1 && Boolean(this.callbacks.onTurnsSnapshot);
5291
+ const emitV2Snapshot = rebuiltV2 && Boolean(this.callbacks.onSnapshot);
5292
+ if (emitV1Snapshot || emitV2Snapshot) {
5293
+ this.seq++;
5294
+ if (emitV1Snapshot) {
5295
+ this.callbacks.onTurnsSnapshot(
5296
+ boundMarkdownTurnsForWire(this.turns.slice(-MAX_REPLAY_TURNS)),
5297
+ this.seq
5298
+ );
5299
+ }
5300
+ if (emitV2Snapshot) {
5301
+ this.callbacks.onSnapshot(this.snapshot, this.seq);
5302
+ }
5303
+ }
3824
5304
  if (this.offset < savedOffset) {
3825
5305
  this.offset = savedOffset;
3826
5306
  }
3827
5307
  }
5308
+ /** Await the current historical replay. Small replays resolve immediately. */
5309
+ waitForReplay() {
5310
+ return this.replayPromise;
5311
+ }
5312
+ async replayLineHistoryAsync(replayEnd, replayInode, generation, wantsV1, wantsV2) {
5313
+ this.offset = replayEnd;
5314
+ this.partialLine = "";
5315
+ this.discardingOversizedLine = false;
5316
+ this.decoder = new import_string_decoder.StringDecoder("utf8");
5317
+ this.turns = [];
5318
+ this.snapshot = createEmptySnapshot(this.snapshot.sessionId);
5319
+ let carry = "";
5320
+ let discardingOversizedLine = false;
5321
+ let recordsSinceYield = 0;
5322
+ let sourceBytesSinceYield = 0;
5323
+ let pendingReplayOps = [];
5324
+ const stream = fs2.createReadStream(this.filePath, {
5325
+ start: 0,
5326
+ end: Math.max(0, replayEnd - 1),
5327
+ encoding: "utf8",
5328
+ highWaterMark: READ_CHUNK_BYTES
5329
+ });
5330
+ for await (const rawChunk of stream) {
5331
+ if (generation !== this.replayGeneration || this.stopped) {
5332
+ stream.destroy();
5333
+ return;
5334
+ }
5335
+ let chunk = String(rawChunk);
5336
+ if (discardingOversizedLine) {
5337
+ const recordEnd = chunk.indexOf("\n");
5338
+ if (recordEnd < 0) continue;
5339
+ discardingOversizedLine = false;
5340
+ chunk = chunk.slice(recordEnd + 1);
5341
+ }
5342
+ const combined = carry + chunk;
5343
+ let lineStart = 0;
5344
+ let lineEnd = combined.indexOf("\n", lineStart);
5345
+ while (lineEnd >= 0) {
5346
+ const line = combined.slice(lineStart, lineEnd);
5347
+ lineStart = lineEnd + 1;
5348
+ const lineBytes = Buffer.byteLength(line, "utf8");
5349
+ if (lineBytes > MAX_JSONL_RECORD_BYTES) {
5350
+ this.reportOversizedRecord();
5351
+ } else {
5352
+ const trimmed = line.trim();
5353
+ if (trimmed) {
5354
+ this.parseHistoricalLine(
5355
+ trimmed,
5356
+ wantsV1,
5357
+ wantsV2,
5358
+ pendingReplayOps
5359
+ );
5360
+ }
5361
+ }
5362
+ recordsSinceYield++;
5363
+ sourceBytesSinceYield += lineBytes;
5364
+ if (recordsSinceYield >= ASYNC_REPLAY_YIELD_RECORDS || sourceBytesSinceYield >= MAX_READ_BYTES_PER_PASS) {
5365
+ recordsSinceYield = 0;
5366
+ sourceBytesSinceYield = 0;
5367
+ if (pendingReplayOps.length > 0) {
5368
+ this.snapshot = applyOps(this.snapshot, pendingReplayOps);
5369
+ pendingReplayOps = [];
5370
+ }
5371
+ await new Promise((resolve) => setImmediate(resolve));
5372
+ if (generation !== this.replayGeneration || this.stopped) {
5373
+ stream.destroy();
5374
+ return;
5375
+ }
5376
+ }
5377
+ lineEnd = combined.indexOf("\n", lineStart);
5378
+ }
5379
+ carry = combined.slice(lineStart);
5380
+ if (Buffer.byteLength(carry, "utf8") > MAX_JSONL_RECORD_BYTES) {
5381
+ this.reportOversizedRecord();
5382
+ carry = "";
5383
+ discardingOversizedLine = true;
5384
+ }
5385
+ }
5386
+ if (generation !== this.replayGeneration || this.stopped) return;
5387
+ try {
5388
+ const currentStat = fs2.statSync(this.filePath);
5389
+ if (currentStat.ino !== replayInode || currentStat.size < replayEnd) {
5390
+ this.restartAfterRotation(currentStat);
5391
+ return;
5392
+ }
5393
+ } catch {
5394
+ return;
5395
+ }
5396
+ if (pendingReplayOps.length > 0) {
5397
+ this.snapshot = applyOps(this.snapshot, pendingReplayOps);
5398
+ }
5399
+ this.partialLine = carry;
5400
+ this.discardingOversizedLine = discardingOversizedLine;
5401
+ this.seq++;
5402
+ if (wantsV1 && this.callbacks.onTurnsSnapshot) {
5403
+ this.callbacks.onTurnsSnapshot(
5404
+ boundMarkdownTurnsForWire(this.turns.slice(-MAX_REPLAY_TURNS)),
5405
+ this.seq
5406
+ );
5407
+ }
5408
+ if (wantsV2 && this.callbacks.onSnapshot) {
5409
+ this.snapshot = trimSnapshot(this.snapshot);
5410
+ this.callbacks.onSnapshot(this.snapshot, this.seq);
5411
+ }
5412
+ }
5413
+ parseHistoricalLine(line, wantsV1, wantsV2, pendingReplayOps) {
5414
+ if (wantsV2 && this.parserV2) {
5415
+ try {
5416
+ const ops = this.parserV2.parseLine(line);
5417
+ if (ops.length > 0) pendingReplayOps.push(...ops);
5418
+ } catch (error) {
5419
+ this.reportParserFailure(this.parserV2.name, "line", error);
5420
+ }
5421
+ }
5422
+ if (!wantsV1) return;
5423
+ try {
5424
+ const turn = this.parser.parseLine(line);
5425
+ if (!turn) return;
5426
+ this.turns.push(boundMarkdownTurnForWire({
5427
+ turnId: turn.turnId,
5428
+ role: turn.role,
5429
+ content: turn.content,
5430
+ tools: turn.tools,
5431
+ timestamp: turn.timestamp
5432
+ }));
5433
+ if (this.turns.length > MAX_REPLAY_TURNS * 2) {
5434
+ this.turns = this.turns.slice(-MAX_REPLAY_TURNS);
5435
+ }
5436
+ } catch (error) {
5437
+ this.reportParserFailure(this.parser.name, "line", error);
5438
+ }
5439
+ }
3828
5440
  /**
3829
5441
  * If the V2 parser supports parseDocument (e.g. Codex), rebuild the V2
3830
5442
  * snapshot from the full file contents and notify subscribers. This is the
@@ -3832,24 +5444,31 @@ var TranscriptWatcher = class {
3832
5444
  * unblocks blank-on-attach for agents whose V1 parser is line-based but
3833
5445
  * whose V2 parser can replay the whole document. See #473.
3834
5446
  */
3835
- rebuildV2SnapshotFromDocument() {
3836
- if (!this.parserV2?.parseDocument || !this.callbacks.onSnapshot) return;
3837
- let content;
5447
+ rebuildV2SnapshotFromDocument(content) {
5448
+ if (!this.parserV2?.parseDocument) return false;
5449
+ let ops;
3838
5450
  try {
3839
- content = fs2.readFileSync(this.filePath, "utf-8");
3840
- } catch {
3841
- return;
5451
+ ops = this.parserV2.parseDocument(content);
5452
+ } catch (error) {
5453
+ this.reportParserFailure(this.parserV2.name, "document", error);
5454
+ return false;
3842
5455
  }
3843
- const ops = this.parserV2.parseDocument(content);
3844
5456
  if (ops.length === 0 && this.snapshot.turnOrder.length > 0) {
3845
- return;
5457
+ return false;
3846
5458
  }
3847
- let nextSnapshot = createEmptySnapshot(this.snapshot.sessionId);
3848
- if (ops.length > 0) {
3849
- nextSnapshot = applyOps(nextSnapshot, ops);
5459
+ try {
5460
+ let nextSnapshot = createEmptySnapshot(this.snapshot.sessionId);
5461
+ if (ops.length > 0) {
5462
+ for (const batch of boundTranscriptOpsForWire(ops)) {
5463
+ nextSnapshot = applyOps(nextSnapshot, batch);
5464
+ }
5465
+ }
5466
+ this.snapshot = nextSnapshot;
5467
+ return true;
5468
+ } catch (error) {
5469
+ this.reportParserFailure(this.parserV2.name, "document", error);
5470
+ return false;
3850
5471
  }
3851
- this.snapshot = trimSnapshot(nextSnapshot);
3852
- this.callbacks.onSnapshot(this.snapshot, this.seq);
3853
5472
  }
3854
5473
  startWatching() {
3855
5474
  try {
@@ -3882,17 +5501,54 @@ var TranscriptWatcher = class {
3882
5501
  try {
3883
5502
  const stat = fs2.statSync(this.filePath);
3884
5503
  if (stat.ino !== this.inode) {
3885
- this.inode = stat.ino;
3886
- this.offset = 0;
3887
- this.partialLine = "";
5504
+ if (this.parser.parseDocument) {
5505
+ this.resetReadCursor(stat);
5506
+ } else {
5507
+ this.restartAfterRotation(stat);
5508
+ }
3888
5509
  } else if (stat.size < this.offset) {
3889
- this.offset = 0;
3890
- this.partialLine = "";
5510
+ if (this.parser.parseDocument) {
5511
+ this.resetReadCursor(stat);
5512
+ } else {
5513
+ this.restartAfterRotation(stat);
5514
+ }
3891
5515
  }
3892
5516
  } catch {
3893
5517
  }
3894
5518
  }
5519
+ resetReadCursor(stat) {
5520
+ this.flushPendingOps();
5521
+ this.inode = stat.ino;
5522
+ this.offset = 0;
5523
+ this.partialLine = "";
5524
+ this.discardingOversizedLine = false;
5525
+ this.decoder = new import_string_decoder.StringDecoder("utf8");
5526
+ }
5527
+ restartAfterRotation(stat) {
5528
+ this.flushPendingOps();
5529
+ this.replayGeneration++;
5530
+ this.replayInProgress = false;
5531
+ this.replayReadPending = false;
5532
+ this.inode = stat.ino;
5533
+ this.offset = stat.size;
5534
+ this.partialLine = "";
5535
+ this.discardingOversizedLine = false;
5536
+ this.decoder = new import_string_decoder.StringDecoder("utf8");
5537
+ this.turns = [];
5538
+ this.snapshot = createEmptySnapshot(this.snapshot.sessionId);
5539
+ if (this.callbacks.shouldProcessV1?.() !== false) {
5540
+ this.callbacks.onTurnsSnapshot?.([], 0);
5541
+ }
5542
+ if ((this.callbacks.shouldProcessV2?.() ?? Boolean(this.callbacks.onSnapshot)) && this.callbacks.onSnapshot) {
5543
+ this.callbacks.onSnapshot(this.snapshot, 0, true);
5544
+ }
5545
+ this.replayFromStart();
5546
+ }
3895
5547
  readNewData() {
5548
+ if (this.replayInProgress) {
5549
+ this.replayReadPending = true;
5550
+ return;
5551
+ }
3896
5552
  if (this.parser.parseDocument) {
3897
5553
  this.readDocument(false);
3898
5554
  return;
@@ -3906,17 +5562,36 @@ var TranscriptWatcher = class {
3906
5562
  try {
3907
5563
  const stat = fs2.fstatSync(fd);
3908
5564
  if (stat.size <= this.offset) return;
3909
- const readSize = Math.min(stat.size - this.offset, 256 * 1024);
3910
- const buffer = Buffer.alloc(readSize);
3911
- const bytesRead = fs2.readSync(fd, buffer, 0, readSize, this.offset);
3912
- if (bytesRead === 0) return;
3913
- this.offset += bytesRead;
3914
- const chunk = buffer.subarray(0, bytesRead).toString("utf-8");
3915
- this.processChunk(chunk);
5565
+ let remaining = Math.min(
5566
+ stat.size - this.offset,
5567
+ MAX_READ_BYTES_PER_PASS
5568
+ );
5569
+ while (remaining > 0) {
5570
+ const readSize = Math.min(remaining, READ_CHUNK_BYTES);
5571
+ const buffer = Buffer.allocUnsafe(readSize);
5572
+ const bytesRead = fs2.readSync(fd, buffer, 0, readSize, this.offset);
5573
+ if (bytesRead === 0) break;
5574
+ this.offset += bytesRead;
5575
+ remaining -= bytesRead;
5576
+ this.processChunk(this.decoder.write(buffer.subarray(0, bytesRead)));
5577
+ }
5578
+ if (this.offset < stat.size) {
5579
+ this.scheduleImmediateRead();
5580
+ }
3916
5581
  } finally {
3917
5582
  fs2.closeSync(fd);
3918
5583
  }
3919
5584
  }
5585
+ scheduleImmediateRead() {
5586
+ if (this.immediateReadTimer || this.stopped) return;
5587
+ this.immediateReadTimer = setTimeout(() => {
5588
+ this.immediateReadTimer = null;
5589
+ if (!this.stopped) this.readNewData();
5590
+ }, 0);
5591
+ if (typeof this.immediateReadTimer.unref === "function") {
5592
+ this.immediateReadTimer.unref();
5593
+ }
5594
+ }
3920
5595
  readDocument(force) {
3921
5596
  let stat;
3922
5597
  try {
@@ -3933,68 +5608,142 @@ var TranscriptWatcher = class {
3933
5608
  return;
3934
5609
  }
3935
5610
  this.documentFingerprint = fingerprint;
3936
- const parsedTurns = this.parser.parseDocument?.(content) ?? [];
3937
- const v2Ops = this.parserV2?.parseDocument?.(content) ?? [];
5611
+ let parsedTurns;
5612
+ try {
5613
+ parsedTurns = this.parser.parseDocument?.(content) ?? [];
5614
+ } catch (error) {
5615
+ this.reportParserFailure(this.parser.name, "document", error);
5616
+ return;
5617
+ }
5618
+ let v2Ops;
5619
+ try {
5620
+ v2Ops = this.parserV2?.parseDocument?.(content) ?? [];
5621
+ } catch (error) {
5622
+ this.reportParserFailure(
5623
+ this.parserV2?.name ?? "transcript-v2",
5624
+ "document",
5625
+ error
5626
+ );
5627
+ return;
5628
+ }
3938
5629
  const parsedNothing = parsedTurns.length === 0 && v2Ops.length === 0;
3939
5630
  const haveContent = this.turns.length > 0 || this.snapshot.turnOrder.length > 0;
3940
5631
  if (parsedNothing && haveContent) {
3941
5632
  return;
3942
5633
  }
3943
- this.seq++;
3944
- this.turns = parsedTurns.slice(-MAX_REPLAY_TURNS).map((turn) => ({
3945
- turnId: turn.turnId,
3946
- role: turn.role,
3947
- content: turn.content,
3948
- tools: turn.tools,
3949
- timestamp: turn.timestamp
3950
- }));
3951
- this.callbacks.onTurnsSnapshot?.(this.turns, this.seq);
3952
- if (this.parserV2?.parseDocument && this.callbacks.onSnapshot) {
3953
- let nextSnapshot = createEmptySnapshot(this.snapshot.sessionId);
3954
- if (v2Ops.length > 0) {
3955
- nextSnapshot = applyOps(nextSnapshot, v2Ops);
5634
+ try {
5635
+ this.flushPendingOps();
5636
+ this.seq++;
5637
+ this.turns = parsedTurns.slice(-MAX_REPLAY_TURNS).map((turn) => boundMarkdownTurnForWire({
5638
+ turnId: turn.turnId,
5639
+ role: turn.role,
5640
+ content: turn.content,
5641
+ tools: turn.tools,
5642
+ timestamp: turn.timestamp
5643
+ }));
5644
+ this.callbacks.onTurnsSnapshot?.(
5645
+ boundMarkdownTurnsForWire(this.turns),
5646
+ this.seq
5647
+ );
5648
+ if (this.parserV2?.parseDocument && this.callbacks.onSnapshot) {
5649
+ let nextSnapshot = createEmptySnapshot(this.snapshot.sessionId);
5650
+ if (v2Ops.length > 0) {
5651
+ for (const batch of boundTranscriptOpsForWire(v2Ops)) {
5652
+ nextSnapshot = applyOps(nextSnapshot, batch);
5653
+ }
5654
+ }
5655
+ this.snapshot = nextSnapshot;
5656
+ this.callbacks.onSnapshot(this.snapshot, this.seq);
3956
5657
  }
3957
- this.snapshot = trimSnapshot(nextSnapshot);
3958
- this.callbacks.onSnapshot(this.snapshot, this.seq);
5658
+ } catch (error) {
5659
+ this.reportParserFailure(
5660
+ this.parserV2?.name ?? this.parser.name,
5661
+ "document",
5662
+ error
5663
+ );
3959
5664
  }
3960
5665
  }
3961
5666
  processChunk(chunk) {
5667
+ if (this.discardingOversizedLine) {
5668
+ const recordEnd = chunk.indexOf("\n");
5669
+ if (recordEnd < 0) return;
5670
+ this.discardingOversizedLine = false;
5671
+ chunk = chunk.slice(recordEnd + 1);
5672
+ }
3962
5673
  const combined = this.partialLine + chunk;
3963
5674
  const lines = combined.split("\n");
3964
5675
  this.partialLine = lines.pop() ?? "";
3965
- if (this.partialLine.length > MAX_PARTIAL_BUFFER) {
3966
- this.callbacks.onError?.("Partial line buffer exceeded limit, discarding");
5676
+ if (Buffer.byteLength(this.partialLine, "utf8") > MAX_JSONL_RECORD_BYTES) {
5677
+ this.reportOversizedRecord();
3967
5678
  this.partialLine = "";
5679
+ this.discardingOversizedLine = true;
3968
5680
  }
3969
5681
  for (const line of lines) {
5682
+ if (Buffer.byteLength(line, "utf8") > MAX_JSONL_RECORD_BYTES) {
5683
+ this.reportOversizedRecord();
5684
+ continue;
5685
+ }
3970
5686
  const trimmed = line.trim();
3971
5687
  if (!trimmed) continue;
3972
- if (this.parserV2 && this.callbacks.onOps) {
3973
- const ops = this.parserV2.parseLine(trimmed);
3974
- if (ops.length > 0) {
3975
- this.seq++;
3976
- this.snapshot = applyOps(this.snapshot, ops);
3977
- this.snapshot = trimSnapshot(this.snapshot);
3978
- this.callbacks.onOps(ops, this.seq);
3979
- }
3980
- }
3981
- const turn = this.parser.parseLine(trimmed);
3982
- if (turn) {
3983
- if (!this.parserV2 || !this.callbacks.onOps) {
3984
- this.seq++;
3985
- }
3986
- this.turns.push({
3987
- turnId: turn.turnId,
3988
- role: turn.role,
3989
- content: turn.content,
3990
- tools: turn.tools,
3991
- timestamp: turn.timestamp
3992
- });
3993
- if (this.turns.length > MAX_REPLAY_TURNS * 2) {
3994
- this.turns = this.turns.slice(-MAX_REPLAY_TURNS);
5688
+ let v2Queued = false;
5689
+ const deferV2ToDocumentReplay = this.replaying && this.suppressReplayV2Deltas;
5690
+ const shouldProcessV2 = this.callbacks.shouldProcessV2?.() ?? Boolean(this.callbacks.onOps || this.callbacks.onSnapshot);
5691
+ if (!deferV2ToDocumentReplay && this.parserV2 && shouldProcessV2 && (this.callbacks.onOps || this.callbacks.onSnapshot)) {
5692
+ try {
5693
+ const ops = this.parserV2.parseLine(trimmed);
5694
+ if (ops.length > 0) {
5695
+ this.queueV2Ops(ops);
5696
+ v2Queued = true;
5697
+ }
5698
+ } catch (error) {
5699
+ this.reportParserFailure(this.parserV2.name, "line", error);
3995
5700
  }
3996
- this.callbacks.onTurn(turn, this.seq);
3997
5701
  }
5702
+ if (this.callbacks.shouldProcessV1?.() === false) continue;
5703
+ try {
5704
+ const turn = this.parser.parseLine(trimmed);
5705
+ if (turn) {
5706
+ const emitV1Delta = !this.replaying || !this.suppressReplayV1Deltas;
5707
+ if (!v2Queued && emitV1Delta) {
5708
+ this.flushPendingOps();
5709
+ this.seq++;
5710
+ }
5711
+ const boundedTurn = boundMarkdownTurnForWire({
5712
+ turnId: turn.turnId,
5713
+ role: turn.role,
5714
+ content: turn.content,
5715
+ tools: turn.tools,
5716
+ timestamp: turn.timestamp
5717
+ });
5718
+ this.turns.push(boundedTurn);
5719
+ if (this.turns.length > MAX_REPLAY_TURNS * 2) {
5720
+ this.turns = this.turns.slice(-MAX_REPLAY_TURNS);
5721
+ }
5722
+ if (emitV1Delta) {
5723
+ this.callbacks.onTurn(boundedTurn, this.seq);
5724
+ }
5725
+ }
5726
+ } catch (error) {
5727
+ this.reportParserFailure(this.parser.name, "line", error);
5728
+ }
5729
+ }
5730
+ }
5731
+ reportOversizedRecord() {
5732
+ const message = `Transcript JSONL record exceeded ${MAX_JSONL_RECORD_BYTES} bytes; record omitted and streaming resumed at the next line`;
5733
+ try {
5734
+ if (this.callbacks.onError) {
5735
+ this.callbacks.onError(message);
5736
+ } else {
5737
+ log7.warn("oversized transcript record omitted", {
5738
+ filePath: this.filePath,
5739
+ maxBytes: MAX_JSONL_RECORD_BYTES
5740
+ });
5741
+ }
5742
+ } catch (error) {
5743
+ log7.warn("oversized transcript error callback threw", {
5744
+ filePath: this.filePath,
5745
+ error: error instanceof Error ? error.message : String(error)
5746
+ });
3998
5747
  }
3999
5748
  }
4000
5749
  };
@@ -4011,7 +5760,11 @@ function getParser(agentType) {
4011
5760
  }
4012
5761
  var TranscriptWatcherManager = class {
4013
5762
  active = /* @__PURE__ */ new Map();
4014
- rediscoveryTimers = /* @__PURE__ */ new Map();
5763
+ /** One staggered round-robin scheduler replaces synchronized per-session timers. */
5764
+ rediscoverySessions = /* @__PURE__ */ new Map();
5765
+ rediscoveryTimer;
5766
+ rediscoveryCursor = 0;
5767
+ rediscoveryIndex;
4015
5768
  /** #542: sessions awaiting a markdown snapshot re-emit after a frame drop. */
4016
5769
  pendingMarkdownResync = /* @__PURE__ */ new Set();
4017
5770
  markdownResyncTimer;
@@ -4019,6 +5772,10 @@ var TranscriptWatcherManager = class {
4019
5772
  enableRetryTimers = /* @__PURE__ */ new Map();
4020
5773
  /** Index into ENABLE_RETRY_DELAYS_MS for the next scheduled attempt. */
4021
5774
  enableRetryAttempts = /* @__PURE__ */ new Map();
5775
+ /** Desired subscriptions outlive transient discovery failures. Keeping this
5776
+ * per viewer prevents one pane's disable from cancelling another pane's
5777
+ * pending retry and lets a replacement watcher restore every subscriber. */
5778
+ desiredSubscriptions = /* @__PURE__ */ new Map();
4022
5779
  sendFn;
4023
5780
  sendSnapshotFn;
4024
5781
  sendOpsFn;
@@ -4032,16 +5789,33 @@ var TranscriptWatcherManager = class {
4032
5789
  this.onEnableRetrySuccess = onEnableRetrySuccess;
4033
5790
  }
4034
5791
  /** Enable markdown streaming for a session. */
4035
- enableMarkdown(sessionId, agentType) {
5792
+ enableMarkdown(sessionId, agentType, viewerId, schemaVersion) {
5793
+ const subscriberKey = viewerId || "__legacy_anonymous__";
5794
+ let desired = this.desiredSubscriptions.get(sessionId);
5795
+ if (!desired) {
5796
+ desired = /* @__PURE__ */ new Map();
5797
+ this.desiredSubscriptions.set(sessionId, desired);
5798
+ }
5799
+ desired.set(subscriberKey, {
5800
+ agentType,
5801
+ schemaVersion: schemaVersion === 2 ? 2 : schemaVersion === 1 ? 1 : 0
5802
+ });
5803
+ if (!this.enableRetryTimers.has(sessionId) && !this.enableRetryAttempts.has(sessionId)) {
5804
+ this.enableRetryAttempts.set(sessionId, 0);
5805
+ }
4036
5806
  return this.enableMarkdownInternal(
4037
5807
  sessionId,
4038
5808
  agentType,
4039
5809
  /* isRetry */
4040
- false
5810
+ false,
5811
+ viewerId,
5812
+ schemaVersion
4041
5813
  );
4042
5814
  }
4043
- enableMarkdownInternal(sessionId, agentType, isRetry) {
5815
+ enableMarkdownInternal(sessionId, agentType, isRetry, viewerId, schemaVersion) {
4044
5816
  const requestedAgentType = agentType ?? "";
5817
+ const subscriberKey = viewerId || "__legacy_anonymous__";
5818
+ const requestedSchema = schemaVersion === 2 ? 2 : schemaVersion === 1 ? 1 : 0;
4045
5819
  const lockedSource = getSessionSourceLock(sessionId);
4046
5820
  if (lockedSource && lockedSource !== "file-watcher") {
4047
5821
  log7.info("native-stream consumer owns session; skipping file watch", {
@@ -4049,11 +5823,22 @@ var TranscriptWatcherManager = class {
4049
5823
  agentType,
4050
5824
  lockedSource
4051
5825
  });
4052
- this.cancelEnableRetry(sessionId);
5826
+ this.removeDesiredSubscription(sessionId, subscriberKey);
5827
+ this.cancelEnableRetryIfUnused(sessionId);
5828
+ return { ok: false, reason: "unsupported_agent" };
5829
+ }
5830
+ const parser = getParser(agentType);
5831
+ if (!parser) {
5832
+ log7.info("no transcript parser for agent type", { agentType });
5833
+ this.removeDesiredSubscription(sessionId, subscriberKey);
5834
+ this.cancelEnableRetryIfUnused(sessionId);
4053
5835
  return { ok: false, reason: "unsupported_agent" };
4054
5836
  }
4055
5837
  const existing = this.active.get(sessionId);
4056
5838
  let replacingExisting = false;
5839
+ let replacementSubscribers;
5840
+ let existingToReplace;
5841
+ let replacementFilePath;
4057
5842
  if (existing) {
4058
5843
  const newPath = discoverTranscriptFileDetailed(
4059
5844
  sessionId,
@@ -4064,34 +5849,31 @@ var TranscriptWatcherManager = class {
4064
5849
  const pathChanged = Boolean(newPath && newPath !== existing.filePath);
4065
5850
  const existingPathGone = !fs2.existsSync(existing.filePath);
4066
5851
  if (!agentChanged && !pathChanged && !existingPathGone) {
4067
- existing.watcher.addSubscriber();
5852
+ if (!existing.subscribers.has(subscriberKey)) {
5853
+ existing.watcher.addSubscriber();
5854
+ }
5855
+ existing.subscribers.set(subscriberKey, requestedSchema);
4068
5856
  this.cancelEnableRetry(sessionId);
4069
5857
  return { ok: true };
4070
5858
  }
4071
- existing.watcher.stop();
4072
- this.active.delete(sessionId);
4073
- this.stopRediscovery(sessionId);
4074
- replacingExisting = true;
5859
+ replacementSubscribers = new Map(existing.subscribers);
5860
+ replacementSubscribers.set(subscriberKey, requestedSchema);
4075
5861
  if (!newPath) {
4076
5862
  log7.info("no replacement transcript file found", { sessionId, agentType });
4077
- this.armEnableRetry(sessionId, agentType);
5863
+ this.armEnableRetry(sessionId);
4078
5864
  return { ok: false, reason: "no_transcript" };
4079
5865
  }
5866
+ existingToReplace = existing;
5867
+ replacementFilePath = newPath;
4080
5868
  }
4081
- const parser = getParser(agentType);
4082
- if (!parser) {
4083
- log7.info("no transcript parser for agent type", { agentType });
4084
- this.cancelEnableRetry(sessionId);
4085
- return { ok: false, reason: "unsupported_agent" };
4086
- }
4087
- const filePath = discoverTranscriptFileDetailed(
5869
+ const filePath = replacementFilePath ?? discoverTranscriptFileDetailed(
4088
5870
  sessionId,
4089
5871
  agentType,
4090
5872
  this.ownedPathsExcept(sessionId)
4091
5873
  ).path;
4092
5874
  if (!filePath) {
4093
5875
  log7.info("no transcript file found", { sessionId });
4094
- this.armEnableRetry(sessionId, agentType);
5876
+ this.armEnableRetry(sessionId);
4095
5877
  return { ok: false, reason: "no_transcript" };
4096
5878
  }
4097
5879
  log7.info("watching transcript", { path: filePath, parser: parser.name });
@@ -4101,17 +5883,27 @@ var TranscriptWatcherManager = class {
4101
5883
  parser,
4102
5884
  {
4103
5885
  onTurn: (turn, seq) => {
4104
- this.sendFn(sessionId, turn, seq);
5886
+ if (this.hasLegacySubscribers(sessionId)) {
5887
+ this.sendFn(sessionId, turn, seq);
5888
+ }
4105
5889
  },
4106
5890
  onOps: this.sendOpsFn ? (ops, seq) => {
4107
- this.sendOpsFn(sessionId, ops, seq);
5891
+ if (this.hasV2Subscribers(sessionId)) {
5892
+ this.sendOpsFn(sessionId, ops, seq);
5893
+ }
4108
5894
  } : void 0,
4109
5895
  onTurnsSnapshot: (turns, seq) => {
4110
- this.sendSnapshotFn(sessionId, turns, seq);
5896
+ if (this.hasLegacySubscribers(sessionId)) {
5897
+ this.sendSnapshotFn(sessionId, turns, seq);
5898
+ }
4111
5899
  },
4112
- onSnapshot: this.sendV2SnapshotFn ? (snapshot, seq) => {
4113
- this.sendV2SnapshotFn(sessionId, snapshot, seq);
5900
+ onSnapshot: this.sendV2SnapshotFn ? (snapshot, seq, reset) => {
5901
+ if (this.hasV2Subscribers(sessionId)) {
5902
+ this.sendV2SnapshotFn(sessionId, snapshot, seq, reset);
5903
+ }
4114
5904
  } : void 0,
5905
+ shouldProcessV1: () => this.hasLegacySubscribers(sessionId),
5906
+ shouldProcessV2: () => this.hasV2Subscribers(sessionId),
4115
5907
  onError: (error) => {
4116
5908
  log7.warn("transcript watcher error", { sessionId, error: String(error) });
4117
5909
  }
@@ -4121,13 +5913,32 @@ var TranscriptWatcherManager = class {
4121
5913
  );
4122
5914
  if (!watcher.start()) {
4123
5915
  log7.warn("failed to start watching", { path: filePath });
4124
- this.armEnableRetry(sessionId, agentType);
5916
+ this.armEnableRetry(sessionId);
4125
5917
  return { ok: false, reason: "watch_failed" };
4126
5918
  }
5919
+ const subscribers = replacementSubscribers ?? /* @__PURE__ */ new Map();
5920
+ for (const [key, intent] of this.desiredSubscriptions.get(sessionId) ?? []) {
5921
+ subscribers.set(key, intent.schemaVersion);
5922
+ }
5923
+ if (subscribers.size === 0) {
5924
+ subscribers.set(subscriberKey, requestedSchema);
5925
+ }
5926
+ for (let i = 1; i < subscribers.size; i++) {
5927
+ watcher.addSubscriber();
5928
+ }
5929
+ if (existingToReplace) {
5930
+ existingToReplace.watcher.stop();
5931
+ replacingExisting = true;
5932
+ }
5933
+ this.active.set(sessionId, {
5934
+ watcher,
5935
+ filePath,
5936
+ agentType: agentType ?? "",
5937
+ subscribers
5938
+ });
4127
5939
  if (replacingExisting) {
4128
5940
  this.clearTranscriptState(sessionId);
4129
5941
  }
4130
- this.active.set(sessionId, { watcher, filePath, agentType: agentType ?? "" });
4131
5942
  watcher.replayFromStart();
4132
5943
  this.startRediscovery(sessionId, agentType);
4133
5944
  this.cancelEnableRetry(sessionId);
@@ -4148,24 +5959,39 @@ var TranscriptWatcherManager = class {
4148
5959
  * loading placeholder, and a subsequent prefer-markdown from the portal
4149
5960
  * (e.g. on reconnect or pane re-focus) will start a fresh attempt.
4150
5961
  */
4151
- armEnableRetry(sessionId, agentType) {
5962
+ armEnableRetry(sessionId) {
5963
+ const desired = this.desiredSubscriptions.get(sessionId);
5964
+ if (!desired || desired.size === 0) {
5965
+ this.cancelEnableRetry(sessionId);
5966
+ return;
5967
+ }
4152
5968
  const nextAttempt = this.enableRetryAttempts.get(sessionId) ?? 0;
4153
5969
  if (nextAttempt >= ENABLE_RETRY_DELAYS_MS.length) {
4154
5970
  this.cancelEnableRetry(sessionId);
4155
5971
  return;
4156
5972
  }
4157
5973
  const existing = this.enableRetryTimers.get(sessionId);
4158
- if (existing) clearTimeout(existing);
5974
+ if (existing) return;
4159
5975
  const delay2 = ENABLE_RETRY_DELAYS_MS[nextAttempt];
4160
5976
  const timer = setTimeout(() => {
4161
5977
  this.enableRetryAttempts.set(sessionId, nextAttempt + 1);
4162
5978
  this.enableRetryTimers.delete(sessionId);
5979
+ const currentDesired = this.desiredSubscriptions.get(sessionId);
5980
+ const next = currentDesired?.entries().next();
5981
+ if (!next || next.done) {
5982
+ this.cancelEnableRetry(sessionId);
5983
+ return;
5984
+ }
5985
+ const [subscriberKey, intent] = next.value;
5986
+ const viewerId = subscriberKey === "__legacy_anonymous__" ? void 0 : subscriberKey;
4163
5987
  try {
4164
5988
  this.enableMarkdownInternal(
4165
5989
  sessionId,
4166
- agentType,
5990
+ intent.agentType,
4167
5991
  /* isRetry */
4168
- true
5992
+ true,
5993
+ viewerId,
5994
+ intent.schemaVersion
4169
5995
  );
4170
5996
  } catch (err) {
4171
5997
  log7.warn("enableMarkdown retry threw", { sessionId, err: String(err) });
@@ -4182,9 +6008,22 @@ var TranscriptWatcherManager = class {
4182
6008
  }
4183
6009
  this.enableRetryAttempts.delete(sessionId);
4184
6010
  }
6011
+ removeDesiredSubscription(sessionId, subscriberKey) {
6012
+ const desired = this.desiredSubscriptions.get(sessionId);
6013
+ if (!desired) return;
6014
+ desired.delete(subscriberKey);
6015
+ if (desired.size === 0) this.desiredSubscriptions.delete(sessionId);
6016
+ }
6017
+ cancelEnableRetryIfUnused(sessionId) {
6018
+ if ((this.desiredSubscriptions.get(sessionId)?.size ?? 0) === 0) {
6019
+ this.cancelEnableRetry(sessionId);
6020
+ }
6021
+ }
4185
6022
  clearTranscriptState(sessionId) {
4186
- this.sendSnapshotFn(sessionId, [], 0);
4187
- if (this.sendV2SnapshotFn) {
6023
+ if (this.hasLegacySubscribers(sessionId)) {
6024
+ this.sendSnapshotFn(sessionId, [], 0);
6025
+ }
6026
+ if (this.hasV2Subscribers(sessionId) && this.sendV2SnapshotFn) {
4188
6027
  this.sendV2SnapshotFn(sessionId, createEmptySnapshot(sessionId), 0, true);
4189
6028
  }
4190
6029
  }
@@ -4204,15 +6043,52 @@ var TranscriptWatcherManager = class {
4204
6043
  }
4205
6044
  return owned;
4206
6045
  }
6046
+ hasLegacySubscribers(sessionId) {
6047
+ const session = this.active.get(sessionId);
6048
+ if (!session) return true;
6049
+ for (const schemaVersion of session.subscribers.values()) {
6050
+ if (schemaVersion !== 2) return true;
6051
+ }
6052
+ return false;
6053
+ }
6054
+ hasV2Subscribers(sessionId) {
6055
+ const session = this.active.get(sessionId);
6056
+ if (!session) return false;
6057
+ for (const schemaVersion of session.subscribers.values()) {
6058
+ if (schemaVersion !== 1) return true;
6059
+ }
6060
+ return false;
6061
+ }
4207
6062
  /** Disable markdown streaming for a session. */
4208
- disableMarkdown(sessionId) {
4209
- this.cancelEnableRetry(sessionId);
6063
+ disableMarkdown(sessionId, viewerId) {
6064
+ const subscriberKey = viewerId || "__legacy_anonymous__";
6065
+ const desired = this.desiredSubscriptions.get(sessionId);
6066
+ const stopWholeSession = !viewerId && !desired?.has(subscriberKey);
6067
+ if (stopWholeSession) {
6068
+ this.desiredSubscriptions.delete(sessionId);
6069
+ this.cancelEnableRetry(sessionId);
6070
+ } else {
6071
+ this.removeDesiredSubscription(sessionId, subscriberKey);
6072
+ this.cancelEnableRetryIfUnused(sessionId);
6073
+ }
4210
6074
  const session = this.active.get(sessionId);
4211
6075
  if (!session) return;
6076
+ if (!viewerId && !session.subscribers.has(subscriberKey)) {
6077
+ session.subscribers.clear();
6078
+ session.watcher.stop();
6079
+ this.active.delete(sessionId);
6080
+ this.stopRediscovery(sessionId);
6081
+ this.pendingMarkdownResync.delete(sessionId);
6082
+ log7.info("stopped watching transcript", { sessionId });
6083
+ return;
6084
+ }
6085
+ if (!session.subscribers.has(subscriberKey)) return;
6086
+ session.subscribers.delete(subscriberKey);
4212
6087
  const remaining = session.watcher.removeSubscriber();
4213
6088
  if (remaining > 0) {
4214
6089
  log7.info("viewer left transcript watcher; other viewers still subscribed", {
4215
6090
  sessionId,
6091
+ viewerId,
4216
6092
  remaining
4217
6093
  });
4218
6094
  return;
@@ -4224,74 +6100,164 @@ var TranscriptWatcherManager = class {
4224
6100
  }
4225
6101
  /** Periodically check if the transcript file has changed (new conversation). */
4226
6102
  startRediscovery(sessionId, agentType) {
4227
- this.stopRediscovery(sessionId);
4228
- const timer = setInterval(() => {
4229
- const session = this.active.get(sessionId);
4230
- if (!session) {
4231
- this.stopRediscovery(sessionId);
4232
- return;
6103
+ this.rediscoverySessions.set(sessionId, agentType);
6104
+ this.rediscoveryCursor = 0;
6105
+ this.rediscoveryIndex = void 0;
6106
+ this.scheduleRediscovery(true);
6107
+ }
6108
+ scheduleRediscovery(restart = false) {
6109
+ if (restart && this.rediscoveryTimer) {
6110
+ clearTimeout(this.rediscoveryTimer);
6111
+ this.rediscoveryTimer = void 0;
6112
+ }
6113
+ if (this.rediscoveryTimer || this.rediscoverySessions.size === 0) return;
6114
+ const delay2 = Math.max(
6115
+ MIN_REDISCOVERY_STAGGER_MS,
6116
+ Math.ceil(REDISCOVERY_INTERVAL_MS / this.rediscoverySessions.size)
6117
+ );
6118
+ this.rediscoveryTimer = setTimeout(() => {
6119
+ this.rediscoveryTimer = void 0;
6120
+ this.runRediscoveryTick();
6121
+ }, delay2);
6122
+ if (typeof this.rediscoveryTimer.unref === "function") {
6123
+ this.rediscoveryTimer.unref();
6124
+ }
6125
+ }
6126
+ runRediscoveryTick() {
6127
+ for (const sessionId2 of this.rediscoverySessions.keys()) {
6128
+ if (!this.active.has(sessionId2)) {
6129
+ this.rediscoverySessions.delete(sessionId2);
4233
6130
  }
4234
- const { path: newPath } = discoverTranscriptFileDetailed(
4235
- sessionId,
4236
- agentType,
4237
- this.ownedPathsExcept(sessionId)
4238
- );
4239
- if (!newPath || newPath === session.filePath) return;
4240
- log7.info("transcript file changed", { sessionId, newPath });
4241
- session.watcher.stop();
4242
- const parser = getParser(agentType);
4243
- if (!parser) return;
4244
- const newV2Parser = getParserV2(agentType);
4245
- const newWatcher = new TranscriptWatcher(
4246
- newPath,
4247
- parser,
4248
- {
4249
- onTurn: (turn, seq) => {
6131
+ }
6132
+ const sessionIds = [...this.rediscoverySessions.keys()];
6133
+ if (sessionIds.length === 0) {
6134
+ this.rediscoveryCursor = 0;
6135
+ this.rediscoveryIndex = void 0;
6136
+ return;
6137
+ }
6138
+ if (this.rediscoveryCursor >= sessionIds.length) {
6139
+ this.rediscoveryCursor = 0;
6140
+ this.rediscoveryIndex = void 0;
6141
+ }
6142
+ const sessionId = sessionIds[this.rediscoveryCursor];
6143
+ const agentType = this.rediscoverySessions.get(sessionId);
6144
+ const discoveryIndex = this.rediscoveryIndex ?? createTranscriptDiscoveryIndex();
6145
+ this.rediscoveryIndex = discoveryIndex;
6146
+ this.rediscoverSession(sessionId, agentType, discoveryIndex);
6147
+ this.rediscoveryCursor++;
6148
+ if (this.rediscoveryCursor >= sessionIds.length) {
6149
+ this.rediscoveryCursor = 0;
6150
+ this.rediscoveryIndex = void 0;
6151
+ }
6152
+ this.scheduleRediscovery();
6153
+ }
6154
+ rediscoverSession(sessionId, agentType, discoveryIndex) {
6155
+ const session = this.active.get(sessionId);
6156
+ if (!session) return;
6157
+ const { path: newPath } = discoverTranscriptFileDetailed(
6158
+ sessionId,
6159
+ agentType,
6160
+ this.ownedPathsExcept(sessionId),
6161
+ discoveryIndex
6162
+ );
6163
+ if (!newPath || newPath === session.filePath) return;
6164
+ log7.info("transcript file changed", { sessionId, newPath });
6165
+ const parser = getParser(agentType);
6166
+ if (!parser) return;
6167
+ const newV2Parser = getParserV2(agentType);
6168
+ const newWatcher = new TranscriptWatcher(
6169
+ newPath,
6170
+ parser,
6171
+ {
6172
+ onTurn: (turn, seq) => {
6173
+ if (this.hasLegacySubscribers(sessionId)) {
4250
6174
  this.sendFn(sessionId, turn, seq);
4251
- },
4252
- onOps: this.sendOpsFn ? (ops, seq) => {
6175
+ }
6176
+ },
6177
+ onOps: this.sendOpsFn ? (ops, seq) => {
6178
+ if (this.hasV2Subscribers(sessionId)) {
4253
6179
  this.sendOpsFn(sessionId, ops, seq);
4254
- } : void 0,
4255
- onTurnsSnapshot: (turns, seq) => {
6180
+ }
6181
+ } : void 0,
6182
+ onTurnsSnapshot: (turns, seq) => {
6183
+ if (this.hasLegacySubscribers(sessionId)) {
4256
6184
  this.sendSnapshotFn(sessionId, turns, seq);
4257
- },
4258
- onSnapshot: this.sendV2SnapshotFn ? (snapshot, seq) => {
4259
- this.sendV2SnapshotFn(sessionId, snapshot, seq);
4260
- } : void 0,
4261
- onError: (error) => {
4262
- log7.warn("transcript watcher error", { sessionId, error: String(error) });
4263
6185
  }
4264
6186
  },
4265
- newV2Parser,
4266
- sessionId
4267
- );
4268
- if (!newWatcher.start()) {
4269
- log7.warn("failed to start watching new transcript", { newPath });
4270
- return;
4271
- }
4272
- this.sendSnapshotFn(sessionId, [], 0);
4273
- if (this.sendV2SnapshotFn) {
4274
- this.sendV2SnapshotFn(sessionId, createEmptySnapshot(sessionId), 0, true);
4275
- }
4276
- newWatcher.replayFromStart();
4277
- this.active.set(sessionId, { watcher: newWatcher, filePath: newPath, agentType: agentType ?? "" });
4278
- }, REDISCOVERY_INTERVAL_MS);
4279
- this.rediscoveryTimers.set(sessionId, timer);
6187
+ onSnapshot: this.sendV2SnapshotFn ? (snapshot, seq, reset) => {
6188
+ if (this.hasV2Subscribers(sessionId)) {
6189
+ this.sendV2SnapshotFn(sessionId, snapshot, seq, reset);
6190
+ }
6191
+ } : void 0,
6192
+ shouldProcessV1: () => this.hasLegacySubscribers(sessionId),
6193
+ shouldProcessV2: () => this.hasV2Subscribers(sessionId),
6194
+ onError: (error) => {
6195
+ log7.warn("transcript watcher error", {
6196
+ sessionId,
6197
+ error: String(error)
6198
+ });
6199
+ }
6200
+ },
6201
+ newV2Parser,
6202
+ sessionId
6203
+ );
6204
+ if (!newWatcher.start()) {
6205
+ log7.warn("failed to start watching new transcript", { newPath });
6206
+ return;
6207
+ }
6208
+ for (let i = 1; i < session.subscribers.size; i++) {
6209
+ newWatcher.addSubscriber();
6210
+ }
6211
+ session.watcher.stop();
6212
+ this.active.set(sessionId, {
6213
+ watcher: newWatcher,
6214
+ filePath: newPath,
6215
+ agentType: agentType ?? "",
6216
+ subscribers: session.subscribers
6217
+ });
6218
+ this.clearTranscriptState(sessionId);
6219
+ newWatcher.replayFromStart();
4280
6220
  }
4281
- /** Stop the re-discovery timer for a session. */
6221
+ /** Remove a session from the shared re-discovery scheduler. */
4282
6222
  stopRediscovery(sessionId) {
4283
- const timer = this.rediscoveryTimers.get(sessionId);
4284
- if (timer) {
4285
- clearInterval(timer);
4286
- this.rediscoveryTimers.delete(sessionId);
4287
- }
6223
+ if (!this.rediscoverySessions.delete(sessionId)) return;
6224
+ this.rediscoveryCursor = 0;
6225
+ this.rediscoveryIndex = void 0;
6226
+ this.scheduleRediscovery(true);
4288
6227
  }
4289
- /** Handle sync-markdown request — send replay snapshot (V1 + V2). */
4290
- handleSyncRequest(sessionId, fromSeq) {
6228
+ /**
6229
+ * Handle sync-markdown. V2 sync is always snapshot-only because the relay
6230
+ * group is broadcast and the portal has no logical-op deduplication. A
6231
+ * future `fromSeq` is therefore also answered with current idempotent state.
6232
+ * Callers without a schema keep the legacy dual-snapshot behavior.
6233
+ */
6234
+ handleSyncRequest(sessionId, fromSeq, schemaVersion, viewerId) {
4291
6235
  const session = this.active.get(sessionId);
4292
6236
  if (!session) return;
4293
- const turns = session.watcher.getReplayTurns(fromSeq);
6237
+ if (viewerId) {
6238
+ const subscriberKey = viewerId;
6239
+ if (session.subscribers.has(subscriberKey)) {
6240
+ const requestedSchema = schemaVersion === 2 ? 2 : schemaVersion === 1 ? 1 : 0;
6241
+ session.subscribers.set(subscriberKey, requestedSchema);
6242
+ const intent = this.desiredSubscriptions.get(sessionId)?.get(subscriberKey);
6243
+ if (intent) intent.schemaVersion = requestedSchema;
6244
+ }
6245
+ }
6246
+ void fromSeq;
6247
+ if (schemaVersion === 2) {
6248
+ if (this.sendV2SnapshotFn) {
6249
+ const snapshot = session.watcher.getSnapshot();
6250
+ this.sendV2SnapshotFn(
6251
+ sessionId,
6252
+ snapshot,
6253
+ session.watcher.currentSeq
6254
+ );
6255
+ }
6256
+ return;
6257
+ }
6258
+ const turns = session.watcher.getReplayTurns();
4294
6259
  this.sendSnapshotFn(sessionId, turns, session.watcher.currentSeq);
6260
+ if (schemaVersion === 1) return;
4295
6261
  if (this.sendV2SnapshotFn) {
4296
6262
  const snapshot = session.watcher.getSnapshot();
4297
6263
  this.sendV2SnapshotFn(sessionId, snapshot, session.watcher.currentSeq);
@@ -4303,15 +6269,16 @@ var TranscriptWatcherManager = class {
4303
6269
  * and is the prime drop victim, but — unlike tmux-pipe terminal streams,
4304
6270
  * which resync via capture-pane — the markdown channel had no recovery path,
4305
6271
  * so a dropped snapshot left the read view permanently partial until a
4306
- * reconnect. Re-emit the V2 snapshot for every session that currently holds
4307
- * content, draining one per tick so the (large) snapshots don't immediately
4308
- * re-saturate the queue.
6272
+ * reconnect. Re-emit bounded V1 and/or V2 snapshots according to active
6273
+ * subscriber capabilities, draining one session per tick so recovery does
6274
+ * not immediately re-saturate the queue.
4309
6275
  */
4310
6276
  resyncMarkdownAfterDrop() {
4311
- if (!this.sendV2SnapshotFn) return;
4312
6277
  let queued = false;
4313
6278
  for (const [sessionId, session] of this.active) {
4314
- if (session.watcher.getSnapshot().turnOrder.length > 0) {
6279
+ const legacyPopulated = this.hasLegacySubscribers(sessionId) && session.watcher.getReplayTurns().length > 0;
6280
+ const v2Subscribed = this.hasV2Subscribers(sessionId) && Boolean(this.sendV2SnapshotFn);
6281
+ if (legacyPopulated || v2Subscribed) {
4315
6282
  this.pendingMarkdownResync.add(sessionId);
4316
6283
  queued = true;
4317
6284
  }
@@ -4331,12 +6298,22 @@ var TranscriptWatcherManager = class {
4331
6298
  const sessionId = next.value;
4332
6299
  this.pendingMarkdownResync.delete(sessionId);
4333
6300
  const session = this.active.get(sessionId);
4334
- if (session && this.sendV2SnapshotFn) {
4335
- this.sendV2SnapshotFn(
4336
- sessionId,
4337
- session.watcher.getSnapshot(),
4338
- session.watcher.currentSeq
4339
- );
6301
+ if (session) {
6302
+ if (this.hasLegacySubscribers(sessionId)) {
6303
+ this.sendSnapshotFn(
6304
+ sessionId,
6305
+ session.watcher.getReplayTurns(),
6306
+ session.watcher.currentSeq
6307
+ );
6308
+ }
6309
+ if (this.hasV2Subscribers(sessionId) && this.sendV2SnapshotFn) {
6310
+ this.sendV2SnapshotFn(
6311
+ sessionId,
6312
+ session.watcher.getSnapshot(),
6313
+ session.watcher.currentSeq,
6314
+ true
6315
+ );
6316
+ }
4340
6317
  }
4341
6318
  }, MARKDOWN_RESYNC_DRAIN_MS);
4342
6319
  if (typeof this.markdownResyncTimer.unref === "function") {
@@ -4355,16 +6332,22 @@ var TranscriptWatcherManager = class {
4355
6332
  this.stopMarkdownResync();
4356
6333
  for (const [sessionId, session] of this.active) {
4357
6334
  session.watcher.stop();
4358
- this.stopRediscovery(sessionId);
4359
6335
  log7.info("stopped transcript watcher", { sessionId });
4360
6336
  }
4361
6337
  this.active.clear();
4362
- this.rediscoveryTimers.clear();
6338
+ this.rediscoverySessions.clear();
6339
+ if (this.rediscoveryTimer) {
6340
+ clearTimeout(this.rediscoveryTimer);
6341
+ this.rediscoveryTimer = void 0;
6342
+ }
6343
+ this.rediscoveryCursor = 0;
6344
+ this.rediscoveryIndex = void 0;
4363
6345
  for (const timer of this.enableRetryTimers.values()) {
4364
6346
  clearTimeout(timer);
4365
6347
  }
4366
6348
  this.enableRetryTimers.clear();
4367
6349
  this.enableRetryAttempts.clear();
6350
+ this.desiredSubscriptions.clear();
4368
6351
  }
4369
6352
  /** Check if a session has an active watcher. */
4370
6353
  isWatching(sessionId) {
@@ -9378,6 +11361,80 @@ var KIND_TO_BINARY_LABEL = {
9378
11361
  codex: "Codex"
9379
11362
  };
9380
11363
  var activeSessions = /* @__PURE__ */ new Map();
11364
+ var MAX_NATIVE_RECOVERY_TOMBSTONES = 32;
11365
+ var nativeRecoverySnapshots = /* @__PURE__ */ new Map();
11366
+ function retainNativeRecoverySnapshot(session) {
11367
+ const snapshot = trimSnapshot(session.snapshot);
11368
+ nativeRecoverySnapshots.delete(session.sessionId);
11369
+ nativeRecoverySnapshots.set(session.sessionId, {
11370
+ snapshot,
11371
+ seq: session.seq
11372
+ });
11373
+ while (nativeRecoverySnapshots.size > MAX_NATIVE_RECOVERY_TOMBSTONES) {
11374
+ const oldest = nativeRecoverySnapshots.keys().next().value;
11375
+ if (oldest === void 0) break;
11376
+ nativeRecoverySnapshots.delete(oldest);
11377
+ }
11378
+ }
11379
+ function publishNativeOps(session, ops, sendOps) {
11380
+ try {
11381
+ let nextSnapshot = session.snapshot;
11382
+ for (const batch of boundTranscriptOpsForWire(ops)) {
11383
+ nextSnapshot = applyOps(nextSnapshot, batch);
11384
+ }
11385
+ session.snapshot = nextSnapshot;
11386
+ } catch (error) {
11387
+ log14.warn("native transcript state update failed; delta omitted", {
11388
+ sessionId: session.sessionId,
11389
+ error: error instanceof Error ? error.message : String(error)
11390
+ });
11391
+ return;
11392
+ }
11393
+ session.seq += 1;
11394
+ try {
11395
+ sendOps(session.sessionId, ops, session.seq);
11396
+ } catch (error) {
11397
+ log14.warn("native transcript delta send failed; snapshot retained for recovery", {
11398
+ sessionId: session.sessionId,
11399
+ seq: session.seq,
11400
+ error: error instanceof Error ? error.message : String(error)
11401
+ });
11402
+ }
11403
+ }
11404
+ function getNativeTranscriptSnapshot(sessionId) {
11405
+ const session = activeSessions.get(sessionId);
11406
+ if (session) {
11407
+ return {
11408
+ snapshot: trimSnapshot(session.snapshot),
11409
+ seq: session.seq
11410
+ };
11411
+ }
11412
+ const recovery = nativeRecoverySnapshots.get(sessionId);
11413
+ if (!recovery) return null;
11414
+ return {
11415
+ snapshot: trimSnapshot(recovery.snapshot),
11416
+ seq: recovery.seq
11417
+ };
11418
+ }
11419
+ function syncNativeTranscriptSnapshot(sessionId, sendSnapshot, forceReset = false) {
11420
+ const current = getNativeTranscriptSnapshot(sessionId);
11421
+ if (!current) return false;
11422
+ const reset = forceReset || current.snapshot.turnOrder.length === 0;
11423
+ sendSnapshot(
11424
+ sessionId,
11425
+ current.snapshot,
11426
+ current.seq,
11427
+ reset ? true : void 0
11428
+ );
11429
+ return true;
11430
+ }
11431
+ function getNativeTranscriptSnapshotSessionIds() {
11432
+ const ids = [...activeSessions.keys()];
11433
+ for (const sessionId of nativeRecoverySnapshots.keys()) {
11434
+ if (!activeSessions.has(sessionId)) ids.push(sessionId);
11435
+ }
11436
+ return ids;
11437
+ }
9381
11438
  var defaultDeps = {
9382
11439
  detect: detectNativeCapabilities,
9383
11440
  startClaude: startClaudeNativeStream,
@@ -9502,8 +11559,10 @@ async function launchClaudeNative(options, sendOps) {
9502
11559
  command: `claude -p (native)`,
9503
11560
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9504
11561
  seq: 0,
11562
+ snapshot: createEmptySnapshot(sessionId),
9505
11563
  handle: null
9506
11564
  };
11565
+ nativeRecoverySnapshots.delete(sessionId);
9507
11566
  activeSessions.set(sessionId, session);
9508
11567
  const extraEnv = deps.buildExtraEnv();
9509
11568
  try {
@@ -9519,8 +11578,9 @@ async function launchClaudeNative(options, sendOps) {
9519
11578
  },
9520
11579
  {
9521
11580
  onOps: (ops) => {
9522
- session.seq += 1;
9523
- sendOps(sessionId, ops, session.seq);
11581
+ if (activeSessions.get(sessionId) === session) {
11582
+ publishNativeOps(session, ops, sendOps);
11583
+ }
9524
11584
  },
9525
11585
  onWarn: (msg) => {
9526
11586
  log14.warn("claude native warn", { sessionId, msg });
@@ -9539,16 +11599,16 @@ async function launchClaudeNative(options, sendOps) {
9539
11599
  log14.warn("claude native breaker opened; removing inventory entry", {
9540
11600
  sessionId
9541
11601
  });
9542
- if (activeSessions.has(sessionId)) {
9543
- session.seq += 1;
9544
- sendOps(
9545
- sessionId,
11602
+ if (activeSessions.get(sessionId) === session) {
11603
+ publishNativeOps(
11604
+ session,
9546
11605
  buildFallbackStatusOps(
9547
11606
  "Claude native stream stopped after repeated failures. Restart the session to retry.",
9548
11607
  "error"
9549
11608
  ),
9550
- session.seq
11609
+ sendOps
9551
11610
  );
11611
+ retainNativeRecoverySnapshot(session);
9552
11612
  activeSessions.delete(sessionId);
9553
11613
  }
9554
11614
  }
@@ -9556,6 +11616,7 @@ async function launchClaudeNative(options, sendOps) {
9556
11616
  );
9557
11617
  } catch (err) {
9558
11618
  activeSessions.delete(sessionId);
11619
+ nativeRecoverySnapshots.delete(sessionId);
9559
11620
  const detail = err instanceof Error ? err.message : String(err);
9560
11621
  log14.error("claude native launch threw", { sessionId, detail });
9561
11622
  return {
@@ -9640,8 +11701,10 @@ async function launchGeminiNative(options, sendOps) {
9640
11701
  command: `gemini -p (native)`,
9641
11702
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9642
11703
  seq: 0,
11704
+ snapshot: createEmptySnapshot(sessionId),
9643
11705
  handle: null
9644
11706
  };
11707
+ nativeRecoverySnapshots.delete(sessionId);
9645
11708
  activeSessions.set(sessionId, session);
9646
11709
  const extraEnv = deps.buildGeminiExtraEnv();
9647
11710
  try {
@@ -9656,8 +11719,9 @@ async function launchGeminiNative(options, sendOps) {
9656
11719
  },
9657
11720
  {
9658
11721
  onOps: (ops) => {
9659
- session.seq += 1;
9660
- sendOps(sessionId, ops, session.seq);
11722
+ if (activeSessions.get(sessionId) === session) {
11723
+ publishNativeOps(session, ops, sendOps);
11724
+ }
9661
11725
  },
9662
11726
  onWarn: (msg) => {
9663
11727
  log14.warn("gemini native warn", { sessionId, msg });
@@ -9672,16 +11736,16 @@ async function launchGeminiNative(options, sendOps) {
9672
11736
  log14.warn("gemini native breaker opened; removing inventory entry", {
9673
11737
  sessionId
9674
11738
  });
9675
- if (activeSessions.has(sessionId)) {
9676
- session.seq += 1;
9677
- sendOps(
9678
- sessionId,
11739
+ if (activeSessions.get(sessionId) === session) {
11740
+ publishNativeOps(
11741
+ session,
9679
11742
  buildFallbackStatusOps(
9680
11743
  "Gemini native stream stopped after repeated failures. Restart the session to retry.",
9681
11744
  "error"
9682
11745
  ),
9683
- session.seq
11746
+ sendOps
9684
11747
  );
11748
+ retainNativeRecoverySnapshot(session);
9685
11749
  activeSessions.delete(sessionId);
9686
11750
  }
9687
11751
  }
@@ -9689,6 +11753,7 @@ async function launchGeminiNative(options, sendOps) {
9689
11753
  );
9690
11754
  } catch (err) {
9691
11755
  activeSessions.delete(sessionId);
11756
+ nativeRecoverySnapshots.delete(sessionId);
9692
11757
  const detail = err instanceof Error ? err.message : String(err);
9693
11758
  log14.error("gemini native launch threw", { sessionId, detail });
9694
11759
  return {
@@ -9774,8 +11839,10 @@ async function launchCodexNative(options, sendOps) {
9774
11839
  command: transport === "app-server" ? `codex app-server (native)` : `codex exec (native)`,
9775
11840
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9776
11841
  seq: 0,
11842
+ snapshot: createEmptySnapshot(sessionId),
9777
11843
  handle: null
9778
11844
  };
11845
+ nativeRecoverySnapshots.delete(sessionId);
9779
11846
  activeSessions.set(sessionId, session);
9780
11847
  const extraEnv = transport === "app-server" ? buildCodexAppServerExtraEnv() : deps.buildCodexExtraEnv();
9781
11848
  const onBreakerOpenHandler = () => {
@@ -9783,16 +11850,16 @@ async function launchCodexNative(options, sendOps) {
9783
11850
  sessionId,
9784
11851
  transport
9785
11852
  });
9786
- if (activeSessions.has(sessionId)) {
9787
- session.seq += 1;
9788
- sendOps(
9789
- sessionId,
11853
+ if (activeSessions.get(sessionId) === session) {
11854
+ publishNativeOps(
11855
+ session,
9790
11856
  buildFallbackStatusOps(
9791
11857
  "Codex native stream stopped after repeated failures. Restart the session to retry.",
9792
11858
  "error"
9793
11859
  ),
9794
- session.seq
11860
+ sendOps
9795
11861
  );
11862
+ retainNativeRecoverySnapshot(session);
9796
11863
  activeSessions.delete(sessionId);
9797
11864
  }
9798
11865
  };
@@ -9812,8 +11879,9 @@ async function launchCodexNative(options, sendOps) {
9812
11879
  },
9813
11880
  {
9814
11881
  onOps: (ops) => {
9815
- session.seq += 1;
9816
- sendOps(sessionId, ops, session.seq);
11882
+ if (activeSessions.get(sessionId) === session) {
11883
+ publishNativeOps(session, ops, sendOps);
11884
+ }
9817
11885
  },
9818
11886
  onWarn: (msg) => {
9819
11887
  log14.warn("codex app-server warn", { sessionId, msg });
@@ -9840,8 +11908,9 @@ async function launchCodexNative(options, sendOps) {
9840
11908
  },
9841
11909
  {
9842
11910
  onOps: (ops) => {
9843
- session.seq += 1;
9844
- sendOps(sessionId, ops, session.seq);
11911
+ if (activeSessions.get(sessionId) === session) {
11912
+ publishNativeOps(session, ops, sendOps);
11913
+ }
9845
11914
  },
9846
11915
  onWarn: (msg) => {
9847
11916
  log14.warn("codex native warn", { sessionId, msg });
@@ -9858,6 +11927,7 @@ async function launchCodexNative(options, sendOps) {
9858
11927
  }
9859
11928
  } catch (err) {
9860
11929
  activeSessions.delete(sessionId);
11930
+ nativeRecoverySnapshots.delete(sessionId);
9861
11931
  const detail = err instanceof Error ? err.message : String(err);
9862
11932
  log14.error("codex native launch threw", { sessionId, detail });
9863
11933
  return {
@@ -9884,6 +11954,7 @@ async function launchCodexNative(options, sendOps) {
9884
11954
  } catch {
9885
11955
  }
9886
11956
  activeSessions.delete(sessionId);
11957
+ nativeRecoverySnapshots.delete(sessionId);
9887
11958
  return {
9888
11959
  ok: false,
9889
11960
  kind,
@@ -9910,7 +11981,8 @@ async function launchCodexNative(options, sendOps) {
9910
11981
  }
9911
11982
  function stopNativeSession(sessionId) {
9912
11983
  const session = activeSessions.get(sessionId);
9913
- if (!session) return false;
11984
+ const removedRecovery = nativeRecoverySnapshots.delete(sessionId);
11985
+ if (!session) return removedRecovery;
9914
11986
  try {
9915
11987
  session.handle?.stop();
9916
11988
  } catch (err) {
@@ -9926,6 +11998,7 @@ function stopNativeSession(sessionId) {
9926
11998
  function stopAllNativeSessions() {
9927
11999
  const ids = [...activeSessions.keys()];
9928
12000
  for (const id of ids) stopNativeSession(id);
12001
+ nativeRecoverySnapshots.clear();
9929
12002
  }
9930
12003
  function isNativeSession(sessionId) {
9931
12004
  for (const prefix of Object.values(KIND_TO_SESSION_PREFIX)) {
@@ -10746,6 +12819,7 @@ async function claimHost(params) {
10746
12819
  return data;
10747
12820
  }
10748
12821
  async function negotiateAgent(params) {
12822
+ const requestStartedAt = Date.now();
10749
12823
  const response = await fetch(`${params.portal}/api/agent/negotiate`, {
10750
12824
  method: "POST",
10751
12825
  headers: {
@@ -10754,6 +12828,7 @@ async function negotiateAgent(params) {
10754
12828
  },
10755
12829
  body: JSON.stringify({})
10756
12830
  });
12831
+ const responseReceivedAt = Date.now();
10757
12832
  const data = await response.json();
10758
12833
  if (response.status === 401 || response.status === 403) {
10759
12834
  throw new AuthError(
@@ -10766,6 +12841,11 @@ async function negotiateAgent(params) {
10766
12841
  if (!data.url || !data.groups?.privateGroup || !data.groups?.registryGroup) {
10767
12842
  throw new Error("Negotiate response missing websocket URL or group info");
10768
12843
  }
12844
+ delete data.serverClockOffsetMs;
12845
+ if (Number.isSafeInteger(data.serverTimeMs)) {
12846
+ const requestMidpoint = requestStartedAt + Math.round((responseReceivedAt - requestStartedAt) / 2);
12847
+ data.serverClockOffsetMs = data.serverTimeMs - requestMidpoint;
12848
+ }
10769
12849
  return data;
10770
12850
  }
10771
12851
  async function deregisterHost(params) {
@@ -11075,7 +13155,9 @@ var SessionStreamer = class {
11075
13155
  * never fires, and `resyncStream` deliberately does NOT restart pipe-pane —
11076
13156
  * so without this the pane is frozen until the stream is torn down by hand.
11077
13157
  *
11078
- * Batched into one `tmux list-panes -a` call so liveness costs one fork.
13158
+ * Costs one batched `tmux list-panes -a` fork for pids, plus one `capture-pane`
13159
+ * fork ONLY per currently-silent stream (flowing streams are provably alive and
13160
+ * skip the hash) — so ~one fork/tick on a healthy host.
11079
13161
  */
11080
13162
  checkPipeLiveness() {
11081
13163
  const tmuxStreams = [...this.active.values()].filter(
@@ -11101,22 +13183,29 @@ var SessionStreamer = class {
11101
13183
  continue;
11102
13184
  }
11103
13185
  const bytesNow = stream.bytesReceived ?? 0;
11104
- const paneMoved = info.fingerprint !== stream.lastLivenessFingerprint;
11105
13186
  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
- });
13187
+ if (!fifoSilent) {
13188
+ stream.deadPipeStrikes = 0;
13189
+ } else {
13190
+ const contentHash = this.paneContentHash(stream.paneTarget, stream.cleanEnv);
13191
+ if (contentHash !== null) {
13192
+ const paneMoved = stream.lastContentHash !== void 0 && contentHash !== stream.lastContentHash;
13193
+ if (paneMoved) {
13194
+ stream.deadPipeStrikes = (stream.deadPipeStrikes ?? 0) + 1;
13195
+ if (stream.deadPipeStrikes >= DEAD_PIPE_STRIKES && !backedOff) {
13196
+ dead.push({
13197
+ sessionId: stream.sessionId,
13198
+ reason: "pipe-pane silent while pane active",
13199
+ detail: { strikes: stream.deadPipeStrikes }
13200
+ });
13201
+ }
13202
+ } else {
13203
+ stream.deadPipeStrikes = 0;
13204
+ }
13205
+ stream.lastContentHash = contentHash;
11114
13206
  }
11115
- } else {
11116
- stream.deadPipeStrikes = 0;
11117
13207
  }
11118
13208
  stream.lastLivenessBytes = bytesNow;
11119
- stream.lastLivenessFingerprint = info.fingerprint;
11120
13209
  }
11121
13210
  if (dead.length === 0) return;
11122
13211
  const [first, ...rest] = dead;
@@ -11460,7 +13549,7 @@ var SessionStreamer = class {
11460
13549
  try {
11461
13550
  this.sendResetFn?.(sessionId, withScrollback ? "full" : "repaint", targetViewerId);
11462
13551
  const alternateScreen = this.detectAlternateScreen(paneTarget, cleanEnv);
11463
- if (withScrollback) {
13552
+ if (withScrollback && !alternateScreen) {
11464
13553
  send("\x1B[3J");
11465
13554
  try {
11466
13555
  const scrollback = (0, import_node_child_process2.execFileSync)(
@@ -11505,6 +13594,31 @@ var SessionStreamer = class {
11505
13594
  return false;
11506
13595
  }
11507
13596
  }
13597
+ /** Cheap stable string hash (djb2) for content change-detection. */
13598
+ hashString(s) {
13599
+ let h = 5381;
13600
+ for (let i = 0; i < s.length; i++) h = Math.imul(h, 33) + s.charCodeAt(i) | 0;
13601
+ return (h >>> 0).toString(36) + ":" + s.length;
13602
+ }
13603
+ /**
13604
+ * Hash of a pane's visible content (`capture-pane -p`). This is the watchdog's
13605
+ * activity signal: unlike cursor/history it changes on ANY visible redraw,
13606
+ * including in-place TUI updates (spinners, token counters, status bars) that
13607
+ * a Claude/Gemini pane emits without moving the cursor. Null on tmux failure
13608
+ * (the watchdog then skips the activity check for that tick).
13609
+ */
13610
+ paneContentHash(paneTarget, cleanEnv) {
13611
+ try {
13612
+ const out = (0, import_node_child_process2.execFileSync)(
13613
+ "tmux",
13614
+ ["capture-pane", "-t", paneTarget, "-p"],
13615
+ { env: cleanEnv ?? this.tmuxEnv(), timeout: 5e3, encoding: "utf-8" }
13616
+ );
13617
+ return this.hashString(out);
13618
+ } catch {
13619
+ return null;
13620
+ }
13621
+ }
11508
13622
  /** Build a tmux-safe env (TMUX/TMUX_PANE stripped) for liveness queries. */
11509
13623
  tmuxEnv() {
11510
13624
  const env = { ...process.env };
@@ -11562,9 +13676,9 @@ var SessionStreamer = class {
11562
13676
  * would shift the viewport by 1 row. Returns the number of lines sent
11563
13677
  * (0 when the capture failed or was empty) for relative cursor sync.
11564
13678
  */
11565
- sendVisiblePane(send, paneTarget, cleanEnv, alternateScreen) {
13679
+ sendVisiblePane(send, paneTarget, cleanEnv, _alternateScreen) {
11566
13680
  try {
11567
- const captureArgs = alternateScreen ? ["capture-pane", "-a", "-t", paneTarget, "-p", "-e"] : ["capture-pane", "-t", paneTarget, "-p", "-e"];
13681
+ const captureArgs = ["capture-pane", "-t", paneTarget, "-p", "-e"];
11568
13682
  const initial = (0, import_node_child_process2.execFileSync)(
11569
13683
  "tmux",
11570
13684
  captureArgs,
@@ -11658,10 +13772,8 @@ var SessionStreamer = class {
11658
13772
  return false;
11659
13773
  }
11660
13774
  const paneInfo = this.readPaneInfo(paneTarget, cleanEnv);
11661
- if (paneInfo) {
11662
- stream.panePid = paneInfo.panePid;
11663
- stream.lastLivenessFingerprint = paneInfo.fingerprint;
11664
- }
13775
+ if (paneInfo) stream.panePid = paneInfo.panePid;
13776
+ stream.lastContentHash = this.paneContentHash(paneTarget, cleanEnv) ?? void 0;
11665
13777
  stream.bytesReceived = 0;
11666
13778
  stream.lastLivenessBytes = 0;
11667
13779
  stream.deadPipeStrikes = 0;
@@ -11698,7 +13810,7 @@ var SessionStreamer = class {
11698
13810
  this.sendResetFn?.(sessionId, "full");
11699
13811
  const alternateScreen = this.detectAlternateScreen(paneTarget, cleanEnv);
11700
13812
  stream.alternateScreen = alternateScreen;
11701
- if (!opts?.skipScrollback) {
13813
+ if (!opts?.skipScrollback && !alternateScreen) {
11702
13814
  this.sendFn(sessionId, "\x1B[3J");
11703
13815
  try {
11704
13816
  const scrollback = (0, import_node_child_process2.execFileSync)(
@@ -11979,7 +14091,104 @@ var SessionStreamer = class {
11979
14091
  };
11980
14092
 
11981
14093
  // src/crypto-channel.ts
11982
- var import_node_crypto = require("crypto");
14094
+ var import_node_crypto2 = require("crypto");
14095
+
14096
+ // src/crypto-binding.ts
14097
+ var CRYPTO_BINDING_V2_VERSION = 2;
14098
+ var CRYPTO_BINDING_V2_DOMAIN = "ragent/aead-binding/v2";
14099
+ var CRYPTO_BINDING_V2_DIRECTION = "portal-to-agent";
14100
+ var CRYPTO_BINDING_V2_MAX_VIEWER_ID_BYTES = 128;
14101
+ var CRYPTO_BINDING_V2_MAX_SESSION_ID_BYTES = 4096;
14102
+ var UINT16_MAX = 65535;
14103
+ var UINT32_MAX = 4294967295;
14104
+ var encoder = new TextEncoder();
14105
+ function encodedField(value, name) {
14106
+ const bytes = encoder.encode(value);
14107
+ if (bytes.byteLength > UINT16_MAX) {
14108
+ throw new Error(`${name} exceeds the UInt16 length bound`);
14109
+ }
14110
+ return bytes;
14111
+ }
14112
+ function validateCryptoBindingV2(value) {
14113
+ if (!value || typeof value !== "object") {
14114
+ throw new Error("Crypto binding must be an object");
14115
+ }
14116
+ const binding = value;
14117
+ if (binding.bindingVersion !== CRYPTO_BINDING_V2_VERSION) {
14118
+ throw new Error("Unsupported crypto binding version");
14119
+ }
14120
+ if (binding.direction !== CRYPTO_BINDING_V2_DIRECTION) {
14121
+ throw new Error("Invalid crypto binding direction");
14122
+ }
14123
+ if (binding.type !== "input" && binding.type !== "control") {
14124
+ throw new Error("Invalid crypto binding message type");
14125
+ }
14126
+ if (binding.type === "input" && binding.action !== "" || binding.type === "control" && binding.action !== "upload-file") {
14127
+ throw new Error("Invalid crypto binding action for message type");
14128
+ }
14129
+ if (typeof binding.viewerId !== "string" || binding.viewerId.length === 0) {
14130
+ throw new Error("Crypto binding viewerId is required");
14131
+ }
14132
+ if (binding.viewerId.length > CRYPTO_BINDING_V2_MAX_VIEWER_ID_BYTES) {
14133
+ throw new Error(
14134
+ `Crypto binding viewerId exceeds ${CRYPTO_BINDING_V2_MAX_VIEWER_ID_BYTES} bytes`
14135
+ );
14136
+ }
14137
+ if (!/^[A-Za-z0-9:_-]+$/.test(binding.viewerId)) {
14138
+ throw new Error("Crypto binding viewerId contains unsupported characters");
14139
+ }
14140
+ if (typeof binding.sessionId !== "string" || binding.sessionId.length === 0) {
14141
+ throw new Error("Crypto binding sessionId is required");
14142
+ }
14143
+ if (binding.sessionId.length > CRYPTO_BINDING_V2_MAX_SESSION_ID_BYTES) {
14144
+ throw new Error(
14145
+ `Crypto binding sessionId exceeds ${CRYPTO_BINDING_V2_MAX_SESSION_ID_BYTES} bytes`
14146
+ );
14147
+ }
14148
+ if (encodedField(binding.viewerId, "viewerId").byteLength > CRYPTO_BINDING_V2_MAX_VIEWER_ID_BYTES) {
14149
+ throw new Error(
14150
+ `Crypto binding viewerId exceeds ${CRYPTO_BINDING_V2_MAX_VIEWER_ID_BYTES} bytes`
14151
+ );
14152
+ }
14153
+ if (encodedField(binding.sessionId, "sessionId").byteLength > CRYPTO_BINDING_V2_MAX_SESSION_ID_BYTES) {
14154
+ throw new Error(
14155
+ `Crypto binding sessionId exceeds ${CRYPTO_BINDING_V2_MAX_SESSION_ID_BYTES} bytes`
14156
+ );
14157
+ }
14158
+ if (!Number.isSafeInteger(binding.sentAtMs) || binding.sentAtMs < 0) {
14159
+ throw new Error("Crypto binding sentAtMs must be a non-negative safe integer");
14160
+ }
14161
+ if (!Number.isInteger(binding.seq) || binding.seq < 1 || binding.seq > UINT32_MAX) {
14162
+ throw new Error("Crypto binding seq must be a non-zero UInt32");
14163
+ }
14164
+ }
14165
+ function buildCryptoBindingV2(binding) {
14166
+ validateCryptoBindingV2(binding);
14167
+ const fields = [
14168
+ encodedField(CRYPTO_BINDING_V2_DOMAIN, "domain"),
14169
+ encodedField(binding.direction, "direction"),
14170
+ encodedField(binding.type, "type"),
14171
+ encodedField(binding.action, "action"),
14172
+ encodedField(binding.viewerId, "viewerId"),
14173
+ encodedField(binding.sessionId, "sessionId")
14174
+ ];
14175
+ const byteLength = fields.reduce((total, field) => total + 2 + field.byteLength, 0) + 8 + 4;
14176
+ const result = new Uint8Array(byteLength);
14177
+ const view = new DataView(result.buffer);
14178
+ let offset = 0;
14179
+ for (const field of fields) {
14180
+ view.setUint16(offset, field.byteLength, false);
14181
+ offset += 2;
14182
+ result.set(field, offset);
14183
+ offset += field.byteLength;
14184
+ }
14185
+ view.setBigUint64(offset, BigInt(binding.sentAtMs), false);
14186
+ offset += 8;
14187
+ view.setUint32(offset, binding.seq, false);
14188
+ return result;
14189
+ }
14190
+
14191
+ // src/crypto-channel.ts
11983
14192
  var log18 = createLogger("crypto-channel");
11984
14193
  var AAD_BYTES = 4;
11985
14194
  function deriveAesKey(sessionKey) {
@@ -11993,11 +14202,20 @@ function seqToAad(seq) {
11993
14202
  buf.writeUInt32BE(seq, 0);
11994
14203
  return buf;
11995
14204
  }
11996
- function encryptPayload(data, sessionKey, seq) {
14205
+ function payloadAad(seq, binding) {
14206
+ if (!binding) return seqToAad(seq);
14207
+ if (binding.seq !== seq) {
14208
+ throw new Error(
14209
+ `Crypto binding seq ${binding.seq} does not match payload seq ${seq}`
14210
+ );
14211
+ }
14212
+ return buildCryptoBindingV2(binding);
14213
+ }
14214
+ function encryptPayload(data, sessionKey, seq, binding) {
11997
14215
  const key = deriveAesKey(sessionKey);
11998
- const iv = (0, import_node_crypto.randomBytes)(12);
11999
- const cipher = (0, import_node_crypto.createCipheriv)("aes-256-gcm", key, iv);
12000
- cipher.setAAD(seqToAad(seq));
14216
+ const iv = (0, import_node_crypto2.randomBytes)(12);
14217
+ const cipher = (0, import_node_crypto2.createCipheriv)("aes-256-gcm", key, iv);
14218
+ cipher.setAAD(payloadAad(seq, binding));
12001
14219
  const encrypted = Buffer.concat([cipher.update(data, "utf-8"), cipher.final()]);
12002
14220
  const authTag = cipher.getAuthTag();
12003
14221
  const combined = Buffer.concat([encrypted, authTag]);
@@ -12007,7 +14225,7 @@ function encryptPayload(data, sessionKey, seq) {
12007
14225
  seq
12008
14226
  };
12009
14227
  }
12010
- function decryptPayload(enc, iv, sessionKey, seq) {
14228
+ function decryptPayload(enc, iv, sessionKey, seq, binding) {
12011
14229
  try {
12012
14230
  const key = deriveAesKey(sessionKey);
12013
14231
  const ivBuf = Buffer.from(iv, "base64");
@@ -12015,8 +14233,8 @@ function decryptPayload(enc, iv, sessionKey, seq) {
12015
14233
  if (combined.length < 16) return null;
12016
14234
  const ciphertext = combined.subarray(0, combined.length - 16);
12017
14235
  const authTag = combined.subarray(combined.length - 16);
12018
- const decipher = (0, import_node_crypto.createDecipheriv)("aes-256-gcm", key, ivBuf);
12019
- decipher.setAAD(seqToAad(seq));
14236
+ const decipher = (0, import_node_crypto2.createDecipheriv)("aes-256-gcm", key, ivBuf);
14237
+ decipher.setAAD(payloadAad(seq, binding));
12020
14238
  decipher.setAuthTag(authTag);
12021
14239
  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
12022
14240
  return decrypted.toString("utf-8");
@@ -12036,10 +14254,15 @@ var SequenceTracker = class {
12036
14254
  this.last += 1;
12037
14255
  return this.last;
12038
14256
  }
12039
- /** Receiver: accept if strictly greater than last-seen; else reject. */
12040
- accept(seq) {
14257
+ /** Receiver: validate without mutating replay state. */
14258
+ canAccept(seq) {
12041
14259
  if (!Number.isInteger(seq) || seq < 1 || seq > 4294967295) return false;
12042
14260
  if (seq <= this.last) return false;
14261
+ return true;
14262
+ }
14263
+ /** Receiver: commit an already-authenticated sequence number. */
14264
+ accept(seq) {
14265
+ if (!this.canAccept(seq)) return false;
12043
14266
  this.last = seq;
12044
14267
  return true;
12045
14268
  }
@@ -12367,22 +14590,89 @@ var ShellManager = class {
12367
14590
  var os7 = __toESM(require("os"));
12368
14591
  var import_child_process5 = require("child_process");
12369
14592
  var import_ws2 = __toESM(require("ws"));
14593
+ var INVENTORY_CACHE_TTL_MS = 1e3;
12370
14594
  var InventoryManager = class {
12371
14595
  lastSentFingerprint = "";
12372
14596
  lastHttpHeartbeatAt = 0;
12373
14597
  prevCpuSnapshot = null;
14598
+ cachedSessions = null;
14599
+ cachedSessionsAt = 0;
14600
+ cachedScope = "";
14601
+ inventoryRefresh = null;
14602
+ confirmedMisses = /* @__PURE__ */ new Map();
12374
14603
  options;
12375
14604
  constructor(options) {
12376
14605
  this.options = options;
12377
14606
  }
12378
14607
  /** Update options (e.g., after token refresh). */
12379
14608
  updateOptions(options) {
14609
+ const nextScope = `${options.hostId}\0${options.command}`;
14610
+ if (nextScope !== this.inventoryScope()) {
14611
+ this.cachedSessions = null;
14612
+ this.cachedSessionsAt = 0;
14613
+ this.cachedScope = "";
14614
+ this.confirmedMisses.clear();
14615
+ }
12380
14616
  this.options = options;
12381
14617
  }
14618
+ inventoryScope() {
14619
+ return `${this.options.hostId}\0${this.options.command}`;
14620
+ }
14621
+ cacheIsFresh(now2 = Date.now()) {
14622
+ return this.cachedSessions !== null && this.cachedScope === this.inventoryScope() && now2 - this.cachedSessionsAt < INVENTORY_CACHE_TTL_MS;
14623
+ }
14624
+ rememberInventory(sessions, scope = this.inventoryScope()) {
14625
+ this.cachedSessions = sessions;
14626
+ this.cachedSessionsAt = Date.now();
14627
+ this.cachedScope = scope;
14628
+ this.confirmedMisses.clear();
14629
+ return sessions;
14630
+ }
14631
+ /** Collect inventory at most once concurrently. Callers awaiting the same
14632
+ * refresh share both the process scan and its result. */
14633
+ refreshInventory() {
14634
+ const scope = this.inventoryScope();
14635
+ if (this.inventoryRefresh?.scope === scope) return this.inventoryRefresh.promise;
14636
+ const hostId = this.options.hostId;
14637
+ const command = this.options.command;
14638
+ const refresh = collectSessionInventory(hostId, command).then((sessions) => this.inventoryScope() === scope ? this.rememberInventory(sessions, scope) : sessions).finally(() => {
14639
+ if (this.inventoryRefresh?.promise === refresh) this.inventoryRefresh = null;
14640
+ });
14641
+ this.inventoryRefresh = { scope, promise: refresh };
14642
+ return refresh;
14643
+ }
14644
+ /** Return the recent inventory, refreshing it when stale or explicitly
14645
+ * requested. Primarily public so control paths can share the scan cache. */
14646
+ async getSessionInventory(forceRefresh = false) {
14647
+ if (!forceRefresh && this.cacheIsFresh()) return this.cachedSessions;
14648
+ return this.refreshInventory();
14649
+ }
14650
+ /** Find a session using the recent inventory. A fresh cache miss gets one
14651
+ * refresh so a just-created pane is not hidden until TTL expiry. Confirmed
14652
+ * misses are negatively cached for the same short TTL. */
14653
+ async getSessionById(sessionId) {
14654
+ const now2 = Date.now();
14655
+ if (this.cacheIsFresh(now2)) {
14656
+ const cached = this.cachedSessions.find((session) => session.id === sessionId);
14657
+ if (cached) return cached;
14658
+ const missAt = this.confirmedMisses.get(sessionId);
14659
+ if (missAt !== void 0 && now2 - missAt < INVENTORY_CACHE_TTL_MS) {
14660
+ return void 0;
14661
+ }
14662
+ const refreshed2 = await this.refreshInventory();
14663
+ const found2 = refreshed2.find((session) => session.id === sessionId);
14664
+ if (!found2) this.confirmedMisses.set(sessionId, Date.now());
14665
+ return found2;
14666
+ }
14667
+ const refreshed = await this.refreshInventory();
14668
+ const found = refreshed.find((session) => session.id === sessionId);
14669
+ if (!found) this.confirmedMisses.set(sessionId, Date.now());
14670
+ return found;
14671
+ }
12382
14672
  /** Announce to the registry group via WebSocket. */
12383
14673
  async announceToRegistry(ws, registryGroup, type = "heartbeat", sessions) {
12384
14674
  if (ws.readyState !== import_ws2.default.OPEN || !registryGroup) return;
12385
- const finalSessions = sessions || await collectSessionInventory(this.options.hostId, this.options.command);
14675
+ const finalSessions = sessions ? this.rememberInventory(sessions) : await this.getSessionInventory();
12386
14676
  const vitals = this.collectVitals();
12387
14677
  sendToGroup(ws, registryGroup, {
12388
14678
  type,
@@ -12400,7 +14690,7 @@ var InventoryManager = class {
12400
14690
  * and HTTP heartbeat as needed.
12401
14691
  */
12402
14692
  async syncInventory(ws, groups, force = false) {
12403
- const sessions = await collectSessionInventory(this.options.hostId, this.options.command);
14693
+ const sessions = await this.getSessionInventory(force);
12404
14694
  const fingerprint = sessionInventoryFingerprint(sessions);
12405
14695
  const changed = fingerprint !== this.lastSentFingerprint;
12406
14696
  const checkpointDue = Date.now() - this.lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
@@ -12461,14 +14751,19 @@ var InventoryManager = class {
12461
14751
 
12462
14752
  // src/connection-manager.ts
12463
14753
  var import_ws3 = __toESM(require("ws"));
12464
- var import_node_crypto2 = require("crypto");
14754
+ var import_node_crypto3 = require("crypto");
12465
14755
  var log21 = createLogger("connection");
12466
14756
  var OPPORTUNISTIC_INVENTORY_SYNC_MS = 1200;
12467
14757
  var MIN_INVENTORY_SYNC_GAP_MS = 1500;
14758
+ var MAX_RX_VIEWERS = 256;
14759
+ var MAX_VIEWER_ID_BYTES = 128;
14760
+ var MAX_BINDING_AGE_MS = 10 * 60 * 1e3;
14761
+ var MAX_BINDING_FUTURE_MS = 2 * 60 * 1e3;
12468
14762
  var ConnectionManager = class {
12469
14763
  activeSocket = null;
12470
14764
  activeGroups = { privateGroup: "", registryGroup: "" };
12471
14765
  sessionKey = null;
14766
+ serverClockOffsetMs = 0;
12472
14767
  reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
12473
14768
  /** Outbound frame counter; reset on every new handshake. See #310. */
12474
14769
  txSeq = new SequenceTracker();
@@ -12481,7 +14776,7 @@ var ConnectionManager = class {
12481
14776
  * trackers when this epoch changes, recovering the stream without a manual
12482
14777
  * refresh. A new value per ConnectionManager == per CLI process. See #543.
12483
14778
  */
12484
- streamEpoch = (0, import_node_crypto2.randomUUID)();
14779
+ streamEpoch = (0, import_node_crypto3.randomUUID)();
12485
14780
  /**
12486
14781
  * Inbound frame trackers, keyed by viewerId. The portal's Terminal.tsx
12487
14782
  * initializes a fresh txSeq (starting at 1) on every component mount, so a
@@ -12497,10 +14792,19 @@ var ConnectionManager = class {
12497
14792
  * the field.
12498
14793
  */
12499
14794
  getRxSeqForViewer(viewerId) {
12500
- const isValid = typeof viewerId === "string" && viewerId.length > 0;
12501
- const key = isValid ? viewerId : "_legacy";
14795
+ const normalized = this.normalizeViewerId(viewerId);
14796
+ if (normalized === null) return null;
14797
+ const isValid = normalized !== "_legacy";
14798
+ const key = normalized;
12502
14799
  let tracker = this.rxSeqByViewer.get(key);
12503
- if (!tracker) {
14800
+ if (tracker) {
14801
+ this.rxSeqByViewer.delete(key);
14802
+ this.rxSeqByViewer.set(key, tracker);
14803
+ } else {
14804
+ if (this.rxSeqByViewer.size >= MAX_RX_VIEWERS) {
14805
+ const oldest = this.rxSeqByViewer.keys().next().value;
14806
+ if (oldest !== void 0) this.rxSeqByViewer.delete(oldest);
14807
+ }
12504
14808
  tracker = new SequenceTracker();
12505
14809
  this.rxSeqByViewer.set(key, tracker);
12506
14810
  if (!isValid) {
@@ -12509,6 +14813,67 @@ var ConnectionManager = class {
12509
14813
  }
12510
14814
  return tracker;
12511
14815
  }
14816
+ /** Authenticate ciphertext before allocating or advancing replay state. */
14817
+ authenticateInboundFrame(enc, iv, seq, viewerId, bindingContext) {
14818
+ if (!this.sessionKey) return { ok: false, reason: "missing_key" };
14819
+ if (!Number.isInteger(seq) || seq < 1 || seq > 4294967295) {
14820
+ return { ok: false, reason: "invalid_seq" };
14821
+ }
14822
+ let binding;
14823
+ let authenticatedViewerKey;
14824
+ const frameBindingVersion = bindingContext?.bindingVersion;
14825
+ if (frameBindingVersion === CRYPTO_BINDING_V2_VERSION) {
14826
+ if (!bindingContext) return { ok: false, reason: "invalid_binding" };
14827
+ if (typeof viewerId !== "string" || typeof bindingContext.sessionId !== "string") {
14828
+ return { ok: false, reason: "invalid_binding" };
14829
+ }
14830
+ binding = {
14831
+ bindingVersion: CRYPTO_BINDING_V2_VERSION,
14832
+ direction: CRYPTO_BINDING_V2_DIRECTION,
14833
+ type: bindingContext.type,
14834
+ action: bindingContext.action,
14835
+ viewerId,
14836
+ sessionId: bindingContext.sessionId,
14837
+ sentAtMs: bindingContext.sentAtMs,
14838
+ seq
14839
+ };
14840
+ try {
14841
+ validateCryptoBindingV2(binding);
14842
+ } catch {
14843
+ return { ok: false, reason: "invalid_binding" };
14844
+ }
14845
+ authenticatedViewerKey = binding.viewerId;
14846
+ const trustedNow = Date.now() + this.serverClockOffsetMs;
14847
+ if (binding.sentAtMs < trustedNow - MAX_BINDING_AGE_MS || binding.sentAtMs > trustedNow + MAX_BINDING_FUTURE_MS) {
14848
+ return { ok: false, reason: "stale_binding" };
14849
+ }
14850
+ } else if (frameBindingVersion !== void 0 && frameBindingVersion !== 1) {
14851
+ return { ok: false, reason: "invalid_binding" };
14852
+ }
14853
+ const plaintext = decryptPayload(enc, iv, this.sessionKey, seq, binding);
14854
+ if (plaintext === null) return { ok: false, reason: "decrypt_failed" };
14855
+ const tracker = this.getRxSeqForViewer(authenticatedViewerKey ?? viewerId);
14856
+ if (!tracker) return { ok: false, reason: "invalid_viewer" };
14857
+ const viewerKey = authenticatedViewerKey ?? this.normalizeViewerId(viewerId);
14858
+ if (!tracker.accept(seq)) {
14859
+ return {
14860
+ ok: false,
14861
+ reason: "replayed",
14862
+ viewerKey,
14863
+ lastSeen: tracker.lastSeq
14864
+ };
14865
+ }
14866
+ return { ok: true, plaintext, viewerKey };
14867
+ }
14868
+ normalizeViewerId(viewerId) {
14869
+ if (viewerId === void 0 || viewerId === null) return "_legacy";
14870
+ if (typeof viewerId !== "string") return "_legacy";
14871
+ if (viewerId.length > MAX_VIEWER_ID_BYTES) return null;
14872
+ const normalized = viewerId.trim();
14873
+ if (normalized.length === 0) return "_legacy";
14874
+ if (Buffer.byteLength(normalized, "utf8") > MAX_VIEWER_ID_BYTES) return null;
14875
+ return normalized;
14876
+ }
12512
14877
  wsHeartbeatTimer = null;
12513
14878
  inventorySyncTimer = null;
12514
14879
  inventorySyncKickTimer = null;
@@ -12530,7 +14895,7 @@ var ConnectionManager = class {
12530
14895
  return this.activeSocket !== null && this.activeSocket.readyState === import_ws3.default.OPEN;
12531
14896
  }
12532
14897
  /** Set the active socket and groups from a negotiate result. */
12533
- setConnection(ws, groups, sessionKey) {
14898
+ setConnection(ws, groups, sessionKey, crypto4) {
12534
14899
  this.activeSocket = ws;
12535
14900
  this.activeGroups = groups;
12536
14901
  if (sessionKey !== this.sessionKey) {
@@ -12538,6 +14903,7 @@ var ConnectionManager = class {
12538
14903
  this.rxSeqByViewer.clear();
12539
14904
  }
12540
14905
  this.sessionKey = sessionKey;
14906
+ this.serverClockOffsetMs = Number.isFinite(crypto4?.serverClockOffsetMs) ? crypto4.serverClockOffsetMs : 0;
12541
14907
  }
12542
14908
  /** Reset reconnect delay on successful connection. */
12543
14909
  resetReconnectDelay() {
@@ -13360,6 +15726,7 @@ async function installLatestCliFromNpm() {
13360
15726
  var log23 = createLogger("control");
13361
15727
  var TMUX_RESYNC_DEBOUNCE_MS = 150;
13362
15728
  var TMUX_INPUT_COALESCE_MS = 4;
15729
+ var MAX_MARKDOWN_GENERATION_ENTRIES = 2048;
13363
15730
  function collectProcessTreePids(rootPid, visited = /* @__PURE__ */ new Set()) {
13364
15731
  if (visited.has(rootPid)) return [];
13365
15732
  visited.add(rootPid);
@@ -13403,15 +15770,22 @@ var ControlDispatcher = class {
13403
15770
  _approvalEnforcer = null;
13404
15771
  tmuxResizeState = /* @__PURE__ */ new Map();
13405
15772
  tmuxInputBuffers = /* @__PURE__ */ new Map();
15773
+ markdownGlobalGeneration = 0;
15774
+ /** Globally unique within this dispatcher, preventing ABA when a bounded
15775
+ * generation-map entry is evicted and the same viewer later reconnects. */
15776
+ markdownGenerationSequence = 0;
15777
+ markdownSessionGenerations = /* @__PURE__ */ new Map();
15778
+ markdownViewerGenerations = /* @__PURE__ */ new Map();
13406
15779
  /** Send V2 delta ops produced by an rAgent-launched native-stream
13407
15780
  * consumer. Same shape as TranscriptWatcherManager's `sendOpsFn` so both
13408
15781
  * sources funnel through the same WebSocket helper in `agent.ts`. */
13409
15782
  sendNativeOps = null;
15783
+ sendNativeSnapshot = null;
13410
15784
  /** Set to true when a reconnect was requested (restart-agent, disconnect). */
13411
15785
  reconnectRequested = false;
13412
15786
  /** Set to false to stop the agent. */
13413
15787
  shouldRun = true;
13414
- constructor(shell, streamer, inventory, connection, options, transcriptWatcher, sendNativeOps) {
15788
+ constructor(shell, streamer, inventory, connection, options, transcriptWatcher, sendNativeOps, sendNativeSnapshot) {
13415
15789
  this.shell = shell;
13416
15790
  this.streamer = streamer;
13417
15791
  this.inventory = inventory;
@@ -13419,6 +15793,7 @@ var ControlDispatcher = class {
13419
15793
  this.options = options;
13420
15794
  this.transcriptWatcher = transcriptWatcher ?? null;
13421
15795
  this.sendNativeOps = sendNativeOps ?? null;
15796
+ this.sendNativeSnapshot = sendNativeSnapshot ?? null;
13422
15797
  }
13423
15798
  /** Install or replace the native-stream ops callback. Used by the agent
13424
15799
  * runner so native ops route through the same encryption/transport path
@@ -13541,6 +15916,7 @@ var ControlDispatcher = class {
13541
15916
  return;
13542
15917
  case "restart-agent":
13543
15918
  case "disconnect":
15919
+ this.invalidateAllMarkdownIntents();
13544
15920
  this.reconnectRequested = true;
13545
15921
  this.streamer.stopAll();
13546
15922
  if (this.connection.activeSocket?.readyState === import_ws4.default.OPEN) {
@@ -13551,6 +15927,7 @@ var ControlDispatcher = class {
13551
15927
  await this.updateAgentFromNpm();
13552
15928
  return;
13553
15929
  case "stop-agent":
15930
+ this.invalidateAllMarkdownIntents();
13554
15931
  this.shouldRun = false;
13555
15932
  this.streamer.stopAll();
13556
15933
  requestStopSelfService();
@@ -13560,6 +15937,7 @@ var ControlDispatcher = class {
13560
15937
  return;
13561
15938
  case "stop-session":
13562
15939
  if (!sessionId) return;
15940
+ this.invalidateMarkdownIntent(sessionId);
13563
15941
  if (sessionId.startsWith("pty:")) {
13564
15942
  this.shell.restart();
13565
15943
  await this.syncInventory();
@@ -13587,6 +15965,7 @@ var ControlDispatcher = class {
13587
15965
  return;
13588
15966
  case "kill-process":
13589
15967
  if (!sessionId) return;
15968
+ this.invalidateMarkdownIntent(sessionId);
13590
15969
  {
13591
15970
  const pid = parseProcessPid2(sessionId);
13592
15971
  if (pid === null) {
@@ -13633,9 +16012,10 @@ var ControlDispatcher = class {
13633
16012
  return;
13634
16013
  case "stop-detached": {
13635
16014
  if (this.transcriptWatcher) {
13636
- const sessions = await collectSessionInventory(this.options.hostId, this.options.command);
16015
+ const sessions = await this.inventory.getSessionInventory(true);
13637
16016
  for (const session of sessions) {
13638
16017
  if (session.type === "tmux" && session.status === "detached") {
16018
+ this.invalidateMarkdownIntent(session.id);
13639
16019
  this.transcriptWatcher.disableMarkdown(session.id);
13640
16020
  }
13641
16021
  }
@@ -13657,17 +16037,35 @@ var ControlDispatcher = class {
13657
16037
  return;
13658
16038
  case "stop-stream":
13659
16039
  if (sessionId) {
16040
+ this.invalidateMarkdownIntent(sessionId, viewerId ?? void 0);
13660
16041
  this.streamer.stopStream(sessionId, viewerId ?? void 0);
13661
- this.transcriptWatcher?.disableMarkdown(sessionId);
16042
+ if (viewerId) this.transcriptWatcher?.disableMarkdown(sessionId, viewerId);
16043
+ else this.transcriptWatcher?.disableMarkdown(sessionId);
13662
16044
  }
13663
16045
  return;
13664
16046
  case "prefer-markdown":
13665
- this.handlePreferMarkdown(sessionId, payload);
16047
+ await this.handlePreferMarkdown(sessionId, payload);
13666
16048
  return;
13667
16049
  case "sync-markdown":
16050
+ if (sessionId && isNativeSession(sessionId)) {
16051
+ if (this.sendNativeSnapshot) {
16052
+ syncNativeTranscriptSnapshot(sessionId, this.sendNativeSnapshot);
16053
+ }
16054
+ return;
16055
+ }
13668
16056
  if (sessionId && this.transcriptWatcher) {
13669
16057
  const fromSeq = typeof payload.fromSeq === "number" ? payload.fromSeq : void 0;
13670
- this.transcriptWatcher.handleSyncRequest(sessionId, fromSeq);
16058
+ const schemaVersion = payload.schemaVersion === 1 || payload.schemaVersion === 2 ? payload.schemaVersion : void 0;
16059
+ if (schemaVersion !== void 0 || viewerId) {
16060
+ this.transcriptWatcher.handleSyncRequest(
16061
+ sessionId,
16062
+ fromSeq,
16063
+ schemaVersion,
16064
+ viewerId ?? void 0
16065
+ );
16066
+ } else {
16067
+ this.transcriptWatcher.handleSyncRequest(sessionId, fromSeq);
16068
+ }
13671
16069
  }
13672
16070
  return;
13673
16071
  case "upload-file":
@@ -13822,11 +16220,25 @@ var ControlDispatcher = class {
13822
16220
  });
13823
16221
  }
13824
16222
  }
13825
- handlePreferMarkdown(sessionId, payload) {
16223
+ async handlePreferMarkdown(sessionId, payload) {
13826
16224
  if (!sessionId || !this.transcriptWatcher) return;
13827
16225
  const enabled = payload.enabled === true;
16226
+ const viewerId = typeof payload.viewerId === "string" && payload.viewerId.trim() ? payload.viewerId.trim() : void 0;
16227
+ const schemaVersion = payload.schemaVersion === 1 || payload.schemaVersion === 2 ? payload.schemaVersion : void 0;
16228
+ if (!enabled) {
16229
+ this.invalidateMarkdownIntent(sessionId, viewerId);
16230
+ if (!isNativeSession(sessionId)) {
16231
+ if (viewerId) this.transcriptWatcher.disableMarkdown(sessionId, viewerId);
16232
+ else this.transcriptWatcher.disableMarkdown(sessionId);
16233
+ }
16234
+ return;
16235
+ }
16236
+ const intent = this.beginMarkdownIntent(sessionId, viewerId);
13828
16237
  if (isNativeSession(sessionId)) {
13829
16238
  if (enabled) {
16239
+ if (this.sendNativeSnapshot) {
16240
+ syncNativeTranscriptSnapshot(sessionId, this.sendNativeSnapshot);
16241
+ }
13830
16242
  this.sendMarkdownStatus({
13831
16243
  type: "markdown",
13832
16244
  subtype: "status",
@@ -13837,8 +16249,33 @@ var ControlDispatcher = class {
13837
16249
  return;
13838
16250
  }
13839
16251
  if (enabled) {
13840
- const agentType = typeof payload.agentType === "string" ? payload.agentType : void 0;
13841
- const result = this.transcriptWatcher.enableMarkdown(sessionId, agentType);
16252
+ const requestedAgentType = typeof payload.agentType === "string" ? payload.agentType : void 0;
16253
+ let agentType = requestedAgentType;
16254
+ try {
16255
+ const liveAgentType = (await this.inventory.getSessionById(sessionId))?.agentType;
16256
+ if (liveAgentType) {
16257
+ agentType = liveAgentType;
16258
+ if (requestedAgentType && requestedAgentType !== liveAgentType) {
16259
+ log23.info("corrected stale transcript agent type from live inventory", {
16260
+ sessionId,
16261
+ requestedAgentType,
16262
+ liveAgentType
16263
+ });
16264
+ }
16265
+ }
16266
+ } catch (error) {
16267
+ log23.warn("could not resolve live transcript agent type", {
16268
+ sessionId,
16269
+ error: error instanceof Error ? error.message : String(error)
16270
+ });
16271
+ }
16272
+ if (!this.isCurrentMarkdownIntent(intent)) return;
16273
+ const result = schemaVersion !== void 0 || viewerId ? this.transcriptWatcher.enableMarkdown(
16274
+ sessionId,
16275
+ agentType,
16276
+ viewerId,
16277
+ schemaVersion
16278
+ ) : this.transcriptWatcher.enableMarkdown(sessionId, agentType);
13842
16279
  if (!result.ok) {
13843
16280
  this.sendMarkdownStatus({
13844
16281
  type: "markdown",
@@ -13856,10 +16293,77 @@ var ControlDispatcher = class {
13856
16293
  status: "enabled"
13857
16294
  });
13858
16295
  }
13859
- } else {
13860
- this.transcriptWatcher.disableMarkdown(sessionId);
13861
16296
  }
13862
16297
  }
16298
+ markdownViewerKey(sessionId, viewerId) {
16299
+ return `${sessionId}\0${viewerId || "__legacy_anonymous__"}`;
16300
+ }
16301
+ beginMarkdownIntent(sessionId, viewerId) {
16302
+ const viewerKey = this.markdownViewerKey(sessionId, viewerId);
16303
+ let sessionGeneration = this.markdownSessionGenerations.get(sessionId);
16304
+ if (sessionGeneration === void 0) {
16305
+ sessionGeneration = this.nextMarkdownGeneration();
16306
+ }
16307
+ this.setBoundedMarkdownGeneration(
16308
+ this.markdownSessionGenerations,
16309
+ sessionId,
16310
+ sessionGeneration
16311
+ );
16312
+ const viewerGeneration = this.nextMarkdownGeneration();
16313
+ this.setBoundedMarkdownGeneration(
16314
+ this.markdownViewerGenerations,
16315
+ viewerKey,
16316
+ viewerGeneration
16317
+ );
16318
+ return {
16319
+ globalGeneration: this.markdownGlobalGeneration,
16320
+ sessionGeneration,
16321
+ viewerGeneration,
16322
+ sessionId,
16323
+ viewerKey
16324
+ };
16325
+ }
16326
+ invalidateMarkdownIntent(sessionId, viewerId) {
16327
+ if (viewerId) {
16328
+ const viewerKey = this.markdownViewerKey(sessionId, viewerId);
16329
+ this.setBoundedMarkdownGeneration(
16330
+ this.markdownViewerGenerations,
16331
+ viewerKey,
16332
+ this.nextMarkdownGeneration()
16333
+ );
16334
+ return;
16335
+ }
16336
+ this.setBoundedMarkdownGeneration(
16337
+ this.markdownSessionGenerations,
16338
+ sessionId,
16339
+ this.nextMarkdownGeneration()
16340
+ );
16341
+ const viewerPrefix = `${sessionId}\0`;
16342
+ for (const key of this.markdownViewerGenerations.keys()) {
16343
+ if (key.startsWith(viewerPrefix)) this.markdownViewerGenerations.delete(key);
16344
+ }
16345
+ }
16346
+ nextMarkdownGeneration() {
16347
+ this.markdownGenerationSequence++;
16348
+ return this.markdownGenerationSequence;
16349
+ }
16350
+ setBoundedMarkdownGeneration(generations, key, generation) {
16351
+ generations.delete(key);
16352
+ generations.set(key, generation);
16353
+ while (generations.size > MAX_MARKDOWN_GENERATION_ENTRIES) {
16354
+ const oldest = generations.keys().next();
16355
+ if (oldest.done) break;
16356
+ generations.delete(oldest.value);
16357
+ }
16358
+ }
16359
+ invalidateAllMarkdownIntents() {
16360
+ this.markdownGlobalGeneration++;
16361
+ this.markdownSessionGenerations.clear();
16362
+ this.markdownViewerGenerations.clear();
16363
+ }
16364
+ isCurrentMarkdownIntent(intent) {
16365
+ return intent.globalGeneration === this.markdownGlobalGeneration && intent.sessionGeneration === (this.markdownSessionGenerations.get(intent.sessionId) ?? 0) && intent.viewerGeneration === (this.markdownViewerGenerations.get(intent.viewerKey) ?? 0);
16366
+ }
13863
16367
  sendMarkdownStatus(payload) {
13864
16368
  const ws = this.connection.activeSocket;
13865
16369
  const group = this.connection.activeGroups.privateGroup;
@@ -13868,6 +16372,7 @@ var ControlDispatcher = class {
13868
16372
  }
13869
16373
  /** Stop all transcript watchers (called on disconnect/cleanup). */
13870
16374
  stopTranscriptWatchers() {
16375
+ this.invalidateAllMarkdownIntents();
13871
16376
  this.transcriptWatcher?.stopAll();
13872
16377
  }
13873
16378
  async syncInventory(force = false) {
@@ -14167,7 +16672,7 @@ function validateProvisionRequest(payload) {
14167
16672
  }
14168
16673
 
14169
16674
  // src/approval-policy.ts
14170
- var import_node_crypto3 = require("crypto");
16675
+ var import_node_crypto4 = require("crypto");
14171
16676
  function hasToken(cmd, token) {
14172
16677
  const lower = cmd.toLowerCase();
14173
16678
  const t = token.toLowerCase();
@@ -14274,7 +16779,7 @@ function matchPolicy(command, _policy = DEFAULT_APPROVAL_POLICY, hostId = "", se
14274
16779
  for (const matcher of MATCHERS) {
14275
16780
  if (matcher.test(command)) {
14276
16781
  return {
14277
- requestId: (0, import_node_crypto3.randomUUID)(),
16782
+ requestId: (0, import_node_crypto4.randomUUID)(),
14278
16783
  hostId,
14279
16784
  sessionId,
14280
16785
  action: matcher.action,
@@ -14577,6 +17082,65 @@ var ApprovalEnforcer = class {
14577
17082
  }
14578
17083
  };
14579
17084
 
17085
+ // src/native-transcript-recovery.ts
17086
+ var NATIVE_TRANSCRIPT_RECOVERY_DRAIN_MS = 300;
17087
+ var NativeTranscriptRecoveryDrain = class {
17088
+ constructor(options) {
17089
+ this.options = options;
17090
+ }
17091
+ options;
17092
+ pending = /* @__PURE__ */ new Set();
17093
+ timer = null;
17094
+ stopped = false;
17095
+ /** Merge the current authoritative session IDs into the pending work set. */
17096
+ request() {
17097
+ if (this.stopped) return;
17098
+ for (const sessionId of this.options.listSessionIds()) {
17099
+ if (sessionId.length > 0) this.pending.add(sessionId);
17100
+ }
17101
+ this.schedule();
17102
+ }
17103
+ /** Permanently stop the drain and release all queued recovery work. */
17104
+ cancel() {
17105
+ this.stopped = true;
17106
+ this.pending.clear();
17107
+ if (this.timer) {
17108
+ clearTimeout(this.timer);
17109
+ this.timer = null;
17110
+ }
17111
+ }
17112
+ schedule() {
17113
+ if (this.stopped || this.timer || this.pending.size === 0) return;
17114
+ this.timer = setTimeout(
17115
+ () => {
17116
+ this.timer = null;
17117
+ this.drainOne();
17118
+ },
17119
+ this.options.intervalMs ?? NATIVE_TRANSCRIPT_RECOVERY_DRAIN_MS
17120
+ );
17121
+ if (typeof this.timer.unref === "function") this.timer.unref();
17122
+ }
17123
+ drainOne() {
17124
+ if (this.stopped || this.pending.size === 0) return;
17125
+ if (this.options.canSend?.() === false || this.options.isQueueHigh()) {
17126
+ this.schedule();
17127
+ return;
17128
+ }
17129
+ const next = this.pending.values().next();
17130
+ if (next.done) return;
17131
+ const sessionId = next.value;
17132
+ try {
17133
+ this.options.syncSession(sessionId);
17134
+ this.pending.delete(sessionId);
17135
+ } catch (error) {
17136
+ this.pending.delete(sessionId);
17137
+ this.pending.add(sessionId);
17138
+ this.options.onError?.(sessionId, error);
17139
+ }
17140
+ this.schedule();
17141
+ }
17142
+ };
17143
+
14580
17144
  // src/agent.ts
14581
17145
  var log25 = createLogger("agent");
14582
17146
  var MAX_TRANSPORT_CHUNK_BYTES = 24 * 1024;
@@ -14654,6 +17218,26 @@ function splitTransportChunks(data, maxBytes = MAX_TRANSPORT_CHUNK_BYTES) {
14654
17218
  }
14655
17219
  return chunks;
14656
17220
  }
17221
+ function parseAuthenticatedUploadControlPayload(plaintext, outer) {
17222
+ let parsed;
17223
+ try {
17224
+ parsed = JSON.parse(plaintext);
17225
+ } catch {
17226
+ return null;
17227
+ }
17228
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
17229
+ const inner = parsed;
17230
+ if (outer.action !== "upload-file" || inner.action !== outer.action || inner.sessionId !== outer.sessionId || inner.viewerId !== outer.viewerId) {
17231
+ return null;
17232
+ }
17233
+ return {
17234
+ ...inner,
17235
+ action: outer.action,
17236
+ sessionId: outer.sessionId,
17237
+ viewerId: outer.viewerId,
17238
+ _verifiedEncrypted: true
17239
+ };
17240
+ }
14657
17241
  async function runAgent(rawOptions) {
14658
17242
  const options = resolveRunOptions(rawOptions);
14659
17243
  if (!options.agentToken) {
@@ -14677,6 +17261,7 @@ async function runAgent(rawOptions) {
14677
17261
  }
14678
17262
  const outputBuffer = new OutputBuffer();
14679
17263
  const ptySessionId = `pty:${options.hostId}`;
17264
+ let nativeTranscriptRecovery = null;
14680
17265
  const sessionStreamer = new SessionStreamer(
14681
17266
  (sessionId, data, viewerId) => {
14682
17267
  if (!conn.isReady()) {
@@ -14727,6 +17312,7 @@ async function runAgent(rawOptions) {
14727
17312
  },
14728
17313
  () => {
14729
17314
  transcriptWatcher.resyncMarkdownAfterDrop();
17315
+ nativeTranscriptRecovery?.request();
14730
17316
  },
14731
17317
  // #556 follow-up: lets the streamer gate resync delivery + the pipe-liveness
14732
17318
  // watchdog on a confirmed-OPEN socket, so resync bytes aren't buffered then
@@ -14749,41 +17335,98 @@ async function runAgent(rawOptions) {
14749
17335
  }
14750
17336
  }
14751
17337
  };
17338
+ const sendTranscriptSnapshot = (sessionId, snapshot, seq, reset) => {
17339
+ if (!conn.isReady()) return;
17340
+ const safeSnapshot = trimSnapshot(snapshot);
17341
+ const payload = {
17342
+ type: "markdown",
17343
+ subtype: "snapshot",
17344
+ schemaVersion: 2,
17345
+ sessionId,
17346
+ seq,
17347
+ snapshot: safeSnapshot,
17348
+ ...reset ? { reset: true } : {}
17349
+ };
17350
+ if (conn.sessionKey) {
17351
+ const contentStr = JSON.stringify(payload);
17352
+ const { enc, iv, seq: frameSeq } = encryptPayload(
17353
+ contentStr,
17354
+ conn.sessionKey,
17355
+ conn.txSeq.next()
17356
+ );
17357
+ sendToGroup(
17358
+ conn.activeSocket,
17359
+ conn.activeGroups.privateGroup,
17360
+ { type: "markdown", enc, iv, seq: frameSeq, sessionId },
17361
+ { markdownSubtype: "snapshot", markdownSchemaVersion: 2, sessionId, reset }
17362
+ );
17363
+ } else {
17364
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
17365
+ }
17366
+ };
17367
+ nativeTranscriptRecovery = new NativeTranscriptRecoveryDrain({
17368
+ listSessionIds: getNativeTranscriptSnapshotSessionIds,
17369
+ syncSession: (sessionId) => syncNativeTranscriptSnapshot(
17370
+ sessionId,
17371
+ sendTranscriptSnapshot,
17372
+ true
17373
+ ),
17374
+ isQueueHigh: () => getQueuePressure().isHigh,
17375
+ canSend: () => conn.isReady(),
17376
+ onError: (sessionId, error) => {
17377
+ log25.warn("native transcript recovery send failed; retrying", {
17378
+ sessionId,
17379
+ error: error instanceof Error ? error.message : String(error)
17380
+ });
17381
+ }
17382
+ });
14752
17383
  const transcriptWatcher = new TranscriptWatcherManager(
14753
17384
  (sessionId, turn, seq) => {
14754
17385
  if (!conn.isReady()) return;
17386
+ const safeTurn = boundMarkdownTurnForWire(turn);
14755
17387
  const payload = {
14756
17388
  type: "markdown",
14757
17389
  subtype: "delta",
14758
17390
  sessionId,
14759
17391
  seq,
14760
- turnId: turn.turnId,
14761
- role: turn.role,
14762
- content: turn.content,
14763
- tools: turn.tools,
14764
- timestamp: turn.timestamp
17392
+ turnId: safeTurn.turnId,
17393
+ role: safeTurn.role,
17394
+ content: safeTurn.content,
17395
+ tools: safeTurn.tools,
17396
+ timestamp: safeTurn.timestamp
14765
17397
  };
14766
17398
  if (conn.sessionKey) {
14767
17399
  const contentStr = JSON.stringify(payload);
14768
17400
  const { enc, iv, seq: frameSeq } = encryptPayload(contentStr, conn.sessionKey, conn.txSeq.next());
14769
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "markdown", enc, iv, seq: frameSeq, sessionId });
17401
+ sendToGroup(
17402
+ conn.activeSocket,
17403
+ conn.activeGroups.privateGroup,
17404
+ { type: "markdown", enc, iv, seq: frameSeq, sessionId },
17405
+ { markdownSubtype: "delta", markdownSchemaVersion: 1, sessionId }
17406
+ );
14770
17407
  } else {
14771
17408
  sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
14772
17409
  }
14773
17410
  },
14774
17411
  (sessionId, turns, seq) => {
14775
17412
  if (!conn.isReady()) return;
17413
+ const safeTurns = boundMarkdownTurnsForWire(turns);
14776
17414
  const payload = {
14777
17415
  type: "markdown",
14778
17416
  subtype: "snapshot",
14779
17417
  sessionId,
14780
17418
  seq,
14781
- turns
17419
+ turns: safeTurns
14782
17420
  };
14783
17421
  if (conn.sessionKey) {
14784
17422
  const contentStr = JSON.stringify(payload);
14785
17423
  const { enc, iv, seq: frameSeq } = encryptPayload(contentStr, conn.sessionKey, conn.txSeq.next());
14786
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "markdown", enc, iv, seq: frameSeq, sessionId });
17424
+ sendToGroup(
17425
+ conn.activeSocket,
17426
+ conn.activeGroups.privateGroup,
17427
+ { type: "markdown", enc, iv, seq: frameSeq, sessionId },
17428
+ { markdownSubtype: "snapshot", markdownSchemaVersion: 1, sessionId }
17429
+ );
14787
17430
  } else {
14788
17431
  sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
14789
17432
  }
@@ -14791,45 +17434,30 @@ async function runAgent(rawOptions) {
14791
17434
  // V2: send block-level delta ops
14792
17435
  (sessionId, ops, seq) => {
14793
17436
  if (!conn.isReady()) return;
14794
- const payload = {
14795
- type: "markdown",
14796
- subtype: "delta",
14797
- schemaVersion: 2,
14798
- sessionId,
14799
- seq,
14800
- ops
14801
- };
14802
- if (conn.sessionKey) {
14803
- const contentStr = JSON.stringify(payload);
14804
- const { enc, iv, seq: frameSeq } = encryptPayload(contentStr, conn.sessionKey, conn.txSeq.next());
14805
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "markdown", enc, iv, seq: frameSeq, sessionId });
14806
- } else {
14807
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
14808
- }
14809
- },
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) => {
14815
- if (!conn.isReady()) return;
14816
- const payload = {
14817
- type: "markdown",
14818
- subtype: "snapshot",
14819
- schemaVersion: 2,
14820
- sessionId,
14821
- seq,
14822
- snapshot,
14823
- ...reset ? { reset: true } : {}
14824
- };
14825
- if (conn.sessionKey) {
14826
- const contentStr = JSON.stringify(payload);
14827
- const { enc, iv, seq: frameSeq } = encryptPayload(contentStr, conn.sessionKey, conn.txSeq.next());
14828
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "markdown", enc, iv, seq: frameSeq, sessionId });
14829
- } else {
14830
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
17437
+ for (const safeOps of boundTranscriptOpsForWire(ops)) {
17438
+ const payload = {
17439
+ type: "markdown",
17440
+ subtype: "delta",
17441
+ schemaVersion: 2,
17442
+ sessionId,
17443
+ seq,
17444
+ ops: safeOps
17445
+ };
17446
+ if (conn.sessionKey) {
17447
+ const contentStr = JSON.stringify(payload);
17448
+ const { enc, iv, seq: frameSeq } = encryptPayload(contentStr, conn.sessionKey, conn.txSeq.next());
17449
+ sendToGroup(
17450
+ conn.activeSocket,
17451
+ conn.activeGroups.privateGroup,
17452
+ { type: "markdown", enc, iv, seq: frameSeq, sessionId },
17453
+ { markdownSubtype: "delta", markdownSchemaVersion: 2, sessionId }
17454
+ );
17455
+ } else {
17456
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
17457
+ }
14831
17458
  }
14832
17459
  },
17460
+ sendTranscriptSnapshot,
14833
17461
  // #483: when a deferred enableMarkdown retry succeeds, flip the portal
14834
17462
  // status from "unavailable" back to "enabled" so the markdown view
14835
17463
  // drops the loading placeholder without a user-driven reconnect.
@@ -14846,30 +17474,36 @@ async function runAgent(rawOptions) {
14846
17474
  );
14847
17475
  const sendNativeOps = (sessionId, ops, seq) => {
14848
17476
  if (!conn.isReady()) return;
14849
- const payload = {
14850
- type: "markdown",
14851
- subtype: "delta",
14852
- schemaVersion: 2,
14853
- sessionId,
14854
- seq,
14855
- ops
14856
- };
14857
- if (conn.sessionKey) {
14858
- const contentStr = JSON.stringify(payload);
14859
- const { enc, iv, seq: frameSeq } = encryptPayload(
14860
- contentStr,
14861
- conn.sessionKey,
14862
- conn.txSeq.next()
14863
- );
14864
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, {
17477
+ for (const safeOps of boundTranscriptOpsForWire(ops)) {
17478
+ const payload = {
14865
17479
  type: "markdown",
14866
- enc,
14867
- iv,
14868
- seq: frameSeq,
14869
- sessionId
14870
- });
14871
- } else {
14872
- sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
17480
+ subtype: "delta",
17481
+ schemaVersion: 2,
17482
+ sessionId,
17483
+ seq,
17484
+ ops: safeOps
17485
+ };
17486
+ if (conn.sessionKey) {
17487
+ const contentStr = JSON.stringify(payload);
17488
+ const { enc, iv, seq: frameSeq } = encryptPayload(
17489
+ contentStr,
17490
+ conn.sessionKey,
17491
+ conn.txSeq.next()
17492
+ );
17493
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, {
17494
+ type: "markdown",
17495
+ enc,
17496
+ iv,
17497
+ seq: frameSeq,
17498
+ sessionId
17499
+ }, {
17500
+ markdownSubtype: "delta",
17501
+ markdownSchemaVersion: 2,
17502
+ sessionId
17503
+ });
17504
+ } else {
17505
+ sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, payload);
17506
+ }
14873
17507
  }
14874
17508
  };
14875
17509
  const shell = new ShellManager(options.command, sendOutput);
@@ -14881,12 +17515,14 @@ async function runAgent(rawOptions) {
14881
17515
  conn,
14882
17516
  options,
14883
17517
  transcriptWatcher,
14884
- sendNativeOps
17518
+ sendNativeOps,
17519
+ sendTranscriptSnapshot
14885
17520
  );
14886
17521
  shell.spawn();
14887
17522
  const onSignal = () => {
14888
17523
  dispatcher.shouldRun = false;
14889
17524
  dispatcher.stopTranscriptWatchers();
17525
+ nativeTranscriptRecovery?.cancel();
14890
17526
  stopAllNativeSessions();
14891
17527
  conn.cleanup({ stopStreams: true });
14892
17528
  shell.stop();
@@ -14914,7 +17550,10 @@ async function runAgent(rawOptions) {
14914
17550
  };
14915
17551
  await new Promise((resolve) => {
14916
17552
  const ws = new import_ws5.default(negotiated.url, "json.webpubsub.azure.v1");
14917
- conn.setConnection(ws, groups, negotiated.sessionKey ?? null);
17553
+ conn.setConnection(ws, groups, negotiated.sessionKey ?? null, {
17554
+ cryptoBindingVersion: negotiated.cryptoBindingVersion,
17555
+ serverClockOffsetMs: negotiated.serverClockOffsetMs
17556
+ });
14918
17557
  let connectedAt = 0;
14919
17558
  if (negotiated.sessionKey) {
14920
17559
  const enforcer = new ApprovalEnforcer({
@@ -14949,6 +17588,7 @@ async function runAgent(rawOptions) {
14949
17588
  // sequence trackers so restarted frames aren't dropped forever.
14950
17589
  streamEpoch: conn.streamEpoch
14951
17590
  });
17591
+ nativeTranscriptRecovery?.request();
14952
17592
  conn.replayBufferedOutput((chunk) => {
14953
17593
  for (const outputChunk of splitTransportChunks(chunk)) {
14954
17594
  if (conn.sessionKey) {
@@ -15003,23 +17643,33 @@ async function runAgent(rawOptions) {
15003
17643
  let inputData = null;
15004
17644
  if (typeof payload.enc === "string" && typeof payload.iv === "string" && conn.sessionKey) {
15005
17645
  const inSeq = typeof payload.seq === "number" ? payload.seq : null;
15006
- const rxSeq = conn.getRxSeqForViewer(payload.viewerId);
15007
17646
  if (inSeq === null) {
15008
17647
  log25.warn("rejecting encrypted input frame without seq (#310)");
15009
- } else if (!rxSeq.accept(inSeq)) {
15010
- log25.warn("rejecting replayed/out-of-order input", {
15011
- seq: inSeq,
15012
- lastSeen: rxSeq.lastSeq,
15013
- viewerId: typeof payload.viewerId === "string" ? payload.viewerId : "_legacy"
15014
- });
15015
17648
  } else {
15016
- inputData = decryptPayload(
17649
+ const authenticated = conn.authenticateInboundFrame(
15017
17650
  payload.enc,
15018
17651
  payload.iv,
15019
- conn.sessionKey,
15020
- inSeq
17652
+ inSeq,
17653
+ payload.viewerId,
17654
+ {
17655
+ bindingVersion: payload.bindingVersion,
17656
+ sentAtMs: payload.sentAtMs,
17657
+ type: "input",
17658
+ action: "",
17659
+ sessionId: payload.sessionId
17660
+ }
15021
17661
  );
15022
- if (inputData === null) {
17662
+ if (authenticated.ok) {
17663
+ inputData = authenticated.plaintext;
17664
+ } else if (authenticated.reason === "replayed") {
17665
+ log25.warn("rejecting replayed/out-of-order input", {
17666
+ seq: inSeq,
17667
+ lastSeen: authenticated.lastSeen,
17668
+ viewerId: authenticated.viewerKey
17669
+ });
17670
+ } else if (authenticated.reason === "invalid_viewer") {
17671
+ log25.warn("rejecting input with invalid viewerId");
17672
+ } else {
15023
17673
  log25.warn("failed to decrypt input \u2014 ignoring");
15024
17674
  }
15025
17675
  }
@@ -15043,39 +17693,53 @@ async function runAgent(rawOptions) {
15043
17693
  } else if (payload.type === "control" && typeof payload.action === "string") {
15044
17694
  let controlPayload = payload;
15045
17695
  if (typeof payload.enc === "string" && typeof payload.iv === "string" && conn.sessionKey) {
17696
+ if (payload.action !== "upload-file") {
17697
+ log25.warn("rejecting encrypted control with unsupported outer action");
17698
+ return;
17699
+ }
15046
17700
  const inSeq = typeof payload.seq === "number" ? payload.seq : null;
15047
- const rxSeq = conn.getRxSeqForViewer(payload.viewerId);
15048
17701
  if (inSeq === null) {
15049
17702
  log25.warn("rejecting encrypted control frame without seq");
15050
17703
  return;
15051
17704
  }
15052
- if (!rxSeq.accept(inSeq)) {
17705
+ const authenticated = conn.authenticateInboundFrame(
17706
+ payload.enc,
17707
+ payload.iv,
17708
+ inSeq,
17709
+ payload.viewerId,
17710
+ {
17711
+ bindingVersion: payload.bindingVersion,
17712
+ sentAtMs: payload.sentAtMs,
17713
+ type: "control",
17714
+ action: payload.action,
17715
+ sessionId: payload.sessionId
17716
+ }
17717
+ );
17718
+ if (!authenticated.ok && authenticated.reason === "replayed") {
15053
17719
  log25.warn("rejecting replayed/out-of-order control", {
15054
17720
  seq: inSeq,
15055
- lastSeen: rxSeq.lastSeq,
15056
- viewerId: typeof payload.viewerId === "string" ? payload.viewerId : "_legacy"
17721
+ lastSeen: authenticated.lastSeen,
17722
+ viewerId: authenticated.viewerKey
15057
17723
  });
15058
17724
  return;
15059
17725
  }
15060
- const decrypted = decryptPayload(
15061
- payload.enc,
15062
- payload.iv,
15063
- conn.sessionKey,
15064
- inSeq
15065
- );
15066
- if (decrypted === null) {
17726
+ if (!authenticated.ok) {
15067
17727
  log25.warn("failed to decrypt control \u2014 ignoring");
15068
17728
  return;
15069
17729
  }
15070
- try {
15071
- controlPayload = {
15072
- ...JSON.parse(decrypted),
15073
- _verifiedEncrypted: true
15074
- };
15075
- } catch {
15076
- log25.warn("failed to parse encrypted control payload");
17730
+ const verifiedUpload = parseAuthenticatedUploadControlPayload(
17731
+ authenticated.plaintext,
17732
+ {
17733
+ action: payload.action,
17734
+ sessionId: payload.sessionId,
17735
+ viewerId: payload.viewerId
17736
+ }
17737
+ );
17738
+ if (!verifiedUpload) {
17739
+ log25.warn("rejecting encrypted control with mismatched or invalid inner envelope");
15077
17740
  return;
15078
17741
  }
17742
+ controlPayload = verifiedUpload;
15079
17743
  } else if (conn.sessionKey && payload.action === "upload-file") {
15080
17744
  log25.warn("rejecting plaintext attachment upload while encrypted channel is active");
15081
17745
  return;
@@ -15155,6 +17819,7 @@ async function runAgent(rawOptions) {
15155
17819
  }
15156
17820
  } finally {
15157
17821
  dispatcher.stopTranscriptWatchers();
17822
+ nativeTranscriptRecovery?.cancel();
15158
17823
  stopAllNativeSessions();
15159
17824
  conn.cleanup({ stopStreams: true });
15160
17825
  shell.stop();