dsh-over-ssh 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -420,7 +420,7 @@ function createProxyForwarder(options) {
420
420
  }
421
421
  const idleMs = now() - current.lastActivityAt;
422
422
  if (idleMs < idleTimeoutMs) return;
423
- void closeEntry(current).then(() => options.onReclaimed?.(handle, idleMs));
423
+ void closeEntry(current).then(() => options.onReclaimed?.(handle, idleMs)).catch(() => void 0);
424
424
  }, checkMs);
425
425
  entry.timer.unref?.();
426
426
  }
@@ -1267,6 +1267,13 @@ ${buildRemoteScript(request.script)}`;
1267
1267
  ...request.env === void 0 ? {} : { env: request.env }
1268
1268
  });
1269
1269
  }
1270
+ /** Mark the transport lost **only if the dead session is still the current one** (a reconnect may have won). */
1271
+ #markSessionLost(session) {
1272
+ if (this.#session !== session) return;
1273
+ this.#sftp = void 0;
1274
+ this.#sftpPending = void 0;
1275
+ this.markState("lost");
1276
+ }
1270
1277
  #requireSession() {
1271
1278
  if (this.#session === void 0) {
1272
1279
  throw new SshDevError("CONNECTION_LOST", "SSH \u4F1A\u8BDD\u5C1A\u672A\u5EFA\u7ACB", { connectionId: this.id });
@@ -1324,8 +1331,13 @@ ${buildRemoteScript(request.script)}`;
1324
1331
  }
1325
1332
  const connector = this.#options.connector ?? defaultConnector;
1326
1333
  try {
1327
- this.#session = await connector(signal === void 0 ? connect2 : { ...connect2, signal });
1334
+ const session = await connector(signal === void 0 ? connect2 : { ...connect2, signal });
1335
+ this.#session = session;
1328
1336
  this.#sessionCount++;
1337
+ const onLost = () => this.#markSessionLost(session);
1338
+ session.on?.("close", onLost);
1339
+ session.on?.("error", onLost);
1340
+ session.on?.("end", onLost);
1329
1341
  this.markState("ready");
1330
1342
  } catch (error) {
1331
1343
  this.markState("lost");
@@ -1651,6 +1663,35 @@ var TransportService = class {
1651
1663
 
1652
1664
  // src/host/integration/session-scope.ts
1653
1665
  import { AsyncLocalStorage } from "node:async_hooks";
1666
+
1667
+ // src/host/integration/session-fork-pending.ts
1668
+ var pending = /* @__PURE__ */ new Map();
1669
+ var MAX_PENDING = 256;
1670
+ function markForkInheritancePending(childSessionId, sourceSessionId) {
1671
+ const child = childSessionId.trim();
1672
+ const source = sourceSessionId.trim();
1673
+ if (child === "" || source === "" || child === source) return;
1674
+ pending.delete(child);
1675
+ pending.set(child, source);
1676
+ while (pending.size > MAX_PENDING) {
1677
+ const oldest = pending.keys().next();
1678
+ if (oldest.done === true) break;
1679
+ pending.delete(oldest.value);
1680
+ }
1681
+ }
1682
+ function pendingForkSources(sessionId) {
1683
+ if (sessionId === void 0 || sessionId === "") return [];
1684
+ const source = pending.get(sessionId);
1685
+ return source === void 0 ? [] : [source];
1686
+ }
1687
+ function clearForkInheritancePending(childSessionId) {
1688
+ pending.delete(childSessionId);
1689
+ }
1690
+ function clearAllForkInheritancePending() {
1691
+ pending.clear();
1692
+ }
1693
+
1694
+ // src/host/integration/session-scope.ts
1654
1695
  var STORE_KEY = /* @__PURE__ */ Symbol.for("dsh-over-ssh/session-scope");
1655
1696
  function store() {
1656
1697
  const holder = globalThis;
@@ -1670,6 +1711,15 @@ var MAX_FORK_DEPTH = 32;
1670
1711
  var ancestorCache = /* @__PURE__ */ new Map();
1671
1712
  var ANCESTOR_CACHE_MAX = 256;
1672
1713
  function readSessionAncestors(ctx, sessionId) {
1714
+ const pending2 = pendingForkSources(sessionId);
1715
+ if (pending2.length > 0) {
1716
+ const inherited = /* @__PURE__ */ new Set();
1717
+ for (const source of pending2) {
1718
+ inherited.add(source);
1719
+ for (const ancestor of walkAncestors(ctx, source).ancestors) inherited.add(ancestor);
1720
+ }
1721
+ return [...inherited];
1722
+ }
1673
1723
  const cached2 = ancestorCache.get(sessionId);
1674
1724
  if (cached2 !== void 0) {
1675
1725
  ancestorCache.delete(sessionId);
@@ -2193,6 +2243,7 @@ var MAX_OUTPUT_BYTES = 64 * 1024;
2193
2243
  var DEFAULT_EXEC_TIMEOUT_MS = 3e4;
2194
2244
  var MAX_EXEC_TIMEOUT_MS = 3e5;
2195
2245
  var MAX_EXEC_ENV_ENTRIES = 64;
2246
+ var MAX_EXEC_BATCH_CONNECTIONS = 8;
2196
2247
  var ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2197
2248
  function stringArg(value) {
2198
2249
  if (typeof value !== "string") return void 0;
@@ -3016,16 +3067,16 @@ function createTransferService(dependencies) {
3016
3067
  };
3017
3068
  const partialBytesOnRemote = async (absolute, context, signal) => {
3018
3069
  try {
3019
- const stat5 = await dependencies.statRemote(absolute);
3020
- return stat5.exists && stat5.isFile ? stat5.size : void 0;
3070
+ const stat7 = await dependencies.statRemote(absolute);
3071
+ return stat7.exists && stat7.isFile ? stat7.size : void 0;
3021
3072
  } catch (error) {
3022
3073
  throw failureFrom(error, "remote-stat-failed", "\u8BFB\u53D6\u8FDC\u7AEF\u76EE\u6807\u4FE1\u606F\u5931\u8D25\uFF08\u7EED\u4F20\u524D\u9700\u8981\u786E\u8BA4\u5DF2\u6709\u957F\u5EA6\uFF09", { ...context }, signal);
3023
3074
  }
3024
3075
  };
3025
3076
  const partialBytesOnLocal = async (path, context, signal) => {
3026
3077
  try {
3027
- const stat5 = await dependencies.statLocal(path);
3028
- return stat5.exists && stat5.isFile ? stat5.size : void 0;
3078
+ const stat7 = await dependencies.statLocal(path);
3079
+ return stat7.exists && stat7.isFile ? stat7.size : void 0;
3029
3080
  } catch (error) {
3030
3081
  throw failureFrom(error, "local-stat-failed", "\u8BFB\u53D6\u672C\u5730\u76EE\u6807\u4FE1\u606F\u5931\u8D25\uFF08\u7EED\u4F20\u524D\u9700\u8981\u786E\u8BA4\u5DF2\u6709\u957F\u5EA6\uFF09", { ...context }, signal);
3031
3082
  }
@@ -3126,7 +3177,13 @@ function createTransferService(dependencies) {
3126
3177
  ...request.signal === void 0 ? {} : { signal: request.signal }
3127
3178
  })
3128
3179
  });
3129
- const digest = await lookupDigest(dependencies, localPath);
3180
+ const degradations = [...decision.degradations];
3181
+ let digest = { sha256: void 0, degradation: void 0 };
3182
+ const needsLocalDigest = move.writerSha256 !== void 0 || decision.resumed;
3183
+ if (needsLocalDigest) {
3184
+ digest = await lookupDigest(dependencies, localPath);
3185
+ if (digest.degradation !== void 0) degradations.push(digest.degradation);
3186
+ }
3130
3187
  if (move.writerSha256 !== void 0 && digest.sha256 !== void 0 && !digestsMatch(move.writerSha256, digest.sha256)) {
3131
3188
  throw transferFailure(
3132
3189
  "verify-failed",
@@ -3134,12 +3191,15 @@ function createTransferService(dependencies) {
3134
3191
  { ...context, bytesTransferred: move.bytesTransferred, chunks: move.chunks, localSha256: digest.sha256, remoteSha256: move.writerSha256 }
3135
3192
  );
3136
3193
  }
3137
- const degradations = [...decision.degradations, ...digest.degradation === void 0 ? [] : [digest.degradation]];
3138
3194
  let landed = move.writerSha256;
3139
3195
  if (decision.resumed && landed === void 0) {
3140
3196
  const remote = await lookupRemoteDigest(dependencies, target.absolute, request.signal);
3141
3197
  if (remote.degradation !== void 0) degradations.push(remote.degradation);
3142
3198
  if (remote.sha256 !== void 0) {
3199
+ if (digest.sha256 === void 0) {
3200
+ digest = await lookupDigest(dependencies, localPath);
3201
+ if (digest.degradation !== void 0) degradations.push(digest.degradation);
3202
+ }
3143
3203
  if (digest.sha256 !== void 0 && !digestsMatch(remote.sha256, digest.sha256)) {
3144
3204
  throw transferFailure(
3145
3205
  "verify-failed",
@@ -4182,7 +4242,7 @@ function planDelete(records, request) {
4182
4242
  refusals.push({ id, reason: "unreadable" });
4183
4243
  continue;
4184
4244
  }
4185
- if (record2.classification === "subsession") {
4245
+ if (record2.classification === "subsession" && !isOrphanSubsession(record2, byId)) {
4186
4246
  deferred.push(id);
4187
4247
  continue;
4188
4248
  }
@@ -4203,6 +4263,11 @@ function planDelete(records, request) {
4203
4263
  for (const id of ordered) bytes += byId.get(id)?.bytes ?? 0;
4204
4264
  return { delete: ordered, refusals, bytes };
4205
4265
  }
4266
+ function isOrphanSubsession(record2, byId) {
4267
+ if (record2.parentSessionId === void 0) return true;
4268
+ const parent = byId.get(record2.parentSessionId);
4269
+ return parent === void 0 || parent.classification === "unreadable";
4270
+ }
4206
4271
  function descendantsOf(records, rootId) {
4207
4272
  const children = /* @__PURE__ */ new Map();
4208
4273
  for (const record2 of records) {
@@ -4391,6 +4456,11 @@ function createSerialQueue() {
4391
4456
  };
4392
4457
  }
4393
4458
 
4459
+ // src/shared/path-fold.ts
4460
+ function foldPathKey(path) {
4461
+ return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
4462
+ }
4463
+
4394
4464
  // src/host/app/session-admin/session-admin-context.ts
4395
4465
  function isMissing(error) {
4396
4466
  const code = error?.code;
@@ -4406,18 +4476,18 @@ function createSessionAdminContext(deps) {
4406
4476
  reason: "bin-inside-sessions-root"
4407
4477
  });
4408
4478
  }
4409
- const indexePath = () => joinPath(binRoot, SESSION_TRASH_INDEX_FILE);
4479
+ const indexPath = () => joinPath(binRoot, SESSION_TRASH_INDEX_FILE);
4410
4480
  const queue = createSerialQueue();
4411
4481
  const serialized = (operation) => (
4412
4482
  // Order matters: take the in-process slot **first** (the queue), then the cross-process lock. Holding a file
4413
4483
  // lock while queued behind another in-process call would serialize on the wrong resource.
4414
- queue.run(() => deps.withLock === void 0 ? operation() : deps.withLock(indexePath(), operation))
4484
+ queue.run(() => deps.withLock === void 0 ? operation() : deps.withLock(indexPath(), operation))
4415
4485
  );
4416
4486
  const entryDir = (segment) => joinPath(binRoot, "entries", segment);
4417
4487
  const readIndex = async () => {
4418
4488
  let text8;
4419
4489
  try {
4420
- text8 = await deps.readText(indexePath());
4490
+ text8 = await deps.readText(indexPath());
4421
4491
  } catch (error) {
4422
4492
  if (isMissing(error)) return [];
4423
4493
  throw error;
@@ -4427,77 +4497,105 @@ function createSessionAdminContext(deps) {
4427
4497
  const writeIndex = async (entries) => {
4428
4498
  const text8 = serializeSessionTrashIndex(entries);
4429
4499
  if (deps.writeTextAtomic !== void 0) {
4430
- await deps.writeTextAtomic(indexePath(), text8);
4500
+ await deps.writeTextAtomic(indexPath(), text8);
4431
4501
  return;
4432
4502
  }
4433
- await deps.writeText(indexePath(), text8);
4503
+ await deps.writeText(indexPath(), text8);
4504
+ };
4505
+ const INVENTORY_CONCURRENCY = 8;
4506
+ const mapWithLimit = async (items, limit, task) => {
4507
+ const results = new Array(items.length);
4508
+ let next = 0;
4509
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
4510
+ for (; ; ) {
4511
+ const index = next;
4512
+ next += 1;
4513
+ if (index >= items.length) return;
4514
+ results[index] = await task(items[index], index);
4515
+ }
4516
+ });
4517
+ await Promise.all(workers);
4518
+ return results;
4519
+ };
4520
+ const throwIfAborted = (signal) => {
4521
+ if (signal?.aborted === true) {
4522
+ throw new SshDevError("CANCELLED", "\u64CD\u4F5C\u5DF2\u53D6\u6D88", { reason: "aborted" });
4523
+ }
4434
4524
  };
4435
4525
  const inventory = async (options = {}) => {
4436
- void options;
4526
+ const signal = options.signal;
4527
+ throwIfAborted(signal);
4437
4528
  const projects = await deps.listDir(sessionsRoot);
4438
- const records = [];
4439
- for (const project of projects) {
4440
- if (!project.isDirectory) continue;
4441
- const projectDir = joinPath(sessionsRoot, project.name);
4529
+ const projectNames = projects.filter((project) => project.isDirectory).map((project) => project.name);
4530
+ const perProject = await mapWithLimit(projectNames, INVENTORY_CONCURRENCY, async (project) => {
4531
+ const projectDir = joinPath(sessionsRoot, project);
4442
4532
  const sessions = await deps.listDir(projectDir);
4443
- for (const session of sessions) {
4444
- if (!session.isDirectory) continue;
4445
- const dir = joinPath(projectDir, session.name);
4446
- const contents = await deps.listDir(dir);
4447
- let bytes = 0;
4448
- let updatedAtMs = session.mtimeMs;
4449
- for (const item of contents) {
4450
- if (item.isDirectory) continue;
4451
- bytes += item.bytes;
4452
- if (item.mtimeMs > updatedAtMs) updatedAtMs = item.mtimeMs;
4453
- }
4454
- const header = await deps.readHeader(dir);
4455
- if (header === void 0) {
4456
- records.push({
4457
- id: session.name,
4458
- projectDir: project.name,
4459
- segment: session.name,
4460
- cwd: void 0,
4461
- title: void 0,
4462
- workspaceTitle: void 0,
4463
- parentSessionId: void 0,
4464
- bytes,
4465
- updatedAtMs,
4466
- live: false,
4467
- running: false,
4468
- classification: "unreadable"
4469
- });
4470
- continue;
4471
- }
4472
- const title = header.title ?? deps.resolveSessionTitle?.(header);
4473
- records.push({
4474
- id: header.id,
4475
- projectDir: project.name,
4533
+ return sessions.filter((session) => session.isDirectory).map((session) => ({ project, session }));
4534
+ });
4535
+ throwIfAborted(signal);
4536
+ const entries = perProject.flat();
4537
+ const workspaceTitles = /* @__PURE__ */ new Map();
4538
+ const workspaceTitleOf = (cwd) => {
4539
+ if (cwd === void 0) return void 0;
4540
+ if (!workspaceTitles.has(cwd)) workspaceTitles.set(cwd, deps.resolveWorkspaceTitle?.(cwd));
4541
+ return workspaceTitles.get(cwd);
4542
+ };
4543
+ return mapWithLimit(entries, INVENTORY_CONCURRENCY, async ({ project, session }) => {
4544
+ throwIfAborted(signal);
4545
+ const dir = joinPath(joinPath(sessionsRoot, project), session.name);
4546
+ const contents = await deps.listDir(dir);
4547
+ let bytes = 0;
4548
+ let updatedAtMs = Math.trunc(session.mtimeMs);
4549
+ for (const item of contents) {
4550
+ if (item.isDirectory) continue;
4551
+ bytes += item.bytes;
4552
+ const itemMs = Math.trunc(item.mtimeMs);
4553
+ if (itemMs > updatedAtMs) updatedAtMs = itemMs;
4554
+ }
4555
+ const header = await deps.readHeader(dir, contents);
4556
+ if (header === void 0) {
4557
+ return {
4558
+ id: session.name,
4559
+ projectDir: project,
4476
4560
  segment: session.name,
4477
- cwd: header.cwd,
4478
- title,
4479
- workspaceTitle: deps.resolveWorkspaceTitle?.(header.cwd),
4480
- parentSessionId: header.parentSessionId,
4561
+ cwd: void 0,
4562
+ title: void 0,
4563
+ workspaceTitle: void 0,
4564
+ parentSessionId: void 0,
4481
4565
  bytes,
4482
4566
  updatedAtMs,
4483
- live: deps.isLive(header.id),
4484
- running: deps.isRunning(header.id),
4485
- classification: header.parentSessionId === void 0 ? "normal" : "subsession"
4486
- });
4567
+ live: false,
4568
+ running: false,
4569
+ classification: "unreadable"
4570
+ };
4487
4571
  }
4488
- }
4489
- return records;
4572
+ const title = header.title ?? deps.resolveSessionTitle?.(header);
4573
+ return {
4574
+ id: header.id,
4575
+ projectDir: project,
4576
+ segment: session.name,
4577
+ cwd: header.cwd,
4578
+ title,
4579
+ workspaceTitle: workspaceTitleOf(header.cwd),
4580
+ parentSessionId: header.parentSessionId,
4581
+ bytes,
4582
+ updatedAtMs,
4583
+ live: deps.isLive(header.id),
4584
+ running: deps.isRunning(header.id),
4585
+ classification: header.parentSessionId === void 0 ? "normal" : "subsession"
4586
+ };
4587
+ });
4490
4588
  };
4491
- return { deps, sessionsRoot, binRoot, indexePath, serialized, entryDir, readIndex, writeIndex, inventory };
4589
+ return { deps, sessionsRoot, binRoot, indexPath, serialized, entryDir, readIndex, writeIndex, inventory, throwIfAborted };
4492
4590
  }
4493
4591
  function normalizePathKey(path) {
4494
- return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
4592
+ return foldPathKey(path);
4495
4593
  }
4496
4594
 
4497
4595
  // src/host/app/session-admin/session-admin-delete.ts
4498
4596
  init_errors();
4499
4597
  function createSessionAdminDelete(ctx) {
4500
- const { deps, sessionsRoot, serialized, entryDir, readIndex, writeIndex, inventory } = ctx;
4598
+ const { deps, sessionsRoot, serialized, entryDir, readIndex, writeIndex, inventory, throwIfAborted } = ctx;
4501
4599
  const previewDelete = async (ids, options = {}) => {
4502
4600
  const records = await inventory(options);
4503
4601
  return planDelete(records, {
@@ -4505,10 +4603,11 @@ function createSessionAdminDelete(ctx) {
4505
4603
  ...options.keep === void 0 ? {} : { keep: options.keep }
4506
4604
  });
4507
4605
  };
4508
- const deletePermanently = async (decision, byId) => {
4606
+ const deletePermanently = async (decision, byId, options = {}) => {
4509
4607
  let bytes = 0;
4510
4608
  const deleted = [];
4511
4609
  for (const id of decision.delete) {
4610
+ throwIfAborted(options.signal);
4512
4611
  const record2 = byId.get(id);
4513
4612
  if (record2 === void 0) continue;
4514
4613
  if (deps.isRunning(id)) {
@@ -4523,7 +4622,7 @@ function createSessionAdminDelete(ctx) {
4523
4622
  }
4524
4623
  return { mode: "permanent", entry: void 0, recoverable: false, verified: true, copied: false, refusals: decision.refusals, bytes };
4525
4624
  };
4526
- const moveIntoBin = async (decision, byId) => {
4625
+ const moveIntoBin = async (decision, byId, options = {}) => {
4527
4626
  let bytes = 0;
4528
4627
  let entry;
4529
4628
  let verified = false;
@@ -4544,6 +4643,7 @@ function createSessionAdminDelete(ctx) {
4544
4643
  };
4545
4644
  try {
4546
4645
  for (const id of decision.delete) {
4646
+ throwIfAborted(options.signal);
4547
4647
  const record2 = byId.get(id);
4548
4648
  if (record2 === void 0) continue;
4549
4649
  if (deps.isRunning(id)) {
@@ -4631,7 +4731,7 @@ function createSessionAdminDelete(ctx) {
4631
4731
  ...options.keep === void 0 ? {} : { keep: options.keep }
4632
4732
  });
4633
4733
  const byId = new Map(records.map((record2) => [record2.id, record2]));
4634
- return mode === "permanent" ? deletePermanently(decision, byId) : moveIntoBin(decision, byId);
4734
+ return mode === "permanent" ? deletePermanently(decision, byId, options) : moveIntoBin(decision, byId, options);
4635
4735
  });
4636
4736
  const purgeEntry = async (entryId) => {
4637
4737
  const entries = await readIndex();
@@ -4755,7 +4855,6 @@ function createSessionAdminService(deps) {
4755
4855
  const ordered = [...entries].sort((left, right) => left.deletedAtMs - right.deletedAtMs);
4756
4856
  const doomed = [];
4757
4857
  const nowMs = deps.now();
4758
- let keptBytes = ordered.reduce((sum, item) => sum + item.bytes, 0);
4759
4858
  if (policy.maxAgeMs > 0) {
4760
4859
  for (const entry of ordered) {
4761
4860
  if (nowMs - entry.deletedAtMs > policy.maxAgeMs) doomed.push(entry);
@@ -4764,6 +4863,7 @@ function createSessionAdminService(deps) {
4764
4863
  const doomedIds = new Set(doomed.map((entry) => entry.id));
4765
4864
  const survivors = ordered.filter((entry) => !doomedIds.has(entry.id));
4766
4865
  if (policy.maxBytes > 0) {
4866
+ let keptBytes = survivors.reduce((sum, item) => sum + item.bytes, 0);
4767
4867
  for (const entry of survivors) {
4768
4868
  if (keptBytes <= policy.maxBytes) break;
4769
4869
  doomed.push(entry);
@@ -4776,7 +4876,7 @@ function createSessionAdminService(deps) {
4776
4876
  const excess = stillKept.length - policy.maxEntries;
4777
4877
  for (const entry of stillKept.slice(0, excess)) {
4778
4878
  doomed.push(entry);
4779
- keptBytes -= entry.bytes;
4879
+ doomedIds.add(entry.id);
4780
4880
  }
4781
4881
  }
4782
4882
  const outcome = await purgeEntries(doomed, entries);
@@ -4792,11 +4892,54 @@ function createSessionAdminService(deps) {
4792
4892
  }
4793
4893
 
4794
4894
  // src/host/integration/session/session-header.ts
4795
- import { open, readdir } from "node:fs/promises";
4895
+ import { open, readdir, stat } from "node:fs/promises";
4796
4896
  import { join } from "node:path";
4797
4897
  import { createZstdDecompress } from "node:zlib";
4798
4898
  var SESSION_HEADER_READ_BYTES = 64 * 1024;
4799
4899
  var SESSION_HEADER_RETRY_BYTES = 1024 * 1024;
4900
+ function createCachedSessionHeaderReader(io, options = {}) {
4901
+ const inner = createSessionHeaderReader(io);
4902
+ const stat7 = io.stat;
4903
+ if (stat7 === void 0) return inner;
4904
+ const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? SESSION_HEADER_CACHE_MAX));
4905
+ const cache = /* @__PURE__ */ new Map();
4906
+ return async (dir, listing) => {
4907
+ let signature;
4908
+ if (listing !== void 0) {
4909
+ const log = listing.find((entry) => entry.name.startsWith("session.v") && entry.name.endsWith(".zstd"));
4910
+ if (log !== void 0) signature = `${log.size}:${Math.trunc(log.mtimeMs)}`;
4911
+ } else {
4912
+ try {
4913
+ const names = await io.listDir(dir);
4914
+ const log = names.find((name2) => name2.startsWith("session.v") && name2.endsWith(".zstd"));
4915
+ if (log !== void 0) {
4916
+ const info = await stat7(join(dir, log));
4917
+ signature = `${info.size}:${Math.trunc(info.mtimeMs)}`;
4918
+ }
4919
+ } catch {
4920
+ }
4921
+ }
4922
+ if (signature !== void 0) {
4923
+ const hit = cache.get(dir);
4924
+ if (hit !== void 0 && hit.signature === signature) {
4925
+ cache.delete(dir);
4926
+ cache.set(dir, hit);
4927
+ return hit.header;
4928
+ }
4929
+ }
4930
+ const header = await inner(dir);
4931
+ if (signature !== void 0) {
4932
+ cache.set(dir, { signature, header });
4933
+ while (cache.size > maxEntries) {
4934
+ const oldest = cache.keys().next();
4935
+ if (oldest.done === true) break;
4936
+ cache.delete(oldest.value);
4937
+ }
4938
+ }
4939
+ return header;
4940
+ };
4941
+ }
4942
+ var SESSION_HEADER_CACHE_MAX = 2048;
4800
4943
  function parseSessionHeaderLine(text8) {
4801
4944
  const firstLine = text8.split("\n")[0];
4802
4945
  if (firstLine === void 0 || firstLine === "") return void 0;
@@ -4882,11 +5025,16 @@ function createDiskSessionHeaderIo() {
4882
5025
  return {
4883
5026
  listDir: (dir) => readdir(dir),
4884
5027
  readHead: readHeadFromDisk,
4885
- decompress: decompressPrefix
5028
+ decompress: decompressPrefix,
5029
+ // The cache's only input: `(size, mtime)` is what a session log's append changes.
5030
+ stat: async (path) => {
5031
+ const info = await stat(path);
5032
+ return { size: info.size, mtimeMs: info.mtimeMs };
5033
+ }
4886
5034
  };
4887
5035
  }
4888
5036
  function createDiskSessionHeaderReader() {
4889
- return createSessionHeaderReader(createDiskSessionHeaderIo());
5037
+ return createCachedSessionHeaderReader(createDiskSessionHeaderIo());
4890
5038
  }
4891
5039
 
4892
5040
  // src/host/app/routed-fs.ts
@@ -5772,17 +5920,25 @@ var RootRegistry = class {
5772
5920
  * sessions at all: an empty list means "the session service was not readable", not "no session is live", and
5773
5921
  * dropping every grant on a transient read failure would break live turns.
5774
5922
  *
5775
- * @param cwds - working directories of the live sessions.
5923
+ * @param cwds - working directories of the live sessions, optionally with the session id that owns each one.
5924
+ * With an id the root carries `sessions: [id]` and is writable **only by that session**; without one it is a
5925
+ * workspace-scoped root (the legacy shape tests and agentless compositions use).
5776
5926
  * @returns the session roots this call added (empty when every cwd was already covered).
5777
5927
  */
5778
5928
  syncSessionWorkspaces(cwds) {
5779
5929
  if (cwds.length === 0) return [];
5780
5930
  const live = /* @__PURE__ */ new Set();
5781
5931
  const added = [];
5782
- for (const cwd of cwds) {
5932
+ for (const item of cwds) {
5933
+ const cwd = typeof item === "string" ? item : item.cwd;
5934
+ const sessionId = typeof item === "string" ? void 0 : item.sessionId;
5783
5935
  if (typeof cwd !== "string" || cwd === "") continue;
5784
5936
  if (!isAbsoluteLocalPath(normalizeLocalPath2(cwd, { caseInsensitive: false }))) continue;
5785
- const root = makeLocalRoot(cwd, { workspaces: [cwd], writable: true });
5937
+ const root = makeLocalRoot(cwd, {
5938
+ workspaces: [cwd],
5939
+ ...sessionId === void 0 || sessionId === "" ? {} : { sessions: [sessionId] },
5940
+ writable: true
5941
+ });
5786
5942
  const key = rootKey(root);
5787
5943
  live.add(key);
5788
5944
  if (this.#localRootCovers(cwd)) continue;
@@ -5959,7 +6115,7 @@ function carryMissingState(legacy, current, files, dirs) {
5959
6115
  renameSync(temp, to);
5960
6116
  } catch (error) {
5961
6117
  rmSync(temp, { force: true });
5962
- failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6118
+ if (!existsSync(to)) failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
5963
6119
  }
5964
6120
  }
5965
6121
  for (const name2 of dirs) {
@@ -5972,7 +6128,7 @@ function carryMissingState(legacy, current, files, dirs) {
5972
6128
  renameSync(temp, to);
5973
6129
  } catch (error) {
5974
6130
  rmSync(temp, { recursive: true, force: true });
5975
- failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
6131
+ if (!existsSync(to)) failures.push(`${from} \u2192 ${to}\uFF1A${describe(error)}`);
5976
6132
  }
5977
6133
  }
5978
6134
  return failures;
@@ -6398,9 +6554,9 @@ var AgentFrameParser = class {
6398
6554
  */
6399
6555
  #append(chunk) {
6400
6556
  if (chunk.byteLength === 0) return;
6401
- const pending = this.#end - this.#start;
6402
- if (this.#store.byteLength - this.#start < pending + chunk.byteLength) {
6403
- const needed = pending + chunk.byteLength;
6557
+ const pending2 = this.#end - this.#start;
6558
+ if (this.#store.byteLength - this.#start < pending2 + chunk.byteLength) {
6559
+ const needed = pending2 + chunk.byteLength;
6404
6560
  if (this.#store.byteLength >= needed) {
6405
6561
  this.#store.copyWithin(0, this.#start, this.#end);
6406
6562
  } else {
@@ -6409,7 +6565,7 @@ var AgentFrameParser = class {
6409
6565
  this.#store = grown;
6410
6566
  }
6411
6567
  this.#start = 0;
6412
- this.#end = pending;
6568
+ this.#end = pending2;
6413
6569
  }
6414
6570
  this.#store.set(chunk, this.#end);
6415
6571
  this.#end += chunk.byteLength;
@@ -7194,9 +7350,9 @@ var AgentProtocolClient = class {
7194
7350
  failConnection(error) {
7195
7351
  if (this.#lost !== void 0) return;
7196
7352
  this.#lost = error;
7197
- const pending = [...this.#pending.values()];
7353
+ const pending2 = [...this.#pending.values()];
7198
7354
  this.#pending.clear();
7199
- for (const entry of pending) entry.settle({ error });
7355
+ for (const entry of pending2) entry.settle({ error });
7200
7356
  for (const channel of this.#blobQueue) channel.fail(error);
7201
7357
  this.#blobQueue.length = 0;
7202
7358
  this.#blobById.clear();
@@ -8081,14 +8237,14 @@ var AgentTransport = class extends TransportBase {
8081
8237
  }
8082
8238
  }
8083
8239
  if (expectedVersion === void 0) {
8084
- let stat5;
8240
+ let stat7;
8085
8241
  try {
8086
- stat5 = asRecord(await client.request("fs.stat", { path: request.path }, this.#requestOptions(void 0, signal)));
8242
+ stat7 = asRecord(await client.request("fs.stat", { path: request.path }, this.#requestOptions(void 0, signal)));
8087
8243
  } catch (error) {
8088
8244
  throw this.#translate(error);
8089
8245
  }
8090
- const exists = stat5?.["exists"] === true;
8091
- const observed = typeof stat5?.["version"] === "string" ? stat5["version"] : void 0;
8246
+ const exists = stat7?.["exists"] === true;
8247
+ const observed = typeof stat7?.["version"] === "string" ? stat7["version"] : void 0;
8092
8248
  if (!exists || observed === void 0 || observed !== expected) {
8093
8249
  throw new SshDevError("TRANSFER_CONFLICT", "\u8FDC\u7AEF\u6587\u4EF6\u7248\u672C\u4E0E\u8C03\u7528\u65B9\u7ED9\u51FA\u7684\u5B88\u536B\u4E0D\u4E00\u81F4\uFF0C\u5199\u5165\u88AB\u62D2\u7EDD", {
8094
8250
  connectionId: this.id,
@@ -9346,7 +9502,7 @@ function localVersion(info) {
9346
9502
  init_temp_name();
9347
9503
  init_errors();
9348
9504
  import { constants } from "node:fs";
9349
- import { access, chmod, lstat, mkdir, open as open2, readdir as readdir2, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
9505
+ import { access, chmod, lstat, mkdir, open as open2, readdir as readdir2, rename, rm, stat as stat2, unlink, writeFile } from "node:fs/promises";
9350
9506
  import { dirname as dirname3, join as join5 } from "node:path";
9351
9507
  var LIST_STAT_CONCURRENCY = 32;
9352
9508
  var LocalBackend = class {
@@ -9359,12 +9515,12 @@ var LocalBackend = class {
9359
9515
  this.id = options.id;
9360
9516
  this.sandboxMode = options.sandboxMode ?? "workspace-write";
9361
9517
  this.#maxReadBytes = options.maxReadBytes ?? 16 * 1024 * 1024;
9362
- this.#statEntry = options.statEntry ?? ((path) => stat(path));
9518
+ this.#statEntry = options.statEntry ?? ((path) => stat2(path));
9363
9519
  }
9364
9520
  async stat(absolute, signal) {
9365
9521
  signal?.throwIfAborted();
9366
9522
  try {
9367
- const info = await stat(absolute);
9523
+ const info = await stat2(absolute);
9368
9524
  return {
9369
9525
  exists: true,
9370
9526
  isDirectory: info.isDirectory(),
@@ -10021,7 +10177,9 @@ function buildListScript(absolute) {
10021
10177
  `if [ ! -d "$p" ]; then printf 'DSH_ERROR=not-a-dir\\n'; exit 4; fi`,
10022
10178
  // GNU find (Linux). `-printf ''` is the cheap capability probe: it fails on BSD/macOS find.
10023
10179
  `if find "$p" -maxdepth 1 -printf '' >/dev/null 2>&1; then`,
10024
- ` find "$p" -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\n'`,
10180
+ // **NUL 分隔**:文件名可以含换行(POSIX 合法,`git checkout` 就能造出来),按行切会把一个条目拆成
10181
+ // 两行、解析出一个不存在的名字并丢掉真条目(审计 2026-11-06 第 3 轮 P1)。
10182
+ ` find "$p" -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\0'`,
10025
10183
  `else`,
10026
10184
  ` for entry in "$p"/* "$p"/.[!.]*; do`,
10027
10185
  ` [ -e "$entry" ] || continue`,
@@ -10029,28 +10187,28 @@ function buildListScript(absolute) {
10029
10187
  ` if [ -d "$entry" ]; then kind=d; elif [ -L "$entry" ]; then kind=l; else kind=f; fi`,
10030
10188
  ` size=$(wc -c < "$entry" 2>/dev/null | tr -d ' ' || echo 0)`,
10031
10189
  ` mtime=$(stat -c '%Y' "$entry" 2>/dev/null || stat -f '%m' "$entry" 2>/dev/null || echo 0)`,
10032
- ` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$kind" "$kind" "$size" "$mtime" "$name"`,
10190
+ ` printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$kind" "$kind" "$size" "$mtime" "$name"`,
10033
10191
  ` done`,
10034
10192
  `fi`
10035
10193
  ].join("\n");
10036
10194
  }
10037
10195
  function parseListing(stdout) {
10038
10196
  const entries = [];
10039
- for (const line of stdout.split("\n")) {
10040
- if (line === "" || line.startsWith("DSH_")) continue;
10041
- const parts = line.split(" ");
10197
+ for (const record2 of stdout.split("\0")) {
10198
+ if (record2 === "" || record2.startsWith("DSH_")) continue;
10199
+ const parts = record2.split(" ");
10042
10200
  if (parts.length < 5) continue;
10043
10201
  const [typeChar, targetChar, sizeText, mtimeText] = parts;
10044
10202
  const kind = typeChar === "d" ? "d" : typeChar === "l" ? targetChar === "d" ? "d" : "l" : "f";
10045
10203
  const size = Number.parseInt(sizeText, 10);
10046
- const seconds = Number.parseInt(mtimeText, 10);
10204
+ const seconds = Number.parseFloat(mtimeText);
10047
10205
  entries.push({
10048
10206
  name: parts.slice(4).join(" "),
10049
10207
  isDirectory: kind === "d",
10050
10208
  isFile: kind === "f",
10051
10209
  isSymlink: kind === "l",
10052
10210
  size: Number.isFinite(size) ? size : 0,
10053
- mtimeMs: (Number.isFinite(seconds) ? seconds : 0) * 1e3
10211
+ mtimeMs: Number.isFinite(seconds) ? Math.trunc(seconds * 1e3) : 0
10054
10212
  });
10055
10213
  }
10056
10214
  entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
@@ -10438,6 +10596,13 @@ var RemoteBackend = class {
10438
10596
  if (result.stdout.includes("DSH_ERROR=not-a-dir")) {
10439
10597
  throw new SshDevError("REMOTE_NOT_FOUND", `\u8FDC\u7AEF\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${absolute}`, { path: absolute });
10440
10598
  }
10599
+ if (result.truncated === true) {
10600
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u76EE\u5F55\u6761\u76EE\u8FC7\u591A\uFF0C\u5217\u4E3E\u7ED3\u679C\u88AB\u622A\u65AD\uFF1A${absolute}`, {
10601
+ path: absolute,
10602
+ reason: "listing-truncated",
10603
+ maxBytes: 4 * 1024 * 1024
10604
+ });
10605
+ }
10441
10606
  return parseListing(result.stdout);
10442
10607
  }
10443
10608
  /**
@@ -11079,7 +11244,21 @@ var RemoteBackend = class {
11079
11244
  }
11080
11245
  const command = tool === "sha256sum" ? "sha256sum" : "shasum -a 256";
11081
11246
  const result = await (await this.#transport()).exec({ argv: ["sh", "-c", `${command} -- "$1" | cut -d' ' -f1`, "dsh-digest", absolute] }, signal);
11082
- return result.stdout.trim();
11247
+ if (result.exitCode !== 0) {
11248
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u65E0\u6CD5\u8BA1\u7B97\u8FDC\u7AEF\u6587\u4EF6\u7684 sha256\uFF08\u6458\u8981\u547D\u4EE4\u9000\u51FA\u7801 ${result.exitCode}\uFF09\uFF1A${absolute}`, {
11249
+ path: absolute,
11250
+ exitCode: result.exitCode,
11251
+ stderr: result.stderr.slice(0, 200)
11252
+ });
11253
+ }
11254
+ const digest = result.stdout.trim();
11255
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
11256
+ throw new SshDevError("REMOTE_COMMAND_FAILED", `\u8FDC\u7AEF\u6458\u8981\u5DE5\u5177\u8FD4\u56DE\u4E86\u975E\u6CD5\u6458\u8981\uFF08\u4E0D\u662F 64 \u4F4D\u5C0F\u5199\u5341\u516D\u8FDB\u5236\uFF09\uFF1A${absolute}`, {
11257
+ path: absolute,
11258
+ digest: digest.slice(0, 120)
11259
+ });
11260
+ }
11261
+ return digest;
11083
11262
  }
11084
11263
  };
11085
11264
  function sha256Hex(bytes) {
@@ -11910,12 +12089,12 @@ init_ssh2_transport();
11910
12089
 
11911
12090
  // src/host/integration/agent/agent-install-io.ts
11912
12091
  init_shell_quote();
11913
- import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
12092
+ import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
11914
12093
  var DEFAULT_MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
11915
12094
  function createAgentInstallIo(options) {
11916
12095
  const maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
11917
12096
  const readLocal = options.readLocalFile ?? ((path) => readFile2(path).then((buffer) => new Uint8Array(buffer)));
11918
- const sizeOf = options.localSize ?? ((path) => stat2(path).then((info) => info.size));
12097
+ const sizeOf = options.localSize ?? ((path) => stat3(path).then((info) => info.size));
11919
12098
  const exec = async (command, signal) => {
11920
12099
  const result = await options.transport.exec(
11921
12100
  { argv: ["/bin/sh", "-c", command], maxBytes: options.probeMaxBytes ?? 256 * 1024 },
@@ -11998,14 +12177,14 @@ function reviseToTier1(profile, decision, reason, hint) {
11998
12177
  // src/host/integration/agent/askpass-registry.ts
11999
12178
  function createAskpassRegistry(options) {
12000
12179
  const handles = /* @__PURE__ */ new Map();
12001
- const pending = /* @__PURE__ */ new Map();
12180
+ const pending2 = /* @__PURE__ */ new Map();
12002
12181
  const retired = /* @__PURE__ */ new Set();
12003
12182
  const prepare = async (profile) => {
12004
12183
  if (!needsAskpass(profile.auth.kind)) return void 0;
12005
12184
  if (retired.has(profile.id)) return void 0;
12006
12185
  const existing = handles.get(profile.id);
12007
12186
  if (existing !== void 0) return existing.env;
12008
- const inflight = pending.get(profile.id);
12187
+ const inflight = pending2.get(profile.id);
12009
12188
  if (inflight !== void 0) return inflight;
12010
12189
  const attempt = (async () => {
12011
12190
  try {
@@ -12022,16 +12201,16 @@ function createAskpassRegistry(options) {
12022
12201
  return void 0;
12023
12202
  }
12024
12203
  })().finally(() => {
12025
- pending.delete(profile.id);
12204
+ pending2.delete(profile.id);
12026
12205
  });
12027
- pending.set(profile.id, attempt);
12206
+ pending2.set(profile.id, attempt);
12028
12207
  return attempt;
12029
12208
  };
12030
12209
  const forget = async (connectionId) => {
12031
12210
  retired.add(connectionId);
12032
12211
  const handle = handles.get(connectionId);
12033
12212
  handles.delete(connectionId);
12034
- pending.delete(connectionId);
12213
+ pending2.delete(connectionId);
12035
12214
  if (handle !== void 0) await handle.dispose().catch(() => void 0);
12036
12215
  };
12037
12216
  return {
@@ -12049,7 +12228,7 @@ function createAskpassRegistry(options) {
12049
12228
  retired.delete(connectionId);
12050
12229
  },
12051
12230
  disposeAll: async () => {
12052
- const ids = /* @__PURE__ */ new Set([...handles.keys(), ...pending.keys()]);
12231
+ const ids = /* @__PURE__ */ new Set([...handles.keys(), ...pending2.keys()]);
12053
12232
  for (const id of ids) retired.add(id);
12054
12233
  await Promise.all([...ids].map((id) => forget(id)));
12055
12234
  }
@@ -12166,6 +12345,27 @@ var RemoteIndex = class {
12166
12345
  * root is correct, just slower.
12167
12346
  */
12168
12347
  #scannedOnce = /* @__PURE__ */ new Set();
12348
+ /**
12349
+ * In-flight enumerations, so a burst of callers on a cold root does **one** scan.
12350
+ *
12351
+ * 审计 2026-11-05 ts.md P1:`enumerate` 没有 single-flight——同一次键入可能同时发出 `index.scan` 与
12352
+ * `index.find`(file-reference 一次 list 最多两个枚举,可指向同一 root),于是两次远端扫描都跑、
12353
+ * 结果只留一份。键里带 signal 身份:不同请求的取消不能互相传染(A 的 abort 不该杀掉 B 的扫描)。
12354
+ */
12355
+ #inFlight = /* @__PURE__ */ new Map();
12356
+ /** Stable identity per `AbortSignal`, only used to keep in-flight keys apart. */
12357
+ #signalIds = /* @__PURE__ */ new WeakMap();
12358
+ #nextSignalId = 0;
12359
+ #inFlightKey(root, signal) {
12360
+ if (signal === void 0) return `${root}\0`;
12361
+ let id = this.#signalIds.get(signal);
12362
+ if (id === void 0) {
12363
+ this.#nextSignalId += 1;
12364
+ id = this.#nextSignalId;
12365
+ this.#signalIds.set(signal, id);
12366
+ }
12367
+ return `${root}\0${id}`;
12368
+ }
12169
12369
  constructor(options) {
12170
12370
  this.#tiers = options.tiers;
12171
12371
  this.#ttlMs = options.ttlMs ?? DEFAULTS.ttlMs;
@@ -12195,9 +12395,20 @@ var RemoteIndex = class {
12195
12395
  };
12196
12396
  }
12197
12397
  }
12198
- const result = await this.#enumerateFresh(root, maxEntries, options.budgetMs ?? this.#budgetMs, options.signal);
12199
- if (result.cacheable) this.#remember(root, result);
12200
- return result;
12398
+ const key = this.#inFlightKey(root, options.signal);
12399
+ const running = this.#inFlight.get(key);
12400
+ if (running !== void 0) return running;
12401
+ const attempt = (async () => {
12402
+ const result = await this.#enumerateFresh(root, maxEntries, options.budgetMs ?? this.#budgetMs, options.signal);
12403
+ if (result.cacheable) this.#remember(root, result);
12404
+ return result;
12405
+ })();
12406
+ this.#inFlight.set(key, attempt);
12407
+ try {
12408
+ return await attempt;
12409
+ } finally {
12410
+ if (this.#inFlight.get(key) === attempt) this.#inFlight.delete(key);
12411
+ }
12201
12412
  }
12202
12413
  /**
12203
12414
  * Completion for a partially typed `@` query.
@@ -12712,6 +12923,129 @@ import { join as join9 } from "node:path";
12712
12923
  import { homedir as homedir2 } from "node:os";
12713
12924
  import { createRequire } from "node:module";
12714
12925
  import "@deepseek-ai/cordis";
12926
+
12927
+ // src/host/domain/roots/ssh-config.ts
12928
+ function stripComment(line) {
12929
+ let inQuotes = false;
12930
+ for (let index = 0; index < line.length; index++) {
12931
+ const char = line[index];
12932
+ if (char === '"') inQuotes = !inQuotes;
12933
+ else if (char === "#" && !inQuotes) return line.slice(0, index);
12934
+ }
12935
+ return line;
12936
+ }
12937
+ function unquote(value) {
12938
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
12939
+ return value;
12940
+ }
12941
+ function splitKeyValue(line) {
12942
+ const match = /^([A-Za-z][A-Za-z0-9]*)\s*(?:=|\s)\s*(.*)$/.exec(line);
12943
+ if (match === null) return void 0;
12944
+ const key = match[1];
12945
+ const value = match[2];
12946
+ if (key === void 0 || value === void 0) return void 0;
12947
+ return [key.toLowerCase(), unquote(value.trim())];
12948
+ }
12949
+ function parseSshConfig(text8) {
12950
+ const entries = [];
12951
+ let current;
12952
+ const lines = text8.split(/\r?\n/);
12953
+ for (let index = 0; index < lines.length; index++) {
12954
+ const raw = lines[index] ?? "";
12955
+ const line = stripComment(raw).trim();
12956
+ if (line === "") continue;
12957
+ const pair = splitKeyValue(line);
12958
+ if (pair === void 0) continue;
12959
+ const [key, value] = pair;
12960
+ if (key === "host") {
12961
+ const patterns = value.split(/\s+/).filter((part) => part !== "");
12962
+ if (patterns.length === 0) continue;
12963
+ current = { patterns, identityFiles: [], includes: [], line: index + 1 };
12964
+ entries.push(current);
12965
+ continue;
12966
+ }
12967
+ if (current === void 0) continue;
12968
+ switch (key) {
12969
+ case "hostname":
12970
+ current.hostName ??= value;
12971
+ break;
12972
+ case "user":
12973
+ current.user ??= value;
12974
+ break;
12975
+ case "port": {
12976
+ if (current.port !== void 0) break;
12977
+ const port = Number.parseInt(value, 10);
12978
+ if (Number.isInteger(port) && port > 0 && port <= 65535) current.port = port;
12979
+ break;
12980
+ }
12981
+ case "identityfile":
12982
+ current.identityFiles.push(value);
12983
+ break;
12984
+ case "proxyjump":
12985
+ current.proxyJump ??= value.toLowerCase() === "none" ? "" : value;
12986
+ break;
12987
+ case "include":
12988
+ current.includes.push(value);
12989
+ break;
12990
+ default:
12991
+ break;
12992
+ }
12993
+ }
12994
+ return entries.map((entry) => ({
12995
+ patterns: entry.patterns,
12996
+ ...entry.hostName === void 0 ? {} : { hostName: entry.hostName },
12997
+ ...entry.user === void 0 ? {} : { user: entry.user },
12998
+ ...entry.port === void 0 ? {} : { port: entry.port },
12999
+ identityFiles: entry.identityFiles,
13000
+ ...entry.proxyJump === void 0 || entry.proxyJump === "" ? {} : { proxyJump: entry.proxyJump },
13001
+ includes: entry.includes,
13002
+ line: entry.line
13003
+ }));
13004
+ }
13005
+ function isConcretePattern(pattern) {
13006
+ return !/[*?!]/.test(pattern);
13007
+ }
13008
+ function expandHome(path, home) {
13009
+ if (path === "~") return home;
13010
+ if (path.startsWith("~/") || path.startsWith("~\\")) return `${home.replace(/[\\/]+$/, "")}${path.slice(1)}`;
13011
+ return path;
13012
+ }
13013
+ function importableEntries(entries, options = {}) {
13014
+ const home = options.home ?? "";
13015
+ const excluded = new Set(options.excluded ?? []);
13016
+ const seen = /* @__PURE__ */ new Set();
13017
+ const drafts = [];
13018
+ const skipped = [];
13019
+ for (const entry of entries) {
13020
+ for (const pattern of entry.patterns) {
13021
+ if (!isConcretePattern(pattern)) {
13022
+ skipped.push({ pattern, reason: "wildcard", line: entry.line });
13023
+ continue;
13024
+ }
13025
+ if (seen.has(pattern)) {
13026
+ skipped.push({ pattern, reason: "duplicate", line: entry.line });
13027
+ continue;
13028
+ }
13029
+ if (excluded.has(pattern)) {
13030
+ skipped.push({ pattern, reason: "already-imported", line: entry.line });
13031
+ continue;
13032
+ }
13033
+ seen.add(pattern);
13034
+ const identityFile = entry.identityFiles[0];
13035
+ drafts.push({
13036
+ alias: pattern,
13037
+ hostName: entry.hostName ?? pattern,
13038
+ ...entry.user === void 0 ? {} : { user: entry.user },
13039
+ ...entry.port === void 0 ? {} : { port: entry.port },
13040
+ ...identityFile === void 0 ? {} : { identityFile: home === "" ? identityFile : expandHome(identityFile, home) },
13041
+ ...entry.proxyJump === void 0 ? {} : { proxyJump: entry.proxyJump }
13042
+ });
13043
+ }
13044
+ }
13045
+ return { drafts, skipped };
13046
+ }
13047
+
13048
+ // src/wiring/helpers.ts
12715
13049
  init_errors();
12716
13050
  init_ssh2_transport();
12717
13051
 
@@ -12800,7 +13134,7 @@ async function resolveAuth(ctx, profile) {
12800
13134
  return agent === void 0 ? {} : { agent };
12801
13135
  }
12802
13136
  if (spec.kind === "key") {
12803
- const path = expandHome(spec.keyPath);
13137
+ const path = expandHome2(spec.keyPath);
12804
13138
  const privateKey = await readFile4(path).catch((error) => {
12805
13139
  const message = error instanceof Error ? error.message : String(error);
12806
13140
  throw new SshDevError("AUTH_FAILED", `\u65E0\u6CD5\u8BFB\u53D6\u79C1\u94A5 ${path}\uFF1A${message}`, { path });
@@ -12832,10 +13166,8 @@ function isSsh2Available() {
12832
13166
  return false;
12833
13167
  }
12834
13168
  }
12835
- function expandHome(path) {
12836
- if (path === "~") return homedir2();
12837
- if (path.startsWith("~/") || path.startsWith("~\\")) return join9(homedir2(), path.slice(2));
12838
- return path;
13169
+ function expandHome2(path) {
13170
+ return expandHome(path, homedir2());
12839
13171
  }
12840
13172
  var readSessionHeader = createDiskSessionHeaderReader();
12841
13173
 
@@ -12928,8 +13260,8 @@ function wireConnections(ctx, config) {
12928
13260
  hostKeyPolicy: profile.hostKeyPolicy,
12929
13261
  // A connection that names a key must use it: the alias transport otherwise relies entirely on the
12930
13262
  // user's own ~/.ssh/config, which silently ignores the plugin's `keyPath`.
12931
- ...profile.auth.kind === "key" ? { identityFile: expandHome(profile.auth.keyPath) } : {},
12932
- ...limits.knownHostsFile === void 0 ? {} : { knownHostsFile: expandHome(limits.knownHostsFile) },
13263
+ ...profile.auth.kind === "key" ? { identityFile: expandHome2(profile.auth.keyPath) } : {},
13264
+ ...limits.knownHostsFile === void 0 ? {} : { knownHostsFile: expandHome2(limits.knownHostsFile) },
12933
13265
  // The connection's `user` must reach ssh; otherwise a bare host falls back to the local username.
12934
13266
  ...profile.user === "" ? {} : { user: profile.user },
12935
13267
  // Password connections authenticate through an SSH_ASKPASS helper: they must stop forbidding prompts
@@ -13022,8 +13354,8 @@ function wireConnections(ctx, config) {
13022
13354
  sshArgv: sshPrefix("ssh", {
13023
13355
  alias: profile.alias ?? profile.host,
13024
13356
  user: profile.user,
13025
- ...profile.auth.kind === "key" ? { identityFile: expandHome(profile.auth.keyPath) } : {},
13026
- ...limits.knownHostsFile === void 0 ? {} : { knownHostsFile: expandHome(limits.knownHostsFile) },
13357
+ ...profile.auth.kind === "key" ? { identityFile: expandHome2(profile.auth.keyPath) } : {},
13358
+ ...limits.knownHostsFile === void 0 ? {} : { knownHostsFile: expandHome2(limits.knownHostsFile) },
13027
13359
  hostKeyPolicy: profile.hostKeyPolicy,
13028
13360
  connectTimeoutMs: limits.connectTimeoutMs ?? profile.connectTimeoutMs,
13029
13361
  ...passwordOptions.batchMode === void 0 ? {} : { batchMode: passwordOptions.batchMode }
@@ -13120,11 +13452,11 @@ function wireConnections(ctx, config) {
13120
13452
  port: profile.port,
13121
13453
  user: profile.user,
13122
13454
  authKind: profile.auth.kind,
13123
- ...profile.auth.kind === "key" ? { identityFile: expandHome(profile.auth.keyPath) } : {},
13455
+ ...profile.auth.kind === "key" ? { identityFile: expandHome2(profile.auth.keyPath) } : {},
13124
13456
  connectTimeoutMs: profile.connectTimeoutMs
13125
13457
  },
13126
13458
  {
13127
- ...limits.knownHostsFile === void 0 ? {} : { knownHostsFile: expandHome(limits.knownHostsFile) },
13459
+ ...limits.knownHostsFile === void 0 ? {} : { knownHostsFile: expandHome2(limits.knownHostsFile) },
13128
13460
  ...limits.connectTimeoutMs === void 0 ? {} : { connectTimeoutMs: limits.connectTimeoutMs }
13129
13461
  }
13130
13462
  );
@@ -13254,12 +13586,8 @@ function wireConnections(ctx, config) {
13254
13586
  }
13255
13587
  function readLiveSessions(ctx) {
13256
13588
  let list;
13257
- try {
13258
- const sessions = ctx.get("sessions");
13259
- list = sessions?.list?.();
13260
- } catch {
13261
- return [];
13262
- }
13589
+ const sessions = ctx.get("sessions");
13590
+ list = sessions?.list?.();
13263
13591
  if (!Array.isArray(list)) return [];
13264
13592
  const live = [];
13265
13593
  for (const session of list) {
@@ -13277,9 +13605,13 @@ import { mkdir as mkdir7 } from "node:fs/promises";
13277
13605
  // src/host/integration/remote/remote-face.ts
13278
13606
  import { Service } from "@deepseek-ai/cordis";
13279
13607
  import { bindTypertRemote } from "@deepseek-ai/dsh-typert-protocol";
13608
+
13609
+ // src/shared/remote-identity.ts
13280
13610
  var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
13281
13611
  var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
13282
- var SSH_DEV_REMOTE_CONNECTIONS_METHOD = "connections";
13612
+ var SSH_DEV_REMOTE_METHOD = "connections";
13613
+
13614
+ // src/host/integration/remote/remote-face.ts
13283
13615
  var NOT_CONNECTED_NOTE = "\u5C1A\u672A\u5EFA\u7ACB\u8FDE\u63A5\uFF1A\u4F20\u8F93\u51B3\u7B56\u5728\u9996\u6B21\u8FDE\u63A5\u65F6\u786E\u5B9A\u3002";
13284
13616
  function buildConnectionStatusRows(source) {
13285
13617
  return source.profiles().map((profile) => {
@@ -13344,7 +13676,7 @@ var SshDevRemoteFace = class extends Service {
13344
13676
  return buildConnectionStatusRows(this.#source);
13345
13677
  }
13346
13678
  };
13347
- markRemoteMethod(SshDevRemoteFace.prototype, SSH_DEV_REMOTE_CONNECTIONS_METHOD);
13679
+ markRemoteMethod(SshDevRemoteFace.prototype, SSH_DEV_REMOTE_METHOD);
13348
13680
  function installRemoteFace(ctx) {
13349
13681
  return new SshDevRemoteFace(ctx, {
13350
13682
  profiles: () => ctx.get("sshDevConnections")?.list() ?? [],
@@ -13541,11 +13873,14 @@ function registerSessionTools(ctx, service) {
13541
13873
  };
13542
13874
  }
13543
13875
 
13544
- // src/host/app/roots/roots-api-http.ts
13876
+ // src/host/integration/tools/session-directory-tools.ts
13877
+ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
13878
+
13879
+ // src/host/app/roots/session-directories.ts
13545
13880
  init_errors();
13546
13881
 
13547
13882
  // src/host/app/roots/user-roots.ts
13548
- import { mkdir as mkdir4, readFile as readFile5, rename as rename3, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
13883
+ import { mkdir as mkdir4, readFile as readFile5, rename as rename3, stat as stat4, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
13549
13884
  import { join as join12 } from "node:path";
13550
13885
  init_errors();
13551
13886
  var USER_ROOTS_VERSION = 1;
@@ -13571,6 +13906,10 @@ var nodeUserRootsIo = {
13571
13906
  await mkdir4(path, { recursive: true, mode: 448 });
13572
13907
  },
13573
13908
  rename: (from, to) => rename3(from, to),
13909
+ stat: async (path) => {
13910
+ const info = await stat4(path);
13911
+ return { size: info.size, mtimeMs: info.mtimeMs };
13912
+ },
13574
13913
  unlink: async (path) => {
13575
13914
  try {
13576
13915
  await unlink3(path);
@@ -13933,7 +14272,19 @@ function createUserRootsStore(options) {
13933
14272
  const filePath = join12(options.dir, USER_ROOTS_FILE_NAME);
13934
14273
  let cachedRaw;
13935
14274
  let cachedLoad;
14275
+ let cachedSignature;
14276
+ const signatureOf = async () => {
14277
+ if (io.stat === void 0) return void 0;
14278
+ try {
14279
+ const info = await io.stat(filePath);
14280
+ return `${info.size}:${Math.trunc(info.mtimeMs)}`;
14281
+ } catch {
14282
+ return void 0;
14283
+ }
14284
+ };
13936
14285
  const load = async () => {
14286
+ const signature = await signatureOf();
14287
+ if (signature !== void 0 && signature === cachedSignature && cachedLoad !== void 0) return cachedLoad;
13937
14288
  let text8;
13938
14289
  try {
13939
14290
  text8 = await io.readFile(filePath);
@@ -13945,16 +14296,20 @@ function createUserRootsStore(options) {
13945
14296
  degraded: true
13946
14297
  };
13947
14298
  }
13948
- if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) return cachedLoad;
14299
+ if (cachedRaw !== void 0 && text8 === cachedRaw && cachedLoad !== void 0) {
14300
+ cachedSignature = signature;
14301
+ return cachedLoad;
14302
+ }
13949
14303
  const parsed = parseUserRoots(text8);
13950
14304
  cachedRaw = text8;
13951
14305
  cachedLoad = parsed;
14306
+ cachedSignature = signature;
13952
14307
  return parsed;
13953
14308
  };
13954
- const write = async (next) => {
14309
+ const write = async (next, preparedText) => {
13955
14310
  const validated = validateState(next);
13956
14311
  const temporary = `${filePath}.${process.pid}-${++tempSequence2}.tmp`;
13957
- const text8 = serializeUserRoots(validated);
14312
+ const text8 = preparedText ?? serializeUserRoots(validated);
13958
14313
  await io.mkdir(options.dir);
13959
14314
  try {
13960
14315
  await io.writeFile(temporary, text8);
@@ -13965,6 +14320,7 @@ function createUserRootsStore(options) {
13965
14320
  }
13966
14321
  cachedRaw = text8;
13967
14322
  cachedLoad = { state: validated, warnings: [], degraded: false };
14323
+ cachedSignature = void 0;
13968
14324
  return validated;
13969
14325
  };
13970
14326
  const queue = createSerialQueue();
@@ -13974,16 +14330,18 @@ function createUserRootsStore(options) {
13974
14330
  assertUserRootsWritable(await load());
13975
14331
  return write(next);
13976
14332
  });
14333
+ const applyTransition = async (transition) => {
14334
+ const loaded = await load();
14335
+ assertUserRootsWritable(loaded);
14336
+ const wanted = transition(loaded.state);
14337
+ if (wanted === loaded.state) return loaded.state;
14338
+ const wantedText = serializeUserRoots(wanted);
14339
+ if (cachedRaw !== void 0 && wantedText === cachedRaw) return loaded.state;
14340
+ return write(wanted, wantedText);
14341
+ };
13977
14342
  const mutate = (transition) => {
13978
14343
  const locked = options.withLock;
13979
- const run = async () => serialize(async () => {
13980
- const loaded = await load();
13981
- assertUserRootsWritable(loaded);
13982
- const wanted = transition(loaded.state);
13983
- if (serializeUserRoots(wanted) === serializeUserRoots(loaded.state)) return loaded.state;
13984
- return write(wanted);
13985
- });
13986
- return locked === void 0 ? run() : locked(run);
14344
+ return serialize(() => locked === void 0 ? applyTransition(transition) : locked(() => applyTransition(transition)));
13987
14345
  };
13988
14346
  return {
13989
14347
  filePath,
@@ -14137,6 +14495,7 @@ function isMissingFile(error) {
14137
14495
  }
14138
14496
 
14139
14497
  // src/host/app/roots/roots-api-http.ts
14498
+ init_errors();
14140
14499
  async function readBody(req, maxBytes) {
14141
14500
  const chunks = [];
14142
14501
  let total = 0;
@@ -14193,11 +14552,17 @@ var STATUS_BY_CODE = {
14193
14552
  ROOT_INVALID: 400,
14194
14553
  CONNECTION_INVALID: 400,
14195
14554
  CONNECTION_NOT_FOUND: 400,
14555
+ // 路径不在任何已注册的根内 / 根没有注册:客户端换一个路径或先加根就能成功,不是服务器故障。
14556
+ // 它此前漏在表外,于是**所有**经 HTTP 暴露的根外访问都报 500(审计 2026-11-06 第 4 轮 P1)。
14557
+ ROOT_OUTSIDE: 400,
14196
14558
  // 目标不存在 / 状态冲突 / 本组合不支持:客户端可以据此换一条路。
14197
14559
  TRASH_NOT_FOUND: 404,
14198
14560
  TRANSFER_CONFLICT: 409,
14199
14561
  SESSION_ACTIVE: 409,
14200
- REMOTE_UNSUPPORTED: 501
14562
+ REMOTE_UNSUPPORTED: 501,
14563
+ // 调用方主动取消(客户端断开/关掉面板)。499 是 nginx 的 "client closed request" 约定:它不是一个错误,
14564
+ // 只是说明"没人等这个回答了";映射成 500 会让日志里出现一堆假故障。
14565
+ CANCELLED: 499
14201
14566
  };
14202
14567
  function codeOf(error) {
14203
14568
  const code = asRecord2(error)?.["code"];
@@ -14232,6 +14597,9 @@ function optionalPath(value) {
14232
14597
  reason: "invalid-path"
14233
14598
  });
14234
14599
  }
14600
+ if (/[\u0000-\u001f]/.test(value)) {
14601
+ throw new SshDevError("ROOT_INVALID", "path \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\u542B NUL\uFF09", { reason: "invalid-path" });
14602
+ }
14235
14603
  return value.trim();
14236
14604
  }
14237
14605
  function pick(source, fields) {
@@ -14275,27 +14643,375 @@ function connectionIdFromAlias(alias) {
14275
14643
  return isValidConnectionId(folded) ? folded : void 0;
14276
14644
  }
14277
14645
 
14278
- // src/shared/routes.ts
14279
- var ROOTS_API_PATH = "/api/over-ssh/roots";
14280
- var SESSION_API_PATH = "/api/over-ssh/sessions";
14281
- function asRouteHost(value) {
14282
- if (typeof value !== "object" || value === null) return void 0;
14283
- const register = value.register;
14284
- return typeof register === "function" ? value : void 0;
14646
+ // src/host/app/roots/session-directories.ts
14647
+ var SESSION_DIRECTORY_MAX_ROOTS = 32;
14648
+ var SYSTEM_TREES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc", "/dev", "/var", "/root"];
14649
+ var REMOTE_SYSTEM_TREES = ["/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/sys", "/proc", "/dev", "/var", "/root"];
14650
+ var REMOTE_BROAD_ROOTS = [...REMOTE_SYSTEM_TREES, "/home", "/opt", "/srv", "/mnt", "/media", "/tmp"];
14651
+ function remotePathTooBroad(normalized) {
14652
+ if (REMOTE_BROAD_ROOTS.includes(normalized)) return true;
14653
+ for (const tree of REMOTE_SYSTEM_TREES) {
14654
+ if (normalized === tree || normalized.startsWith(`${tree}/`)) return true;
14655
+ }
14656
+ return false;
14285
14657
  }
14286
-
14287
- // src/host/app/roots/roots-api-contract.ts
14288
- var ROOTS_API_MAX_BODY_BYTES = 64 * 1024;
14289
- var DIRECTORY_LIST_MAX_ENTRIES = 2e3;
14290
- var REMOTE_LIST_MAX_ENTRIES = DIRECTORY_LIST_MAX_ENTRIES;
14291
- var LOCAL_LIST_MAX_ENTRIES = DIRECTORY_LIST_MAX_ENTRIES;
14292
- var ROOTS_API_ACTIONS = [
14293
- "add-root",
14294
- "remove-root",
14295
- "add-connection",
14296
- "update-connection",
14297
- "test-connection",
14298
- "remove-connection",
14658
+ function directoryRefusal(request) {
14659
+ const trimmed = request.path.trim();
14660
+ if (trimmed === "") return "\u76EE\u5F55\u8DEF\u5F84\u4E3A\u7A7A\u3002";
14661
+ if (request.location === "remote") {
14662
+ if (request.connectionId === void 0 || request.connectionId === "") {
14663
+ return "\u8FDC\u7AEF\u76EE\u5F55\u9700\u8981 connectionId\uFF08\u5148\u786E\u8BA4\u76EE\u6807\u8FDE\u63A5\uFF09\u3002";
14664
+ }
14665
+ if (!trimmed.startsWith("/")) return `\u8FDC\u7AEF\u76EE\u5F55\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${trimmed}`;
14666
+ let normalized2;
14667
+ try {
14668
+ normalized2 = normalizeRemotePath(trimmed);
14669
+ } catch (error) {
14670
+ return error instanceof Error ? error.message : `\u8FDC\u7AEF\u8DEF\u5F84\u4E0D\u5408\u6CD5\uFF1A${trimmed}`;
14671
+ }
14672
+ if (normalized2 === "/") return "\u4E0D\u5141\u8BB8\u628A\u8FDC\u7AEF\u7684\u6839\u76EE\u5F55 / \u6DFB\u52A0\u5230\u4F1A\u8BDD\u3002";
14673
+ if (remotePathTooBroad(normalized2)) {
14674
+ return `\u4E0D\u5141\u8BB8\u628A\u8FC7\u5BBD\u7684\u8FDC\u7AEF\u76EE\u5F55\u6DFB\u52A0\u5230\u4F1A\u8BDD\uFF08${normalized2}\uFF09\uFF1A\u8BF7\u9009\u5230\u5177\u4F53\u9879\u76EE\u76EE\u5F55\u3002`;
14675
+ }
14676
+ return void 0;
14677
+ }
14678
+ const normalized = trimmed.replace(/\/+$/u, "") || "/";
14679
+ if (!isAbsoluteLocalPath(normalized)) return `\u672C\u5730\u76EE\u5F55\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${trimmed}`;
14680
+ if (normalized === "/") return "\u4E0D\u5141\u8BB8\u628A\u672C\u673A\u7684\u6839\u76EE\u5F55 / \u6DFB\u52A0\u5230\u4F1A\u8BDD\u3002";
14681
+ if (SYSTEM_TREES.some((tree) => normalized === tree || localPathContains(tree, normalized))) {
14682
+ return `\u4E0D\u5141\u8BB8\u628A\u7CFB\u7EDF\u76EE\u5F55\u6DFB\u52A0\u5230\u4F1A\u8BDD\uFF1A${normalized}`;
14683
+ }
14684
+ const workspace = request.workspacePath.trim();
14685
+ if (workspace !== "") {
14686
+ if (normalized === workspace || localPathContains(normalized, workspace)) {
14687
+ return `\u4E0D\u5141\u8BB8\u6DFB\u52A0\u5F53\u524D\u5DE5\u4F5C\u533A\u7684\u7956\u5148\u76EE\u5F55\uFF08${normalized}\uFF09\uFF1A\u90A3\u4F1A\u628A\u6574\u68F5\u7236\u6811\u7EB3\u5165\u53EF\u5199\u8303\u56F4\u3002\u8BF7\u6DFB\u52A0\u5DE5\u4F5C\u533A\u5185\u90E8\u6216\u4E0E\u5B83\u5E76\u5217\u7684\u76EE\u5F55\u3002`;
14688
+ }
14689
+ }
14690
+ return void 0;
14691
+ }
14692
+ function createSessionDirectories(deps) {
14693
+ return {
14694
+ async add(request) {
14695
+ const refusal = directoryRefusal(request);
14696
+ if (refusal !== void 0) return { ok: false, reason: refusal };
14697
+ if (request.workspacePath.trim() === "") return { ok: false, reason: "\u62FF\u4E0D\u5230\u5F53\u524D\u5DE5\u4F5C\u533A\u8DEF\u5F84\uFF0C\u65E0\u6CD5\u6DFB\u52A0\u76EE\u5F55\u3002" };
14698
+ if (request.sessionId.trim() === "") return { ok: false, reason: "\u62FF\u4E0D\u5230\u5F53\u524D\u4F1A\u8BDD id\uFF0C\u65E0\u6CD5\u6DFB\u52A0\u76EE\u5F55\u3002" };
14699
+ const entry = {
14700
+ location: request.location,
14701
+ path: request.path.trim(),
14702
+ ...request.connectionId === void 0 ? {} : { connectionId: request.connectionId },
14703
+ writable: true,
14704
+ workspaces: [request.workspacePath],
14705
+ sessions: [request.sessionId]
14706
+ };
14707
+ const key = userRootKey(entry);
14708
+ let state;
14709
+ try {
14710
+ state = await deps.mutateUserState((current) => {
14711
+ const existing = findRootInState(current, key);
14712
+ if (existing === void 0) {
14713
+ const scoped = current.roots.filter(
14714
+ (candidate) => (candidate.sessions ?? []).includes(request.sessionId) || (candidate.workspaces ?? []).includes(request.workspacePath)
14715
+ ).length;
14716
+ if (scoped >= SESSION_DIRECTORY_MAX_ROOTS) {
14717
+ throw new SshDevError(
14718
+ "ROOT_INVALID",
14719
+ `\u672C\u4F1A\u8BDD\u5DF2\u6DFB\u52A0 ${scoped} \u4E2A\u76EE\u5F55\uFF0C\u8FBE\u5230\u4E0A\u9650 ${SESSION_DIRECTORY_MAX_ROOTS}\uFF1B\u8BF7\u5148\u79FB\u9664\u4E0D\u518D\u9700\u8981\u7684\u76EE\u5F55\u3002`,
14720
+ { limit: SESSION_DIRECTORY_MAX_ROOTS }
14721
+ );
14722
+ }
14723
+ return addRootToState(current, entry);
14724
+ }
14725
+ return withSessions(withWorkspaces(current, key, [request.workspacePath]), key, [request.sessionId]);
14726
+ });
14727
+ } catch (error) {
14728
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
14729
+ }
14730
+ return { ok: true, key, ...await ensureLiveAndReadAnchor(deps, key, state) };
14731
+ },
14732
+ async remove(request) {
14733
+ if (request.sessionId.trim() === "") return { ok: false, reason: "\u62FF\u4E0D\u5230\u5F53\u524D\u4F1A\u8BDD id\uFF0C\u65E0\u6CD5\u79FB\u9664\u76EE\u5F55" };
14734
+ let key = request.key?.trim() ?? "";
14735
+ let before;
14736
+ let state;
14737
+ try {
14738
+ state = await deps.mutateUserState((current) => {
14739
+ const resolved = resolveRemoveKey(current, request);
14740
+ if (resolved === void 0) {
14741
+ throw new SshDevError(
14742
+ "ROOT_INVALID",
14743
+ request.path === void 0 ? `\u6CA1\u6709\u627E\u5230\u8981\u79FB\u9664\u7684\u76EE\u5F55\uFF1A${key}` : `\u672C\u4F1A\u8BDD\u6CA1\u6709\u8FD9\u4E2A\u76EE\u5F55\uFF1A${request.path}`,
14744
+ { key, path: request.path }
14745
+ );
14746
+ }
14747
+ key = resolved.key;
14748
+ before = resolved.entry;
14749
+ if (!(resolved.entry.sessions ?? []).includes(request.sessionId)) {
14750
+ throw new SshDevError("ROOT_INVALID", "\u53EA\u80FD\u79FB\u9664\u672C\u4F1A\u8BDD\u81EA\u5DF1\u6DFB\u52A0\u7684\u76EE\u5F55\uFF1B\u8FD9\u6761\u4E0D\u662F\u672C\u4F1A\u8BDD\u52A0\u7684\u3002", { key });
14751
+ }
14752
+ return removeRootFromSession(current, key, request.sessionId);
14753
+ });
14754
+ } catch (error) {
14755
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
14756
+ }
14757
+ if (before === void 0) return { ok: false, reason: `\u6CA1\u6709\u627E\u5230\u8981\u79FB\u9664\u7684\u76EE\u5F55\uFF1A${key}` };
14758
+ if (!stateHasRoot(state, key)) {
14759
+ const liveKey = await findLiveKey(deps, before);
14760
+ if (liveKey !== void 0) await deps.unregisterRoot(liveKey);
14761
+ return { ok: true, key };
14762
+ }
14763
+ await refreshLiveScope(deps, key, state);
14764
+ return { ok: true, key };
14765
+ }
14766
+ };
14767
+ }
14768
+ function resolveRemoveKey(state, request) {
14769
+ const wantedKey = request.key?.trim();
14770
+ if (wantedKey !== void 0 && wantedKey !== "") {
14771
+ const entry = findRootInState(state, wantedKey);
14772
+ return entry === void 0 ? void 0 : { key: wantedKey, entry };
14773
+ }
14774
+ const path = request.path?.trim();
14775
+ if (path === void 0 || path === "") return void 0;
14776
+ const wanted = request.connectionId === void 0 ? userRootKey({ location: "local", path }) : userRootKey({ location: "remote", path, connectionId: request.connectionId });
14777
+ for (const entry of state.roots) {
14778
+ if (userRootKey(entry) === wanted) return { key: wanted, entry };
14779
+ }
14780
+ if (request.connectionId === void 0) {
14781
+ const matches = state.roots.filter((entry) => entry.path === path);
14782
+ if (matches.length === 1) return { key: userRootKey(matches[0]), entry: matches[0] };
14783
+ }
14784
+ return void 0;
14785
+ }
14786
+ async function ensureLiveAndReadAnchor(deps, key, state) {
14787
+ const persisted = findRootInState(state, key);
14788
+ if (persisted === void 0) return {};
14789
+ const readAnchor = (roots2) => {
14790
+ for (const raw of roots2) {
14791
+ if (liveKeyOfRecord(raw, persisted) === void 0) continue;
14792
+ const source = raw?.root ?? raw;
14793
+ const anchor = source["anchorPath"];
14794
+ if (typeof anchor === "string" && anchor !== "") return { anchorPath: anchor };
14795
+ return {};
14796
+ }
14797
+ return {};
14798
+ };
14799
+ const roots = await Promise.resolve(deps.listRoots());
14800
+ const liveEntry = roots.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
14801
+ if (liveEntry === void 0) {
14802
+ await deps.registerRoot(persisted);
14803
+ return readAnchor(await Promise.resolve(deps.listRoots()));
14804
+ }
14805
+ if (liveScopeIsCurrent(liveEntry, persisted)) return readAnchor(roots);
14806
+ const liveKey = liveKeyOfRecord(liveEntry, persisted);
14807
+ if (liveKey !== void 0) {
14808
+ await deps.unregisterRoot(liveKey);
14809
+ await deps.registerRoot(persisted);
14810
+ }
14811
+ return readAnchor(await Promise.resolve(deps.listRoots()));
14812
+ }
14813
+ async function liveScopeWarning(deps, key, state) {
14814
+ const persisted = findRootInState(state, key);
14815
+ if (persisted === void 0) return void 0;
14816
+ const roots = await Promise.resolve(deps.listRoots());
14817
+ const liveEntry = roots.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
14818
+ if (liveEntry === void 0) return `\u6DFB\u52A0\u76EE\u5F55\uFF1A\u6839 ${key} \u4E0D\u5728\u5B9E\u65F6\u6CE8\u518C\u8868\u91CC\uFF0C\u91CD\u542F\u524D\u53EF\u80FD\u4E0D\u53EF\u7528`;
14819
+ if (liveScopeIsCurrent(liveEntry, persisted)) return void 0;
14820
+ return `\u6DFB\u52A0\u76EE\u5F55\uFF1A\u6839 ${key} \u7684\u5B9E\u65F6\u4F5C\u7528\u57DF\u4E0E\u6587\u4EF6\u4E0D\u4E00\u81F4\uFF0C\u53E6\u4E00\u4E2A\u4F1A\u8BDD\u53EF\u80FD\u8981\u91CD\u542F\u540E\u624D\u53EF\u89C1`;
14821
+ }
14822
+ async function findLiveKey(deps, entry) {
14823
+ for (const raw of await Promise.resolve(deps.listRoots())) {
14824
+ const key = liveKeyOfRecord(raw, entry);
14825
+ if (key !== void 0) return key;
14826
+ }
14827
+ return void 0;
14828
+ }
14829
+ async function refreshLiveScope(deps, key, state) {
14830
+ const persisted = findRootInState(state, key);
14831
+ if (persisted === void 0) return;
14832
+ const roots = await Promise.resolve(deps.listRoots());
14833
+ const liveEntry = roots.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
14834
+ if (liveEntry === void 0) return;
14835
+ if (liveScopeIsCurrent(liveEntry, persisted)) return;
14836
+ const liveKey = liveKeyOfRecord(liveEntry, persisted);
14837
+ if (liveKey === void 0) return;
14838
+ await deps.unregisterRoot(liveKey);
14839
+ await deps.registerRoot(persisted);
14840
+ }
14841
+ function liveScopeIsCurrent(raw, entry) {
14842
+ const outer = raw ?? {};
14843
+ const source = outer.root ?? outer;
14844
+ if (source["shared"] === true) return true;
14845
+ return sameStrings(source["workspaces"], entry.workspaces) && sameStrings(source["sessions"], entry.sessions);
14846
+ }
14847
+ function sameStrings(left, right) {
14848
+ const asSet = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string").sort() : [];
14849
+ const a = asSet(left);
14850
+ const b = asSet(right);
14851
+ return a.length === b.length && a.every((value, index) => value === b[index]);
14852
+ }
14853
+
14854
+ // src/host/integration/tools/session-directory-tools.ts
14855
+ var SESSION_DIRECTORY_TOOL_NAMES = {
14856
+ // **必须以 `remote_` 开头**:插件的严格参数校验网关按这个名字前缀覆盖它的全部工具
14857
+ //(`strict-arguments.ts` 的 `STRICT_TOOL_PREFIX`),换个前缀就会静默逃过参数校验。
14858
+ add: "remote_add_directory",
14859
+ remove: "remote_remove_directory"
14860
+ };
14861
+ function sessionDirectoryToolDefinitions(deps) {
14862
+ return [
14863
+ defineTool2({
14864
+ name: SESSION_DIRECTORY_TOOL_NAMES.add,
14865
+ description: `Add a directory to **this conversation**, so its file tools and \`@\` completion can use it. The directory becomes part of the session workspace: under \`workspace-write\` it is readable and writable, and it is visible to this conversation only (the user's card lists it). **Requires human approval** \u2014 the call is refused when the deployment has no approval surface. Refused: a relative path, the machine root \`/\`, a system tree, a directory that is the current workspace **or one of its ancestors** (that would hand the model the whole parent tree), and more than ${SESSION_DIRECTORY_MAX_ROOTS} directories in one conversation. Prefer asking the user to add a directory when you are unsure: this tool changes your own permission boundary.`,
14866
+ parameters: {
14867
+ path: {
14868
+ type: "string",
14869
+ description: "Absolute path. Local spelling for a local directory, absolute remote path when `connectionId` is given.",
14870
+ required: true
14871
+ },
14872
+ connectionId: {
14873
+ type: "string",
14874
+ description: "Configured connection id when the directory is on a remote host. Omit for a local directory."
14875
+ }
14876
+ },
14877
+ output: {
14878
+ schema: {
14879
+ type: "object",
14880
+ additionalProperties: false,
14881
+ properties: {
14882
+ path: { type: "string", required: true },
14883
+ added: { type: "boolean", required: true },
14884
+ anchorPath: { type: "string" },
14885
+ note: { type: "string" }
14886
+ }
14887
+ },
14888
+ render: (_args, value) => {
14889
+ const record2 = value;
14890
+ const anchor = typeof record2["anchorPath"] === "string" ? `\uFF08\u951A\u70B9 ${record2["anchorPath"]}\uFF09` : "";
14891
+ return [{ type: "text", text: `\u5DF2\u52A0\u5165\u672C\u4F1A\u8BDD\uFF1A${String(record2["path"] ?? "")}${anchor}\u3002` }];
14892
+ }
14893
+ },
14894
+ async execute(args, exec) {
14895
+ const record2 = args ?? {};
14896
+ const path = typeof record2["path"] === "string" ? record2["path"].trim() : "";
14897
+ if (path === "") throw new Error("path \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
14898
+ const sessionId = deps.sessionId();
14899
+ if (sessionId === void 0 || sessionId === "") throw new Error("\u62FF\u4E0D\u5230\u5F53\u524D\u4F1A\u8BDD id\uFF0C\u65E0\u6CD5\u6DFB\u52A0\u76EE\u5F55");
14900
+ const workspacePath = deps.workspacePathOf(sessionId);
14901
+ if (workspacePath === void 0 || workspacePath === "") {
14902
+ throw new Error("\u62FF\u4E0D\u5230\u5F53\u524D\u5DE5\u4F5C\u533A\u8DEF\u5F84\uFF0C\u65E0\u6CD5\u628A\u76EE\u5F55\u52A0\u5165\u672C\u4F1A\u8BDD");
14903
+ }
14904
+ const connectionId = typeof record2["connectionId"] === "string" && record2["connectionId"] !== "" ? record2["connectionId"] : void 0;
14905
+ const outcome = await deps.directories.add({
14906
+ location: connectionId === void 0 ? "local" : "remote",
14907
+ path,
14908
+ ...connectionId === void 0 ? {} : { connectionId },
14909
+ workspacePath,
14910
+ sessionId
14911
+ });
14912
+ if (!outcome.ok) throw new Error(outcome.reason);
14913
+ return {
14914
+ path,
14915
+ added: true,
14916
+ ...outcome.anchorPath === void 0 ? {} : { anchorPath: outcome.anchorPath },
14917
+ note: "\u5DF2\u52A0\u5165\u672C\u4F1A\u8BDD\uFF1B`workspace-write` \u4E0B\u8BE5\u76EE\u5F55\u53EF\u8BFB\u53EF\u5199\u3002"
14918
+ };
14919
+ }
14920
+ }),
14921
+ defineTool2({
14922
+ name: SESSION_DIRECTORY_TOOL_NAMES.remove,
14923
+ description: "Remove a directory that **this conversation added** from its list. The directory stays registered for any other conversation that added it; a directory the user added in the plugin settings, and the conversation workspace itself, cannot be removed here. Removing only narrows access, so it needs no approval.",
14924
+ parameters: {
14925
+ path: { type: "string", description: "Absolute path of the directory to remove, as it was added.", required: true },
14926
+ connectionId: {
14927
+ type: "string",
14928
+ description: "Connection id when the directory is remote; must match the one it was added with."
14929
+ }
14930
+ },
14931
+ output: {
14932
+ schema: {
14933
+ type: "object",
14934
+ additionalProperties: false,
14935
+ properties: {
14936
+ path: { type: "string", required: true },
14937
+ removed: { type: "boolean", required: true },
14938
+ note: { type: "string" }
14939
+ }
14940
+ },
14941
+ render: (_args, value) => {
14942
+ const record2 = value;
14943
+ return [{ type: "text", text: `\u5DF2\u4ECE\u672C\u4F1A\u8BDD\u79FB\u9664\uFF1A${String(record2["path"] ?? "")}\u3002` }];
14944
+ }
14945
+ },
14946
+ async execute(args) {
14947
+ const record2 = args ?? {};
14948
+ const path = typeof record2["path"] === "string" ? record2["path"].trim() : "";
14949
+ if (path === "") throw new Error("path \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
14950
+ const sessionId = deps.sessionId();
14951
+ if (sessionId === void 0 || sessionId === "") throw new Error("\u62FF\u4E0D\u5230\u5F53\u524D\u4F1A\u8BDD id\uFF0C\u65E0\u6CD5\u79FB\u9664\u76EE\u5F55");
14952
+ const workspacePath = deps.workspacePathOf(sessionId);
14953
+ if (workspacePath === void 0 || workspacePath === "") {
14954
+ throw new Error("\u62FF\u4E0D\u5230\u5F53\u524D\u5DE5\u4F5C\u533A\u8DEF\u5F84\uFF0C\u65E0\u6CD5\u79FB\u9664\u76EE\u5F55");
14955
+ }
14956
+ const connectionId = typeof record2["connectionId"] === "string" && record2["connectionId"] !== "" ? record2["connectionId"] : void 0;
14957
+ const outcome = await deps.directories.remove({
14958
+ path,
14959
+ ...connectionId === void 0 ? {} : { connectionId },
14960
+ workspacePath,
14961
+ sessionId
14962
+ });
14963
+ if (!outcome.ok) throw new Error(outcome.reason);
14964
+ return { path, removed: true, note: "\u5DF2\u4ECE\u672C\u4F1A\u8BDD\u79FB\u9664\u3002" };
14965
+ }
14966
+ })
14967
+ ];
14968
+ }
14969
+ function sessionDirectoryApprovalGate(ctx) {
14970
+ return ctx.on("tools/pre-execute", async (exec, next) => {
14971
+ if (exec.name !== SESSION_DIRECTORY_TOOL_NAMES.add) return next();
14972
+ const args = exec.arguments ?? {};
14973
+ const target = String(args["path"] ?? "");
14974
+ const where = typeof args["connectionId"] === "string" && args["connectionId"] !== "" ? `${args["connectionId"]}:${target}` : target;
14975
+ return {
14976
+ kind: "ask",
14977
+ reason: `${SESSION_DIRECTORY_TOOL_NAMES.add} \u60F3\u628A ${where} \u52A0\u5165\u672C\u4F1A\u8BDD\uFF08\u4F1A\u6269\u5927\u6A21\u578B\u53EF\u8BFB\u5199\u7684\u8303\u56F4\uFF09`,
14978
+ displayReason: {
14979
+ en: `Add ${where} to this conversation? It becomes readable and writable by the model.`,
14980
+ zh: `\u628A ${where} \u52A0\u5165\u672C\u4F1A\u8BDD\uFF1F\u52A0\u5165\u540E\u6A21\u578B\u53EF\u8BFB\u5199\u8BE5\u76EE\u5F55\u3002`
14981
+ }
14982
+ };
14983
+ });
14984
+ }
14985
+ function registerSessionDirectoryTools(ctx, deps) {
14986
+ const disposers = sessionDirectoryToolDefinitions(deps).map((definition) => ctx.tools.register(definition));
14987
+ const disposeGate = sessionDirectoryApprovalGate(ctx);
14988
+ return () => {
14989
+ for (const dispose of disposers.splice(0)) dispose();
14990
+ disposeGate();
14991
+ };
14992
+ }
14993
+
14994
+ // src/shared/routes.ts
14995
+ var ROOTS_API_PATH = "/api/over-ssh/roots";
14996
+ var SESSION_API_PATH = "/api/over-ssh/sessions";
14997
+ function asRouteHost(value) {
14998
+ if (typeof value !== "object" || value === null) return void 0;
14999
+ const register = value.register;
15000
+ return typeof register === "function" ? value : void 0;
15001
+ }
15002
+
15003
+ // src/host/app/roots/roots-api-contract.ts
15004
+ var ROOTS_API_MAX_BODY_BYTES = 64 * 1024;
15005
+ var DIRECTORY_LIST_MAX_ENTRIES = 2e3;
15006
+ var REMOTE_LIST_MAX_ENTRIES = DIRECTORY_LIST_MAX_ENTRIES;
15007
+ var LOCAL_LIST_MAX_ENTRIES = DIRECTORY_LIST_MAX_ENTRIES;
15008
+ var ROOTS_API_ACTIONS = [
15009
+ "add-root",
15010
+ "remove-root",
15011
+ "add-connection",
15012
+ "update-connection",
15013
+ "test-connection",
15014
+ "remove-connection",
14299
15015
  "list-remote",
14300
15016
  "list-local",
14301
15017
  "list-ssh-config",
@@ -14369,9 +15085,25 @@ function publicRoot(raw) {
14369
15085
  ...typeof source["connectionId"] === "string" ? { connectionId: source["connectionId"] } : {},
14370
15086
  path: String(source["path"] ?? ""),
14371
15087
  ...typeof source["label"] === "string" ? { label: source["label"] } : {},
14372
- ...typeof source["writable"] === "boolean" ? { writable: source["writable"] } : {}
15088
+ ...typeof source["writable"] === "boolean" ? { writable: source["writable"] } : {},
15089
+ ...stringList(source["workspaces"]) === void 0 ? {} : { workspaces: stringList(source["workspaces"]) },
15090
+ ...stringList(source["sessions"]) === void 0 ? {} : { sessions: stringList(source["sessions"]) }
14373
15091
  };
14374
15092
  }
15093
+ function liveRootScopes(raw) {
15094
+ const outer = asRecord2(raw) ?? {};
15095
+ const source = asRecord2(outer["root"]) ?? outer;
15096
+ const sessions = stringList(source["sessions"]);
15097
+ const workspaces = stringList(source["workspaces"]);
15098
+ return {
15099
+ ...sessions === void 0 ? {} : { sessions },
15100
+ ...workspaces === void 0 ? {} : { workspaces }
15101
+ };
15102
+ }
15103
+ function stringList(value) {
15104
+ if (!Array.isArray(value)) return void 0;
15105
+ return value.filter((item) => typeof item === "string");
15106
+ }
14375
15107
  function publicConnection(raw) {
14376
15108
  const source = asRecord2(raw) ?? {};
14377
15109
  const authKind = source["authKind"] ?? asRecord2(source["auth"])?.["kind"];
@@ -14497,21 +15229,34 @@ async function handleRootActions(action, body, req, res, deps) {
14497
15229
  if (registeredNow) await deps.unregisterRoot(key);
14498
15230
  throw error;
14499
15231
  }
15232
+ const warnings = [];
15233
+ if (existing !== void 0) {
15234
+ const warning = await liveScopeWarning(deps, key, state);
15235
+ if (warning !== void 0) warnings.push(warning);
15236
+ }
14500
15237
  const anchorPath = await anchorPathOf(deps, entry);
14501
15238
  send(req, res, 200, {
14502
15239
  ok: true,
14503
15240
  action,
14504
15241
  state: publicUserState(state),
14505
- ...anchorPath === void 0 ? {} : { anchorPath }
15242
+ ...anchorPath === void 0 ? {} : { anchorPath },
15243
+ ...warnings.length === 0 ? {} : { warnings }
14506
15244
  });
14507
15245
  return true;
14508
- return true;
14509
15246
  });
14510
15247
  }
14511
15248
  case "remove-root": {
14512
15249
  const key = requiredKey(body["key"]);
14513
15250
  const workspace = optionalWorkspaceName(body["workspace"]);
14514
15251
  const session = optionalSessionName(body["session"]);
15252
+ if (session !== void 0) {
15253
+ const current = await currentUserState(deps);
15254
+ const entry = findRootInState(current, key);
15255
+ const owners = entry?.sessions ?? [];
15256
+ if (owners.length > 0 && !owners.includes(session)) {
15257
+ throw new SshDevError("ROOT_INVALID", "\u8FD9\u4E2A\u76EE\u5F55\u662F\u53E6\u4E00\u4E2A\u4F1A\u8BDD\u6DFB\u52A0\u7684\uFF1B\u8BF7\u5230\u6DFB\u52A0\u5B83\u7684\u4F1A\u8BDD\u91CC\u79FB\u9664", { key });
15258
+ }
15259
+ }
14515
15260
  const state = await reconcile(deps, (current) => {
14516
15261
  if (session !== void 0 && rootAddedBySession(current, key, session)) {
14517
15262
  return removeRootFromSession(current, key, session);
@@ -14529,30 +15274,6 @@ async function handleRootActions(action, body, req, res, deps) {
14529
15274
  return false;
14530
15275
  }
14531
15276
  }
14532
- async function refreshLiveScope(deps, key, state) {
14533
- const persisted = findRootInState(state, key);
14534
- if (persisted === void 0) return;
14535
- const raw = await Promise.resolve(deps.listRoots());
14536
- const liveEntry = raw.find((candidate) => liveKeyOfRecord(candidate, persisted) !== void 0);
14537
- if (liveEntry === void 0) return;
14538
- if (liveScopeIsCurrent(liveEntry, persisted)) return;
14539
- const liveKey = liveKeyOfRecord(liveEntry, persisted);
14540
- if (liveKey === void 0) return;
14541
- await deps.unregisterRoot(liveKey);
14542
- await deps.registerRoot(persisted);
14543
- }
14544
- function liveScopeIsCurrent(raw, entry) {
14545
- const outer = asRecord2(raw) ?? {};
14546
- const source = asRecord2(outer["root"]) ?? outer;
14547
- if (source["shared"] === true) return true;
14548
- return sameStrings(source["workspaces"], entry.workspaces) && sameStrings(source["sessions"], entry.sessions);
14549
- }
14550
- function sameStrings(left, right) {
14551
- const asSet = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string").sort() : [];
14552
- const a = asSet(left);
14553
- const b = asSet(right);
14554
- return a.length === b.length && a.every((value, index) => value === b[index]);
14555
- }
14556
15277
  function rootAddedBySession(state, key, sessionId) {
14557
15278
  const entry = findRootInState(state, key);
14558
15279
  return (entry?.sessions ?? []).includes(sessionId);
@@ -14607,129 +15328,6 @@ function optionalWorkspaceName(value) {
14607
15328
  // src/host/app/roots/roots-api-connections.ts
14608
15329
  init_errors();
14609
15330
  import { homedir as homedir3 } from "node:os";
14610
-
14611
- // src/host/domain/roots/ssh-config.ts
14612
- function stripComment(line) {
14613
- let inQuotes = false;
14614
- for (let index = 0; index < line.length; index++) {
14615
- const char = line[index];
14616
- if (char === '"') inQuotes = !inQuotes;
14617
- else if (char === "#" && !inQuotes) return line.slice(0, index);
14618
- }
14619
- return line;
14620
- }
14621
- function unquote(value) {
14622
- if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
14623
- return value;
14624
- }
14625
- function splitKeyValue(line) {
14626
- const match = /^([A-Za-z][A-Za-z0-9]*)\s*(?:=|\s)\s*(.*)$/.exec(line);
14627
- if (match === null) return void 0;
14628
- const key = match[1];
14629
- const value = match[2];
14630
- if (key === void 0 || value === void 0) return void 0;
14631
- return [key.toLowerCase(), unquote(value.trim())];
14632
- }
14633
- function parseSshConfig(text8) {
14634
- const entries = [];
14635
- let current;
14636
- const lines = text8.split(/\r?\n/);
14637
- for (let index = 0; index < lines.length; index++) {
14638
- const raw = lines[index] ?? "";
14639
- const line = stripComment(raw).trim();
14640
- if (line === "") continue;
14641
- const pair = splitKeyValue(line);
14642
- if (pair === void 0) continue;
14643
- const [key, value] = pair;
14644
- if (key === "host") {
14645
- const patterns = value.split(/\s+/).filter((part) => part !== "");
14646
- if (patterns.length === 0) continue;
14647
- current = { patterns, identityFiles: [], includes: [], line: index + 1 };
14648
- entries.push(current);
14649
- continue;
14650
- }
14651
- if (current === void 0) continue;
14652
- switch (key) {
14653
- case "hostname":
14654
- current.hostName ??= value;
14655
- break;
14656
- case "user":
14657
- current.user ??= value;
14658
- break;
14659
- case "port": {
14660
- if (current.port !== void 0) break;
14661
- const port = Number.parseInt(value, 10);
14662
- if (Number.isInteger(port) && port > 0 && port <= 65535) current.port = port;
14663
- break;
14664
- }
14665
- case "identityfile":
14666
- current.identityFiles.push(value);
14667
- break;
14668
- case "proxyjump":
14669
- current.proxyJump ??= value.toLowerCase() === "none" ? "" : value;
14670
- break;
14671
- case "include":
14672
- current.includes.push(value);
14673
- break;
14674
- default:
14675
- break;
14676
- }
14677
- }
14678
- return entries.map((entry) => ({
14679
- patterns: entry.patterns,
14680
- ...entry.hostName === void 0 ? {} : { hostName: entry.hostName },
14681
- ...entry.user === void 0 ? {} : { user: entry.user },
14682
- ...entry.port === void 0 ? {} : { port: entry.port },
14683
- identityFiles: entry.identityFiles,
14684
- ...entry.proxyJump === void 0 || entry.proxyJump === "" ? {} : { proxyJump: entry.proxyJump },
14685
- includes: entry.includes,
14686
- line: entry.line
14687
- }));
14688
- }
14689
- function isConcretePattern(pattern) {
14690
- return !/[*?!]/.test(pattern);
14691
- }
14692
- function expandHome2(path, home) {
14693
- if (path === "~") return home;
14694
- if (path.startsWith("~/") || path.startsWith("~\\")) return `${home.replace(/[\\/]+$/, "")}${path.slice(1)}`;
14695
- return path;
14696
- }
14697
- function importableEntries(entries, options = {}) {
14698
- const home = options.home ?? "";
14699
- const excluded = new Set(options.excluded ?? []);
14700
- const seen = /* @__PURE__ */ new Set();
14701
- const drafts = [];
14702
- const skipped = [];
14703
- for (const entry of entries) {
14704
- for (const pattern of entry.patterns) {
14705
- if (!isConcretePattern(pattern)) {
14706
- skipped.push({ pattern, reason: "wildcard", line: entry.line });
14707
- continue;
14708
- }
14709
- if (seen.has(pattern)) {
14710
- skipped.push({ pattern, reason: "duplicate", line: entry.line });
14711
- continue;
14712
- }
14713
- if (excluded.has(pattern)) {
14714
- skipped.push({ pattern, reason: "already-imported", line: entry.line });
14715
- continue;
14716
- }
14717
- seen.add(pattern);
14718
- const identityFile = entry.identityFiles[0];
14719
- drafts.push({
14720
- alias: pattern,
14721
- hostName: entry.hostName ?? pattern,
14722
- ...entry.user === void 0 ? {} : { user: entry.user },
14723
- ...entry.port === void 0 ? {} : { port: entry.port },
14724
- ...identityFile === void 0 ? {} : { identityFile: home === "" ? identityFile : expandHome2(identityFile, home) },
14725
- ...entry.proxyJump === void 0 ? {} : { proxyJump: entry.proxyJump }
14726
- });
14727
- }
14728
- }
14729
- return { drafts, skipped };
14730
- }
14731
-
14732
- // src/host/app/roots/roots-api-connections.ts
14733
15331
  async function handleConnectionActions(action, body, req, res, deps) {
14734
15332
  switch (action) {
14735
15333
  case "list-ssh-config": {
@@ -14748,14 +15346,12 @@ async function handleConnectionActions(action, body, req, res, deps) {
14748
15346
  }
14749
15347
  if (wanted.length === 0) throw new SshDevError("CONNECTION_INVALID", "\u6CA1\u6709\u9009\u62E9\u8981\u5BFC\u5165\u7684\u522B\u540D", {});
14750
15348
  const before = await currentUserState(deps);
14751
- const found = await sshConfigCandidates(deps, before.connections.map((entry) => entry.id));
14752
- if (found === void 0) {
15349
+ const plan = await planSshImport(deps, before.connections.map((entry) => entry.id));
15350
+ if (plan === void 0) {
14753
15351
  throw new SshDevError("CONNECTION_INVALID", "\u8FD9\u4E2A\u5BBF\u4E3B\u7EC4\u5408\u6CA1\u6709\u63D0\u4F9B ~/.ssh/config \u7684\u8BFB\u53D6\u80FD\u529B", {});
14754
15352
  }
14755
- const byAlias = new Map(found.candidates.map((candidate) => [candidate.alias, candidate]));
14756
- const text8 = await deps.readSshConfig?.();
14757
- const drafts = importableEntries(parseSshConfig(text8 ?? ""), { home: homedir3() }).drafts;
14758
- const draftByAlias = new Map(drafts.map((draft) => [draft.alias, draft]));
15353
+ const byAlias = new Map(plan.candidates.map((candidate) => [candidate.alias, candidate]));
15354
+ const draftByAlias = new Map(plan.drafts.map((draft) => [draft.alias, draft]));
14759
15355
  const accepted = [];
14760
15356
  const refused = [];
14761
15357
  const seenIds = new Set(before.connections.map((entry) => entry.id));
@@ -14773,7 +15369,10 @@ async function handleConnectionActions(action, body, req, res, deps) {
14773
15369
  seenIds.add(candidate.suggestedId);
14774
15370
  accepted.push(connectionRecordFromCandidate(draft, candidate.suggestedId));
14775
15371
  }
14776
- for (const record2 of accepted) await deps.addConnection(record2);
15372
+ if (accepted.length > 0) {
15373
+ if (deps.addConnections !== void 0) await deps.addConnections(accepted);
15374
+ else for (const record2 of accepted) await deps.addConnection(record2);
15375
+ }
14777
15376
  const state = await reconcile(deps, (current) => {
14778
15377
  let next = current;
14779
15378
  for (const record2 of accepted) {
@@ -15015,11 +15614,11 @@ function publicProbeResult(raw) {
15015
15614
  ms
15016
15615
  };
15017
15616
  }
15018
- async function sshConfigCandidates(deps, excluded) {
15617
+ async function planSshImport(deps, excluded) {
15019
15618
  const read = deps.readSshConfig;
15020
15619
  if (read === void 0) return void 0;
15021
15620
  const text8 = await read();
15022
- if (text8 === void 0 || text8.trim() === "") return { candidates: [], skipped: [] };
15621
+ if (text8 === void 0 || text8.trim() === "") return { candidates: [], skipped: [], drafts: [] };
15023
15622
  const result = importableEntries(parseSshConfig(text8), { home: homedir3(), excluded });
15024
15623
  const taken = new Set(excluded);
15025
15624
  const candidates = [];
@@ -15045,7 +15644,12 @@ async function sshConfigCandidates(deps, excluded) {
15045
15644
  suggestedId
15046
15645
  });
15047
15646
  }
15048
- return { candidates, skipped };
15647
+ return { candidates, skipped, drafts: result.drafts };
15648
+ }
15649
+ async function sshConfigCandidates(deps, excluded) {
15650
+ const plan = await planSshImport(deps, excluded);
15651
+ if (plan === void 0) return void 0;
15652
+ return { candidates: plan.candidates, skipped: plan.skipped };
15049
15653
  }
15050
15654
  function connectionRecordFromCandidate(draft, id) {
15051
15655
  const keyPath = draft.identityFile === void 0 || draft.identityFile === "" ? void 0 : draft.identityFile;
@@ -15334,16 +15938,40 @@ async function serveSnapshot(req, res, deps) {
15334
15938
  Promise.resolve(deps.listConnections()),
15335
15939
  Promise.resolve(deps.loadUserState())
15336
15940
  ]);
15941
+ const query = queryOf(req.url);
15942
+ const requestedSession = (query.get("session") ?? "").trim();
15943
+ const requestedWorkspace = (query.get("workspace") ?? "").trim();
15944
+ const ancestorsOf = deps.resolveSessionAncestors;
15945
+ const canFilter = requestedWorkspace !== "" || ancestorsOf !== void 0;
15946
+ const filtered = !canFilter || requestedSession === "" && requestedWorkspace === "" ? void 0 : {
15947
+ ids: new Set(
15948
+ requestedSession === "" ? [] : [requestedSession, ...ancestorsOf?.(requestedSession) ?? []]
15949
+ ),
15950
+ workspace: requestedWorkspace
15951
+ };
15952
+ const roots = filtered === void 0 ? liveRoots.map(publicRoot) : liveRoots.filter((raw) => visibleToSession(liveRootScopes(raw), filtered.ids, filtered.workspace)).map(publicRoot);
15337
15953
  send(req, res, 200, {
15338
15954
  ok: true,
15955
+ // 两个标记分开写:客户端只认 `scopedForSession`,而只带 workspace 的调用方也能看出"这份列表被过滤过"。
15956
+ ...filtered === void 0 ? {} : requestedSession === "" ? { scopedForWorkspace: requestedWorkspace } : { scopedForSession: requestedSession },
15339
15957
  // The plugin's **anchor root**, not the roots' individual anchor paths: see `RootsApiDeps.anchorRootDir`.
15340
15958
  ...deps.anchorRootDir === void 0 ? {} : { anchorRoot: deps.anchorRootDir },
15341
- roots: liveRoots.map(publicRoot),
15959
+ roots,
15342
15960
  connections: liveConnections.map(publicConnection),
15343
15961
  userRoots: publicUserState(loaded.state),
15344
15962
  warnings: [...loaded.warnings]
15345
15963
  });
15346
15964
  }
15965
+ function queryOf(url) {
15966
+ const index = url === void 0 ? -1 : url.indexOf("?");
15967
+ return new URLSearchParams(index === -1 || url === void 0 ? "" : url.slice(index + 1));
15968
+ }
15969
+ function visibleToSession(row, sessionIds, workspace) {
15970
+ const sessions = row.sessions ?? [];
15971
+ if (sessions.length > 0) return sessions.some((id) => sessionIds.has(id));
15972
+ if (workspace === "") return false;
15973
+ return (row.workspaces ?? []).some((candidate) => sameWorkspacePath(candidate, workspace));
15974
+ }
15347
15975
  async function serveAction(req, res, deps) {
15348
15976
  let text8;
15349
15977
  try {
@@ -15399,7 +16027,7 @@ async function serveAction(req, res, deps) {
15399
16027
 
15400
16028
  // src/host/integration/session/session-bin-lock.ts
15401
16029
  init_errors();
15402
- import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat3 } from "node:fs/promises";
16030
+ import { mkdir as mkdir5, open as open3, readFile as readFile6, rm as rm3, stat as stat5 } from "node:fs/promises";
15403
16031
  import { dirname as dirname5 } from "node:path";
15404
16032
  var DEFAULT_STALE_LOCK_MS = 6e4;
15405
16033
  var DEFAULT_LOCK_RETRY_MS = 25;
@@ -15457,21 +16085,35 @@ function createSessionBinLock(options) {
15457
16085
  const reclaimIfStale = async () => {
15458
16086
  if (staleMs <= 0) return false;
15459
16087
  let age;
16088
+ let malformed = false;
15460
16089
  try {
15461
- const info = await stat3(options.path);
16090
+ const info = await stat5(options.path);
16091
+ malformed = info.isDirectory();
15462
16092
  age = now() - info.mtimeMs;
15463
16093
  } catch {
15464
16094
  return true;
15465
16095
  }
15466
- if (age < staleMs) return false;
15467
- try {
15468
- const text8 = await readFile6(options.path, "utf8");
15469
- const record2 = JSON.parse(text8);
15470
- options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
15471
- } catch {
16096
+ if (!malformed && age < staleMs) return false;
16097
+ if (!malformed) {
16098
+ try {
16099
+ const text8 = await readFile6(options.path, "utf8");
16100
+ const record2 = JSON.parse(text8);
16101
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: record2 });
16102
+ } catch {
16103
+ options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
16104
+ }
16105
+ } else {
15472
16106
  options.onReclaim?.({ path: options.path, ageMs: age, holder: {} });
15473
16107
  }
15474
- await rm3(options.path, { force: true });
16108
+ try {
16109
+ await rm3(options.path, { force: true, recursive: malformed });
16110
+ } catch (error) {
16111
+ throw new SshDevError("TRANSFER_CONFLICT", `\u4F1A\u8BDD\u56DE\u6536\u7AD9\u9501\u4F4D\u7F6E\u662F\u4E00\u4E2A\u76EE\u5F55\uFF0C\u4E14\u65E0\u6CD5\u5220\u9664\uFF1A${options.path}`, {
16112
+ path: options.path,
16113
+ reason: "malformed-lock",
16114
+ cause: error instanceof Error ? error.message : String(error)
16115
+ });
16116
+ }
15475
16117
  return true;
15476
16118
  };
15477
16119
  const withLock = async (_key, operation) => {
@@ -15537,6 +16179,12 @@ function createRootsWiring(deps) {
15537
16179
  const transfers = deps.transfers;
15538
16180
  const readCredentials = deps.readCredentials;
15539
16181
  const testConnection = deps.testConnection;
16182
+ const sessionDirectories = createSessionDirectories({
16183
+ mutateUserState: (transition) => store2.mutate(transition),
16184
+ listRoots: () => deps.listRoots(),
16185
+ registerRoot: deps.registerRoot,
16186
+ unregisterRoot: deps.unregisterRoot
16187
+ });
15540
16188
  const apiDeps = {
15541
16189
  listRoots: () => deps.listRoots(),
15542
16190
  listConnections: () => deps.listConnections(),
@@ -15553,6 +16201,39 @@ function createRootsWiring(deps) {
15553
16201
  deps.applyConnection(entry, entry.id);
15554
16202
  return next;
15555
16203
  },
16204
+ addConnections: async (entries) => {
16205
+ if (entries.length === 0) return store2.load().then((loaded) => loaded.state);
16206
+ const applied = [];
16207
+ try {
16208
+ for (const entry of entries) {
16209
+ deps.applyConnection(entry, entry.id);
16210
+ applied.push(entry.id);
16211
+ }
16212
+ } catch (error) {
16213
+ for (const id of applied) {
16214
+ try {
16215
+ deps.removeConnection(id);
16216
+ } catch {
16217
+ }
16218
+ }
16219
+ throw error;
16220
+ }
16221
+ try {
16222
+ return await store2.mutate((state) => {
16223
+ let next = state;
16224
+ for (const entry of entries) next = stateHasConnection(next, entry.id) ? next : addConnectionToState(next, entry);
16225
+ return next;
16226
+ });
16227
+ } catch (error) {
16228
+ for (const id of applied) {
16229
+ try {
16230
+ deps.removeConnection(id);
16231
+ } catch {
16232
+ }
16233
+ }
16234
+ throw error;
16235
+ }
16236
+ },
15556
16237
  applyConnection: async (entry, previousId) => {
15557
16238
  deps.applyConnection(entry, previousId);
15558
16239
  },
@@ -15560,6 +16241,7 @@ function createRootsWiring(deps) {
15560
16241
  deps.removeConnection(id);
15561
16242
  },
15562
16243
  ...deps.anchorRootDir === void 0 ? {} : { anchorRootDir: deps.anchorRootDir },
16244
+ ...deps.resolveSessionAncestors === void 0 ? {} : { resolveSessionAncestors: deps.resolveSessionAncestors },
15563
16245
  ...listRemoteDirectory === void 0 ? {} : { listRemoteDirectory: (input) => listRemoteDirectory(input) },
15564
16246
  ...remoteHomeDirectory === void 0 ? {} : { remoteHomeDirectory },
15565
16247
  ...listLocalDirectory === void 0 ? {} : { listLocalDirectory: (input) => listLocalDirectory(input) },
@@ -15633,9 +16315,55 @@ function createRootsWiring(deps) {
15633
16315
  }
15634
16316
  return { roots, connections, warnings };
15635
16317
  };
16318
+ const inheritAddedRoots = async (sourceSessionId, childSessionId) => {
16319
+ const source = sourceSessionId.trim();
16320
+ const child = childSessionId.trim();
16321
+ if (source === "" || child === "") return;
16322
+ const keys = [];
16323
+ await store2.mutate((current) => {
16324
+ let next = current;
16325
+ for (const entry of current.roots) {
16326
+ const sessions = entry.sessions ?? [];
16327
+ if (!sessions.includes(source) || sessions.includes(child)) continue;
16328
+ const key = userRootKey(entry);
16329
+ next = withSessions(next, key, [child]);
16330
+ keys.push(key);
16331
+ }
16332
+ return next;
16333
+ });
16334
+ if (keys.length > 0) {
16335
+ const { state } = await store2.load();
16336
+ for (const key of keys) await refreshLiveScope(apiDeps, key, state);
16337
+ }
16338
+ };
16339
+ const pruneSession = async (sessionId) => {
16340
+ const trimmed = sessionId.trim();
16341
+ if (trimmed === "") return { changed: [], removed: [] };
16342
+ const before = (await store2.load()).state;
16343
+ const touched = before.roots.filter((entry) => (entry.sessions ?? []).includes(trimmed));
16344
+ if (touched.length === 0) return { changed: [], removed: [] };
16345
+ const after = await store2.mutate((current) => pruneSessionFromRoots(current, trimmed));
16346
+ const afterKeys = new Set(after.roots.map(userRootKey));
16347
+ const removed = [];
16348
+ const changed = [];
16349
+ for (const entry of touched) {
16350
+ const key = userRootKey(entry);
16351
+ if (!afterKeys.has(key)) {
16352
+ removed.push(key);
16353
+ await deps.unregisterRoot(key);
16354
+ } else {
16355
+ changed.push(key);
16356
+ }
16357
+ }
16358
+ for (const key of changed) await refreshLiveScope(apiDeps, key, after);
16359
+ return { changed, removed };
16360
+ };
15636
16361
  return {
15637
16362
  api,
15638
16363
  store: store2,
16364
+ sessionDirectories,
16365
+ inheritAddedRoots,
16366
+ pruneSession,
15639
16367
  mergeUserRoots,
15640
16368
  routeWarnings: () => [...routeWarnings],
15641
16369
  routeFacts: () => ({ injectRan, routeRegistered, warnings: [...routeWarnings] }),
@@ -15778,13 +16506,34 @@ function createSessionApi(deps) {
15778
16506
  return { handle, dispose: () => {
15779
16507
  } };
15780
16508
  }
16509
+ function abortOnDisconnect(res) {
16510
+ const controller = new AbortController();
16511
+ const closable = res;
16512
+ const onClose = () => {
16513
+ if (closable.writableEnded !== true) controller.abort();
16514
+ };
16515
+ if (typeof closable.on === "function") closable.on("close", onClose);
16516
+ return {
16517
+ signal: controller.signal,
16518
+ dispose: () => {
16519
+ if (typeof closable.off === "function") closable.off("close", onClose);
16520
+ }
16521
+ };
16522
+ }
15781
16523
  async function serveSnapshot2(req, res, deps) {
15782
16524
  const service = deps.service();
15783
16525
  if (service === void 0) {
15784
16526
  send(req, res, 200, { ok: true, available: false, sessions: [], bin: [], policy: deps.policy() });
15785
16527
  return;
15786
16528
  }
15787
- const [sessions, bin] = await Promise.all([service.inventory(), service.list()]);
16529
+ const connection = abortOnDisconnect(res);
16530
+ let sessions;
16531
+ let bin;
16532
+ try {
16533
+ [sessions, bin] = await Promise.all([service.inventory({ signal: connection.signal }), service.list()]);
16534
+ } finally {
16535
+ connection.dispose();
16536
+ }
15788
16537
  send(req, res, 200, {
15789
16538
  ok: true,
15790
16539
  available: true,
@@ -15801,10 +16550,19 @@ async function serveAction2(req, res, deps) {
15801
16550
  return;
15802
16551
  }
15803
16552
  const service = requireService(deps);
16553
+ const connection = abortOnDisconnect(res);
16554
+ try {
16555
+ return await serveActionWithSignal(action, body, req, res, deps, service, connection.signal);
16556
+ } finally {
16557
+ connection.dispose();
16558
+ }
16559
+ }
16560
+ async function serveActionWithSignal(action, body, req, res, deps, service, signal) {
16561
+ const options = { signal };
15804
16562
  switch (action) {
15805
16563
  case "preview": {
15806
16564
  const ids = readIds(body);
15807
- const decision = await service.previewDelete(ids);
16565
+ const decision = await service.previewDelete(ids, options);
15808
16566
  send(req, res, 200, { ok: true, delete: decision.delete, refusals: decision.refusals, bytes: decision.bytes });
15809
16567
  return;
15810
16568
  }
@@ -15819,7 +16577,7 @@ async function serveAction2(req, res, deps) {
15819
16577
  });
15820
16578
  return;
15821
16579
  }
15822
- const result = await service.deleteSession(ids, { mode });
16580
+ const result = await service.deleteSession(ids, { mode, ...options });
15823
16581
  send(req, res, 200, {
15824
16582
  ok: true,
15825
16583
  mode: result.mode,
@@ -15847,7 +16605,7 @@ async function serveAction2(req, res, deps) {
15847
16605
  });
15848
16606
  return;
15849
16607
  }
15850
- const result = await service.deleteWorkspaceSessions(cwd, { mode });
16608
+ const result = await service.deleteWorkspaceSessions(cwd, { mode, ...options });
15851
16609
  send(req, res, 200, {
15852
16610
  ok: true,
15853
16611
  mode: result.mode,
@@ -15867,7 +16625,7 @@ async function serveAction2(req, res, deps) {
15867
16625
  send(req, res, 400, { ok: false, error: "entryId \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32" });
15868
16626
  return;
15869
16627
  }
15870
- const result = await service.restore(entryId);
16628
+ const result = await service.restore(entryId, options);
15871
16629
  send(req, res, 200, { ok: true, restoredPath: result.restoredPath, movedBy: result.movedBy });
15872
16630
  return;
15873
16631
  }
@@ -15881,14 +16639,27 @@ async function serveAction2(req, res, deps) {
15881
16639
  send(req, res, 400, { ok: false, error: "purge \u9700\u8981 confirm: true\uFF08\u4E0D\u53EF\u6062\u590D\uFF09", reason: "confirmation-required" });
15882
16640
  return;
15883
16641
  }
15884
- const result = await service.purge(entryId);
15885
- send(req, res, 200, { ok: true, purged: result.purged, indexStale: result.indexStale });
16642
+ const result = await service.purge(entryId, options);
16643
+ send(req, res, 200, {
16644
+ ...result.indexStale === true ? { ok: false, error: "\u56DE\u6536\u7AD9\u7D22\u5F15\u66F4\u65B0\u5931\u8D25\uFF1A\u6761\u76EE\u5DF2\u5220\u9664\uFF0C\u4F46\u7D22\u5F15\u4ECD\u5217\u7740\u5B83\uFF08\u4E0B\u6B21\u8FD8\u539F\u4F1A\u5931\u8D25\uFF09\u3002\u91CD\u542F\u540E\u91CD\u8BD5 sweep\u3002", reason: "index-stale" } : { ok: true },
16645
+ purged: result.purged,
16646
+ indexStale: result.indexStale
16647
+ });
15886
16648
  return;
15887
16649
  }
15888
16650
  case "sweep": {
15889
- const result = await service.sweep(deps.policy());
16651
+ if (body["confirm"] !== true) {
16652
+ send(req, res, 400, {
16653
+ ok: false,
16654
+ error: "sweep \u9700\u8981 confirm: true\uFF08\u4E0D\u53EF\u6062\u590D\uFF09",
16655
+ reason: "confirmation-required"
16656
+ });
16657
+ return;
16658
+ }
16659
+ const result = await service.sweep(deps.policy(), options);
16660
+ const stale = result.purged.filter((item) => item.indexStale === true).length;
15890
16661
  send(req, res, 200, {
15891
- ok: true,
16662
+ ...stale > 0 ? { ok: false, error: `\u56DE\u6536\u7AD9\u7D22\u5F15\u66F4\u65B0\u5931\u8D25\uFF1A${stale} \u4E2A\u6761\u76EE\u5DF2\u5220\u9664\u4F46\u7D22\u5F15\u4ECD\u5217\u7740\u5B83\u4EEC\uFF08\u4E0B\u6B21\u8FD8\u539F\u4F1A\u5931\u8D25\uFF09\u3002`, reason: "index-stale" } : { ok: true },
15892
16663
  purged: result.purged.map((item) => ({ entryId: String(item.id), sessionId: item.sessionId, indexStale: item.indexStale })),
15893
16664
  failed: result.failed.map((item) => ({ entryId: String(item.id), reason: item.reason })),
15894
16665
  remainingEntries: result.remainingEntries,
@@ -15963,6 +16734,56 @@ function createSessionRouteWiring(deps) {
15963
16734
  };
15964
16735
  }
15965
16736
 
16737
+ // src/host/integration/roots/fork-inheritance.ts
16738
+ var PATCHED = /* @__PURE__ */ Symbol.for("dsh-over-ssh/fork-inheritance");
16739
+ function sourceIdOf(source) {
16740
+ if (typeof source === "string") return source === "" ? void 0 : source;
16741
+ if (typeof source !== "object" || source === null) return void 0;
16742
+ const candidate = source;
16743
+ const id = candidate.header?.id ?? candidate.id;
16744
+ return typeof id === "string" && id !== "" ? id : void 0;
16745
+ }
16746
+ function installForkInheritance(ctx, deps) {
16747
+ const service = ctx.get("sessions");
16748
+ const original = service?.fork;
16749
+ if (typeof service !== "object" || service === null || typeof original !== "function") {
16750
+ return { installed: false, dispose: () => void 0 };
16751
+ }
16752
+ if (original[PATCHED] === true) {
16753
+ return { installed: true, dispose: () => void 0 };
16754
+ }
16755
+ const invoke = original;
16756
+ const patched2 = function patchedFork(...args) {
16757
+ const child = invoke.apply(this, args);
16758
+ try {
16759
+ const sourceId = sourceIdOf(args[0]);
16760
+ const childId = sourceIdOf(child);
16761
+ if (sourceId !== void 0 && childId !== void 0) {
16762
+ markForkInheritancePending(childId, sourceId);
16763
+ void deps.inherit(sourceId, childId).catch((error) => {
16764
+ deps.warn?.(`fork \u7EE7\u627F\u6DFB\u52A0\u76EE\u5F55\u5931\u8D25\uFF08source=${sourceId} child=${childId}\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
16765
+ }).finally(() => {
16766
+ clearForkInheritancePending(childId);
16767
+ });
16768
+ }
16769
+ } catch (error) {
16770
+ deps.warn?.(`fork \u7EE7\u627F\u6DFB\u52A0\u76EE\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
16771
+ }
16772
+ return child;
16773
+ };
16774
+ patched2[PATCHED] = true;
16775
+ service.fork = patched2;
16776
+ return {
16777
+ installed: true,
16778
+ dispose: () => {
16779
+ if (service.fork === patched2) {
16780
+ service.fork = original;
16781
+ }
16782
+ clearAllForkInheritancePending();
16783
+ }
16784
+ };
16785
+ }
16786
+
15966
16787
  // src/wiring/fs-panel-routes.ts
15967
16788
  function readOnlySeams(env) {
15968
16789
  const { ctx, profiles, registry, transports } = env;
@@ -16008,10 +16829,15 @@ function rootLifecycleSeams(env, hooks) {
16008
16829
  const removed = registry.removeByLooseKey(key);
16009
16830
  binRootResolver.forget(removed ?? key);
16010
16831
  sessionRoots.invalidate();
16011
- if (anchor !== void 0) void rmdir(anchor).catch(() => void 0);
16832
+ if (anchor !== void 0 && !anchorStillUsedBy(registry.records, anchor)) {
16833
+ void rmdir(anchor).catch(() => void 0);
16834
+ }
16012
16835
  }
16013
16836
  };
16014
16837
  }
16838
+ function anchorStillUsedBy(records, anchor) {
16839
+ return records.some((record2) => record2.root.anchorPath === anchor);
16840
+ }
16015
16841
  function anchorPathOfRecord(records, key) {
16016
16842
  const target = key.toLowerCase();
16017
16843
  for (const record2 of records) {
@@ -16030,18 +16856,18 @@ function connectionLifecycleSeams(env, hooks) {
16030
16856
  // Keeping them across an edit answered for the previous host (audit finding: changing a connection's
16031
16857
  // host left its remote roots connected to the old machine); keeping them across a rename answered under
16032
16858
  // the new name for the old host.
16033
- applyConnection: (entry, previousId) => {
16859
+ applyConnection: async (entry, previousId) => {
16034
16860
  const profile = validateConnectionProfile(toProfileInput({ ...entry, transport: "agent" }));
16035
16861
  profiles.set(profile.id, profile);
16036
16862
  if (previousId !== profile.id) {
16037
16863
  profiles.delete(previousId);
16038
- hooks.forgetPerConnection(previousId);
16864
+ await hooks.forgetPerConnection(previousId);
16039
16865
  indexes.delete(previousId);
16040
16866
  retireBrowseBackend(browseBackends, previousId);
16041
16867
  forgetBackendsOf(previousId);
16042
16868
  void transports.release(previousId).catch(() => void 0);
16043
16869
  }
16044
- hooks.forgetPerConnection(profile.id);
16870
+ await hooks.forgetPerConnection(profile.id);
16045
16871
  askpass.revive(profile.id);
16046
16872
  indexes.delete(profile.id);
16047
16873
  retireBrowseBackend(browseBackends, profile.id);
@@ -16175,6 +17001,8 @@ function registerPanelRoutes(env, hooks) {
16175
17001
  stateDir: resolveStateDir(),
16176
17002
  // Published in the GET snapshot so a client can recognise a session whose cwd is a remote workspace.
16177
17003
  anchorRootDir: env.anchorRootDir,
17004
+ // fork 继承父会话添加的目录(ADR-0013):血缘只有宿主知道,所以 `GET /roots?session=` 由这里回答。
17005
+ resolveSessionAncestors: (sessionId) => readSessionAncestors(env.ctx, sessionId),
16178
17006
  ...readOnlySeams(env),
16179
17007
  ...rootLifecycleSeams(env, hooks),
16180
17008
  ...connectionLifecycleSeams(env, hooks),
@@ -16182,7 +17010,11 @@ function registerPanelRoutes(env, hooks) {
16182
17010
  ...remoteFileSeams(env),
16183
17011
  webServerProvider: ctx
16184
17012
  });
16185
- return { sessionWiring, rootsWiring };
17013
+ const forkInheritance = installForkInheritance(ctx, {
17014
+ inherit: (sourceSessionId, childSessionId) => rootsWiring.inheritAddedRoots(sourceSessionId, childSessionId),
17015
+ warn: (message) => ctx.logger?.warn?.(`over-ssh: ${message}`)
17016
+ });
17017
+ return { sessionWiring, rootsWiring, disposeForkInheritance: () => forkInheritance.dispose() };
16186
17018
  }
16187
17019
 
16188
17020
  // src/wiring/fs.ts
@@ -16213,11 +17045,19 @@ function provideHostServices(env) {
16213
17045
  }
16214
17046
  function provideAgentStatus(env) {
16215
17047
  const { ctx, agentBridge, installations, askpass, remoteHome } = env;
16216
- const forgetPerConnection = (connectionId) => {
17048
+ const forgetPerConnectionAsync = async (connectionId) => {
16217
17049
  agentBridge.forget(connectionId);
16218
17050
  installations.forget(connectionId);
16219
- void askpass.forget(connectionId);
16220
17051
  remoteHome.forget(connectionId);
17052
+ try {
17053
+ await askpass.forget(connectionId);
17054
+ } catch {
17055
+ }
17056
+ };
17057
+ const forgetPerConnection = (connectionId) => {
17058
+ void forgetPerConnectionAsync(connectionId).catch((error) => {
17059
+ ctx.logger?.warn?.(`over-ssh: \u5FD8\u8BB0\u8FDE\u63A5 ${connectionId} \u7684\u4EE3\u7406\u5B89\u88C5\u8BB0\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
17060
+ });
16221
17061
  };
16222
17062
  const disposeAgent = ctx.provide("sshDevAgent", {
16223
17063
  outcome: (connectionId) => agentBridge.installationFor(connectionId),
@@ -16225,7 +17065,7 @@ function provideAgentStatus(env) {
16225
17065
  forgetPerConnection(connectionId);
16226
17066
  }
16227
17067
  });
16228
- return { forgetPerConnection, disposeAgent };
17068
+ return { forgetPerConnection, forgetPerConnectionAsync, disposeAgent };
16229
17069
  }
16230
17070
  function createBinHousekeeping(env) {
16231
17071
  const { ctx, limits, binService } = env;
@@ -16250,7 +17090,9 @@ function registerConfiguredRoots(env) {
16250
17090
  ctx.logger?.warn?.(`over-ssh: \u8FDC\u7AEF\u6839\u7684\u951A\u70B9\u76EE\u5F55\u65E0\u6CD5\u521B\u5EFA\uFF08${root.anchorPath ?? ""}\uFF09\uFF1A${error instanceof Error ? error.message : String(error)}`);
16251
17091
  });
16252
17092
  }
16253
- void Promise.allSettled(binRootPreparation).then(runBinHousekeeping);
17093
+ void Promise.allSettled(binRootPreparation).then(runBinHousekeeping).catch((error) => {
17094
+ ctx.logger?.warn?.(`over-ssh: \u56DE\u6536\u7AD9\u6E05\u7406\u7531\u672C\u6B21\u542F\u52A8\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`);
17095
+ });
16254
17096
  }
16255
17097
  function scheduleActivationDiagnostics(env) {
16256
17098
  const { ctx, rootsWiring, sessionWiring } = env;
@@ -16276,7 +17118,7 @@ function scheduleActivationDiagnostics(env) {
16276
17118
  function watchSessionRemovals(env) {
16277
17119
  const { ctx, rootsWiring, onSessionRemoved } = env;
16278
17120
  onSessionRemoved((sessionId) => {
16279
- void rootsWiring.store.pruneSession(sessionId).catch((error) => {
17121
+ void rootsWiring.pruneSession(sessionId).catch((error) => {
16280
17122
  ctx.logger?.warn?.(
16281
17123
  `dsh-over-ssh: \u6E05\u7406\u5DF2\u5220\u4F1A\u8BDD ${sessionId} \u7684\u6839\u8BB0\u5F55\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}`
16282
17124
  );
@@ -16292,9 +17134,10 @@ function mergePersistedRoots(env) {
16292
17134
  });
16293
17135
  }
16294
17136
  function createTeardown(env) {
16295
- const { activationTimers, routes, hostServices, disposeAgent, tools } = env;
17137
+ const { activationTimers, routes, hostServices, disposeAgent, tools, disposeSessionDirectories } = env;
16296
17138
  return async () => {
16297
17139
  for (const timer of activationTimers) clearTimeout(timer);
17140
+ routes.disposeForkInheritance();
16298
17141
  routes.rootsWiring.dispose();
16299
17142
  routes.sessionWiring.dispose();
16300
17143
  tools.disposeManagement();
@@ -16305,6 +17148,7 @@ function createTeardown(env) {
16305
17148
  await tools.disposeLsp();
16306
17149
  hostServices.disposeBin();
16307
17150
  hostServices.disposeSessionTools();
17151
+ disposeSessionDirectories();
16308
17152
  hostServices.disposeSessionRoots();
16309
17153
  hostServices.disposeRange();
16310
17154
  hostServices.disposeTransfer();
@@ -16335,7 +17179,7 @@ function wireFs(env) {
16335
17179
  sessionRoots: env.sessionRoots,
16336
17180
  readBytesRange: env.readBytesRange
16337
17181
  });
16338
- const { forgetPerConnection, disposeAgent } = provideAgentStatus({
17182
+ const { forgetPerConnectionAsync, disposeAgent } = provideAgentStatus({
16339
17183
  ctx,
16340
17184
  agentBridge: env.agentBridge,
16341
17185
  installations: env.installations,
@@ -16345,14 +17189,19 @@ function wireFs(env) {
16345
17189
  const runBinHousekeeping = createBinHousekeeping({ ctx, limits, binService: env.binService });
16346
17190
  registerConfiguredRoots({ ctx, config, anchorRootDir, registry, prepareBinRoots, runBinHousekeeping });
16347
17191
  const routes = registerPanelRoutes(env, {
16348
- forgetPerConnection,
17192
+ forgetPerConnection: forgetPerConnectionAsync,
16349
17193
  runBinHousekeeping,
16350
17194
  warn: (message) => ctx.logger?.warn?.(`over-ssh: ${message}`)
16351
17195
  });
17196
+ const disposeSessionDirectories = registerSessionDirectoryTools(ctx, {
17197
+ directories: routes.rootsWiring.sessionDirectories,
17198
+ sessionId: () => currentSessionId(),
17199
+ workspacePathOf: (sessionId) => readSessionCwd(ctx, sessionId)
17200
+ });
16352
17201
  const activationTimers = scheduleActivationDiagnostics({ ctx, ...routes });
16353
17202
  watchSessionRemovals({ ctx, rootsWiring: routes.rootsWiring, onSessionRemoved: env.onSessionRemoved });
16354
17203
  mergePersistedRoots({ ctx, rootsWiring: routes.rootsWiring });
16355
- return createTeardown({ activationTimers, routes, hostServices, disposeAgent, tools: env });
17204
+ return createTeardown({ activationTimers, routes, hostServices, disposeAgent, tools: env, disposeSessionDirectories });
16356
17205
  });
16357
17206
  }
16358
17207
 
@@ -16366,7 +17215,7 @@ import { dirname as dirname7, join as join16 } from "node:path";
16366
17215
  // src/host/integration/transfer/transfer-adapters.ts
16367
17216
  init_errors();
16368
17217
  import { createHash as createHash3 } from "node:crypto";
16369
- import { mkdir as mkdir8, open as open4, readFile as readFile8, readdir as readdir5, rename as rename4, rm as rm4, stat as stat4, writeFile as writeFile5 } from "node:fs/promises";
17218
+ import { mkdir as mkdir8, open as open4, readFile as readFile8, readdir as readdir5, rename as rename4, rm as rm4, stat as stat6, writeFile as writeFile5 } from "node:fs/promises";
16370
17219
  import { dirname as dirname6 } from "node:path";
16371
17220
  function requireRemote(deps, path) {
16372
17221
  const target = deps.routed.resolve(path);
@@ -16430,7 +17279,7 @@ function createTransferDeps(deps) {
16430
17279
  },
16431
17280
  async statLocal(path) {
16432
17281
  try {
16433
- const info = await stat4(path);
17282
+ const info = await stat6(path);
16434
17283
  return toBackendStat(info, localVersion(info));
16435
17284
  } catch {
16436
17285
  return toBackendStat(void 0, "0:0");
@@ -16645,6 +17494,7 @@ function trimSlashes(value) {
16645
17494
  // src/host/integration/roots/bin-root-resolver.ts
16646
17495
  function createBinRootResolver(deps) {
16647
17496
  const choices = /* @__PURE__ */ new Map();
17497
+ const inFlight = /* @__PURE__ */ new Map();
16648
17498
  const fallback = (root, note) => ({
16649
17499
  binRoot: binRootInsideRoot(root, deps.binDir),
16650
17500
  placement: "inside",
@@ -16665,6 +17515,17 @@ function createBinRootResolver(deps) {
16665
17515
  const key = rootKey(root);
16666
17516
  const existing = choices.get(key);
16667
17517
  if (existing !== void 0) return existing;
17518
+ const running = inFlight.get(key);
17519
+ if (running !== void 0) return running;
17520
+ const attempt = resolveBinRoot(root);
17521
+ inFlight.set(key, attempt);
17522
+ try {
17523
+ return await attempt;
17524
+ } finally {
17525
+ if (inFlight.get(key) === attempt) inFlight.delete(key);
17526
+ }
17527
+ };
17528
+ const resolveBinRoot = async (root) => {
16668
17529
  const beside = binRootBesideRoot(root, deps.binDir);
16669
17530
  const parent = binParentDirectory(root);
16670
17531
  if (beside === void 0 || parent === void 0) {
@@ -17169,7 +18030,7 @@ function createSessionRootIndex(deps) {
17169
18030
  const now = deps.now ?? (() => Date.now());
17170
18031
  let cachedAt;
17171
18032
  let cached2 = [];
17172
- let pending;
18033
+ let pending2;
17173
18034
  let generation = 0;
17174
18035
  const readEntries = async () => {
17175
18036
  try {
@@ -17183,14 +18044,14 @@ function createSessionRootIndex(deps) {
17183
18044
  const entriesNow = () => {
17184
18045
  const at = cachedAt;
17185
18046
  const fresh = at !== void 0 && now() - at < deps.ttlMs;
17186
- if (!fresh && pending === void 0) {
18047
+ if (!fresh && pending2 === void 0) {
17187
18048
  const startedAt = generation;
17188
- pending = readEntries().then((entries) => {
18049
+ pending2 = readEntries().then((entries) => {
17189
18050
  if (startedAt === generation) {
17190
18051
  cached2 = entries;
17191
18052
  cachedAt = now();
17192
18053
  }
17193
- pending = void 0;
18054
+ pending2 = void 0;
17194
18055
  return entries;
17195
18056
  });
17196
18057
  }
@@ -17205,7 +18066,7 @@ function createSessionRootIndex(deps) {
17205
18066
  generation += 1;
17206
18067
  cachedAt = void 0;
17207
18068
  cached2 = [];
17208
- pending = void 0;
18069
+ pending2 = void 0;
17209
18070
  }
17210
18071
  };
17211
18072
  }
@@ -17238,7 +18099,7 @@ function execWriteRootsFor(input) {
17238
18099
  }
17239
18100
 
17240
18101
  // src/host/integration/tools/remote-tools.ts
17241
- import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
18102
+ import { defineTool as defineTool3 } from "@deepseek-ai/dsh-tools";
17242
18103
 
17243
18104
  // src/host/integration/tools/session-target.ts
17244
18105
  var NO_SESSION_CWD = "\u65E0\u6CD5\u786E\u5B9A\u8C03\u7528\u65B9\u7684\u8FDC\u7AEF\u6839\uFF1A\u8BE5\u5DE5\u5177\u9700\u8981 agent \u7684\u4F1A\u8BDD\u5DE5\u4F5C\u76EE\u5F55";
@@ -17264,8 +18125,12 @@ function resolveSessionTarget(exec, seams) {
17264
18125
  function readConnectionIds(record2) {
17265
18126
  const raw = record2["connectionIds"];
17266
18127
  if (!Array.isArray(raw)) return void 0;
18128
+ if (raw.length > MAX_EXEC_BATCH_CONNECTIONS) {
18129
+ throw new Error(`connectionIds \u4E00\u6B21\u6700\u591A ${MAX_EXEC_BATCH_CONNECTIONS} \u4E2A\uFF08\u6536\u5230 ${raw.length} \u4E2A\uFF09\u3002\u8BF7\u5206\u6279\u6267\u884C\u3002`);
18130
+ }
17267
18131
  const ids = [...new Set(raw.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()))];
17268
- return ids.length === 0 ? void 0 : ids;
18132
+ if (ids.length === 0) return void 0;
18133
+ return ids;
17269
18134
  }
17270
18135
  function text2(value) {
17271
18136
  return [{ type: "text", text: value }];
@@ -17283,7 +18148,7 @@ var READONLY_CONCURRENCY_NOTE = " Read-only: calls to this tool may overlap with
17283
18148
  var EXEC_SERIAL_NOTE = " This tool is deliberately NOT concurrency-safe: remote commands run one at a time per session, so `Promise.all` over several remote_exec calls produces no overlap. That protects a shared build server from concurrent `make` runs; use one command (`a && b`) or a background job instead of relying on parallelism.";
17284
18149
  function remoteToolDefinitions(backend) {
17285
18150
  return [
17286
- defineTool2({
18151
+ defineTool3({
17287
18152
  name: "remote_grep",
17288
18153
  description: "Search file contents inside the remote workspace this session is working in. Returns matching lines with their file and 1-based line number. Case-insensitive and ignore-aware by default; `caseSensitive` and `noIgnore` change that. Bounded: at most 200 matches, and the result says so when the cap was reached. When the agent answers, `noIgnore` also stops `.gitignore` from hiding files; on the plain-`grep` fallback tier VCS ignores were never applied, so there the flag only controls the built-in noise directories. The result names the tier that answered in `strategy`." + READONLY_CONCURRENCY_NOTE,
17289
18154
  isConcurrencySafe: () => true,
@@ -17356,7 +18221,7 @@ function remoteToolDefinitions(backend) {
17356
18221
  };
17357
18222
  }
17358
18223
  }),
17359
- defineTool2({
18224
+ defineTool3({
17360
18225
  name: "remote_glob",
17361
18226
  description: "List files in the remote workspace this session is working in by **glob pattern**, matched the same way on every transport. A pattern without `/` matches the file name at any depth (`*.h` finds `app/include/header.h`); a pattern with `/` is anchored at the directory you pass in and `*` never crosses a `/`. `**` crosses zero or more segments, `?` is one character, `[abc]` and `[!abc]` are character classes, `{a,b}` alternates. Matching is case-sensitive. Paths are returned relative to the enumerated directory. An invalid pattern (an unclosed `[` or `{`) is rejected rather than answered with an empty list. Bounded: at most 500 paths, and the result says so when the cap was reached." + READONLY_CONCURRENCY_NOTE,
17362
18227
  isConcurrencySafe: () => true,
@@ -17403,7 +18268,7 @@ function remoteToolDefinitions(backend) {
17403
18268
  };
17404
18269
  }
17405
18270
  }),
17406
- defineTool2({
18271
+ defineTool3({
17407
18272
  name: "remote_exec",
17408
18273
  description: "Run one shell command on the remote host in the workspace this session is working in, and return its exit code, stdout and stderr. **A shell command is not confined by \u300C\u6DFB\u52A0\u76EE\u5F55\u300D**: it runs with the SSH user's own permissions, so it can read and write anything that user can, not only the directories added to this conversation. (A deployment that sets `limits.execWriteFence` puts a Landlock write fence around the workspace root and the directories this session added; the tool report says when that fence is active.) Output is bounded to 64 KiB per stream; when a stream is cut the result names that stream and, if the agent spilled it, the remote path holding the full text. Use this for remote builds, tests and version checks. Each call is a NEW shell: exported variables and `cd` do not carry over to the next call \u2014 pass `env` or put `source ... && cmd` in one command." + EXEC_SERIAL_NOTE,
17409
18274
  parameters: {
@@ -17725,13 +18590,14 @@ function strictArgumentsGate(ctx) {
17725
18590
 
17726
18591
  // src/host/integration/tools/remote-job-tools.ts
17727
18592
  init_errors();
17728
- import { defineTool as defineTool3 } from "@deepseek-ai/dsh-tools";
18593
+ import { defineTool as defineTool4 } from "@deepseek-ai/dsh-tools";
17729
18594
  var DEFAULT_JOB_WAIT_MS = 3e4;
17730
18595
  var MAX_JOB_WAIT_MS = 6e5;
17731
18596
  var DEFAULT_JOB_POLL_MS = 500;
17732
18597
  var WAIT_SLICE_MS = 5e3;
17733
18598
  var DEFAULT_JOB_OUTPUT_BYTES = 1 << 20;
17734
18599
  var MAX_JOB_OUTPUT_BYTES = 8 << 20;
18600
+ var MAX_JOB_STDIN_BYTES = 1 << 20;
17735
18601
  function isAgentJobCapable(transport) {
17736
18602
  if (transport === null || typeof transport !== "object") return false;
17737
18603
  const candidate = transport;
@@ -18036,7 +18902,7 @@ function clampInt2(value, fallback, min, max) {
18036
18902
  }
18037
18903
  function remoteJobToolDefinitions(backend) {
18038
18904
  return [
18039
- defineTool3({
18905
+ defineTool4({
18040
18906
  name: "remote_exec_background",
18041
18907
  description: 'Start one shell command on the remote host as a background job and return immediately with its job id, instead of blocking until it finishes. Use it for anything that takes minutes: a full `m -j16` tree build, a test sweep, a long transfer. Read its output, wait for it, list jobs and cancel with `remote_job` (`action: "output" | "status" | "list" | "kill"`) \u2014 that tool also explains how output is retained. Pass `interactive: true` to keep stdin open so `remote_job { action: "stdin" }` can feed it later; without it the job reads EOF immediately.' + // 只保留「作业随连接结束」这一条:它必须出现在**启动**的地方,因为「后台 = 持久」正是这里最
18042
18908
  // 容易产生的错误预期。轮询与环形缓冲的细节归 `remote_job`,那里才是读输出的地方。
@@ -18091,6 +18957,11 @@ function remoteJobToolDefinitions(backend) {
18091
18957
  const maxOutputBytes = clampInt2(record2["maxOutputBytes"], DEFAULT_JOB_OUTPUT_BYTES, 1, MAX_JOB_OUTPUT_BYTES);
18092
18958
  const stdinText = record2["stdin"];
18093
18959
  if (stdinText !== void 0 && typeof stdinText !== "string") throw new Error("stdin \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
18960
+ if (typeof stdinText === "string" && Buffer.byteLength(stdinText, "utf8") > MAX_JOB_STDIN_BYTES) {
18961
+ throw new Error(
18962
+ `stdin \u4E00\u6B21\u6700\u591A ${MAX_JOB_STDIN_BYTES} \u5B57\u8282\uFF08\u6536\u5230 ${Buffer.byteLength(stdinText, "utf8")}\uFF09\u3002\u8BF7\u7528 interactive:true \u542F\u52A8\uFF0C\u518D\u7528 remote_job {action:"stdin"} \u5206\u5757\u5199\u5165\u3002`
18963
+ );
18964
+ }
18094
18965
  const interactive = record2["interactive"] === true;
18095
18966
  const target = targetFor2(backend, exec);
18096
18967
  const cwd = resolveOrThrow2(target, stringArg2(record2["cwd"]));
@@ -18114,7 +18985,7 @@ function remoteJobToolDefinitions(backend) {
18114
18985
  };
18115
18986
  }
18116
18987
  }),
18117
- defineTool3({
18988
+ defineTool4({
18118
18989
  name: "remote_job",
18119
18990
  description: "Observe or control a background job started with `remote_exec_background`; `action` decides which: `status` reads one job's state (still running, exit code, how long it ran, bytes produced, bytes dropped); `output` reads its output from a byte offset; `list` shows every job this connection has started, running and finished; `kill` requests termination \u2014 asynchronous, so this reports the request and `status` confirms the end, and the agent kills the whole process group; `stdin` writes to a job started with `interactive: true`, and `close: true` signals EOF; `forget` drops a settled job from the list so it stops crowding `list` (a running job is refused)." + JOB_LIFETIME_NOTE + JOB_POLL_NOTE + JOB_DROP_NOTE + " `list` is per connection: it is not a cross-host task queue.",
18120
18991
  parameters: {
@@ -18210,6 +19081,9 @@ function remoteJobToolDefinitions(backend) {
18210
19081
  };
18211
19082
  }
18212
19083
  if (action === "stdin") {
19084
+ if (typeof record2["data"] === "string" && typeof record2["dataBase64"] === "string") {
19085
+ throw new Error("data \u4E0E dataBase64 \u53EA\u80FD\u7ED9\u4E00\u4E2A");
19086
+ }
18213
19087
  const result = await backend.stdin(
18214
19088
  target,
18215
19089
  jobId,
@@ -18354,7 +19228,7 @@ function registerRemoteJobTools(ctx, backend) {
18354
19228
 
18355
19229
  // src/host/integration/fs/remote-file-tools.ts
18356
19230
  init_errors();
18357
- import { defineTool as defineTool4 } from "@deepseek-ai/dsh-tools";
19231
+ import { defineTool as defineTool5 } from "@deepseek-ai/dsh-tools";
18358
19232
  var DEFAULT_READ_LINES = 2e3;
18359
19233
  var MAX_READ_LINES = 5e3;
18360
19234
  var DEFAULT_MAX_LINE_LENGTH = 2e3;
@@ -18428,16 +19302,16 @@ function createRemoteFileBackend(deps) {
18428
19302
  if (carried !== void 0) rememberVersion(target, to, carried);
18429
19303
  },
18430
19304
  async stat(target, absolute, signal) {
18431
- const stat5 = await requireBackend(target).stat(absolute, signal);
18432
- if (stat5.exists) rememberVersion(target, absolute, stat5.version);
19305
+ const stat7 = await requireBackend(target).stat(absolute, signal);
19306
+ if (stat7.exists) rememberVersion(target, absolute, stat7.version);
18433
19307
  return {
18434
- exists: stat5.exists,
18435
- isDirectory: stat5.isDirectory,
18436
- isFile: stat5.isFile,
18437
- isSymlink: stat5.isSymlink,
18438
- size: stat5.size,
18439
- mtimeMs: stat5.mtimeMs,
18440
- version: stat5.version
19308
+ exists: stat7.exists,
19309
+ isDirectory: stat7.isDirectory,
19310
+ isFile: stat7.isFile,
19311
+ isSymlink: stat7.isSymlink,
19312
+ size: stat7.size,
19313
+ mtimeMs: stat7.mtimeMs,
19314
+ version: stat7.version
18441
19315
  };
18442
19316
  },
18443
19317
  async patch(target, absolute, diff, expectedVersion, signal) {
@@ -18566,7 +19440,7 @@ function formatWrite(view) {
18566
19440
  }
18567
19441
  function requiredString3(value, field) {
18568
19442
  if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32`);
18569
- return value;
19443
+ return value.trim();
18570
19444
  }
18571
19445
  function clampInt3(value, fallback, min, max) {
18572
19446
  const number = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
@@ -18575,7 +19449,7 @@ function clampInt3(value, fallback, min, max) {
18575
19449
  }
18576
19450
  function remoteFileToolDefinitions(backend, allowMutation) {
18577
19451
  return [
18578
- defineTool4({
19452
+ defineTool5({
18579
19453
  name: "remote_read",
18580
19454
  description: "Read one text file in the remote workspace this session is working in, with **1-based line numbers**, and return a window of it controlled by `offset` (first line) and `limit` (how many lines). Use this instead of `cat` through `remote_exec`: `remote_exec` truncates at 64 KiB and prints no line numbers. The result carries the file's `version` token \u2014 pass it back as `expectedVersion` to `remote_write` or `remote_edit` so a concurrent change is refused instead of overwritten. Read-only: calls to this tool may overlap with other read-only remote calls.",
18581
19455
  // Read-only: it observes one remote file and changes nothing.
@@ -18657,7 +19531,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
18657
19531
  };
18658
19532
  }
18659
19533
  }),
18660
- defineTool4({
19534
+ defineTool5({
18661
19535
  name: "remote_write",
18662
19536
  description: "Write one text file in the remote workspace this session is working in, replacing its whole content. Pass `expectedVersion` (from `remote_read`) to make the write refuse when the remote file changed since that read. An existing target without a version guard is **refused** unless `force: true`: a blind overwrite silently discards content this process never saw, including an edit made by another user on a shared machine. Parent directories are created when missing. Pass `mode` to set permission bits in the same call, which is what makes a written build or probe script runnable. NOT concurrency-safe: it changes remote state, so it never joins a parallel group.",
18663
19537
  parameters: {
@@ -18735,7 +19609,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
18735
19609
  };
18736
19610
  }
18737
19611
  }),
18738
- defineTool4({
19612
+ defineTool5({
18739
19613
  name: "remote_edit",
18740
19614
  description: "Replace a **literal** string inside one file in the remote workspace this session is working in. `old` is matched verbatim \u2014 a backslash-n in `old` matches the two characters `\\` and `n`, never a newline, so write real newlines when you mean them. `new` is inserted verbatim, so `$&`, `$1` and shell variables in it are not expanded. By default `old` must occur **exactly once**: zero matches and multiple matches are both refused, and the multiple-match refusal reports how many there were. Pass `all: true` to replace every occurrence on purpose. Prefer this over patching through `remote_exec` + python/sed: it cannot mis-escape your text, and `expectedVersion` (from `remote_read`) refuses the edit when the file changed in the meantime. NOT concurrency-safe: it changes remote state, so it never joins a parallel group.",
18741
19615
  parameters: {
@@ -18817,7 +19691,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
18817
19691
  };
18818
19692
  }
18819
19693
  }),
18820
- defineTool4({
19694
+ defineTool5({
18821
19695
  name: "remote_patch",
18822
19696
  description: "Apply a **unified diff** to one file in the remote workspace, atomically: every hunk lands in one round trip, and if any hunk fails, nothing is written. Use this instead of calling `remote_edit` three times \u2014 three edits are three read-modify-write cycles, and a failure in the middle leaves the file half changed. Hunks are located by **context, not by line number**: the numbers in `@@ -a,b +c,d @@` are used only in error messages, so a diff generated against a slightly older file still applies as long as the context is right. Each hunk's old text must occur exactly once \u2014 zero occurrences means the file changed, several means the context is not distinctive enough; both are refused rather than guessed at.",
18823
19697
  parameters: {
@@ -18862,7 +19736,7 @@ function remoteFileToolDefinitions(backend, allowMutation) {
18862
19736
  return { path, hunks: outcome.hunks, bytes: outcome.bytes, version: outcome.version };
18863
19737
  }
18864
19738
  }),
18865
- defineTool4({
19739
+ defineTool5({
18866
19740
  name: "remote_move",
18867
19741
  description: 'Move or rename one path in the remote workspace. `createParents` decides whether a missing destination directory is created (default true) or refused. A cross-device move **fails** rather than silently copying: a silent copy would turn "move then delete the source" into data loss when the delete lands first. Moving onto an existing directory is refused too \u2014 the caller meant a rename, and replacing a directory is not that. Use `remote_copy` when you want a copy that stays put. NOT concurrency-safe: it changes remote state, so it never joins a parallel group.',
18868
19742
  parameters: {
@@ -18893,13 +19767,16 @@ function remoteFileToolDefinitions(backend, allowMutation) {
18893
19767
  const from = resolveOrThrow3(target, rawFrom);
18894
19768
  const to = resolveOrThrow3(target, rawTo);
18895
19769
  if (from === to) throw new Error(`\u6E90\u4E0E\u76EE\u6807\u662F\u540C\u4E00\u4E2A\u8DEF\u5F84\uFF1A${from}`);
19770
+ if (to.startsWith(from.endsWith("/") ? from : `${from}/`)) {
19771
+ throw new Error(`\u4E0D\u80FD\u628A\u76EE\u5F55\u79FB\u52A8\u5230\u5B83\u81EA\u5DF1\u7684\u5B50\u76EE\u5F55\u91CC\uFF1A${from} \u2192 ${to}`);
19772
+ }
18896
19773
  assertWritable(target, to, allowMutation);
18897
19774
  const createParents = record2["createParents"] !== false;
18898
19775
  await backend.move(target, from, to, createParents, exec.signal);
18899
19776
  return { from, to, createParents };
18900
19777
  }
18901
19778
  }),
18902
- defineTool4({
19779
+ defineTool5({
18903
19780
  name: "remote_stat",
18904
19781
  description: 'Read one path\'s **metadata** in the remote workspace without reading its content: whether it exists, whether it is a file, directory or symlink, its size, its modification time and its version token. Use this instead of `remote_read` when you only need to know "does it exist / how big / when did it change" \u2014 reading a file to answer that costs the whole file. The `version` it returns is the same token `remote_read` reports, so it can be passed to `remote_write` / `remote_edit` / `remote_patch` as `expectedVersion`. NOT concurrency-safe: read-only, but it never joins a parallel group.',
18905
19782
  parameters: {
@@ -18929,15 +19806,15 @@ function remoteFileToolDefinitions(backend, allowMutation) {
18929
19806
  const requested = requiredString3(record2["path"], "path");
18930
19807
  const target = targetFor3(backend, exec);
18931
19808
  const path = resolveOrThrow3(target, requested);
18932
- const stat5 = await backend.stat(target, path, exec.signal);
18933
- const kind = !stat5.exists ? "missing" : stat5.isSymlink ? "symlink" : stat5.isDirectory ? "directory" : "file";
19809
+ const stat7 = await backend.stat(target, path, exec.signal);
19810
+ const kind = !stat7.exists ? "missing" : stat7.isSymlink ? "symlink" : stat7.isDirectory ? "directory" : "file";
18934
19811
  return {
18935
19812
  path,
18936
- exists: stat5.exists,
19813
+ exists: stat7.exists,
18937
19814
  kind,
18938
- size: stat5.exists ? stat5.size : 0,
18939
- mtimeMs: stat5.exists ? stat5.mtimeMs : 0,
18940
- version: stat5.exists ? stat5.version : ""
19815
+ size: stat7.exists ? stat7.size : 0,
19816
+ mtimeMs: stat7.exists ? stat7.mtimeMs : 0,
19817
+ version: stat7.exists ? stat7.version : ""
18941
19818
  };
18942
19819
  }
18943
19820
  })
@@ -19017,11 +19894,11 @@ var LspFrameParser = class {
19017
19894
  #append(bytes) {
19018
19895
  if (bytes.length === 0) return;
19019
19896
  if (this.#end + bytes.length > this.#store.length) {
19020
- const pending = this.#end - this.#start;
19897
+ const pending2 = this.#end - this.#start;
19021
19898
  if (this.#start > 0) {
19022
19899
  this.#store.copyWithin(0, this.#start, this.#end);
19023
19900
  this.#start = 0;
19024
- this.#end = pending;
19901
+ this.#end = pending2;
19025
19902
  }
19026
19903
  if (this.#end + bytes.length > this.#store.length) {
19027
19904
  let capacity = Math.max(this.#store.length, 64 * 1024);
@@ -19030,7 +19907,7 @@ var LspFrameParser = class {
19030
19907
  this.#store.copy(next, 0, this.#start, this.#end);
19031
19908
  this.#store = next;
19032
19909
  this.#start = 0;
19033
- this.#end = pending;
19910
+ this.#end = pending2;
19034
19911
  }
19035
19912
  }
19036
19913
  this.#store.set(bytes, this.#end);
@@ -19242,11 +20119,11 @@ var LspSession = class _LspSession {
19242
20119
  throw error;
19243
20120
  }
19244
20121
  const onAbort = () => {
19245
- const pending = this.#pending.get(id);
19246
- if (pending === void 0) return;
20122
+ const pending2 = this.#pending.get(id);
20123
+ if (pending2 === void 0) return;
19247
20124
  this.#pending.delete(id);
19248
- clearTimeout(pending.timer);
19249
- pending.reject(new LspSessionError("\u7B49\u5F85\u8BED\u8A00\u670D\u52A1\u5668\u65F6\u88AB\u53D6\u6D88"));
20125
+ clearTimeout(pending2.timer);
20126
+ pending2.reject(new LspSessionError("\u7B49\u5F85\u8BED\u8A00\u670D\u52A1\u5668\u65F6\u88AB\u53D6\u6D88"));
19250
20127
  };
19251
20128
  signal.addEventListener("abort", onAbort, { once: true });
19252
20129
  try {
@@ -19370,15 +20247,15 @@ var LspSession = class _LspSession {
19370
20247
  if (typeof message["method"] !== "string") {
19371
20248
  const id = message["id"];
19372
20249
  if (typeof id === "number") {
19373
- const pending = this.#pending.get(id);
19374
- if (pending === void 0) return;
20250
+ const pending2 = this.#pending.get(id);
20251
+ if (pending2 === void 0) return;
19375
20252
  this.#pending.delete(id);
19376
- clearTimeout(pending.timer);
20253
+ clearTimeout(pending2.timer);
19377
20254
  const error = message["error"];
19378
20255
  if (error !== void 0 && error !== null) {
19379
- pending.reject(new LspSessionError(`${pending.method} \u5931\u8D25\uFF1A${describeError(error)}`));
20256
+ pending2.reject(new LspSessionError(`${pending2.method} \u5931\u8D25\uFF1A${describeError(error)}`));
19380
20257
  } else {
19381
- pending.resolve(message["result"]);
20258
+ pending2.resolve(message["result"]);
19382
20259
  }
19383
20260
  }
19384
20261
  return;
@@ -19434,10 +20311,10 @@ var LspSession = class _LspSession {
19434
20311
  }
19435
20312
  #failAll(error) {
19436
20313
  this.#exitReason ??= error;
19437
- for (const [id, pending] of this.#pending) {
19438
- clearTimeout(pending.timer);
20314
+ for (const [id, pending2] of this.#pending) {
20315
+ clearTimeout(pending2.timer);
19439
20316
  this.#pending.delete(id);
19440
- pending.reject(error);
20317
+ pending2.reject(error);
19441
20318
  }
19442
20319
  }
19443
20320
  };
@@ -19601,8 +20478,12 @@ function describeInstalled(available) {
19601
20478
  return `\u8BE5\u4E3B\u673A\u4E0A\u63A2\u6D4B\u5230\u7684\u8BED\u8A00\u670D\u52A1\u5668\uFF1A${list}\u3002`;
19602
20479
  }
19603
20480
  var MAX_SESSIONS = 8;
19604
- function sessionKey(target, language) {
19605
- return `${target.connectionId}\0${target.root}\0${language}`;
20481
+ function sessionKeyPrefix(target, language) {
20482
+ return `${target.connectionId}\0${target.root}\0${language}\0`;
20483
+ }
20484
+ function sessionKey(target, language, override) {
20485
+ const server = override === void 0 ? "" : `${override.command}\0${(override.args ?? []).join("\0")}`;
20486
+ return `${sessionKeyPrefix(target, language)}${server}`;
19606
20487
  }
19607
20488
  function createLspRegistry(deps) {
19608
20489
  const sessions = /* @__PURE__ */ new Map();
@@ -19621,15 +20502,15 @@ function createLspRegistry(deps) {
19621
20502
  }
19622
20503
  return {
19623
20504
  async session(target, language, override, signal) {
19624
- const key = sessionKey(target, language);
20505
+ const key = sessionKey(target, language, override);
19625
20506
  const existing = sessions.get(key);
19626
20507
  if (existing !== void 0 && !existing.session.stopped) {
19627
20508
  sessions.delete(key);
19628
20509
  sessions.set(key, existing);
19629
20510
  return existing.session;
19630
20511
  }
19631
- const pending = inFlight.get(key);
19632
- if (pending !== void 0) return pending;
20512
+ const pending2 = inFlight.get(key);
20513
+ if (pending2 !== void 0) return pending2;
19633
20514
  const attempt = (async () => {
19634
20515
  const command = override ?? defaultServerFor(language);
19635
20516
  if (command === void 0) {
@@ -19701,17 +20582,21 @@ ${describeInstalled(available)}`);
19701
20582
  }));
19702
20583
  },
19703
20584
  async stop(target, language, signal) {
19704
- const key = sessionKey(target, language);
19705
- const starting = inFlight.get(key);
19706
- if (starting !== void 0) {
19707
- retired.add(key);
19708
- await starting.catch(() => void 0);
19709
- retired.delete(key);
19710
- }
19711
- const entry = sessions.get(key);
19712
- if (entry === void 0) return starting !== void 0;
19713
- sessions.delete(key);
19714
- await entry.session.close(signal);
20585
+ const prefix = sessionKeyPrefix(target, language);
20586
+ const keys = [...new Set([...sessions.keys(), ...inFlight.keys()].filter((key) => key.startsWith(prefix)))];
20587
+ if (keys.length === 0) return false;
20588
+ for (const key of keys) {
20589
+ const starting = inFlight.get(key);
20590
+ if (starting !== void 0) {
20591
+ retired.add(key);
20592
+ await starting.catch(() => void 0);
20593
+ retired.delete(key);
20594
+ }
20595
+ const entry = sessions.get(key);
20596
+ if (entry === void 0) continue;
20597
+ sessions.delete(key);
20598
+ await entry.session.close(signal);
20599
+ }
19715
20600
  return true;
19716
20601
  },
19717
20602
  async disposeAll() {
@@ -19726,7 +20611,7 @@ ${describeInstalled(available)}`);
19726
20611
 
19727
20612
  // src/host/integration/tools/forward-tools.ts
19728
20613
  init_errors();
19729
- import { defineTool as defineTool5 } from "@deepseek-ai/dsh-tools";
20614
+ import { defineTool as defineTool6 } from "@deepseek-ai/dsh-tools";
19730
20615
  function createForwardToolBackend(acquire) {
19731
20616
  const records = /* @__PURE__ */ new Map();
19732
20617
  let sequence2 = 0;
@@ -19788,7 +20673,7 @@ function text5(value) {
19788
20673
  return [{ type: "text", text: value }];
19789
20674
  }
19790
20675
  function remoteForwardDefinition(backend) {
19791
- return defineTool5({
20676
+ return defineTool6({
19792
20677
  name: "remote_forward",
19793
20678
  description: "Forward one remote TCP port to a local one, so a service the agent started on the remote host becomes reachable from this machine (and from the DSH browser sidebar). Typical use: start a dev server with `remote_exec_background`, then `remote_forward` its port and open the returned `http://127.0.0.1:<port>` URL. The local listener binds **127.0.0.1 only** \u2014 never the LAN; open it up via the host sshd's `GatewayPorts` if that is really what you want. `start` returns the real local port (auto-picked when omitted) plus a `forwardId`; `list` shows what is live; `stop` closes one and is idempotent. Forwards live as long as the connection and are closed automatically when the plugin unloads, so a forgotten one does not leak a port forever \u2014 but stop the ones you are done with.",
19794
20679
  parameters: {
@@ -19891,7 +20776,7 @@ function registerRemoteForwardTool(ctx, backend) {
19891
20776
 
19892
20777
  // src/host/integration/tools/remote-git-tools.ts
19893
20778
  init_shell();
19894
- import { defineTool as defineTool6 } from "@deepseek-ai/dsh-tools";
20779
+ import { defineTool as defineTool7 } from "@deepseek-ai/dsh-tools";
19895
20780
  var READ_ACTIONS = ["status", "diff", "log", "show", "branch"];
19896
20781
  var WRITE_ACTIONS = ["add", "commit", "checkout", "restore", "stash"];
19897
20782
  var ACTIONS = [...READ_ACTIONS, ...WRITE_ACTIONS];
@@ -20078,7 +20963,7 @@ function formatOutcome(value) {
20078
20963
  ${body}${suffix}`;
20079
20964
  }
20080
20965
  function remoteGitDefinition(backend) {
20081
- return defineTool6({
20966
+ return defineTool7({
20082
20967
  name: "remote_git",
20083
20968
  description: "Run a git operation on the remote repository this session is working in, and return its **parsed** result instead of raw terminal text. Prefer this over `remote_exec git ... : the parse is done here (branch/ahead/behind, status entries, log entries), so a new git version's wording cannot break your reading. Read-only actions: `status`, `diff`, `log`, `show`, `branch`. Mutating actions: `add`, `commit`, `checkout`, `restore`, `stash` \u2014 these change the user's repository, so state the intent in your message first. Deliberately **not** offered: `reset --hard`, `clean -fd`, `push --force`, `checkout .`. Those have no safe preview; use `remote_exec` explicitly if you really need one. Git is run with `GIT_TERMINAL_PROMPT=0`, so a missing credential **fails** instead of hanging on a password prompt. Read-only actions may overlap with other read-only remote calls.",
20084
20969
  parameters: {
@@ -20252,7 +21137,7 @@ function registerRemoteGitTool(ctx, backend) {
20252
21137
  }
20253
21138
 
20254
21139
  // src/host/integration/tools/remote-lsp-tools.ts
20255
- import { defineTool as defineTool7 } from "@deepseek-ai/dsh-tools";
21140
+ import { defineTool as defineTool8 } from "@deepseek-ai/dsh-tools";
20256
21141
  function toView(diagnostic) {
20257
21142
  return {
20258
21143
  line: diagnostic.line + 1,
@@ -20304,7 +21189,7 @@ function parseServer(value) {
20304
21189
  function remoteLspToolDefinitions(deps) {
20305
21190
  const { backend, registry } = deps;
20306
21191
  return [
20307
- defineTool7({
21192
+ defineTool8({
20308
21193
  name: "remote_lsp",
20309
21194
  description: 'Ask a **language server running on the remote host** about a file: diagnostics, hover, or definition. Use this instead of running the compiler and reading its text output \u2014 the answer is structured and scoped to one document, and it is the only way to ask "what does this symbol mean" or "where is this defined". The server is started on first use and reused; it takes seconds to index a project, so the first call is slow and later ones are fast. Line and column are **1-based**, matching `remote_read`. Requires a language server installed on the remote host (gopls, rust-analyzer, typescript-language-server, pylsp, clangd); pass `server` to use a different one. NOT concurrency-safe: it changes remote state (starts a process), so it never joins a parallel group.',
20310
21195
  parameters: {
@@ -20513,11 +21398,10 @@ function registerRemoteLspTools(ctx, deps) {
20513
21398
  // src/wiring/tools-session.ts
20514
21399
  import { cp, mkdir as mkdir10, readFile as readFile9, readdir as readdir6, rename as rename6, rm as rm5, stat as nodeStat2, writeFile as writeFile6 } from "node:fs/promises";
20515
21400
  import { dirname as dirname8, join as join17 } from "node:path";
20516
- import { homedir as homedir6 } from "node:os";
20517
21401
 
20518
21402
  // src/host/integration/session/session-labels.ts
20519
21403
  function normalizePath(path) {
20520
- return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
21404
+ return foldPathKey(path);
20521
21405
  }
20522
21406
  function createSessionTitleResolver(ctx) {
20523
21407
  return (header) => {
@@ -20563,8 +21447,8 @@ function createWorkspaceTitleResolver(ctx) {
20563
21447
  // src/wiring/tools-session.ts
20564
21448
  function wireSessionAdmin(env) {
20565
21449
  const { ctx, limits, notifySessionRemoved: notifySessionRemoved2 } = env;
20566
- const sessionsRoot = limits.sessionsRoot ?? join17(homedir6(), ".dsh", "sessions");
20567
- const storagesRoot = join17(homedir6(), ".dsh", "storages");
21450
+ const sessionsRoot = limits.sessionsRoot ?? join17(dshHome(), "sessions");
21451
+ const storagesRoot = join17(dshHome(), "storages");
20568
21452
  const sessionBinRoot = limits.sessionBinRoot ?? join17(storagesRoot, "over-ssh", "session-trash");
20569
21453
  if (limits.sessionBinRoot === void 0) {
20570
21454
  const carried = carryDirectory(join17(storagesRoot, "remote-workspace", "session-trash"), sessionBinRoot);
@@ -20587,33 +21471,47 @@ function wireSessionAdmin(env) {
20587
21471
  listDir: async (path) => {
20588
21472
  try {
20589
21473
  const entries = await readdir6(path, { withFileTypes: true });
20590
- const out = [];
20591
- for (const entry of entries) {
20592
- let bytes = 0;
20593
- let mtimeMs = 0;
20594
- try {
20595
- const stat5 = await nodeStat2(join17(path, entry.name));
20596
- mtimeMs = stat5.mtimeMs;
20597
- if (entry.isFile()) bytes = stat5.size;
20598
- } catch {
21474
+ const statLimit = 16;
21475
+ const out = new Array(entries.length);
21476
+ let next = 0;
21477
+ const workers = Array.from({ length: Math.max(1, Math.min(statLimit, entries.length)) }, async () => {
21478
+ for (; ; ) {
21479
+ const index = next;
21480
+ next += 1;
21481
+ if (index >= entries.length) return;
21482
+ const entry = entries[index];
21483
+ if (entry === void 0) return;
21484
+ let bytes = 0;
21485
+ let mtimeMs = 0;
21486
+ try {
21487
+ const info = await nodeStat2(join17(path, entry.name));
21488
+ mtimeMs = info.mtimeMs;
21489
+ if (entry.isFile()) bytes = info.size;
21490
+ } catch {
21491
+ }
21492
+ out[index] = { name: entry.name, isDirectory: entry.isDirectory(), bytes, mtimeMs };
20599
21493
  }
20600
- out.push({ name: entry.name, isDirectory: entry.isDirectory(), bytes, mtimeMs });
20601
- }
21494
+ });
21495
+ await Promise.all(workers);
20602
21496
  return out;
20603
21497
  } catch (error) {
20604
21498
  if (error.code === "ENOENT") return [];
20605
21499
  throw error;
20606
21500
  }
20607
21501
  },
20608
- readHeader: (dir) => readSessionHeader(dir),
21502
+ // The listing is already in hand: hand it over so the header cache can key on it instead of re-listing.
21503
+ readHeader: (dir, listing) => readSessionHeader(
21504
+ dir,
21505
+ listing?.map((entry) => ({ name: entry.name, size: entry.bytes, mtimeMs: entry.mtimeMs }))
21506
+ ),
20609
21507
  // Both resolvers are optional: a profile without the platform services still lists sessions, labelled
20610
21508
  // by their directory and id rather than by name.
20611
21509
  resolveSessionTitle: createSessionTitleResolver(ctx),
20612
21510
  resolveWorkspaceTitle: createWorkspaceTitleResolver(ctx),
20613
21511
  stat: async (path) => {
20614
21512
  try {
20615
- const stat5 = await nodeStat2(path);
20616
- return { isDirectory: stat5.isDirectory(), bytes: stat5.size };
21513
+ const stat7 = await nodeStat2(path);
21514
+ return { isDirectory: stat7.isDirectory(), bytes: stat7.size };
20617
21515
  } catch {
20618
21516
  return void 0;
20619
21517
  }
@@ -20690,7 +21588,7 @@ init_errors();
20690
21588
 
20691
21589
  // src/host/integration/tools/management-tools.ts
20692
21590
  import { isAbsolute as isAbsolute3, resolve as resolve2 } from "node:path";
20693
- import { defineTool as defineTool8 } from "@deepseek-ai/dsh-tools";
21591
+ import { defineTool as defineTool9 } from "@deepseek-ai/dsh-tools";
20694
21592
  function text7(value) {
20695
21593
  return [{ type: "text", text: value }];
20696
21594
  }
@@ -20765,7 +21663,7 @@ function formatEnv(result) {
20765
21663
  }
20766
21664
  function managementToolDefinitions(backend) {
20767
21665
  return [
20768
- defineTool8({
21666
+ defineTool9({
20769
21667
  name: "remote_transfer",
20770
21668
  description: "Upload one local file to the remote workspace this session is working in, or download one remote file to the local machine. Chunked with progress; pass `resume: true` to continue an interrupted transfer instead of rewriting the whole file (the prefix is **compared with the source** before it is kept, so a changed source degrades to a full retransfer instead of splicing two files). The result reports bytes, chunk count, whether it resumed, the absolute local path the bytes landed at, and \u2014 when the host can hash \u2014 the digest of the content that actually landed. `localPath` must be **absolute**: the plugin resolves it in its own process, so a relative spelling would land under the plugin's working directory instead of anywhere the caller expects. An upload that would overwrite an **existing** remote file is refused unless you pass `expectedVersion` (from `remote_stat`/`remote_read`) or `force: true`: a silent overwrite is the one operation that destroys someone else's work without a trace.",
20771
21669
  parameters: {
@@ -20854,6 +21752,7 @@ function managementToolDefinitions(backend) {
20854
21752
  throw new Error("expectedVersion / expectedSha256 / force \u53EA\u5BF9 upload \u6709\u610F\u4E49\uFF1B\u4E0B\u8F7D\u7531\u672C\u5730\u6587\u4EF6\u7CFB\u7EDF\u8986\u76D6\uFF0C\u4E0D\u505A\u8FDC\u7AEF\u5B88\u536B");
20855
21753
  }
20856
21754
  const target = targetFor4(backend, exec);
21755
+ if (direction === "upload") backend.assertWritable?.(target, "\u4E0A\u4F20\u8FDC\u7AEF\u6587\u4EF6");
20857
21756
  if (direction === "upload" && backend.statRemote !== void 0 && (expectedVersion !== void 0 || !force && expectedSha256 === void 0)) {
20858
21757
  const absolute = resolveUnderRoot(target.root, remotePath);
20859
21758
  if (typeof absolute !== "string") throw new Error(absolute.error);
@@ -20903,7 +21802,7 @@ function managementToolDefinitions(backend) {
20903
21802
  };
20904
21803
  }
20905
21804
  }),
20906
- defineTool8({
21805
+ defineTool9({
20907
21806
  name: "remote_delete",
20908
21807
  description: 'Delete one path inside the remote workspace. `mode: "recycle"` (the default) moves it to the workspace recycle bin, where it is recoverable and can be undone; `mode: "permanent"` removes it for good and requires user approval. Omitting `mode` is therefore the safe call, and the result always names which mode ran.',
20909
21808
  parameters: {
@@ -20941,6 +21840,7 @@ function managementToolDefinitions(backend) {
20941
21840
  const target = targetFor4(backend, exec);
20942
21841
  const resolved = resolveUnderRoot(target.root, requested, void 0, target.connectionId);
20943
21842
  if (typeof resolved !== "string") throw new Error(resolved.error);
21843
+ backend.assertWritable?.(target, mode === "permanent" ? "\u6C38\u4E45\u5220\u9664\u8FDC\u7AEF\u6587\u4EF6" : "\u5220\u9664\u8FDC\u7AEF\u6587\u4EF6");
20944
21844
  const result = await backend.discard({ target, path: resolved, mode, signal: exec.signal });
20945
21845
  return {
20946
21846
  mode: result.mode,
@@ -20951,7 +21851,7 @@ function managementToolDefinitions(backend) {
20951
21851
  };
20952
21852
  }
20953
21853
  }),
20954
- defineTool8({
21854
+ defineTool9({
20955
21855
  name: "remote_restore",
20956
21856
  description: "Undo a delete. With `id`, puts that bin entry back at the path it was deleted from; without `id`, undoes the most recent discard; with `list: true`, shows what the bin holds for this session's root and changes nothing. `remote_delete` returns the entry id as `entryId`. The bin is subject to retention limits (`binMaxEntries` / `binMaxBytes` / `binMaxAgeMs`), so an old entry may already be gone.",
20957
21857
  parameters: {
@@ -21000,6 +21900,8 @@ function managementToolDefinitions(backend) {
21000
21900
  throw new Error("id \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\uFF08\u7701\u7565\u5B83\u8868\u793A\u64A4\u9500\u6700\u8FD1\u4E00\u6B21\u5220\u9664\uFF09");
21001
21901
  }
21002
21902
  const target = targetFor4(backend, exec);
21903
+ const listing = record2["list"] === true;
21904
+ if (!listing) backend.assertWritable?.(target, "\u4ECE\u56DE\u6536\u7AD9\u6062\u590D\u8FDC\u7AEF\u6587\u4EF6");
21003
21905
  const outcome = await backend.restore({
21004
21906
  target,
21005
21907
  id: typeof id === "string" ? id : void 0,
@@ -21013,7 +21915,7 @@ function managementToolDefinitions(backend) {
21013
21915
  return { action: "undone", id: outcome.id, message: outcome.message };
21014
21916
  }
21015
21917
  }),
21016
- defineTool8({
21918
+ defineTool9({
21017
21919
  name: "remote_copy",
21018
21920
  description: "Copy one file to another path **on the remote host**. Both paths are inside this session's remote root. On a Tier 2 (agent) connection the copy happens on the remote side, so the bytes never cross this machine; otherwise they are relayed through it, and the result says which happened in `via`. Pass `recursive: true` to copy a whole directory tree in one round trip (Tier 2 only \u2014 it refuses rather than relaying a tree file by file). The destination must not exist, or be an empty directory: a recursive copy never deletes anything.",
21019
21921
  parameters: {
@@ -21056,6 +21958,7 @@ function managementToolDefinitions(backend) {
21056
21958
  if (typeof source !== "string") throw new Error(source.error);
21057
21959
  const destination = resolveUnderRoot(target.root, to, void 0, target.connectionId);
21058
21960
  if (typeof destination !== "string") throw new Error(destination.error);
21961
+ backend.assertWritable?.(target, "\u590D\u5236\u8FDC\u7AEF\u6587\u4EF6");
21059
21962
  const outcome = await backend.copy({
21060
21963
  target,
21061
21964
  from: source,
@@ -21076,7 +21979,7 @@ function managementToolDefinitions(backend) {
21076
21979
  };
21077
21980
  }
21078
21981
  }),
21079
- defineTool8({
21982
+ defineTool9({
21080
21983
  name: "remote_env",
21081
21984
  description: "Sample what the remote host is and what it can do: OS/arch/distro/kernel, shell, CPU count, memory and disk, user and home, and **which commands exist** (rg, fd, git, python3, gcc, docker, ...). Ask this once instead of guessing and retrying: the answer is cached on the agent until `refresh: true`, so a repeat call costs one round trip and no resampling.",
21082
21985
  parameters: {
@@ -21290,6 +22193,8 @@ function wireManagementTools(env) {
21290
22193
  )?.root;
21291
22194
  const managementBackend = {
21292
22195
  resolve: (cwd) => toolBackend.resolve(cwd),
22196
+ // 管理工具(上传/删除/恢复/复制)之前完全不过写闸:read-only 会话照样能删文件(审计 2026-11-06 宿主 P0)。
22197
+ assertWritable: (target, action) => env.assertWritable(target, action),
21293
22198
  // ADR-0013: the session id (plus its fork lineage) is the strict scope; `cwd` alone cannot separate
21294
22199
  // two conversations opened on the same directory.
21295
22200
  resolveSessionRoot: (cwd, sessionId) => sessionRoots.outcomeFor(cwd, sessionIdentityOf(ctx, sessionId)),
@@ -21409,6 +22314,7 @@ function wireManagementTools(env) {
21409
22314
  }
21410
22315
 
21411
22316
  // src/wiring/tools.ts
22317
+ init_errors();
21412
22318
  function sessionRootsFilePath() {
21413
22319
  return join18(resolveStateDir(), USER_ROOTS_FILE_NAME);
21414
22320
  }
@@ -21449,7 +22355,8 @@ function wireTools(env) {
21449
22355
  disposeJobTools,
21450
22356
  disposeFileTools,
21451
22357
  lspRegistry,
21452
- disposeLspTools
22358
+ disposeLspTools,
22359
+ assertWritable: assertWritable2
21453
22360
  } = wireFileAndJobTools({ ctx, limits, profiles, transports, registry, sessionRoots });
21454
22361
  const sessionAdmin = wireSessionAdmin({ ctx, limits, notifySessionRemoved: notifySessionRemoved2 });
21455
22362
  const { dispose: disposeManagement } = wireManagementTools({
@@ -21463,7 +22370,8 @@ function wireTools(env) {
21463
22370
  sessionRoots,
21464
22371
  recordFor,
21465
22372
  transferService,
21466
- transferTracker
22373
+ transferTracker,
22374
+ assertWritable: assertWritable2
21467
22375
  });
21468
22376
  const disposeStrictArguments = strictArgumentsGate(ctx);
21469
22377
  return {
@@ -21532,7 +22440,9 @@ function wireAgentTools(env) {
21532
22440
  disposeRemoteTools();
21533
22441
  disposeGitTool();
21534
22442
  disposeForwardTool();
21535
- void forwardBackend.closeAll();
22443
+ void forwardBackend.closeAll().catch((error) => {
22444
+ ctx.logger?.warn?.(`over-ssh: \u5173\u95ED\u7AEF\u53E3\u8F6C\u53D1\u65F6\u51FA\u9519\uFF1A${error instanceof Error ? error.message : String(error)}`);
22445
+ });
21536
22446
  };
21537
22447
  return { sessionRoots, toolBackend, disposeTools };
21538
22448
  }
@@ -21569,21 +22479,33 @@ function wireFileAndJobTools(env) {
21569
22479
  backendFor: (target) => recordFor(target)?.backend,
21570
22480
  ...limits.maxReadBytes === void 0 ? {} : { maxReadBytes: limits.maxReadBytes }
21571
22481
  });
21572
- const allowMutation = (target) => {
22482
+ const allowMutation = (target, action) => {
22483
+ const policy = platformMutationPolicy(ctx);
22484
+ const sessionId = currentSessionId();
22485
+ const cwd = sessionId === void 0 || sessionId === "" ? void 0 : readSessionCwd(ctx, sessionId);
21573
22486
  const refusal = mutationRefusal(
21574
- `${target.connectionId}:${target.root}`,
22487
+ action === void 0 ? `${target.connectionId}:${target.root}` : `${action}\uFF1A${target.connectionId}:${target.root}`,
21575
22488
  {
21576
- ...platformMutationPolicy(ctx),
22489
+ ...policy,
21577
22490
  rootArea: () => {
21578
22491
  const record2 = recordFor(target);
21579
22492
  if (record2 === void 0) return "deny";
21580
- return workspaceWriteArea(record2.root, void 0, void 0, sessionIdentityOf(ctx, currentSessionId()));
22493
+ return workspaceWriteArea(record2.root, policy.policy?.workspaceRoot, cwd, sessionIdentityOf(ctx, sessionId));
21581
22494
  }
21582
22495
  }
21583
22496
  );
21584
22497
  if (refusal !== void 0) throw new Error(refusal);
21585
22498
  };
21586
22499
  const disposeFileTools = registerRemoteFileTools(ctx, fileBackend, { allowMutation });
22500
+ const assertWritable2 = (target, action) => {
22501
+ allowMutation(target, action);
22502
+ if (recordFor(target)?.root.writable === false) {
22503
+ throw new SshDevError("SANDBOX_DENIED", `\u8FD9\u4E2A\u6839\u5728\u914D\u7F6E\u91CC\u662F\u53EA\u8BFB\u7684\uFF08writable: false\uFF09\uFF0C\u62D2\u7EDD\u5199\u5165\uFF1A${action}`, {
22504
+ connectionId: target.connectionId,
22505
+ root: target.root
22506
+ });
22507
+ }
22508
+ };
21587
22509
  const lspRegistry = createLspRegistry({
21588
22510
  acquire: async (connectionId) => (await transports.acquire(requireProfile(profiles, connectionId))).transport,
21589
22511
  onLog: (connectionId, message) => {
@@ -21591,7 +22513,7 @@ function wireFileAndJobTools(env) {
21591
22513
  }
21592
22514
  });
21593
22515
  const disposeLspTools = registerRemoteLspTools(ctx, { backend: fileBackend, registry: lspRegistry });
21594
- return { recordFor, disposeJobTools, disposeFileTools, lspRegistry, disposeLspTools };
22516
+ return { recordFor, disposeJobTools, disposeFileTools, lspRegistry, disposeLspTools, assertWritable: assertWritable2 };
21595
22517
  }
21596
22518
 
21597
22519
  // src/index.ts