dsh-over-ssh 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1560,12 +1560,23 @@ function decisionKey(decision) {
1560
1560
  const degradations = decision.degradations.map((item) => item.code).join(",");
1561
1561
  return `${decision.kind}|${decision.reason}|${decision.reusesConnection}|${degradations}`;
1562
1562
  }
1563
+ function isWatchSessionKey(sessionKey2) {
1564
+ return sessionKey2 === "watch" || sessionKey2.startsWith("watch:");
1565
+ }
1566
+ function laneKey(connectionId, options) {
1567
+ const lane = options?.lane ?? "interactive";
1568
+ if (options === void 0 || lane === "interactive" && options.sessionKey === void 0) return connectionId;
1569
+ return `${connectionId}\0${lane}\0${options.sessionKey ?? ""}`;
1570
+ }
1563
1571
  var TransportService = class {
1564
1572
  #selector;
1565
1573
  #factories;
1566
1574
  #preflight;
1567
1575
  #entries = /* @__PURE__ */ new Map();
1568
1576
  #pending = /* @__PURE__ */ new Map();
1577
+ #statefulLaneLimit;
1578
+ /** Every cache key currently owned by a profile, so release/dispose can cover all of its lanes. */
1579
+ #slots = /* @__PURE__ */ new Map();
1569
1580
  /**
1570
1581
  * Connections released while a `connect()` was still in flight.
1571
1582
  *
@@ -1580,84 +1591,260 @@ var TransportService = class {
1580
1591
  * it just created, so it needs to know it was released.
1581
1592
  */
1582
1593
  #retired = /* @__PURE__ */ new Set();
1594
+ /**
1595
+ * Per-connection release counter: bumped by every explicit release of **that** connection.
1596
+ *
1597
+ * An `acquire()` that had to discard a lost transport checks it after the async discard: a release that
1598
+ * arrived in that window must not be undone by recreating the lane.
1599
+ *
1600
+ * Keyed by connection id (not a single global counter): a global one meant that deleting any session, or
1601
+ * releasing any *other* connection, cancelled an unrelated in-flight reconnect with `CANCELLED` — a
1602
+ * spurious failure the caller could only interpret as a real one. The key space is the profile set
1603
+ * (bounded), so this map cannot grow with sessions and cannot retain deleted session ids.
1604
+ */
1605
+ #releaseGeneration = /* @__PURE__ */ new Map();
1606
+ /**
1607
+ * Lane keys whose owning `acquire()` is between "the old transport is lost" and "the lanes were rebuilt".
1608
+ *
1609
+ * That window deletes the slot and the entry **before** it awaits the async discard, so neither table can
1610
+ * say "this session is still being rebuilt here". Without it, a session deleted exactly then would not be
1611
+ * recognised as the owner and its lane would be recreated (the bug `#releaseGeneration` exists to
1612
+ * prevent). It is a set of *in-flight* keys, so it cannot grow.
1613
+ */
1614
+ #rebuilding = /* @__PURE__ */ new Set();
1583
1615
  constructor(options) {
1584
1616
  this.#selector = options.selector;
1585
1617
  this.#factories = options.factories;
1586
1618
  this.#preflight = options.preflight;
1587
- }
1588
- /**
1589
- * Get a connected transport for a profile, creating it if needed.
1590
- *
1591
- * @param profile - the connection to serve.
1592
- * @param signal - cancellation for the connect attempt.
1593
- * @returns the transport and the decision behind it (after any preflight revision).
1594
- * @throws SshDevError from the selector or from the transport's own connect.
1595
- */
1596
- async acquire(profile, signal) {
1619
+ this.#statefulLaneLimit = Math.max(0, Math.floor(options.statefulLaneLimit ?? 8));
1620
+ }
1621
+ async acquire(profile, signalOrOptions, signalArgument) {
1622
+ const legacySignal = typeof signalOrOptions?.addEventListener === "function" ? signalOrOptions : void 0;
1623
+ const options = legacySignal === void 0 ? signalOrOptions : void 0;
1624
+ const signal = legacySignal ?? signalArgument;
1625
+ const key = laneKey(profile.id, options);
1626
+ const lane = options?.lane ?? "interactive";
1597
1627
  const decided = this.#selector.decide(profile);
1598
- const existing = this.#entries.get(profile.id);
1628
+ const releaseGeneration = this.#generationOf(profile.id);
1629
+ if (lane === "stateful" && this.#statefulLaneLimit > 0 && !this.#entries.has(key) && !this.#pending.has(key) && this.#statefulLaneCount(profile.id, isWatchSessionKey(options?.sessionKey ?? "")) >= this.#statefulLaneLimit) {
1630
+ throw new SshDevError(
1631
+ "CONNECTION_INVALID",
1632
+ `stateful transport \u6570\u91CF\u5DF2\u8FBE\u4E0A\u9650\uFF08${this.#statefulLaneLimit} \u6761/\u8FDE\u63A5\uFF09`,
1633
+ { connectionId: profile.id, limit: this.#statefulLaneLimit }
1634
+ );
1635
+ }
1636
+ this.#rememberSlot(profile.id, key);
1637
+ const existing = this.#entries.get(key);
1599
1638
  if (existing !== void 0 && existing.transport.state !== "lost") {
1600
1639
  if (decisionKey(existing.decision) === decisionKey(decided)) return existing;
1601
1640
  }
1602
1641
  if (existing !== void 0) {
1603
- await this.#discard(profile.id);
1642
+ this.#rebuilding.add(key);
1643
+ try {
1644
+ await this.#discard(profile.id);
1645
+ if (this.#generationOf(profile.id) !== releaseGeneration) {
1646
+ this.#forgetSlotIfIdle(profile.id, key);
1647
+ throw new SshDevError("CANCELLED", `\u8FDE\u63A5\u5728\u91CD\u5EFA\u8FC7\u7A0B\u4E2D\u5DF2\u88AB\u91CA\u653E\uFF1A${profile.id}`, { connectionId: profile.id });
1648
+ }
1649
+ this.#rememberSlot(profile.id, key);
1650
+ } finally {
1651
+ this.#rebuilding.delete(key);
1652
+ }
1604
1653
  }
1605
- const inflight = this.#pending.get(profile.id);
1654
+ const inflight = this.#pending.get(key);
1606
1655
  if (inflight !== void 0) return inflight;
1607
1656
  const attempt = (async () => {
1608
1657
  const decision = this.#preflight === void 0 ? decided : await this.#preflight(profile, decided, signal);
1609
- const transport = this.#create(profile, decision);
1658
+ const transport = this.#create(profile, decision, lane, options?.sessionKey);
1610
1659
  try {
1611
1660
  await transport.connect(signal);
1612
1661
  } catch (error) {
1613
1662
  await transport.dispose().catch(() => void 0);
1614
1663
  throw error;
1615
1664
  }
1616
- if (this.#retired.delete(profile.id)) {
1665
+ if (this.#retired.delete(key)) {
1617
1666
  await transport.dispose().catch(() => void 0);
1618
1667
  throw new SshDevError("CANCELLED", `\u8FDE\u63A5\u5728\u5EFA\u7ACB\u8FC7\u7A0B\u4E2D\u5DF2\u88AB\u91CA\u653E\uFF1A${profile.id}`, { connectionId: profile.id });
1619
1668
  }
1620
- const acquired = { transport, decision };
1621
- this.#entries.set(profile.id, acquired);
1669
+ const acquired = { transport, decision, lane, connectionId: profile.id };
1670
+ this.#entries.set(key, acquired);
1622
1671
  return acquired;
1623
1672
  })().finally(() => {
1624
- this.#pending.delete(profile.id);
1625
- this.#retired.delete(profile.id);
1673
+ this.#pending.delete(key);
1674
+ this.#retired.delete(key);
1675
+ this.#forgetSlotIfIdle(profile.id, key);
1626
1676
  });
1627
- this.#pending.set(profile.id, attempt);
1677
+ this.#pending.set(key, attempt);
1628
1678
  return attempt;
1629
1679
  }
1630
- /** The cached transport for a connection, if any. */
1631
- current(connectionId) {
1632
- return this.#entries.get(connectionId);
1680
+ /**
1681
+ * The cached transport for a connection.
1682
+ *
1683
+ * Without options this is the legacy interactive slot, so existing callers keep their old meaning.
1684
+ */
1685
+ current(connectionId, options) {
1686
+ return this.#entries.get(laneKey(connectionId, options));
1633
1687
  }
1634
1688
  /** Decisions currently in force, for the connection status UI. */
1635
1689
  decisions() {
1636
- return [...this.#entries.values()].map((entry) => entry.decision);
1690
+ const seen = /* @__PURE__ */ new Set();
1691
+ const decisions = [];
1692
+ for (const entry of this.#entries.values()) {
1693
+ const key = entry.connectionId ?? decisionKey(entry.decision);
1694
+ if (seen.has(key)) continue;
1695
+ seen.add(key);
1696
+ decisions.push(entry.decision);
1697
+ }
1698
+ return decisions;
1699
+ }
1700
+ /**
1701
+ * Connections that currently own (or are still building / tearing down) the stateful lane of one session.
1702
+ *
1703
+ * `#slots` alone is not enough: a reconnect deletes the slot **before** it awaits the async discard, so the
1704
+ * release that happens in that window must still be recognised — otherwise the rebuild is not cancelled
1705
+ * and an already-deleted session gets its lane back. Pending keys carry the connection id as their first
1706
+ * `\u0000`-separated segment (see {@link laneKey}).
1707
+ */
1708
+ #connectionsOwningSessionLane(sessionKey2) {
1709
+ const candidates = new Set(this.#slots.keys());
1710
+ for (const entry of this.#entries.values()) {
1711
+ if (entry.connectionId !== void 0) candidates.add(entry.connectionId);
1712
+ }
1713
+ for (const key of [...this.#pending.keys(), ...this.#rebuilding]) {
1714
+ const separator = key.indexOf("\0");
1715
+ if (separator > 0) candidates.add(key.slice(0, separator));
1716
+ }
1717
+ const found = /* @__PURE__ */ new Set();
1718
+ for (const connectionId of candidates) {
1719
+ const key = laneKey(connectionId, { lane: "stateful", sessionKey: sessionKey2 });
1720
+ if (this.#pending.has(key) || this.#rebuilding.has(key) || this.#entries.has(key) || (this.#slots.get(connectionId)?.has(key) ?? false)) {
1721
+ found.add(connectionId);
1722
+ }
1723
+ }
1724
+ return found;
1725
+ }
1726
+ /** Current release counter of one connection. */
1727
+ #generationOf(connectionId) {
1728
+ return this.#releaseGeneration.get(connectionId) ?? 0;
1637
1729
  }
1638
- /** Dispose and forget one connection. */
1730
+ /** Record one explicit release of a connection. */
1731
+ #bumpGeneration(connectionId) {
1732
+ this.#releaseGeneration.set(connectionId, this.#generationOf(connectionId) + 1);
1733
+ }
1734
+ /** Dispose and forget **every lane** of one connection. */
1639
1735
  async release(connectionId) {
1736
+ this.#bumpGeneration(connectionId);
1640
1737
  await this.#discard(connectionId);
1641
1738
  }
1642
- /** Dispose every connection (plugin unload, connection-service shutdown). */
1739
+ /** Number of cached/pending stateful lanes of one class (watch vs session) on one connection. */
1740
+ #statefulLaneCount(connectionId, watchOnly) {
1741
+ let count = 0;
1742
+ const marker = "\0stateful\0";
1743
+ for (const key of this.#slots.get(connectionId) ?? []) {
1744
+ const index = key.indexOf(marker);
1745
+ if (index < 0) continue;
1746
+ if (isWatchSessionKey(key.slice(index + marker.length)) === watchOnly) count++;
1747
+ }
1748
+ return count;
1749
+ }
1750
+ /** Unique DSH session keys currently owning a stateful lane (used by the idle janitor). */
1751
+ statefulSessions() {
1752
+ const sessions = /* @__PURE__ */ new Set();
1753
+ for (const keys of this.#slots.values()) {
1754
+ for (const key of keys) {
1755
+ const marker = "\0stateful\0";
1756
+ const index = key.indexOf(marker);
1757
+ if (index >= 0) sessions.add(key.slice(index + marker.length));
1758
+ }
1759
+ }
1760
+ return [...sessions];
1761
+ }
1762
+ /** Release stateful lanes whose DSH session is no longer live. */
1763
+ async pruneStatefulSessions(isLive) {
1764
+ for (const sessionKey2 of this.statefulSessions()) {
1765
+ if (!isLive(sessionKey2)) await this.releaseSession(sessionKey2);
1766
+ }
1767
+ }
1768
+ /**
1769
+ * Dispose the `stateful` lane of one DSH session on every connection.
1770
+ *
1771
+ * Stateful lanes are keyed by session, so deleting a DSH session would otherwise leave one agent
1772
+ * transport per connection alive for the life of the host process (and with it the daemon-side jobs/LSP
1773
+ * ownership records that transport created). Other lanes are untouched.
1774
+ *
1775
+ * @param sessionKey - the DSH session id used when the stateful lane was acquired.
1776
+ */
1777
+ async releaseSession(sessionKey2) {
1778
+ const released = this.#connectionsOwningSessionLane(sessionKey2);
1779
+ const disposals = [];
1780
+ for (const [connectionId, keys] of [...this.#slots]) {
1781
+ for (const key of [...keys]) {
1782
+ if (key !== laneKey(connectionId, { lane: "stateful", sessionKey: sessionKey2 })) continue;
1783
+ if (this.#pending.has(key)) this.#retired.add(key);
1784
+ const entry = this.#entries.get(key);
1785
+ this.#entries.delete(key);
1786
+ keys.delete(key);
1787
+ if (entry !== void 0) {
1788
+ disposals.push((async () => {
1789
+ const sessionAware = entry.transport;
1790
+ if (typeof sessionAware.endSession === "function") {
1791
+ await sessionAware.endSession(sessionKey2).catch(() => void 0);
1792
+ }
1793
+ await entry.transport.dispose().catch(() => void 0);
1794
+ })());
1795
+ }
1796
+ }
1797
+ if (keys.size === 0) this.#slots.delete(connectionId);
1798
+ }
1799
+ for (const connectionId of released) this.#bumpGeneration(connectionId);
1800
+ await Promise.all(disposals);
1801
+ }
1802
+ /** Dispose every connection and lane (plugin unload, connection-service shutdown). */
1643
1803
  async disposeAll() {
1644
- for (const id of this.#pending.keys()) this.#retired.add(id);
1645
- await Promise.all([...this.#entries.keys()].map((id) => this.#discard(id)));
1804
+ for (const connectionId of this.#slots.keys()) this.#bumpGeneration(connectionId);
1805
+ for (const key of this.#pending.keys()) this.#retired.add(key);
1806
+ await Promise.all([...this.#slots.keys()].map((id) => this.#discard(id)));
1646
1807
  }
1647
- #create(profile, decision) {
1808
+ #create(profile, decision, lane, sessionKey2) {
1648
1809
  if (decision.kind === "agent") {
1649
1810
  if (this.#factories.createAgent === void 0) {
1650
1811
  throw new SshDevError("CONNECTION_INVALID", "\u9009\u62E9\u5668\u9009\u4E86 Tier 2 \u4EE3\u7406\uFF0C\u4F46\u6CA1\u6709\u6CE8\u518C\u4EE3\u7406\u5DE5\u5382", { connectionId: profile.id });
1651
1812
  }
1652
- return this.#factories.createAgent(profile, decision);
1813
+ return this.#factories.createAgent(profile, decision, lane, sessionKey2);
1653
1814
  }
1654
1815
  return decision.kind === "ssh2" ? this.#factories.createSsh2(profile, decision) : this.#factories.createAlias(profile, decision);
1655
1816
  }
1817
+ /** Record that a profile owns a cache key (idempotent). */
1818
+ #rememberSlot(connectionId, key) {
1819
+ const keys = this.#slots.get(connectionId);
1820
+ if (keys === void 0) {
1821
+ this.#slots.set(connectionId, /* @__PURE__ */ new Set([key]));
1822
+ return;
1823
+ }
1824
+ keys.add(key);
1825
+ }
1826
+ /** Drop a slot key once neither a cached entry nor an in-flight attempt uses it. */
1827
+ #forgetSlotIfIdle(connectionId, key) {
1828
+ if (this.#entries.has(key) || this.#pending.has(key)) return;
1829
+ const keys = this.#slots.get(connectionId);
1830
+ if (keys === void 0) return;
1831
+ keys.delete(key);
1832
+ if (keys.size === 0) this.#slots.delete(connectionId);
1833
+ }
1656
1834
  async #discard(connectionId) {
1657
- if (this.#pending.has(connectionId)) this.#retired.add(connectionId);
1658
- const entry = this.#entries.get(connectionId);
1659
- this.#entries.delete(connectionId);
1660
- if (entry !== void 0) await entry.transport.dispose().catch(() => void 0);
1835
+ const keys = [...this.#slots.get(connectionId) ?? []];
1836
+ for (const key of keys) {
1837
+ if (this.#pending.has(key)) this.#retired.add(key);
1838
+ }
1839
+ const entries = keys.map((key) => {
1840
+ const entry = this.#entries.get(key);
1841
+ this.#entries.delete(key);
1842
+ return entry;
1843
+ });
1844
+ this.#slots.delete(connectionId);
1845
+ await Promise.all(entries.map(async (entry) => {
1846
+ if (entry !== void 0) await entry.transport.dispose().catch(() => void 0);
1847
+ }));
1661
1848
  }
1662
1849
  };
1663
1850
 
@@ -1932,6 +2119,17 @@ var Config = z.object({
1932
2119
  transferChunkBytes: z.natural().default(4 * 1024 * 1024),
1933
2120
  /** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
1934
2121
  transferConcurrency: z.natural().default(4),
2122
+ /**
2123
+ * Split one connection into traffic lanes (`interactive` / `bulk` / `compute`).
2124
+ *
2125
+ * On by default: transfers then open their own transport instead of competing with an interactive
2126
+ * `remote_exec` for the connection's in-flight window. A lane that fails to connect falls back to the
2127
+ * interactive transport, and turning this off restores exactly the pre-lane behaviour (one transport
2128
+ * per connection). `stateful` carries long-lived job/LSP/watch state (watch uses a dedicated per-session key when an ambient session id is available).
2129
+ */
2130
+ transportLanes: z.boolean().default(true),
2131
+ /** Upper bound on concurrent stateful lane transports per connection, separately for job/LSP sessions and watch (default 8 each). */
2132
+ statefulLanes: z.natural().default(8),
1935
2133
  /** How many finished transfers the progress table retains (FR-2.6.4). */
1936
2134
  transferHistoryEntries: z.natural().default(50),
1937
2135
  // 根内回收站目录名(`<root>/<binDir>`)。改过名(`.dsh-remote-workspace/trash` → 这里):旧目录仍被
@@ -2410,9 +2608,14 @@ function parseGrepArgs(args) {
2410
2608
  caseSensitive: record2["caseSensitive"] === true,
2411
2609
  noIgnore: record2["noIgnore"] === true,
2412
2610
  include: include.values.length === 0 ? void 0 : include.values,
2413
- maxResults: clampInt(record2["maxResults"], MAX_GREP_RESULTS, MAX_GREP_RESULTS)
2611
+ maxResults: clampInt(record2["maxResults"], MAX_GREP_RESULTS, MAX_GREP_RESULTS),
2612
+ offset: clampOffset(record2["offset"])
2414
2613
  };
2415
2614
  }
2615
+ function clampOffset(value) {
2616
+ const number = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0;
2617
+ return Math.max(0, number);
2618
+ }
2416
2619
  function parseGlobArgs(args) {
2417
2620
  const record2 = args ?? {};
2418
2621
  const raw = record2["pattern"];
@@ -2569,7 +2772,7 @@ function readSearchTruncation(raw) {
2569
2772
  function searchTruncationAdvice(reason) {
2570
2773
  switch (reason) {
2571
2774
  case "maxResults":
2572
- return "\u547D\u4E2D\u6761\u6570\u5230\u9876\uFF08maxResults\uFF09\uFF1A\u7528\u66F4\u5177\u4F53\u7684 pattern\u3001\u52A0 include \u8FC7\u6EE4\uFF0C\u6216\u7F29\u5C0F path \u540E\u91CD\u8DD1\u3002";
2775
+ return "\u547D\u4E2D\u6761\u6570\u5230\u9876\uFF08maxResults\uFF09\uFF1A\u7528\u8FD4\u56DE\u7684 nextOffset \u4F5C\u4E3A offset \u7EE7\u7EED\u7FFB\u9875\uFF0C\u6216\u6536\u7A84 pattern/include/path\u3002";
2573
2776
  case "bytesScanned":
2574
2777
  return "\u626B\u63CF\u5B57\u8282\u6570\u5230\u9876\uFF08bytesScanned\uFF09\uFF1A\u7F29\u5C0F path \u6216\u52A0 include\uFF0C\u907F\u514D\u6574\u6811\u626B\u63CF\u3002";
2575
2778
  case "maxFileBytes":
@@ -6482,14 +6685,26 @@ init_errors();
6482
6685
 
6483
6686
  // src/host/infra/transport/agent/agent-protocol-frames.ts
6484
6687
  init_errors();
6485
- var AGENT_PROTOCOL_VERSION = 1;
6688
+ import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from "node:zlib";
6689
+ var AGENT_PROTOCOL_VERSION = 3;
6486
6690
  var AGENT_CLIENT_ID = "dsh-over-ssh/0.1.0";
6487
6691
  var MAX_AGENT_FRAME_BYTES = 16 << 20;
6692
+ var AGENT_FRAME_FLAG_ZSTD = 1;
6693
+ var AGENT_ZSTD_MIN_BYTES = 512;
6694
+ var ZSTD_LEVEL = 1;
6695
+ function zstdCompress(payload) {
6696
+ return zstdCompressSync(payload, { params: { [zlibConstants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } });
6697
+ }
6698
+ function likelyCompressible(payload) {
6699
+ const sample = payload.byteLength > 2048 ? payload.subarray(0, 2048) : payload;
6700
+ const compressed = zstdCompress(sample);
6701
+ return compressed.byteLength < sample.byteLength * 0.9;
6702
+ }
6488
6703
  var AGENT_KIND_JSON = 74;
6489
6704
  var AGENT_KIND_BLOB = 66;
6490
6705
  var DEFAULT_AGENT_WATCHER_ID = "default";
6491
6706
  var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 15e3;
6492
- var DEFAULT_AGENT_MAX_IN_FLIGHT = 8;
6707
+ var DEFAULT_AGENT_MAX_IN_FLIGHT = 16;
6493
6708
  var DEFAULT_AGENT_MODE = "--shared";
6494
6709
  var AgentProtocolError = class extends Error {
6495
6710
  code;
@@ -6511,10 +6726,20 @@ function encodeFrame(kind, payload) {
6511
6726
  limit: MAX_AGENT_FRAME_BYTES
6512
6727
  });
6513
6728
  }
6514
- const header = new Uint8Array(5);
6729
+ let body = payload;
6730
+ let flags = 0;
6731
+ if (payload.byteLength >= AGENT_ZSTD_MIN_BYTES && (payload.byteLength <= 4096 || likelyCompressible(payload))) {
6732
+ const compressed = zstdCompress(payload);
6733
+ if (compressed.byteLength < payload.byteLength) {
6734
+ body = compressed;
6735
+ flags = AGENT_FRAME_FLAG_ZSTD;
6736
+ }
6737
+ }
6738
+ const header = new Uint8Array(6);
6515
6739
  header[0] = kind;
6516
- new DataView(header.buffer).setUint32(1, payload.byteLength, false);
6517
- return { header, payload };
6740
+ header[1] = flags;
6741
+ new DataView(header.buffer).setUint32(2, body.byteLength, false);
6742
+ return { header, payload: body };
6518
6743
  }
6519
6744
  function encodeFrameBytes(kind, payload) {
6520
6745
  const { header, payload: body } = encodeFrame(kind, payload);
@@ -6524,24 +6749,44 @@ function encodeFrameBytes(kind, payload) {
6524
6749
  return frame;
6525
6750
  }
6526
6751
  function decodeFrame(bytes) {
6527
- if (bytes.byteLength < 5) return null;
6752
+ if (bytes.byteLength < 6) return null;
6528
6753
  const kind = bytes[0];
6529
- const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1, false);
6754
+ const flags = bytes[1];
6755
+ const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(2, false);
6530
6756
  if (kind !== AGENT_KIND_JSON && kind !== AGENT_KIND_BLOB) {
6531
6757
  throw new AgentProtocolError("E_PROTOCOL", `\u672A\u77E5\u7684\u5E27\u7C7B\u578B\uFF1A${kind}`, { kind });
6532
6758
  }
6759
+ if ((flags & ~AGENT_FRAME_FLAG_ZSTD) !== 0) {
6760
+ throw new AgentProtocolError("E_PROTOCOL", `\u672A\u77E5\u7684\u5E27\u6807\u5FD7\uFF1A${flags}`, { flags });
6761
+ }
6533
6762
  if (length > MAX_AGENT_FRAME_BYTES) {
6534
6763
  throw new AgentProtocolError("E_TOO_LARGE", `\u58F0\u660E\u957F\u5EA6\u8D85\u8FC7\u4E0A\u9650\uFF08${length} > ${MAX_AGENT_FRAME_BYTES}\uFF09`, {
6535
6764
  declared: length,
6536
6765
  limit: MAX_AGENT_FRAME_BYTES
6537
6766
  });
6538
6767
  }
6539
- if (bytes.byteLength - 5 < length) return null;
6540
- return { frame: { kind, payload: bytes.slice(5, 5 + length) }, consumed: 5 + length };
6768
+ if (bytes.byteLength - 6 < length) return null;
6769
+ let payload = bytes.slice(6, 6 + length);
6770
+ if ((flags & AGENT_FRAME_FLAG_ZSTD) !== 0) {
6771
+ try {
6772
+ payload = zstdDecompressSync(payload, { maxOutputLength: MAX_AGENT_FRAME_BYTES + 1 });
6773
+ } catch (error) {
6774
+ if (error instanceof RangeError) {
6775
+ throw new AgentProtocolError("E_TOO_LARGE", "\u89E3\u538B\u540E\u5E27\u8D85\u8FC7\u4E0A\u9650", { limit: MAX_AGENT_FRAME_BYTES });
6776
+ }
6777
+ throw new AgentProtocolError("E_PROTOCOL", "zstd \u5E27\u89E3\u538B\u5931\u8D25", { reason: error instanceof Error ? error.message : String(error) });
6778
+ }
6779
+ if (payload.byteLength > MAX_AGENT_FRAME_BYTES) {
6780
+ throw new AgentProtocolError("E_TOO_LARGE", `\u89E3\u538B\u540E\u5E27\u8D85\u8FC7\u4E0A\u9650\uFF08${payload.byteLength}\uFF09`, { limit: MAX_AGENT_FRAME_BYTES });
6781
+ }
6782
+ }
6783
+ return { frame: { kind, payload }, consumed: 6 + length };
6541
6784
  }
6542
6785
  var COMPACT_MIN_BYTES = 64 * 1024;
6786
+ var IDLE_STORE_BYTES = 64 * 1024;
6543
6787
  function growTo(needed) {
6544
- return Math.min(Math.max(needed * 2, 64 * 1024), MAX_AGENT_FRAME_BYTES + 64 * 1024 + 5);
6788
+ const capped = Math.min(Math.max(needed * 2, 64 * 1024), MAX_AGENT_FRAME_BYTES + 64 * 1024 + 6);
6789
+ return Math.max(capped, needed);
6545
6790
  }
6546
6791
  var AgentFrameParser = class {
6547
6792
  /** Backing store. Its **capacity** may exceed the bytes in use — see {@link growTo}. */
@@ -6568,6 +6813,7 @@ var AgentFrameParser = class {
6568
6813
  if (this.#start === this.#end) {
6569
6814
  this.#start = 0;
6570
6815
  this.#end = 0;
6816
+ if (this.#store.byteLength > IDLE_STORE_BYTES) this.#store = new Uint8Array(IDLE_STORE_BYTES);
6571
6817
  } else if (this.#start >= COMPACT_MIN_BYTES) {
6572
6818
  this.#shift();
6573
6819
  }
@@ -6671,6 +6917,7 @@ function capabilitiesFromAgent(caps, extras = {}) {
6671
6917
  git: source.git === true,
6672
6918
  ripgrep: source.ripgrep === true,
6673
6919
  grep: source.grep === true,
6920
+ ...source.grepPaging === void 0 ? {} : { grepPaging: source.grepPaging === true },
6674
6921
  base64: false,
6675
6922
  findMaxDepth: false,
6676
6923
  sha256: null,
@@ -6706,6 +6953,14 @@ function readAgentCaps(result) {
6706
6953
  ...typeof caps["grep"] === "boolean" ? { grep: caps["grep"] } : {},
6707
6954
  ...typeof caps["exec"] === "boolean" ? { exec: caps["exec"] } : {},
6708
6955
  ...typeof caps["cancel"] === "boolean" ? { cancel: caps["cancel"] } : {},
6956
+ // 增补能力位:新增字段时**必须**在这里放行,否则 hello 发了、宿主也永远看不到(分页门禁就是靠它)。
6957
+ ...typeof caps["batch"] === "boolean" ? { batch: caps["batch"] } : {},
6958
+ ...typeof caps["readGzip"] === "boolean" ? { readGzip: caps["readGzip"] } : {},
6959
+ ...typeof caps["repoSummary"] === "boolean" ? { repoSummary: caps["repoSummary"] } : {},
6960
+ ...typeof caps["patchMany"] === "boolean" ? { patchMany: caps["patchMany"] } : {},
6961
+ ...typeof caps["grepPaging"] === "boolean" ? { grepPaging: caps["grepPaging"] } : {},
6962
+ ...typeof caps["netProbe"] === "boolean" ? { netProbe: caps["netProbe"] } : {},
6963
+ ...typeof caps["resumableUpload"] === "boolean" ? { resumableUpload: caps["resumableUpload"] } : {},
6709
6964
  ...readLspServers(caps["lspServers"])
6710
6965
  };
6711
6966
  }
@@ -6925,7 +7180,9 @@ function readSearchResult(value) {
6925
7180
  truncatedReasons: readStringArray(record2, "truncatedReasons"),
6926
7181
  // `?? undefined` because the helper reports "absent" as `null`, and this field's "absent" must stay
6927
7182
  // distinct from a real `0`.
6928
- filesFiltered: readOptionalNumber(record2, "filesFiltered") ?? void 0
7183
+ filesFiltered: readOptionalNumber(record2, "filesFiltered") ?? void 0,
7184
+ total: readOptionalNumber(record2, "total"),
7185
+ nextOffset: readOptionalNumber(record2, "nextOffset")
6929
7186
  };
6930
7187
  }
6931
7188
  function readWatchStartResult(value) {
@@ -7423,7 +7680,9 @@ var AgentProtocolClient = class {
7423
7680
  if (session !== void 0 && session !== "") envelope["session"] = session;
7424
7681
  try {
7425
7682
  this.#session.write(encodeFrameBytes(AGENT_KIND_JSON, TEXT_ENCODER.encode(JSON.stringify(envelope))));
7426
- } catch {
7683
+ } catch (error) {
7684
+ this.#pending.delete(id);
7685
+ this.failConnection(new SshDevError("CONNECTION_LOST", `\u5411\u4EE3\u7406\u5199\u5165 cancel \u5E27\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`, { method: "cancel" }));
7427
7686
  }
7428
7687
  }
7429
7688
  #deadlineError(method) {
@@ -7930,6 +8189,7 @@ function createNodeLongSpawner() {
7930
8189
 
7931
8190
  // src/host/infra/transport/agent/agent-transport.ts
7932
8191
  init_transport_base();
8192
+ import { gunzipSync } from "node:zlib";
7933
8193
  var DEFAULT_AGENT_PATH = "~/.dsh/over-ssh/dsh-over-ssh-agent";
7934
8194
  var AgentTransport = class extends TransportBase {
7935
8195
  id;
@@ -8151,6 +8411,8 @@ var AgentTransport = class extends TransportBase {
8151
8411
  timedOut: record2["timedOut"] === true,
8152
8412
  stdout,
8153
8413
  stderr,
8414
+ ...nonEmptyString(record2["stdoutBase64"]) === void 0 ? {} : { stdoutBase64: nonEmptyString(record2["stdoutBase64"]) },
8415
+ ...nonEmptyString(record2["stderrBase64"]) === void 0 ? {} : { stderrBase64: nonEmptyString(record2["stderrBase64"]) },
8154
8416
  durationMs: agentDuration ?? Math.max(0, this.#now() - startedAt),
8155
8417
  // The combined alias stays honest about what it covers: either stream was cut.
8156
8418
  truncated: stdoutTruncated || stderrTruncated,
@@ -8196,6 +8458,90 @@ var AgentTransport = class extends TransportBase {
8196
8458
  version: version ?? ""
8197
8459
  };
8198
8460
  }
8461
+ /** Remote TCP reachability preflight (`net.probe`). */
8462
+ async netProbe(host, port, signal) {
8463
+ const result = await this.#rpc("net.probe", { host, port }, signal);
8464
+ const record2 = asRecord(result) ?? {};
8465
+ return {
8466
+ reachable: record2["reachable"] === true,
8467
+ ...typeof record2["error"] === "string" && record2["error"] !== "" ? { error: record2["error"] } : {}
8468
+ };
8469
+ }
8470
+ /** One-round-trip project overview (`repo.summary`). */
8471
+ async repoSummary(root, signal) {
8472
+ await this.connect(signal);
8473
+ const client = this.#requireClient();
8474
+ let result;
8475
+ try {
8476
+ result = await client.request("repo.summary", { root }, this.#requestOptions(void 0, signal));
8477
+ } catch (error) {
8478
+ throw this.#translate(error);
8479
+ }
8480
+ const record2 = asRecord(result);
8481
+ if (record2 === void 0) throw new Error("repo.summary \u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u7ED3\u679C");
8482
+ return record2;
8483
+ }
8484
+ /** Multi-file edit in one round trip (`fs.patchMany`). */
8485
+ async patchMany(edits, atomic, signal) {
8486
+ await this.connect(signal);
8487
+ const client = this.#requireClient();
8488
+ let result;
8489
+ try {
8490
+ result = await client.request("fs.patchMany", { edits, atomic }, this.#requestOptions(void 0, signal));
8491
+ } catch (error) {
8492
+ throw this.#translate(error);
8493
+ }
8494
+ const record2 = asRecord(result);
8495
+ if (record2 === void 0) throw new Error("fs.patchMany \u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u7ED3\u679C");
8496
+ return record2;
8497
+ }
8498
+ /**
8499
+ * Read several file ranges in one `fs.readMany` round trip.
8500
+ *
8501
+ * Per-item `encoding: "gzip"` payloads are gunzipped here; a single item's failure stays in its item.
8502
+ * Older agents answer `E_METHOD`; `#translate` turns that into `REMOTE_UNSUPPORTED` for the caller's
8503
+ * fallback path.
8504
+ */
8505
+ async batchRead(requests, signal) {
8506
+ await this.connect(signal);
8507
+ const client = this.#requireClient();
8508
+ let result;
8509
+ try {
8510
+ result = await client.request("fs.readMany", {
8511
+ requests: requests.map((request) => ({
8512
+ path: request.path,
8513
+ ...request.offset === void 0 ? {} : { offset: request.offset },
8514
+ ...request.maxBytes === void 0 ? {} : { maxBytes: request.maxBytes }
8515
+ }))
8516
+ // 帧层已对 ≥512 B 的帧做 zstd;这里再要求逐条压缩会让同一段文本被压两遍。
8517
+ }, this.#requestOptions(void 0, signal));
8518
+ } catch (error) {
8519
+ throw this.#translate(error);
8520
+ }
8521
+ const record2 = asRecord(result) ?? {};
8522
+ const items = Array.isArray(record2["items"]) ? record2["items"] : [];
8523
+ const chunks = [];
8524
+ for (const raw of items) {
8525
+ const item = asRecord(raw);
8526
+ if (item === void 0) continue;
8527
+ const path = typeof item["path"] === "string" ? item["path"] : "";
8528
+ const exists = item["exists"] === true;
8529
+ const dataBase64 = typeof item["dataBase64"] === "string" ? item["dataBase64"] : "";
8530
+ let bytes = new Uint8Array(0);
8531
+ if (dataBase64 !== "" && item["encoding"] !== "gzip") bytes = Buffer.from(dataBase64, "base64");
8532
+ else if (dataBase64 !== "") bytes = gunzipSync(Buffer.from(dataBase64, "base64"));
8533
+ chunks.push({
8534
+ path,
8535
+ exists,
8536
+ size: typeof item["size"] === "number" ? item["size"] : bytes.byteLength,
8537
+ version: typeof item["version"] === "string" ? item["version"] : "",
8538
+ truncated: item["truncated"] === true,
8539
+ bytes,
8540
+ ...typeof item["error"] === "string" && item["error"] !== "" ? { error: item["error"] } : {}
8541
+ });
8542
+ }
8543
+ return chunks;
8544
+ }
8199
8545
  /**
8200
8546
  * Read a remote file: `fs.read` (control frame) followed by its blob frame.
8201
8547
  *
@@ -8250,6 +8596,9 @@ var AgentTransport = class extends TransportBase {
8250
8596
  */
8251
8597
  async writeFile(request, signal) {
8252
8598
  await this.connect(signal);
8599
+ if (!(request.bytes instanceof Uint8Array)) {
8600
+ throw new SshDevError("CONNECTION_INVALID", "writeFile \u9700\u8981 Uint8Array \u7C7B\u578B\u7684 bytes", { path: request.path });
8601
+ }
8253
8602
  const client = this.#requireClient();
8254
8603
  const expected = request.expectedVersion ?? request.expectedSha256;
8255
8604
  let expectedVersion;
@@ -8873,7 +9222,9 @@ var AgentTransport = class extends TransportBase {
8873
9222
  ...request.include === void 0 ? {} : { include: [...request.include] },
8874
9223
  // Sent only when the caller asked for it: omitting the key leaves the agent's own default in force, so
8875
9224
  // an older agent that never heard of the field keeps behaving exactly as before.
8876
- ...request.noIgnore === void 0 ? {} : { noIgnore: request.noIgnore }
9225
+ ...request.noIgnore === void 0 ? {} : { noIgnore: request.noIgnore },
9226
+ ...request.offset === void 0 ? {} : { offset: request.offset },
9227
+ ...request.limit === void 0 ? {} : { limit: request.limit }
8877
9228
  };
8878
9229
  const result = await this.#rpc("search.grep", params, signal);
8879
9230
  return readSearchResult(result);
@@ -9241,6 +9592,8 @@ var defaultLauncher = async (options) => {
9241
9592
  };
9242
9593
  var ChildProcessSession = class {
9243
9594
  #child;
9595
+ /** One shared drain waiter: 16 concurrent requests must not add 16×3 stdin listeners. */
9596
+ #draining;
9244
9597
  constructor(child) {
9245
9598
  this.#child = child;
9246
9599
  this.#child.stdin?.on?.("error", () => void 0);
@@ -9261,12 +9614,14 @@ var ChildProcessSession = class {
9261
9614
  * Resolve once the pipe has drained (or ended).
9262
9615
  *
9263
9616
  * 不等 `'drain'` 会把整个文件堆进 Node 的流缓冲:实测 8 × 15 MiB 打到不读的对端,进程里堆了
9264
- * 120 MiB——那是在 agent 侧 192 MiB 上限**之外**的另一份。end/error 也要收,否则对端一死就永远等。
9617
+ * 120 MiB——那是在 agent 侧窗口/排队上限**之外**的另一份。end/error 也要收,否则对端一死就永远等。
9265
9618
  */
9266
9619
  drain() {
9267
9620
  const stdin = this.#child.stdin;
9268
9621
  if (stdin === void 0 || stdin === null || stdin.once === void 0) return Promise.resolve();
9269
- return new Promise((resolve3) => {
9622
+ const existing = this.#draining;
9623
+ if (existing !== void 0) return existing;
9624
+ const pending2 = new Promise((resolve3) => {
9270
9625
  let settled = false;
9271
9626
  const done = () => {
9272
9627
  if (settled) return;
@@ -9274,12 +9629,15 @@ var ChildProcessSession = class {
9274
9629
  stdin.removeListener?.("drain", done);
9275
9630
  stdin.removeListener?.("error", done);
9276
9631
  stdin.removeListener?.("close", done);
9632
+ this.#draining = void 0;
9277
9633
  resolve3();
9278
9634
  };
9279
9635
  stdin.once?.("drain", done);
9280
9636
  stdin.once?.("error", done);
9281
9637
  stdin.once?.("close", done);
9282
9638
  });
9639
+ this.#draining = pending2;
9640
+ return pending2;
9283
9641
  }
9284
9642
  onData(callback) {
9285
9643
  this.#child.stdout?.on("data", callback);
@@ -10363,9 +10721,25 @@ function parentRemotePath(absolute) {
10363
10721
  return remoteDirname(absolute);
10364
10722
  }
10365
10723
  var UTF8 = new TextDecoder("utf-8", { ignoreBOM: true });
10724
+ var UTF8_FATAL = new TextDecoder("utf-8", { ignoreBOM: true, fatal: true });
10725
+ function toBackendText(bytes, size, truncated, version) {
10726
+ try {
10727
+ return { text: UTF8_FATAL.decode(bytes), size, truncated, version };
10728
+ } catch {
10729
+ return {
10730
+ text: UTF8.decode(bytes),
10731
+ size,
10732
+ truncated,
10733
+ version,
10734
+ binary: true,
10735
+ dataBase64: Buffer.from(bytes).toString("base64")
10736
+ };
10737
+ }
10738
+ }
10366
10739
  var POLL_LISTING_SHARE_TTL_MS = 1e3;
10367
10740
  var STAT_CACHE_TTL_MS = 5e3;
10368
10741
  var STAT_CACHE_MAX_ENTRIES = 2048;
10742
+ var POLL_LISTING_MAX_ENTRIES = 64;
10369
10743
  var DEFAULT_CACHE = { ttlMs: 5e3, maxEntries: 256, maxBytes: 8 * 1024 * 1024 };
10370
10744
  var STAT_MARKER = "# DSH_STAT";
10371
10745
  var BATCH_MARKER = "#DSH_F ";
@@ -10383,12 +10757,7 @@ function parseBatchRead(stdout, cap) {
10383
10757
  missing.push(path);
10384
10758
  } else {
10385
10759
  const bytes = base64Body === "" ? Buffer.alloc(0) : Buffer.from(base64Body, "base64");
10386
- chunks.set(path, {
10387
- text: bytes.toString("utf8"),
10388
- size,
10389
- truncated: size > cap,
10390
- version: `${size}:${Math.floor(mtimeSeconds)}`
10391
- });
10760
+ chunks.set(path, toBackendText(bytes, size, size > cap, `${size}:${Math.floor(mtimeSeconds)}`));
10392
10761
  }
10393
10762
  path = void 0;
10394
10763
  size = void 0;
@@ -10727,16 +11096,31 @@ var RemoteBackend = class {
10727
11096
  this.#invalidatePath(absolute);
10728
11097
  return { bytes: result.bytes, version: result.version, hunks: result.hunks };
10729
11098
  }
11099
+ async repoSummary(root, signal) {
11100
+ const transport = this.#options.repoSummaryTransport === void 0 ? await this.#transport() : await this.#options.repoSummaryTransport();
11101
+ if (transport.repoSummary === void 0) {
11102
+ throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\u6CA1\u6709 repo.summary\uFF08\u4EE3\u7406\u592A\u65E7\uFF0C\u91CD\u88C5/\u5347\u7EA7\u4EE3\u7406\uFF09", { root });
11103
+ }
11104
+ return transport.repoSummary(root, signal);
11105
+ }
11106
+ async patchMany(edits, atomic, signal) {
11107
+ const transport = await this.#transport();
11108
+ if (transport.patchMany === void 0) {
11109
+ throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\u6CA1\u6709 fs.patchMany\uFF08\u4EE3\u7406\u592A\u65E7\uFF0C\u91CD\u88C5/\u5347\u7EA7\u4EE3\u7406\uFF09", { count: edits.length });
11110
+ }
11111
+ const result = await transport.patchMany(edits, atomic, signal);
11112
+ if (result["rolledBack"] !== true) {
11113
+ for (const edit of edits) this.#invalidatePath(edit.path);
11114
+ }
11115
+ return result;
11116
+ }
10730
11117
  async readText(absolute, maxBytes, signal) {
10731
11118
  const chunk = await this.readBytes(absolute, maxBytes, signal);
10732
11119
  return {
10733
11120
  // `TextDecoder` 直接解码字节,`Buffer.from(...).toString()` 要先**整份拷贝**再解码——9 MiB 的文件
10734
11121
  // 就是白白多一次 9 MiB 的 memcpy。`fs-provider` 早就这么做了,这条读路径漏了。
10735
11122
  // `ignoreBOM: true`:默认行为会把 BOM 吃掉,而 `Buffer#toString` 不会,两者必须一致。
10736
- text: UTF8.decode(chunk.bytes),
10737
- size: chunk.size,
10738
- truncated: chunk.truncated,
10739
- version: chunk.version
11123
+ ...toBackendText(chunk.bytes, chunk.size, chunk.truncated, chunk.version)
10740
11124
  };
10741
11125
  }
10742
11126
  /**
@@ -10785,6 +11169,19 @@ var RemoteBackend = class {
10785
11169
  async #readManyUncached(absolutePaths, options) {
10786
11170
  const transport = await this.#transport();
10787
11171
  const cap = Math.max(0, Math.min(Math.floor(options.maxBytesEach), this.#maxReadBytes));
11172
+ if (transport.batchRead !== void 0) {
11173
+ const chunks2 = await transport.batchRead(
11174
+ absolutePaths.map((path) => ({ path, maxBytes: cap })),
11175
+ options.signal
11176
+ );
11177
+ const texts = absolutePaths.map((path, index) => {
11178
+ const chunk = chunks2[index];
11179
+ if (chunk === void 0 || !chunk.exists || chunk.error !== void 0) return void 0;
11180
+ return toBackendText(chunk.bytes, chunk.size, chunk.truncated, chunk.version);
11181
+ });
11182
+ const missing2 = absolutePaths.filter((path, index) => texts[index] === void 0);
11183
+ return { chunks: texts, missing: [...new Set(missing2)] };
11184
+ }
10788
11185
  const unframeable = absolutePaths.filter((path) => path.includes("\n") || path.includes("\r"));
10789
11186
  const unframeableSet = new Set(unframeable);
10790
11187
  const frameable = absolutePaths.filter((path) => !unframeableSet.has(path));
@@ -10943,7 +11340,7 @@ var RemoteBackend = class {
10943
11340
  * `CANCELLED` when aborted during setup.
10944
11341
  */
10945
11342
  async watch(absolute, changed, signal) {
10946
- const transport = await this.#transport();
11343
+ const transport = this.#options.watchTransport === void 0 ? await this.#transport() : await this.#options.watchTransport();
10947
11344
  if (!isWatchCapable(transport)) {
10948
11345
  return this.#watchByPolling(absolute, changed, signal);
10949
11346
  }
@@ -11081,6 +11478,10 @@ var RemoteBackend = class {
11081
11478
  })();
11082
11479
  const entry = { at: now, promise };
11083
11480
  this.#pollListings.set(root, entry);
11481
+ if (this.#pollListings.size > POLL_LISTING_MAX_ENTRIES) {
11482
+ const oldest = this.#pollListings.keys().next();
11483
+ if (!oldest.done) this.#pollListings.delete(oldest.value);
11484
+ }
11084
11485
  promise.catch(() => {
11085
11486
  if (this.#pollListings.get(root) === entry) this.#pollListings.delete(root);
11086
11487
  });
@@ -13252,6 +13653,46 @@ function defaultAgentManifestPath(moduleUrl) {
13252
13653
 
13253
13654
  // src/wiring/connections.ts
13254
13655
  var degradedRetryMs = 3e4;
13656
+ var LANE_RETRY_AFTER_MS = 15e3;
13657
+ function createTransportLaneRouter(options) {
13658
+ const retryAfter = /* @__PURE__ */ new Map();
13659
+ return {
13660
+ enabled: options.enabled,
13661
+ releaseSession: async (sessionKey2) => {
13662
+ if (!options.enabled) return;
13663
+ const suffix = `\0stateful\0${sessionKey2}`;
13664
+ for (const key of retryAfter.keys()) {
13665
+ if (key.endsWith(suffix)) retryAfter.delete(key);
13666
+ }
13667
+ await options.transports.releaseSession(sessionKey2);
13668
+ },
13669
+ statefulSessions: () => options.enabled ? options.transports.statefulSessions() : [],
13670
+ pruneStatefulSessions: async (isLive) => {
13671
+ if (!options.enabled) return;
13672
+ const now = Date.now();
13673
+ for (const [key, until] of retryAfter) {
13674
+ if (until <= now) retryAfter.delete(key);
13675
+ }
13676
+ await options.transports.pruneStatefulSessions(isLive);
13677
+ },
13678
+ async acquire(profile, lane = "interactive", signal, sessionKey2) {
13679
+ if (!options.enabled || lane === "interactive") return options.transports.acquire(profile, signal);
13680
+ const laneOptions = { lane, ...sessionKey2 === void 0 ? {} : { sessionKey: sessionKey2 } };
13681
+ const laneId = `${profile.id}\0${lane}\0${sessionKey2 ?? ""}`;
13682
+ if ((retryAfter.get(laneId) ?? 0) > Date.now()) return options.transports.acquire(profile, signal);
13683
+ try {
13684
+ const acquired = await options.transports.acquire(profile, laneOptions, signal);
13685
+ retryAfter.delete(laneId);
13686
+ return acquired;
13687
+ } catch (error) {
13688
+ if (signal?.aborted === true || isSshDevError(error) && error.code === "CANCELLED") throw error;
13689
+ retryAfter.set(laneId, Date.now() + LANE_RETRY_AFTER_MS);
13690
+ options.onFallback?.({ connectionId: profile.id, lane, error });
13691
+ return options.transports.acquire(profile, signal);
13692
+ }
13693
+ }
13694
+ };
13695
+ }
13255
13696
  function pruneBackendConnections(owners, liveRootKeys) {
13256
13697
  for (const backendId of [...owners.keys()]) {
13257
13698
  if (!liveRootKeys.has(backendId)) owners.delete(backendId);
@@ -13363,6 +13804,7 @@ function wireConnections(ctx, config) {
13363
13804
  const transports = new TransportService({
13364
13805
  selector,
13365
13806
  preflight: agentBridge.preflight,
13807
+ statefulLaneLimit: limits.statefulLanes,
13366
13808
  factories: {
13367
13809
  createAlias: aliasFactory,
13368
13810
  createSsh2: (profile) => new Ssh2Transport({
@@ -13384,15 +13826,20 @@ function wireConnections(ctx, config) {
13384
13826
  });
13385
13827
  }
13386
13828
  }),
13387
- createAgent: (profile) => {
13829
+ createAgent: (profile, _decision, lane, sessionKey2) => {
13388
13830
  const outcome = agentBridge.installationFor(profile.id);
13389
13831
  if (outcome === void 0 || outcome.kind !== "agent") {
13390
13832
  throw new SshDevError("REMOTE_UNSUPPORTED", "Tier 2 \u4EE3\u7406\u5C1A\u672A\u5C31\u7EEA\uFF1A\u5B89\u88C5\u9636\u68AF\u672A\u6210\u529F\uFF0C\u4E0D\u80FD\u521B\u5EFA\u4EE3\u7406\u4F20\u8F93", { connectionId: profile.id });
13391
13833
  }
13392
13834
  const helper = askpass.envFor(profile);
13393
13835
  const passwordOptions = passwordAskpassOptions(profile, helper);
13836
+ const maxInFlight = lane === void 0 || lane === "interactive" ? void 0 : 8;
13394
13837
  return new AgentTransport({
13395
13838
  connectionId: profile.id,
13839
+ ...maxInFlight === void 0 ? {} : { maxInFlight },
13840
+ // Stateful lanes get a stable agent session id: their jobs/LSP/watch ownership must not
13841
+ // collapse into the daemon's default session (or two DSH sessions would share one quota).
13842
+ ...sessionKey2 === void 0 || sessionKey2 === "" ? {} : { sessionId: sessionKey2 },
13396
13843
  agentPath: outcome.remotePath,
13397
13844
  // Same credential source as the Tier 1 transport: an agent session that authenticates differently
13398
13845
  // from the ladder that installed it is exactly the drift the agent-path e2e caught. For a password
@@ -13413,6 +13860,15 @@ function wireConnections(ctx, config) {
13413
13860
  }
13414
13861
  }
13415
13862
  });
13863
+ const transportLanes = createTransportLaneRouter({
13864
+ transports,
13865
+ enabled: limits.transportLanes !== false,
13866
+ onFallback: ({ connectionId, lane, error }) => {
13867
+ ctx.logger?.warn?.(
13868
+ `[dsh-over-ssh] ${connectionId} \u7684 ${lane} lane \u4E0D\u53EF\u7528\uFF0C\u56DE\u9000\u5230 interactive\uFF1A${error instanceof Error ? error.message : String(error)}`
13869
+ );
13870
+ }
13871
+ });
13416
13872
  const remoteHome = createRemoteHomeResolver({
13417
13873
  probe: async (connectionId) => {
13418
13874
  const profile = profiles.get(connectionId);
@@ -13474,6 +13930,26 @@ function wireConnections(ctx, config) {
13474
13930
  }
13475
13931
  return (await transports.acquire(profile)).transport;
13476
13932
  },
13933
+ // `repo.summary` is compute work (it may walk the tree and run git). It runs on the compute lane when
13934
+ // available; reads/writes/listing stay interactive, watch has its own stateful lane below.
13935
+ repoSummaryTransport: async () => {
13936
+ const profile = profiles.get(connectionId);
13937
+ if (profile === void 0) {
13938
+ throw new SshDevError("CONNECTION_NOT_FOUND", `\u8FDC\u7AEF\u6839\u6307\u5411\u672A\u914D\u7F6E\u7684\u8FDE\u63A5\uFF1A${connectionId}`, { root: rootKey(root) });
13939
+ }
13940
+ return (await transportLanes.acquire(profile, "compute")).transport;
13941
+ },
13942
+ // watch.* is long-lived state. Give it a stateful lane per DSH session when the platform's
13943
+ // AsyncLocalStorage carries one; fall back to one shared `watch` lane for agentless callers.
13944
+ watchTransport: async () => {
13945
+ const profile = profiles.get(connectionId);
13946
+ if (profile === void 0) {
13947
+ throw new SshDevError("CONNECTION_NOT_FOUND", `\u8FDC\u7AEF\u6839\u6307\u5411\u672A\u914D\u7F6E\u7684\u8FDE\u63A5\uFF1A${connectionId}`, { root: rootKey(root) });
13948
+ }
13949
+ const sessionId = currentSessionId();
13950
+ const sessionKey2 = sessionId === void 0 || sessionId === "" ? "watch" : `watch:${sessionId}`;
13951
+ return (await transportLanes.acquire(profile, "stateful", void 0, sessionKey2)).transport;
13952
+ },
13477
13953
  // FR-2.7 is a budget, so it has to be reachable from Config: the defaults are the requirement's
13478
13954
  // numbers, and an operator can turn retention off per deployment without a rebuild.
13479
13955
  cache: {
@@ -13577,7 +14053,7 @@ function wireConnections(ctx, config) {
13577
14053
  // Tier 2 when this connection actually runs an agent that reports index support; otherwise this
13578
14054
  // throws and RemoteIndex falls through to the shell path (which is the floor).
13579
14055
  agentScan: async (root, request) => {
13580
- const { transport } = await transports.acquire(profile);
14056
+ const { transport } = await transportLanes.acquire(profile, "compute");
13581
14057
  if (!(transport instanceof AgentTransport) || transport.agentCapabilities.index !== true) {
13582
14058
  throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u8FDE\u63A5\u6CA1\u6709\u53EF\u7528\u7684\u4EE3\u7406\u7D22\u5F15", { connectionId });
13583
14059
  }
@@ -13594,7 +14070,7 @@ function wireConnections(ctx, config) {
13594
14070
  // `index.refresh` answers E_METHOD, which becomes REMOTE_UNSUPPORTED and makes RemoteIndex fall back
13595
14071
  // to `agentScan` - so wiring this can never regress a working connection into a failing one.
13596
14072
  agentRefresh: async (root, request) => {
13597
- const { transport } = await transports.acquire(profile);
14073
+ const { transport } = await transportLanes.acquire(profile, "compute");
13598
14074
  if (!(transport instanceof AgentTransport) || transport.agentCapabilities.index !== true) {
13599
14075
  throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u8FDE\u63A5\u6CA1\u6709\u53EF\u7528\u7684\u4EE3\u7406\u7D22\u5F15", { connectionId });
13600
14076
  }
@@ -13607,14 +14083,14 @@ function wireConnections(ctx, config) {
13607
14083
  return { paths: scan.entries.map((entry) => entry.path), strategy: scan.strategy, truncated: scan.truncated };
13608
14084
  },
13609
14085
  exec: async (command, options) => {
13610
- const { transport } = await transports.acquire(profile);
14086
+ const { transport } = await transportLanes.acquire(profile, "compute");
13611
14087
  const result = await transport.exec(
13612
14088
  { script: command, maxBytes: options.maxBytes, timeoutMs: options.timeoutMs },
13613
14089
  options.signal
13614
14090
  );
13615
14091
  return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, truncated: result.truncated };
13616
14092
  },
13617
- capabilities: async () => (await transports.acquire(profile)).transport.capabilities
14093
+ capabilities: async () => (await transportLanes.acquire(profile, "compute")).transport.capabilities
13618
14094
  }
13619
14095
  });
13620
14096
  indexes.set(connectionId, created);
@@ -13629,7 +14105,7 @@ function wireConnections(ctx, config) {
13629
14105
  }));
13630
14106
  }
13631
14107
  };
13632
- return { ctx, config, limits, anchorRootDir, profiles, selector, askpass, installations, agentBridge, transports, remoteHome, forgetBackendsOf, browseBackends, readBytesRange, registry, directory, indexes, indexFor, index, cacheBudget };
14108
+ return { ctx, config, limits, anchorRootDir, profiles, selector, askpass, installations, agentBridge, transports, transportLanes, remoteHome, forgetBackendsOf, browseBackends, readBytesRange, registry, directory, indexes, indexFor, index, cacheBudget };
13633
14109
  }
13634
14110
  function readLiveSessions(ctx) {
13635
14111
  let list;
@@ -16023,7 +16499,13 @@ function queryOf(url) {
16023
16499
  }
16024
16500
  function visibleToSession(row, sessionIds, workspace) {
16025
16501
  const sessions = row.sessions ?? [];
16026
- if (sessions.length > 0) return sessions.some((id) => sessionIds.has(id));
16502
+ if (sessions.length > 0) {
16503
+ if (!sessions.some((id) => sessionIds.has(id))) return false;
16504
+ if (workspace !== "" && row.workspaces !== void 0 && row.workspaces.length > 0) {
16505
+ return row.workspaces.some((candidate) => sameWorkspacePath(candidate, workspace));
16506
+ }
16507
+ return true;
16508
+ }
16027
16509
  if (workspace === "") return false;
16028
16510
  return (row.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, workspace));
16029
16511
  }
@@ -16974,7 +17456,7 @@ function retireBrowseBackend(browseBackends, connectionId) {
16974
17456
  const backend = browseBackends.get(connectionId);
16975
17457
  if (backend === void 0) return;
16976
17458
  browseBackends.delete(connectionId);
16977
- void backend.dispose?.();
17459
+ void Promise.resolve().then(() => backend.dispose?.()).catch(() => void 0);
16978
17460
  }
16979
17461
  function browsingSeams(env) {
16980
17462
  const { profiles, transports, remoteHome, browseBackends } = env;
@@ -17109,10 +17591,11 @@ function installClientDataChannel(ctx) {
17109
17591
  }
17110
17592
  }
17111
17593
  function provideHostServices(env) {
17112
- const { ctx, limits, registry, directory, transports, index, transferService, binService, sessionAdmin, sessionRoots, readBytesRange } = env;
17594
+ const { ctx, limits, registry, directory, transports, transportLanes, index, transferService, binService, sessionAdmin, sessionRoots, readBytesRange } = env;
17113
17595
  const disposeRoots = ctx.provide("sshDevRoots", registry);
17114
17596
  const disposeConnections = ctx.provide("sshDevConnections", directory);
17115
17597
  const disposeTransports = ctx.provide("sshDevTransports", transports);
17598
+ const disposeTransportLanes = ctx.provide("sshDevTransportLanes", transportLanes);
17116
17599
  const disposeIndex = ctx.provide("sshDevIndex", index);
17117
17600
  const disposeTransfer = ctx.provide("sshDevTransfer", transferService);
17118
17601
  const disposeBin = ctx.provide("sshDevRecycleBin", binService);
@@ -17123,7 +17606,7 @@ function provideHostServices(env) {
17123
17606
  ...limits.rangeChunkBytes === void 0 ? {} : { rangeChunkBytes: limits.rangeChunkBytes }
17124
17607
  });
17125
17608
  const disposeSessionRoots = ctx.provide("sshDevSessionRoots", sessionRoots);
17126
- return { disposeRoots, disposeConnections, disposeTransports, disposeIndex, disposeTransfer, disposeBin, disposeSessionTools, disposeRange, disposeSessionRoots };
17609
+ return { disposeRoots, disposeConnections, disposeTransports, disposeTransportLanes, disposeIndex, disposeTransfer, disposeBin, disposeSessionTools, disposeRange, disposeSessionRoots };
17127
17610
  }
17128
17611
  function provideAgentStatus(env) {
17129
17612
  const { ctx, agentBridge, installations, askpass, remoteHome } = env;
@@ -17198,8 +17681,15 @@ function scheduleActivationDiagnostics(env) {
17198
17681
  return activationTimers;
17199
17682
  }
17200
17683
  function watchSessionRemovals(env) {
17201
- const { ctx, rootsWiring, onSessionRemoved } = env;
17684
+ const { ctx, rootsWiring, onSessionRemoved, transportLanes } = env;
17202
17685
  onSessionRemoved((sessionId) => {
17686
+ for (const sessionKey2 of [sessionId, `watch:${sessionId}`]) {
17687
+ void transportLanes.releaseSession(sessionKey2).catch((error) => {
17688
+ ctx.logger?.warn?.(
17689
+ `dsh-over-ssh: \u56DE\u6536\u5DF2\u5220\u4F1A\u8BDD ${sessionKey2} \u7684 stateful transport \u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`
17690
+ );
17691
+ });
17692
+ }
17203
17693
  void rootsWiring.pruneSession(sessionId).catch((error) => {
17204
17694
  ctx.logger?.warn?.(
17205
17695
  `dsh-over-ssh: \u6E05\u7406\u5DF2\u5220\u4F1A\u8BDD ${sessionId} \u7684\u6839\u8BB0\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`
@@ -17237,6 +17727,7 @@ function createTeardown(env) {
17237
17727
  disposeAgent();
17238
17728
  hostServices.disposeIndex();
17239
17729
  hostServices.disposeTransports();
17730
+ hostServices.disposeTransportLanes();
17240
17731
  hostServices.disposeConnections();
17241
17732
  hostServices.disposeRoots();
17242
17733
  await tools.transports.disposeAll();
@@ -17254,6 +17745,7 @@ function wireFs(env) {
17254
17745
  registry,
17255
17746
  directory: env.directory,
17256
17747
  transports,
17748
+ transportLanes: env.transportLanes,
17257
17749
  index: env.index,
17258
17750
  transferService: env.transferService,
17259
17751
  binService: env.binService,
@@ -17281,7 +17773,24 @@ function wireFs(env) {
17281
17773
  workspacePathOf: (sessionId) => readSessionCwd(ctx, sessionId)
17282
17774
  });
17283
17775
  const activationTimers = scheduleActivationDiagnostics({ ctx, ...routes });
17284
- watchSessionRemovals({ ctx, rootsWiring: routes.rootsWiring, onSessionRemoved: env.onSessionRemoved });
17776
+ watchSessionRemovals({ ctx, rootsWiring: routes.rootsWiring, onSessionRemoved: env.onSessionRemoved, transportLanes: env.transportLanes });
17777
+ const laneJanitor = setInterval(() => {
17778
+ try {
17779
+ const sessions = ctx.get("sessions");
17780
+ if (sessions?.get === void 0) return;
17781
+ void env.transportLanes.pruneStatefulSessions((sessionKey2) => {
17782
+ if (sessionKey2 === "watch") return true;
17783
+ const target = sessionKey2.startsWith("watch:") ? sessionKey2.slice("watch:".length) : sessionKey2;
17784
+ return sessions.get?.(target) !== void 0;
17785
+ }).catch((error) => {
17786
+ ctx.logger?.warn?.(`over-ssh: \u56DE\u6536\u5931\u8054\u4F1A\u8BDD\u7684 stateful lane \u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
17787
+ });
17788
+ } catch (error) {
17789
+ ctx.logger?.warn?.(`over-ssh: \u8BFB\u53D6\u6D3B\u4F1A\u8BDD\u8868\u5931\u8D25\uFF0C\u8DF3\u8FC7 stateful lane \u6E05\u7406\uFF1A${error instanceof Error ? error.message : String(error)}`);
17790
+ }
17791
+ }, 6e4);
17792
+ laneJanitor.unref?.();
17793
+ activationTimers.push(laneJanitor);
17285
17794
  mergePersistedRoots({ ctx, rootsWiring: routes.rootsWiring });
17286
17795
  return createTeardown({ activationTimers, routes, hostServices, disposeAgent, tools: env, disposeSessionDirectories });
17287
17796
  });
@@ -17386,7 +17895,7 @@ function createTransferDeps(deps) {
17386
17895
  */
17387
17896
  async sha256Remote(absolute, signal) {
17388
17897
  const { connectionId } = requireRemote(deps, absolute);
17389
- const transport = await deps.transportFor(connectionId);
17898
+ const transport = await deps.transportFor(connectionId, "bulk");
17390
17899
  if (transport.hashFile !== void 0) {
17391
17900
  try {
17392
17901
  const hashed = await transport.hashFile({ path: absolute }, signal);
@@ -17404,7 +17913,7 @@ function createTransferDeps(deps) {
17404
17913
  },
17405
17914
  async readRemote(absolute, options) {
17406
17915
  const { connectionId } = requireRemote(deps, absolute);
17407
- const transport = await deps.transportFor(connectionId);
17916
+ const transport = await deps.transportFor(connectionId, "bulk");
17408
17917
  const offset = Math.max(0, options.offset ?? 0);
17409
17918
  if (transport.readFileRange !== void 0) {
17410
17919
  const chunk2 = await transport.readFileRange(
@@ -17418,7 +17927,7 @@ function createTransferDeps(deps) {
17418
17927
  },
17419
17928
  async writeRemote(absolute, bytes, options) {
17420
17929
  const { connectionId } = requireRemote(deps, absolute);
17421
- const transport = await deps.transportFor(connectionId);
17930
+ const transport = await deps.transportFor(connectionId, "interactive");
17422
17931
  if (options.offset === void 0) {
17423
17932
  const outcome2 = await transport.writeFile(
17424
17933
  {
@@ -17441,15 +17950,35 @@ function createTransferDeps(deps) {
17441
17950
  return outcome;
17442
17951
  },
17443
17952
  async statRemote(absolute) {
17444
- const { target } = requireRemote(deps, absolute);
17445
- return target.backend.stat(target.absolute ?? absolute);
17953
+ const { target, connectionId } = requireRemote(deps, absolute);
17954
+ const resolved = target.absolute ?? absolute;
17955
+ try {
17956
+ const transport = await deps.transportFor(connectionId, "bulk");
17957
+ if (transport.statPath !== void 0) {
17958
+ const native = await transport.statPath(resolved);
17959
+ if (!(native.exists && native.version === "")) {
17960
+ return {
17961
+ exists: native.exists,
17962
+ isDirectory: native.isDirectory,
17963
+ isFile: native.isFile,
17964
+ isSymlink: false,
17965
+ size: native.size,
17966
+ mtimeMs: native.mtimeMs,
17967
+ version: native.version
17968
+ };
17969
+ }
17970
+ }
17971
+ } catch (error) {
17972
+ if (isSshDevError(error) && error.code === "CANCELLED") throw error;
17973
+ }
17974
+ return target.backend.stat(resolved);
17446
17975
  }
17447
17976
  };
17448
17977
  }
17449
17978
  function createRecycleBinDeps(deps) {
17450
17979
  const remoteMove = async (from, to, signal) => {
17451
17980
  const { connectionId } = requireRemote(deps, from);
17452
- const transport = await deps.transportFor(connectionId);
17981
+ const transport = await deps.transportFor(connectionId, "interactive");
17453
17982
  if (transport.rename === void 0) {
17454
17983
  throw new SshDevError("REMOTE_UNSUPPORTED", '\u8BE5\u4F20\u8F93\u4E0D\u652F\u6301 rename\uFF0C\u56DE\u6536\u7AD9\u65E0\u6CD5\u4FDD\u8BC1"\u5148\u79FB\u52A8\u540E\u786E\u8BA4"', { from, to });
17455
17984
  }
@@ -17457,7 +17986,7 @@ function createRecycleBinDeps(deps) {
17457
17986
  };
17458
17987
  const remoteRemove = async (path, options) => {
17459
17988
  const { connectionId } = requireRemote(deps, path);
17460
- const transport = await deps.transportFor(connectionId);
17989
+ const transport = await deps.transportFor(connectionId, "interactive");
17461
17990
  if (transport.remove === void 0) {
17462
17991
  throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\u4E0D\u652F\u6301 remove\uFF0C\u65E0\u6CD5\u6C38\u4E45\u5220\u9664", { path });
17463
17992
  }
@@ -17827,7 +18356,7 @@ function pruneStaleLocks(locks, liveRootKeys) {
17827
18356
  }
17828
18357
  }
17829
18358
  function wireRoots(env) {
17830
- const { ctx, limits, profiles, transports, registry } = env;
18359
+ const { ctx, limits, profiles, transports, transportLanes, registry } = env;
17831
18360
  const binRootResolver = createBinRootResolver({
17832
18361
  addHiddenRoot: (root) => registry.addHidden(root),
17833
18362
  removeHiddenRoot: (key) => {
@@ -17883,7 +18412,7 @@ function wireRoots(env) {
17883
18412
  const worldDeps = {
17884
18413
  routed: registry.routed,
17885
18414
  // Local roots resolve through the same router, so a path never reaches the wrong world.
17886
- transportFor: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
18415
+ transportFor: async (connectionId, lane = "interactive") => (await transportLanes.acquire(requireProfile(profiles, connectionId), lane)).transport,
17887
18416
  // ADR-0011: the bin lives **beside** the root (outside the caller's repository) and is registered as a
17888
18417
  // hidden root so the router can reach it. `binRootFor` stays synchronous by answering from a decision made
17889
18418
  // when the root was registered; see `binRootResolver`.
@@ -17982,6 +18511,8 @@ function toExecOutcome(result) {
17982
18511
  exitCode: result.exitCode,
17983
18512
  stdout: result.stdout,
17984
18513
  stderr: result.stderr,
18514
+ ...result.stdoutBase64 === void 0 ? {} : { stdoutBase64: result.stdoutBase64 },
18515
+ ...result.stderrBase64 === void 0 ? {} : { stderrBase64: result.stderrBase64 },
17985
18516
  durationMs: result.durationMs,
17986
18517
  truncated: result.truncated,
17987
18518
  // 审计 0052 §8: these four used to be dropped right here, so the renderer's per-stream and spill logic
@@ -18009,20 +18540,30 @@ function createRemoteToolBackend(deps) {
18009
18540
  ...resolveForConnection === void 0 ? {} : { resolveForConnection: (connectionId, cwd) => resolveForConnection(connectionId, cwd) },
18010
18541
  ...resolveSessionRoot === void 0 ? {} : { resolveSessionRoot },
18011
18542
  async grep(target, request, signal) {
18012
- const transport = await deps.acquire(target.connectionId);
18543
+ const transport = await deps.acquire(target.connectionId, "compute");
18013
18544
  const path = joinUnderRoot(target.root, request.path);
18545
+ const offset = request.offset ?? 0;
18546
+ if (offset > 0 && (!isAgentSearchCapable(transport) || transport.agentCapabilities.grepPaging !== true)) {
18547
+ throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\u6216\u4EE3\u7406\u4E0D\u652F\u6301\u641C\u7D22\u5206\u9875\uFF08offset\uFF09\uFF0C\u8BF7\u51CF\u5C11\u7ED3\u679C\u6216\u6536\u7A84\u67E5\u8BE2", {
18548
+ offset,
18549
+ connectionId: target.connectionId
18550
+ });
18551
+ }
18014
18552
  if (isAgentSearchCapable(transport)) {
18015
18553
  const result2 = await transport.searchGrep(
18016
18554
  {
18017
18555
  paths: [path],
18018
18556
  query: request.pattern,
18019
18557
  literal: !request.regex,
18558
+ ...offset === 0 ? {} : { offset },
18559
+ // 本页条数交给代理切页;扫描上限多要 1 条,才能判断"后面还有没有下一页"。
18560
+ limit: request.maxResults,
18561
+ maxResults: offset + request.maxResults + 1,
18020
18562
  // Case sensitivity and ignore rules are both two-tier contracts: the tier that answers has to
18021
18563
  // receive the same intent the Tier 1 `grepCommand` expresses, or one tool call would mean two
18022
18564
  // things depending on which host has ripgrep (audit 0064 P3).
18023
18565
  ignoreCase: request.caseSensitive !== true,
18024
18566
  ...request.noIgnore === true ? { noIgnore: true } : {},
18025
- maxResults: request.maxResults,
18026
18567
  // Audit 0052 P10: symmetric with the Tier 1 `grepCommand` `--include=` filters. Without this the
18027
18568
  // **degraded** path had the capability the preferred path lacked.
18028
18569
  ...request.include === void 0 ? {} : { include: [...request.include] }
@@ -18034,7 +18575,9 @@ function createRemoteToolBackend(deps) {
18034
18575
  truncated: result2.truncated,
18035
18576
  strategy: `agent:${result2.strategy}`,
18036
18577
  // Audit 0052 P7: the agent is the only tier that can name the bound that stopped the scan.
18037
- truncation: readSearchTruncation(result2)
18578
+ truncation: readSearchTruncation(result2),
18579
+ ...result2.total === null ? {} : { total: result2.total },
18580
+ ...result2.nextOffset === null ? { nextOffset: null } : { nextOffset: result2.nextOffset }
18038
18581
  };
18039
18582
  }
18040
18583
  const result = await transport.exec({ script: grepCommand(request, target.root), maxBytes: MAX_OUTPUT_BYTES }, signal);
@@ -18066,7 +18609,7 @@ function createRemoteToolBackend(deps) {
18066
18609
  async glob(target, request, signal) {
18067
18610
  const base = joinUnderRoot(target.root, request.path);
18068
18611
  compileGlob(request.pattern);
18069
- const transport = await deps.acquire(target.connectionId);
18612
+ const transport = await deps.acquire(target.connectionId, "compute");
18070
18613
  if (transport.globPaths !== void 0) {
18071
18614
  try {
18072
18615
  const native = await transport.globPaths({
@@ -18088,8 +18631,8 @@ function createRemoteToolBackend(deps) {
18088
18631
  strategy: enumerated.source
18089
18632
  };
18090
18633
  },
18091
- async exec(target, request, signal) {
18092
- const transport = await deps.acquire(target.connectionId);
18634
+ async exec(target, request, signal, lane = "interactive") {
18635
+ const transport = await deps.acquire(target.connectionId, lane);
18093
18636
  const writeRoots = deps.execWriteRoots?.(target.connectionId, target.root);
18094
18637
  const result = await transport.exec(
18095
18638
  {
@@ -18255,7 +18798,11 @@ function remoteToolDefinitions(backend) {
18255
18798
  items: { type: "string" },
18256
18799
  description: 'Optional filename filters, e.g. `["*.cpp","*.h"]`. Applied to file names, so a whole-tree search for a symbol can be narrowed to one language without hand-picking directories. At most 16 entries.'
18257
18800
  },
18258
- maxResults: { type: "number", description: "Maximum matches (default 200, hard cap 200). The result reports when the cap was reached." }
18801
+ maxResults: { type: "number", description: "Maximum matches (default 200, hard cap 200). The result reports when the cap was reached." },
18802
+ offset: {
18803
+ type: "number",
18804
+ description: "0-based match index to start from (default 0). Use `nextOffset` from a previous page to continue. Paging needs the agent tier; other tiers refuse instead of silently returning page 0."
18805
+ }
18259
18806
  },
18260
18807
  output: {
18261
18808
  schema: {
@@ -18270,6 +18817,9 @@ function remoteToolDefinitions(backend) {
18270
18817
  include: { type: "array", required: true, items: { type: "string" } },
18271
18818
  truncatedReasons: { type: "array", required: true, items: { type: "string" } },
18272
18819
  filesFiltered: { type: "integer" },
18820
+ total: { type: "integer", description: "Matches found before paging (when the agent reports it)." },
18821
+ nextOffset: { type: "integer", description: "Offset of the next page, or null when there is no next page." },
18822
+ offset: { type: "integer", required: true, description: "The 0-based page offset this result was produced with." },
18273
18823
  matches: {
18274
18824
  type: "array",
18275
18825
  required: true,
@@ -18303,7 +18853,10 @@ function remoteToolDefinitions(backend) {
18303
18853
  include: [...parsed.include ?? []],
18304
18854
  truncatedReasons: [...truncation.reasons],
18305
18855
  ...truncation.filesFiltered === void 0 ? {} : { filesFiltered: truncation.filesFiltered },
18306
- matches: outcome.matches.map((hit) => ({ path: hit.path, line: hit.line, text: hit.text }))
18856
+ matches: outcome.matches.map((hit) => ({ path: hit.path, line: hit.line, text: hit.text })),
18857
+ offset: parsed.offset ?? 0,
18858
+ ...outcome.total === void 0 ? {} : { total: outcome.total },
18859
+ ...typeof outcome.nextOffset === "number" ? { nextOffset: outcome.nextOffset } : {}
18307
18860
  };
18308
18861
  }
18309
18862
  }),
@@ -18400,6 +18953,8 @@ function remoteToolDefinitions(backend) {
18400
18953
  timedOut: { type: "boolean" },
18401
18954
  stdout: { type: "string" },
18402
18955
  stderr: { type: "string" },
18956
+ stdoutBase64: { type: "string" },
18957
+ stderrBase64: { type: "string" },
18403
18958
  durationMs: { type: "integer" },
18404
18959
  truncated: { type: "boolean" }
18405
18960
  }
@@ -18414,8 +18969,10 @@ function remoteToolDefinitions(backend) {
18414
18969
  type: "boolean",
18415
18970
  description: "Whether this plugin stopped waiting because the timeout budget expired. True does not prove the remote process died; false does not rule out a signal (see `signal`)."
18416
18971
  },
18417
- stdout: { type: "string" },
18418
- stderr: { type: "string" },
18972
+ stdout: { type: "string", description: "stdout as text; invalid UTF-8 is replaced (see stdoutBase64)." },
18973
+ stderr: { type: "string", description: "stderr as text; invalid UTF-8 is replaced (see stderrBase64)." },
18974
+ stdoutBase64: { type: "string", description: "Exact stdout bytes (base64) when stdout is not valid UTF-8 and was not truncated." },
18975
+ stderrBase64: { type: "string", description: "Exact stderr bytes (base64) when stderr is not valid UTF-8 and was not truncated." },
18419
18976
  durationMs: { type: "integer" },
18420
18977
  truncated: { type: "boolean" },
18421
18978
  stdoutTruncated: { type: "boolean" },
@@ -18460,6 +19017,8 @@ function remoteToolDefinitions(backend) {
18460
19017
  timedOut: outcome2.timedOut === true,
18461
19018
  stdout: outcome2.stdout,
18462
19019
  stderr: outcome2.stderr,
19020
+ ...outcome2.stdoutBase64 === void 0 ? {} : { stdoutBase64: outcome2.stdoutBase64 },
19021
+ ...outcome2.stderrBase64 === void 0 ? {} : { stderrBase64: outcome2.stderrBase64 },
18463
19022
  durationMs: outcome2.durationMs,
18464
19023
  truncated: flags2.truncated
18465
19024
  };
@@ -18493,6 +19052,8 @@ function remoteToolDefinitions(backend) {
18493
19052
  timedOut: outcome.timedOut === true,
18494
19053
  stdout: outcome.stdout,
18495
19054
  stderr: outcome.stderr,
19055
+ ...outcome.stdoutBase64 === void 0 ? {} : { stdoutBase64: outcome.stdoutBase64 },
19056
+ ...outcome.stderrBase64 === void 0 ? {} : { stderrBase64: outcome.stderrBase64 },
18496
19057
  durationMs: outcome.durationMs,
18497
19058
  truncated: flags.truncated,
18498
19059
  stdoutTruncated: flags.stdoutTruncated,
@@ -18534,27 +19095,42 @@ function formatGrep(outcome) {
18534
19095
  const truncation = grepTruncation(outcome);
18535
19096
  const lines = [];
18536
19097
  if (outcome.matches.length === 0) {
18537
- lines.push(`\u5728\u8FDC\u7AEF\u672A\u627E\u5230\u5339\u914D\uFF08\u7B56\u7565\uFF1A${outcome.strategy}\uFF09\u3002`);
19098
+ const offset = outcome.offset ?? 0;
19099
+ if (offset > 0 && typeof outcome.total === "number" && outcome.total > 0) {
19100
+ lines.push(`offset=${offset} \u8D85\u51FA\u5DF2\u626B\u63CF\u5230\u7684\u5339\u914D\u8303\u56F4\uFF08\u672C\u6B21\u5171\u626B\u63CF\u5230 ${outcome.total} \u6761\uFF09\u3002\u8BF7\u7528\u66F4\u5C0F\u7684 offset\uFF0C\u6216\u6536\u7A84\u641C\u7D22\u540E\u91CD\u8DD1\u3002`);
19101
+ } else {
19102
+ lines.push(`\u5728\u8FDC\u7AEF\u672A\u627E\u5230\u5339\u914D\uFF08\u7B56\u7565\uFF1A${outcome.strategy}\uFF09\u3002`);
19103
+ }
18538
19104
  if (include.length > 0 && truncation?.filesFiltered !== void 0 && truncation.filesFiltered > 0) {
18539
19105
  lines.push(`\u6CE8\u610F\uFF1A${truncation.filesFiltered} \u4E2A\u5019\u9009\u6587\u4EF6\u88AB include \u8FC7\u6EE4\u6389\u4E86 \u2014\u2014 \u82E5\u8FD9\u91CC\u672C\u8BE5\u6709\u547D\u4E2D\uFF0C\u5148\u68C0\u67E5 include \u5199\u5BF9\u6CA1\u6709\u3002`);
18540
19106
  }
18541
19107
  } else {
18542
19108
  lines.push(outcome.matches.map((hit) => `${hit.path}:${hit.line}: ${hit.text}`).join("\n"));
18543
19109
  }
19110
+ if (typeof outcome.total === "number") {
19111
+ if (typeof outcome.nextOffset === "number") {
19112
+ lines.push(`\u5DF2\u626B\u63CF\u5230 ${outcome.total} \u6761\uFF0C\u8FD8\u6709\u4E0B\u4E00\u9875\uFF1Aoffset=${outcome.nextOffset}`);
19113
+ } else {
19114
+ lines.push(`\u5339\u914D\u603B\u6570\uFF1A${outcome.total}`);
19115
+ }
19116
+ }
18544
19117
  const notes = [scope, ...modes].filter((part) => part !== "");
18545
19118
  if (notes.length > 0) lines.push(`\uFF08${notes.join("\uFF1B")}\uFF09`);
18546
- lines.push(...truncationLines(truncation));
19119
+ lines.push(...truncationLines(truncation, { paging: outcome.strategy.startsWith("agent") }));
18547
19120
  return lines.join("\n");
18548
19121
  }
18549
- function truncationLines(truncation) {
19122
+ function truncationLines(truncation, options = {}) {
18550
19123
  if (truncation === void 0 || !truncation.truncated) return [];
18551
- if (truncation.reasons.length === 0) {
18552
- return ["\uFF08\u7ED3\u679C\u88AB\u622A\u65AD\uFF1A\u8FDC\u7AEF\u672A\u8BF4\u660E\u89E6\u53D1\u4E86\u54EA\u4E2A\u4E0A\u9650\u3002\u7EE7\u7EED\u53D6\u7ED3\u679C\u9700\u6536\u7A84\u8F93\u5165\u2014\u2014\u52A0 include\u3001\u7F29\u5C0F path\uFF0C\u6216\u8C03\u5927 maxResults \u540E\u91CD\u8DD1\uFF1B\u672C\u5DE5\u5177\u4E0D\u652F\u6301\u5206\u9875/offset\u3002\uFF09"];
18553
- }
19124
+ const paging = options.paging === true;
19125
+ const summary = paging ? "\uFF08\u7ED3\u679C\u88AB\u622A\u65AD\uFF1A\u8FDC\u7AEF\u672A\u8BF4\u660E\u89E6\u53D1\u4E86\u54EA\u4E2A\u4E0A\u9650\u3002\u53EF\u5C1D\u8BD5\u7528 offset \u7FFB\u9875\uFF1B\u6CA1\u6709 nextOffset \u65F6\u6536\u7A84\u8F93\u5165\u2014\u2014\u52A0 include\u3001\u7F29\u5C0F path\uFF0C\u6216\u8C03\u5927 maxResults \u540E\u91CD\u8DD1\u3002\uFF09" : "\uFF08\u7ED3\u679C\u88AB\u622A\u65AD\uFF1A\u8FDC\u7AEF\u672A\u8BF4\u660E\u89E6\u53D1\u4E86\u54EA\u4E2A\u4E0A\u9650\u3002\u7EE7\u7EED\u53D6\u7ED3\u679C\u9700\u6536\u7A84\u8F93\u5165\u2014\u2014\u52A0 include\u3001\u7F29\u5C0F path\uFF0C\u6216\u8C03\u5927 maxResults \u540E\u91CD\u8DD1\uFF1B\u5F53\u524D\u4F20\u8F93\u4E0D\u652F\u6301\u5206\u9875/offset\u3002\uFF09";
19126
+ if (truncation.reasons.length === 0) return [summary];
19127
+ const advice = truncation.reasons.map(
19128
+ (reason) => reason === "maxResults" && !paging ? "\u547D\u4E2D\u6761\u6570\u5230\u9876\uFF08maxResults\uFF09\uFF1A\u7528\u66F4\u5177\u4F53\u7684 pattern\u3001\u52A0 include \u8FC7\u6EE4\uFF0C\u6216\u7F29\u5C0F path \u540E\u91CD\u8DD1\u3002" : searchTruncationAdvice(reason)
19129
+ );
18554
19130
  return [
18555
19131
  `\uFF08\u7ED3\u679C\u88AB\u622A\u65AD\uFF0C\u89E6\u53D1\u4E0A\u9650\uFF1A${truncation.reasons.join(", ")}\uFF09`,
18556
- ...truncation.reasons.map((reason) => `\xB7 ${searchTruncationAdvice(reason)}`),
18557
- "\u7EE7\u7EED\u53D6\u7ED3\u679C\u9700\u6536\u7A84\u8F93\u5165\u540E\u91CD\u8DD1\uFF08\u672C\u5DE5\u5177\u4E0D\u652F\u6301\u5206\u9875/offset\uFF1A\u670D\u52A1\u7AEF\u4E0D\u4FDD\u7559\u4E0A\u6B21\u8C03\u7528\u7684\u72B6\u6001\uFF09\u3002"
19132
+ ...advice.map((line) => `\xB7 ${line}`),
19133
+ paging ? "\u7EE7\u7EED\u53D6\u7ED3\u679C\uFF1A\u4F18\u5148\u7528\u8FD4\u56DE\u7684 nextOffset \u4F5C\u4E3A offset \u7FFB\u9875\uFF1B\u6CA1\u6709 nextOffset \u65F6\u6536\u7A84\u8F93\u5165\u540E\u91CD\u8DD1\u3002" : "\u7EE7\u7EED\u53D6\u7ED3\u679C\u9700\u6536\u7A84\u8F93\u5165\u540E\u91CD\u8DD1\uFF08\u5F53\u524D\u4F20\u8F93\u4E0D\u652F\u6301\u5206\u9875/offset\uFF09\u3002"
18558
19134
  ];
18559
19135
  }
18560
19136
  function formatGlob(outcome) {
@@ -18603,6 +19179,12 @@ function formatExec(outcome) {
18603
19179
  ${stdout.text}`);
18604
19180
  if (stderr.text !== "") parts.push(`stderr:
18605
19181
  ${stderr.text}`);
19182
+ if (outcome.stdoutBase64 !== void 0) {
19183
+ parts.push(`\u26A0 stdout \u5305\u542B\u975E UTF-8 \u5B57\u8282\uFF1A\u4E0A\u9762 stdout \u91CC\u7684\u66FF\u6362\u5B57\u7B26\u4E0D\u662F\u539F\u6587\uFF0C\u7CBE\u786E\u5B57\u8282\u5728 stdoutBase64\uFF08${String(Buffer.from(outcome.stdoutBase64, "base64").byteLength)} \u5B57\u8282\uFF09\u3002`);
19184
+ }
19185
+ if (outcome.stderrBase64 !== void 0) {
19186
+ parts.push(`\u26A0 stderr \u5305\u542B\u975E UTF-8 \u5B57\u8282\uFF1A\u4E0A\u9762 stderr \u91CC\u7684\u66FF\u6362\u5B57\u7B26\u4E0D\u662F\u539F\u6587\uFF0C\u7CBE\u786E\u5B57\u8282\u5728 stderrBase64\uFF08${String(Buffer.from(outcome.stderrBase64, "base64").byteLength)} \u5B57\u8282\uFF09\u3002`);
19187
+ }
18606
19188
  const stdoutCut = outcome.stdoutTruncated ?? outcome.truncated;
18607
19189
  const stderrCut = outcome.stderrTruncated ?? outcome.truncated;
18608
19190
  const cut = [
@@ -18722,7 +19304,8 @@ function toOutputView(output) {
18722
19304
  lost: output.lost,
18723
19305
  exitCode: output.exitCode,
18724
19306
  ...output.signal === void 0 ? {} : { signal: output.signal },
18725
- timedOut: output.timedOut
19307
+ timedOut: output.timedOut,
19308
+ ...output.exactBytes === void 0 ? {} : { binary: true, dataBase64: Buffer.from(output.exactBytes).toString("base64") }
18726
19309
  };
18727
19310
  }
18728
19311
  function defaultSleep2(ms, signal) {
@@ -18747,7 +19330,7 @@ function createRemoteJobBackend(deps) {
18747
19330
  const now = deps.now ?? (() => Date.now());
18748
19331
  const sleep2 = deps.sleep ?? defaultSleep2;
18749
19332
  const require2 = async (target) => {
18750
- const transport = await deps.acquire(target.connectionId);
19333
+ const transport = await deps.acquire(target.connectionId, target.sessionKey);
18751
19334
  if (!isAgentJobCapable(transport)) throw unsupported(target.connectionId);
18752
19335
  return transport;
18753
19336
  };
@@ -18830,7 +19413,9 @@ function text3(value) {
18830
19413
  return [{ type: "text", text: value }];
18831
19414
  }
18832
19415
  function targetFor2(backend, exec) {
18833
- return resolveSessionTarget(exec, { resolve: (cwd) => backend.resolve(cwd), resolveSessionRoot: backend.resolveSessionRoot });
19416
+ const target = resolveSessionTarget(exec, { resolve: (cwd) => backend.resolve(cwd), resolveSessionRoot: backend.resolveSessionRoot });
19417
+ const sessionKey2 = exec.agent?.session.header.id;
19418
+ return sessionKey2 === void 0 || sessionKey2 === "" ? target : { ...target, sessionKey: sessionKey2 };
18834
19419
  }
18835
19420
  var PATH_SPELLING2 = "Accepts an absolute remote path inside the workspace root, or a path relative to the root. An absolute path outside the root is refused. `..` folds but never leaves the root.";
18836
19421
  var JOB_LIFETIME_NOTE = " Job lifetime depends on the transport, and the difference matters for long builds. **Tier 2 (the default) runs a resident daemon**: jobs belong to the *session*, not to the ssh channel, so a dropped connection does **not** kill a running job \u2014 reconnect and read it again with the same `jobId` and `offset`. **Tier 1 (pure POSIX) and `--stdio` mode** tie jobs to the connection: closing the channel kills them. A resident daemon is reclaimed only when it goes idle for its timeout or the process stops, and that reclaim kills its jobs \u2014 so do not rely on a job surviving a long idle gap.";
@@ -18887,6 +19472,11 @@ function formatJobOutput(view) {
18887
19472
  );
18888
19473
  }
18889
19474
  parts.push(view.data === "" ? "\uFF08\u672C\u6B21\u6CA1\u6709\u65B0\u7684\u8F93\u51FA\uFF09" : view.data.replace(/\n$/, ""));
19475
+ if (view.binary === true) {
19476
+ parts.push(
19477
+ `\u26A0 \u672C\u6B21\u8F93\u51FA\u5305\u542B\u975E UTF-8 \u5B57\u8282\uFF1A\u4E0A\u9762 data \u91CC\u7684\u66FF\u6362\u5B57\u7B26\u4E0D\u662F\u539F\u6587\uFF0C\u7CBE\u786E\u5B57\u8282\u5728 dataBase64\uFF08${view.dataBase64 === void 0 ? "\u7F3A\u5931" : `${String(Buffer.from(view.dataBase64, "base64").byteLength)} \u5B57\u8282`}\uFF09\u3002`
19478
+ );
19479
+ }
18890
19480
  if (view.lost) {
18891
19481
  parts.push(
18892
19482
  `\u26A0 \u4F60\u8BF7\u6C42\u7684 offset \u8D77\u70B9\u5DF2\u7ECF\u88AB\u4E22\u5F03\uFF1A\u672C\u6BB5\u6570\u636E**\u4E0D\u662F\u8FDE\u7EED\u8F93\u51FA**\uFF08\u4E2D\u95F4\u6709\u6D1E\uFF09\u3002\u8BE5\u4F5C\u4E1A\u7D2F\u8BA1\u4EA7\u51FA ${view.totalBytes} \u5B57\u8282\uFF0C\u5176\u4E2D\u6700\u65E9\u7684 ${view.droppedBytes} \u5B57\u8282\u5DF2\u4E0D\u53EF\u53D6\u56DE\uFF0C\u4F5C\u4E1A\u6CA1\u6709\u843D\u76D8\u526F\u672C\u3002`
@@ -19119,7 +19709,9 @@ function remoteJobToolDefinitions(backend) {
19119
19709
  jobs: { type: "array", items: { type: "string" }, description: "`list`: one formatted line per job; use `status` for structured fields." },
19120
19710
  jobId: { type: "string", description: "`output` / `kill` / `stdin`: the job acted on." },
19121
19711
  killed: { type: "boolean", description: "`kill`: false when the job had already finished." },
19122
- data: { type: "string", description: "`output`: output from the requested offset; empty when there is nothing new." },
19712
+ data: { type: "string", description: "`output`: output from the requested offset; empty when there is nothing new. Invalid UTF-8 is replaced (see binary/dataBase64)." },
19713
+ binary: { type: "boolean", description: "`output`: true when the chunk contained non-UTF-8 bytes; `data` is then not the exact content." },
19714
+ dataBase64: { type: "string", description: "`output`: exact bytes (base64), present only when binary is true." },
19123
19715
  state: { type: "string", description: "`output`: `running` / `exited` / `killed` / `failed`." },
19124
19716
  droppedBytes: { type: "integer", description: "`output`: bytes dropped from the oldest end of the ring. Bytes, not lines." },
19125
19717
  exitCode: { type: "integer", description: "`output`: exit code; 0 while the job is still running." },
@@ -19223,6 +19815,7 @@ function remoteJobToolDefinitions(backend) {
19223
19815
  lost: view.lost,
19224
19816
  exitCode: view.exitCode,
19225
19817
  ...view.signal === void 0 ? {} : { signal: view.signal },
19818
+ ...view.binary === true ? { binary: true, ...view.dataBase64 === void 0 ? {} : { dataBase64: view.dataBase64 } } : {},
19226
19819
  timedOut: view.timedOut,
19227
19820
  running: view.state === "running",
19228
19821
  waitedMs,
@@ -19254,6 +19847,7 @@ async function followOutput(backend, target, jobId, offset, timeoutMs, signal) {
19254
19847
  lost: view.lost,
19255
19848
  exitCode: view.exitCode,
19256
19849
  ...view.signal === void 0 ? {} : { signal: view.signal },
19850
+ ...view.binary === true ? { binary: true, ...view.dataBase64 === void 0 ? {} : { dataBase64: view.dataBase64 } } : {},
19257
19851
  timedOut: false,
19258
19852
  running,
19259
19853
  waitedMs: Date.now() - started,
@@ -19274,6 +19868,7 @@ async function followOutput(backend, target, jobId, offset, timeoutMs, signal) {
19274
19868
  lost: view.lost,
19275
19869
  exitCode: view.exitCode,
19276
19870
  ...view.signal === void 0 ? {} : { signal: view.signal },
19871
+ ...view.binary === true ? { binary: true, ...view.dataBase64 === void 0 ? {} : { dataBase64: view.dataBase64 } } : {},
19277
19872
  timedOut: true,
19278
19873
  running: true,
19279
19874
  waitedMs: Date.now() - started,
@@ -19335,6 +19930,52 @@ function rememberVersion(target, absolute, version) {
19335
19930
  rememberedVersions.delete(oldest.value);
19336
19931
  }
19337
19932
  }
19933
+ function capReadManyItems(items, maxBytesEach, totalBytesCap) {
19934
+ let remaining = totalBytesCap;
19935
+ return items.map((item) => {
19936
+ if (item.text === void 0) return item;
19937
+ if (item.binary === true && item.dataBase64 !== void 0) {
19938
+ let raw = Buffer.from(item.dataBase64, "base64");
19939
+ let truncated2 = item.truncated;
19940
+ if (raw.byteLength > maxBytesEach) {
19941
+ raw = raw.subarray(0, maxBytesEach);
19942
+ truncated2 = true;
19943
+ }
19944
+ if (raw.byteLength > remaining) {
19945
+ raw = raw.subarray(0, Math.max(0, remaining));
19946
+ truncated2 = true;
19947
+ remaining = 0;
19948
+ } else {
19949
+ remaining -= raw.byteLength;
19950
+ }
19951
+ return { ...item, text: raw.toString("utf8"), dataBase64: raw.toString("base64"), truncated: truncated2 };
19952
+ }
19953
+ let text8 = item.text;
19954
+ let truncated = item.truncated;
19955
+ let bytes = Buffer.byteLength(text8, "utf8");
19956
+ if (bytes > maxBytesEach) {
19957
+ text8 = truncateUtf8(text8, maxBytesEach);
19958
+ truncated = true;
19959
+ bytes = Buffer.byteLength(text8, "utf8");
19960
+ }
19961
+ if (bytes > remaining) {
19962
+ text8 = truncateUtf8(text8, Math.max(0, remaining));
19963
+ truncated = true;
19964
+ remaining = 0;
19965
+ } else {
19966
+ remaining -= bytes;
19967
+ }
19968
+ return { ...item, text: text8, truncated };
19969
+ });
19970
+ }
19971
+ function truncateUtf8(text8, maxBytes) {
19972
+ const buffer = Buffer.from(text8, "utf8");
19973
+ if (buffer.byteLength <= maxBytes) return text8;
19974
+ if (maxBytes <= 0) return "";
19975
+ let end = Math.min(maxBytes, buffer.byteLength);
19976
+ while (end > 0 && end < buffer.byteLength && ((buffer[end] ?? 0) & 192) === 128) end -= 1;
19977
+ return buffer.subarray(0, end).toString("utf8");
19978
+ }
19338
19979
  function createRemoteFileBackend(deps) {
19339
19980
  const cap = Math.max(1, Math.floor(deps.maxReadBytes ?? MAX_READ_FILE_BYTES));
19340
19981
  const requireBackend = (target) => {
@@ -19352,7 +19993,14 @@ function createRemoteFileBackend(deps) {
19352
19993
  async read(target, absolute, signal) {
19353
19994
  const chunk = await requireBackend(target).readText(absolute, cap, signal);
19354
19995
  rememberVersion(target, absolute, chunk.version);
19355
- return { text: chunk.text, size: chunk.size, truncated: chunk.truncated, version: chunk.version };
19996
+ return {
19997
+ text: chunk.text,
19998
+ size: chunk.size,
19999
+ truncated: chunk.truncated,
20000
+ version: chunk.version,
20001
+ ...chunk.binary === true ? { binary: true } : {},
20002
+ ...chunk.dataBase64 === void 0 ? {} : { dataBase64: chunk.dataBase64 }
20003
+ };
19356
20004
  },
19357
20005
  async write(target, absolute, text8, expectedVersion, signal) {
19358
20006
  const outcome = await requireBackend(target).writeText(absolute, text8, expectedVersion, signal);
@@ -19374,6 +20022,69 @@ function createRemoteFileBackend(deps) {
19374
20022
  rememberVersion(target, absolute, outcome.version);
19375
20023
  return { replaced: outcome.replaced, version: outcome.version, bytes: outcome.bytes };
19376
20024
  },
20025
+ async readMany(target, absolutes, maxBytesEach, totalBytesCap, signal) {
20026
+ const backend = requireBackend(target);
20027
+ if (backend.readMany === void 0) {
20028
+ const read = await Promise.all(absolutes.map(async (absolute) => {
20029
+ try {
20030
+ const chunk = await backend.readText(absolute, maxBytesEach, signal);
20031
+ rememberVersion(target, absolute, chunk.version);
20032
+ return {
20033
+ path: absolute,
20034
+ text: chunk.text,
20035
+ size: chunk.size,
20036
+ version: chunk.version,
20037
+ truncated: chunk.truncated,
20038
+ ...chunk.binary === true ? { binary: true } : {},
20039
+ ...chunk.dataBase64 === void 0 ? {} : { dataBase64: chunk.dataBase64 }
20040
+ };
20041
+ } catch (error) {
20042
+ return { path: absolute, size: 0, version: "", truncated: false, error: error instanceof Error ? error.message : String(error) };
20043
+ }
20044
+ }));
20045
+ return { items: capReadManyItems(read, maxBytesEach, totalBytesCap), missing: [] };
20046
+ }
20047
+ const result = await backend.readMany(absolutes, { maxBytesEach, totalBytesCap, signal });
20048
+ const items = absolutes.map((absolute, index) => {
20049
+ const chunk = result.chunks[index];
20050
+ if (chunk === void 0) {
20051
+ return { path: absolute, size: 0, version: "", truncated: false, error: "\u65E0\u6CD5\u8BFB\u53D6\uFF08\u6587\u4EF6\u4E0D\u5B58\u5728\u3001\u8D85\u9650\u6216\u8BFB\u53D6\u5931\u8D25\uFF09" };
20052
+ }
20053
+ rememberVersion(target, absolute, chunk.version);
20054
+ return {
20055
+ path: absolute,
20056
+ text: chunk.text,
20057
+ size: chunk.size,
20058
+ version: chunk.version,
20059
+ truncated: chunk.truncated,
20060
+ ...chunk.binary === true ? { binary: true } : {},
20061
+ ...chunk.dataBase64 === void 0 ? {} : { dataBase64: chunk.dataBase64 }
20062
+ };
20063
+ });
20064
+ return { items: capReadManyItems(items, maxBytesEach, totalBytesCap), missing: result.missing };
20065
+ },
20066
+ async projectSummary(target, signal) {
20067
+ const backend = requireBackend(target);
20068
+ if (backend.repoSummary === void 0) {
20069
+ throw new Error("\u5F53\u524D\u540E\u7AEF\u6CA1\u6709 repo.summary\uFF08\u8FDC\u7AEF\u4EE3\u7406\u592A\u65E7\uFF0C\u8BF7\u91CD\u88C5/\u5347\u7EA7\u4EE3\u7406\uFF09");
20070
+ }
20071
+ return backend.repoSummary(target.root, signal);
20072
+ },
20073
+ async applyPatchMany(target, edits, atomic, signal) {
20074
+ const backend = requireBackend(target);
20075
+ if (backend.patchMany === void 0) {
20076
+ throw new Error("\u5F53\u524D\u540E\u7AEF\u6CA1\u6709 fs.patchMany\uFF08\u8FDC\u7AEF\u4EE3\u7406\u592A\u65E7\uFF0C\u8BF7\u91CD\u88C5/\u5347\u7EA7\u4EE3\u7406\uFF09");
20077
+ }
20078
+ const result = await backend.patchMany(edits, atomic, signal);
20079
+ if (result["rolledBack"] !== true && Array.isArray(result["results"])) {
20080
+ for (const entry of result["results"]) {
20081
+ if (entry.ok === true && typeof entry.path === "string" && typeof entry.version === "string" && entry.version !== "") {
20082
+ rememberVersion(target, entry.path, entry.version);
20083
+ }
20084
+ }
20085
+ }
20086
+ return result;
20087
+ },
19377
20088
  recall(target, absolute) {
19378
20089
  return rememberedVersions.get(versionKey(target, absolute));
19379
20090
  },
@@ -19472,6 +20183,9 @@ function formatRead(view) {
19472
20183
  const truncatedByWindow = view.lines.length >= view.limit && endLine < view.totalLines;
19473
20184
  const body = view.lines.length > 0 ? view.lines.map((line) => `${line.number}: ${line.text}`).join("\n") : "\uFF08\u8BE5\u8303\u56F4\u5185\u6CA1\u6709\u884C\uFF09";
19474
20185
  const parts = [body];
20186
+ if (view.binary === true) {
20187
+ parts.push("\u26A0 \u8BE5\u6587\u4EF6\u4E0D\u662F\u5408\u6CD5 UTF-8\uFF1A\u4E0A\u9762\u7684\u884C\u6587\u672C\u662F\u66FF\u6362\u89C6\u56FE\uFF0C\u4E0D\u80FD\u7528\u4E8E\u7CBE\u786E\u5185\u5BB9\uFF1B\u9700\u8981\u539F\u59CB\u5B57\u8282\u8BF7\u7528 remote_read_many\uFF08binary/dataBase64\uFF09\u3002");
20188
+ }
19475
20189
  if (view.sourceTruncated) {
19476
20190
  parts.push(
19477
20191
  `\uFF08\u672C\u6B21\u8BFB\u53D6\u88AB ${view.maxReadBytes} \u5B57\u8282\u4E0A\u9650\u622A\u65AD\uFF1A\u6587\u4EF6\u5171 ${view.size} \u5B57\u8282\uFF0C\u4E0A\u9762\u53EA\u8986\u76D6\u5230\u7B2C ${view.totalLines} \u884C\uFF0C\u540E\u7EED\u884C\u672A\u8BFB\u5165\u3002\u8BF7\u7528 offset \u4ECE\u540E\u9762\u7EE7\u7EED\u8BFB\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u5B8C\u6574\u6587\u4EF6\u3002\uFF09`
@@ -19570,6 +20284,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19570
20284
  description: "The whole-file read hit the byte cap, so later lines were not read at all."
19571
20285
  },
19572
20286
  maxReadBytes: { type: "integer", required: true, description: "The byte cap that read was subject to." },
20287
+ binary: { type: "boolean", description: "The file is not valid UTF-8; lines are only a readable replacement view. Use remote_read_many for exact bytes." },
19573
20288
  size: { type: "integer", required: true, description: "Full file size in bytes." },
19574
20289
  version: {
19575
20290
  type: "string",
@@ -19613,6 +20328,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19613
20328
  maxReadBytes: backend.maxReadBytes,
19614
20329
  size: file.size,
19615
20330
  version: file.version,
20331
+ ...file.binary === true ? { binary: true } : {},
19616
20332
  lines: window.lines.map((line) => ({ number: line.number, text: line.text }))
19617
20333
  };
19618
20334
  }
@@ -19907,11 +20623,280 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19907
20623
  ];
19908
20624
  }
19909
20625
  function registerRemoteFileTools(ctx, backend, options) {
19910
- const disposers = remoteFileToolDefinitions(backend, options?.allowMutation).map((definition) => ctx.tools.register(definition));
20626
+ const disposers = [
20627
+ ...remoteFileToolDefinitions(backend, options?.allowMutation),
20628
+ remoteReadManyToolDefinition(backend),
20629
+ remoteProjectSummaryToolDefinition(backend),
20630
+ remoteApplyPatchManyToolDefinition(backend, options?.allowMutation)
20631
+ ].map((definition) => ctx.tools.register(definition));
19911
20632
  return () => {
19912
20633
  for (const dispose of disposers.splice(0)) dispose();
19913
20634
  };
19914
20635
  }
20636
+ function remoteReadManyToolDefinition(backend) {
20637
+ return defineTool5({
20638
+ name: "remote_read_many",
20639
+ description: "Read several text files from the remote workspace in **one** round trip. Use it for context gathering when you already know the file list: one call instead of N `remote_read` calls. Returns each file's raw text (no line numbers), size, version token and a per-file error when that file could not be read. Bounded: at most 16 files, `maxBytes` per file (default 256 KiB, hard cap 1 MiB), 2 MiB total. Read-only: calls may overlap with other read-only remote calls.",
20640
+ isConcurrencySafe: () => true,
20641
+ parameters: {
20642
+ paths: {
20643
+ type: "array",
20644
+ items: { type: "string" },
20645
+ required: true,
20646
+ description: `Files to read (1..16). ${PATH_SPELLING3} Relative paths resolve under the target root.`
20647
+ },
20648
+ maxBytes: {
20649
+ type: "number",
20650
+ description: "Per-file byte cap (default 262144, hard cap 1048576). Truncated files say so."
20651
+ }
20652
+ },
20653
+ output: {
20654
+ schema: {
20655
+ type: "object",
20656
+ additionalProperties: false,
20657
+ properties: {
20658
+ strategy: { type: "string", required: true, description: "`batch` (one logical call; the backend may fall back to parallel single reads internally)." },
20659
+ totalBytes: { type: "integer", required: true },
20660
+ items: {
20661
+ type: "array",
20662
+ required: true,
20663
+ items: {
20664
+ type: "object",
20665
+ additionalProperties: false,
20666
+ properties: {
20667
+ path: { type: "string", required: true },
20668
+ text: { type: "string", description: "File text; invalid UTF-8 is replaced (see binary/dataBase64)." },
20669
+ size: { type: "integer", required: true },
20670
+ version: { type: "string", required: true },
20671
+ truncated: { type: "boolean", required: true },
20672
+ error: { type: "string" },
20673
+ binary: { type: "boolean", description: "The file is not valid UTF-8; `text` is only a readable replacement." },
20674
+ dataBase64: { type: "string", description: "Exact bytes (base64), present only when binary is true." }
20675
+ }
20676
+ }
20677
+ },
20678
+ missing: { type: "array", required: true, items: { type: "string" } }
20679
+ }
20680
+ },
20681
+ render: (_args, value) => text4(formatReadMany(value))
20682
+ },
20683
+ async execute(args, exec) {
20684
+ const record2 = args ?? {};
20685
+ const raw = record2["paths"];
20686
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 16) {
20687
+ throw new Error("paths \u5FC5\u987B\u662F 1..16 \u4E2A\u6587\u4EF6\u8DEF\u5F84");
20688
+ }
20689
+ const requested = raw.map((item, index) => requiredString3(item, `paths[${index}]`));
20690
+ const maxBytesEach = clampInt3(record2["maxBytes"], 262144, 1, 1048576);
20691
+ const target = targetFor3(backend, exec);
20692
+ const absolutes = requested.map((path) => resolveOrThrow3(target, path));
20693
+ const totalBytesCap = 2 * 1024 * 1024;
20694
+ const batch = backend.readMany;
20695
+ const result = batch === void 0 ? {
20696
+ items: await (async () => {
20697
+ const read = await Promise.all(absolutes.map(async (path) => {
20698
+ try {
20699
+ const chunk = await backend.read(target, path, exec.signal);
20700
+ return { path, text: chunk.text, size: chunk.size, version: chunk.version, truncated: chunk.truncated };
20701
+ } catch (error) {
20702
+ return { path, size: 0, version: "", truncated: false, error: error instanceof Error ? error.message : String(error) };
20703
+ }
20704
+ }));
20705
+ let remaining = totalBytesCap;
20706
+ return read.map((item) => {
20707
+ if (item.text === void 0) return item;
20708
+ let text8 = item.text;
20709
+ let truncated = item.truncated;
20710
+ let bytes = Buffer.byteLength(text8, "utf8");
20711
+ if (bytes > maxBytesEach) {
20712
+ text8 = truncateUtf8(text8, maxBytesEach);
20713
+ truncated = true;
20714
+ bytes = Buffer.byteLength(text8, "utf8");
20715
+ }
20716
+ if (bytes > remaining) {
20717
+ text8 = truncateUtf8(text8, remaining);
20718
+ truncated = true;
20719
+ remaining = 0;
20720
+ } else {
20721
+ remaining -= bytes;
20722
+ }
20723
+ return { ...item, text: text8, truncated };
20724
+ });
20725
+ })(),
20726
+ missing: []
20727
+ } : await batch.call(backend, target, absolutes, maxBytesEach, totalBytesCap, exec.signal);
20728
+ return {
20729
+ strategy: "batch",
20730
+ totalBytes: result.items.reduce((sum, item) => {
20731
+ const binary = item.binary === true;
20732
+ const dataBase64 = item.dataBase64;
20733
+ if (binary && dataBase64 !== void 0) return sum + Buffer.from(dataBase64, "base64").byteLength;
20734
+ return sum + (item.text === void 0 ? 0 : Buffer.byteLength(item.text, "utf8"));
20735
+ }, 0),
20736
+ items: result.items.map((item) => ({ ...item })),
20737
+ missing: [...result.missing]
20738
+ };
20739
+ }
20740
+ });
20741
+ }
20742
+ function formatReadMany(view) {
20743
+ const head = `\u6279\u91CF\u8BFB\u53D6 ${view.items.length} \u4E2A\u6587\u4EF6\uFF08${view.strategy === "batch" ? "\u4E00\u6B21\u5F80\u8FD4" : "\u5E76\u53D1\u5355\u8BFB"}\uFF0C\u5171 ${view.totalBytes} \u5B57\u8282\uFF09\u3002`;
20744
+ const sections = view.items.map((item) => {
20745
+ if (item.error !== void 0) return `## ${item.path}
20746
+ \u8BFB\u53D6\u5931\u8D25\uFF1A${item.error}`;
20747
+ const truncated = item.truncated ? `\uFF08\u5DF2\u622A\u65AD\uFF0C\u5B8C\u6574 ${item.size} \u5B57\u8282\uFF09` : "";
20748
+ const body = item.text ?? "";
20749
+ const binary = item.binary === true ? `
20750
+ \u26A0 \u8BE5\u6587\u4EF6\u4E0D\u662F\u5408\u6CD5 UTF-8\uFF1A\u4E0A\u9762\u6B63\u6587\u91CC\u7684\u66FF\u6362\u5B57\u7B26\u4E0D\u662F\u539F\u6587\uFF0C\u7CBE\u786E\u5B57\u8282\u5728 dataBase64\uFF08${item.dataBase64 === void 0 ? "\u7F3A\u5931" : `${String(Buffer.from(item.dataBase64, "base64").byteLength)} \u5B57\u8282`}\uFF09\u3002` : "";
20751
+ return `## ${item.path}\uFF08version=${item.version}${truncated}\uFF09
20752
+ ${body}${binary}`;
20753
+ });
20754
+ const missing = view.missing.length === 0 ? "" : `
20755
+ \u65E0\u6CD5\u8BFB\u53D6\uFF1A${view.missing.join("\u3001")}`;
20756
+ return [head, ...sections].join("\n\n") + missing;
20757
+ }
20758
+ function remoteProjectSummaryToolDefinition(backend) {
20759
+ return defineTool5({
20760
+ name: "remote_project_summary",
20761
+ description: "Describe the remote project at a path in **one** round trip: git branch/ahead/behind/dirty (when the agent has git), top-level entries, package manifests (package.json / go.mod / Cargo.toml / pyproject.toml / ...), a README head and detected test/build/lint commands. Use it once at the start of work on an unfamiliar remote repo instead of chaining `ls`, `remote_git` and several `remote_read` calls. Read-only: may overlap with other read-only remote calls.",
20762
+ isConcurrencySafe: () => true,
20763
+ parameters: {
20764
+ path: { type: "string", description: `Directory to summarise. ${PATH_SPELLING3} Defaults to the target root.` }
20765
+ },
20766
+ output: {
20767
+ schema: {
20768
+ type: "object",
20769
+ additionalProperties: false,
20770
+ properties: {
20771
+ root: { type: "string", required: true },
20772
+ summary: { type: "object", required: true, additionalProperties: true, description: "The agent repo.summary payload." }
20773
+ }
20774
+ },
20775
+ render: (_args, value) => text4(formatProjectSummary(value))
20776
+ },
20777
+ async execute(args, exec) {
20778
+ const record2 = args ?? {};
20779
+ const target = targetFor3(backend, exec);
20780
+ const root = record2["path"] === void 0 ? target.root : resolveOrThrow3(target, requiredString3(record2["path"], "path"));
20781
+ if (backend.projectSummary === void 0) throw new Error("\u5F53\u524D\u540E\u7AEF\u6CA1\u6709 repo.summary\uFF08\u8FDC\u7AEF\u4EE3\u7406\u592A\u65E7\uFF0C\u8BF7\u91CD\u88C5/\u5347\u7EA7\u4EE3\u7406\uFF09");
20782
+ const summary = await backend.projectSummary({ ...target, root }, exec.signal);
20783
+ return { root, summary };
20784
+ }
20785
+ });
20786
+ }
20787
+ function formatProjectSummary(value) {
20788
+ const lines = [`\u9879\u76EE\u6982\u89C8 ${value.root}`];
20789
+ const git = value.summary["git"];
20790
+ if (typeof git === "object" && git !== null) {
20791
+ const record2 = git;
20792
+ const remote = typeof record2["remoteUrl"] === "string" ? `\uFF0Cremote=${record2["remoteUrl"]}` : "";
20793
+ const cut = record2["truncated"] === true ? "\uFF08status \u8F93\u51FA\u88AB\u622A\u65AD\uFF0Cdirty \u53EA\u662F\u4E0B\u754C\uFF09" : "";
20794
+ lines.push(`git\uFF1A${String(record2["branch"] ?? "?")}\uFF0Cdirty=${String(record2["dirty"] ?? 0)}\uFF0Cahead=${String(record2["ahead"] ?? 0)}\uFF0Cbehind=${String(record2["behind"] ?? 0)}${remote}${cut}`);
20795
+ } else {
20796
+ lines.push("git\uFF1A\u4E0D\u53EF\u7528\uFF08\u6216\u8BE5\u76EE\u5F55\u4E0D\u662F\u5DE5\u4F5C\u6811\uFF09");
20797
+ }
20798
+ const topLevel = Array.isArray(value.summary["topLevel"]) ? value.summary["topLevel"] : [];
20799
+ if (topLevel.length > 0) {
20800
+ const names = topLevel.map((entry) => {
20801
+ const record2 = entry ?? {};
20802
+ const name2 = typeof record2["name"] === "string" ? record2["name"] : "?";
20803
+ return record2["isDirectory"] === true ? `${name2}/` : name2;
20804
+ });
20805
+ lines.push(`\u9876\u5C42\uFF08${names.length}${value.summary["truncated"] === true ? "\uFF0C\u5DF2\u622A\u65AD" : ""}\uFF09\uFF1A${names.join("\uFF0C")}`);
20806
+ }
20807
+ const manifests = Array.isArray(value.summary["manifests"]) ? value.summary["manifests"] : [];
20808
+ if (manifests.length > 0) {
20809
+ const listed = manifests.map((entry) => {
20810
+ const record2 = entry ?? {};
20811
+ const kind = typeof record2["kind"] === "string" ? record2["kind"] : "?";
20812
+ const path = typeof record2["path"] === "string" ? record2["path"] : "?";
20813
+ return `${kind}\uFF08${path}\uFF09`;
20814
+ });
20815
+ lines.push(`\u6E05\u5355\uFF1A${listed.join("\uFF0C")}`);
20816
+ }
20817
+ const detected = value.summary["detected"];
20818
+ const commands = typeof detected === "object" && detected !== null ? Object.entries(detected).filter(([, command]) => typeof command === "string").map(([name2, command]) => `${name2}=${String(command)}`).join("\uFF0C") : "";
20819
+ if (commands !== "") lines.push(`\u63A8\u65AD\u547D\u4EE4\uFF1A${commands}`);
20820
+ const readme = value.summary["readmeHead"];
20821
+ if (typeof readme === "string" && readme.trim() !== "") {
20822
+ const head = readme.length > 1536 ? `${readme.slice(0, 1536)}
20823
+ \u2026\uFF08README \u5DF2\u622A\u65AD\uFF09` : readme;
20824
+ lines.push(`README \u5934\uFF1A
20825
+ ${head.trimEnd()}`);
20826
+ }
20827
+ return lines.join("\n");
20828
+ }
20829
+ function remoteApplyPatchManyToolDefinition(backend, allowMutation) {
20830
+ return defineTool5({
20831
+ name: "remote_apply_patch",
20832
+ description: "Apply several file edits in **one** round trip on the remote host. Each edit is either a literal find/replace (`find` + `replace`, `all` to replace every occurrence) or a unified diff (`patch`), with an optional `expectedVersion` guard. Every edit is validated before **any** file is written; with `atomic: true` (default) a late failure rolls applied edits back. Use it for multi-file refactors instead of N `remote_edit` calls; for one file prefer `remote_edit`/`remote_patch`. Max 32 edits, 4 MiB total snapshot.",
20833
+ parameters: {
20834
+ edits: {
20835
+ type: "array",
20836
+ required: true,
20837
+ description: "1..32 edits; each needs `path` plus either `find`+`replace` or `patch`.",
20838
+ items: {
20839
+ type: "object",
20840
+ additionalProperties: false,
20841
+ properties: {
20842
+ path: { type: "string", required: true },
20843
+ expectedVersion: { type: "string" },
20844
+ find: { type: "string" },
20845
+ replace: { type: "string" },
20846
+ all: { type: "boolean" },
20847
+ create: { type: "boolean" },
20848
+ patch: { type: "string" }
20849
+ }
20850
+ }
20851
+ },
20852
+ atomic: { type: "boolean", description: "Roll back on a late failure (default true)." }
20853
+ },
20854
+ output: {
20855
+ schema: {
20856
+ type: "object",
20857
+ additionalProperties: false,
20858
+ properties: {
20859
+ applied: { type: "integer", required: true },
20860
+ rolledBack: { type: "boolean", required: true },
20861
+ results: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
20862
+ }
20863
+ },
20864
+ render: (_args, value) => text4(formatApplyPatchMany(value))
20865
+ },
20866
+ async execute(args, exec) {
20867
+ const record2 = args ?? {};
20868
+ const raw = record2["edits"];
20869
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 32) throw new Error("edits \u5FC5\u987B\u662F 1..32 \u6761\u7F16\u8F91");
20870
+ const target = targetFor3(backend, exec);
20871
+ const edits = raw.map((entry, index) => {
20872
+ const item = entry ?? {};
20873
+ const path = resolveOrThrow3(target, requiredString3(item["path"], `edits[${index}].path`));
20874
+ assertWritable(target, path, allowMutation);
20875
+ return {
20876
+ path,
20877
+ ...typeof item["expectedVersion"] === "string" ? { expectedVersion: item["expectedVersion"] } : {},
20878
+ ...typeof item["find"] === "string" ? { find: item["find"] } : {},
20879
+ ...typeof item["replace"] === "string" ? { replace: item["replace"] } : {},
20880
+ ...item["all"] === true ? { all: true } : {},
20881
+ ...item["create"] === true ? { create: true } : {},
20882
+ ...typeof item["patch"] === "string" ? { patch: item["patch"] } : {}
20883
+ };
20884
+ });
20885
+ if (backend.applyPatchMany === void 0) throw new Error("\u5F53\u524D\u540E\u7AEF\u6CA1\u6709 fs.patchMany\uFF08\u8FDC\u7AEF\u4EE3\u7406\u592A\u65E7\uFF0C\u8BF7\u91CD\u88C5/\u5347\u7EA7\u4EE3\u7406\uFF09");
20886
+ const result = await backend.applyPatchMany(target, edits, record2["atomic"] !== false, exec.signal);
20887
+ return {
20888
+ applied: typeof result["applied"] === "number" ? result["applied"] : 0,
20889
+ rolledBack: result["rolledBack"] === true,
20890
+ results: Array.isArray(result["results"]) ? result["results"] : []
20891
+ };
20892
+ }
20893
+ });
20894
+ }
20895
+ function formatApplyPatchMany(value) {
20896
+ const head = `\u6279\u91CF\u4FEE\u6539\uFF1A\u6210\u529F ${value.applied} \u6761${value.rolledBack ? "\uFF0C\u5931\u8D25\u540E\u5DF2\u56DE\u6EDA" : ""}\u3002`;
20897
+ const lines = value.results.map((item) => `- ${item.path}\uFF1A${item.ok ? "\u6210\u529F" : `\u5931\u8D25\uFF08${item.error ?? "\u672A\u77E5\u539F\u56E0"}\uFF09`}`);
20898
+ return [head, ...lines].join("\n");
20899
+ }
19915
20900
 
19916
20901
  // src/host/integration/lsp/lsp-framing.ts
19917
20902
  var HEADER_END = Buffer.from("\r\n\r\n", "latin1");
@@ -20312,9 +21297,10 @@ var LspSession = class _LspSession {
20312
21297
  this.#stopped = true;
20313
21298
  return;
20314
21299
  }
20315
- if (chunk.data !== "") {
21300
+ const bytes = chunk.exactBytes ?? (chunk.data === "" ? void 0 : Buffer.from(chunk.data, "utf8"));
21301
+ if (bytes !== void 0 && bytes.length > 0) {
20316
21302
  delay = BUSY_POLL_MS;
20317
- for (const message of this.#parser.feed(chunk.data)) this.#dispatch(message);
21303
+ for (const message of this.#parser.feed(bytes)) this.#dispatch(message);
20318
21304
  }
20319
21305
  if (chunk.state === "exited") {
20320
21306
  this.#failAll(new LspSessionError("\u8BED\u8A00\u670D\u52A1\u5668\u5DF2\u9000\u51FA"));
@@ -20566,7 +21552,7 @@ function describeInstalled(available) {
20566
21552
  }
20567
21553
  var MAX_SESSIONS = 8;
20568
21554
  function sessionKeyPrefix(target, language) {
20569
- return `${target.connectionId}\0${target.root}\0${language}\0`;
21555
+ return `${target.connectionId}\0${target.root}\0${language}\0${target.sessionKey ?? ""}\0`;
20570
21556
  }
20571
21557
  function sessionKey(target, language, override) {
20572
21558
  const server = override === void 0 ? "" : `${override.command}\0${(override.args ?? []).join("\0")}`;
@@ -20605,7 +21591,7 @@ function createLspRegistry(deps) {
20605
21591
  `\u6CA1\u6709 ${language} \u7684\u9ED8\u8BA4\u8BED\u8A00\u670D\u52A1\u5668\u3002\u8BF7\u7528 \`server\` \u6307\u5B9A\u8981\u8FD0\u884C\u7684\u547D\u4EE4\uFF08\u8FDC\u7AEF\u9700\u8981\u6709\u8FD9\u4E2A\u7A0B\u5E8F\uFF09\u3002`
20606
21592
  );
20607
21593
  }
20608
- const transport = await deps.acquire(target.connectionId);
21594
+ const transport = await deps.acquire(target.connectionId, target.sessionKey);
20609
21595
  const available = transport.capabilities?.lspServers;
20610
21596
  if (available !== void 0 && !command.command.includes("/")) {
20611
21597
  if (!available.some((server) => server.command === command.command)) {
@@ -20712,6 +21698,21 @@ function createForwardToolBackend(acquire) {
20712
21698
  { connectionId }
20713
21699
  );
20714
21700
  }
21701
+ if (transport.netProbe !== void 0) {
21702
+ let probe2;
21703
+ try {
21704
+ probe2 = await transport.netProbe(normalizeProbeHost(request.remoteHost), request.remotePort, signal);
21705
+ } catch (error) {
21706
+ if (!(error instanceof SshDevError) || error.code !== "REMOTE_UNSUPPORTED") throw error;
21707
+ }
21708
+ if (probe2 !== void 0 && !probe2.reachable) {
21709
+ throw new SshDevError(
21710
+ "CONNECTION_UNREACHABLE",
21711
+ `\u8FDC\u7AEF\u7AEF\u53E3\u4E0D\u53EF\u8FBE\uFF1A${request.remoteHost}:${String(request.remotePort)}${probe2.error === void 0 ? "" : `\uFF08${probe2.error}\uFF09`}`,
21712
+ { connectionId, remoteHost: request.remoteHost, remotePort: request.remotePort }
21713
+ );
21714
+ }
21715
+ }
20715
21716
  const handle = await transport.forward(request, signal);
20716
21717
  sequence2 += 1;
20717
21718
  const id = `fwd-${String(sequence2)}`;
@@ -20745,6 +21746,9 @@ function createForwardToolBackend(acquire) {
20745
21746
  }
20746
21747
  };
20747
21748
  }
21749
+ function normalizeProbeHost(host) {
21750
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
21751
+ }
20748
21752
  function formatResult(value) {
20749
21753
  if (Array.isArray(value["forwards"])) {
20750
21754
  const forwards = value["forwards"];
@@ -21154,12 +22158,13 @@ function remoteGitDefinition(backend) {
21154
22158
  stashAction: readString3(record2["stashAction"]),
21155
22159
  all: record2["all"] === true
21156
22160
  });
22161
+ const lane = READ_ACTIONS.includes(action) ? "compute" : "interactive";
21157
22162
  const outcome = await backend.exec(target, {
21158
22163
  command: quoteAll(argv),
21159
22164
  cwd: resolveCwd(target, readString3(record2["cwd"])),
21160
22165
  env: { ...GIT_ENV },
21161
22166
  timeoutMs: readPositiveInt2(record2["timeoutMs"], DEFAULT_TIMEOUT_MS3)
21162
- }, exec.signal);
22167
+ }, exec.signal, lane);
21163
22168
  const capped = capLines(outcome.stdout, maxLines);
21164
22169
  const errCapped = capLines(outcome.stderr, maxLines);
21165
22170
  const base = {
@@ -21394,19 +22399,16 @@ function remoteLspToolDefinitions(deps) {
21394
22399
  const requested = parsed.path;
21395
22400
  if (requested === void 0) throw new Error(`${parsed.action} \u9700\u8981 path`);
21396
22401
  const target = targetFor3(backend, exec);
22402
+ const sessionKey2 = exec.agent?.session.header.id;
22403
+ const lspTarget = sessionKey2 === void 0 || sessionKey2 === "" ? { connectionId: target.connectionId, root: target.root } : { connectionId: target.connectionId, root: target.root, sessionKey: sessionKey2 };
21397
22404
  const path = resolveOrThrow3(target, requested);
21398
22405
  const language = languageForPath(path);
21399
22406
  const override = parseServer(parsed.server);
21400
22407
  if (parsed.action === "stop") {
21401
- const stopped = await registry.stop({ connectionId: target.connectionId, root: target.root }, language, exec.signal);
22408
+ const stopped = await registry.stop(lspTarget, language, exec.signal);
21402
22409
  return { action: "stop", path, language, stopped };
21403
22410
  }
21404
- const session = await registry.session(
21405
- { connectionId: target.connectionId, root: target.root },
21406
- language,
21407
- override,
21408
- exec.signal
21409
- );
22411
+ const session = await registry.session(lspTarget, language, override, exec.signal);
21410
22412
  const uri = pathToUri(path);
21411
22413
  if (parsed.action === "diagnostics") {
21412
22414
  const document = await backend.read(target, path, exec.signal);
@@ -22313,7 +23315,7 @@ async function relayCopy(deps, transport, request, source) {
22313
23315
 
22314
23316
  // src/wiring/tools-management.ts
22315
23317
  function wireManagementTools(env) {
22316
- const { ctx, limits, profiles, transports, registry, binService, toolBackend, sessionRoots, recordFor, transferService, transferTracker } = env;
23318
+ const { ctx, limits, profiles, transportLanes, registry, binService, toolBackend, sessionRoots, recordFor, transferService, transferTracker } = env;
22317
23319
  const rootRefFor = (target) => registry.records.find(
22318
23320
  (record2) => record2.root.location === "remote" && record2.root.connectionId === target.connectionId && record2.root.path === target.root
22319
23321
  )?.root;
@@ -22401,7 +23403,7 @@ function wireManagementTools(env) {
22401
23403
  return { kind: "undone", action: undone.action, id: String(undone.entry.id), message: undone.message };
22402
23404
  },
22403
23405
  copy: async ({ target, from, to, overwrite, recursive, signal }) => {
22404
- const acquire = async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport;
23406
+ const acquire = async (connectionId) => (await transportLanes.acquire(requireProfile(profiles, connectionId), "interactive")).transport;
22405
23407
  return copyRemoteFile(
22406
23408
  {
22407
23409
  transportFor: acquire,
@@ -22430,7 +23432,7 @@ function wireManagementTools(env) {
22430
23432
  );
22431
23433
  },
22432
23434
  probeEnv: async (connectionId, refresh, signal) => {
22433
- const transport = (await transports.acquire(requireProfile(profiles, connectionId))).transport;
23435
+ const transport = (await transportLanes.acquire(requireProfile(profiles, connectionId), "interactive")).transport;
22434
23436
  if (transport.probeEnv === void 0) return void 0;
22435
23437
  return transport.probeEnv(refresh, signal);
22436
23438
  }
@@ -22474,8 +23476,8 @@ function execWriteRootsSeam(env) {
22474
23476
  };
22475
23477
  }
22476
23478
  function wireTools(env) {
22477
- const { ctx, limits, profiles, transports, registry, indexFor, transferService, transferTracker, binService, notifySessionRemoved: notifySessionRemoved2 } = env;
22478
- const { sessionRoots, toolBackend, disposeTools } = wireAgentTools({ ctx, limits, profiles, transports, registry, indexFor });
23479
+ const { ctx, limits, profiles, transports, transportLanes, registry, indexFor, transferService, transferTracker, binService, notifySessionRemoved: notifySessionRemoved2 } = env;
23480
+ const { sessionRoots, toolBackend, disposeTools } = wireAgentTools({ ctx, limits, profiles, transports, transportLanes, registry, indexFor });
22479
23481
  const {
22480
23482
  recordFor,
22481
23483
  disposeJobTools,
@@ -22483,13 +23485,14 @@ function wireTools(env) {
22483
23485
  lspRegistry,
22484
23486
  disposeLspTools,
22485
23487
  assertWritable: assertWritable2
22486
- } = wireFileAndJobTools({ ctx, limits, profiles, transports, registry, sessionRoots });
23488
+ } = wireFileAndJobTools({ ctx, limits, profiles, transports, transportLanes, registry, sessionRoots });
22487
23489
  const sessionAdmin = wireSessionAdmin({ ctx, limits, notifySessionRemoved: notifySessionRemoved2 });
22488
23490
  const { dispose: disposeManagement } = wireManagementTools({
22489
23491
  ctx,
22490
23492
  limits,
22491
23493
  profiles,
22492
23494
  transports,
23495
+ transportLanes,
22493
23496
  registry,
22494
23497
  binService,
22495
23498
  toolBackend,
@@ -22521,7 +23524,7 @@ function wireTools(env) {
22521
23524
  };
22522
23525
  }
22523
23526
  function wireAgentTools(env) {
22524
- const { ctx, limits, profiles, transports, registry, indexFor } = env;
23527
+ const { ctx, limits, profiles, transports, transportLanes, registry, indexFor } = env;
22525
23528
  const sessionRoots = createSessionRootIndex({
22526
23529
  filePath: sessionRootsFilePath(),
22527
23530
  readFile: readFile10,
@@ -22546,7 +23549,7 @@ function wireAgentTools(env) {
22546
23549
  const covering = longestContainingRoot(candidates, normalizeRemotePath(cwd));
22547
23550
  return covering === void 0 ? void 0 : { connectionId, root: covering };
22548
23551
  },
22549
- acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
23552
+ acquire: async (connectionId, lane = "interactive") => (await transportLanes.acquire(requireProfile(profiles, connectionId), lane)).transport,
22550
23553
  enumerate: async (connectionId, root, signal) => {
22551
23554
  const result = await indexFor(connectionId).enumerate(root, signal === void 0 ? {} : { signal });
22552
23555
  return { paths: result.paths, truncated: result.truncated, source: result.source };
@@ -22573,7 +23576,7 @@ function wireAgentTools(env) {
22573
23576
  return { sessionRoots, toolBackend, disposeTools };
22574
23577
  }
22575
23578
  function wireFileAndJobTools(env) {
22576
- const { ctx, limits, profiles, transports, registry, sessionRoots } = env;
23579
+ const { ctx, limits, profiles, transportLanes, registry, sessionRoots } = env;
22577
23580
  const remoteRootFor = (cwd) => {
22578
23581
  const resolved = registry.routed.tryResolve(cwd);
22579
23582
  if (!resolved.ok) return void 0;
@@ -22591,7 +23594,10 @@ function wireFileAndJobTools(env) {
22591
23594
  // ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
22592
23595
  // two conversations opened on the same directory.
22593
23596
  resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
22594
- acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport
23597
+ acquire: async (connectionId, sessionKey2) => {
23598
+ const lane = sessionKey2 === void 0 || sessionKey2 === "" ? "interactive" : "stateful";
23599
+ return (await transportLanes.acquire(requireProfile(profiles, connectionId), lane, void 0, sessionKey2)).transport;
23600
+ }
22595
23601
  });
22596
23602
  const disposeJobTools = registerRemoteJobTools(ctx, jobBackend);
22597
23603
  const fileBackend = createRemoteFileBackend({
@@ -22633,7 +23639,10 @@ function wireFileAndJobTools(env) {
22633
23639
  }
22634
23640
  };
22635
23641
  const lspRegistry = createLspRegistry({
22636
- acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
23642
+ acquire: async (connectionId, sessionKey2) => {
23643
+ const lane = sessionKey2 === void 0 || sessionKey2 === "" ? "interactive" : "stateful";
23644
+ return (await transportLanes.acquire(requireProfile(profiles, connectionId), lane, void 0, sessionKey2)).transport;
23645
+ },
22637
23646
  onLog: (connectionId, message) => {
22638
23647
  ctx.logger?.info?.(`over-ssh: [${connectionId}] \u8BED\u8A00\u670D\u52A1\u5668\uFF1A${message}`);
22639
23648
  }