dsh-over-ssh 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +82 -4
  2. package/agents/agent-manifest.json +7 -7
  3. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  4. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  5. package/cordis.patch.yml +10 -12
  6. package/lib/client.js +161 -60
  7. package/lib/file-references.js +136 -13
  8. package/lib/fs.js +2 -1
  9. package/lib/index.js +1245 -165
  10. package/lib/types/src/config.d.ts +44 -0
  11. package/lib/types/src/host/app/backend.d.ts +31 -0
  12. package/lib/types/src/host/app/routed-fs.d.ts +238 -0
  13. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +17 -0
  14. package/lib/types/src/host/app/transport/transport-service.d.ts +66 -11
  15. package/lib/types/src/host/domain/capability.d.ts +6 -0
  16. package/lib/types/src/host/domain/roots/route-table.d.ts +101 -0
  17. package/lib/types/src/host/domain/session/session-admin.d.ts +8 -0
  18. package/lib/types/src/host/infra/backend/poll-watch.d.ts +119 -0
  19. package/lib/types/src/host/infra/backend/remote-backend.d.ts +297 -0
  20. package/lib/types/src/host/infra/cache/budget.d.ts +68 -0
  21. package/lib/types/src/host/infra/cache/versioned.d.ts +98 -0
  22. package/lib/types/src/host/infra/transport/ssh/askpass.d.ts +132 -0
  23. package/lib/types/src/host/infra/transport/transport.d.ts +48 -0
  24. package/lib/types/src/host/integration/agent/askpass-registry.d.ts +65 -0
  25. package/lib/types/src/host/integration/fs/remote-index.d.ts +183 -0
  26. package/lib/types/src/host/integration/roots/remote-home.d.ts +68 -0
  27. package/lib/types/src/host/integration/roots/root-registry.d.ts +148 -0
  28. package/lib/types/src/index.d.ts +7 -0
  29. package/lib/types/src/local-bridge.d.ts +4 -4
  30. package/lib/types/src/wiring/connections.d.ts +261 -0
  31. package/lib/types/src/wiring/install-memo.d.ts +31 -0
  32. package/package.json +1 -5
  33. package/lib/local-bridge-refs.js +0 -16
  34. package/lib/types/src/local-bridge-refs.d.ts +0 -25
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,205 @@ 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
+ * Bumped by every explicit release. An `acquire()` that had to discard a lost transport checks this
1596
+ * after the async discard: a release that arrived in that window must not be undone by recreating the
1597
+ * lane. A global counter avoids a per-session map that would itself retain deleted session ids.
1598
+ */
1599
+ #releaseGeneration = 0;
1583
1600
  constructor(options) {
1584
1601
  this.#selector = options.selector;
1585
1602
  this.#factories = options.factories;
1586
1603
  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) {
1604
+ this.#statefulLaneLimit = Math.max(0, Math.floor(options.statefulLaneLimit ?? 8));
1605
+ }
1606
+ async acquire(profile, signalOrOptions, signalArgument) {
1607
+ const legacySignal = typeof signalOrOptions?.addEventListener === "function" ? signalOrOptions : void 0;
1608
+ const options = legacySignal === void 0 ? signalOrOptions : void 0;
1609
+ const signal = legacySignal ?? signalArgument;
1610
+ const key = laneKey(profile.id, options);
1611
+ const lane = options?.lane ?? "interactive";
1597
1612
  const decided = this.#selector.decide(profile);
1598
- const existing = this.#entries.get(profile.id);
1613
+ const releaseGeneration = this.#releaseGeneration;
1614
+ if (lane === "stateful" && this.#statefulLaneLimit > 0 && !this.#entries.has(key) && !this.#pending.has(key) && this.#statefulLaneCount(profile.id, isWatchSessionKey(options?.sessionKey ?? "")) >= this.#statefulLaneLimit) {
1615
+ throw new SshDevError(
1616
+ "CONNECTION_INVALID",
1617
+ `stateful transport \u6570\u91CF\u5DF2\u8FBE\u4E0A\u9650\uFF08${this.#statefulLaneLimit} \u6761/\u8FDE\u63A5\uFF09`,
1618
+ { connectionId: profile.id, limit: this.#statefulLaneLimit }
1619
+ );
1620
+ }
1621
+ this.#rememberSlot(profile.id, key);
1622
+ const existing = this.#entries.get(key);
1599
1623
  if (existing !== void 0 && existing.transport.state !== "lost") {
1600
1624
  if (decisionKey(existing.decision) === decisionKey(decided)) return existing;
1601
1625
  }
1602
1626
  if (existing !== void 0) {
1603
1627
  await this.#discard(profile.id);
1628
+ if (this.#releaseGeneration !== releaseGeneration) {
1629
+ this.#forgetSlotIfIdle(profile.id, key);
1630
+ throw new SshDevError("CANCELLED", `\u8FDE\u63A5\u5728\u91CD\u5EFA\u8FC7\u7A0B\u4E2D\u5DF2\u88AB\u91CA\u653E\uFF1A${profile.id}`, { connectionId: profile.id });
1631
+ }
1632
+ this.#rememberSlot(profile.id, key);
1604
1633
  }
1605
- const inflight = this.#pending.get(profile.id);
1634
+ const inflight = this.#pending.get(key);
1606
1635
  if (inflight !== void 0) return inflight;
1607
1636
  const attempt = (async () => {
1608
1637
  const decision = this.#preflight === void 0 ? decided : await this.#preflight(profile, decided, signal);
1609
- const transport = this.#create(profile, decision);
1638
+ const transport = this.#create(profile, decision, lane, options?.sessionKey);
1610
1639
  try {
1611
1640
  await transport.connect(signal);
1612
1641
  } catch (error) {
1613
1642
  await transport.dispose().catch(() => void 0);
1614
1643
  throw error;
1615
1644
  }
1616
- if (this.#retired.delete(profile.id)) {
1645
+ if (this.#retired.delete(key)) {
1617
1646
  await transport.dispose().catch(() => void 0);
1618
1647
  throw new SshDevError("CANCELLED", `\u8FDE\u63A5\u5728\u5EFA\u7ACB\u8FC7\u7A0B\u4E2D\u5DF2\u88AB\u91CA\u653E\uFF1A${profile.id}`, { connectionId: profile.id });
1619
1648
  }
1620
- const acquired = { transport, decision };
1621
- this.#entries.set(profile.id, acquired);
1649
+ const acquired = { transport, decision, lane, connectionId: profile.id };
1650
+ this.#entries.set(key, acquired);
1622
1651
  return acquired;
1623
1652
  })().finally(() => {
1624
- this.#pending.delete(profile.id);
1625
- this.#retired.delete(profile.id);
1653
+ this.#pending.delete(key);
1654
+ this.#retired.delete(key);
1655
+ this.#forgetSlotIfIdle(profile.id, key);
1626
1656
  });
1627
- this.#pending.set(profile.id, attempt);
1657
+ this.#pending.set(key, attempt);
1628
1658
  return attempt;
1629
1659
  }
1630
- /** The cached transport for a connection, if any. */
1631
- current(connectionId) {
1632
- return this.#entries.get(connectionId);
1660
+ /**
1661
+ * The cached transport for a connection.
1662
+ *
1663
+ * Without options this is the legacy interactive slot, so existing callers keep their old meaning.
1664
+ */
1665
+ current(connectionId, options) {
1666
+ return this.#entries.get(laneKey(connectionId, options));
1633
1667
  }
1634
1668
  /** Decisions currently in force, for the connection status UI. */
1635
1669
  decisions() {
1636
- return [...this.#entries.values()].map((entry) => entry.decision);
1670
+ const seen = /* @__PURE__ */ new Set();
1671
+ const decisions = [];
1672
+ for (const entry of this.#entries.values()) {
1673
+ const key = entry.connectionId ?? decisionKey(entry.decision);
1674
+ if (seen.has(key)) continue;
1675
+ seen.add(key);
1676
+ decisions.push(entry.decision);
1677
+ }
1678
+ return decisions;
1637
1679
  }
1638
- /** Dispose and forget one connection. */
1680
+ /** Dispose and forget **every lane** of one connection. */
1639
1681
  async release(connectionId) {
1682
+ this.#releaseGeneration++;
1640
1683
  await this.#discard(connectionId);
1641
1684
  }
1642
- /** Dispose every connection (plugin unload, connection-service shutdown). */
1685
+ /** Number of cached/pending stateful lanes of one class (watch vs session) on one connection. */
1686
+ #statefulLaneCount(connectionId, watchOnly) {
1687
+ let count = 0;
1688
+ const marker = "\0stateful\0";
1689
+ for (const key of this.#slots.get(connectionId) ?? []) {
1690
+ const index = key.indexOf(marker);
1691
+ if (index < 0) continue;
1692
+ if (isWatchSessionKey(key.slice(index + marker.length)) === watchOnly) count++;
1693
+ }
1694
+ return count;
1695
+ }
1696
+ /** Unique DSH session keys currently owning a stateful lane (used by the idle janitor). */
1697
+ statefulSessions() {
1698
+ const sessions = /* @__PURE__ */ new Set();
1699
+ for (const keys of this.#slots.values()) {
1700
+ for (const key of keys) {
1701
+ const marker = "\0stateful\0";
1702
+ const index = key.indexOf(marker);
1703
+ if (index >= 0) sessions.add(key.slice(index + marker.length));
1704
+ }
1705
+ }
1706
+ return [...sessions];
1707
+ }
1708
+ /** Release stateful lanes whose DSH session is no longer live. */
1709
+ async pruneStatefulSessions(isLive) {
1710
+ for (const sessionKey2 of this.statefulSessions()) {
1711
+ if (!isLive(sessionKey2)) await this.releaseSession(sessionKey2);
1712
+ }
1713
+ }
1714
+ /**
1715
+ * Dispose the `stateful` lane of one DSH session on every connection.
1716
+ *
1717
+ * Stateful lanes are keyed by session, so deleting a DSH session would otherwise leave one agent
1718
+ * transport per connection alive for the life of the host process (and with it the daemon-side jobs/LSP
1719
+ * ownership records that transport created). Other lanes are untouched.
1720
+ *
1721
+ * @param sessionKey - the DSH session id used when the stateful lane was acquired.
1722
+ */
1723
+ async releaseSession(sessionKey2) {
1724
+ this.#releaseGeneration++;
1725
+ const disposals = [];
1726
+ for (const [connectionId, keys] of [...this.#slots]) {
1727
+ for (const key of [...keys]) {
1728
+ if (key !== laneKey(connectionId, { lane: "stateful", sessionKey: sessionKey2 })) continue;
1729
+ if (this.#pending.has(key)) this.#retired.add(key);
1730
+ const entry = this.#entries.get(key);
1731
+ this.#entries.delete(key);
1732
+ keys.delete(key);
1733
+ if (entry !== void 0) {
1734
+ disposals.push((async () => {
1735
+ const sessionAware = entry.transport;
1736
+ if (typeof sessionAware.endSession === "function") {
1737
+ await sessionAware.endSession(sessionKey2).catch(() => void 0);
1738
+ }
1739
+ await entry.transport.dispose().catch(() => void 0);
1740
+ })());
1741
+ }
1742
+ }
1743
+ if (keys.size === 0) this.#slots.delete(connectionId);
1744
+ }
1745
+ await Promise.all(disposals);
1746
+ }
1747
+ /** Dispose every connection and lane (plugin unload, connection-service shutdown). */
1643
1748
  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)));
1749
+ this.#releaseGeneration++;
1750
+ for (const key of this.#pending.keys()) this.#retired.add(key);
1751
+ await Promise.all([...this.#slots.keys()].map((id) => this.#discard(id)));
1646
1752
  }
1647
- #create(profile, decision) {
1753
+ #create(profile, decision, lane, sessionKey2) {
1648
1754
  if (decision.kind === "agent") {
1649
1755
  if (this.#factories.createAgent === void 0) {
1650
1756
  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
1757
  }
1652
- return this.#factories.createAgent(profile, decision);
1758
+ return this.#factories.createAgent(profile, decision, lane, sessionKey2);
1653
1759
  }
1654
1760
  return decision.kind === "ssh2" ? this.#factories.createSsh2(profile, decision) : this.#factories.createAlias(profile, decision);
1655
1761
  }
1762
+ /** Record that a profile owns a cache key (idempotent). */
1763
+ #rememberSlot(connectionId, key) {
1764
+ const keys = this.#slots.get(connectionId);
1765
+ if (keys === void 0) {
1766
+ this.#slots.set(connectionId, /* @__PURE__ */ new Set([key]));
1767
+ return;
1768
+ }
1769
+ keys.add(key);
1770
+ }
1771
+ /** Drop a slot key once neither a cached entry nor an in-flight attempt uses it. */
1772
+ #forgetSlotIfIdle(connectionId, key) {
1773
+ if (this.#entries.has(key) || this.#pending.has(key)) return;
1774
+ const keys = this.#slots.get(connectionId);
1775
+ if (keys === void 0) return;
1776
+ keys.delete(key);
1777
+ if (keys.size === 0) this.#slots.delete(connectionId);
1778
+ }
1656
1779
  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);
1780
+ const keys = [...this.#slots.get(connectionId) ?? []];
1781
+ for (const key of keys) {
1782
+ if (this.#pending.has(key)) this.#retired.add(key);
1783
+ }
1784
+ const entries = keys.map((key) => {
1785
+ const entry = this.#entries.get(key);
1786
+ this.#entries.delete(key);
1787
+ return entry;
1788
+ });
1789
+ this.#slots.delete(connectionId);
1790
+ await Promise.all(entries.map(async (entry) => {
1791
+ if (entry !== void 0) await entry.transport.dispose().catch(() => void 0);
1792
+ }));
1661
1793
  }
1662
1794
  };
1663
1795
 
@@ -1932,6 +2064,17 @@ var Config = z.object({
1932
2064
  transferChunkBytes: z.natural().default(4 * 1024 * 1024),
1933
2065
  /** Chunks in flight at once during a transfer. 1 restores the sequential behaviour. */
1934
2066
  transferConcurrency: z.natural().default(4),
2067
+ /**
2068
+ * Split one connection into traffic lanes (`interactive` / `bulk` / `compute`).
2069
+ *
2070
+ * On by default: transfers then open their own transport instead of competing with an interactive
2071
+ * `remote_exec` for the connection's in-flight window. A lane that fails to connect falls back to the
2072
+ * interactive transport, and turning this off restores exactly the pre-lane behaviour (one transport
2073
+ * per connection). `stateful` carries long-lived job/LSP/watch state (watch uses a dedicated per-session key when an ambient session id is available).
2074
+ */
2075
+ transportLanes: z.boolean().default(true),
2076
+ /** Upper bound on concurrent stateful lane transports per connection, separately for job/LSP sessions and watch (default 8 each). */
2077
+ statefulLanes: z.natural().default(8),
1935
2078
  /** How many finished transfers the progress table retains (FR-2.6.4). */
1936
2079
  transferHistoryEntries: z.natural().default(50),
1937
2080
  // 根内回收站目录名(`<root>/<binDir>`)。改过名(`.dsh-remote-workspace/trash` → 这里):旧目录仍被
@@ -2410,9 +2553,14 @@ function parseGrepArgs(args) {
2410
2553
  caseSensitive: record2["caseSensitive"] === true,
2411
2554
  noIgnore: record2["noIgnore"] === true,
2412
2555
  include: include.values.length === 0 ? void 0 : include.values,
2413
- maxResults: clampInt(record2["maxResults"], MAX_GREP_RESULTS, MAX_GREP_RESULTS)
2556
+ maxResults: clampInt(record2["maxResults"], MAX_GREP_RESULTS, MAX_GREP_RESULTS),
2557
+ offset: clampOffset(record2["offset"])
2414
2558
  };
2415
2559
  }
2560
+ function clampOffset(value) {
2561
+ const number = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0;
2562
+ return Math.max(0, number);
2563
+ }
2416
2564
  function parseGlobArgs(args) {
2417
2565
  const record2 = args ?? {};
2418
2566
  const raw = record2["pattern"];
@@ -2569,7 +2717,7 @@ function readSearchTruncation(raw) {
2569
2717
  function searchTruncationAdvice(reason) {
2570
2718
  switch (reason) {
2571
2719
  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";
2720
+ 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
2721
  case "bytesScanned":
2574
2722
  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
2723
  case "maxFileBytes":
@@ -4372,6 +4520,7 @@ function parseSessionTrashIndex(text8) {
4372
4520
  segment: record2["segment"],
4373
4521
  originalSegment: record2["originalSegment"],
4374
4522
  title: typeof record2["title"] === "string" ? record2["title"] : void 0,
4523
+ ...typeof record2["parentSessionId"] === "string" && record2["parentSessionId"] !== "" ? { parentSessionId: record2["parentSessionId"] } : {},
4375
4524
  bytes: typeof record2["bytes"] === "number" && Number.isFinite(record2["bytes"]) ? record2["bytes"] : 0,
4376
4525
  deletedAtMs: record2["deletedAtMs"],
4377
4526
  movedBy: record2["movedBy"] === "copy" ? "copy" : "rename",
@@ -4418,6 +4567,7 @@ function makeSessionTrashEntry(input) {
4418
4567
  segment: input.segment,
4419
4568
  originalSegment: input.originalSegment,
4420
4569
  title: input.title,
4570
+ ...input.parentSessionId === void 0 ? {} : { parentSessionId: input.parentSessionId },
4421
4571
  bytes: input.bytes,
4422
4572
  deletedAtMs: input.deletedAtMs,
4423
4573
  movedBy: input.movedBy,
@@ -4693,6 +4843,7 @@ function createSessionAdminDelete(ctx) {
4693
4843
  segment: entryId,
4694
4844
  originalSegment: record2.segment,
4695
4845
  title: record2.title,
4846
+ parentSessionId: record2.parentSessionId,
4696
4847
  bytes: record2.bytes,
4697
4848
  deletedAtMs,
4698
4849
  movedBy,
@@ -4810,31 +4961,63 @@ function createSessionAdminService(deps) {
4810
4961
  if (entry === void 0) {
4811
4962
  throw new SshDevError("TRASH_NOT_FOUND", `\u56DE\u6536\u7AD9\u6761\u76EE\u4E0D\u5B58\u5728\uFF1A${entryId}`, { entryId });
4812
4963
  }
4813
- const target = joinPath(sessionsRoot, entry.projectDir, entry.originalSegment);
4814
- const occupied = await deps.stat(target) !== void 0;
4815
- const plan = planRestoreSession(entry, { occupied });
4816
- if (plan.kind === "refuse") {
4817
- throw new SshDevError("TRANSFER_CONFLICT", `\u539F\u8DEF\u5F84\u5DF2\u88AB\u5360\u7528\uFF0C\u62D2\u7EDD\u9759\u9ED8\u6362\u540D\u8FD8\u539F\uFF1A${target}`, {
4818
- entryId,
4819
- suggestedId: plan.suggestedId,
4820
- target
4821
- });
4822
- }
4823
- const from = entryDir(entry.segment);
4824
- let movedBy;
4825
- try {
4826
- movedBy = await deps.moveDir(from, target);
4827
- } catch (error) {
4828
- const landed = await deps.stat(target) !== void 0;
4829
- if (!landed) {
4830
- throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FD8\u539F\u4F1A\u8BDD\u5931\u8D25\uFF1A${entry.sessionId}`, {
4831
- entryId,
4832
- cause: error instanceof Error ? error.message : String(error)
4964
+ const restoreOne = async (item) => {
4965
+ const target = joinPath(sessionsRoot, item.projectDir, item.originalSegment);
4966
+ const occupied = await deps.stat(target) !== void 0;
4967
+ const plan = planRestoreSession(item, { occupied });
4968
+ if (plan.kind === "refuse") {
4969
+ throw new SshDevError("TRANSFER_CONFLICT", `\u539F\u8DEF\u5F84\u5DF2\u88AB\u5360\u7528\uFF0C\u62D2\u7EDD\u9759\u9ED8\u6362\u540D\u8FD8\u539F\uFF1A${target}`, {
4970
+ entryId: item.id,
4971
+ suggestedId: plan.suggestedId,
4972
+ target
4973
+ });
4974
+ }
4975
+ const from = entryDir(item.segment);
4976
+ let movedBy;
4977
+ try {
4978
+ movedBy = await deps.moveDir(from, target);
4979
+ } catch (error) {
4980
+ const landed = await deps.stat(target) !== void 0;
4981
+ if (!landed) {
4982
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FD8\u539F\u4F1A\u8BDD\u5931\u8D25\uFF1A${item.sessionId}`, {
4983
+ entryId: item.id,
4984
+ cause: error instanceof Error ? error.message : String(error)
4985
+ });
4986
+ }
4987
+ movedBy = "copy";
4988
+ }
4989
+ return { entry: item, restoredPath: target, movedBy };
4990
+ };
4991
+ const parentStillBinned = entry.parentSessionId !== void 0 && entries.some((item) => item.sessionId === entry.parentSessionId);
4992
+ const restored = await restoreOne(entry);
4993
+ const childrenOf = /* @__PURE__ */ new Map();
4994
+ for (const item of entries) {
4995
+ if (item.id === entry.id || item.parentSessionId === void 0) continue;
4996
+ const bucket = childrenOf.get(item.parentSessionId);
4997
+ if (bucket === void 0) childrenOf.set(item.parentSessionId, [item]);
4998
+ else bucket.push(item);
4999
+ }
5000
+ const restoredSubsessions = [];
5001
+ const failedSubsessions = [];
5002
+ const removedIds = /* @__PURE__ */ new Set([entry.id]);
5003
+ const queue = [...childrenOf.get(entry.sessionId) ?? []];
5004
+ while (queue.length > 0) {
5005
+ const child = queue.shift();
5006
+ if (child === void 0) break;
5007
+ if (removedIds.has(child.id)) continue;
5008
+ try {
5009
+ const childRestored = await restoreOne(child);
5010
+ restoredSubsessions.push({ sessionId: child.sessionId, restoredPath: childRestored.restoredPath });
5011
+ removedIds.add(child.id);
5012
+ queue.push(...childrenOf.get(child.sessionId) ?? []);
5013
+ } catch (error) {
5014
+ failedSubsessions.push({
5015
+ sessionId: child.sessionId,
5016
+ reason: error instanceof Error ? error.message : String(error)
4833
5017
  });
4834
5018
  }
4835
- movedBy = "copy";
4836
5019
  }
4837
- const remaining = entries.filter((item) => item.id !== entryId);
5020
+ const remaining = entries.filter((item) => !removedIds.has(item.id));
4838
5021
  try {
4839
5022
  await writeIndex(remaining);
4840
5023
  } catch (error) {
@@ -4843,7 +5026,7 @@ function createSessionAdminService(deps) {
4843
5026
  cause: error instanceof Error ? error.message : String(error)
4844
5027
  });
4845
5028
  }
4846
- return { entry, restoredPath: target, movedBy };
5029
+ return { ...restored, restoredSubsessions, failedSubsessions, parentStillBinned };
4847
5030
  });
4848
5031
  const purge = (entryId, options = {}) => {
4849
5032
  void options;
@@ -6447,14 +6630,26 @@ init_errors();
6447
6630
 
6448
6631
  // src/host/infra/transport/agent/agent-protocol-frames.ts
6449
6632
  init_errors();
6450
- var AGENT_PROTOCOL_VERSION = 1;
6633
+ import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from "node:zlib";
6634
+ var AGENT_PROTOCOL_VERSION = 3;
6451
6635
  var AGENT_CLIENT_ID = "dsh-over-ssh/0.1.0";
6452
6636
  var MAX_AGENT_FRAME_BYTES = 16 << 20;
6637
+ var AGENT_FRAME_FLAG_ZSTD = 1;
6638
+ var AGENT_ZSTD_MIN_BYTES = 512;
6639
+ var ZSTD_LEVEL = 1;
6640
+ function zstdCompress(payload) {
6641
+ return zstdCompressSync(payload, { params: { [zlibConstants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } });
6642
+ }
6643
+ function likelyCompressible(payload) {
6644
+ const sample = payload.byteLength > 2048 ? payload.subarray(0, 2048) : payload;
6645
+ const compressed = zstdCompress(sample);
6646
+ return compressed.byteLength < sample.byteLength * 0.9;
6647
+ }
6453
6648
  var AGENT_KIND_JSON = 74;
6454
6649
  var AGENT_KIND_BLOB = 66;
6455
6650
  var DEFAULT_AGENT_WATCHER_ID = "default";
6456
6651
  var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 15e3;
6457
- var DEFAULT_AGENT_MAX_IN_FLIGHT = 8;
6652
+ var DEFAULT_AGENT_MAX_IN_FLIGHT = 16;
6458
6653
  var DEFAULT_AGENT_MODE = "--shared";
6459
6654
  var AgentProtocolError = class extends Error {
6460
6655
  code;
@@ -6476,10 +6671,20 @@ function encodeFrame(kind, payload) {
6476
6671
  limit: MAX_AGENT_FRAME_BYTES
6477
6672
  });
6478
6673
  }
6479
- const header = new Uint8Array(5);
6674
+ let body = payload;
6675
+ let flags = 0;
6676
+ if (payload.byteLength >= AGENT_ZSTD_MIN_BYTES && (payload.byteLength <= 4096 || likelyCompressible(payload))) {
6677
+ const compressed = zstdCompress(payload);
6678
+ if (compressed.byteLength < payload.byteLength) {
6679
+ body = compressed;
6680
+ flags = AGENT_FRAME_FLAG_ZSTD;
6681
+ }
6682
+ }
6683
+ const header = new Uint8Array(6);
6480
6684
  header[0] = kind;
6481
- new DataView(header.buffer).setUint32(1, payload.byteLength, false);
6482
- return { header, payload };
6685
+ header[1] = flags;
6686
+ new DataView(header.buffer).setUint32(2, body.byteLength, false);
6687
+ return { header, payload: body };
6483
6688
  }
6484
6689
  function encodeFrameBytes(kind, payload) {
6485
6690
  const { header, payload: body } = encodeFrame(kind, payload);
@@ -6489,24 +6694,44 @@ function encodeFrameBytes(kind, payload) {
6489
6694
  return frame;
6490
6695
  }
6491
6696
  function decodeFrame(bytes) {
6492
- if (bytes.byteLength < 5) return null;
6697
+ if (bytes.byteLength < 6) return null;
6493
6698
  const kind = bytes[0];
6494
- const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1, false);
6699
+ const flags = bytes[1];
6700
+ const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(2, false);
6495
6701
  if (kind !== AGENT_KIND_JSON && kind !== AGENT_KIND_BLOB) {
6496
6702
  throw new AgentProtocolError("E_PROTOCOL", `\u672A\u77E5\u7684\u5E27\u7C7B\u578B\uFF1A${kind}`, { kind });
6497
6703
  }
6704
+ if ((flags & ~AGENT_FRAME_FLAG_ZSTD) !== 0) {
6705
+ throw new AgentProtocolError("E_PROTOCOL", `\u672A\u77E5\u7684\u5E27\u6807\u5FD7\uFF1A${flags}`, { flags });
6706
+ }
6498
6707
  if (length > MAX_AGENT_FRAME_BYTES) {
6499
6708
  throw new AgentProtocolError("E_TOO_LARGE", `\u58F0\u660E\u957F\u5EA6\u8D85\u8FC7\u4E0A\u9650\uFF08${length} > ${MAX_AGENT_FRAME_BYTES}\uFF09`, {
6500
6709
  declared: length,
6501
6710
  limit: MAX_AGENT_FRAME_BYTES
6502
6711
  });
6503
6712
  }
6504
- if (bytes.byteLength - 5 < length) return null;
6505
- return { frame: { kind, payload: bytes.slice(5, 5 + length) }, consumed: 5 + length };
6713
+ if (bytes.byteLength - 6 < length) return null;
6714
+ let payload = bytes.slice(6, 6 + length);
6715
+ if ((flags & AGENT_FRAME_FLAG_ZSTD) !== 0) {
6716
+ try {
6717
+ payload = zstdDecompressSync(payload, { maxOutputLength: MAX_AGENT_FRAME_BYTES + 1 });
6718
+ } catch (error) {
6719
+ if (error instanceof RangeError) {
6720
+ throw new AgentProtocolError("E_TOO_LARGE", "\u89E3\u538B\u540E\u5E27\u8D85\u8FC7\u4E0A\u9650", { limit: MAX_AGENT_FRAME_BYTES });
6721
+ }
6722
+ throw new AgentProtocolError("E_PROTOCOL", "zstd \u5E27\u89E3\u538B\u5931\u8D25", { reason: error instanceof Error ? error.message : String(error) });
6723
+ }
6724
+ if (payload.byteLength > MAX_AGENT_FRAME_BYTES) {
6725
+ throw new AgentProtocolError("E_TOO_LARGE", `\u89E3\u538B\u540E\u5E27\u8D85\u8FC7\u4E0A\u9650\uFF08${payload.byteLength}\uFF09`, { limit: MAX_AGENT_FRAME_BYTES });
6726
+ }
6727
+ }
6728
+ return { frame: { kind, payload }, consumed: 6 + length };
6506
6729
  }
6507
6730
  var COMPACT_MIN_BYTES = 64 * 1024;
6731
+ var IDLE_STORE_BYTES = 64 * 1024;
6508
6732
  function growTo(needed) {
6509
- return Math.min(Math.max(needed * 2, 64 * 1024), MAX_AGENT_FRAME_BYTES + 64 * 1024 + 5);
6733
+ const capped = Math.min(Math.max(needed * 2, 64 * 1024), MAX_AGENT_FRAME_BYTES + 64 * 1024 + 6);
6734
+ return Math.max(capped, needed);
6510
6735
  }
6511
6736
  var AgentFrameParser = class {
6512
6737
  /** Backing store. Its **capacity** may exceed the bytes in use — see {@link growTo}. */
@@ -6533,6 +6758,7 @@ var AgentFrameParser = class {
6533
6758
  if (this.#start === this.#end) {
6534
6759
  this.#start = 0;
6535
6760
  this.#end = 0;
6761
+ if (this.#store.byteLength > IDLE_STORE_BYTES) this.#store = new Uint8Array(IDLE_STORE_BYTES);
6536
6762
  } else if (this.#start >= COMPACT_MIN_BYTES) {
6537
6763
  this.#shift();
6538
6764
  }
@@ -6636,6 +6862,7 @@ function capabilitiesFromAgent(caps, extras = {}) {
6636
6862
  git: source.git === true,
6637
6863
  ripgrep: source.ripgrep === true,
6638
6864
  grep: source.grep === true,
6865
+ ...source.grepPaging === void 0 ? {} : { grepPaging: source.grepPaging === true },
6639
6866
  base64: false,
6640
6867
  findMaxDepth: false,
6641
6868
  sha256: null,
@@ -6671,6 +6898,14 @@ function readAgentCaps(result) {
6671
6898
  ...typeof caps["grep"] === "boolean" ? { grep: caps["grep"] } : {},
6672
6899
  ...typeof caps["exec"] === "boolean" ? { exec: caps["exec"] } : {},
6673
6900
  ...typeof caps["cancel"] === "boolean" ? { cancel: caps["cancel"] } : {},
6901
+ // 增补能力位:新增字段时**必须**在这里放行,否则 hello 发了、宿主也永远看不到(分页门禁就是靠它)。
6902
+ ...typeof caps["batch"] === "boolean" ? { batch: caps["batch"] } : {},
6903
+ ...typeof caps["readGzip"] === "boolean" ? { readGzip: caps["readGzip"] } : {},
6904
+ ...typeof caps["repoSummary"] === "boolean" ? { repoSummary: caps["repoSummary"] } : {},
6905
+ ...typeof caps["patchMany"] === "boolean" ? { patchMany: caps["patchMany"] } : {},
6906
+ ...typeof caps["grepPaging"] === "boolean" ? { grepPaging: caps["grepPaging"] } : {},
6907
+ ...typeof caps["netProbe"] === "boolean" ? { netProbe: caps["netProbe"] } : {},
6908
+ ...typeof caps["resumableUpload"] === "boolean" ? { resumableUpload: caps["resumableUpload"] } : {},
6674
6909
  ...readLspServers(caps["lspServers"])
6675
6910
  };
6676
6911
  }
@@ -6890,7 +7125,9 @@ function readSearchResult(value) {
6890
7125
  truncatedReasons: readStringArray(record2, "truncatedReasons"),
6891
7126
  // `?? undefined` because the helper reports "absent" as `null`, and this field's "absent" must stay
6892
7127
  // distinct from a real `0`.
6893
- filesFiltered: readOptionalNumber(record2, "filesFiltered") ?? void 0
7128
+ filesFiltered: readOptionalNumber(record2, "filesFiltered") ?? void 0,
7129
+ total: readOptionalNumber(record2, "total"),
7130
+ nextOffset: readOptionalNumber(record2, "nextOffset")
6894
7131
  };
6895
7132
  }
6896
7133
  function readWatchStartResult(value) {
@@ -7388,7 +7625,9 @@ var AgentProtocolClient = class {
7388
7625
  if (session !== void 0 && session !== "") envelope["session"] = session;
7389
7626
  try {
7390
7627
  this.#session.write(encodeFrameBytes(AGENT_KIND_JSON, TEXT_ENCODER.encode(JSON.stringify(envelope))));
7391
- } catch {
7628
+ } catch (error) {
7629
+ this.#pending.delete(id);
7630
+ 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" }));
7392
7631
  }
7393
7632
  }
7394
7633
  #deadlineError(method) {
@@ -7895,6 +8134,7 @@ function createNodeLongSpawner() {
7895
8134
 
7896
8135
  // src/host/infra/transport/agent/agent-transport.ts
7897
8136
  init_transport_base();
8137
+ import { gunzipSync } from "node:zlib";
7898
8138
  var DEFAULT_AGENT_PATH = "~/.dsh/over-ssh/dsh-over-ssh-agent";
7899
8139
  var AgentTransport = class extends TransportBase {
7900
8140
  id;
@@ -8116,6 +8356,8 @@ var AgentTransport = class extends TransportBase {
8116
8356
  timedOut: record2["timedOut"] === true,
8117
8357
  stdout,
8118
8358
  stderr,
8359
+ ...nonEmptyString(record2["stdoutBase64"]) === void 0 ? {} : { stdoutBase64: nonEmptyString(record2["stdoutBase64"]) },
8360
+ ...nonEmptyString(record2["stderrBase64"]) === void 0 ? {} : { stderrBase64: nonEmptyString(record2["stderrBase64"]) },
8119
8361
  durationMs: agentDuration ?? Math.max(0, this.#now() - startedAt),
8120
8362
  // The combined alias stays honest about what it covers: either stream was cut.
8121
8363
  truncated: stdoutTruncated || stderrTruncated,
@@ -8161,6 +8403,90 @@ var AgentTransport = class extends TransportBase {
8161
8403
  version: version ?? ""
8162
8404
  };
8163
8405
  }
8406
+ /** Remote TCP reachability preflight (`net.probe`). */
8407
+ async netProbe(host, port, signal) {
8408
+ const result = await this.#rpc("net.probe", { host, port }, signal);
8409
+ const record2 = asRecord(result) ?? {};
8410
+ return {
8411
+ reachable: record2["reachable"] === true,
8412
+ ...typeof record2["error"] === "string" && record2["error"] !== "" ? { error: record2["error"] } : {}
8413
+ };
8414
+ }
8415
+ /** One-round-trip project overview (`repo.summary`). */
8416
+ async repoSummary(root, signal) {
8417
+ await this.connect(signal);
8418
+ const client = this.#requireClient();
8419
+ let result;
8420
+ try {
8421
+ result = await client.request("repo.summary", { root }, this.#requestOptions(void 0, signal));
8422
+ } catch (error) {
8423
+ throw this.#translate(error);
8424
+ }
8425
+ const record2 = asRecord(result);
8426
+ if (record2 === void 0) throw new Error("repo.summary \u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u7ED3\u679C");
8427
+ return record2;
8428
+ }
8429
+ /** Multi-file edit in one round trip (`fs.patchMany`). */
8430
+ async patchMany(edits, atomic, signal) {
8431
+ await this.connect(signal);
8432
+ const client = this.#requireClient();
8433
+ let result;
8434
+ try {
8435
+ result = await client.request("fs.patchMany", { edits, atomic }, this.#requestOptions(void 0, signal));
8436
+ } catch (error) {
8437
+ throw this.#translate(error);
8438
+ }
8439
+ const record2 = asRecord(result);
8440
+ if (record2 === void 0) throw new Error("fs.patchMany \u8FD4\u56DE\u4E86\u65E0\u6CD5\u89E3\u6790\u7684\u7ED3\u679C");
8441
+ return record2;
8442
+ }
8443
+ /**
8444
+ * Read several file ranges in one `fs.readMany` round trip.
8445
+ *
8446
+ * Per-item `encoding: "gzip"` payloads are gunzipped here; a single item's failure stays in its item.
8447
+ * Older agents answer `E_METHOD`; `#translate` turns that into `REMOTE_UNSUPPORTED` for the caller's
8448
+ * fallback path.
8449
+ */
8450
+ async batchRead(requests, signal) {
8451
+ await this.connect(signal);
8452
+ const client = this.#requireClient();
8453
+ let result;
8454
+ try {
8455
+ result = await client.request("fs.readMany", {
8456
+ requests: requests.map((request) => ({
8457
+ path: request.path,
8458
+ ...request.offset === void 0 ? {} : { offset: request.offset },
8459
+ ...request.maxBytes === void 0 ? {} : { maxBytes: request.maxBytes }
8460
+ }))
8461
+ // 帧层已对 ≥512 B 的帧做 zstd;这里再要求逐条压缩会让同一段文本被压两遍。
8462
+ }, this.#requestOptions(void 0, signal));
8463
+ } catch (error) {
8464
+ throw this.#translate(error);
8465
+ }
8466
+ const record2 = asRecord(result) ?? {};
8467
+ const items = Array.isArray(record2["items"]) ? record2["items"] : [];
8468
+ const chunks = [];
8469
+ for (const raw of items) {
8470
+ const item = asRecord(raw);
8471
+ if (item === void 0) continue;
8472
+ const path = typeof item["path"] === "string" ? item["path"] : "";
8473
+ const exists = item["exists"] === true;
8474
+ const dataBase64 = typeof item["dataBase64"] === "string" ? item["dataBase64"] : "";
8475
+ let bytes = new Uint8Array(0);
8476
+ if (dataBase64 !== "" && item["encoding"] !== "gzip") bytes = Buffer.from(dataBase64, "base64");
8477
+ else if (dataBase64 !== "") bytes = gunzipSync(Buffer.from(dataBase64, "base64"));
8478
+ chunks.push({
8479
+ path,
8480
+ exists,
8481
+ size: typeof item["size"] === "number" ? item["size"] : bytes.byteLength,
8482
+ version: typeof item["version"] === "string" ? item["version"] : "",
8483
+ truncated: item["truncated"] === true,
8484
+ bytes,
8485
+ ...typeof item["error"] === "string" && item["error"] !== "" ? { error: item["error"] } : {}
8486
+ });
8487
+ }
8488
+ return chunks;
8489
+ }
8164
8490
  /**
8165
8491
  * Read a remote file: `fs.read` (control frame) followed by its blob frame.
8166
8492
  *
@@ -8215,6 +8541,9 @@ var AgentTransport = class extends TransportBase {
8215
8541
  */
8216
8542
  async writeFile(request, signal) {
8217
8543
  await this.connect(signal);
8544
+ if (!(request.bytes instanceof Uint8Array)) {
8545
+ throw new SshDevError("CONNECTION_INVALID", "writeFile \u9700\u8981 Uint8Array \u7C7B\u578B\u7684 bytes", { path: request.path });
8546
+ }
8218
8547
  const client = this.#requireClient();
8219
8548
  const expected = request.expectedVersion ?? request.expectedSha256;
8220
8549
  let expectedVersion;
@@ -8753,9 +9082,16 @@ var AgentTransport = class extends TransportBase {
8753
9082
  state: typeof record2["state"] === "string" ? record2["state"] : "unknown"
8754
9083
  };
8755
9084
  }
8756
- /** Read a language server's output from `offset`: `lsp.read`. Same offset semantics as {@link procOutput}. */
8757
- async lspRead(id, offset = 0, signal) {
8758
- const result = await this.#rpc("lsp.read", { id, offset }, signal);
9085
+ /**
9086
+ * Read a language server's output from `offset`: `lsp.read`. Same offset semantics as {@link procOutput}.
9087
+ *
9088
+ * `maxBytes` bounds one response. The agent defaults it to its 2 MiB chunk; callers that poll (the LSP pump)
9089
+ * pass a smaller cap so a quiet server does not cost 2 MiB of transient host memory per poll. Agents older
9090
+ * than the field ignore it (Go's JSON decoder ignores unknown fields), so it is safe to always send.
9091
+ */
9092
+ async lspRead(id, offset = 0, signal, maxBytes) {
9093
+ const params = maxBytes === void 0 ? { id, offset } : { id, offset, maxBytes: Math.max(1, Math.floor(maxBytes)) };
9094
+ const result = await this.#rpc("lsp.read", params, signal);
8759
9095
  return readLspRead(result);
8760
9096
  }
8761
9097
  /** One language-server session's state: `lsp.status`. */
@@ -8831,7 +9167,9 @@ var AgentTransport = class extends TransportBase {
8831
9167
  ...request.include === void 0 ? {} : { include: [...request.include] },
8832
9168
  // Sent only when the caller asked for it: omitting the key leaves the agent's own default in force, so
8833
9169
  // an older agent that never heard of the field keeps behaving exactly as before.
8834
- ...request.noIgnore === void 0 ? {} : { noIgnore: request.noIgnore }
9170
+ ...request.noIgnore === void 0 ? {} : { noIgnore: request.noIgnore },
9171
+ ...request.offset === void 0 ? {} : { offset: request.offset },
9172
+ ...request.limit === void 0 ? {} : { limit: request.limit }
8835
9173
  };
8836
9174
  const result = await this.#rpc("search.grep", params, signal);
8837
9175
  return readSearchResult(result);
@@ -9199,6 +9537,8 @@ var defaultLauncher = async (options) => {
9199
9537
  };
9200
9538
  var ChildProcessSession = class {
9201
9539
  #child;
9540
+ /** One shared drain waiter: 16 concurrent requests must not add 16×3 stdin listeners. */
9541
+ #draining;
9202
9542
  constructor(child) {
9203
9543
  this.#child = child;
9204
9544
  this.#child.stdin?.on?.("error", () => void 0);
@@ -9219,12 +9559,14 @@ var ChildProcessSession = class {
9219
9559
  * Resolve once the pipe has drained (or ended).
9220
9560
  *
9221
9561
  * 不等 `'drain'` 会把整个文件堆进 Node 的流缓冲:实测 8 × 15 MiB 打到不读的对端,进程里堆了
9222
- * 120 MiB——那是在 agent 侧 192 MiB 上限**之外**的另一份。end/error 也要收,否则对端一死就永远等。
9562
+ * 120 MiB——那是在 agent 侧窗口/排队上限**之外**的另一份。end/error 也要收,否则对端一死就永远等。
9223
9563
  */
9224
9564
  drain() {
9225
9565
  const stdin = this.#child.stdin;
9226
9566
  if (stdin === void 0 || stdin === null || stdin.once === void 0) return Promise.resolve();
9227
- return new Promise((resolve3) => {
9567
+ const existing = this.#draining;
9568
+ if (existing !== void 0) return existing;
9569
+ const pending2 = new Promise((resolve3) => {
9228
9570
  let settled = false;
9229
9571
  const done = () => {
9230
9572
  if (settled) return;
@@ -9232,12 +9574,15 @@ var ChildProcessSession = class {
9232
9574
  stdin.removeListener?.("drain", done);
9233
9575
  stdin.removeListener?.("error", done);
9234
9576
  stdin.removeListener?.("close", done);
9577
+ this.#draining = void 0;
9235
9578
  resolve3();
9236
9579
  };
9237
9580
  stdin.once?.("drain", done);
9238
9581
  stdin.once?.("error", done);
9239
9582
  stdin.once?.("close", done);
9240
9583
  });
9584
+ this.#draining = pending2;
9585
+ return pending2;
9241
9586
  }
9242
9587
  onData(callback) {
9243
9588
  this.#child.stdout?.on("data", callback);
@@ -10321,9 +10666,25 @@ function parentRemotePath(absolute) {
10321
10666
  return remoteDirname(absolute);
10322
10667
  }
10323
10668
  var UTF8 = new TextDecoder("utf-8", { ignoreBOM: true });
10669
+ var UTF8_FATAL = new TextDecoder("utf-8", { ignoreBOM: true, fatal: true });
10670
+ function toBackendText(bytes, size, truncated, version) {
10671
+ try {
10672
+ return { text: UTF8_FATAL.decode(bytes), size, truncated, version };
10673
+ } catch {
10674
+ return {
10675
+ text: UTF8.decode(bytes),
10676
+ size,
10677
+ truncated,
10678
+ version,
10679
+ binary: true,
10680
+ dataBase64: Buffer.from(bytes).toString("base64")
10681
+ };
10682
+ }
10683
+ }
10324
10684
  var POLL_LISTING_SHARE_TTL_MS = 1e3;
10325
10685
  var STAT_CACHE_TTL_MS = 5e3;
10326
10686
  var STAT_CACHE_MAX_ENTRIES = 2048;
10687
+ var POLL_LISTING_MAX_ENTRIES = 64;
10327
10688
  var DEFAULT_CACHE = { ttlMs: 5e3, maxEntries: 256, maxBytes: 8 * 1024 * 1024 };
10328
10689
  var STAT_MARKER = "# DSH_STAT";
10329
10690
  var BATCH_MARKER = "#DSH_F ";
@@ -10341,12 +10702,7 @@ function parseBatchRead(stdout, cap) {
10341
10702
  missing.push(path);
10342
10703
  } else {
10343
10704
  const bytes = base64Body === "" ? Buffer.alloc(0) : Buffer.from(base64Body, "base64");
10344
- chunks.set(path, {
10345
- text: bytes.toString("utf8"),
10346
- size,
10347
- truncated: size > cap,
10348
- version: `${size}:${Math.floor(mtimeSeconds)}`
10349
- });
10705
+ chunks.set(path, toBackendText(bytes, size, size > cap, `${size}:${Math.floor(mtimeSeconds)}`));
10350
10706
  }
10351
10707
  path = void 0;
10352
10708
  size = void 0;
@@ -10685,16 +11041,31 @@ var RemoteBackend = class {
10685
11041
  this.#invalidatePath(absolute);
10686
11042
  return { bytes: result.bytes, version: result.version, hunks: result.hunks };
10687
11043
  }
11044
+ async repoSummary(root, signal) {
11045
+ const transport = this.#options.repoSummaryTransport === void 0 ? await this.#transport() : await this.#options.repoSummaryTransport();
11046
+ if (transport.repoSummary === void 0) {
11047
+ 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 });
11048
+ }
11049
+ return transport.repoSummary(root, signal);
11050
+ }
11051
+ async patchMany(edits, atomic, signal) {
11052
+ const transport = await this.#transport();
11053
+ if (transport.patchMany === void 0) {
11054
+ 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 });
11055
+ }
11056
+ const result = await transport.patchMany(edits, atomic, signal);
11057
+ if (result["rolledBack"] !== true) {
11058
+ for (const edit of edits) this.#invalidatePath(edit.path);
11059
+ }
11060
+ return result;
11061
+ }
10688
11062
  async readText(absolute, maxBytes, signal) {
10689
11063
  const chunk = await this.readBytes(absolute, maxBytes, signal);
10690
11064
  return {
10691
11065
  // `TextDecoder` 直接解码字节,`Buffer.from(...).toString()` 要先**整份拷贝**再解码——9 MiB 的文件
10692
11066
  // 就是白白多一次 9 MiB 的 memcpy。`fs-provider` 早就这么做了,这条读路径漏了。
10693
11067
  // `ignoreBOM: true`:默认行为会把 BOM 吃掉,而 `Buffer#toString` 不会,两者必须一致。
10694
- text: UTF8.decode(chunk.bytes),
10695
- size: chunk.size,
10696
- truncated: chunk.truncated,
10697
- version: chunk.version
11068
+ ...toBackendText(chunk.bytes, chunk.size, chunk.truncated, chunk.version)
10698
11069
  };
10699
11070
  }
10700
11071
  /**
@@ -10743,6 +11114,19 @@ var RemoteBackend = class {
10743
11114
  async #readManyUncached(absolutePaths, options) {
10744
11115
  const transport = await this.#transport();
10745
11116
  const cap = Math.max(0, Math.min(Math.floor(options.maxBytesEach), this.#maxReadBytes));
11117
+ if (transport.batchRead !== void 0) {
11118
+ const chunks2 = await transport.batchRead(
11119
+ absolutePaths.map((path) => ({ path, maxBytes: cap })),
11120
+ options.signal
11121
+ );
11122
+ const texts = absolutePaths.map((path, index) => {
11123
+ const chunk = chunks2[index];
11124
+ if (chunk === void 0 || !chunk.exists || chunk.error !== void 0) return void 0;
11125
+ return toBackendText(chunk.bytes, chunk.size, chunk.truncated, chunk.version);
11126
+ });
11127
+ const missing2 = absolutePaths.filter((path, index) => texts[index] === void 0);
11128
+ return { chunks: texts, missing: [...new Set(missing2)] };
11129
+ }
10746
11130
  const unframeable = absolutePaths.filter((path) => path.includes("\n") || path.includes("\r"));
10747
11131
  const unframeableSet = new Set(unframeable);
10748
11132
  const frameable = absolutePaths.filter((path) => !unframeableSet.has(path));
@@ -10901,7 +11285,7 @@ var RemoteBackend = class {
10901
11285
  * `CANCELLED` when aborted during setup.
10902
11286
  */
10903
11287
  async watch(absolute, changed, signal) {
10904
- const transport = await this.#transport();
11288
+ const transport = this.#options.watchTransport === void 0 ? await this.#transport() : await this.#options.watchTransport();
10905
11289
  if (!isWatchCapable(transport)) {
10906
11290
  return this.#watchByPolling(absolute, changed, signal);
10907
11291
  }
@@ -11039,6 +11423,10 @@ var RemoteBackend = class {
11039
11423
  })();
11040
11424
  const entry = { at: now, promise };
11041
11425
  this.#pollListings.set(root, entry);
11426
+ if (this.#pollListings.size > POLL_LISTING_MAX_ENTRIES) {
11427
+ const oldest = this.#pollListings.keys().next();
11428
+ if (!oldest.done) this.#pollListings.delete(oldest.value);
11429
+ }
11042
11430
  promise.catch(() => {
11043
11431
  if (this.#pollListings.get(root) === entry) this.#pollListings.delete(root);
11044
11432
  });
@@ -12613,7 +13001,7 @@ function directoryProbeCommand(root) {
12613
13001
  return `test -d ${shellQuote(root)}`;
12614
13002
  }
12615
13003
  function rankMatches(paths, query, limit) {
12616
- const needle = query.trim().toLowerCase();
13004
+ const needle = separatorsForMatching(query.trim().toLowerCase());
12617
13005
  if (needle === "") {
12618
13006
  const top = [];
12619
13007
  const better = (left, right) => {
@@ -12638,7 +13026,8 @@ function rankMatches(paths, query, limit) {
12638
13026
  }
12639
13027
  const scored = [];
12640
13028
  for (const path of paths) {
12641
- const lower = path.toLowerCase();
13029
+ const normalized = separatorsForMatching(path);
13030
+ const lower = normalized.toLowerCase();
12642
13031
  const base = lower.slice(lower.lastIndexOf("/") + 1);
12643
13032
  let score;
12644
13033
  if (base.startsWith(needle)) score = 0;
@@ -12653,11 +13042,15 @@ function rankMatches(paths, query, limit) {
12653
13042
  );
12654
13043
  return scored.slice(0, limit).map((entry) => entry.path);
12655
13044
  }
13045
+ function separatorsForMatching(path) {
13046
+ return path.includes("\\") && !path.includes("/") ? path.replaceAll("\\", "/") : path;
13047
+ }
12656
13048
  function depth2(path) {
12657
13049
  let segments = 0;
12658
13050
  let inSegment = false;
12659
13051
  for (let index = 0; index < path.length; index += 1) {
12660
- if (path.charCodeAt(index) === 47) {
13052
+ const code = path.charCodeAt(index);
13053
+ if (code === 47 || code === 92) {
12661
13054
  inSegment = false;
12662
13055
  continue;
12663
13056
  }
@@ -13205,6 +13598,46 @@ function defaultAgentManifestPath(moduleUrl) {
13205
13598
 
13206
13599
  // src/wiring/connections.ts
13207
13600
  var degradedRetryMs = 3e4;
13601
+ var LANE_RETRY_AFTER_MS = 15e3;
13602
+ function createTransportLaneRouter(options) {
13603
+ const retryAfter = /* @__PURE__ */ new Map();
13604
+ return {
13605
+ enabled: options.enabled,
13606
+ releaseSession: async (sessionKey2) => {
13607
+ if (!options.enabled) return;
13608
+ const suffix = `\0stateful\0${sessionKey2}`;
13609
+ for (const key of retryAfter.keys()) {
13610
+ if (key.endsWith(suffix)) retryAfter.delete(key);
13611
+ }
13612
+ await options.transports.releaseSession(sessionKey2);
13613
+ },
13614
+ statefulSessions: () => options.enabled ? options.transports.statefulSessions() : [],
13615
+ pruneStatefulSessions: async (isLive) => {
13616
+ if (!options.enabled) return;
13617
+ const now = Date.now();
13618
+ for (const [key, until] of retryAfter) {
13619
+ if (until <= now) retryAfter.delete(key);
13620
+ }
13621
+ await options.transports.pruneStatefulSessions(isLive);
13622
+ },
13623
+ async acquire(profile, lane = "interactive", signal, sessionKey2) {
13624
+ if (!options.enabled || lane === "interactive") return options.transports.acquire(profile, signal);
13625
+ const laneOptions = { lane, ...sessionKey2 === void 0 ? {} : { sessionKey: sessionKey2 } };
13626
+ const laneId = `${profile.id}\0${lane}\0${sessionKey2 ?? ""}`;
13627
+ if ((retryAfter.get(laneId) ?? 0) > Date.now()) return options.transports.acquire(profile, signal);
13628
+ try {
13629
+ const acquired = await options.transports.acquire(profile, laneOptions, signal);
13630
+ retryAfter.delete(laneId);
13631
+ return acquired;
13632
+ } catch (error) {
13633
+ if (signal?.aborted === true || isSshDevError(error) && error.code === "CANCELLED") throw error;
13634
+ retryAfter.set(laneId, Date.now() + LANE_RETRY_AFTER_MS);
13635
+ options.onFallback?.({ connectionId: profile.id, lane, error });
13636
+ return options.transports.acquire(profile, signal);
13637
+ }
13638
+ }
13639
+ };
13640
+ }
13208
13641
  function pruneBackendConnections(owners, liveRootKeys) {
13209
13642
  for (const backendId of [...owners.keys()]) {
13210
13643
  if (!liveRootKeys.has(backendId)) owners.delete(backendId);
@@ -13316,6 +13749,7 @@ function wireConnections(ctx, config) {
13316
13749
  const transports = new TransportService({
13317
13750
  selector,
13318
13751
  preflight: agentBridge.preflight,
13752
+ statefulLaneLimit: limits.statefulLanes,
13319
13753
  factories: {
13320
13754
  createAlias: aliasFactory,
13321
13755
  createSsh2: (profile) => new Ssh2Transport({
@@ -13337,15 +13771,20 @@ function wireConnections(ctx, config) {
13337
13771
  });
13338
13772
  }
13339
13773
  }),
13340
- createAgent: (profile) => {
13774
+ createAgent: (profile, _decision, lane, sessionKey2) => {
13341
13775
  const outcome = agentBridge.installationFor(profile.id);
13342
13776
  if (outcome === void 0 || outcome.kind !== "agent") {
13343
13777
  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 });
13344
13778
  }
13345
13779
  const helper = askpass.envFor(profile);
13346
13780
  const passwordOptions = passwordAskpassOptions(profile, helper);
13781
+ const maxInFlight = lane === void 0 || lane === "interactive" ? void 0 : 8;
13347
13782
  return new AgentTransport({
13348
13783
  connectionId: profile.id,
13784
+ ...maxInFlight === void 0 ? {} : { maxInFlight },
13785
+ // Stateful lanes get a stable agent session id: their jobs/LSP/watch ownership must not
13786
+ // collapse into the daemon's default session (or two DSH sessions would share one quota).
13787
+ ...sessionKey2 === void 0 || sessionKey2 === "" ? {} : { sessionId: sessionKey2 },
13349
13788
  agentPath: outcome.remotePath,
13350
13789
  // Same credential source as the Tier 1 transport: an agent session that authenticates differently
13351
13790
  // from the ladder that installed it is exactly the drift the agent-path e2e caught. For a password
@@ -13366,6 +13805,15 @@ function wireConnections(ctx, config) {
13366
13805
  }
13367
13806
  }
13368
13807
  });
13808
+ const transportLanes = createTransportLaneRouter({
13809
+ transports,
13810
+ enabled: limits.transportLanes !== false,
13811
+ onFallback: ({ connectionId, lane, error }) => {
13812
+ ctx.logger?.warn?.(
13813
+ `[dsh-over-ssh] ${connectionId} \u7684 ${lane} lane \u4E0D\u53EF\u7528\uFF0C\u56DE\u9000\u5230 interactive\uFF1A${error instanceof Error ? error.message : String(error)}`
13814
+ );
13815
+ }
13816
+ });
13369
13817
  const remoteHome = createRemoteHomeResolver({
13370
13818
  probe: async (connectionId) => {
13371
13819
  const profile = profiles.get(connectionId);
@@ -13427,6 +13875,26 @@ function wireConnections(ctx, config) {
13427
13875
  }
13428
13876
  return (await transports.acquire(profile)).transport;
13429
13877
  },
13878
+ // `repo.summary` is compute work (it may walk the tree and run git). It runs on the compute lane when
13879
+ // available; reads/writes/listing stay interactive, watch has its own stateful lane below.
13880
+ repoSummaryTransport: async () => {
13881
+ const profile = profiles.get(connectionId);
13882
+ if (profile === void 0) {
13883
+ throw new SshDevError("CONNECTION_NOT_FOUND", `\u8FDC\u7AEF\u6839\u6307\u5411\u672A\u914D\u7F6E\u7684\u8FDE\u63A5\uFF1A${connectionId}`, { root: rootKey(root) });
13884
+ }
13885
+ return (await transportLanes.acquire(profile, "compute")).transport;
13886
+ },
13887
+ // watch.* is long-lived state. Give it a stateful lane per DSH session when the platform's
13888
+ // AsyncLocalStorage carries one; fall back to one shared `watch` lane for agentless callers.
13889
+ watchTransport: async () => {
13890
+ const profile = profiles.get(connectionId);
13891
+ if (profile === void 0) {
13892
+ throw new SshDevError("CONNECTION_NOT_FOUND", `\u8FDC\u7AEF\u6839\u6307\u5411\u672A\u914D\u7F6E\u7684\u8FDE\u63A5\uFF1A${connectionId}`, { root: rootKey(root) });
13893
+ }
13894
+ const sessionId = currentSessionId();
13895
+ const sessionKey2 = sessionId === void 0 || sessionId === "" ? "watch" : `watch:${sessionId}`;
13896
+ return (await transportLanes.acquire(profile, "stateful", void 0, sessionKey2)).transport;
13897
+ },
13430
13898
  // FR-2.7 is a budget, so it has to be reachable from Config: the defaults are the requirement's
13431
13899
  // numbers, and an operator can turn retention off per deployment without a rebuild.
13432
13900
  cache: {
@@ -13530,7 +13998,7 @@ function wireConnections(ctx, config) {
13530
13998
  // Tier 2 when this connection actually runs an agent that reports index support; otherwise this
13531
13999
  // throws and RemoteIndex falls through to the shell path (which is the floor).
13532
14000
  agentScan: async (root, request) => {
13533
- const { transport } = await transports.acquire(profile);
14001
+ const { transport } = await transportLanes.acquire(profile, "compute");
13534
14002
  if (!(transport instanceof AgentTransport) || transport.agentCapabilities.index !== true) {
13535
14003
  throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u8FDE\u63A5\u6CA1\u6709\u53EF\u7528\u7684\u4EE3\u7406\u7D22\u5F15", { connectionId });
13536
14004
  }
@@ -13547,7 +14015,7 @@ function wireConnections(ctx, config) {
13547
14015
  // `index.refresh` answers E_METHOD, which becomes REMOTE_UNSUPPORTED and makes RemoteIndex fall back
13548
14016
  // to `agentScan` - so wiring this can never regress a working connection into a failing one.
13549
14017
  agentRefresh: async (root, request) => {
13550
- const { transport } = await transports.acquire(profile);
14018
+ const { transport } = await transportLanes.acquire(profile, "compute");
13551
14019
  if (!(transport instanceof AgentTransport) || transport.agentCapabilities.index !== true) {
13552
14020
  throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u8FDE\u63A5\u6CA1\u6709\u53EF\u7528\u7684\u4EE3\u7406\u7D22\u5F15", { connectionId });
13553
14021
  }
@@ -13560,14 +14028,14 @@ function wireConnections(ctx, config) {
13560
14028
  return { paths: scan.entries.map((entry) => entry.path), strategy: scan.strategy, truncated: scan.truncated };
13561
14029
  },
13562
14030
  exec: async (command, options) => {
13563
- const { transport } = await transports.acquire(profile);
14031
+ const { transport } = await transportLanes.acquire(profile, "compute");
13564
14032
  const result = await transport.exec(
13565
14033
  { script: command, maxBytes: options.maxBytes, timeoutMs: options.timeoutMs },
13566
14034
  options.signal
13567
14035
  );
13568
14036
  return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, truncated: result.truncated };
13569
14037
  },
13570
- capabilities: async () => (await transports.acquire(profile)).transport.capabilities
14038
+ capabilities: async () => (await transportLanes.acquire(profile, "compute")).transport.capabilities
13571
14039
  }
13572
14040
  });
13573
14041
  indexes.set(connectionId, created);
@@ -13582,7 +14050,7 @@ function wireConnections(ctx, config) {
13582
14050
  }));
13583
14051
  }
13584
14052
  };
13585
- return { ctx, config, limits, anchorRootDir, profiles, selector, askpass, installations, agentBridge, transports, remoteHome, forgetBackendsOf, browseBackends, readBytesRange, registry, directory, indexes, indexFor, index, cacheBudget };
14053
+ return { ctx, config, limits, anchorRootDir, profiles, selector, askpass, installations, agentBridge, transports, transportLanes, remoteHome, forgetBackendsOf, browseBackends, readBytesRange, registry, directory, indexes, indexFor, index, cacheBudget };
13586
14054
  }
13587
14055
  function readLiveSessions(ctx) {
13588
14056
  let list;
@@ -13763,6 +14231,8 @@ function sessionToolDefinitions(service) {
13763
14231
  sessions: { type: "array", items: { type: "string" }, description: "`list`: one formatted line per session." },
13764
14232
  entries: { type: "array", items: { type: "string" }, description: "`restore` without an id: bin entry ids." },
13765
14233
  restoredPath: { type: "string", description: "`restore` with an id: where the entry went back to." },
14234
+ restoredSubsessions: { type: "integer", description: "`restore`: sub-sessions restored together with the entry." },
14235
+ failedSubsessions: { type: "integer", description: "`restore`: sub-sessions that stayed in the bin (path occupied / move failed)." },
13766
14236
  matched: { type: "integer", description: "`delete_workspace`: how many sessions belonged to the directory." },
13767
14237
  mode: { type: "string", description: "`delete` / `delete_workspace`: the mode that was applied." },
13768
14238
  recoverable: { type: "boolean", description: "`delete`: whether the bin can bring it back." },
@@ -13798,10 +14268,16 @@ function sessionToolDefinitions(service) {
13798
14268
  }
13799
14269
  if (typeof entryId !== "string" || entryId === "") throw new Error("entryId \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u7701\u7565\u5219\u5217\u51FA\u56DE\u6536\u7AD9\uFF09");
13800
14270
  const result = await service.restore(entryId);
14271
+ const extra = [];
14272
+ if (result.restoredSubsessions.length > 0) extra.push(`\u5E76\u8FD8\u539F\u5B50\u4F1A\u8BDD ${result.restoredSubsessions.length} \u4E2A`);
14273
+ if (result.failedSubsessions.length > 0) extra.push(`${result.failedSubsessions.length} \u4E2A\u5B50\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9`);
14274
+ if (result.parentStillBinned) extra.push("\u5176\u7236\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9");
13801
14275
  return {
13802
14276
  action: "restore",
13803
14277
  restoredPath: result.restoredPath,
13804
- summary: `\u5DF2\u8FD8\u539F\u4F1A\u8BDD ${result.entry.sessionId} -> ${result.restoredPath}`
14278
+ restoredSubsessions: result.restoredSubsessions.length,
14279
+ failedSubsessions: result.failedSubsessions.length,
14280
+ summary: `\u5DF2\u8FD8\u539F\u4F1A\u8BDD ${result.entry.sessionId} -> ${result.restoredPath}${extra.length === 0 ? "" : `\uFF08${extra.join("\uFF0C")}\uFF09`}`
13805
14281
  };
13806
14282
  }
13807
14283
  const mode = record2["mode"] ?? "recycle";
@@ -15968,7 +16444,13 @@ function queryOf(url) {
15968
16444
  }
15969
16445
  function visibleToSession(row, sessionIds, workspace) {
15970
16446
  const sessions = row.sessions ?? [];
15971
- if (sessions.length > 0) return sessions.some((id) => sessionIds.has(id));
16447
+ if (sessions.length > 0) {
16448
+ if (!sessions.some((id) => sessionIds.has(id))) return false;
16449
+ if (workspace !== "" && row.workspaces !== void 0 && row.workspaces.length > 0) {
16450
+ return row.workspaces.some((candidate) => sameWorkspacePath(candidate, workspace));
16451
+ }
16452
+ return true;
16453
+ }
15972
16454
  if (workspace === "") return false;
15973
16455
  return (row.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, workspace));
15974
16456
  }
@@ -16027,7 +16509,7 @@ async function serveAction(req, res, deps) {
16027
16509
 
16028
16510
  // src/host/integration/session/session-bin-lock.ts
16029
16511
  init_errors();
16030
- import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5 } from "node:fs/promises";
16512
+ import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5, utimes } from "node:fs/promises";
16031
16513
  import { dirname as dirname5 } from "node:path";
16032
16514
  var DEFAULT_STALE_LOCK_MS = 6e4;
16033
16515
  var DEFAULT_LOCK_RETRY_MS = 25;
@@ -16036,6 +16518,7 @@ function createSessionBinLock(options) {
16036
16518
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
16037
16519
  const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
16038
16520
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
16521
+ const heartbeatMs = options.heartbeatMs ?? (staleMs > 0 ? Math.max(1e3, Math.floor(staleMs / 4)) : 0);
16039
16522
  const sleep2 = options.sleep ?? ((ms) => new Promise((resolve3) => setTimeout(resolve3, ms)));
16040
16523
  const now = options.now ?? (() => Date.now());
16041
16524
  const owner = options.owner ?? `${process.pid}@${hostnameOf()}`;
@@ -16116,6 +16599,17 @@ function createSessionBinLock(options) {
16116
16599
  }
16117
16600
  return true;
16118
16601
  };
16602
+ const refreshLock = async (token) => {
16603
+ try {
16604
+ const text8 = await readFile6(options.path, "utf8");
16605
+ const record2 = JSON.parse(text8);
16606
+ if (record2.token !== token) return;
16607
+ const at = new Date(now());
16608
+ await utimes(options.path, at, at);
16609
+ } catch (error) {
16610
+ options.onHeartbeatError?.(error);
16611
+ }
16612
+ };
16119
16613
  const withLock = async (_key, operation) => {
16120
16614
  const deadline = now() + timeoutMs;
16121
16615
  let token;
@@ -16132,9 +16626,17 @@ function createSessionBinLock(options) {
16132
16626
  }
16133
16627
  await sleep2(retryMs);
16134
16628
  }
16629
+ let heartbeat;
16630
+ if (token !== void 0 && heartbeatMs > 0) {
16631
+ heartbeat = setInterval(() => {
16632
+ void refreshLock(token);
16633
+ }, heartbeatMs);
16634
+ heartbeat.unref?.();
16635
+ }
16135
16636
  try {
16136
16637
  return await operation();
16137
16638
  } finally {
16639
+ if (heartbeat !== void 0) clearInterval(heartbeat);
16138
16640
  if (token !== void 0) await releaseIfOwned(token);
16139
16641
  }
16140
16642
  };
@@ -16626,7 +17128,14 @@ async function serveActionWithSignal(action, body, req, res, deps, service, sign
16626
17128
  return;
16627
17129
  }
16628
17130
  const result = await service.restore(entryId, options);
16629
- send(req, res, 200, { ok: true, restoredPath: result.restoredPath, movedBy: result.movedBy });
17131
+ send(req, res, 200, {
17132
+ ok: true,
17133
+ restoredPath: result.restoredPath,
17134
+ movedBy: result.movedBy,
17135
+ restoredSubsessions: result.restoredSubsessions.length,
17136
+ failedSubsessions: result.failedSubsessions.length,
17137
+ parentStillBinned: result.parentStillBinned
17138
+ });
16630
17139
  return;
16631
17140
  }
16632
17141
  case "purge": {
@@ -16892,7 +17401,7 @@ function retireBrowseBackend(browseBackends, connectionId) {
16892
17401
  const backend = browseBackends.get(connectionId);
16893
17402
  if (backend === void 0) return;
16894
17403
  browseBackends.delete(connectionId);
16895
- void backend.dispose?.();
17404
+ void Promise.resolve().then(() => backend.dispose?.()).catch(() => void 0);
16896
17405
  }
16897
17406
  function browsingSeams(env) {
16898
17407
  const { profiles, transports, remoteHome, browseBackends } = env;
@@ -17018,7 +17527,7 @@ function registerPanelRoutes(env, hooks) {
17018
17527
  }
17019
17528
 
17020
17529
  // src/wiring/fs.ts
17021
- var LOCAL_WORLD_MISSING_WARNING = 'dsh-over-ssh: \u79C1\u6709\u57DF\u7684 bridge \u672A\u5C31\u4F4D\uFF08ctx.get("localFs") === undefined\uFF09\uFF1A\u672C\u5730\u8DEF\u5F84\u7684\u5199\u5165\u56F4\u680F\u9000\u56DE\u672C\u63D2\u4EF6\u81EA\u5DF1\u7684 LocalBackend\u2014\u2014\u529F\u80FD\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u56F4\u680F\u6362\u4E86\u6267\u884C\u8005\u3002\u8BF7\u68C0\u67E5 cordis.patch.yml \u91CC \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u79C1\u6709\u57DF \u7EC4\uFF08fs/fileReferences \u7684 isolate \u4E0E\u4E24\u4E2A bridge \u884C\uFF09\u662F\u5426\u88AB\u7981\u7528\u6216\u52A0\u8F7D\u5931\u8D25\u3002';
17530
+ var LOCAL_WORLD_MISSING_WARNING = 'dsh-over-ssh: \u79C1\u6709\u57DF\u7684 bridge \u672A\u5C31\u4F4D\uFF08ctx.get("localFs") === undefined\uFF09\uFF1A\u672C\u5730\u8DEF\u5F84\u7684\u5199\u5165\u56F4\u680F\u9000\u56DE\u672C\u63D2\u4EF6\u81EA\u5DF1\u7684 LocalBackend\u2014\u2014\u529F\u80FD\u770B\u8D77\u6765\u6B63\u5E38\uFF0C\u4F46\u56F4\u680F\u6362\u4E86\u6267\u884C\u8005\u3002\u8BF7\u68C0\u67E5 cordis.patch.yml \u91CC \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u79C1\u6709\u57DF \u7EC4\uFF08fs \u7684 isolate \u4E0E \u8FDC\u7A0B\u5DE5\u4F5C\u533A-\u6865\u63A5fs \u884C\uFF09\u662F\u5426\u88AB\u7981\u7528\u6216\u52A0\u8F7D\u5931\u8D25\u3002';
17022
17531
  function installClientDataChannel(ctx) {
17023
17532
  try {
17024
17533
  installRemoteFace(ctx);
@@ -17027,10 +17536,11 @@ function installClientDataChannel(ctx) {
17027
17536
  }
17028
17537
  }
17029
17538
  function provideHostServices(env) {
17030
- const { ctx, limits, registry, directory, transports, index, transferService, binService, sessionAdmin, sessionRoots, readBytesRange } = env;
17539
+ const { ctx, limits, registry, directory, transports, transportLanes, index, transferService, binService, sessionAdmin, sessionRoots, readBytesRange } = env;
17031
17540
  const disposeRoots = ctx.provide("sshDevRoots", registry);
17032
17541
  const disposeConnections = ctx.provide("sshDevConnections", directory);
17033
17542
  const disposeTransports = ctx.provide("sshDevTransports", transports);
17543
+ const disposeTransportLanes = ctx.provide("sshDevTransportLanes", transportLanes);
17034
17544
  const disposeIndex = ctx.provide("sshDevIndex", index);
17035
17545
  const disposeTransfer = ctx.provide("sshDevTransfer", transferService);
17036
17546
  const disposeBin = ctx.provide("sshDevRecycleBin", binService);
@@ -17041,7 +17551,7 @@ function provideHostServices(env) {
17041
17551
  ...limits.rangeChunkBytes === void 0 ? {} : { rangeChunkBytes: limits.rangeChunkBytes }
17042
17552
  });
17043
17553
  const disposeSessionRoots = ctx.provide("sshDevSessionRoots", sessionRoots);
17044
- return { disposeRoots, disposeConnections, disposeTransports, disposeIndex, disposeTransfer, disposeBin, disposeSessionTools, disposeRange, disposeSessionRoots };
17554
+ return { disposeRoots, disposeConnections, disposeTransports, disposeTransportLanes, disposeIndex, disposeTransfer, disposeBin, disposeSessionTools, disposeRange, disposeSessionRoots };
17045
17555
  }
17046
17556
  function provideAgentStatus(env) {
17047
17557
  const { ctx, agentBridge, installations, askpass, remoteHome } = env;
@@ -17116,8 +17626,15 @@ function scheduleActivationDiagnostics(env) {
17116
17626
  return activationTimers;
17117
17627
  }
17118
17628
  function watchSessionRemovals(env) {
17119
- const { ctx, rootsWiring, onSessionRemoved } = env;
17629
+ const { ctx, rootsWiring, onSessionRemoved, transportLanes } = env;
17120
17630
  onSessionRemoved((sessionId) => {
17631
+ for (const sessionKey2 of [sessionId, `watch:${sessionId}`]) {
17632
+ void transportLanes.releaseSession(sessionKey2).catch((error) => {
17633
+ ctx.logger?.warn?.(
17634
+ `dsh-over-ssh: \u56DE\u6536\u5DF2\u5220\u4F1A\u8BDD ${sessionKey2} \u7684 stateful transport \u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`
17635
+ );
17636
+ });
17637
+ }
17121
17638
  void rootsWiring.pruneSession(sessionId).catch((error) => {
17122
17639
  ctx.logger?.warn?.(
17123
17640
  `dsh-over-ssh: \u6E05\u7406\u5DF2\u5220\u4F1A\u8BDD ${sessionId} \u7684\u6839\u8BB0\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`
@@ -17155,6 +17672,7 @@ function createTeardown(env) {
17155
17672
  disposeAgent();
17156
17673
  hostServices.disposeIndex();
17157
17674
  hostServices.disposeTransports();
17675
+ hostServices.disposeTransportLanes();
17158
17676
  hostServices.disposeConnections();
17159
17677
  hostServices.disposeRoots();
17160
17678
  await tools.transports.disposeAll();
@@ -17172,6 +17690,7 @@ function wireFs(env) {
17172
17690
  registry,
17173
17691
  directory: env.directory,
17174
17692
  transports,
17693
+ transportLanes: env.transportLanes,
17175
17694
  index: env.index,
17176
17695
  transferService: env.transferService,
17177
17696
  binService: env.binService,
@@ -17199,7 +17718,24 @@ function wireFs(env) {
17199
17718
  workspacePathOf: (sessionId) => readSessionCwd(ctx, sessionId)
17200
17719
  });
17201
17720
  const activationTimers = scheduleActivationDiagnostics({ ctx, ...routes });
17202
- watchSessionRemovals({ ctx, rootsWiring: routes.rootsWiring, onSessionRemoved: env.onSessionRemoved });
17721
+ watchSessionRemovals({ ctx, rootsWiring: routes.rootsWiring, onSessionRemoved: env.onSessionRemoved, transportLanes: env.transportLanes });
17722
+ const laneJanitor = setInterval(() => {
17723
+ try {
17724
+ const sessions = ctx.get("sessions");
17725
+ if (sessions?.get === void 0) return;
17726
+ void env.transportLanes.pruneStatefulSessions((sessionKey2) => {
17727
+ if (sessionKey2 === "watch") return true;
17728
+ const target = sessionKey2.startsWith("watch:") ? sessionKey2.slice("watch:".length) : sessionKey2;
17729
+ return sessions.get?.(target) !== void 0;
17730
+ }).catch((error) => {
17731
+ ctx.logger?.warn?.(`over-ssh: \u56DE\u6536\u5931\u8054\u4F1A\u8BDD\u7684 stateful lane \u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
17732
+ });
17733
+ } catch (error) {
17734
+ 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)}`);
17735
+ }
17736
+ }, 6e4);
17737
+ laneJanitor.unref?.();
17738
+ activationTimers.push(laneJanitor);
17203
17739
  mergePersistedRoots({ ctx, rootsWiring: routes.rootsWiring });
17204
17740
  return createTeardown({ activationTimers, routes, hostServices, disposeAgent, tools: env, disposeSessionDirectories });
17205
17741
  });
@@ -17304,7 +17840,7 @@ function createTransferDeps(deps) {
17304
17840
  */
17305
17841
  async sha256Remote(absolute, signal) {
17306
17842
  const { connectionId } = requireRemote(deps, absolute);
17307
- const transport = await deps.transportFor(connectionId);
17843
+ const transport = await deps.transportFor(connectionId, "bulk");
17308
17844
  if (transport.hashFile !== void 0) {
17309
17845
  try {
17310
17846
  const hashed = await transport.hashFile({ path: absolute }, signal);
@@ -17322,7 +17858,7 @@ function createTransferDeps(deps) {
17322
17858
  },
17323
17859
  async readRemote(absolute, options) {
17324
17860
  const { connectionId } = requireRemote(deps, absolute);
17325
- const transport = await deps.transportFor(connectionId);
17861
+ const transport = await deps.transportFor(connectionId, "bulk");
17326
17862
  const offset = Math.max(0, options.offset ?? 0);
17327
17863
  if (transport.readFileRange !== void 0) {
17328
17864
  const chunk2 = await transport.readFileRange(
@@ -17336,7 +17872,7 @@ function createTransferDeps(deps) {
17336
17872
  },
17337
17873
  async writeRemote(absolute, bytes, options) {
17338
17874
  const { connectionId } = requireRemote(deps, absolute);
17339
- const transport = await deps.transportFor(connectionId);
17875
+ const transport = await deps.transportFor(connectionId, "interactive");
17340
17876
  if (options.offset === void 0) {
17341
17877
  const outcome2 = await transport.writeFile(
17342
17878
  {
@@ -17359,15 +17895,35 @@ function createTransferDeps(deps) {
17359
17895
  return outcome;
17360
17896
  },
17361
17897
  async statRemote(absolute) {
17362
- const { target } = requireRemote(deps, absolute);
17363
- return target.backend.stat(target.absolute ?? absolute);
17898
+ const { target, connectionId } = requireRemote(deps, absolute);
17899
+ const resolved = target.absolute ?? absolute;
17900
+ try {
17901
+ const transport = await deps.transportFor(connectionId, "bulk");
17902
+ if (transport.statPath !== void 0) {
17903
+ const native = await transport.statPath(resolved);
17904
+ if (!(native.exists && native.version === "")) {
17905
+ return {
17906
+ exists: native.exists,
17907
+ isDirectory: native.isDirectory,
17908
+ isFile: native.isFile,
17909
+ isSymlink: false,
17910
+ size: native.size,
17911
+ mtimeMs: native.mtimeMs,
17912
+ version: native.version
17913
+ };
17914
+ }
17915
+ }
17916
+ } catch (error) {
17917
+ if (isSshDevError(error) && error.code === "CANCELLED") throw error;
17918
+ }
17919
+ return target.backend.stat(resolved);
17364
17920
  }
17365
17921
  };
17366
17922
  }
17367
17923
  function createRecycleBinDeps(deps) {
17368
17924
  const remoteMove = async (from, to, signal) => {
17369
17925
  const { connectionId } = requireRemote(deps, from);
17370
- const transport = await deps.transportFor(connectionId);
17926
+ const transport = await deps.transportFor(connectionId, "interactive");
17371
17927
  if (transport.rename === void 0) {
17372
17928
  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 });
17373
17929
  }
@@ -17375,7 +17931,7 @@ function createRecycleBinDeps(deps) {
17375
17931
  };
17376
17932
  const remoteRemove = async (path, options) => {
17377
17933
  const { connectionId } = requireRemote(deps, path);
17378
- const transport = await deps.transportFor(connectionId);
17934
+ const transport = await deps.transportFor(connectionId, "interactive");
17379
17935
  if (transport.remove === void 0) {
17380
17936
  throw new SshDevError("REMOTE_UNSUPPORTED", "\u8BE5\u4F20\u8F93\u4E0D\u652F\u6301 remove\uFF0C\u65E0\u6CD5\u6C38\u4E45\u5220\u9664", { path });
17381
17937
  }
@@ -17745,7 +18301,7 @@ function pruneStaleLocks(locks, liveRootKeys) {
17745
18301
  }
17746
18302
  }
17747
18303
  function wireRoots(env) {
17748
- const { ctx, limits, profiles, transports, registry } = env;
18304
+ const { ctx, limits, profiles, transports, transportLanes, registry } = env;
17749
18305
  const binRootResolver = createBinRootResolver({
17750
18306
  addHiddenRoot: (root) => registry.addHidden(root),
17751
18307
  removeHiddenRoot: (key) => {
@@ -17801,7 +18357,7 @@ function wireRoots(env) {
17801
18357
  const worldDeps = {
17802
18358
  routed: registry.routed,
17803
18359
  // Local roots resolve through the same router, so a path never reaches the wrong world.
17804
- transportFor: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
18360
+ transportFor: async (connectionId, lane = "interactive") => (await transportLanes.acquire(requireProfile(profiles, connectionId), lane)).transport,
17805
18361
  // ADR-0011: the bin lives **beside** the root (outside the caller's repository) and is registered as a
17806
18362
  // hidden root so the router can reach it. `binRootFor` stays synchronous by answering from a decision made
17807
18363
  // when the root was registered; see `binRootResolver`.
@@ -17849,6 +18405,10 @@ function wireRoots(env) {
17849
18405
  ctx.logger?.warn?.(
17850
18406
  `\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5DF2\u8FC7\u671F\u88AB\u56DE\u6536\uFF1A\u6301\u6709\u8005 ${JSON.stringify(event.holder)}\uFF0C\u5DF2\u8FC7\u671F ${Math.round(event.ageMs / 1e3)} \u79D2`
17851
18407
  );
18408
+ },
18409
+ // 心跳失败只警告:锁仍然有效,操作继续;但网络盘 EIO/EPERM 值得在日志里留下痕迹。
18410
+ onHeartbeatError: (error) => {
18411
+ ctx.logger?.warn?.(`\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5FC3\u8DF3\u5237\u65B0\u5931\u8D25\uFF08\u9501\u4ECD\u6301\u6709\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
17852
18412
  }
17853
18413
  });
17854
18414
  binLocks.set(key, created);
@@ -17896,6 +18456,8 @@ function toExecOutcome(result) {
17896
18456
  exitCode: result.exitCode,
17897
18457
  stdout: result.stdout,
17898
18458
  stderr: result.stderr,
18459
+ ...result.stdoutBase64 === void 0 ? {} : { stdoutBase64: result.stdoutBase64 },
18460
+ ...result.stderrBase64 === void 0 ? {} : { stderrBase64: result.stderrBase64 },
17899
18461
  durationMs: result.durationMs,
17900
18462
  truncated: result.truncated,
17901
18463
  // 审计 0052 §8: these four used to be dropped right here, so the renderer's per-stream and spill logic
@@ -17923,20 +18485,30 @@ function createRemoteToolBackend(deps) {
17923
18485
  ...resolveForConnection === void 0 ? {} : { resolveForConnection: (connectionId, cwd) => resolveForConnection(connectionId, cwd) },
17924
18486
  ...resolveSessionRoot === void 0 ? {} : { resolveSessionRoot },
17925
18487
  async grep(target, request, signal) {
17926
- const transport = await deps.acquire(target.connectionId);
18488
+ const transport = await deps.acquire(target.connectionId, "compute");
17927
18489
  const path = joinUnderRoot(target.root, request.path);
18490
+ const offset = request.offset ?? 0;
18491
+ if (offset > 0 && (!isAgentSearchCapable(transport) || transport.agentCapabilities.grepPaging !== true)) {
18492
+ 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", {
18493
+ offset,
18494
+ connectionId: target.connectionId
18495
+ });
18496
+ }
17928
18497
  if (isAgentSearchCapable(transport)) {
17929
18498
  const result2 = await transport.searchGrep(
17930
18499
  {
17931
18500
  paths: [path],
17932
18501
  query: request.pattern,
17933
18502
  literal: !request.regex,
18503
+ ...offset === 0 ? {} : { offset },
18504
+ // 本页条数交给代理切页;扫描上限多要 1 条,才能判断"后面还有没有下一页"。
18505
+ limit: request.maxResults,
18506
+ maxResults: offset + request.maxResults + 1,
17934
18507
  // Case sensitivity and ignore rules are both two-tier contracts: the tier that answers has to
17935
18508
  // receive the same intent the Tier 1 `grepCommand` expresses, or one tool call would mean two
17936
18509
  // things depending on which host has ripgrep (audit 0064 P3).
17937
18510
  ignoreCase: request.caseSensitive !== true,
17938
18511
  ...request.noIgnore === true ? { noIgnore: true } : {},
17939
- maxResults: request.maxResults,
17940
18512
  // Audit 0052 P10: symmetric with the Tier 1 `grepCommand` `--include=` filters. Without this the
17941
18513
  // **degraded** path had the capability the preferred path lacked.
17942
18514
  ...request.include === void 0 ? {} : { include: [...request.include] }
@@ -17948,7 +18520,9 @@ function createRemoteToolBackend(deps) {
17948
18520
  truncated: result2.truncated,
17949
18521
  strategy: `agent:${result2.strategy}`,
17950
18522
  // Audit 0052 P7: the agent is the only tier that can name the bound that stopped the scan.
17951
- truncation: readSearchTruncation(result2)
18523
+ truncation: readSearchTruncation(result2),
18524
+ ...result2.total === null ? {} : { total: result2.total },
18525
+ ...result2.nextOffset === null ? { nextOffset: null } : { nextOffset: result2.nextOffset }
17952
18526
  };
17953
18527
  }
17954
18528
  const result = await transport.exec({ script: grepCommand(request, target.root), maxBytes: MAX_OUTPUT_BYTES }, signal);
@@ -17980,7 +18554,7 @@ function createRemoteToolBackend(deps) {
17980
18554
  async glob(target, request, signal) {
17981
18555
  const base = joinUnderRoot(target.root, request.path);
17982
18556
  compileGlob(request.pattern);
17983
- const transport = await deps.acquire(target.connectionId);
18557
+ const transport = await deps.acquire(target.connectionId, "compute");
17984
18558
  if (transport.globPaths !== void 0) {
17985
18559
  try {
17986
18560
  const native = await transport.globPaths({
@@ -18002,8 +18576,8 @@ function createRemoteToolBackend(deps) {
18002
18576
  strategy: enumerated.source
18003
18577
  };
18004
18578
  },
18005
- async exec(target, request, signal) {
18006
- const transport = await deps.acquire(target.connectionId);
18579
+ async exec(target, request, signal, lane = "interactive") {
18580
+ const transport = await deps.acquire(target.connectionId, lane);
18007
18581
  const writeRoots = deps.execWriteRoots?.(target.connectionId, target.root);
18008
18582
  const result = await transport.exec(
18009
18583
  {
@@ -18169,7 +18743,11 @@ function remoteToolDefinitions(backend) {
18169
18743
  items: { type: "string" },
18170
18744
  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.'
18171
18745
  },
18172
- maxResults: { type: "number", description: "Maximum matches (default 200, hard cap 200). The result reports when the cap was reached." }
18746
+ maxResults: { type: "number", description: "Maximum matches (default 200, hard cap 200). The result reports when the cap was reached." },
18747
+ offset: {
18748
+ type: "number",
18749
+ 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."
18750
+ }
18173
18751
  },
18174
18752
  output: {
18175
18753
  schema: {
@@ -18184,6 +18762,9 @@ function remoteToolDefinitions(backend) {
18184
18762
  include: { type: "array", required: true, items: { type: "string" } },
18185
18763
  truncatedReasons: { type: "array", required: true, items: { type: "string" } },
18186
18764
  filesFiltered: { type: "integer" },
18765
+ total: { type: "integer", description: "Matches found before paging (when the agent reports it)." },
18766
+ nextOffset: { type: "integer", description: "Offset of the next page, or null when there is no next page." },
18767
+ offset: { type: "integer", required: true, description: "The 0-based page offset this result was produced with." },
18187
18768
  matches: {
18188
18769
  type: "array",
18189
18770
  required: true,
@@ -18217,7 +18798,10 @@ function remoteToolDefinitions(backend) {
18217
18798
  include: [...parsed.include ?? []],
18218
18799
  truncatedReasons: [...truncation.reasons],
18219
18800
  ...truncation.filesFiltered === void 0 ? {} : { filesFiltered: truncation.filesFiltered },
18220
- matches: outcome.matches.map((hit) => ({ path: hit.path, line: hit.line, text: hit.text }))
18801
+ matches: outcome.matches.map((hit) => ({ path: hit.path, line: hit.line, text: hit.text })),
18802
+ offset: parsed.offset ?? 0,
18803
+ ...outcome.total === void 0 ? {} : { total: outcome.total },
18804
+ ...typeof outcome.nextOffset === "number" ? { nextOffset: outcome.nextOffset } : {}
18221
18805
  };
18222
18806
  }
18223
18807
  }),
@@ -18314,6 +18898,8 @@ function remoteToolDefinitions(backend) {
18314
18898
  timedOut: { type: "boolean" },
18315
18899
  stdout: { type: "string" },
18316
18900
  stderr: { type: "string" },
18901
+ stdoutBase64: { type: "string" },
18902
+ stderrBase64: { type: "string" },
18317
18903
  durationMs: { type: "integer" },
18318
18904
  truncated: { type: "boolean" }
18319
18905
  }
@@ -18328,8 +18914,10 @@ function remoteToolDefinitions(backend) {
18328
18914
  type: "boolean",
18329
18915
  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`)."
18330
18916
  },
18331
- stdout: { type: "string" },
18332
- stderr: { type: "string" },
18917
+ stdout: { type: "string", description: "stdout as text; invalid UTF-8 is replaced (see stdoutBase64)." },
18918
+ stderr: { type: "string", description: "stderr as text; invalid UTF-8 is replaced (see stderrBase64)." },
18919
+ stdoutBase64: { type: "string", description: "Exact stdout bytes (base64) when stdout is not valid UTF-8 and was not truncated." },
18920
+ stderrBase64: { type: "string", description: "Exact stderr bytes (base64) when stderr is not valid UTF-8 and was not truncated." },
18333
18921
  durationMs: { type: "integer" },
18334
18922
  truncated: { type: "boolean" },
18335
18923
  stdoutTruncated: { type: "boolean" },
@@ -18374,6 +18962,8 @@ function remoteToolDefinitions(backend) {
18374
18962
  timedOut: outcome2.timedOut === true,
18375
18963
  stdout: outcome2.stdout,
18376
18964
  stderr: outcome2.stderr,
18965
+ ...outcome2.stdoutBase64 === void 0 ? {} : { stdoutBase64: outcome2.stdoutBase64 },
18966
+ ...outcome2.stderrBase64 === void 0 ? {} : { stderrBase64: outcome2.stderrBase64 },
18377
18967
  durationMs: outcome2.durationMs,
18378
18968
  truncated: flags2.truncated
18379
18969
  };
@@ -18407,6 +18997,8 @@ function remoteToolDefinitions(backend) {
18407
18997
  timedOut: outcome.timedOut === true,
18408
18998
  stdout: outcome.stdout,
18409
18999
  stderr: outcome.stderr,
19000
+ ...outcome.stdoutBase64 === void 0 ? {} : { stdoutBase64: outcome.stdoutBase64 },
19001
+ ...outcome.stderrBase64 === void 0 ? {} : { stderrBase64: outcome.stderrBase64 },
18410
19002
  durationMs: outcome.durationMs,
18411
19003
  truncated: flags.truncated,
18412
19004
  stdoutTruncated: flags.stdoutTruncated,
@@ -18448,27 +19040,42 @@ function formatGrep(outcome) {
18448
19040
  const truncation = grepTruncation(outcome);
18449
19041
  const lines = [];
18450
19042
  if (outcome.matches.length === 0) {
18451
- lines.push(`\u5728\u8FDC\u7AEF\u672A\u627E\u5230\u5339\u914D\uFF08\u7B56\u7565\uFF1A${outcome.strategy}\uFF09\u3002`);
19043
+ const offset = outcome.offset ?? 0;
19044
+ if (offset > 0 && typeof outcome.total === "number" && outcome.total > 0) {
19045
+ 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`);
19046
+ } else {
19047
+ lines.push(`\u5728\u8FDC\u7AEF\u672A\u627E\u5230\u5339\u914D\uFF08\u7B56\u7565\uFF1A${outcome.strategy}\uFF09\u3002`);
19048
+ }
18452
19049
  if (include.length > 0 && truncation?.filesFiltered !== void 0 && truncation.filesFiltered > 0) {
18453
19050
  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`);
18454
19051
  }
18455
19052
  } else {
18456
19053
  lines.push(outcome.matches.map((hit) => `${hit.path}:${hit.line}: ${hit.text}`).join("\n"));
18457
19054
  }
19055
+ if (typeof outcome.total === "number") {
19056
+ if (typeof outcome.nextOffset === "number") {
19057
+ lines.push(`\u5DF2\u626B\u63CF\u5230 ${outcome.total} \u6761\uFF0C\u8FD8\u6709\u4E0B\u4E00\u9875\uFF1Aoffset=${outcome.nextOffset}`);
19058
+ } else {
19059
+ lines.push(`\u5339\u914D\u603B\u6570\uFF1A${outcome.total}`);
19060
+ }
19061
+ }
18458
19062
  const notes = [scope, ...modes].filter((part) => part !== "");
18459
19063
  if (notes.length > 0) lines.push(`\uFF08${notes.join("\uFF1B")}\uFF09`);
18460
- lines.push(...truncationLines(truncation));
19064
+ lines.push(...truncationLines(truncation, { paging: outcome.strategy.startsWith("agent") }));
18461
19065
  return lines.join("\n");
18462
19066
  }
18463
- function truncationLines(truncation) {
19067
+ function truncationLines(truncation, options = {}) {
18464
19068
  if (truncation === void 0 || !truncation.truncated) return [];
18465
- if (truncation.reasons.length === 0) {
18466
- 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"];
18467
- }
19069
+ const paging = options.paging === true;
19070
+ 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";
19071
+ if (truncation.reasons.length === 0) return [summary];
19072
+ const advice = truncation.reasons.map(
19073
+ (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)
19074
+ );
18468
19075
  return [
18469
19076
  `\uFF08\u7ED3\u679C\u88AB\u622A\u65AD\uFF0C\u89E6\u53D1\u4E0A\u9650\uFF1A${truncation.reasons.join(", ")}\uFF09`,
18470
- ...truncation.reasons.map((reason) => `\xB7 ${searchTruncationAdvice(reason)}`),
18471
- "\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"
19077
+ ...advice.map((line) => `\xB7 ${line}`),
19078
+ 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"
18472
19079
  ];
18473
19080
  }
18474
19081
  function formatGlob(outcome) {
@@ -18517,6 +19124,12 @@ function formatExec(outcome) {
18517
19124
  ${stdout.text}`);
18518
19125
  if (stderr.text !== "") parts.push(`stderr:
18519
19126
  ${stderr.text}`);
19127
+ if (outcome.stdoutBase64 !== void 0) {
19128
+ 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`);
19129
+ }
19130
+ if (outcome.stderrBase64 !== void 0) {
19131
+ 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`);
19132
+ }
18520
19133
  const stdoutCut = outcome.stdoutTruncated ?? outcome.truncated;
18521
19134
  const stderrCut = outcome.stderrTruncated ?? outcome.truncated;
18522
19135
  const cut = [
@@ -18636,7 +19249,8 @@ function toOutputView(output) {
18636
19249
  lost: output.lost,
18637
19250
  exitCode: output.exitCode,
18638
19251
  ...output.signal === void 0 ? {} : { signal: output.signal },
18639
- timedOut: output.timedOut
19252
+ timedOut: output.timedOut,
19253
+ ...output.exactBytes === void 0 ? {} : { binary: true, dataBase64: Buffer.from(output.exactBytes).toString("base64") }
18640
19254
  };
18641
19255
  }
18642
19256
  function defaultSleep2(ms, signal) {
@@ -18661,7 +19275,7 @@ function createRemoteJobBackend(deps) {
18661
19275
  const now = deps.now ?? (() => Date.now());
18662
19276
  const sleep2 = deps.sleep ?? defaultSleep2;
18663
19277
  const require2 = async (target) => {
18664
- const transport = await deps.acquire(target.connectionId);
19278
+ const transport = await deps.acquire(target.connectionId, target.sessionKey);
18665
19279
  if (!isAgentJobCapable(transport)) throw unsupported(target.connectionId);
18666
19280
  return transport;
18667
19281
  };
@@ -18744,7 +19358,9 @@ function text3(value) {
18744
19358
  return [{ type: "text", text: value }];
18745
19359
  }
18746
19360
  function targetFor2(backend, exec) {
18747
- return resolveSessionTarget(exec, { resolve: (cwd) => backend.resolve(cwd), resolveSessionRoot: backend.resolveSessionRoot });
19361
+ const target = resolveSessionTarget(exec, { resolve: (cwd) => backend.resolve(cwd), resolveSessionRoot: backend.resolveSessionRoot });
19362
+ const sessionKey2 = exec.agent?.session.header.id;
19363
+ return sessionKey2 === void 0 || sessionKey2 === "" ? target : { ...target, sessionKey: sessionKey2 };
18748
19364
  }
18749
19365
  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.";
18750
19366
  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.";
@@ -18801,6 +19417,11 @@ function formatJobOutput(view) {
18801
19417
  );
18802
19418
  }
18803
19419
  parts.push(view.data === "" ? "\uFF08\u672C\u6B21\u6CA1\u6709\u65B0\u7684\u8F93\u51FA\uFF09" : view.data.replace(/\n$/, ""));
19420
+ if (view.binary === true) {
19421
+ parts.push(
19422
+ `\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`
19423
+ );
19424
+ }
18804
19425
  if (view.lost) {
18805
19426
  parts.push(
18806
19427
  `\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`
@@ -19033,7 +19654,9 @@ function remoteJobToolDefinitions(backend) {
19033
19654
  jobs: { type: "array", items: { type: "string" }, description: "`list`: one formatted line per job; use `status` for structured fields." },
19034
19655
  jobId: { type: "string", description: "`output` / `kill` / `stdin`: the job acted on." },
19035
19656
  killed: { type: "boolean", description: "`kill`: false when the job had already finished." },
19036
- data: { type: "string", description: "`output`: output from the requested offset; empty when there is nothing new." },
19657
+ data: { type: "string", description: "`output`: output from the requested offset; empty when there is nothing new. Invalid UTF-8 is replaced (see binary/dataBase64)." },
19658
+ binary: { type: "boolean", description: "`output`: true when the chunk contained non-UTF-8 bytes; `data` is then not the exact content." },
19659
+ dataBase64: { type: "string", description: "`output`: exact bytes (base64), present only when binary is true." },
19037
19660
  state: { type: "string", description: "`output`: `running` / `exited` / `killed` / `failed`." },
19038
19661
  droppedBytes: { type: "integer", description: "`output`: bytes dropped from the oldest end of the ring. Bytes, not lines." },
19039
19662
  exitCode: { type: "integer", description: "`output`: exit code; 0 while the job is still running." },
@@ -19137,6 +19760,7 @@ function remoteJobToolDefinitions(backend) {
19137
19760
  lost: view.lost,
19138
19761
  exitCode: view.exitCode,
19139
19762
  ...view.signal === void 0 ? {} : { signal: view.signal },
19763
+ ...view.binary === true ? { binary: true, ...view.dataBase64 === void 0 ? {} : { dataBase64: view.dataBase64 } } : {},
19140
19764
  timedOut: view.timedOut,
19141
19765
  running: view.state === "running",
19142
19766
  waitedMs,
@@ -19168,6 +19792,7 @@ async function followOutput(backend, target, jobId, offset, timeoutMs, signal) {
19168
19792
  lost: view.lost,
19169
19793
  exitCode: view.exitCode,
19170
19794
  ...view.signal === void 0 ? {} : { signal: view.signal },
19795
+ ...view.binary === true ? { binary: true, ...view.dataBase64 === void 0 ? {} : { dataBase64: view.dataBase64 } } : {},
19171
19796
  timedOut: false,
19172
19797
  running,
19173
19798
  waitedMs: Date.now() - started,
@@ -19188,6 +19813,7 @@ async function followOutput(backend, target, jobId, offset, timeoutMs, signal) {
19188
19813
  lost: view.lost,
19189
19814
  exitCode: view.exitCode,
19190
19815
  ...view.signal === void 0 ? {} : { signal: view.signal },
19816
+ ...view.binary === true ? { binary: true, ...view.dataBase64 === void 0 ? {} : { dataBase64: view.dataBase64 } } : {},
19191
19817
  timedOut: true,
19192
19818
  running: true,
19193
19819
  waitedMs: Date.now() - started,
@@ -19249,6 +19875,52 @@ function rememberVersion(target, absolute, version) {
19249
19875
  rememberedVersions.delete(oldest.value);
19250
19876
  }
19251
19877
  }
19878
+ function capReadManyItems(items, maxBytesEach, totalBytesCap) {
19879
+ let remaining = totalBytesCap;
19880
+ return items.map((item) => {
19881
+ if (item.text === void 0) return item;
19882
+ if (item.binary === true && item.dataBase64 !== void 0) {
19883
+ let raw = Buffer.from(item.dataBase64, "base64");
19884
+ let truncated2 = item.truncated;
19885
+ if (raw.byteLength > maxBytesEach) {
19886
+ raw = raw.subarray(0, maxBytesEach);
19887
+ truncated2 = true;
19888
+ }
19889
+ if (raw.byteLength > remaining) {
19890
+ raw = raw.subarray(0, Math.max(0, remaining));
19891
+ truncated2 = true;
19892
+ remaining = 0;
19893
+ } else {
19894
+ remaining -= raw.byteLength;
19895
+ }
19896
+ return { ...item, text: raw.toString("utf8"), dataBase64: raw.toString("base64"), truncated: truncated2 };
19897
+ }
19898
+ let text8 = item.text;
19899
+ let truncated = item.truncated;
19900
+ let bytes = Buffer.byteLength(text8, "utf8");
19901
+ if (bytes > maxBytesEach) {
19902
+ text8 = truncateUtf8(text8, maxBytesEach);
19903
+ truncated = true;
19904
+ bytes = Buffer.byteLength(text8, "utf8");
19905
+ }
19906
+ if (bytes > remaining) {
19907
+ text8 = truncateUtf8(text8, Math.max(0, remaining));
19908
+ truncated = true;
19909
+ remaining = 0;
19910
+ } else {
19911
+ remaining -= bytes;
19912
+ }
19913
+ return { ...item, text: text8, truncated };
19914
+ });
19915
+ }
19916
+ function truncateUtf8(text8, maxBytes) {
19917
+ const buffer = Buffer.from(text8, "utf8");
19918
+ if (buffer.byteLength <= maxBytes) return text8;
19919
+ if (maxBytes <= 0) return "";
19920
+ let end = Math.min(maxBytes, buffer.byteLength);
19921
+ while (end > 0 && end < buffer.byteLength && ((buffer[end] ?? 0) & 192) === 128) end -= 1;
19922
+ return buffer.subarray(0, end).toString("utf8");
19923
+ }
19252
19924
  function createRemoteFileBackend(deps) {
19253
19925
  const cap = Math.max(1, Math.floor(deps.maxReadBytes ?? MAX_READ_FILE_BYTES));
19254
19926
  const requireBackend = (target) => {
@@ -19266,7 +19938,14 @@ function createRemoteFileBackend(deps) {
19266
19938
  async read(target, absolute, signal) {
19267
19939
  const chunk = await requireBackend(target).readText(absolute, cap, signal);
19268
19940
  rememberVersion(target, absolute, chunk.version);
19269
- return { text: chunk.text, size: chunk.size, truncated: chunk.truncated, version: chunk.version };
19941
+ return {
19942
+ text: chunk.text,
19943
+ size: chunk.size,
19944
+ truncated: chunk.truncated,
19945
+ version: chunk.version,
19946
+ ...chunk.binary === true ? { binary: true } : {},
19947
+ ...chunk.dataBase64 === void 0 ? {} : { dataBase64: chunk.dataBase64 }
19948
+ };
19270
19949
  },
19271
19950
  async write(target, absolute, text8, expectedVersion, signal) {
19272
19951
  const outcome = await requireBackend(target).writeText(absolute, text8, expectedVersion, signal);
@@ -19288,6 +19967,69 @@ function createRemoteFileBackend(deps) {
19288
19967
  rememberVersion(target, absolute, outcome.version);
19289
19968
  return { replaced: outcome.replaced, version: outcome.version, bytes: outcome.bytes };
19290
19969
  },
19970
+ async readMany(target, absolutes, maxBytesEach, totalBytesCap, signal) {
19971
+ const backend = requireBackend(target);
19972
+ if (backend.readMany === void 0) {
19973
+ const read = await Promise.all(absolutes.map(async (absolute) => {
19974
+ try {
19975
+ const chunk = await backend.readText(absolute, maxBytesEach, signal);
19976
+ rememberVersion(target, absolute, chunk.version);
19977
+ return {
19978
+ path: absolute,
19979
+ text: chunk.text,
19980
+ size: chunk.size,
19981
+ version: chunk.version,
19982
+ truncated: chunk.truncated,
19983
+ ...chunk.binary === true ? { binary: true } : {},
19984
+ ...chunk.dataBase64 === void 0 ? {} : { dataBase64: chunk.dataBase64 }
19985
+ };
19986
+ } catch (error) {
19987
+ return { path: absolute, size: 0, version: "", truncated: false, error: error instanceof Error ? error.message : String(error) };
19988
+ }
19989
+ }));
19990
+ return { items: capReadManyItems(read, maxBytesEach, totalBytesCap), missing: [] };
19991
+ }
19992
+ const result = await backend.readMany(absolutes, { maxBytesEach, totalBytesCap, signal });
19993
+ const items = absolutes.map((absolute, index) => {
19994
+ const chunk = result.chunks[index];
19995
+ if (chunk === void 0) {
19996
+ 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" };
19997
+ }
19998
+ rememberVersion(target, absolute, chunk.version);
19999
+ return {
20000
+ path: absolute,
20001
+ text: chunk.text,
20002
+ size: chunk.size,
20003
+ version: chunk.version,
20004
+ truncated: chunk.truncated,
20005
+ ...chunk.binary === true ? { binary: true } : {},
20006
+ ...chunk.dataBase64 === void 0 ? {} : { dataBase64: chunk.dataBase64 }
20007
+ };
20008
+ });
20009
+ return { items: capReadManyItems(items, maxBytesEach, totalBytesCap), missing: result.missing };
20010
+ },
20011
+ async projectSummary(target, signal) {
20012
+ const backend = requireBackend(target);
20013
+ if (backend.repoSummary === void 0) {
20014
+ 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");
20015
+ }
20016
+ return backend.repoSummary(target.root, signal);
20017
+ },
20018
+ async applyPatchMany(target, edits, atomic, signal) {
20019
+ const backend = requireBackend(target);
20020
+ if (backend.patchMany === void 0) {
20021
+ 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");
20022
+ }
20023
+ const result = await backend.patchMany(edits, atomic, signal);
20024
+ if (result["rolledBack"] !== true && Array.isArray(result["results"])) {
20025
+ for (const entry of result["results"]) {
20026
+ if (entry.ok === true && typeof entry.path === "string" && typeof entry.version === "string" && entry.version !== "") {
20027
+ rememberVersion(target, entry.path, entry.version);
20028
+ }
20029
+ }
20030
+ }
20031
+ return result;
20032
+ },
19291
20033
  recall(target, absolute) {
19292
20034
  return rememberedVersions.get(versionKey(target, absolute));
19293
20035
  },
@@ -19386,6 +20128,9 @@ function formatRead(view) {
19386
20128
  const truncatedByWindow = view.lines.length >= view.limit && endLine < view.totalLines;
19387
20129
  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";
19388
20130
  const parts = [body];
20131
+ if (view.binary === true) {
20132
+ 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");
20133
+ }
19389
20134
  if (view.sourceTruncated) {
19390
20135
  parts.push(
19391
20136
  `\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`
@@ -19484,6 +20229,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19484
20229
  description: "The whole-file read hit the byte cap, so later lines were not read at all."
19485
20230
  },
19486
20231
  maxReadBytes: { type: "integer", required: true, description: "The byte cap that read was subject to." },
20232
+ 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." },
19487
20233
  size: { type: "integer", required: true, description: "Full file size in bytes." },
19488
20234
  version: {
19489
20235
  type: "string",
@@ -19527,6 +20273,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19527
20273
  maxReadBytes: backend.maxReadBytes,
19528
20274
  size: file.size,
19529
20275
  version: file.version,
20276
+ ...file.binary === true ? { binary: true } : {},
19530
20277
  lines: window.lines.map((line) => ({ number: line.number, text: line.text }))
19531
20278
  };
19532
20279
  }
@@ -19821,11 +20568,280 @@ function remoteFileToolDefinitions(backend, allowMutation) {
19821
20568
  ];
19822
20569
  }
19823
20570
  function registerRemoteFileTools(ctx, backend, options) {
19824
- const disposers = remoteFileToolDefinitions(backend, options?.allowMutation).map((definition) => ctx.tools.register(definition));
20571
+ const disposers = [
20572
+ ...remoteFileToolDefinitions(backend, options?.allowMutation),
20573
+ remoteReadManyToolDefinition(backend),
20574
+ remoteProjectSummaryToolDefinition(backend),
20575
+ remoteApplyPatchManyToolDefinition(backend, options?.allowMutation)
20576
+ ].map((definition) => ctx.tools.register(definition));
19825
20577
  return () => {
19826
20578
  for (const dispose of disposers.splice(0)) dispose();
19827
20579
  };
19828
20580
  }
20581
+ function remoteReadManyToolDefinition(backend) {
20582
+ return defineTool5({
20583
+ name: "remote_read_many",
20584
+ 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.",
20585
+ isConcurrencySafe: () => true,
20586
+ parameters: {
20587
+ paths: {
20588
+ type: "array",
20589
+ items: { type: "string" },
20590
+ required: true,
20591
+ description: `Files to read (1..16). ${PATH_SPELLING3} Relative paths resolve under the target root.`
20592
+ },
20593
+ maxBytes: {
20594
+ type: "number",
20595
+ description: "Per-file byte cap (default 262144, hard cap 1048576). Truncated files say so."
20596
+ }
20597
+ },
20598
+ output: {
20599
+ schema: {
20600
+ type: "object",
20601
+ additionalProperties: false,
20602
+ properties: {
20603
+ strategy: { type: "string", required: true, description: "`batch` (one logical call; the backend may fall back to parallel single reads internally)." },
20604
+ totalBytes: { type: "integer", required: true },
20605
+ items: {
20606
+ type: "array",
20607
+ required: true,
20608
+ items: {
20609
+ type: "object",
20610
+ additionalProperties: false,
20611
+ properties: {
20612
+ path: { type: "string", required: true },
20613
+ text: { type: "string", description: "File text; invalid UTF-8 is replaced (see binary/dataBase64)." },
20614
+ size: { type: "integer", required: true },
20615
+ version: { type: "string", required: true },
20616
+ truncated: { type: "boolean", required: true },
20617
+ error: { type: "string" },
20618
+ binary: { type: "boolean", description: "The file is not valid UTF-8; `text` is only a readable replacement." },
20619
+ dataBase64: { type: "string", description: "Exact bytes (base64), present only when binary is true." }
20620
+ }
20621
+ }
20622
+ },
20623
+ missing: { type: "array", required: true, items: { type: "string" } }
20624
+ }
20625
+ },
20626
+ render: (_args, value) => text4(formatReadMany(value))
20627
+ },
20628
+ async execute(args, exec) {
20629
+ const record2 = args ?? {};
20630
+ const raw = record2["paths"];
20631
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 16) {
20632
+ throw new Error("paths \u5FC5\u987B\u662F 1..16 \u4E2A\u6587\u4EF6\u8DEF\u5F84");
20633
+ }
20634
+ const requested = raw.map((item, index) => requiredString3(item, `paths[${index}]`));
20635
+ const maxBytesEach = clampInt3(record2["maxBytes"], 262144, 1, 1048576);
20636
+ const target = targetFor3(backend, exec);
20637
+ const absolutes = requested.map((path) => resolveOrThrow3(target, path));
20638
+ const totalBytesCap = 2 * 1024 * 1024;
20639
+ const batch = backend.readMany;
20640
+ const result = batch === void 0 ? {
20641
+ items: await (async () => {
20642
+ const read = await Promise.all(absolutes.map(async (path) => {
20643
+ try {
20644
+ const chunk = await backend.read(target, path, exec.signal);
20645
+ return { path, text: chunk.text, size: chunk.size, version: chunk.version, truncated: chunk.truncated };
20646
+ } catch (error) {
20647
+ return { path, size: 0, version: "", truncated: false, error: error instanceof Error ? error.message : String(error) };
20648
+ }
20649
+ }));
20650
+ let remaining = totalBytesCap;
20651
+ return read.map((item) => {
20652
+ if (item.text === void 0) return item;
20653
+ let text8 = item.text;
20654
+ let truncated = item.truncated;
20655
+ let bytes = Buffer.byteLength(text8, "utf8");
20656
+ if (bytes > maxBytesEach) {
20657
+ text8 = truncateUtf8(text8, maxBytesEach);
20658
+ truncated = true;
20659
+ bytes = Buffer.byteLength(text8, "utf8");
20660
+ }
20661
+ if (bytes > remaining) {
20662
+ text8 = truncateUtf8(text8, remaining);
20663
+ truncated = true;
20664
+ remaining = 0;
20665
+ } else {
20666
+ remaining -= bytes;
20667
+ }
20668
+ return { ...item, text: text8, truncated };
20669
+ });
20670
+ })(),
20671
+ missing: []
20672
+ } : await batch.call(backend, target, absolutes, maxBytesEach, totalBytesCap, exec.signal);
20673
+ return {
20674
+ strategy: "batch",
20675
+ totalBytes: result.items.reduce((sum, item) => {
20676
+ const binary = item.binary === true;
20677
+ const dataBase64 = item.dataBase64;
20678
+ if (binary && dataBase64 !== void 0) return sum + Buffer.from(dataBase64, "base64").byteLength;
20679
+ return sum + (item.text === void 0 ? 0 : Buffer.byteLength(item.text, "utf8"));
20680
+ }, 0),
20681
+ items: result.items.map((item) => ({ ...item })),
20682
+ missing: [...result.missing]
20683
+ };
20684
+ }
20685
+ });
20686
+ }
20687
+ function formatReadMany(view) {
20688
+ 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`;
20689
+ const sections = view.items.map((item) => {
20690
+ if (item.error !== void 0) return `## ${item.path}
20691
+ \u8BFB\u53D6\u5931\u8D25\uFF1A${item.error}`;
20692
+ const truncated = item.truncated ? `\uFF08\u5DF2\u622A\u65AD\uFF0C\u5B8C\u6574 ${item.size} \u5B57\u8282\uFF09` : "";
20693
+ const body = item.text ?? "";
20694
+ const binary = item.binary === true ? `
20695
+ \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` : "";
20696
+ return `## ${item.path}\uFF08version=${item.version}${truncated}\uFF09
20697
+ ${body}${binary}`;
20698
+ });
20699
+ const missing = view.missing.length === 0 ? "" : `
20700
+ \u65E0\u6CD5\u8BFB\u53D6\uFF1A${view.missing.join("\u3001")}`;
20701
+ return [head, ...sections].join("\n\n") + missing;
20702
+ }
20703
+ function remoteProjectSummaryToolDefinition(backend) {
20704
+ return defineTool5({
20705
+ name: "remote_project_summary",
20706
+ 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.",
20707
+ isConcurrencySafe: () => true,
20708
+ parameters: {
20709
+ path: { type: "string", description: `Directory to summarise. ${PATH_SPELLING3} Defaults to the target root.` }
20710
+ },
20711
+ output: {
20712
+ schema: {
20713
+ type: "object",
20714
+ additionalProperties: false,
20715
+ properties: {
20716
+ root: { type: "string", required: true },
20717
+ summary: { type: "object", required: true, additionalProperties: true, description: "The agent repo.summary payload." }
20718
+ }
20719
+ },
20720
+ render: (_args, value) => text4(formatProjectSummary(value))
20721
+ },
20722
+ async execute(args, exec) {
20723
+ const record2 = args ?? {};
20724
+ const target = targetFor3(backend, exec);
20725
+ const root = record2["path"] === void 0 ? target.root : resolveOrThrow3(target, requiredString3(record2["path"], "path"));
20726
+ 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");
20727
+ const summary = await backend.projectSummary({ ...target, root }, exec.signal);
20728
+ return { root, summary };
20729
+ }
20730
+ });
20731
+ }
20732
+ function formatProjectSummary(value) {
20733
+ const lines = [`\u9879\u76EE\u6982\u89C8 ${value.root}`];
20734
+ const git = value.summary["git"];
20735
+ if (typeof git === "object" && git !== null) {
20736
+ const record2 = git;
20737
+ const remote = typeof record2["remoteUrl"] === "string" ? `\uFF0Cremote=${record2["remoteUrl"]}` : "";
20738
+ const cut = record2["truncated"] === true ? "\uFF08status \u8F93\u51FA\u88AB\u622A\u65AD\uFF0Cdirty \u53EA\u662F\u4E0B\u754C\uFF09" : "";
20739
+ lines.push(`git\uFF1A${String(record2["branch"] ?? "?")}\uFF0Cdirty=${String(record2["dirty"] ?? 0)}\uFF0Cahead=${String(record2["ahead"] ?? 0)}\uFF0Cbehind=${String(record2["behind"] ?? 0)}${remote}${cut}`);
20740
+ } else {
20741
+ lines.push("git\uFF1A\u4E0D\u53EF\u7528\uFF08\u6216\u8BE5\u76EE\u5F55\u4E0D\u662F\u5DE5\u4F5C\u6811\uFF09");
20742
+ }
20743
+ const topLevel = Array.isArray(value.summary["topLevel"]) ? value.summary["topLevel"] : [];
20744
+ if (topLevel.length > 0) {
20745
+ const names = topLevel.map((entry) => {
20746
+ const record2 = entry ?? {};
20747
+ const name2 = typeof record2["name"] === "string" ? record2["name"] : "?";
20748
+ return record2["isDirectory"] === true ? `${name2}/` : name2;
20749
+ });
20750
+ lines.push(`\u9876\u5C42\uFF08${names.length}${value.summary["truncated"] === true ? "\uFF0C\u5DF2\u622A\u65AD" : ""}\uFF09\uFF1A${names.join("\uFF0C")}`);
20751
+ }
20752
+ const manifests = Array.isArray(value.summary["manifests"]) ? value.summary["manifests"] : [];
20753
+ if (manifests.length > 0) {
20754
+ const listed = manifests.map((entry) => {
20755
+ const record2 = entry ?? {};
20756
+ const kind = typeof record2["kind"] === "string" ? record2["kind"] : "?";
20757
+ const path = typeof record2["path"] === "string" ? record2["path"] : "?";
20758
+ return `${kind}\uFF08${path}\uFF09`;
20759
+ });
20760
+ lines.push(`\u6E05\u5355\uFF1A${listed.join("\uFF0C")}`);
20761
+ }
20762
+ const detected = value.summary["detected"];
20763
+ const commands = typeof detected === "object" && detected !== null ? Object.entries(detected).filter(([, command]) => typeof command === "string").map(([name2, command]) => `${name2}=${String(command)}`).join("\uFF0C") : "";
20764
+ if (commands !== "") lines.push(`\u63A8\u65AD\u547D\u4EE4\uFF1A${commands}`);
20765
+ const readme = value.summary["readmeHead"];
20766
+ if (typeof readme === "string" && readme.trim() !== "") {
20767
+ const head = readme.length > 1536 ? `${readme.slice(0, 1536)}
20768
+ \u2026\uFF08README \u5DF2\u622A\u65AD\uFF09` : readme;
20769
+ lines.push(`README \u5934\uFF1A
20770
+ ${head.trimEnd()}`);
20771
+ }
20772
+ return lines.join("\n");
20773
+ }
20774
+ function remoteApplyPatchManyToolDefinition(backend, allowMutation) {
20775
+ return defineTool5({
20776
+ name: "remote_apply_patch",
20777
+ 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.",
20778
+ parameters: {
20779
+ edits: {
20780
+ type: "array",
20781
+ required: true,
20782
+ description: "1..32 edits; each needs `path` plus either `find`+`replace` or `patch`.",
20783
+ items: {
20784
+ type: "object",
20785
+ additionalProperties: false,
20786
+ properties: {
20787
+ path: { type: "string", required: true },
20788
+ expectedVersion: { type: "string" },
20789
+ find: { type: "string" },
20790
+ replace: { type: "string" },
20791
+ all: { type: "boolean" },
20792
+ create: { type: "boolean" },
20793
+ patch: { type: "string" }
20794
+ }
20795
+ }
20796
+ },
20797
+ atomic: { type: "boolean", description: "Roll back on a late failure (default true)." }
20798
+ },
20799
+ output: {
20800
+ schema: {
20801
+ type: "object",
20802
+ additionalProperties: false,
20803
+ properties: {
20804
+ applied: { type: "integer", required: true },
20805
+ rolledBack: { type: "boolean", required: true },
20806
+ results: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
20807
+ }
20808
+ },
20809
+ render: (_args, value) => text4(formatApplyPatchMany(value))
20810
+ },
20811
+ async execute(args, exec) {
20812
+ const record2 = args ?? {};
20813
+ const raw = record2["edits"];
20814
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 32) throw new Error("edits \u5FC5\u987B\u662F 1..32 \u6761\u7F16\u8F91");
20815
+ const target = targetFor3(backend, exec);
20816
+ const edits = raw.map((entry, index) => {
20817
+ const item = entry ?? {};
20818
+ const path = resolveOrThrow3(target, requiredString3(item["path"], `edits[${index}].path`));
20819
+ assertWritable(target, path, allowMutation);
20820
+ return {
20821
+ path,
20822
+ ...typeof item["expectedVersion"] === "string" ? { expectedVersion: item["expectedVersion"] } : {},
20823
+ ...typeof item["find"] === "string" ? { find: item["find"] } : {},
20824
+ ...typeof item["replace"] === "string" ? { replace: item["replace"] } : {},
20825
+ ...item["all"] === true ? { all: true } : {},
20826
+ ...item["create"] === true ? { create: true } : {},
20827
+ ...typeof item["patch"] === "string" ? { patch: item["patch"] } : {}
20828
+ };
20829
+ });
20830
+ 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");
20831
+ const result = await backend.applyPatchMany(target, edits, record2["atomic"] !== false, exec.signal);
20832
+ return {
20833
+ applied: typeof result["applied"] === "number" ? result["applied"] : 0,
20834
+ rolledBack: result["rolledBack"] === true,
20835
+ results: Array.isArray(result["results"]) ? result["results"] : []
20836
+ };
20837
+ }
20838
+ });
20839
+ }
20840
+ function formatApplyPatchMany(value) {
20841
+ const head = `\u6279\u91CF\u4FEE\u6539\uFF1A\u6210\u529F ${value.applied} \u6761${value.rolledBack ? "\uFF0C\u5931\u8D25\u540E\u5DF2\u56DE\u6EDA" : ""}\u3002`;
20842
+ const lines = value.results.map((item) => `- ${item.path}\uFF1A${item.ok ? "\u6210\u529F" : `\u5931\u8D25\uFF08${item.error ?? "\u672A\u77E5\u539F\u56E0"}\uFF09`}`);
20843
+ return [head, ...lines].join("\n");
20844
+ }
19829
20845
 
19830
20846
  // src/host/integration/lsp/lsp-framing.ts
19831
20847
  var HEADER_END = Buffer.from("\r\n\r\n", "latin1");
@@ -19930,6 +20946,7 @@ function parseContentLength(header) {
19930
20946
  }
19931
20947
 
19932
20948
  // src/host/integration/lsp/lsp-session.ts
20949
+ var LSP_READ_CHUNK_BYTES = 256 * 1024;
19933
20950
  var IDLE_POLL_MS = 60;
19934
20951
  var BUSY_POLL_MS = 0;
19935
20952
  var DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
@@ -20218,16 +21235,17 @@ var LspSession = class _LspSession {
20218
21235
  if (this.#stopped) return;
20219
21236
  let delay = IDLE_POLL_MS;
20220
21237
  try {
20221
- const chunk = await this.#transport.lspRead(this.#id, this.#offset, signal);
21238
+ const chunk = await this.#transport.lspRead(this.#id, this.#offset, signal, LSP_READ_CHUNK_BYTES);
20222
21239
  this.#offset = chunk.nextOffset;
20223
21240
  if (chunk.lost) {
20224
21241
  this.#failAll(new LspSessionError("\u8BED\u8A00\u670D\u52A1\u5668\u8F93\u51FA\u5DF2\u88AB\u4E22\u5F03\uFF08\u73AF\u5F62\u7F13\u51B2\u6EA2\u51FA\uFF09\uFF0C\u6D41\u4E0D\u53EF\u6062\u590D"));
20225
21242
  this.#stopped = true;
20226
21243
  return;
20227
21244
  }
20228
- if (chunk.data !== "") {
21245
+ const bytes = chunk.exactBytes ?? (chunk.data === "" ? void 0 : Buffer.from(chunk.data, "utf8"));
21246
+ if (bytes !== void 0 && bytes.length > 0) {
20229
21247
  delay = BUSY_POLL_MS;
20230
- for (const message of this.#parser.feed(chunk.data)) this.#dispatch(message);
21248
+ for (const message of this.#parser.feed(bytes)) this.#dispatch(message);
20231
21249
  }
20232
21250
  if (chunk.state === "exited") {
20233
21251
  this.#failAll(new LspSessionError("\u8BED\u8A00\u670D\u52A1\u5668\u5DF2\u9000\u51FA"));
@@ -20479,7 +21497,7 @@ function describeInstalled(available) {
20479
21497
  }
20480
21498
  var MAX_SESSIONS = 8;
20481
21499
  function sessionKeyPrefix(target, language) {
20482
- return `${target.connectionId}\0${target.root}\0${language}\0`;
21500
+ return `${target.connectionId}\0${target.root}\0${language}\0${target.sessionKey ?? ""}\0`;
20483
21501
  }
20484
21502
  function sessionKey(target, language, override) {
20485
21503
  const server = override === void 0 ? "" : `${override.command}\0${(override.args ?? []).join("\0")}`;
@@ -20518,7 +21536,7 @@ function createLspRegistry(deps) {
20518
21536
  `\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`
20519
21537
  );
20520
21538
  }
20521
- const transport = await deps.acquire(target.connectionId);
21539
+ const transport = await deps.acquire(target.connectionId, target.sessionKey);
20522
21540
  const available = transport.capabilities?.lspServers;
20523
21541
  if (available !== void 0 && !command.command.includes("/")) {
20524
21542
  if (!available.some((server) => server.command === command.command)) {
@@ -20625,6 +21643,21 @@ function createForwardToolBackend(acquire) {
20625
21643
  { connectionId }
20626
21644
  );
20627
21645
  }
21646
+ if (transport.netProbe !== void 0) {
21647
+ let probe2;
21648
+ try {
21649
+ probe2 = await transport.netProbe(normalizeProbeHost(request.remoteHost), request.remotePort, signal);
21650
+ } catch (error) {
21651
+ if (!(error instanceof SshDevError) || error.code !== "REMOTE_UNSUPPORTED") throw error;
21652
+ }
21653
+ if (probe2 !== void 0 && !probe2.reachable) {
21654
+ throw new SshDevError(
21655
+ "CONNECTION_UNREACHABLE",
21656
+ `\u8FDC\u7AEF\u7AEF\u53E3\u4E0D\u53EF\u8FBE\uFF1A${request.remoteHost}:${String(request.remotePort)}${probe2.error === void 0 ? "" : `\uFF08${probe2.error}\uFF09`}`,
21657
+ { connectionId, remoteHost: request.remoteHost, remotePort: request.remotePort }
21658
+ );
21659
+ }
21660
+ }
20628
21661
  const handle = await transport.forward(request, signal);
20629
21662
  sequence2 += 1;
20630
21663
  const id = `fwd-${String(sequence2)}`;
@@ -20658,6 +21691,9 @@ function createForwardToolBackend(acquire) {
20658
21691
  }
20659
21692
  };
20660
21693
  }
21694
+ function normalizeProbeHost(host) {
21695
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
21696
+ }
20661
21697
  function formatResult(value) {
20662
21698
  if (Array.isArray(value["forwards"])) {
20663
21699
  const forwards = value["forwards"];
@@ -21067,12 +22103,13 @@ function remoteGitDefinition(backend) {
21067
22103
  stashAction: readString3(record2["stashAction"]),
21068
22104
  all: record2["all"] === true
21069
22105
  });
22106
+ const lane = READ_ACTIONS.includes(action) ? "compute" : "interactive";
21070
22107
  const outcome = await backend.exec(target, {
21071
22108
  command: quoteAll(argv),
21072
22109
  cwd: resolveCwd(target, readString3(record2["cwd"])),
21073
22110
  env: { ...GIT_ENV },
21074
22111
  timeoutMs: readPositiveInt2(record2["timeoutMs"], DEFAULT_TIMEOUT_MS3)
21075
- }, exec.signal);
22112
+ }, exec.signal, lane);
21076
22113
  const capped = capLines(outcome.stdout, maxLines);
21077
22114
  const errCapped = capLines(outcome.stderr, maxLines);
21078
22115
  const base = {
@@ -21180,9 +22217,44 @@ function readArgs(args) {
21180
22217
  ...typeof record2["server"] === "string" && record2["server"] !== "" ? { server: record2["server"] } : {}
21181
22218
  };
21182
22219
  }
22220
+ function splitCommandLine(value) {
22221
+ const parts = [];
22222
+ let current = "";
22223
+ let started = false;
22224
+ let quote2;
22225
+ for (let index = 0; index < value.length; index += 1) {
22226
+ const char = value[index];
22227
+ if (quote2 !== void 0) {
22228
+ if (char === quote2) quote2 = void 0;
22229
+ else if (char === "\\" && quote2 === '"' && index + 1 < value.length) {
22230
+ current += value[index + 1];
22231
+ index += 1;
22232
+ } else current += char;
22233
+ continue;
22234
+ }
22235
+ if (char === '"' || char === "'") {
22236
+ quote2 = char;
22237
+ started = true;
22238
+ continue;
22239
+ }
22240
+ if (/\s/.test(char)) {
22241
+ if (started) {
22242
+ parts.push(current);
22243
+ current = "";
22244
+ started = false;
22245
+ }
22246
+ continue;
22247
+ }
22248
+ current += char;
22249
+ started = true;
22250
+ }
22251
+ if (quote2 !== void 0) throw new Error('server \u7684\u5F15\u53F7\u6CA1\u6709\u95ED\u5408\uFF1B\u5E26\u7A7A\u683C\u7684\u8DEF\u5F84\u8BF7\u5199\u6210 "/opt/my tools/server" --stdio');
22252
+ if (started) parts.push(current);
22253
+ return parts;
22254
+ }
21183
22255
  function parseServer(value) {
21184
22256
  if (value === void 0) return void 0;
21185
- const parts = value.split(/\s+/).filter((part) => part !== "");
22257
+ const parts = splitCommandLine(value);
21186
22258
  if (parts.length === 0) return void 0;
21187
22259
  return parts.length === 1 ? { command: parts[0] } : { command: parts[0], args: parts.slice(1) };
21188
22260
  }
@@ -21204,7 +22276,7 @@ function remoteLspToolDefinitions(deps) {
21204
22276
  column: { type: "integer", description: "Column for `hover` / `definition`, 1-based. Required for those two actions." },
21205
22277
  server: {
21206
22278
  type: "string",
21207
- description: "Executable to run, with optional arguments (`gopls`, `rust-analyzer`, `pylsp -v`). Defaults to the table entry for the file language; required when the language has no entry."
22279
+ description: 'Executable to run, with optional arguments (`gopls`, `rust-analyzer`, `pylsp -v`). Defaults to the table entry for the file language; required when the language has no entry. Quote a path that contains spaces: `"/opt/my tools/server" --stdio`.'
21208
22280
  }
21209
22281
  },
21210
22282
  output: {
@@ -21272,19 +22344,16 @@ function remoteLspToolDefinitions(deps) {
21272
22344
  const requested = parsed.path;
21273
22345
  if (requested === void 0) throw new Error(`${parsed.action} \u9700\u8981 path`);
21274
22346
  const target = targetFor3(backend, exec);
22347
+ const sessionKey2 = exec.agent?.session.header.id;
22348
+ const lspTarget = sessionKey2 === void 0 || sessionKey2 === "" ? { connectionId: target.connectionId, root: target.root } : { connectionId: target.connectionId, root: target.root, sessionKey: sessionKey2 };
21275
22349
  const path = resolveOrThrow3(target, requested);
21276
22350
  const language = languageForPath(path);
21277
22351
  const override = parseServer(parsed.server);
21278
22352
  if (parsed.action === "stop") {
21279
- const stopped = await registry.stop({ connectionId: target.connectionId, root: target.root }, language, exec.signal);
22353
+ const stopped = await registry.stop(lspTarget, language, exec.signal);
21280
22354
  return { action: "stop", path, language, stopped };
21281
22355
  }
21282
- const session = await registry.session(
21283
- { connectionId: target.connectionId, root: target.root },
21284
- language,
21285
- override,
21286
- exec.signal
21287
- );
22356
+ const session = await registry.session(lspTarget, language, override, exec.signal);
21288
22357
  const uri = pathToUri(path);
21289
22358
  if (parsed.action === "diagnostics") {
21290
22359
  const document = await backend.read(target, path, exec.signal);
@@ -21463,6 +22532,10 @@ function wireSessionAdmin(env) {
21463
22532
  ctx.logger?.warn?.(
21464
22533
  `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5DF2\u8FC7\u671F\u88AB\u56DE\u6536\uFF1A\u6301\u6709\u8005 ${JSON.stringify(event.holder)}\uFF0C\u5DF2\u8FC7\u671F ${Math.round(event.ageMs / 1e3)} \u79D2`
21465
22534
  );
22535
+ },
22536
+ // 心跳失败只警告:锁仍有效,操作继续;网络盘 EIO/EPERM 需要留下痕迹。
22537
+ onHeartbeatError: (error) => {
22538
+ ctx.logger?.warn?.(`\u4F1A\u8BDD\u56DE\u6536\u7AD9\u7D22\u5F15\u9501\u5FC3\u8DF3\u5237\u65B0\u5931\u8D25\uFF08\u9501\u4ECD\u6301\u6709\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
21466
22539
  }
21467
22540
  });
21468
22541
  const sessionAdmin = createSessionAdminService({
@@ -22187,7 +23260,7 @@ async function relayCopy(deps, transport, request, source) {
22187
23260
 
22188
23261
  // src/wiring/tools-management.ts
22189
23262
  function wireManagementTools(env) {
22190
- const { ctx, limits, profiles, transports, registry, binService, toolBackend, sessionRoots, recordFor, transferService, transferTracker } = env;
23263
+ const { ctx, limits, profiles, transportLanes, registry, binService, toolBackend, sessionRoots, recordFor, transferService, transferTracker } = env;
22191
23264
  const rootRefFor = (target) => registry.records.find(
22192
23265
  (record2) => record2.root.location === "remote" && record2.root.connectionId === target.connectionId && record2.root.path === target.root
22193
23266
  )?.root;
@@ -22275,7 +23348,7 @@ function wireManagementTools(env) {
22275
23348
  return { kind: "undone", action: undone.action, id: String(undone.entry.id), message: undone.message };
22276
23349
  },
22277
23350
  copy: async ({ target, from, to, overwrite, recursive, signal }) => {
22278
- const acquire = async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport;
23351
+ const acquire = async (connectionId) => (await transportLanes.acquire(requireProfile(profiles, connectionId), "interactive")).transport;
22279
23352
  return copyRemoteFile(
22280
23353
  {
22281
23354
  transportFor: acquire,
@@ -22304,7 +23377,7 @@ function wireManagementTools(env) {
22304
23377
  );
22305
23378
  },
22306
23379
  probeEnv: async (connectionId, refresh, signal) => {
22307
- const transport = (await transports.acquire(requireProfile(profiles, connectionId))).transport;
23380
+ const transport = (await transportLanes.acquire(requireProfile(profiles, connectionId), "interactive")).transport;
22308
23381
  if (transport.probeEnv === void 0) return void 0;
22309
23382
  return transport.probeEnv(refresh, signal);
22310
23383
  }
@@ -22348,8 +23421,8 @@ function execWriteRootsSeam(env) {
22348
23421
  };
22349
23422
  }
22350
23423
  function wireTools(env) {
22351
- const { ctx, limits, profiles, transports, registry, indexFor, transferService, transferTracker, binService, notifySessionRemoved: notifySessionRemoved2 } = env;
22352
- const { sessionRoots, toolBackend, disposeTools } = wireAgentTools({ ctx, limits, profiles, transports, registry, indexFor });
23424
+ const { ctx, limits, profiles, transports, transportLanes, registry, indexFor, transferService, transferTracker, binService, notifySessionRemoved: notifySessionRemoved2 } = env;
23425
+ const { sessionRoots, toolBackend, disposeTools } = wireAgentTools({ ctx, limits, profiles, transports, transportLanes, registry, indexFor });
22353
23426
  const {
22354
23427
  recordFor,
22355
23428
  disposeJobTools,
@@ -22357,13 +23430,14 @@ function wireTools(env) {
22357
23430
  lspRegistry,
22358
23431
  disposeLspTools,
22359
23432
  assertWritable: assertWritable2
22360
- } = wireFileAndJobTools({ ctx, limits, profiles, transports, registry, sessionRoots });
23433
+ } = wireFileAndJobTools({ ctx, limits, profiles, transports, transportLanes, registry, sessionRoots });
22361
23434
  const sessionAdmin = wireSessionAdmin({ ctx, limits, notifySessionRemoved: notifySessionRemoved2 });
22362
23435
  const { dispose: disposeManagement } = wireManagementTools({
22363
23436
  ctx,
22364
23437
  limits,
22365
23438
  profiles,
22366
23439
  transports,
23440
+ transportLanes,
22367
23441
  registry,
22368
23442
  binService,
22369
23443
  toolBackend,
@@ -22395,7 +23469,7 @@ function wireTools(env) {
22395
23469
  };
22396
23470
  }
22397
23471
  function wireAgentTools(env) {
22398
- const { ctx, limits, profiles, transports, registry, indexFor } = env;
23472
+ const { ctx, limits, profiles, transports, transportLanes, registry, indexFor } = env;
22399
23473
  const sessionRoots = createSessionRootIndex({
22400
23474
  filePath: sessionRootsFilePath(),
22401
23475
  readFile: readFile10,
@@ -22420,7 +23494,7 @@ function wireAgentTools(env) {
22420
23494
  const covering = longestContainingRoot(candidates, normalizeRemotePath(cwd));
22421
23495
  return covering === void 0 ? void 0 : { connectionId, root: covering };
22422
23496
  },
22423
- acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
23497
+ acquire: async (connectionId, lane = "interactive") => (await transportLanes.acquire(requireProfile(profiles, connectionId), lane)).transport,
22424
23498
  enumerate: async (connectionId, root, signal) => {
22425
23499
  const result = await indexFor(connectionId).enumerate(root, signal === void 0 ? {} : { signal });
22426
23500
  return { paths: result.paths, truncated: result.truncated, source: result.source };
@@ -22447,7 +23521,7 @@ function wireAgentTools(env) {
22447
23521
  return { sessionRoots, toolBackend, disposeTools };
22448
23522
  }
22449
23523
  function wireFileAndJobTools(env) {
22450
- const { ctx, limits, profiles, transports, registry, sessionRoots } = env;
23524
+ const { ctx, limits, profiles, transportLanes, registry, sessionRoots } = env;
22451
23525
  const remoteRootFor = (cwd) => {
22452
23526
  const resolved = registry.routed.tryResolve(cwd);
22453
23527
  if (!resolved.ok) return void 0;
@@ -22465,7 +23539,10 @@ function wireFileAndJobTools(env) {
22465
23539
  // ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
22466
23540
  // two conversations opened on the same directory.
22467
23541
  resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
22468
- acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport
23542
+ acquire: async (connectionId, sessionKey2) => {
23543
+ const lane = sessionKey2 === void 0 || sessionKey2 === "" ? "interactive" : "stateful";
23544
+ return (await transportLanes.acquire(requireProfile(profiles, connectionId), lane, void 0, sessionKey2)).transport;
23545
+ }
22469
23546
  });
22470
23547
  const disposeJobTools = registerRemoteJobTools(ctx, jobBackend);
22471
23548
  const fileBackend = createRemoteFileBackend({
@@ -22507,7 +23584,10 @@ function wireFileAndJobTools(env) {
22507
23584
  }
22508
23585
  };
22509
23586
  const lspRegistry = createLspRegistry({
22510
- acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
23587
+ acquire: async (connectionId, sessionKey2) => {
23588
+ const lane = sessionKey2 === void 0 || sessionKey2 === "" ? "interactive" : "stateful";
23589
+ return (await transportLanes.acquire(requireProfile(profiles, connectionId), lane, void 0, sessionKey2)).transport;
23590
+ },
22511
23591
  onLog: (connectionId, message) => {
22512
23592
  ctx.logger?.info?.(`over-ssh: [${connectionId}] \u8BED\u8A00\u670D\u52A1\u5668\uFF1A${message}`);
22513
23593
  }