dsh-remote-vs 0.0.0-stage → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
package/lib/index.js ADDED
@@ -0,0 +1,4287 @@
1
+ import { a as pushPipeline, c as require_lib, d as ensurePrivateDir, f as keysDir, i as probe, l as aliasRoot, o as seedHelperCache, p as storePath, r as helperAvailability, s as RemoteFiles, t as RemoteWorlds, u as dshHome } from "./worlds-PUumRIh7.js";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { WebSocketServer } from "ws";
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import { defineTool } from "@deepseek-ai/dsh-tools";
12
+ //#region src/store.ts
13
+ /**
14
+ * Durable store for dsh-remote-vs: hosts + workspace bindings in
15
+ * $DSH_HOME/dsh-remote.json (0600, atomic tmp+rename writes).
16
+ */
17
+ /** Empty document. */
18
+ function emptyDoc() {
19
+ return {
20
+ version: 1,
21
+ hosts: [],
22
+ workspaces: [],
23
+ tombstones: []
24
+ };
25
+ }
26
+ /** Same file identity (both absent counts as same). */
27
+ function sameStamp(left, right) {
28
+ if (left === null || right === null) return left === right;
29
+ return left.mtimeMs === right.mtimeMs && left.size === right.size;
30
+ }
31
+ /** Alias grammar: 1-64 chars of letters/digits/._- */
32
+ const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
33
+ function assertAlias(alias) {
34
+ if (typeof alias !== "string" || !ALIAS_RE.test(alias)) throw new Error("alias must match [A-Za-z0-9._-]{1,64} and start with an alphanumeric");
35
+ }
36
+ var RemoteStore = class {
37
+ file;
38
+ doc;
39
+ /** File identity when `doc` was read from disk (undefined = `doc` is not known fresh). */
40
+ stamp;
41
+ constructor(file) {
42
+ this.file = file ?? storePath();
43
+ this.doc = emptyDoc();
44
+ this.reload();
45
+ }
46
+ /**
47
+ * Reload from disk (picks up external edits).
48
+ *
49
+ * Re-reading on every access is deliberate: the root fs router asks this store
50
+ * 2-4 times per routed file operation, and an edit made by another process
51
+ * must be visible without a restart. A stat is orders of magnitude cheaper
52
+ * than read + parse, so the file's identity (mtime + size) decides instead —
53
+ * unchanged means the cached doc is already the file's content. The stamp is
54
+ * taken BEFORE the read: a write landing in between leaves the older stamp
55
+ * behind, so the next access re-reads rather than trusting a stale doc. The
56
+ * one edit this cannot see is a same-length rewrite inside the filesystem's
57
+ * mtime granularity (sub-ms on NTFS/ext4).
58
+ */
59
+ reload() {
60
+ const stamp = this.stampOf();
61
+ if (stamp !== void 0 && this.stamp !== void 0 && sameStamp(stamp, this.stamp)) return;
62
+ this.doc = this.loadDoc();
63
+ this.stamp = stamp;
64
+ }
65
+ /**
66
+ * File identity right now: `null` when the file is absent, `undefined` when it
67
+ * cannot be statted at all. Both cases fall through to a full load, so the
68
+ * missing-file and unreadable-file paths behave exactly as they did before.
69
+ */
70
+ stampOf() {
71
+ try {
72
+ const stat = statSync(this.file, { throwIfNoEntry: false });
73
+ return stat === void 0 ? null : {
74
+ mtimeMs: stat.mtimeMs,
75
+ size: stat.size
76
+ };
77
+ } catch {
78
+ return;
79
+ }
80
+ }
81
+ loadDoc() {
82
+ if (!existsSync(this.file)) return emptyDoc();
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(readFileSync(this.file, "utf8"));
86
+ } catch (error) {
87
+ throw new Error(`cannot parse ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
88
+ }
89
+ if (typeof parsed !== "object" || parsed === null) throw new Error(`${this.file}: root must be an object`);
90
+ const doc = parsed;
91
+ if (doc.version !== 1) throw new Error(`${this.file}: unsupported version ${doc.version}`);
92
+ if (!Array.isArray(doc.hosts) || !Array.isArray(doc.workspaces)) throw new Error(`${this.file}: hosts/workspaces must be arrays`);
93
+ return {
94
+ version: 1,
95
+ hosts: doc.hosts,
96
+ workspaces: doc.workspaces,
97
+ tombstones: Array.isArray(doc.tombstones) ? doc.tombstones.filter((t) => typeof t === "string") : []
98
+ };
99
+ }
100
+ save() {
101
+ this.stamp = void 0;
102
+ const payload = JSON.stringify(this.doc, null, 2);
103
+ const tmp = `${this.file}.${process.pid}.tmp`;
104
+ const fd = openSync(tmp, "w", 384);
105
+ try {
106
+ writeSync(fd, payload);
107
+ } finally {
108
+ closeSync(fd);
109
+ }
110
+ renameSync(tmp, this.file);
111
+ }
112
+ list() {
113
+ this.reload();
114
+ return [...this.doc.hosts];
115
+ }
116
+ get(alias) {
117
+ this.reload();
118
+ return this.doc.hosts.find((h) => h.alias === alias);
119
+ }
120
+ require(alias) {
121
+ const entry = this.get(alias);
122
+ if (entry === void 0) throw new Error(`unknown host alias: ${alias}`);
123
+ return entry;
124
+ }
125
+ create(payload) {
126
+ assertAlias(payload.alias);
127
+ if (this.get(payload.alias) !== void 0) throw new Error(`alias already exists: ${payload.alias}`);
128
+ const entry = this.normalize(payload, void 0);
129
+ this.doc.hosts.push(entry);
130
+ this.save();
131
+ return entry;
132
+ }
133
+ update(alias, patch) {
134
+ const current = this.require(alias);
135
+ let auth = patch.auth ?? current.auth;
136
+ if (patch.auth !== void 0) if (patch.auth.kind === "key") {
137
+ const keyPath = (patch.auth.keyPath ?? "").trim();
138
+ auth = {
139
+ kind: "key",
140
+ keyPath: keyPath !== "" ? keyPath : current.auth.kind === "key" ? current.auth.keyPath : "",
141
+ ...patch.auth.passphrase !== void 0 ? { passphrase: patch.auth.passphrase } : current.auth.kind === "key" && current.auth.passphrase !== void 0 ? { passphrase: current.auth.passphrase } : {}
142
+ };
143
+ } else {
144
+ const password = patch.auth.password ?? "";
145
+ auth = {
146
+ kind: "password",
147
+ password: password !== "" ? password : current.auth.kind === "password" ? current.auth.password : ""
148
+ };
149
+ }
150
+ const merged = {
151
+ alias,
152
+ host: patch.host ?? current.host,
153
+ port: patch.port ?? current.port,
154
+ user: patch.user ?? current.user,
155
+ auth,
156
+ description: patch.description ?? current.description,
157
+ tags: patch.tags ?? current.tags
158
+ };
159
+ const next = this.normalize(merged, current);
160
+ const index = this.doc.hosts.findIndex((h) => h.alias === alias);
161
+ this.doc.hosts[index] = next;
162
+ this.save();
163
+ return next;
164
+ }
165
+ remove(alias) {
166
+ const before = this.doc.hosts.length;
167
+ this.doc.hosts = this.doc.hosts.filter((h) => h.alias !== alias);
168
+ this.doc.workspaces = this.doc.workspaces.filter((w) => w.alias !== alias);
169
+ if (this.doc.hosts.length === before) return false;
170
+ this.save();
171
+ return true;
172
+ }
173
+ normalize(payload, current) {
174
+ if (typeof payload.host !== "string" || payload.host.trim() === "") throw new Error("host must be a non-empty string");
175
+ const port = payload.port ?? 22;
176
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("port must be an integer 1-65535");
177
+ if (typeof payload.user !== "string" || payload.user.trim() === "") throw new Error("user must be a non-empty string");
178
+ const auth = payload.auth;
179
+ if (auth === void 0 || auth.kind !== "key" && auth.kind !== "password") throw new Error("auth.kind must be key or password");
180
+ if (auth.kind === "key") {
181
+ if (typeof auth.keyPath !== "string" || auth.keyPath.trim() === "") throw new Error("auth.keyPath is required for key auth");
182
+ if (auth.passphrase !== void 0 && typeof auth.passphrase !== "string") throw new Error("auth.passphrase must be a string when provided");
183
+ } else if (typeof auth.password !== "string" || auth.password === "") throw new Error("auth.password must be a non-empty string for password auth");
184
+ if (payload.description !== void 0 && typeof payload.description !== "string") throw new Error("description must be a string when provided");
185
+ if (payload.tags !== void 0 && (!Array.isArray(payload.tags) || payload.tags.some((t) => typeof t !== "string"))) throw new Error("tags must be string[] when provided");
186
+ const now = Date.now();
187
+ return {
188
+ alias: payload.alias,
189
+ host: payload.host.trim(),
190
+ port,
191
+ user: payload.user.trim(),
192
+ auth: {
193
+ kind: auth.kind,
194
+ ...auth.keyPath !== void 0 ? { keyPath: auth.keyPath } : {},
195
+ ...auth.passphrase !== void 0 ? { passphrase: auth.passphrase } : {},
196
+ ...auth.password !== void 0 ? { password: auth.password } : {}
197
+ },
198
+ ...payload.description !== void 0 ? { description: payload.description } : {},
199
+ tags: payload.tags ?? [],
200
+ ...current?.deploy !== void 0 ? { deploy: current.deploy } : {},
201
+ createdAt: current?.createdAt ?? now,
202
+ updatedAt: now
203
+ };
204
+ }
205
+ /** Secret-free projection for browser/agent surfaces. */
206
+ summarize(entry) {
207
+ let keyReady = false;
208
+ if (entry.auth.kind === "key" && entry.auth.keyPath !== void 0) try {
209
+ keyReady = existsSync(entry.auth.keyPath);
210
+ } catch {
211
+ keyReady = false;
212
+ }
213
+ return {
214
+ alias: entry.alias,
215
+ host: entry.host,
216
+ port: entry.port,
217
+ user: entry.user,
218
+ auth: entry.auth.kind,
219
+ keyReady,
220
+ managedKey: entry.auth.kind === "key" && entry.auth.keyPath !== void 0 && entry.auth.keyPath.startsWith(keysDir() + (process.platform === "win32" ? "\\" : "/")),
221
+ ...entry.description !== void 0 ? { description: entry.description } : {},
222
+ tags: [...entry.tags],
223
+ ...entry.deploy !== void 0 ? { deploy: entry.deploy } : {},
224
+ createdAt: entry.createdAt,
225
+ updatedAt: entry.updatedAt
226
+ };
227
+ }
228
+ setDeploy(alias, state) {
229
+ const entry = this.require(alias);
230
+ entry.deploy = state;
231
+ entry.updatedAt = Date.now();
232
+ this.save();
233
+ }
234
+ listWorkspaces() {
235
+ this.reload();
236
+ return [...this.doc.workspaces];
237
+ }
238
+ createWorkspace(payload) {
239
+ if (typeof payload.name !== "string" || payload.name.trim() === "") throw new Error("name must be a non-empty string");
240
+ this.require(payload.alias);
241
+ if (typeof payload.remotePath !== "string" || !payload.remotePath.startsWith("/")) throw new Error("remotePath must be an absolute POSIX path");
242
+ const binding = {
243
+ id: randomUUID().slice(0, 8),
244
+ name: payload.name.trim(),
245
+ alias: payload.alias,
246
+ remotePath: payload.remotePath,
247
+ createdAt: Date.now()
248
+ };
249
+ this.doc.workspaces.push(binding);
250
+ this.save();
251
+ return binding;
252
+ }
253
+ removeWorkspace(id) {
254
+ this.reload();
255
+ if (this.doc.workspaces.find((w) => w.id === id) === void 0) return false;
256
+ this.addTombstone(join(aliasRoot(), id));
257
+ this.doc.workspaces = this.doc.workspaces.filter((w) => w.id !== id);
258
+ this.save();
259
+ return true;
260
+ }
261
+ /** Record a removed alias path (idempotent). */
262
+ addTombstone(aliasPath) {
263
+ if (!this.doc.tombstones.includes(aliasPath)) {
264
+ this.doc.tombstones.push(aliasPath);
265
+ this.save();
266
+ }
267
+ }
268
+ listTombstones() {
269
+ this.reload();
270
+ return [...this.doc.tombstones];
271
+ }
272
+ };
273
+ //#endregion
274
+ //#region src/pool.ts
275
+ /**
276
+ * Persistent ssh2 connection pool: one long-lived connection per host alias,
277
+ * dropped after 30 idle minutes. All remote ops (exec / shell / sftp) ride it.
278
+ *
279
+ * Trust model (P0): host keys are accepted on first use (TOFU); a changed key
280
+ * for a known host is logged loudly but the connection still proceeds. Strict
281
+ * known-hosts enforcement is P1 work.
282
+ */
283
+ var import_lib = require_lib();
284
+ /** Idle time after which a pooled connection is closed. */
285
+ const IDLE_MS = 1800 * 1e3;
286
+ /**
287
+ * Connection establishment timeout, including authentication.
288
+ *
289
+ * This was 15s, which is not a handshake budget so much as a CPU budget: on a
290
+ * two-core host running a build, sshd could not complete a PASSWORD exchange
291
+ * inside it, and every call failed with "Timed out while waiting for handshake"
292
+ * while the banner was arriving in 15ms. Key auth fixed the common case (~1s),
293
+ * but a loaded host is still slow enough to be worth headroom.
294
+ *
295
+ * Override with DSH_REMOTE_VS_CONNECT_TIMEOUT_MS (milliseconds, >= 1000).
296
+ */
297
+ const READY_TIMEOUT_MS = (() => {
298
+ const raw = Number(process.env.DSH_REMOTE_VS_CONNECT_TIMEOUT_MS);
299
+ return Number.isFinite(raw) && raw >= 1e3 ? raw : 45e3;
300
+ })();
301
+ /** Per-stream output cap (stdout/stderr each); overflow truncates with a marker. */
302
+ const OUTPUT_CAP = 2 * 1024 * 1024;
303
+ /**
304
+ * Wall-clock cap for one SFTP callback body. SSH gives no request timeout of
305
+ * its own, so a server that never answers would hold the session (and the
306
+ * pooled connection's hold count) open forever — the leak that eventually
307
+ * exhausts the server's channel budget. The timeout rejects so the caller's
308
+ * `finally` still ends the session and releases the connection.
309
+ */
310
+ const SFTP_OP_TIMEOUT_MS = 6e4;
311
+ /**
312
+ * No-progress budget for a bulk upload. Deliberately an *idle* timeout rather
313
+ * than a wall clock: the point is to catch a stream that stopped moving, not
314
+ * to give up on a large file that is transferring fine on a slow link.
315
+ * `fastPut` resets it on every chunk via its `step` callback.
316
+ */
317
+ const UPLOAD_IDLE_MS = 6e4;
318
+ const TRUNCATED_MARKER = "\n[... truncated: output exceeded 2 MiB ...]\n";
319
+ /** Leading line the session wrapper prints so the timeout can kill its group. */
320
+ const PGID_MARKER = "RV_PID=";
321
+ /** One-shot `setsid --wait` capability probe (see RemotePool.supportsSetsid). */
322
+ const SETSID_PROBE = "setsid --wait sh -c 'exit 7' 2>/dev/null; test $? -eq 7 && echo RV_SETSID=1";
323
+ const SETSID_PROBE_MARK = "RV_SETSID=1";
324
+ /** Deadline for the probe and for a group kill; both are one tiny command. */
325
+ const CONTROL_TIMEOUT_MS = 15e3;
326
+ /**
327
+ * How long a killed group gets to report its exit before TERM escalates to
328
+ * KILL. Without the escalation a command that ignores TERM would survive the
329
+ * timeout, which is the leak this kill exists to close (p2/ssh-proc.ts uses
330
+ * the same grace before its own escalation).
331
+ */
332
+ const GROUP_KILL_GRACE_MS = 3e3;
333
+ /**
334
+ * Concurrent channels the pool opens per alias. The server's own `MaxSessions`
335
+ * (10 on the hosts this was measured against) is the real ceiling, and running
336
+ * past it fails the extra channels hard — "open failed / administratively
337
+ * prohibited" — with nobody retrying. Six is chosen to leave room for what is
338
+ * deliberately NOT counted here: PTY terminals, the processes the subprocess
339
+ * runtime owns, and the helper's channel.
340
+ */
341
+ const CHANNEL_LIMIT = 6;
342
+ /**
343
+ * How long an operation waits for a channel slot before failing. Bounded on
344
+ * purpose: queueing forever behind a peer that never finishes is worse than a
345
+ * readable error that tells the user to retry.
346
+ */
347
+ const CHANNEL_WAIT_MS = 3e4;
348
+ var RemotePool = class {
349
+ store;
350
+ conns = /* @__PURE__ */ new Map();
351
+ /** TOFU fingerprints observed this process (alias -> sha256). */
352
+ hostKeys = /* @__PURE__ */ new Map();
353
+ /** Per-alias `setsid --wait` capability, probed once (see supportsSetsid). */
354
+ setsidProbes = /* @__PURE__ */ new Map();
355
+ /** Per-alias channel slots and the queue waiting for one (see acquireSlot). */
356
+ slots = /* @__PURE__ */ new Map();
357
+ constructor(store) {
358
+ this.store = store;
359
+ }
360
+ /** Drop one pooled connection (call after host update/delete). */
361
+ drop(alias) {
362
+ const pooled = this.conns.get(alias);
363
+ this.setsidProbes.delete(alias);
364
+ if (pooled === void 0) return;
365
+ this.conns.delete(alias);
366
+ if (pooled.timer !== void 0) clearTimeout(pooled.timer);
367
+ try {
368
+ pooled.conn.end();
369
+ } catch {}
370
+ }
371
+ /** Close every pooled connection. */
372
+ dispose() {
373
+ for (const alias of [...this.conns.keys()]) this.drop(alias);
374
+ }
375
+ /** Test a connection without disturbing the pool. */
376
+ async test(alias) {
377
+ const started = Date.now();
378
+ try {
379
+ await this.acquire(alias);
380
+ this.hold(alias);
381
+ this.release(alias);
382
+ return {
383
+ ok: true,
384
+ durationMs: Date.now() - started
385
+ };
386
+ } catch (error) {
387
+ return {
388
+ ok: false,
389
+ error: error instanceof Error ? error.message : String(error),
390
+ durationMs: Date.now() - started
391
+ };
392
+ }
393
+ }
394
+ /**
395
+ * Run one non-interactive command.
396
+ *
397
+ * The command needs a channel slot: a burst of SCM or search calls is exactly
398
+ * how a host runs past `MaxSessions`, where the surplus channels fail instead
399
+ * of queueing. The pool's own housekeeping (`bare`: the setsid probe and the
400
+ * group kill) is exempt — a kill that waits behind user traffic would be a
401
+ * worse bug than the one it fixes.
402
+ */
403
+ async exec(alias, command, opts = {}) {
404
+ if (opts.bare === true) return this.runExec(alias, command, opts);
405
+ const started = Date.now();
406
+ try {
407
+ await this.acquireSlot(alias);
408
+ } catch (error) {
409
+ return {
410
+ success: false,
411
+ exitCode: null,
412
+ timedOut: false,
413
+ stdout: "",
414
+ stderr: "",
415
+ durationMs: Date.now() - started,
416
+ error: error instanceof Error ? error.message : String(error)
417
+ };
418
+ }
419
+ try {
420
+ return await this.runExec(alias, command, opts);
421
+ } finally {
422
+ this.releaseSlot(alias);
423
+ }
424
+ }
425
+ /** The exec body, run while the caller already holds a channel slot. */
426
+ async runExec(alias, command, opts = {}) {
427
+ const started = Date.now();
428
+ const timeoutMs = opts.timeoutMs ?? 6e4;
429
+ const session = opts.bare === true ? false : await this.supportsSetsid(alias);
430
+ let conn;
431
+ try {
432
+ conn = await this.acquire(alias);
433
+ this.hold(alias);
434
+ } catch (error) {
435
+ return {
436
+ success: false,
437
+ exitCode: null,
438
+ timedOut: false,
439
+ stdout: "",
440
+ stderr: "",
441
+ durationMs: Date.now() - started,
442
+ error: error instanceof Error ? error.message : String(error)
443
+ };
444
+ }
445
+ try {
446
+ return await new Promise((resolve) => {
447
+ let settled = false;
448
+ let timedOut = false;
449
+ /** Server-confirmed child exit; a bare channel close is not proof. */
450
+ let exited = false;
451
+ /** Session/process-group id, from the wrapper's leading marker line. */
452
+ let pgid;
453
+ let markerPending = session;
454
+ let warned = false;
455
+ /** Say once, and only once, that the timeout could not reach the command. */
456
+ const warnOnce = () => {
457
+ if (warned) return;
458
+ warned = true;
459
+ warnDetached(alias);
460
+ };
461
+ const finish = (result) => {
462
+ if (settled) return;
463
+ settled = true;
464
+ clearTimeout(timer);
465
+ this.release(alias);
466
+ resolve({
467
+ ...result,
468
+ durationMs: Date.now() - started
469
+ });
470
+ };
471
+ /**
472
+ * Signal the command's process group over a fresh channel: the timed-out
473
+ * channel is closing, and `channel.signal()` would only reach the outer
474
+ * shell anyway. `bare` keeps the kill out of the wrapper — a kill that
475
+ * hung would otherwise try to kill its own group.
476
+ */
477
+ const killGroup = (signal) => {
478
+ if (pgid === void 0) return;
479
+ this.exec(alias, `kill -${signal} -- -${pgid}`, {
480
+ timeoutMs: CONTROL_TIMEOUT_MS,
481
+ bare: true
482
+ }).catch(() => void 0);
483
+ };
484
+ const terminateGroup = () => {
485
+ killGroup("TERM");
486
+ setTimeout(() => {
487
+ if (!exited) killGroup("KILL");
488
+ }, GROUP_KILL_GRACE_MS).unref?.();
489
+ };
490
+ const timer = setTimeout(() => {
491
+ timedOut = true;
492
+ try {
493
+ stream?.close();
494
+ } catch {}
495
+ if (pgid !== void 0) terminateGroup();
496
+ else if (!session) warnOnce();
497
+ finish({
498
+ success: false,
499
+ exitCode: null,
500
+ timedOut: true,
501
+ stdout: out,
502
+ stderr: err
503
+ });
504
+ }, timeoutMs);
505
+ let stream;
506
+ let out = "";
507
+ let err = "";
508
+ conn.exec(session ? sessionLine(command) : command, (execError, channel) => {
509
+ if (execError !== void 0 && execError !== null) {
510
+ finish({
511
+ success: false,
512
+ exitCode: null,
513
+ timedOut: false,
514
+ stdout: "",
515
+ stderr: "",
516
+ error: execError.message
517
+ });
518
+ return;
519
+ }
520
+ if (settled) setTimeout(() => {
521
+ if (pgid === void 0) warnOnce();
522
+ try {
523
+ channel.close();
524
+ } catch {}
525
+ }, GROUP_KILL_GRACE_MS).unref?.();
526
+ else stream = channel;
527
+ channel.on("close", (code, signalName) => {
528
+ if (code !== null && code !== void 0 || signalName !== null && signalName !== void 0) exited = true;
529
+ if (timedOut && session && pgid === void 0) warnOnce();
530
+ finish({
531
+ success: code === 0,
532
+ exitCode: code,
533
+ timedOut: false,
534
+ stdout: out,
535
+ stderr: err
536
+ });
537
+ });
538
+ channel.on("data", (chunk) => {
539
+ out = appendCapped(out, chunk.toString("utf8"));
540
+ if (pgid !== void 0 || !markerPending) return;
541
+ const marker = takePgid(out);
542
+ if (marker.pgid !== void 0) {
543
+ pgid = marker.pgid;
544
+ out = marker.out;
545
+ markerPending = false;
546
+ if (timedOut) terminateGroup();
547
+ } else if (out.includes("\n")) markerPending = false;
548
+ });
549
+ channel.stderr.on("data", (chunk) => {
550
+ err = appendCapped(err, chunk.toString("utf8"));
551
+ });
552
+ });
553
+ });
554
+ } catch (error) {
555
+ this.release(alias);
556
+ return {
557
+ success: false,
558
+ exitCode: null,
559
+ timedOut: false,
560
+ stdout: "",
561
+ stderr: "",
562
+ durationMs: Date.now() - started,
563
+ error: error instanceof Error ? error.message : String(error)
564
+ };
565
+ }
566
+ }
567
+ /** Open an interactive PTY shell. The caller owns the channel lifecycle. */
568
+ async shell(alias, opts = {}) {
569
+ const conn = await this.acquire(alias);
570
+ this.hold(alias);
571
+ try {
572
+ const channel = await new Promise((resolve, reject) => {
573
+ conn.shell({
574
+ cols: opts.cols ?? 80,
575
+ rows: opts.rows ?? 24,
576
+ term: "xterm-256color"
577
+ }, (error, stream) => {
578
+ if (error !== void 0 && error !== null) reject(error);
579
+ else resolve(stream);
580
+ });
581
+ });
582
+ channel.once("close", () => this.release(alias));
583
+ return channel;
584
+ } catch (error) {
585
+ this.release(alias);
586
+ throw error;
587
+ }
588
+ }
589
+ /** Open one exec channel. The caller owns the channel lifecycle. */
590
+ async execChannel(alias, command) {
591
+ const conn = await this.acquire(alias);
592
+ this.hold(alias);
593
+ try {
594
+ const channel = await new Promise((resolve, reject) => {
595
+ conn.exec(command, (error, stream) => {
596
+ if (error !== void 0 && error !== null) reject(error);
597
+ else resolve(stream);
598
+ });
599
+ });
600
+ channel.once("close", () => this.release(alias));
601
+ return channel;
602
+ } catch (error) {
603
+ this.release(alias);
604
+ throw error;
605
+ }
606
+ }
607
+ /** Run one callback with a fresh SFTP session (closed afterwards). */
608
+ async withSftp(alias, fn, opts = {}) {
609
+ await this.acquireSlot(alias);
610
+ let conn;
611
+ try {
612
+ conn = await this.acquire(alias);
613
+ } catch (error) {
614
+ this.releaseSlot(alias);
615
+ throw error;
616
+ }
617
+ this.hold(alias);
618
+ try {
619
+ const sftp = await withDeadline(new Promise((resolve, reject) => {
620
+ conn.sftp((error, session) => {
621
+ if (error !== void 0 && error !== null) reject(error);
622
+ else resolve(session);
623
+ });
624
+ }), SFTP_OP_TIMEOUT_MS, `SFTP session open timed out after ${SFTP_OP_TIMEOUT_MS / 1e3}s`);
625
+ try {
626
+ const limit = opts.timeoutMs ?? SFTP_OP_TIMEOUT_MS;
627
+ if (limit <= 0) return await fn(sftp);
628
+ return await withDeadline(fn(sftp), limit, `SFTP operation timed out after ${limit / 1e3}s`);
629
+ } finally {
630
+ try {
631
+ sftp.end();
632
+ } catch {}
633
+ }
634
+ } finally {
635
+ this.release(alias);
636
+ this.releaseSlot(alias);
637
+ }
638
+ }
639
+ /** Upload one local file to an absolute remote path (parallel fastPut). */
640
+ async putFile(alias, localPath, remotePath) {
641
+ await this.withSftp(alias, async (sftp) => {
642
+ await new Promise((resolve, reject) => {
643
+ let settled = false;
644
+ let timer;
645
+ const finish = (error) => {
646
+ if (settled) return;
647
+ settled = true;
648
+ if (timer !== void 0) clearTimeout(timer);
649
+ if (error === void 0) resolve();
650
+ else reject(error);
651
+ };
652
+ const arm = () => {
653
+ if (timer !== void 0) clearTimeout(timer);
654
+ timer = setTimeout(() => {
655
+ try {
656
+ sftp.end();
657
+ } catch {}
658
+ finish(/* @__PURE__ */ new Error(`SFTP upload stalled: no progress for ${UPLOAD_IDLE_MS / 1e3}s`));
659
+ }, UPLOAD_IDLE_MS);
660
+ timer.unref?.();
661
+ };
662
+ arm();
663
+ sftp.fastPut(localPath, remotePath, { step: () => {
664
+ arm();
665
+ } }, (error) => {
666
+ finish(error === void 0 || error === null ? void 0 : new Error(error.message));
667
+ });
668
+ });
669
+ }, { timeoutMs: 0 });
670
+ }
671
+ /**
672
+ * Take one channel slot for `alias`, waiting FIFO when the limit is reached.
673
+ * The wait is bounded: waiting forever behind a peer that never finishes is
674
+ * worse than a readable error naming the cause and what to do about it.
675
+ */
676
+ async acquireSlot(alias) {
677
+ const state = this.slotState(alias);
678
+ if (state.active < CHANNEL_LIMIT) {
679
+ state.active += 1;
680
+ return;
681
+ }
682
+ await new Promise((resolve, reject) => {
683
+ const waiter = {
684
+ granted: false,
685
+ resolve,
686
+ reject,
687
+ timer: void 0
688
+ };
689
+ waiter.timer = setTimeout(() => {
690
+ if (waiter.granted) return;
691
+ waiter.granted = true;
692
+ const index = state.queue.indexOf(waiter);
693
+ if (index >= 0) state.queue.splice(index, 1);
694
+ reject(/* @__PURE__ */ new Error(`remote host ${alias} is busy: ${CHANNEL_LIMIT} operations already hold channels (this one waited ${CHANNEL_WAIT_MS / 1e3}s — retry once the earlier work finishes)`));
695
+ }, CHANNEL_WAIT_MS);
696
+ waiter.timer.unref?.();
697
+ state.queue.push(waiter);
698
+ });
699
+ }
700
+ /** Give a slot back, handing it straight to the longest-waiting operation. */
701
+ releaseSlot(alias) {
702
+ const state = this.slots.get(alias);
703
+ if (state === void 0) return;
704
+ const waiter = state.queue.shift();
705
+ if (waiter !== void 0) {
706
+ if (waiter.timer !== void 0) clearTimeout(waiter.timer);
707
+ waiter.granted = true;
708
+ waiter.resolve();
709
+ return;
710
+ }
711
+ if (state.active > 0) state.active -= 1;
712
+ if (state.active === 0) this.slots.delete(alias);
713
+ }
714
+ slotState(alias) {
715
+ const existing = this.slots.get(alias);
716
+ if (existing !== void 0) return existing;
717
+ const created = {
718
+ active: 0,
719
+ queue: []
720
+ };
721
+ this.slots.set(alias, created);
722
+ return created;
723
+ }
724
+ /**
725
+ * Whether this alias offers a util-linux-style `setsid --wait` — the only
726
+ * shape that both detaches the command into its own session AND reports its
727
+ * exit code (p2/ssh-proc.ts:7-21 learned that plain `setsid` double-forks and
728
+ * the channel then reports the wrapper's exit 0, which would turn every exit
729
+ * fact into fiction). The probe also verifies status propagation, so a broken
730
+ * setsid fails it instead of quietly corrupting exit codes.
731
+ *
732
+ * Probed once per alias: the probe is a whole extra channel, and exec is the
733
+ * hot path (SCM polling, search, agent tools). A remote without a compatible
734
+ * setsid keeps the pre-existing behaviour, where a timeout can only close the
735
+ * channel, and says so in the log.
736
+ *
737
+ * Only SUCCESS is remembered. A failed probe means "this host has no
738
+ * setsid OR this one call did not get through", and folding the second case
739
+ * into the first would be permanent: a single transient blip — a dropped
740
+ * connection, a 15 s probe timeout on a slow link — would silently switch the
741
+ * alias to the unwrapped path for the rest of the process, i.e. quietly
742
+ * disable the kill this wrapper exists to perform. Every other backoff in
743
+ * this codebase retries rather than blacklisting; so does this one now.
744
+ */
745
+ supportsSetsid(alias) {
746
+ const cached = this.setsidProbes.get(alias);
747
+ if (cached !== void 0) return cached;
748
+ const probe = this.exec(alias, SETSID_PROBE, {
749
+ timeoutMs: CONTROL_TIMEOUT_MS,
750
+ bare: true
751
+ }).then((result) => result.stdout.includes(SETSID_PROBE_MARK), () => false);
752
+ this.setsidProbes.set(alias, probe);
753
+ probe.then((supported) => {
754
+ if (!supported && this.setsidProbes.get(alias) === probe) this.setsidProbes.delete(alias);
755
+ });
756
+ return probe;
757
+ }
758
+ /** Hold the connection against idle eviction (balanced by release). */
759
+ hold(alias) {
760
+ const pooled = this.conns.get(alias);
761
+ if (pooled === void 0) return;
762
+ pooled.held += 1;
763
+ pooled.lastUse = Date.now();
764
+ if (pooled.timer !== void 0) {
765
+ clearTimeout(pooled.timer);
766
+ pooled.timer = void 0;
767
+ }
768
+ }
769
+ release(alias) {
770
+ const pooled = this.conns.get(alias);
771
+ if (pooled === void 0) return;
772
+ if (pooled.held > 0) pooled.held -= 1;
773
+ pooled.lastUse = Date.now();
774
+ if (pooled.held > 0) return;
775
+ if (pooled.timer !== void 0) clearTimeout(pooled.timer);
776
+ pooled.timer = setTimeout(() => this.drop(alias), IDLE_MS);
777
+ if (typeof pooled.timer === "object" && "unref" in pooled.timer) pooled.timer.unref();
778
+ }
779
+ async acquire(alias) {
780
+ const pooled = this.conns.get(alias);
781
+ if (pooled !== void 0 && pooled.connecting !== void 0) return pooled.connecting;
782
+ if (pooled !== void 0) {
783
+ pooled.lastUse = Date.now();
784
+ return pooled.conn;
785
+ }
786
+ const entry = {
787
+ conn: void 0,
788
+ timer: void 0,
789
+ lastUse: Date.now(),
790
+ connecting: void 0,
791
+ held: 0
792
+ };
793
+ this.conns.set(alias, entry);
794
+ entry.connecting = this.dial(alias).then((conn) => {
795
+ entry.conn = conn;
796
+ entry.connecting = void 0;
797
+ conn.on("close", () => {
798
+ if (this.conns.get(alias) === entry) this.drop(alias);
799
+ });
800
+ conn.on("error", () => {
801
+ if (this.conns.get(alias) === entry) this.drop(alias);
802
+ });
803
+ this.release(alias);
804
+ return conn;
805
+ }, (error) => {
806
+ this.conns.delete(alias);
807
+ throw error;
808
+ });
809
+ return entry.connecting;
810
+ }
811
+ dial(alias) {
812
+ const host = this.store.require(alias);
813
+ return new Promise((resolve, reject) => {
814
+ const conn = new import_lib.Client();
815
+ let settled = false;
816
+ const fail = (error) => {
817
+ if (settled) return;
818
+ settled = true;
819
+ try {
820
+ conn.end();
821
+ } catch {}
822
+ reject(explainAuthError(host, error));
823
+ };
824
+ conn.on("ready", () => {
825
+ if (settled) return;
826
+ settled = true;
827
+ resolve(conn);
828
+ });
829
+ conn.on("error", (error) => fail(error));
830
+ conn.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
831
+ const password = host.auth.kind === "password" ? host.auth.password : void 0;
832
+ if (password !== void 0 && prompts.length === 1 && prompts[0]?.echo === false) {
833
+ try {
834
+ finish([password]);
835
+ } catch {}
836
+ return;
837
+ }
838
+ fail(/* @__PURE__ */ new Error("server requested keyboard-interactive auth (unsupported shape; use key auth or a password challenge)"));
839
+ try {
840
+ finish([]);
841
+ } catch {}
842
+ });
843
+ const hostVerifier = (key) => {
844
+ const fingerprint = createHash("sha256").update(key).digest("base64");
845
+ const known = this.hostKeys.get(alias);
846
+ if (known === void 0) this.hostKeys.set(alias, fingerprint);
847
+ else if (known !== fingerprint) console.warn(`[dsh-remote-vs] host key changed for ${alias}: was ${known}, now ${fingerprint}`);
848
+ return true;
849
+ };
850
+ try {
851
+ if (host.auth.kind === "key") {
852
+ const keyPath = host.auth.keyPath;
853
+ conn.connect({
854
+ host: host.host,
855
+ port: host.port,
856
+ username: host.user,
857
+ privateKey: readFileSync(keyPath),
858
+ passphrase: host.auth.passphrase,
859
+ readyTimeout: READY_TIMEOUT_MS,
860
+ keepaliveInterval: 3e4,
861
+ keepaliveCountMax: 3,
862
+ hostVerifier
863
+ });
864
+ } else conn.connect({
865
+ host: host.host,
866
+ port: host.port,
867
+ username: host.user,
868
+ password: host.auth.password,
869
+ tryKeyboard: true,
870
+ readyTimeout: READY_TIMEOUT_MS,
871
+ keepaliveInterval: 3e4,
872
+ keepaliveCountMax: 3,
873
+ hostVerifier
874
+ });
875
+ } catch (error) {
876
+ fail(error instanceof Error ? error : new Error(String(error)));
877
+ }
878
+ });
879
+ }
880
+ };
881
+ /**
882
+ * Translate ssh2's generic auth failure into something actionable. Never
883
+ * includes secrets — only the entry shape and the likely server-side causes.
884
+ */
885
+ function explainAuthError(host, error) {
886
+ if (host.auth.kind !== "password" || !/authentication methods failed/i.test(error.message)) return error;
887
+ const hints = ["verify the password (it may have changed/expired)", "many servers only offer keyboard-interactive (now auto-answered — retry the test)"];
888
+ if (host.user === "root") hints.push("OpenSSH 7+ defaults prohibit password login for root (PermitRootLogin); use a non-root user or switch to key auth");
889
+ return /* @__PURE__ */ new Error(`${error.message} — password rejected by the server; ${hints.join("; ")}`);
890
+ }
891
+ /** Append with a 2 MiB cap and a truncation marker. */
892
+ function appendCapped(current, chunk) {
893
+ if (current.length >= OUTPUT_CAP) return current;
894
+ const next = current + chunk;
895
+ if (next.length <= OUTPUT_CAP) return next;
896
+ return next.slice(0, OUTPUT_CAP) + TRUNCATED_MARKER;
897
+ }
898
+ /**
899
+ * Put one shell line in its own session/process group.
900
+ *
901
+ * `setsid --wait` is what makes the timeout able to end the command: the
902
+ * wrapper echoes its own pid, which IS the new session's process-group id, and
903
+ * this pid is what `kill -- -<pgid>` needs. Everything the command spawns stays
904
+ * in that group, so a pipeline's whole tree dies with it — `channel.close()`
905
+ * only releases our side, and `channel.signal()` addresses the outer shell.
906
+ *
907
+ * `$0` is the login shell sshd itself used for this line, so a bashism in a
908
+ * caller's line keeps working on a remote whose /bin/sh is dash (a leading dash
909
+ * — login-shell argv[0] — is not an executable, hence the fallback to `sh`).
910
+ * The line travels as a positional argument rather than being re-quoted into
911
+ * the wrapper, so quoting and `$` expansion stay exactly as they were.
912
+ */
913
+ function sessionLine(command) {
914
+ return `rv_shell="\${0:-sh}"; case "\$rv_shell" in -*) rv_shell=sh;; esac; exec ${`setsid --wait "$rv_shell" -c 'echo ${PGID_MARKER}$$; exec "$0" -c "$1"' "$rv_shell" ${shq$1(command)}`}`;
915
+ }
916
+ /** Single-quote one word for POSIX sh (same quoting p2/ssh-proc.ts uses). */
917
+ function shq$1(word) {
918
+ return `'${word.replaceAll("'", `'\\''`)}'`;
919
+ }
920
+ /**
921
+ * Consume the session wrapper's leading `RV_PID=<pgid>` line. The wrapper
922
+ * writes it before it execs the target, so it is the first line of the stream
923
+ * by construction; anything else is left untouched, which keeps a command that
924
+ * happens to print a similar first line intact.
925
+ */
926
+ function takePgid(out) {
927
+ const newline = out.indexOf("\n");
928
+ if (newline < 0) return {
929
+ pgid: void 0,
930
+ out
931
+ };
932
+ const line = out.slice(0, newline).replace(/\r$/, "");
933
+ if (!line.startsWith(PGID_MARKER)) return {
934
+ pgid: void 0,
935
+ out
936
+ };
937
+ const digits = line.slice(7);
938
+ if (!/^\d+$/.test(digits)) return {
939
+ pgid: void 0,
940
+ out
941
+ };
942
+ return {
943
+ pgid: Number(digits),
944
+ out: out.slice(newline + 1)
945
+ };
946
+ }
947
+ /**
948
+ * A timed-out line whose process group was never learned cannot be killed, so
949
+ * say it out loud instead of pretending the command stopped.
950
+ */
951
+ function warnDetached(alias) {
952
+ console.warn(`[dsh-remote-vs] exec timed out on ${alias}: the channel is closed but the remote command may still be running (no killable process group)`);
953
+ }
954
+ /**
955
+ * Reject when `promise` has not settled within `ms`, so a stalled remote
956
+ * callback cannot keep a session (and its pooled connection) alive forever.
957
+ */
958
+ function withDeadline(promise, ms, message) {
959
+ return new Promise((resolve, reject) => {
960
+ const timer = setTimeout(() => reject(new Error(message)), ms);
961
+ timer.unref?.();
962
+ promise.then((value) => {
963
+ clearTimeout(timer);
964
+ resolve(value);
965
+ }, (error) => {
966
+ clearTimeout(timer);
967
+ reject(error instanceof Error ? error : new Error(String(error)));
968
+ });
969
+ });
970
+ }
971
+ //#endregion
972
+ //#region src/http.ts
973
+ const JSON_HEADERS = {
974
+ "content-type": "application/json; charset=utf-8",
975
+ "referrer-policy": "no-referrer"
976
+ };
977
+ /**
978
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
979
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
980
+ */
981
+ async function readJsonBody(req, opts = {}) {
982
+ const maxBytes = opts.maxBytes ?? 64 * 1024;
983
+ const chunks = [];
984
+ let size = 0;
985
+ for await (const chunk of req) {
986
+ const buffer = chunk;
987
+ size += buffer.length;
988
+ if (size > maxBytes) {
989
+ req.destroy();
990
+ return null;
991
+ }
992
+ chunks.push(buffer);
993
+ }
994
+ const text = Buffer.concat(chunks).toString("utf8");
995
+ if (text === "") return null;
996
+ try {
997
+ const parsed = JSON.parse(text);
998
+ if (opts.objectOnly && !isJsonObject(parsed)) return null;
999
+ return parsed;
1000
+ } catch {
1001
+ return null;
1002
+ }
1003
+ }
1004
+ function isJsonObject(value) {
1005
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1006
+ }
1007
+ /** Write one JSON response. */
1008
+ function writeJson(res, status, body, headers = {}) {
1009
+ res.writeHead(status, {
1010
+ ...JSON_HEADERS,
1011
+ ...headers
1012
+ });
1013
+ res.end(JSON.stringify(body));
1014
+ }
1015
+ //#endregion
1016
+ //#region src/loopback.ts
1017
+ function isIPv4Loopback(v4) {
1018
+ const parts = v4.split(".");
1019
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1020
+ }
1021
+ function isLoopbackAddress(address) {
1022
+ if (address === void 0) return false;
1023
+ const normalized = address.toLowerCase();
1024
+ if (normalized === "::1") return true;
1025
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
1026
+ return isIPv4Loopback(normalized);
1027
+ }
1028
+ function isLoopbackHostname(hostname) {
1029
+ if (hostname === "localhost" || hostname === "[::1]") return true;
1030
+ return isIPv4Loopback(hostname);
1031
+ }
1032
+ function isLoopbackRequest(request) {
1033
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
1034
+ const host = request.headers.host;
1035
+ if (typeof host !== "string") return false;
1036
+ let hostUrl;
1037
+ try {
1038
+ hostUrl = new URL("http://" + host);
1039
+ } catch {
1040
+ return false;
1041
+ }
1042
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
1043
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
1044
+ const origin = request.headers.origin;
1045
+ if (origin === void 0) return true;
1046
+ try {
1047
+ return new URL(origin).host === hostUrl.host;
1048
+ } catch {
1049
+ return false;
1050
+ }
1051
+ }
1052
+ //#endregion
1053
+ //#region src/protocol.ts
1054
+ /**
1055
+ * Wire contract between the host half (routes.ts) and the browser half
1056
+ * (client/api.ts). Pure types + path constants only — imported by both
1057
+ * halves, bundled into each, no runtime identity to share.
1058
+ */
1059
+ /** API route table (all loopback-only). */
1060
+ const REMOTE_API = {
1061
+ hosts: "/api/dsh-remote/hosts",
1062
+ test: "/api/dsh-remote/test",
1063
+ keygen: "/api/dsh-remote/keygen",
1064
+ exec: "/api/dsh-remote/exec",
1065
+ files: "/api/dsh-remote/files",
1066
+ /** Streaming binary upload: raw request body, host and path in the query. */
1067
+ upload: "/api/dsh-remote/upload",
1068
+ workspaces: "/api/dsh-remote/workspaces",
1069
+ deploy: "/api/dsh-remote/deploy",
1070
+ importSshConfig: "/api/dsh-remote/import-ssh-config",
1071
+ importDshSsh: "/api/dsh-remote/import-dsh-ssh",
1072
+ terminal: "/api/dsh-remote/terminal",
1073
+ terminalClose: "/api/dsh-remote/terminal-close",
1074
+ routing: "/api/dsh-remote/routing",
1075
+ /** Current working directory of one live session (click resolution helper). */
1076
+ sessionCwd: "/api/dsh-remote/session-cwd",
1077
+ /** What one live session changed on its remote host (the review panel). */
1078
+ changes: "/api/dsh-remote/changes"
1079
+ };
1080
+ //#endregion
1081
+ //#region src/keygen.ts
1082
+ /**
1083
+ * Password -> key bootstrap (Q2 decision): a password host gets a dedicated
1084
+ * ed25519 keypair generated locally (ssh-keygen), the public half is appended
1085
+ * to the remote ~/.ssh/authorized_keys over the password connection, and the
1086
+ * store entry flips to key auth with the managed keyPath.
1087
+ */
1088
+ const execFileAsync = promisify(execFile);
1089
+ /** Ensure the managed keypair exists; return the private key path. */
1090
+ async function ensureKeypair(alias, dir = keysDir()) {
1091
+ ensurePrivateDir(dir);
1092
+ const privatePath = join(dir, alias);
1093
+ const publicPath = `${privatePath}.pub`;
1094
+ if (!existsSync(privatePath) || !existsSync(publicPath)) {
1095
+ try {
1096
+ await execFileAsync("ssh-keygen", [
1097
+ "-t",
1098
+ "ed25519",
1099
+ "-f",
1100
+ privatePath,
1101
+ "-N",
1102
+ "",
1103
+ "-C",
1104
+ `dsh-remote-vs/${alias}`
1105
+ ]);
1106
+ } catch (error) {
1107
+ throw new Error(`ssh-keygen failed (required for key bootstrap): ${error instanceof Error ? error.message : String(error)}`);
1108
+ }
1109
+ try {
1110
+ chmodSync(privatePath, 384);
1111
+ } catch {}
1112
+ }
1113
+ const publicKey = readFileSync(publicPath, "utf8").trim();
1114
+ if (!publicKey.startsWith("ssh-ed25519 ")) throw new Error(`unexpected public key format in ${publicPath}`);
1115
+ return {
1116
+ privatePath,
1117
+ publicKey
1118
+ };
1119
+ }
1120
+ /**
1121
+ * Bootstrap one host: install the managed public key remotely, then flip the
1122
+ * entry to key auth and drop the pooled password connection.
1123
+ */
1124
+ async function bootstrap(pool, store, alias) {
1125
+ const entry = store.require(alias);
1126
+ if (entry.auth.kind !== "password") return {
1127
+ alias,
1128
+ keyPath: entry.auth.keyPath ?? "",
1129
+ flipped: false,
1130
+ detail: "host already uses key auth"
1131
+ };
1132
+ const { privatePath, publicKey } = await ensureKeypair(alias);
1133
+ const script = [
1134
+ "mkdir -p ~/.ssh && chmod 700 ~/.ssh",
1135
+ `touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
1136
+ `grep -qxF '${publicKey}' ~/.ssh/authorized_keys 2>/dev/null || echo '${publicKey}' >> ~/.ssh/authorized_keys`
1137
+ ].join(" && ");
1138
+ const result = await pool.exec(alias, script, { timeoutMs: 3e4 });
1139
+ if (!result.success) throw new Error(`key install failed [exit code: ${result.exitCode}]: ${result.stderr || result.stdout || result.error || "unknown"}`);
1140
+ store.update(alias, { auth: {
1141
+ kind: "key",
1142
+ keyPath: privatePath
1143
+ } });
1144
+ pool.drop(alias);
1145
+ const check = await pool.test(alias);
1146
+ if (!check.ok) throw new Error(`key installed but key login failed: ${check.error} (password entry was already flipped; fix the key or re-enter the password)`);
1147
+ return {
1148
+ alias,
1149
+ keyPath: privatePath,
1150
+ flipped: true,
1151
+ detail: "public key installed; entry now uses key auth"
1152
+ };
1153
+ }
1154
+ //#endregion
1155
+ //#region src/importers.ts
1156
+ /**
1157
+ * One-shot importers: ~/.ssh/config and the existing dsh-ssh.json. Both only
1158
+ * READ the source and produce payloads for our own store — no runtime coupling.
1159
+ * P0 supports direct key/password hosts only; jump/proxy/agent entries are
1160
+ * reported as skipped with reasons.
1161
+ */
1162
+ /** Expand a leading ~ to the user home. */
1163
+ function expandHome(path) {
1164
+ if (path === "~") return homedir();
1165
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
1166
+ return path;
1167
+ }
1168
+ /** Parse OpenSSH client config text into host payloads. */
1169
+ function parseSshConfig(text) {
1170
+ const payloads = [];
1171
+ const skipped = [];
1172
+ const blocks = [];
1173
+ let current;
1174
+ for (const rawLine of text.split("\n")) {
1175
+ const line = rawLine.trim();
1176
+ if (line === "" || line.startsWith("#")) continue;
1177
+ const match = /^(\S+)\s+(.*)$/.exec(line);
1178
+ if (match === null) continue;
1179
+ const key = match[1].toLowerCase();
1180
+ let value = match[2].trim().replace(/^"(.*)"$/, "$1");
1181
+ const hash = value.indexOf(" #");
1182
+ if (hash >= 0) value = value.slice(0, hash).trim();
1183
+ if (key === "host") {
1184
+ current = {
1185
+ patterns: value.split(/\s+/),
1186
+ fields: /* @__PURE__ */ new Map()
1187
+ };
1188
+ blocks.push(current);
1189
+ } else if (key === "match") {
1190
+ current = void 0;
1191
+ skipped.push({
1192
+ alias: "(match)",
1193
+ reason: "Match conditional blocks are not imported"
1194
+ });
1195
+ } else if (current !== void 0 && !current.fields.has(key)) current.fields.set(key, value);
1196
+ }
1197
+ for (const block of blocks) {
1198
+ const alias = block.patterns[0];
1199
+ if (alias === void 0 || alias.includes("*") || alias.includes("?") || alias.includes("!")) {
1200
+ skipped.push({
1201
+ alias: block.patterns.join(" ") || "(empty)",
1202
+ reason: "wildcard patterns are not imported"
1203
+ });
1204
+ continue;
1205
+ }
1206
+ if (block.fields.has("proxyjump") || block.fields.has("proxycommand")) {
1207
+ skipped.push({
1208
+ alias,
1209
+ reason: "proxyJump/proxyCommand needs P1 (direct hosts only in P0)"
1210
+ });
1211
+ continue;
1212
+ }
1213
+ const identityFile = block.fields.get("identityfile")?.split(/\s+/)[0];
1214
+ payloads.push({
1215
+ alias,
1216
+ host: block.fields.get("hostname") ?? alias,
1217
+ port: block.fields.get("port") !== void 0 ? Number(block.fields.get("port")) : 22,
1218
+ user: block.fields.get("user") ?? "",
1219
+ auth: identityFile !== void 0 ? {
1220
+ kind: "key",
1221
+ keyPath: expandHome(identityFile)
1222
+ } : {
1223
+ kind: "password",
1224
+ password: ""
1225
+ },
1226
+ tags: ["imported:ssh-config"]
1227
+ });
1228
+ }
1229
+ return {
1230
+ payloads,
1231
+ skipped
1232
+ };
1233
+ }
1234
+ /** Read the default ~/.ssh/config (throws when missing). */
1235
+ function readDefaultSshConfig() {
1236
+ const file = join(homedir(), ".ssh", "config");
1237
+ if (!existsSync(file)) throw new Error(`no such file: ${file}`);
1238
+ return readFileSync(file, "utf8");
1239
+ }
1240
+ /** Migrate entries from the third-party dsh-ssh.json (one-shot copy). */
1241
+ function migrateDshSsh(home = dshHome()) {
1242
+ const file = join(home, "dsh-ssh.json");
1243
+ if (!existsSync(file)) throw new Error(`no such file: ${file} (nothing to import)`);
1244
+ const doc = JSON.parse(readFileSync(file, "utf8"));
1245
+ if (!Array.isArray(doc.hosts)) throw new Error(`${file}: hosts is not an array`);
1246
+ const payloads = [];
1247
+ const skipped = [];
1248
+ for (const raw of doc.hosts) {
1249
+ const alias = typeof raw.alias === "string" ? raw.alias : "(unknown)";
1250
+ const auth = raw.auth ?? {};
1251
+ const kind = auth.kind;
1252
+ const proxyJump = raw.proxyJump ?? [];
1253
+ const proxyCommand = raw.proxyCommand;
1254
+ if (kind !== "key" && kind !== "password") {
1255
+ skipped.push({
1256
+ alias,
1257
+ reason: `auth kind '${String(kind)}' needs P1 (key/password only in P0)`
1258
+ });
1259
+ continue;
1260
+ }
1261
+ if (proxyJump.length > 0 || typeof proxyCommand === "string" && proxyCommand !== "") {
1262
+ skipped.push({
1263
+ alias,
1264
+ reason: "proxyJump/proxyCommand needs P1 (direct hosts only in P0)"
1265
+ });
1266
+ continue;
1267
+ }
1268
+ if (kind === "key" && typeof auth.keyPath !== "string") {
1269
+ skipped.push({
1270
+ alias,
1271
+ reason: "key auth without keyPath"
1272
+ });
1273
+ continue;
1274
+ }
1275
+ if (kind === "password" && typeof auth.password !== "string") {
1276
+ skipped.push({
1277
+ alias,
1278
+ reason: "password auth without stored password (enter it manually)"
1279
+ });
1280
+ continue;
1281
+ }
1282
+ payloads.push({
1283
+ alias,
1284
+ host: typeof raw.host === "string" ? raw.host : "",
1285
+ port: typeof raw.port === "number" ? raw.port : 22,
1286
+ user: typeof raw.user === "string" ? raw.user : "",
1287
+ auth: kind === "key" ? {
1288
+ kind: "key",
1289
+ keyPath: auth.keyPath,
1290
+ ...typeof auth.passphrase === "string" ? { passphrase: auth.passphrase } : {}
1291
+ } : {
1292
+ kind: "password",
1293
+ password: auth.password
1294
+ },
1295
+ ...typeof raw.description === "string" && raw.description !== "" ? { description: raw.description } : {},
1296
+ tags: ["imported:dsh-ssh"]
1297
+ });
1298
+ }
1299
+ return {
1300
+ payloads,
1301
+ skipped
1302
+ };
1303
+ }
1304
+ //#endregion
1305
+ //#region src/terminal.ts
1306
+ /**
1307
+ * Remote PTY terminal sessions: one ssh2 shell per session, surviving socket
1308
+ * detaches (reattach by id). Frames are JSON text (see protocol.ts).
1309
+ */
1310
+ /**
1311
+ * A session with no attached socket is kept this long so that a page reload
1312
+ * can reattach by id. Past it the session closes and the remote shell dies.
1313
+ *
1314
+ * Without this sweep nothing ever released the session: its `pool.hold` never
1315
+ * returned to zero, so the connection could not idle out, and the remote sshd
1316
+ * session slot stayed taken for the life of the host process. Ten minutes is
1317
+ * far longer than a reload needs and far shorter than "until you restart DSH".
1318
+ */
1319
+ const EMPTY_SOCKET_TTL_MS = 10 * 6e4;
1320
+ /**
1321
+ * Terminal output is a live stream with no natural end, so it needs a bound.
1322
+ * When a socket falls this far behind, pause the channel — that pushes the
1323
+ * brake all the way back to the remote command, instead of growing the host
1324
+ * process. Resume once the socket has drained.
1325
+ *
1326
+ * The pause is real backpressure, not a local buffer: ssh2 only sends a window
1327
+ * adjustment while the stream is flowing, so a paused channel lets the peer's
1328
+ * send window drain to zero and the remote command blocks on its own write.
1329
+ * The retained ceiling is therefore this mark plus ssh2's window (2 MiB) plus
1330
+ * whatever is in flight — bounded, but not exactly 4 MiB.
1331
+ *
1332
+ * The counter is per session, not per socket: one lagging socket pauses the
1333
+ * channel for every socket attached to that shell. That is deliberate — the
1334
+ * alternative is letting the slowest consumer grow the host process.
1335
+ */
1336
+ const OUT_HIGH_WATER = 4 * 1024 * 1024;
1337
+ const OUT_LOW_WATER = 1 * 1024 * 1024;
1338
+ /**
1339
+ * The largest client frame accepted, in bytes.
1340
+ *
1341
+ * The only frame with a payload is `in` — keystrokes, and pastes, which the
1342
+ * client chunks to stay under this. The upgrade server (`routes.ts`) passes
1343
+ * this value as ws's `maxPayload`, so an oversized frame is refused before it
1344
+ * is buffered at all; the check in `hookSocket` is the second layer, and it is
1345
+ * what keeps such a frame from becoming a string plus an object graph in the
1346
+ * process that also runs the agent.
1347
+ */
1348
+ const TERMINAL_MAX_FRAME_BYTES = 1024 * 1024;
1349
+ var TerminalSessionRegistry = class {
1350
+ pool;
1351
+ sessions = /* @__PURE__ */ new Map();
1352
+ constructor(pool) {
1353
+ this.pool = pool;
1354
+ }
1355
+ /** Open a shell and attach the first socket. */
1356
+ async open(ws, alias, cols, rows) {
1357
+ const id = randomUUID().slice(0, 8);
1358
+ let channel;
1359
+ try {
1360
+ channel = await this.pool.shell(alias, {
1361
+ cols,
1362
+ rows
1363
+ });
1364
+ } catch (error) {
1365
+ this.send(ws, {
1366
+ t: "error",
1367
+ message: error instanceof Error ? error.message : String(error)
1368
+ });
1369
+ try {
1370
+ ws.close(1011);
1371
+ } catch {}
1372
+ throw error;
1373
+ }
1374
+ const session = {
1375
+ id,
1376
+ alias,
1377
+ channel,
1378
+ alive: true,
1379
+ exitCode: null,
1380
+ sockets: /* @__PURE__ */ new Set(),
1381
+ pending: 0,
1382
+ paused: false,
1383
+ reap: void 0
1384
+ };
1385
+ this.sessions.set(id, session);
1386
+ channel.on("data", (chunk) => {
1387
+ this.broadcast(session, {
1388
+ t: "out",
1389
+ d: chunk.toString("utf8")
1390
+ });
1391
+ });
1392
+ channel.stderr.on("data", (chunk) => {
1393
+ this.broadcast(session, {
1394
+ t: "out",
1395
+ d: chunk.toString("utf8")
1396
+ });
1397
+ });
1398
+ channel.on("close", (code) => {
1399
+ session.alive = false;
1400
+ session.exitCode = code;
1401
+ this.broadcast(session, {
1402
+ t: "exit",
1403
+ code
1404
+ });
1405
+ for (const socket of session.sockets) try {
1406
+ socket.close(1e3);
1407
+ } catch {}
1408
+ });
1409
+ this.hookSocket(session, ws);
1410
+ this.send(ws, {
1411
+ t: "ready",
1412
+ id
1413
+ });
1414
+ return id;
1415
+ }
1416
+ /**
1417
+ * Attach another socket to a live session; false when unknown/dead.
1418
+ *
1419
+ * The size in the URL is deliberately NOT applied here. It is the same
1420
+ * fixed 80x24 the view uses to open a shell, and re-applying it on every
1421
+ * reattach — which is what a page reload does — SHRANK a running shell:
1422
+ * SIGWINCH, and a full-screen program re-laying itself out at 80 columns,
1423
+ * until that tab became visible and refit. The session already has a size.
1424
+ * The socket sends a real `resize` frame as soon as it can measure itself
1425
+ * (`XTerm.pushSize`), which is the only place that knows how wide it is.
1426
+ *
1427
+ * The parameters stay because the upgrade handler owns the URL and passes
1428
+ * them on both paths; they matter to `open` (the PTY's initial size) only.
1429
+ */
1430
+ attach(ws, id, _cols, _rows) {
1431
+ const session = this.sessions.get(id);
1432
+ if (session === void 0 || !session.alive) return false;
1433
+ this.hookSocket(session, ws);
1434
+ this.send(ws, {
1435
+ t: "ready",
1436
+ id
1437
+ });
1438
+ return true;
1439
+ }
1440
+ /** Close one session (kills the remote shell). */
1441
+ close(id) {
1442
+ const session = this.sessions.get(id);
1443
+ if (session === void 0) return false;
1444
+ this.sessions.delete(id);
1445
+ this.cancelReap(session);
1446
+ try {
1447
+ session.channel.close();
1448
+ } catch {}
1449
+ for (const socket of session.sockets) try {
1450
+ socket.close(1e3);
1451
+ } catch {}
1452
+ session.sockets.clear();
1453
+ return true;
1454
+ }
1455
+ /** Close every session (plugin unload). */
1456
+ dispose() {
1457
+ for (const id of [...this.sessions.keys()]) this.close(id);
1458
+ }
1459
+ list() {
1460
+ return [...this.sessions.values()].map((s) => ({
1461
+ id: s.id,
1462
+ alias: s.alias,
1463
+ alive: s.alive
1464
+ }));
1465
+ }
1466
+ hookSocket(session, ws) {
1467
+ this.cancelReap(session);
1468
+ session.sockets.add(ws);
1469
+ ws.on("message", (raw) => {
1470
+ if ((Array.isArray(raw) ? raw.reduce((total, part) => total + part.byteLength, 0) : raw.byteLength) > 1048576) {
1471
+ try {
1472
+ ws.close(1009);
1473
+ } catch {}
1474
+ return;
1475
+ }
1476
+ let frame;
1477
+ try {
1478
+ frame = JSON.parse(String(raw));
1479
+ } catch {
1480
+ return;
1481
+ }
1482
+ if (!session.alive) return;
1483
+ if (frame.t === "in" && typeof frame.d === "string") try {
1484
+ session.channel.write(frame.d);
1485
+ } catch {}
1486
+ else if (frame.t === "resize" && Number.isFinite(frame.cols) && Number.isFinite(frame.rows)) try {
1487
+ session.channel.setWindow(frame.rows, frame.cols, 0, 0);
1488
+ } catch {}
1489
+ });
1490
+ const detach = () => {
1491
+ session.sockets.delete(ws);
1492
+ if (session.sockets.size > 0) return;
1493
+ session.pending = 0;
1494
+ this.setPaused(session, false);
1495
+ this.armReap(session);
1496
+ };
1497
+ ws.on("close", detach);
1498
+ ws.on("error", detach);
1499
+ }
1500
+ /** Keep the shell for a while (reload can reattach), then close it for good. */
1501
+ armReap(session) {
1502
+ this.cancelReap(session);
1503
+ const timer = setTimeout(() => {
1504
+ session.reap = void 0;
1505
+ if (this.sessions.get(session.id) !== session) return;
1506
+ if (session.sockets.size === 0) this.close(session.id);
1507
+ }, EMPTY_SOCKET_TTL_MS);
1508
+ timer.unref?.();
1509
+ session.reap = timer;
1510
+ }
1511
+ cancelReap(session) {
1512
+ if (session.reap === void 0) return;
1513
+ clearTimeout(session.reap);
1514
+ session.reap = void 0;
1515
+ }
1516
+ setPaused(session, paused) {
1517
+ if (session.paused === paused) return;
1518
+ session.paused = paused;
1519
+ try {
1520
+ if (paused) session.channel.pause();
1521
+ else session.channel.resume();
1522
+ } catch {}
1523
+ }
1524
+ send(ws, frame) {
1525
+ try {
1526
+ ws.send(JSON.stringify(frame));
1527
+ } catch {}
1528
+ }
1529
+ broadcast(session, frame) {
1530
+ const text = JSON.stringify(frame);
1531
+ const size = Buffer.byteLength(text, "utf8");
1532
+ for (const socket of session.sockets) {
1533
+ let settled = false;
1534
+ const done = () => {
1535
+ if (settled) return;
1536
+ settled = true;
1537
+ session.pending = Math.max(0, session.pending - size);
1538
+ if (session.pending <= OUT_LOW_WATER) this.setPaused(session, false);
1539
+ };
1540
+ session.pending += size;
1541
+ try {
1542
+ socket.send(text, done);
1543
+ } catch {
1544
+ done();
1545
+ }
1546
+ }
1547
+ if (session.pending > OUT_HIGH_WATER) this.setPaused(session, true);
1548
+ }
1549
+ };
1550
+ //#endregion
1551
+ //#region src/timeouts.ts
1552
+ /** Smallest accepted timeout: below this, local work would start failing. */
1553
+ const MIN_TIMEOUT_MS = 1e3;
1554
+ /** Largest accepted timeout — the exec route clamps to the same ceiling. */
1555
+ const MAX_TIMEOUT_MS = 6e5;
1556
+ /**
1557
+ * Default for one git command (status/log/branches/diff over exec).
1558
+ *
1559
+ * 120 s was the old internal value and 45 s the old client deadline; both were
1560
+ * routinely exceeded by a cold `git status` on a large monorepo. Five minutes
1561
+ * still fails eventually (a wedged host must not spin forever) but outlasts any
1562
+ * plausible cold index walk.
1563
+ */
1564
+ const DEFAULT_GIT_TIMEOUT_MS = 3e5;
1565
+ /** Default for one SFTP file read (editor, diff sides, document preview). */
1566
+ const DEFAULT_READ_TIMEOUT_MS = 12e4;
1567
+ /** The pair every half starts from before any Host answer arrives. */
1568
+ const DEFAULT_TIMEOUTS = {
1569
+ gitMs: DEFAULT_GIT_TIMEOUT_MS,
1570
+ readMs: DEFAULT_READ_TIMEOUT_MS
1571
+ };
1572
+ /**
1573
+ * Clamp one untrusted timeout into the accepted range.
1574
+ *
1575
+ * @param value - raw value (plugin config, JSON body, anything).
1576
+ * @param fallback - used when the value is not a usable number.
1577
+ * @returns a whole number of milliseconds within [MIN, MAX].
1578
+ */
1579
+ function clampTimeout(value, fallback) {
1580
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
1581
+ const whole = Math.floor(value);
1582
+ if (whole < 1e3) return MIN_TIMEOUT_MS;
1583
+ if (whole > 6e5) return MAX_TIMEOUT_MS;
1584
+ return whole;
1585
+ }
1586
+ /**
1587
+ * Clamp an optional request timeout: absent/invalid stays absent, which is what
1588
+ * the exec route forwards as "use the pool default".
1589
+ *
1590
+ * @param value - raw `timeoutMs` from a request body.
1591
+ * @returns the clamped timeout, or undefined when the caller named none.
1592
+ */
1593
+ function clampExecTimeout(value) {
1594
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
1595
+ return clampTimeout(value, DEFAULT_GIT_TIMEOUT_MS);
1596
+ }
1597
+ /** Resolve the effective pair from raw config values. */
1598
+ function resolveTimeouts(config) {
1599
+ return {
1600
+ gitMs: clampTimeout(config?.gitTimeoutMs, DEFAULT_GIT_TIMEOUT_MS),
1601
+ readMs: clampTimeout(config?.readTimeoutMs, DEFAULT_READ_TIMEOUT_MS)
1602
+ };
1603
+ }
1604
+ //#endregion
1605
+ //#region src/routes.ts
1606
+ /** One noServer WebSocket server for terminal upgrades (RFC 6455). */
1607
+ const terminalWss = new WebSocketServer({
1608
+ noServer: true,
1609
+ maxPayload: TERMINAL_MAX_FRAME_BYTES
1610
+ });
1611
+ function queryParam(url, name) {
1612
+ const value = url.searchParams.get(name);
1613
+ return value === null ? void 0 : value;
1614
+ }
1615
+ /** This plugin's version from the shipped package.json (Settings display). */
1616
+ let cachedVersion;
1617
+ function pluginVersion() {
1618
+ if (cachedVersion !== void 0) return cachedVersion;
1619
+ try {
1620
+ let dir = dirname(fileURLToPath(import.meta.url));
1621
+ for (let depth = 0; depth < 4; depth += 1) {
1622
+ const pkg = join(dir, "package.json");
1623
+ try {
1624
+ if (existsSync(pkg)) {
1625
+ const raw = JSON.parse(readFileSync(pkg, "utf8"));
1626
+ if (raw.name === "dsh-remote-vs" && typeof raw.version === "string" && raw.version !== "") {
1627
+ cachedVersion = raw.version;
1628
+ return cachedVersion;
1629
+ }
1630
+ }
1631
+ } catch {}
1632
+ dir = dirname(dir);
1633
+ }
1634
+ } catch {}
1635
+ cachedVersion = "unknown";
1636
+ return cachedVersion;
1637
+ }
1638
+ /** Package name our loader row is mounted from (ns discovery key). */
1639
+ const REMOTE_PACKAGE_NAME = "dsh-remote-vs";
1640
+ /**
1641
+ * The working directory of one LIVE session, or undefined.
1642
+ *
1643
+ * Soft reads only: a composition without `agents`/`workingDirectory` answers
1644
+ * undefined instead of throwing, and the callers treat that as "unknown".
1645
+ */
1646
+ function liveSessionCwd(ctx, sessionId) {
1647
+ try {
1648
+ const agents = ctx.get("agents");
1649
+ const workingDirectory = ctx.get("workingDirectory");
1650
+ const agent = agents?.get?.(sessionId);
1651
+ if (agent?.session !== void 0 && typeof workingDirectory?.get === "function") {
1652
+ const value = workingDirectory.get(agent.session);
1653
+ if (typeof value === "string" && value !== "") return value;
1654
+ }
1655
+ } catch {}
1656
+ }
1657
+ /**
1658
+ * This plugin's own loader entry id (the settings-form namespace).
1659
+ *
1660
+ * Settings are addressed by profile entry id, which is a mount choice, not
1661
+ * a package property — an aggregate bundle may mount this package under any
1662
+ * id. So the row is identified by package name plus fiber identity, with an
1663
+ * enabled same-name row as fallback (the dsh-better-sidebar pattern). All
1664
+ * reads are soft: unknown compositions answer undefined instead of throwing.
1665
+ */
1666
+ function ownSettingsNs(ctx) {
1667
+ let fallback;
1668
+ try {
1669
+ const loader = ctx.get("loader");
1670
+ if (typeof loader?.entries !== "function") return void 0;
1671
+ const self = ctx.fiber;
1672
+ for (const entry of loader.entries()) {
1673
+ const id = entry.options.id;
1674
+ if (entry.options.name !== REMOTE_PACKAGE_NAME || typeof id !== "string" || id === "") continue;
1675
+ if (self !== void 0 && entry.fiber === self) return id;
1676
+ if (entry.disabled !== true && fallback === void 0) fallback = id;
1677
+ }
1678
+ } catch {
1679
+ return;
1680
+ }
1681
+ return fallback;
1682
+ }
1683
+ function makeRoutes(deps) {
1684
+ const { store, pool, worlds, ctx } = deps;
1685
+ /** Timeouts for this request (a volatile config edit must apply live). */
1686
+ const timeouts = () => deps.timeouts?.() ?? DEFAULT_TIMEOUTS;
1687
+ const files = new RemoteFiles(pool);
1688
+ const terminalSessions = new TerminalSessionRegistry(pool);
1689
+ /** Apply parsed import payloads; per-entry errors become skips. */
1690
+ const applyImport = (parsed) => {
1691
+ const imported = [];
1692
+ const skipped = [...parsed.skipped];
1693
+ for (const payload of parsed.payloads) {
1694
+ if (store.get(payload.alias) !== void 0) {
1695
+ skipped.push({
1696
+ alias: payload.alias,
1697
+ reason: "alias already exists"
1698
+ });
1699
+ continue;
1700
+ }
1701
+ try {
1702
+ store.create(payload);
1703
+ imported.push(payload.alias);
1704
+ } catch (error) {
1705
+ skipped.push({
1706
+ alias: payload.alias,
1707
+ reason: error instanceof Error ? error.message : String(error)
1708
+ });
1709
+ }
1710
+ }
1711
+ return {
1712
+ imported,
1713
+ skipped
1714
+ };
1715
+ };
1716
+ return {
1717
+ routes: [
1718
+ {
1719
+ kind: "exact",
1720
+ path: REMOTE_API.hosts,
1721
+ handler: async (req, res) => {
1722
+ if (!isLoopbackRequest(req)) {
1723
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1724
+ return;
1725
+ }
1726
+ const method = req.method ?? "GET";
1727
+ const url = new URL(req.url ?? "/", "http://localhost");
1728
+ if (method === "GET") {
1729
+ writeJson(res, 200, { hosts: store.list().map((h) => store.summarize(h)) });
1730
+ return;
1731
+ }
1732
+ if (method === "POST") {
1733
+ const body = await readJsonBody(req, { objectOnly: true });
1734
+ if (body === null) {
1735
+ writeJson(res, 400, { error: "invalid JSON body" });
1736
+ return;
1737
+ }
1738
+ try {
1739
+ const entry = store.create(body);
1740
+ writeJson(res, 201, { host: store.summarize(entry) });
1741
+ } catch (error) {
1742
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1743
+ }
1744
+ return;
1745
+ }
1746
+ if (method !== "PATCH" && method !== "DELETE") {
1747
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
1748
+ return;
1749
+ }
1750
+ const alias = queryParam(url, "alias");
1751
+ if (alias === void 0 || alias === "") {
1752
+ writeJson(res, 400, { error: "alias query parameter is required" });
1753
+ return;
1754
+ }
1755
+ if (method === "DELETE") {
1756
+ pool.drop(alias);
1757
+ worlds.dropHost(alias).catch(() => void 0);
1758
+ if (!store.remove(alias)) {
1759
+ writeJson(res, 404, { error: `unknown host alias: ${alias}` });
1760
+ return;
1761
+ }
1762
+ worlds.syncWorkspaces().catch(() => void 0);
1763
+ writeJson(res, 200, { ok: true });
1764
+ return;
1765
+ }
1766
+ const body = await readJsonBody(req, { objectOnly: true });
1767
+ if (body === null) {
1768
+ writeJson(res, 400, { error: "invalid JSON body" });
1769
+ return;
1770
+ }
1771
+ try {
1772
+ pool.drop(alias);
1773
+ worlds.dropHost(alias).catch(() => void 0);
1774
+ const entry = store.update(alias, body);
1775
+ writeJson(res, 200, { host: store.summarize(entry) });
1776
+ } catch (error) {
1777
+ writeJson(res, (error instanceof Error ? error.message : "").startsWith("unknown host alias") ? 404 : 400, { error: error instanceof Error ? error.message : String(error) });
1778
+ }
1779
+ }
1780
+ },
1781
+ {
1782
+ kind: "exact",
1783
+ path: REMOTE_API.test,
1784
+ handler: async (req, res) => {
1785
+ if (!isLoopbackRequest(req)) {
1786
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1787
+ return;
1788
+ }
1789
+ if (req.method !== "POST") {
1790
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1791
+ return;
1792
+ }
1793
+ const body = await readJsonBody(req, { objectOnly: true });
1794
+ if (body === null || typeof body.alias !== "string") {
1795
+ writeJson(res, 400, { error: "alias is required" });
1796
+ return;
1797
+ }
1798
+ writeJson(res, 200, await pool.test(body.alias));
1799
+ }
1800
+ },
1801
+ {
1802
+ kind: "exact",
1803
+ path: REMOTE_API.keygen,
1804
+ handler: async (req, res) => {
1805
+ if (!isLoopbackRequest(req)) {
1806
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1807
+ return;
1808
+ }
1809
+ if (req.method !== "POST") {
1810
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1811
+ return;
1812
+ }
1813
+ const body = await readJsonBody(req, { objectOnly: true });
1814
+ if (body === null || typeof body.alias !== "string") {
1815
+ writeJson(res, 400, { error: "alias is required" });
1816
+ return;
1817
+ }
1818
+ try {
1819
+ writeJson(res, 200, await bootstrap(pool, store, body.alias));
1820
+ } catch (error) {
1821
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1822
+ }
1823
+ }
1824
+ },
1825
+ {
1826
+ kind: "exact",
1827
+ path: REMOTE_API.exec,
1828
+ handler: async (req, res) => {
1829
+ if (!isLoopbackRequest(req)) {
1830
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1831
+ return;
1832
+ }
1833
+ if (req.method !== "POST") {
1834
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1835
+ return;
1836
+ }
1837
+ const body = await readJsonBody(req, { objectOnly: true });
1838
+ if (body === null || typeof body.alias !== "string" || typeof body.command !== "string" || body.command === "") {
1839
+ writeJson(res, 400, { error: "alias and a non-empty command are required" });
1840
+ return;
1841
+ }
1842
+ const timeoutMs = clampExecTimeout(body.timeoutMs);
1843
+ writeJson(res, 200, await pool.exec(body.alias, body.command, timeoutMs === void 0 ? {} : { timeoutMs }));
1844
+ }
1845
+ },
1846
+ {
1847
+ kind: "exact",
1848
+ path: REMOTE_API.files,
1849
+ handler: async (req, res) => {
1850
+ if (!isLoopbackRequest(req)) {
1851
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1852
+ return;
1853
+ }
1854
+ if (req.method !== "POST") {
1855
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1856
+ return;
1857
+ }
1858
+ const body = await readJsonBody(req, {
1859
+ objectOnly: true,
1860
+ maxBytes: 10 * 1024 * 1024
1861
+ });
1862
+ if (body === null || typeof body.op !== "string" || typeof body.alias !== "string") {
1863
+ writeJson(res, 400, { error: "op and alias are required" });
1864
+ return;
1865
+ }
1866
+ const alias = body.alias;
1867
+ try {
1868
+ switch (body.op) {
1869
+ case "list":
1870
+ if (typeof body.path !== "string") throw new Error("path is required");
1871
+ writeJson(res, 200, { entries: await files.list(alias, body.path) });
1872
+ return;
1873
+ case "stat":
1874
+ if (typeof body.path !== "string") throw new Error("path is required");
1875
+ writeJson(res, 200, { entry: await files.stat(alias, body.path) });
1876
+ return;
1877
+ case "read":
1878
+ if (typeof body.path !== "string") throw new Error("path is required");
1879
+ writeJson(res, 200, await files.read(alias, body.path));
1880
+ return;
1881
+ case "write": {
1882
+ if (typeof body.path !== "string" || typeof body.content !== "string") throw new Error("path and content are required");
1883
+ const encoding = body.encoding === "base64" ? "base64" : "utf8";
1884
+ writeJson(res, 200, await files.write(alias, body.path, body.content, encoding));
1885
+ return;
1886
+ }
1887
+ case "mkdir":
1888
+ if (typeof body.path !== "string") throw new Error("path is required");
1889
+ await files.mkdir(alias, body.path);
1890
+ writeJson(res, 200, { ok: true });
1891
+ return;
1892
+ case "rm":
1893
+ if (typeof body.path !== "string") throw new Error("path is required");
1894
+ await files.remove(alias, body.path, body.recursive === true);
1895
+ writeJson(res, 200, { ok: true });
1896
+ return;
1897
+ case "rename":
1898
+ if (typeof body.from !== "string" || typeof body.to !== "string") throw new Error("from and to are required");
1899
+ await files.rename(alias, body.from, body.to);
1900
+ writeJson(res, 200, { ok: true });
1901
+ return;
1902
+ default: writeJson(res, 400, { error: `unknown op: ${body.op}` });
1903
+ }
1904
+ } catch (error) {
1905
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
1906
+ }
1907
+ }
1908
+ },
1909
+ {
1910
+ kind: "exact",
1911
+ path: REMOTE_API.upload,
1912
+ handler: async (req, res) => {
1913
+ if (!isLoopbackRequest(req)) {
1914
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1915
+ return;
1916
+ }
1917
+ if (req.method !== "POST") {
1918
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1919
+ return;
1920
+ }
1921
+ const url = new URL(req.url ?? "/", "http://localhost");
1922
+ const alias = url.searchParams.get("alias") ?? "";
1923
+ const path = url.searchParams.get("path") ?? "";
1924
+ const overwrite = url.searchParams.get("overwrite") === "1";
1925
+ if (alias === "" || path === "") {
1926
+ writeJson(res, 400, { error: "alias and path query parameters are required" });
1927
+ return;
1928
+ }
1929
+ if (!path.startsWith("/")) {
1930
+ writeJson(res, 400, { error: `path must be absolute: ${path}` });
1931
+ return;
1932
+ }
1933
+ try {
1934
+ if (!overwrite) {
1935
+ if (await files.stat(alias, path).then(() => true, () => false)) {
1936
+ writeJson(res, 409, {
1937
+ error: `${path} already exists`,
1938
+ exists: true
1939
+ });
1940
+ return;
1941
+ }
1942
+ }
1943
+ writeJson(res, 200, {
1944
+ ok: true,
1945
+ size: (await files.upload(alias, path, req, void 0, overwrite)).size
1946
+ });
1947
+ } catch (error) {
1948
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
1949
+ }
1950
+ }
1951
+ },
1952
+ {
1953
+ kind: "exact",
1954
+ path: REMOTE_API.workspaces,
1955
+ handler: async (req, res) => {
1956
+ if (!isLoopbackRequest(req)) {
1957
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1958
+ return;
1959
+ }
1960
+ const method = req.method ?? "GET";
1961
+ const url = new URL(req.url ?? "/", "http://localhost");
1962
+ if (method === "GET") {
1963
+ const bindings = store.listWorkspaces();
1964
+ const aliasPaths = {};
1965
+ for (const b of bindings) aliasPaths[b.id] = worlds.aliasPathFor(b.id);
1966
+ worlds.syncWorkspaces().catch(() => void 0);
1967
+ writeJson(res, 200, {
1968
+ workspaces: bindings,
1969
+ mode: worlds.mode,
1970
+ aliasPaths,
1971
+ tombstones: store.listTombstones(),
1972
+ version: pluginVersion(),
1973
+ timeouts: timeouts()
1974
+ });
1975
+ return;
1976
+ }
1977
+ if (method === "POST") {
1978
+ const body = await readJsonBody(req, { objectOnly: true });
1979
+ if (body === null) {
1980
+ writeJson(res, 400, { error: "invalid JSON body" });
1981
+ return;
1982
+ }
1983
+ try {
1984
+ const binding = store.createWorkspace(body);
1985
+ const aliasPath = worlds.aliasPathFor(binding.id);
1986
+ try {
1987
+ mkdirSync(aliasPath, { recursive: true });
1988
+ } catch (error) {
1989
+ throw new Error(`cannot materialize alias dir ${aliasPath}: ${error instanceof Error ? error.message : String(error)}`);
1990
+ }
1991
+ await worlds.syncWorkspaces().catch(() => void 0);
1992
+ writeJson(res, 201, {
1993
+ workspace: binding,
1994
+ aliasPath
1995
+ });
1996
+ } catch (error) {
1997
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1998
+ }
1999
+ return;
2000
+ }
2001
+ if (method !== "DELETE") {
2002
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
2003
+ return;
2004
+ }
2005
+ const id = queryParam(url, "id");
2006
+ if (id === void 0 || id === "") {
2007
+ writeJson(res, 400, { error: "id query parameter is required" });
2008
+ return;
2009
+ }
2010
+ if (!store.removeWorkspace(id)) {
2011
+ writeJson(res, 404, { error: `unknown workspace id: ${id}` });
2012
+ return;
2013
+ }
2014
+ worlds.syncWorkspaces().catch(() => void 0);
2015
+ writeJson(res, 200, { ok: true });
2016
+ }
2017
+ },
2018
+ {
2019
+ kind: "exact",
2020
+ path: REMOTE_API.sessionCwd,
2021
+ handler: async (req, res) => {
2022
+ if (!isLoopbackRequest(req)) {
2023
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2024
+ return;
2025
+ }
2026
+ if ((req.method ?? "GET") !== "POST") {
2027
+ writeJson(res, 405, { error: `method not allowed: ${req.method ?? "GET"}` });
2028
+ return;
2029
+ }
2030
+ const body = await readJsonBody(req, { objectOnly: true });
2031
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId : "";
2032
+ if (sessionId === "") {
2033
+ writeJson(res, 400, { error: "sessionId must be a non-empty string" });
2034
+ return;
2035
+ }
2036
+ writeJson(res, 200, { cwd: liveSessionCwd(ctx, sessionId) });
2037
+ }
2038
+ },
2039
+ {
2040
+ kind: "exact",
2041
+ path: REMOTE_API.changes,
2042
+ handler: async (req, res) => {
2043
+ if (!isLoopbackRequest(req)) {
2044
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2045
+ return;
2046
+ }
2047
+ if ((req.method ?? "GET") !== "POST") {
2048
+ writeJson(res, 405, { error: `method not allowed: ${req.method ?? "GET"}` });
2049
+ return;
2050
+ }
2051
+ const body = await readJsonBody(req, { objectOnly: true });
2052
+ const sessionId = typeof body?.sessionId === "string" ? body.sessionId : "";
2053
+ const op = typeof body?.op === "string" ? body.op : "";
2054
+ if (sessionId === "" || op !== "list" && op !== "diff") {
2055
+ writeJson(res, 400, { error: "op (list|diff) and a non-empty sessionId are required" });
2056
+ return;
2057
+ }
2058
+ const ledger = deps.changes;
2059
+ if (ledger === void 0) {
2060
+ writeJson(res, 200, {
2061
+ summary: null,
2062
+ diff: null
2063
+ });
2064
+ return;
2065
+ }
2066
+ if (typeof body?.repoSubdir === "string" || body?.repoSubdir === null) ledger.useRepoSubdir(sessionId, typeof body.repoSubdir === "string" ? body.repoSubdir : void 0);
2067
+ if (op === "list") {
2068
+ if (ledger.summary(sessionId) === null) {
2069
+ const cwd = liveSessionCwd(ctx, sessionId);
2070
+ if (cwd !== void 0) ledger.beginTurn({
2071
+ sessionId,
2072
+ cwd
2073
+ });
2074
+ }
2075
+ if (body?.force === true) ledger.rescan(sessionId);
2076
+ else ledger.touch(sessionId);
2077
+ writeJson(res, 200, { summary: ledger.summary(sessionId) });
2078
+ return;
2079
+ }
2080
+ const path = typeof body?.path === "string" ? body.path : "";
2081
+ if (path === "") {
2082
+ writeJson(res, 400, { error: "path is required for op=diff" });
2083
+ return;
2084
+ }
2085
+ try {
2086
+ writeJson(res, 200, { diff: await ledger.diff(sessionId, path) });
2087
+ } catch (error) {
2088
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2089
+ }
2090
+ }
2091
+ },
2092
+ {
2093
+ kind: "exact",
2094
+ path: REMOTE_API.routing,
2095
+ handler: async (req, res) => {
2096
+ if (!isLoopbackRequest(req)) {
2097
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2098
+ return;
2099
+ }
2100
+ if ((req.method ?? "GET") !== "POST") {
2101
+ writeJson(res, 405, { error: `method not allowed: ${req.method ?? "GET"}` });
2102
+ return;
2103
+ }
2104
+ const mode = (await readJsonBody(req, { objectOnly: true }))?.mode;
2105
+ if (mode !== "off" && mode !== "shadow" && mode !== "enforce") {
2106
+ writeJson(res, 400, { error: `mode must be one of off/shadow/enforce (got ${JSON.stringify(mode) ?? "missing"})` });
2107
+ return;
2108
+ }
2109
+ try {
2110
+ const settings = ctx.get("settings");
2111
+ if (typeof settings?.update !== "function") {
2112
+ writeJson(res, 503, { error: "settings service unavailable (cannot persist routing mode)" });
2113
+ return;
2114
+ }
2115
+ const ns = ownSettingsNs(ctx) ?? "remote-vs";
2116
+ await settings.update(ns, { routingMode: mode });
2117
+ writeJson(res, 200, { mode: worlds.mode });
2118
+ } catch (error) {
2119
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
2120
+ }
2121
+ }
2122
+ },
2123
+ {
2124
+ kind: "exact",
2125
+ path: REMOTE_API.deploy,
2126
+ handler: async (req, res) => {
2127
+ if (!isLoopbackRequest(req)) {
2128
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2129
+ return;
2130
+ }
2131
+ const method = req.method ?? "GET";
2132
+ const url = new URL(req.url ?? "/", "http://localhost");
2133
+ if (method === "GET") {
2134
+ const alias = queryParam(url, "alias");
2135
+ const view = queryParam(url, "view") ?? "status";
2136
+ if (alias === void 0 || alias === "") {
2137
+ writeJson(res, 400, { error: "alias query parameter is required" });
2138
+ return;
2139
+ }
2140
+ if (view === "probe") {
2141
+ try {
2142
+ writeJson(res, 200, await probe(pool, alias));
2143
+ } catch (error) {
2144
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
2145
+ }
2146
+ return;
2147
+ }
2148
+ if (view === "status") {
2149
+ try {
2150
+ writeJson(res, 200, {
2151
+ deploy: store.require(alias).deploy ?? { status: "not-deployed" },
2152
+ helpers: helperAvailability()
2153
+ });
2154
+ } catch (error) {
2155
+ writeJson(res, 404, { error: error instanceof Error ? error.message : String(error) });
2156
+ }
2157
+ return;
2158
+ }
2159
+ writeJson(res, 400, { error: `unknown view: ${view}` });
2160
+ return;
2161
+ }
2162
+ if (method !== "POST") {
2163
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
2164
+ return;
2165
+ }
2166
+ const body = await readJsonBody(req, { objectOnly: true });
2167
+ if (body === null || typeof body.alias !== "string") {
2168
+ writeJson(res, 400, { error: "alias is required" });
2169
+ return;
2170
+ }
2171
+ try {
2172
+ writeJson(res, 200, await pushPipeline(pool, store, body.alias, typeof body.version === "string" ? { version: body.version } : {}));
2173
+ } catch (error) {
2174
+ writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
2175
+ }
2176
+ }
2177
+ },
2178
+ {
2179
+ kind: "exact",
2180
+ path: REMOTE_API.importSshConfig,
2181
+ handler: async (req, res) => {
2182
+ if (!isLoopbackRequest(req)) {
2183
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2184
+ return;
2185
+ }
2186
+ if (req.method !== "POST") {
2187
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2188
+ return;
2189
+ }
2190
+ const body = await readJsonBody(req, { maxBytes: 1024 * 1024 });
2191
+ try {
2192
+ const text = body !== null && typeof body.text === "string" ? body.text : readDefaultSshConfig();
2193
+ writeJson(res, 200, applyImport(parseSshConfig(text)));
2194
+ } catch (error) {
2195
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2196
+ }
2197
+ }
2198
+ },
2199
+ {
2200
+ kind: "exact",
2201
+ path: REMOTE_API.importDshSsh,
2202
+ handler: async (req, res) => {
2203
+ if (!isLoopbackRequest(req)) {
2204
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2205
+ return;
2206
+ }
2207
+ if (req.method !== "POST") {
2208
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2209
+ return;
2210
+ }
2211
+ try {
2212
+ writeJson(res, 200, applyImport(migrateDshSsh()));
2213
+ } catch (error) {
2214
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2215
+ }
2216
+ }
2217
+ },
2218
+ {
2219
+ kind: "exact",
2220
+ path: REMOTE_API.terminalClose,
2221
+ handler: async (req, res) => {
2222
+ if (!isLoopbackRequest(req)) {
2223
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
2224
+ return;
2225
+ }
2226
+ if (req.method !== "POST") {
2227
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
2228
+ return;
2229
+ }
2230
+ const body = await readJsonBody(req, { objectOnly: true });
2231
+ if (body === null || typeof body.id !== "string" || body.id === "") {
2232
+ writeJson(res, 400, { error: "id is required" });
2233
+ return;
2234
+ }
2235
+ writeJson(res, 200, { ok: terminalSessions.close(body.id) });
2236
+ }
2237
+ }
2238
+ ],
2239
+ upgrade: {
2240
+ path: REMOTE_API.terminal,
2241
+ handler: (req, socket, head) => {
2242
+ if (!isLoopbackRequest(req)) {
2243
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2244
+ socket.destroy();
2245
+ return;
2246
+ }
2247
+ const url = new URL(req.url ?? "/", "http://localhost");
2248
+ const alias = queryParam(url, "alias");
2249
+ const sessionId = queryParam(url, "session");
2250
+ if (alias === void 0 && sessionId === void 0) {
2251
+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
2252
+ socket.destroy();
2253
+ return;
2254
+ }
2255
+ const cols = Number.parseInt(queryParam(url, "cols") ?? "80", 10);
2256
+ const rows = Number.parseInt(queryParam(url, "rows") ?? "24", 10);
2257
+ const size = {
2258
+ cols: Number.isFinite(cols) && cols > 0 ? Math.min(cols, 500) : 80,
2259
+ rows: Number.isFinite(rows) && rows > 0 ? Math.min(rows, 200) : 24
2260
+ };
2261
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
2262
+ if (sessionId !== void 0) {
2263
+ if (terminalSessions.attach(ws, sessionId, size.cols, size.rows)) return;
2264
+ try {
2265
+ ws.send(JSON.stringify({
2266
+ t: "error",
2267
+ message: "terminal session expired"
2268
+ }));
2269
+ } catch {}
2270
+ try {
2271
+ ws.close(1e3);
2272
+ } catch {}
2273
+ return;
2274
+ }
2275
+ terminalSessions.open(ws, alias, size.cols, size.rows).then(() => void 0, () => void 0);
2276
+ });
2277
+ }
2278
+ },
2279
+ terminalSessions
2280
+ };
2281
+ }
2282
+ //#endregion
2283
+ //#region src/changes/status.ts
2284
+ /** Exit statuses of the read commands (git's own statuses pass through as-is). */
2285
+ const READ_EXIT = {
2286
+ /** The reported root does not exist on the host (stale alias). */
2287
+ noRoot: 90,
2288
+ /**
2289
+ * No `base64` on the host. Probed up front because without it every read
2290
+ * would come back empty and look like "this file has no content", which is a
2291
+ * far worse failure than a clear refusal.
2292
+ */
2293
+ noBase64: 92
2294
+ };
2295
+ /** Marker that starts one file block in a read reply. */
2296
+ const FILE_MARKER = "@@RVS-FILE ";
2297
+ /** POSIX single-quote one shell word. Throws on NUL, which no shell can carry. */
2298
+ function shq(word) {
2299
+ if (word.includes("\0")) throw new Error("NUL byte in shell word");
2300
+ return `'${word.replace(/'/g, `'\\''`)}'`;
2301
+ }
2302
+ /** `base64` of a UTF-8 string, for the marker's path field. */
2303
+ function pathToken(path) {
2304
+ return Buffer.from(path, "utf8").toString("base64");
2305
+ }
2306
+ /** `cd` into the root, or exit with {@link READ_EXIT.noRoot}. */
2307
+ function cdLine(root) {
2308
+ return `cd ${shq(root)} || exit ${READ_EXIT.noRoot}`;
2309
+ }
2310
+ /** A cap is a byte count: non-negative, integral, and never NaN. */
2311
+ function capOf(value) {
2312
+ if (!Number.isFinite(value)) return 0;
2313
+ return Math.max(0, Math.floor(value));
2314
+ }
2315
+ /**
2316
+ * Tracked paths that changed, HEAD/index vs worktree, staged changes included.
2317
+ *
2318
+ * `-uno` is what keeps this cheap: untracked detection is the expensive half
2319
+ * (11s above) and it is answered by {@link untrackedCommand} instead. `-- .`
2320
+ * scopes the walk to the session root, so a session sitting in one package of
2321
+ * a monorepo does not pay for the rest.
2322
+ *
2323
+ * @param root - remote absolute POSIX directory (the session cwd).
2324
+ * @returns a `sh` script printing NUL-separated porcelain v1 records.
2325
+ */
2326
+ function trackedStatusCommand(root) {
2327
+ return [cdLine(root), "git --no-pager status --porcelain -z -uno -- ."].join("\n");
2328
+ }
2329
+ /**
2330
+ * New files git is not tracking yet (the ones a session usually creates).
2331
+ *
2332
+ * @param root - remote absolute POSIX directory (the session cwd).
2333
+ * @returns a `sh` script printing NUL-separated paths.
2334
+ */
2335
+ function untrackedCommand(root) {
2336
+ return [cdLine(root), "git ls-files --others --exclude-standard -z -- ."].join("\n");
2337
+ }
2338
+ /**
2339
+ * Parse `git status --porcelain -z -uno` output.
2340
+ *
2341
+ * Porcelain v1 with `-z` writes `XY <path>\0`, and for a rename or copy the
2342
+ * source path follows as its own record: `R <new>\0<old>\0` (verified against
2343
+ * git 2.x: the NEW path comes first). The source record is consumed, not parsed
2344
+ * as its own entry, and the reported path is the NEW one — the only path the
2345
+ * editor can open.
2346
+ *
2347
+ * The two status columns mean "index vs HEAD" (X) and "worktree vs index" (Y);
2348
+ * one letter is reported, chosen for how the file reads in a session review:
2349
+ * conflict > deleted > renamed > copied > added > typechange > modified.
2350
+ *
2351
+ * @param stdout - raw reply of {@link trackedStatusCommand}.
2352
+ */
2353
+ function parseStatusZ(stdout) {
2354
+ const entries = [];
2355
+ const records = stdout.split("\0");
2356
+ for (let index = 0; index < records.length; index += 1) {
2357
+ const record = records[index];
2358
+ if (record === void 0 || record.length < 4) continue;
2359
+ const x = record.charAt(0);
2360
+ const y = record.charAt(1);
2361
+ if (record.charAt(2) !== " ") continue;
2362
+ const path = record.slice(3);
2363
+ if (path === "") continue;
2364
+ if (x === "R" || y === "R" || x === "C" || y === "C") index += 1;
2365
+ entries.push({
2366
+ path,
2367
+ status: statusOf(x, y)
2368
+ });
2369
+ }
2370
+ return entries;
2371
+ }
2372
+ /** Collapse porcelain's two columns into the one letter a review panel shows. */
2373
+ function statusOf(x, y) {
2374
+ const both = `${x}${y}`;
2375
+ if (x === "?" || y === "?") return "?";
2376
+ if (x === "U" || y === "U" || both === "AA" || both === "DD") return "U";
2377
+ if (x === "D" || y === "D") return "D";
2378
+ if (x === "R" || y === "R") return "R";
2379
+ if (x === "C" || y === "C") return "C";
2380
+ if (x === "A" || y === "A") return "A";
2381
+ if (x === "T" || y === "T") return "T";
2382
+ return "M";
2383
+ }
2384
+ /**
2385
+ * Parse `git ls-files --others --exclude-standard -z` output into untracked
2386
+ * entries. Git already filtered ignored files out, and its paths are never
2387
+ * quoted in `-z` mode.
2388
+ */
2389
+ function parseUntrackedZ(stdout) {
2390
+ const entries = [];
2391
+ for (const record of stdout.split("\0")) {
2392
+ if (record === "") continue;
2393
+ entries.push({
2394
+ path: record,
2395
+ status: "?"
2396
+ });
2397
+ }
2398
+ return entries;
2399
+ }
2400
+ /**
2401
+ * Read several worktree files in one remote run.
2402
+ *
2403
+ * One `sh` script walks the paths in order and emits a {@link ReadResult} block
2404
+ * per path. Sizes come from `wc -c` before anything is transferred, so a 4GB
2405
+ * build artifact costs one `stat` instead of 5GB of base64; the running total
2406
+ * stops the reply from growing past `totalCapBytes`, after which the remaining
2407
+ * paths are reported `big` without being read.
2408
+ *
2409
+ * A symlink reports its target as content, because that is what git stores for
2410
+ * mode 120000 — catting the link's target file would diff the wrong bytes.
2411
+ *
2412
+ * @param root - remote absolute POSIX directory (the session cwd).
2413
+ * @param paths - paths relative to `root`.
2414
+ * @param capBytes - per-file cap; larger files are reported `big`.
2415
+ * @param totalCapBytes - cap on the sum of transferred content bytes.
2416
+ */
2417
+ function readWorktreeCommand(root, paths, capBytes, totalCapBytes) {
2418
+ return [
2419
+ "set -u",
2420
+ `command -v base64 >/dev/null 2>&1 || exit ${READ_EXIT.noBase64}`,
2421
+ cdLine(root),
2422
+ `rvs_cap=${capOf(capBytes)}`,
2423
+ `rvs_ttl=${capOf(totalCapBytes)}`,
2424
+ "rvs_total=0",
2425
+ "# rvs_one <base64(path)> <path>: emit one @@RVS-FILE block.",
2426
+ "rvs_one() {",
2427
+ " rvs_b=$1",
2428
+ " rvs_p=$2",
2429
+ " rvs_src=missing",
2430
+ " rvs_n=0",
2431
+ " if [ -L \"$rvs_p\" ]; then",
2432
+ " if rvs_v=$(readlink -- \"$rvs_p\" 2>/dev/null); then",
2433
+ " rvs_src=symlink",
2434
+ " # Bytes, not characters: the caps below are byte budgets.",
2435
+ " rvs_n=$(printf \"%s\" \"$rvs_v\" | wc -c | tr -d \" \")",
2436
+ " fi",
2437
+ " elif [ -f \"$rvs_p\" ] && [ -r \"$rvs_p\" ]; then",
2438
+ " rvs_n=$(wc -c < \"$rvs_p\" 2>/dev/null | tr -d \" \")",
2439
+ " case $rvs_n in",
2440
+ " \"\"|*[!0-9]*) rvs_n=0 ;;",
2441
+ " *) rvs_src=file ;;",
2442
+ " esac",
2443
+ " fi",
2444
+ " if [ \"$rvs_src\" = missing ]; then",
2445
+ " printf '@@RVS-FILE %s %s 0 0@@\\n' \"$rvs_b\" absent",
2446
+ " return 0",
2447
+ " fi",
2448
+ " # mtime is what lets a caller rule out \"unchanged\" for a file whose bytes",
2449
+ " # were withheld by the caps below.",
2450
+ " rvs_m=$(stat -c %Y -- \"$rvs_p\" 2>/dev/null || echo 0)",
2451
+ " case $rvs_m in \"\"|*[!0-9]*) rvs_m=0 ;; esac",
2452
+ " if [ \"$rvs_n\" -gt \"$rvs_cap\" ] || [ \"$((rvs_total + rvs_n))\" -gt \"$rvs_ttl\" ]; then",
2453
+ " printf '@@RVS-FILE %s %s %s %s@@\\n' \"$rvs_b\" big \"$rvs_m\" \"$rvs_n\"",
2454
+ " return 0",
2455
+ " fi",
2456
+ " if [ \"$rvs_src\" = symlink ]; then",
2457
+ " rvs_e=$(printf \"%s\" \"$rvs_v\" | base64 | tr -d \"\\n\")",
2458
+ " else",
2459
+ " rvs_e=$(base64 < \"$rvs_p\" 2>/dev/null | tr -d \"\\n\")",
2460
+ " # A non-empty file that produced no bytes vanished under us.",
2461
+ " if [ -z \"$rvs_e\" ] && [ \"$rvs_n\" -gt 0 ]; then",
2462
+ " printf '@@RVS-FILE %s %s 0 0@@\\n' \"$rvs_b\" absent",
2463
+ " return 0",
2464
+ " fi",
2465
+ " fi",
2466
+ " printf '@@RVS-FILE %s %s %s %s@@\\n%s\\n' \"$rvs_b\" ok \"$rvs_m\" \"$rvs_n\" \"$rvs_e\"",
2467
+ " rvs_total=$((rvs_total + rvs_n))",
2468
+ " return 0",
2469
+ "}",
2470
+ ...paths.map((path) => `rvs_one ${shq(pathToken(path))} ${shq(path)}`)
2471
+ ].join("\n");
2472
+ }
2473
+ /**
2474
+ * Read several `HEAD:<path>` blobs in one remote run — the "before" side of a
2475
+ * file that was already committed and clean when the session started.
2476
+ *
2477
+ * `git cat-file -s` answers existence *and* size before any bytes move, so the
2478
+ * caps are exact, and a path that is not in `HEAD` (a new file, a repository
2479
+ * with no commit yet, a submodule gitlink) is reported `absent` rather than
2480
+ * failing the whole read. `cat-file blob` is used instead of `show` on purpose:
2481
+ * no textconv, no filters, no pager — the bytes git will diff.
2482
+ *
2483
+ * @param root - remote absolute POSIX directory (the session cwd).
2484
+ * @param paths - paths relative to `root`.
2485
+ * @param capBytes - per-file cap; larger blobs are reported `big`.
2486
+ * @param totalCapBytes - cap on the sum of transferred content bytes.
2487
+ */
2488
+ function readHeadCommand(root, paths, capBytes, totalCapBytes) {
2489
+ return [
2490
+ "set -u",
2491
+ `command -v base64 >/dev/null 2>&1 || exit ${READ_EXIT.noBase64}`,
2492
+ cdLine(root),
2493
+ `rvs_cap=${capOf(capBytes)}`,
2494
+ `rvs_ttl=${capOf(totalCapBytes)}`,
2495
+ "rvs_total=0",
2496
+ "# rvs_one <base64(path)> <path>: emit one @@RVS-FILE block.",
2497
+ "rvs_one() {",
2498
+ " rvs_b=$1",
2499
+ " rvs_p=$2",
2500
+ " rvs_n=",
2501
+ " if ! rvs_n=$(git cat-file -s \"HEAD:$rvs_p\" 2>/dev/null); then",
2502
+ " printf '@@RVS-FILE %s absent 0 0@@\\n' \"$rvs_b\"",
2503
+ " return 0",
2504
+ " fi",
2505
+ " rvs_n=$(printf \"%s\" \"$rvs_n\" | tr -d \" \")",
2506
+ " case $rvs_n in",
2507
+ " \"\"|*[!0-9]*)",
2508
+ " printf '@@RVS-FILE %s absent 0 0@@\\n' \"$rvs_b\"",
2509
+ " return 0",
2510
+ " ;;",
2511
+ " esac",
2512
+ " if [ \"$rvs_n\" -gt \"$rvs_cap\" ] || [ \"$((rvs_total + rvs_n))\" -gt \"$rvs_ttl\" ]; then",
2513
+ " printf '@@RVS-FILE %s big 0 %s@@\\n' \"$rvs_b\" \"$rvs_n\"",
2514
+ " return 0",
2515
+ " fi",
2516
+ " rvs_e=$(git cat-file blob \"HEAD:$rvs_p\" 2>/dev/null | base64 | tr -d \"\\n\")",
2517
+ " # Non-empty blob but no bytes decoded: not a blob (a tree or a gitlink).",
2518
+ " if [ -z \"$rvs_e\" ] && [ \"$rvs_n\" -gt 0 ]; then",
2519
+ " printf '@@RVS-FILE %s absent 0 0@@\\n' \"$rvs_b\"",
2520
+ " return 0",
2521
+ " fi",
2522
+ " printf '@@RVS-FILE %s ok 0 %s@@\\n%s\\n' \"$rvs_b\" \"$rvs_n\" \"$rvs_e\"",
2523
+ " rvs_total=$((rvs_total + rvs_n))",
2524
+ " return 0",
2525
+ "}",
2526
+ ...paths.map((path) => `rvs_one ${shq(pathToken(path))} ${shq(path)}`)
2527
+ ].join("\n");
2528
+ }
2529
+ /**
2530
+ * Name every immediate subdirectory that is a git repository.
2531
+ *
2532
+ * A session working directory is often a CONTAINER for repositories (the
2533
+ * reference host has `/home/xiray/dsh_workbase` holding `emv-toolbox-web`), and
2534
+ * `git status` at the container answers 128. This is a one-level `ls`-style
2535
+ * probe — bounded work, no `find` — so it can never become the expensive part
2536
+ * of a scan.
2537
+ *
2538
+ * It prints ALL matches on purpose: a workspace with several repositories has no
2539
+ * single right answer, and the caller must refuse to guess rather than pick the
2540
+ * first one. The SCM panel's explicit repository directory is the real answer.
2541
+ *
2542
+ * @param root - remote absolute POSIX directory (the session cwd).
2543
+ */
2544
+ function reposBelowCommand(root) {
2545
+ return [
2546
+ "set -u",
2547
+ `cd ${shq(root)} || exit ${READ_EXIT.noRoot}`,
2548
+ "for rvs_d in */; do",
2549
+ " [ -d \"$rvs_d/.git\" ] || continue",
2550
+ " printf '%s\\n' \"${rvs_d%/}\"",
2551
+ "done"
2552
+ ].join("\n");
2553
+ }
2554
+ /**
2555
+ * Parse a {@link readWorktreeCommand} / {@link readHeadCommand} reply.
2556
+ *
2557
+ * Keys are the decoded paths, exactly as requested. A marker without its
2558
+ * content line can only mean the reply was cut short (a timeout killing the
2559
+ * pipe); such a path is reported truncated rather than as an empty file, so an
2560
+ * aborted read never masquerades as "the file is empty".
2561
+ *
2562
+ * @param stdout - raw reply of either read command.
2563
+ */
2564
+ function parseReads(stdout) {
2565
+ const results = /* @__PURE__ */ new Map();
2566
+ const lines = stdout.split("\n");
2567
+ for (let index = 0; index < lines.length; index += 1) {
2568
+ const line = lines[index];
2569
+ if (line === void 0 || !line.startsWith(FILE_MARKER)) continue;
2570
+ const fields = line.slice(11);
2571
+ if (!fields.endsWith("@@")) continue;
2572
+ const parts = fields.slice(0, -2).split(" ");
2573
+ if (parts.length < 2) continue;
2574
+ const token = parts[0] ?? "";
2575
+ const flags = parts[1] ?? "";
2576
+ const path = Buffer.from(token, "base64").toString("utf8");
2577
+ if (path === "") continue;
2578
+ const mtime = parts.length > 3 ? Number.parseInt(parts[2] ?? "", 10) : NaN;
2579
+ const size = parts.length > 3 ? Number.parseInt(parts[3] ?? "", 10) : NaN;
2580
+ const stats = {
2581
+ ...Number.isSafeInteger(mtime) && mtime > 0 ? { mtime } : {},
2582
+ ...Number.isSafeInteger(size) && size >= 0 ? { size } : {}
2583
+ };
2584
+ if (flags !== "ok") {
2585
+ results.set(path, flags === "big" ? {
2586
+ text: null,
2587
+ truncated: true,
2588
+ binary: false,
2589
+ absent: false,
2590
+ ...stats
2591
+ } : {
2592
+ text: null,
2593
+ truncated: false,
2594
+ binary: false,
2595
+ absent: true,
2596
+ ...stats
2597
+ });
2598
+ continue;
2599
+ }
2600
+ const encoded = lines[index + 1];
2601
+ if (encoded === void 0 || index + 1 === lines.length - 1) {
2602
+ results.set(path, {
2603
+ text: null,
2604
+ truncated: true,
2605
+ binary: false,
2606
+ absent: false,
2607
+ ...stats
2608
+ });
2609
+ break;
2610
+ }
2611
+ index += 1;
2612
+ const bytes = Buffer.from(encoded, "base64");
2613
+ if (bytes.includes(0)) {
2614
+ results.set(path, {
2615
+ text: null,
2616
+ truncated: false,
2617
+ binary: true,
2618
+ absent: false,
2619
+ ...stats
2620
+ });
2621
+ continue;
2622
+ }
2623
+ results.set(path, {
2624
+ text: bytes.toString("utf8"),
2625
+ truncated: false,
2626
+ binary: false,
2627
+ absent: false,
2628
+ ...stats
2629
+ });
2630
+ }
2631
+ return results;
2632
+ }
2633
+ //#endregion
2634
+ //#region src/changes/text-diff.ts
2635
+ /** How far Myers may wander before the comparison degrades to "all replaced". */
2636
+ const MAX_EDIT_DISTANCE = 2e3;
2637
+ /**
2638
+ * Split text into lines the way git does: `\n` is the separator, the last one
2639
+ * does not create an empty line, and `\r` stays part of the line.
2640
+ */
2641
+ function splitLines(text) {
2642
+ if (text === "") return [];
2643
+ const lines = text.split("\n");
2644
+ if (lines[lines.length - 1] === "") lines.pop();
2645
+ return lines;
2646
+ }
2647
+ /** Diff two line arrays into an edit script; `undefined` when the cap was hit. */
2648
+ function editScript(before, after) {
2649
+ const prefix = [];
2650
+ let start = 0;
2651
+ const shortest = Math.min(before.length, after.length);
2652
+ while (start < shortest && before[start] === after[start]) {
2653
+ prefix.push({
2654
+ kind: "=",
2655
+ text: before[start] ?? ""
2656
+ });
2657
+ start += 1;
2658
+ }
2659
+ const suffix = [];
2660
+ let beforeEnd = before.length - 1;
2661
+ let afterEnd = after.length - 1;
2662
+ while (beforeEnd >= start && afterEnd >= start && before[beforeEnd] === after[afterEnd]) {
2663
+ suffix.push({
2664
+ kind: "=",
2665
+ text: before[beforeEnd] ?? ""
2666
+ });
2667
+ beforeEnd -= 1;
2668
+ afterEnd -= 1;
2669
+ }
2670
+ suffix.reverse();
2671
+ const middle = myers(before.slice(start, beforeEnd + 1), after.slice(start, afterEnd + 1));
2672
+ if (middle === void 0) return void 0;
2673
+ return [
2674
+ ...prefix,
2675
+ ...middle,
2676
+ ...suffix
2677
+ ];
2678
+ }
2679
+ /**
2680
+ * Myers' greedy algorithm with a per-level trace, so the path can be walked back.
2681
+ *
2682
+ * `trace[d]` holds the furthest x per diagonal after `d - 1` steps, which is
2683
+ * exactly what the backtrack at distance `d` reads (`prevK` is always a
2684
+ * diagonal reachable in `d - 1` steps). Only the diagonals a level touches are
2685
+ * copied, which keeps the trace at O(d²) instead of O(d · (n + m)).
2686
+ */
2687
+ function myers(before, after) {
2688
+ const n = before.length;
2689
+ const m = after.length;
2690
+ if (n === 0) return after.map((text) => ({
2691
+ kind: "+",
2692
+ text
2693
+ }));
2694
+ if (m === 0) return before.map((text) => ({
2695
+ kind: "-",
2696
+ text
2697
+ }));
2698
+ const limit = Math.min(n + m, MAX_EDIT_DISTANCE);
2699
+ const offset = limit + 2;
2700
+ const furthest = new Int32Array(2 * offset + 5).fill(-1);
2701
+ furthest[1 + offset] = 0;
2702
+ const trace = [];
2703
+ let distance = -1;
2704
+ for (let d = 0; d <= limit && distance < 0; d += 1) {
2705
+ const base = -(d + 1);
2706
+ const snapshot = new Int32Array(2 * d + 3);
2707
+ for (let k = base; k <= d + 1; k += 1) snapshot[k - base] = furthest[k + offset] ?? -1;
2708
+ trace.push(snapshot);
2709
+ for (let k = -d; k <= d; k += 2) {
2710
+ let x;
2711
+ if (k === -d || k !== d && (furthest[k - 1 + offset] ?? -1) < (furthest[k + 1 + offset] ?? -1)) x = furthest[k + 1 + offset] ?? -1;
2712
+ else x = (furthest[k - 1 + offset] ?? -1) + 1;
2713
+ let y = x - k;
2714
+ while (x < n && y < m && before[x] === after[y]) {
2715
+ x += 1;
2716
+ y += 1;
2717
+ }
2718
+ furthest[k + offset] = x;
2719
+ if (x >= n && y >= m) {
2720
+ distance = d;
2721
+ break;
2722
+ }
2723
+ }
2724
+ }
2725
+ if (distance < 0) return void 0;
2726
+ return backtrack(trace, before, after, distance);
2727
+ }
2728
+ /** Walk a completed Myers trace back into an edit script, in file order. */
2729
+ function backtrack(trace, before, after, distance) {
2730
+ const ops = [];
2731
+ let x = before.length;
2732
+ let y = after.length;
2733
+ for (let d = distance; d > 0; d -= 1) {
2734
+ const snapshot = trace[d];
2735
+ if (snapshot === void 0) break;
2736
+ const base = -(d + 1);
2737
+ const at = (k) => snapshot[k - base] ?? -1;
2738
+ const k = x - y;
2739
+ const prevK = k === -d || k !== d && at(k - 1) < at(k + 1) ? k + 1 : k - 1;
2740
+ const prevX = at(prevK);
2741
+ const prevY = prevX - prevK;
2742
+ while (x > prevX && y > prevY) {
2743
+ ops.push({
2744
+ kind: "=",
2745
+ text: before[x - 1] ?? ""
2746
+ });
2747
+ x -= 1;
2748
+ y -= 1;
2749
+ }
2750
+ if (x === prevX) {
2751
+ ops.push({
2752
+ kind: "+",
2753
+ text: after[y - 1] ?? ""
2754
+ });
2755
+ y -= 1;
2756
+ } else {
2757
+ ops.push({
2758
+ kind: "-",
2759
+ text: before[x - 1] ?? ""
2760
+ });
2761
+ x -= 1;
2762
+ }
2763
+ }
2764
+ while (x > 0 && y > 0) {
2765
+ ops.push({
2766
+ kind: "=",
2767
+ text: before[x - 1] ?? ""
2768
+ });
2769
+ x -= 1;
2770
+ y -= 1;
2771
+ }
2772
+ while (x > 0) {
2773
+ ops.push({
2774
+ kind: "-",
2775
+ text: before[x - 1] ?? ""
2776
+ });
2777
+ x -= 1;
2778
+ }
2779
+ while (y > 0) {
2780
+ ops.push({
2781
+ kind: "+",
2782
+ text: after[y - 1] ?? ""
2783
+ });
2784
+ y -= 1;
2785
+ }
2786
+ ops.reverse();
2787
+ return ops;
2788
+ }
2789
+ /** Compare two texts into git-shaped hunks (default 3 lines of context). */
2790
+ function diffText(before, after, context = 3) {
2791
+ const span = Number.isFinite(context) ? Math.max(0, Math.floor(context)) : 3;
2792
+ if (before === after) return {
2793
+ hunks: [],
2794
+ added: 0,
2795
+ deleted: 0
2796
+ };
2797
+ const oldLines = splitLines(before);
2798
+ const newLines = splitLines(after);
2799
+ const beforeNoEol = before !== "" && !before.endsWith("\n");
2800
+ const afterNoEol = after !== "" && !after.endsWith("\n");
2801
+ const ops = editScript(oldLines, newLines) ?? [...oldLines.map((text) => ({
2802
+ kind: "-",
2803
+ text
2804
+ })), ...newLines.map((text) => ({
2805
+ kind: "+",
2806
+ text
2807
+ }))];
2808
+ markTrailingNewline(ops, beforeNoEol, afterNoEol);
2809
+ return buildHunks(ops, span, beforeNoEol, afterNoEol);
2810
+ }
2811
+ /**
2812
+ * Make "this line's final newline changed" visible to the diff.
2813
+ *
2814
+ * `splitLines` drops the terminator, so a line without one is indistinguishable
2815
+ * from the same line with one — yet to git they are different lines, and it
2816
+ * prints such a pair once removed (with the marker) and once added:
2817
+ *
2818
+ * ```
2819
+ * before "a" after "a\nb\n" => -a (no eol), +a, +b
2820
+ * before "a\nb\n" after "a\nb" => a, -b, +b (no eol)
2821
+ * ```
2822
+ *
2823
+ * The old file's final line can only be byte-equal to a new-side line when that
2824
+ * line is the new file's final line too *and* both sides agree about the EOL, so
2825
+ * any other match against it must be broken into a removal plus an addition.
2826
+ */
2827
+ function markTrailingNewline(ops, beforeNoEol, afterNoEol) {
2828
+ if (!beforeNoEol && !afterNoEol) return;
2829
+ const oldLast = lastIndexOfKind(ops, (kind) => kind !== "+");
2830
+ const newLast = lastIndexOfKind(ops, (kind) => kind !== "-");
2831
+ const oldOp = ops[oldLast];
2832
+ if (oldOp !== void 0 && oldOp.kind === "=" && beforeNoEol && !(oldLast === newLast && afterNoEol)) {
2833
+ ops.splice(oldLast, 1, {
2834
+ kind: "-",
2835
+ text: oldOp.text
2836
+ }, {
2837
+ kind: "+",
2838
+ text: oldOp.text
2839
+ });
2840
+ return;
2841
+ }
2842
+ const newOp = ops[newLast];
2843
+ if (newOp !== void 0 && newOp.kind === "=" && afterNoEol && !(newLast === oldLast && beforeNoEol)) ops.splice(newLast, 1, {
2844
+ kind: "-",
2845
+ text: newOp.text
2846
+ }, {
2847
+ kind: "+",
2848
+ text: newOp.text
2849
+ });
2850
+ }
2851
+ /** Group an edit script into hunks of `context` lines around each change. */
2852
+ function buildHunks(ops, context, beforeNoEol, afterNoEol) {
2853
+ /** Old-side line number just before op `i`. */
2854
+ const oldBefore = new Array(ops.length + 1).fill(0);
2855
+ /** New-side line number just before op `i`. */
2856
+ const newBefore = new Array(ops.length + 1).fill(0);
2857
+ let oldCount = 0;
2858
+ let newCount = 0;
2859
+ for (let index = 0; index < ops.length; index += 1) {
2860
+ oldBefore[index] = oldCount;
2861
+ newBefore[index] = newCount;
2862
+ const kind = ops[index]?.kind;
2863
+ if (kind !== "+") oldCount += 1;
2864
+ if (kind !== "-") newCount += 1;
2865
+ }
2866
+ oldBefore[ops.length] = oldCount;
2867
+ newBefore[ops.length] = newCount;
2868
+ const changes = [];
2869
+ for (let index = 0; index < ops.length; index += 1) if (ops[index]?.kind !== "=") changes.push(index);
2870
+ if (changes.length === 0) return {
2871
+ hunks: [],
2872
+ added: 0,
2873
+ deleted: 0
2874
+ };
2875
+ const groups = [];
2876
+ let groupStart = changes[0] ?? 0;
2877
+ let groupEnd = groupStart;
2878
+ for (let index = 1; index < changes.length; index += 1) {
2879
+ const next = changes[index] ?? groupEnd;
2880
+ if (next - groupEnd - 1 <= 2 * context) {
2881
+ groupEnd = next;
2882
+ continue;
2883
+ }
2884
+ groups.push([groupStart, groupEnd]);
2885
+ groupStart = next;
2886
+ groupEnd = next;
2887
+ }
2888
+ groups.push([groupStart, groupEnd]);
2889
+ const lastOldOp = lastIndexOfKind(ops, (kind) => kind !== "+");
2890
+ const lastNewOp = lastIndexOfKind(ops, (kind) => kind !== "-");
2891
+ const hunks = [];
2892
+ let added = 0;
2893
+ let deleted = 0;
2894
+ for (const [from, to] of groups) {
2895
+ const start = Math.max(0, from - context);
2896
+ const end = Math.min(ops.length - 1, to + context);
2897
+ const oldStartIndex = oldBefore[start] ?? 0;
2898
+ const newStartIndex = newBefore[start] ?? 0;
2899
+ const oldLines = (oldBefore[end + 1] ?? 0) - oldStartIndex;
2900
+ const newLines = (newBefore[end + 1] ?? 0) - newStartIndex;
2901
+ const lines = [];
2902
+ for (let index = start; index <= end; index += 1) {
2903
+ const op = ops[index];
2904
+ if (op === void 0) continue;
2905
+ lines.push((op.kind === "=" ? " " : op.kind) + op.text);
2906
+ if (op.kind === "+") added += 1;
2907
+ if (op.kind === "-") deleted += 1;
2908
+ }
2909
+ const hunk = {
2910
+ oldStart: oldLines === 0 ? oldStartIndex : oldStartIndex + 1,
2911
+ oldLines,
2912
+ newStart: newLines === 0 ? newStartIndex : newStartIndex + 1,
2913
+ newLines,
2914
+ lines
2915
+ };
2916
+ if (beforeNoEol && lastOldOp >= start && lastOldOp <= end) hunk.oldNoEol = true;
2917
+ if (afterNoEol && lastNewOp >= start && lastNewOp <= end) hunk.newNoEol = true;
2918
+ hunks.push(hunk);
2919
+ }
2920
+ return {
2921
+ hunks,
2922
+ added,
2923
+ deleted
2924
+ };
2925
+ }
2926
+ /** Index of the last op satisfying `predicate`, or -1. */
2927
+ function lastIndexOfKind(ops, predicate) {
2928
+ for (let index = ops.length - 1; index >= 0; index -= 1) {
2929
+ const kind = ops[index]?.kind;
2930
+ if (kind !== void 0 && predicate(kind)) return index;
2931
+ }
2932
+ return -1;
2933
+ }
2934
+ /**
2935
+ * Serialize one hunk as a patch `git apply` accepts (and `git apply -R` undoes).
2936
+ *
2937
+ * The `@@ -a,b +c,d @@` counts are always written out — git accepts the elided
2938
+ * `,1` and `,-0` spellings, but a reviewer reading a log is better served by the
2939
+ * explicit form, and one spelling means one code path to test.
2940
+ *
2941
+ * @param displayPath - repo-relative path, as the panel shows it.
2942
+ * @param hunk - one hunk of {@link diffText}.
2943
+ */
2944
+ function hunkPatch(displayPath, hunk) {
2945
+ const out = [
2946
+ `--- a/${displayPath}`,
2947
+ `+++ b/${displayPath}`,
2948
+ `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
2949
+ ];
2950
+ const markers = /* @__PURE__ */ new Set();
2951
+ if (hunk.oldNoEol === true) {
2952
+ const at = lastLineOfSide(hunk.lines, "-");
2953
+ if (at >= 0) markers.add(at);
2954
+ }
2955
+ if (hunk.newNoEol === true) {
2956
+ const at = lastLineOfSide(hunk.lines, "+");
2957
+ if (at >= 0) markers.add(at);
2958
+ }
2959
+ for (let index = 0; index < hunk.lines.length; index += 1) {
2960
+ out.push(hunk.lines[index] ?? "");
2961
+ if (markers.has(index)) out.push("\");
2962
+ }
2963
+ return `${out.join("\n")}\n`;
2964
+ }
2965
+ /**
2966
+ * Index of the last line in `lines` that belongs to the old side (`tag` is `'-'`)
2967
+ * or the new side (`tag` is `'+'`); context lines belong to both.
2968
+ */
2969
+ function lastLineOfSide(lines, tag) {
2970
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
2971
+ const head = lines[index]?.charAt(0);
2972
+ if (head === " " || head === tag) return index;
2973
+ }
2974
+ return -1;
2975
+ }
2976
+ //#endregion
2977
+ //#region src/changes/recorder.ts
2978
+ /**
2979
+ * Map a scan entry onto the panel's vocabulary.
2980
+ *
2981
+ * `?` means "git does not track this path yet", which for a review panel is
2982
+ * exactly a new file — the same thing `A` says for a staged addition.
2983
+ */
2984
+ function panelStatus(status) {
2985
+ return status === "?" ? "A" : status;
2986
+ }
2987
+ /** Grace added to an exec budget before the watchdog gives up on it. */
2988
+ const WATCHDOG_GRACE_MS = 5e3;
2989
+ /** Sentinel for "the host call never came back". */
2990
+ const WATCHDOG_FIRED = "watchdog-fired";
2991
+ /** POSIX join — `node:path` would apply Windows semantics on the host. */
2992
+ function posixJoin(base, rel) {
2993
+ if (rel.startsWith("/")) return rel;
2994
+ const left = base.endsWith("/") ? base.slice(0, -1) : base;
2995
+ const right = rel.startsWith("./") ? rel.slice(2) : rel;
2996
+ return right === "" ? left : `${left}/${right}`;
2997
+ }
2998
+ /**
2999
+ * Whether two reads prove the file did not move between them.
3000
+ *
3001
+ * The last resort for a path whose bytes never crossed the wire (over the size
3002
+ * cap, or binary): mtime and size are all that is left, and requiring BOTH keeps
3003
+ * the claim honest. mtime has one-second resolution, so an edit inside the same
3004
+ * second as the read is invisible here — which is why this is only ever used
3005
+ * where a content comparison is impossible.
3006
+ */
3007
+ function sameStat(before, after) {
3008
+ if (before === void 0 || after === void 0) return false;
3009
+ if (before.mtime === void 0 || after.mtime === void 0) return false;
3010
+ if (before.size === void 0 || after.size === void 0) return false;
3011
+ return before.mtime === after.mtime && before.size === after.size;
3012
+ }
3013
+ var SessionChanges = class {
3014
+ ledgers = /* @__PURE__ */ new Map();
3015
+ deps;
3016
+ disposed = false;
3017
+ constructor(deps) {
3018
+ this.deps = deps;
3019
+ }
3020
+ /** Open (or reuse) the ledger for one session at the start of a turn. */
3021
+ beginTurn(target) {
3022
+ if (this.disposed) return;
3023
+ const ledger = this.ensure(target);
3024
+ if (ledger === void 0) return;
3025
+ if (!ledger.baselineTaken) this.schedule(ledger);
3026
+ }
3027
+ /** Refresh the ledger at the end of a turn. */
3028
+ endTurn(target, turn) {
3029
+ if (this.disposed) return;
3030
+ const ledger = this.ensure(target);
3031
+ if (ledger === void 0) return;
3032
+ ledger.turn = Math.max(ledger.turn, turn);
3033
+ this.schedule(ledger);
3034
+ }
3035
+ /** The current summary for one session, or null when it has no ledger. */
3036
+ summary(sessionId) {
3037
+ const ledger = this.ledgers.get(sessionId);
3038
+ if (ledger === void 0) return null;
3039
+ return {
3040
+ alias: ledger.alias,
3041
+ root: ledger.root,
3042
+ turn: ledger.turn,
3043
+ files: ledger.files,
3044
+ added: ledger.added,
3045
+ deleted: ledger.deleted,
3046
+ at: ledger.at,
3047
+ pending: ledger.untrackedJob !== void 0 || ledger.pending && ledger.files.length > 0,
3048
+ ...ledger.truncated === true ? { truncated: true } : {},
3049
+ ...ledger.unavailable === void 0 ? {} : { unavailable: ledger.unavailable }
3050
+ };
3051
+ }
3052
+ /**
3053
+ * Compare one listed file across the whole session.
3054
+ *
3055
+ * The `before` side comes from the session baseline, which never moves. The
3056
+ * `after` side is read from the host on every call rather than replayed from
3057
+ * the last scan: a revert, a save from the editor or a shell command all
3058
+ * change the file between scans, and replaying a cached copy against a fresh
3059
+ * buffer is exactly how the painted lines end up at the wrong places.
3060
+ *
3061
+ * @param sessionId - live session owning the ledger.
3062
+ * @param path - the file's remote absolute path (as listed in the summary).
3063
+ * @returns hunks for decorations, or null when the session/file is unknown.
3064
+ */
3065
+ async diff(sessionId, path) {
3066
+ const ledger = this.ledgers.get(sessionId);
3067
+ if (ledger === void 0) return null;
3068
+ const record = ledger.records.get(path);
3069
+ if (record === void 0) return null;
3070
+ const status = record.status;
3071
+ let afterText = record.after;
3072
+ let binary = record.binary;
3073
+ let truncated = record.truncated;
3074
+ const fresh = await this.readPaths(ledger, [record.display], "worktree");
3075
+ if (ledger.disposed) return null;
3076
+ const side = fresh.get(record.display);
3077
+ if (side !== void 0) {
3078
+ binary = side.binary;
3079
+ truncated = side.truncated;
3080
+ afterText = side.absent ? null : side.text;
3081
+ if (afterText === null && !side.absent && !side.binary) afterText = record.after;
3082
+ }
3083
+ if (binary) return {
3084
+ kind: "binary",
3085
+ path,
3086
+ display: record.display,
3087
+ hunks: [],
3088
+ ...status === void 0 ? {} : { status }
3089
+ };
3090
+ const hunks = diffText(record.before ?? "", afterText ?? "").hunks.map((hunk) => ({
3091
+ oldStart: hunk.oldStart,
3092
+ oldCount: hunk.oldLines,
3093
+ newStart: hunk.newStart,
3094
+ newCount: hunk.newLines,
3095
+ heading: "",
3096
+ lines: hunk.lines,
3097
+ patch: hunkPatch(record.display, hunk)
3098
+ }));
3099
+ if (hunks.length === 0) return {
3100
+ kind: "missing",
3101
+ path,
3102
+ display: record.display,
3103
+ hunks: [],
3104
+ ...status === void 0 ? {} : { status }
3105
+ };
3106
+ return {
3107
+ kind: "text",
3108
+ path,
3109
+ display: record.display,
3110
+ hunks,
3111
+ ...status === void 0 ? {} : { status },
3112
+ ...truncated ? { truncated: true } : {}
3113
+ };
3114
+ }
3115
+ /** Resolve once every queued refresh has settled, including coalesced work. */
3116
+ async settled() {
3117
+ for (let guard = 0; guard < 100; guard += 1) {
3118
+ await Promise.all([...this.ledgers.values()].map((ledger) => ledger.queue));
3119
+ const jobs = [...this.ledgers.values()].map((ledger) => ledger.untrackedJob).filter((job) => job !== void 0);
3120
+ if (jobs.length > 0) await Promise.all(jobs);
3121
+ if (![...this.ledgers.values()].some((ledger) => ledger.pending || ledger.untrackedJob !== void 0)) return;
3122
+ }
3123
+ }
3124
+ /** Drop one session's ledger. */
3125
+ forget(sessionId) {
3126
+ const ledger = this.ledgers.get(sessionId);
3127
+ if (ledger === void 0) return;
3128
+ this.ledgers.delete(sessionId);
3129
+ ledger.disposed = true;
3130
+ }
3131
+ /** Release every ledger (plugin unmount). */
3132
+ dispose() {
3133
+ this.disposed = true;
3134
+ for (const sessionId of [...this.ledgers.keys()]) this.forget(sessionId);
3135
+ }
3136
+ /**
3137
+ * Force a fresh pass, ignoring the untracked cadence.
3138
+ *
3139
+ * The panel's refresh button calls this: the cadence exists so a slow scan
3140
+ * does not run on every turn, but "I clicked refresh" means the user wants
3141
+ * the truth now — including files that appeared seconds ago.
3142
+ */
3143
+ rescan(sessionId) {
3144
+ const ledger = this.ledgers.get(sessionId);
3145
+ if (ledger === void 0 || ledger.disposed) return;
3146
+ ledger.untrackedAt = 0;
3147
+ this.schedule(ledger);
3148
+ }
3149
+ /**
3150
+ * Self-heal a ledger whose data has gone stale.
3151
+ *
3152
+ * The panel polls `list`, and a poll is the one moment we know a human is
3153
+ * looking at the answer. If the data is older than `maxAgeMs`, re-scan — so a
3154
+ * turn boundary this Host never delivered costs a few seconds of staleness
3155
+ * instead of a manual refresh.
3156
+ *
3157
+ * @returns whether a refresh was queued.
3158
+ */
3159
+ touch(sessionId, maxAgeMs = 3e4) {
3160
+ const ledger = this.ledgers.get(sessionId);
3161
+ if (ledger === void 0 || ledger.disposed || ledger.pending) return false;
3162
+ if (ledger.at > 0 && Date.now() - ledger.at < maxAgeMs) return false;
3163
+ this.schedule(ledger);
3164
+ return true;
3165
+ }
3166
+ /**
3167
+ * Point one ledger at a subdirectory of the session root.
3168
+ *
3169
+ * This is the Source Control panel's "repository directory": an explicit
3170
+ * choice by the user, and therefore the authority whenever it is set. The
3171
+ * automatic one-level probe is only a fallback for a container workspace that
3172
+ * holds exactly one repository.
3173
+ *
3174
+ * @param subdir - workspace-relative directory, or undefined for the session
3175
+ * root itself. Absolute paths and `..` are refused: they would move the
3176
+ * ledger outside the workspace it was created for.
3177
+ */
3178
+ useRepoSubdir(sessionId, subdir) {
3179
+ const ledger = this.ledgers.get(sessionId);
3180
+ if (ledger === void 0 || ledger.disposed) return;
3181
+ const clean = normalizeRepoSubdir(subdir);
3182
+ if (clean === ledger.subdir) return;
3183
+ ledger.subdir = clean;
3184
+ ledger.root = clean === "" ? ledger.baseRoot : `${ledger.baseRoot}/${clean}`;
3185
+ ledger.baseline = void 0;
3186
+ ledger.baselineTaken = false;
3187
+ ledger.adoptedSubdir = false;
3188
+ ledger.files = [];
3189
+ ledger.records = /* @__PURE__ */ new Map();
3190
+ ledger.added = 0;
3191
+ ledger.deleted = 0;
3192
+ ledger.at = 0;
3193
+ this.schedule(ledger);
3194
+ }
3195
+ /** Resolve the routing target once per session, caching the ledger. */
3196
+ ensure(target) {
3197
+ if (target.cwd.trim() === "") return void 0;
3198
+ const existing = this.ledgers.get(target.sessionId);
3199
+ if (existing !== void 0 && !existing.disposed && existing.root !== "") return existing;
3200
+ let route;
3201
+ try {
3202
+ route = this.deps.worlds.routeDir(target.cwd);
3203
+ } catch {
3204
+ return;
3205
+ }
3206
+ if (route.kind !== "remote") {
3207
+ this.ledgers.delete(target.sessionId);
3208
+ return;
3209
+ }
3210
+ const root = route.remotePath.length > 1 && route.remotePath.endsWith("/") ? route.remotePath.replace(/\/+$/, "") : route.remotePath;
3211
+ const ledger = {
3212
+ sessionId: target.sessionId,
3213
+ alias: route.alias,
3214
+ root,
3215
+ baseline: void 0,
3216
+ baselineTaken: false,
3217
+ untrackedGivenUp: false,
3218
+ startedAt: Date.now(),
3219
+ absorbed: false,
3220
+ adoptedSubdir: false,
3221
+ baseRoot: root,
3222
+ subdir: "",
3223
+ tracked: [],
3224
+ untracked: void 0,
3225
+ untrackedAt: 0,
3226
+ untrackedMs: void 0,
3227
+ untrackedJob: void 0,
3228
+ turn: 0,
3229
+ files: [],
3230
+ records: /* @__PURE__ */ new Map(),
3231
+ added: 0,
3232
+ deleted: 0,
3233
+ truncated: void 0,
3234
+ at: 0,
3235
+ unavailable: void 0,
3236
+ pending: false,
3237
+ queue: Promise.resolve(),
3238
+ dirty: false,
3239
+ disposed: false
3240
+ };
3241
+ this.ledgers.set(target.sessionId, ledger);
3242
+ return ledger;
3243
+ }
3244
+ /** Queue a refresh, coalescing bursts (a turn can end while one is running). */
3245
+ schedule(ledger) {
3246
+ if (ledger.disposed) return;
3247
+ if (ledger.pending) {
3248
+ ledger.dirty = true;
3249
+ return;
3250
+ }
3251
+ ledger.pending = true;
3252
+ ledger.queue = ledger.queue.then(() => this.refresh(ledger)).catch((error) => this.warn(`refresh failed: ${messageOf(error)}`)).finally(() => {
3253
+ ledger.pending = false;
3254
+ if (ledger.dirty && !ledger.disposed) {
3255
+ ledger.dirty = false;
3256
+ this.schedule(ledger);
3257
+ }
3258
+ });
3259
+ }
3260
+ /**
3261
+ * One refresh: the cheap scan first, then the slow one in the background.
3262
+ *
3263
+ * The split is not cosmetic. On the reference monorepo `git status -uno`
3264
+ * answers in ~3s while `git ls-files --others` needs over a minute, so a
3265
+ * single sequential refresh would leave the panel empty for that whole
3266
+ * minute even though the tracked changes were known after three seconds.
3267
+ * Phase one publishes, phase two adds the new files when (and if) it lands.
3268
+ */
3269
+ async refresh(ledger) {
3270
+ if (ledger.disposed || ledger.root === "") return;
3271
+ const tracked = await this.scanTracked(ledger);
3272
+ if (ledger.disposed || tracked === void 0) return;
3273
+ ledger.tracked = tracked;
3274
+ await this.publish(ledger);
3275
+ this.kickUntracked(ledger);
3276
+ }
3277
+ /** Recompute the panel's list from the scans currently known. */
3278
+ async publish(ledger) {
3279
+ if (ledger.disposed) return;
3280
+ const merged = /* @__PURE__ */ new Map();
3281
+ for (const entry of ledger.tracked) merged.set(entry.path, entry);
3282
+ for (const entry of ledger.untracked ?? []) if (!merged.has(entry.path)) merged.set(entry.path, entry);
3283
+ const entries = [...merged.values()].sort((left, right) => left.path.localeCompare(right.path));
3284
+ const cap = this.deps.maxFiles ?? 500;
3285
+ const listed = entries.slice(0, cap);
3286
+ const truncated = entries.length > cap;
3287
+ if (!ledger.baselineTaken) {
3288
+ ledger.baseline = await this.readPaths(ledger, listed.map((entry) => entry.path), "worktree");
3289
+ if (ledger.disposed) return;
3290
+ ledger.baselineTaken = true;
3291
+ ledger.files = [];
3292
+ ledger.records = /* @__PURE__ */ new Map();
3293
+ ledger.added = 0;
3294
+ ledger.deleted = 0;
3295
+ ledger.truncated = truncated ? true : void 0;
3296
+ ledger.at = Date.now();
3297
+ return;
3298
+ }
3299
+ const baseline = ledger.baseline ?? /* @__PURE__ */ new Map();
3300
+ const fromHead = listed.filter((entry) => !baseline.has(entry.path)).map((entry) => entry.path);
3301
+ const head = fromHead.length === 0 ? /* @__PURE__ */ new Map() : await this.readPaths(ledger, fromHead, "head");
3302
+ const after = await this.readPaths(ledger, listed.map((entry) => entry.path), "worktree");
3303
+ if (ledger.disposed) return;
3304
+ const records = /* @__PURE__ */ new Map();
3305
+ const files = [];
3306
+ let added = 0;
3307
+ let deleted = 0;
3308
+ for (const entry of listed) {
3309
+ const beforeSide = baseline.get(entry.path) ?? head.get(entry.path);
3310
+ const afterSide = after.get(entry.path);
3311
+ const before = beforeSide?.text ?? null;
3312
+ const afterText = afterSide?.text ?? null;
3313
+ const binary = beforeSide?.binary === true || afterSide?.binary === true;
3314
+ const fileTruncated = beforeSide?.truncated === true || afterSide?.truncated === true;
3315
+ if (beforeSide?.absent === true && afterSide?.absent === true) continue;
3316
+ if (!binary && !fileTruncated && before === afterText) continue;
3317
+ if ((binary || fileTruncated) && sameStat(beforeSide, afterSide)) continue;
3318
+ const oversized = fileTruncated && !binary;
3319
+ const diff = binary || oversized ? {
3320
+ added: 0,
3321
+ deleted: 0
3322
+ } : diffText(before ?? "", afterText ?? "");
3323
+ const status = panelStatus(entry.status);
3324
+ const absolute = posixJoin(ledger.root, entry.path);
3325
+ records.set(absolute, {
3326
+ display: entry.path,
3327
+ path: absolute,
3328
+ status,
3329
+ before,
3330
+ after: afterText,
3331
+ binary,
3332
+ truncated: fileTruncated
3333
+ });
3334
+ added += diff.added;
3335
+ deleted += diff.deleted;
3336
+ files.push({
3337
+ path: absolute,
3338
+ display: entry.path,
3339
+ added: diff.added,
3340
+ deleted: diff.deleted,
3341
+ ...binary ? { binary: true } : {},
3342
+ ...oversized ? { oversized: true } : {},
3343
+ ...status === void 0 ? {} : { status }
3344
+ });
3345
+ }
3346
+ files.sort((left, right) => left.display.localeCompare(right.display));
3347
+ ledger.records = records;
3348
+ ledger.files = files;
3349
+ ledger.added = added;
3350
+ ledger.deleted = deleted;
3351
+ ledger.truncated = truncated ? true : void 0;
3352
+ ledger.unavailable = listed.length > 0 && after.size === 0 ? "file contents could not be read from the host" : void 0;
3353
+ ledger.at = Date.now();
3354
+ }
3355
+ /**
3356
+ * The cheap scan: tracked files whose working-tree bytes differ from the
3357
+ * index (1.4s measured on the reference monorepo).
3358
+ *
3359
+ * A session working directory is often a CONTAINER for repositories rather
3360
+ * than one itself (the reference host has `/home/xiray/dsh_workbase` holding
3361
+ * `emv-toolbox-web`), and `git status` there answers exit 128 — "not a git
3362
+ * repository (or any parent up to mount point /)". When that happens the
3363
+ * ledger looks one level down once and adopts the repository it finds, which
3364
+ * is the same idea the SCM panel's `repoSubdir` expresses by hand.
3365
+ *
3366
+ * @returns entries relative to the ledger's root, or undefined when no
3367
+ * repository could be found (the ledger then reports why).
3368
+ */
3369
+ async scanTracked(ledger) {
3370
+ const tracked = await this.run(ledger.alias, trackedStatusCommand(ledger.root));
3371
+ if (tracked.ok) {
3372
+ ledger.unavailable = void 0;
3373
+ return parseStatusZ(tracked.stdout);
3374
+ }
3375
+ if (tracked.code === 90 && ledger.subdir !== "") {
3376
+ this.warn(`repository directory "${ledger.subdir}" is missing on the host; falling back to the session root`);
3377
+ ledger.subdir = "";
3378
+ ledger.root = ledger.baseRoot;
3379
+ ledger.baseline = void 0;
3380
+ ledger.baselineTaken = false;
3381
+ const retry = await this.run(ledger.alias, trackedStatusCommand(ledger.root));
3382
+ if (retry.ok) {
3383
+ ledger.unavailable = void 0;
3384
+ return parseStatusZ(retry.stdout);
3385
+ }
3386
+ ledger.unavailable = retry.reason;
3387
+ return;
3388
+ }
3389
+ if (!ledger.adoptedSubdir) {
3390
+ if (await this.adoptRepositoryBelow(ledger) !== void 0) {
3391
+ const retry = await this.run(ledger.alias, trackedStatusCommand(ledger.root));
3392
+ if (retry.ok) {
3393
+ ledger.unavailable = void 0;
3394
+ return parseStatusZ(retry.stdout);
3395
+ }
3396
+ ledger.unavailable = retry.reason;
3397
+ return;
3398
+ }
3399
+ }
3400
+ ledger.unavailable = tracked.reason;
3401
+ }
3402
+ /**
3403
+ * Look for a repository in the session root's immediate subdirectories.
3404
+ *
3405
+ * **Only an unambiguous answer is adopted.** A workspace that holds several
3406
+ * repositories has no single right one, and silently picking the first would
3407
+ * report changes from a project the session never touched — worse than saying
3408
+ * "I don't know". The explicit repository directory the Source Control panel
3409
+ * stores is the real answer, and this fallback exists only to spare the user
3410
+ * from setting it when there is exactly one candidate.
3411
+ *
3412
+ * @returns the adopted root, or undefined when nothing (or too much) was found.
3413
+ */
3414
+ async adoptRepositoryBelow(ledger) {
3415
+ ledger.adoptedSubdir = true;
3416
+ const probe = await this.run(ledger.alias, reposBelowCommand(ledger.root));
3417
+ if (!probe.ok) return void 0;
3418
+ const found = probe.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.includes("/") && line !== ".");
3419
+ if (found.length === 0) return void 0;
3420
+ if (found.length > 1) {
3421
+ ledger.unavailable = `no git repository at the workspace root, and ${found.length} candidates below it — set the repository directory in Source Control`;
3422
+ return;
3423
+ }
3424
+ ledger.root = `${ledger.root}/${found[0]}`;
3425
+ this.warn(`no repository at the session root; using ${ledger.root}`);
3426
+ return ledger.root;
3427
+ }
3428
+ /**
3429
+ * The slow scan: new files, in the background.
3430
+ *
3431
+ * The same command costs ~11s on a warm tree and over a minute on a cold one,
3432
+ * and on the reference AOSP monorepo it has been measured past 60s with no
3433
+ * answer at all. It therefore never runs inline with the tracked scan, never
3434
+ * runs twice at once, and only runs again after {@link untrackedCadence}. A
3435
+ * repository that cannot answer inside {@link untrackedBudget} is written off
3436
+ * for the session (`untrackedGivenUp`) rather than retried every turn.
3437
+ */
3438
+ async kickUntracked(ledger) {
3439
+ if (ledger.disposed || ledger.untrackedGivenUp || ledger.untrackedJob !== void 0) return;
3440
+ if (ledger.untracked !== void 0 && Date.now() - ledger.untrackedAt < this.untrackedCadence(ledger)) return;
3441
+ ledger.untrackedAt = Date.now();
3442
+ ledger.untrackedJob = (async () => {
3443
+ const started = Date.now();
3444
+ const scan = await this.run(ledger.alias, untrackedCommand(ledger.root), this.untrackedBudget());
3445
+ ledger.untrackedMs = Date.now() - started;
3446
+ if (ledger.disposed) return;
3447
+ if (!scan.ok) {
3448
+ ledger.untrackedGivenUp = true;
3449
+ this.warn(`untracked scan unusable (${scan.reason}); new files will not be listed`);
3450
+ return;
3451
+ }
3452
+ ledger.untracked = parseUntrackedZ(scan.stdout);
3453
+ await this.absorbPreExisting(ledger);
3454
+ await this.publish(ledger);
3455
+ })().catch((error) => this.warn(`untracked scan failed: ${messageOf(error)}`)).finally(() => {
3456
+ ledger.untrackedJob = void 0;
3457
+ });
3458
+ }
3459
+ /**
3460
+ * How long before the untracked scan may run again.
3461
+ *
3462
+ * Self-tuning on purpose: the same command costs ~0.2s in a small repository
3463
+ * and over a minute in a monorepo, so a fixed cadence is either wasteful or
3464
+ * useless. Backing off to four times the last duration keeps small projects
3465
+ * near-real-time while a big one settles into a scan every couple of minutes
3466
+ * instead of one per turn.
3467
+ */
3468
+ untrackedCadence(ledger) {
3469
+ const configured = this.deps.untrackedRefreshMs;
3470
+ if (configured !== void 0) return configured;
3471
+ const last = ledger.untrackedMs;
3472
+ if (last === void 0) return 0;
3473
+ return Math.max(5e3, Math.min(last * 2, 6e5));
3474
+ }
3475
+ /**
3476
+ * Fold files that already existed when the session began into the baseline.
3477
+ *
3478
+ * The untracked walk is slow enough (a minute on a monorepo) that the baseline
3479
+ * refresh publishes before it lands, so its first result arrives without a
3480
+ * "before" side. Without this, every file the checkout already had — the
3481
+ * user's own scratch files, a build's leftovers — would be announced as this
3482
+ * session's work, complete with a full-file addition count.
3483
+ *
3484
+ * mtime decides: strictly older than the ledger's own start means the session
3485
+ * cannot have created it, and its present content is therefore its baseline.
3486
+ */
3487
+ async absorbPreExisting(ledger) {
3488
+ if (ledger.untracked === void 0 || ledger.absorbed) return;
3489
+ ledger.absorbed = true;
3490
+ const baseline = ledger.baseline ?? /* @__PURE__ */ new Map();
3491
+ ledger.baseline = baseline;
3492
+ const unknown = ledger.untracked.map((entry) => entry.path).filter((path) => !baseline.has(path));
3493
+ if (unknown.length === 0) return;
3494
+ const reads = await this.readPaths(ledger, unknown, "worktree");
3495
+ if (ledger.disposed) return;
3496
+ for (const [path, read] of reads) {
3497
+ if (read.mtime === void 0) continue;
3498
+ if (read.mtime * 1e3 < ledger.startedAt) baseline.set(path, read);
3499
+ }
3500
+ }
3501
+ /**
3502
+ * Race one host call against its own budget.
3503
+ *
3504
+ * A budget handed to `exec` is a request, not a guarantee: the transport may
3505
+ * keep the promise open long after the command should have been killed (the
3506
+ * remote process can even be gone while the channel stays). Awaiting such a
3507
+ * promise blocks the ledger's whole queue, so the panel shows "computing…"
3508
+ * for as long as the user is willing to stare at it. This bounds that wait.
3509
+ */
3510
+ async withWatchdog(call, ms) {
3511
+ let timer;
3512
+ try {
3513
+ return await Promise.race([call, new Promise((resolve) => {
3514
+ timer = setTimeout(() => resolve(WATCHDOG_FIRED), ms);
3515
+ })]);
3516
+ } finally {
3517
+ if (timer !== void 0) clearTimeout(timer);
3518
+ call.catch(() => void 0);
3519
+ }
3520
+ }
3521
+ /** Read a batch of files, capped per file and in total. */
3522
+ async readPaths(ledger, paths, side) {
3523
+ if (paths.length === 0) return /* @__PURE__ */ new Map();
3524
+ const perFile = this.deps.maxFileBytes ?? 256 * 1024;
3525
+ const total = this.deps.maxTotalBytes ?? 8 * 1024 * 1024;
3526
+ const command = side === "worktree" ? readWorktreeCommand(ledger.root, paths, perFile, total) : readHeadCommand(ledger.root, paths, perFile, total);
3527
+ const result = await this.run(ledger.alias, command);
3528
+ if (!result.ok) {
3529
+ this.warn(`read failed (${side}): ${result.reason}`);
3530
+ return /* @__PURE__ */ new Map();
3531
+ }
3532
+ return parseReads(result.stdout);
3533
+ }
3534
+ /** Run one command on the host, normalizing failure into a reason string. */
3535
+ async run(alias, command, budget = this.execBudget()) {
3536
+ try {
3537
+ const call = this.deps.pool.exec(alias, command, { timeoutMs: budget });
3538
+ const result = await this.withWatchdog(call, budget + Math.min(WATCHDOG_GRACE_MS, budget));
3539
+ if (result === WATCHDOG_FIRED) return {
3540
+ ok: false,
3541
+ reason: "git did not answer in time"
3542
+ };
3543
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
3544
+ if (result.success) return {
3545
+ ok: true,
3546
+ stdout
3547
+ };
3548
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
3549
+ return {
3550
+ ok: false,
3551
+ reason: this.reasonOf(result.exitCode, stderr),
3552
+ code: result.exitCode
3553
+ };
3554
+ } catch (error) {
3555
+ return {
3556
+ ok: false,
3557
+ reason: `host unreachable: ${messageOf(error)}`
3558
+ };
3559
+ }
3560
+ }
3561
+ /** Turn a failed scan into something a user can act on. */
3562
+ reasonOf(exitCode, stderr) {
3563
+ if (exitCode === 93 || exitCode === 128) return "not a git repository (change tracking needs git)";
3564
+ if (exitCode === 90) return "session directory is missing on the host";
3565
+ if (exitCode === 124 || exitCode === null || exitCode === void 0) return `git did not answer in time${stderr === "" ? "" : ` (${firstLine(stderr)})`}`;
3566
+ return `git failed (exit ${exitCode})${stderr === "" ? "" : ` (${firstLine(stderr)})`}`;
3567
+ }
3568
+ /** One exec's budget: the git setting, clamped so a hung host cannot pile up work. */
3569
+ execBudget() {
3570
+ const override = this.deps.execBudgetMs;
3571
+ if (override !== void 0 && Number.isFinite(override) && override > 0) return override;
3572
+ return this.budget(this.deps.timeouts().gitMs, 5e3, 6e4);
3573
+ }
3574
+ /** The untracked scan gets its own, more generous budget. */
3575
+ /**
3576
+ * The untracked scan gets its own, much more generous budget.
3577
+ *
3578
+ * Three minutes is a deliberate choice for a monorepo: the walk is proportional
3579
+ * to the whole checkout, not to the session, and the reference AOSP tree has
3580
+ * been measured past a minute cold. Waiting is acceptable here because the
3581
+ * scan runs in the background and the tracked changes are already on screen;
3582
+ * giving up early is not, because the files a session creates with a shell
3583
+ * command are visible ONLY through this scan.
3584
+ */
3585
+ untrackedBudget() {
3586
+ return this.deps.untrackedBudgetMs ?? this.budget(this.deps.timeouts().gitMs, 1e4, 18e4);
3587
+ }
3588
+ budget(configured, floor, ceiling) {
3589
+ return Math.max(floor, Math.min(Number.isFinite(configured) && configured > 0 ? configured : 3e4, ceiling));
3590
+ }
3591
+ warn(message) {
3592
+ try {
3593
+ this.deps.log?.("warn", message);
3594
+ } catch {}
3595
+ }
3596
+ };
3597
+ function firstLine(text) {
3598
+ return (text.split("\n").find((candidate) => candidate.trim() !== "") ?? "").trim().slice(0, 160);
3599
+ }
3600
+ /** Workspace-relative repository directory, or '' when the value is unusable. */
3601
+ function normalizeRepoSubdir(value) {
3602
+ if (value === void 0) return "";
3603
+ const trimmed = value.trim().replace(/^\.\/+/, "").replace(/\/+$/, "");
3604
+ if (trimmed === "" || trimmed === ".") return "";
3605
+ if (trimmed.startsWith("/") || trimmed.includes("..")) return "";
3606
+ return trimmed;
3607
+ }
3608
+ function messageOf(error) {
3609
+ return error instanceof Error ? error.message : String(error);
3610
+ }
3611
+ //#endregion
3612
+ //#region src/tools.ts
3613
+ /**
3614
+ * Agent tools: the model-facing counterpart of the remote GUI. Every tool
3615
+ * talks to the same pool/files the web UI uses. Hosts must be configured in
3616
+ * the GUI first (agents cannot create hosts).
3617
+ */
3618
+ function text(value) {
3619
+ return [{
3620
+ type: "text",
3621
+ text: value
3622
+ }];
3623
+ }
3624
+ function toAgentHostRow(host) {
3625
+ return {
3626
+ alias: host.alias,
3627
+ host: host.host,
3628
+ port: host.port,
3629
+ user: host.user,
3630
+ auth: host.auth,
3631
+ keyReady: host.keyReady,
3632
+ managedKey: host.managedKey,
3633
+ ...host.description !== void 0 ? { description: host.description } : {},
3634
+ tags: [...host.tags],
3635
+ createdAt: host.createdAt,
3636
+ updatedAt: host.updatedAt
3637
+ };
3638
+ }
3639
+ /**
3640
+ * Render the host table.
3641
+ *
3642
+ * An empty list has TWO very different causes and they used to share one
3643
+ * message: nothing is configured, or a query filtered everything out. The
3644
+ * second one claimed "no remote hosts configured" while five were sitting
3645
+ * there, which reads as "stop looking" to a caller that just searched.
3646
+ */
3647
+ function renderHosts(hosts, query) {
3648
+ if (hosts.length === 0) return query === void 0 || query.trim() === "" ? "no remote hosts configured (add one in the Remote sidebar tab first)" : `no remote host matches "${query}" — hosts are configured, the query just filtered them all out`;
3649
+ return [
3650
+ "alias | host | port | user | auth | key | tags | description",
3651
+ "--- | --- | --- | --- | --- | --- | --- | ---",
3652
+ ...hosts.map((h) => [
3653
+ h.alias,
3654
+ h.host,
3655
+ String(h.port),
3656
+ h.user,
3657
+ h.auth,
3658
+ h.auth === "key" ? h.keyReady ? "key-ok" : "key-missing" : "password",
3659
+ h.tags.length > 0 ? h.tags.join(",") : "-",
3660
+ h.description ?? ""
3661
+ ].join(" | "))
3662
+ ].join("\n");
3663
+ }
3664
+ /**
3665
+ * `ls -l`-style type letter for one entry.
3666
+ *
3667
+ * A symlink is NOT a regular file, and rendering it as one made the two
3668
+ * indistinguishable: `/bin`, `/lib`, `/lib64` and `/sbin` on an ordinary host
3669
+ * are symlinks to directories, and they came out as `- 7 bin`, which reads as a
3670
+ * seven-byte file. `isSymlink` is already in the entry, so the render simply has
3671
+ * to use it. `.spike/tool-schema-check.mjs` now renders every tool and fails if
3672
+ * these three kinds stop being distinguishable.
3673
+ */
3674
+ function entryKind(entry) {
3675
+ if (entry.isDir) return "d";
3676
+ if (entry.isSymlink) return "l";
3677
+ return "-";
3678
+ }
3679
+ function renderExec(result) {
3680
+ const parts = [result.timedOut ? "[timed out]" : `[exit code: ${result.exitCode ?? "null"}]`];
3681
+ if (result.stdout !== "") parts.push("stdout:\n" + result.stdout);
3682
+ if (result.stderr !== "") parts.push("stderr:\n" + result.stderr);
3683
+ if (result.error !== void 0) parts.push("error: " + result.error);
3684
+ parts.push(`duration: ${result.durationMs} ms`);
3685
+ return parts.join("\n");
3686
+ }
3687
+ function remoteHostsTool(deps) {
3688
+ return defineTool({
3689
+ name: "remote_hosts",
3690
+ description: "List configured remote SSH hosts (alias, host, user, auth). Use remote_exec/remote_read/remote_write/remote_ls with the alias.",
3691
+ parameters: { query: {
3692
+ type: "string",
3693
+ description: "Optional fuzzy match against alias, host, and description."
3694
+ } },
3695
+ output: {
3696
+ schema: {
3697
+ type: "object",
3698
+ additionalProperties: false,
3699
+ properties: { hosts: {
3700
+ type: "array",
3701
+ required: true,
3702
+ items: {
3703
+ type: "object",
3704
+ additionalProperties: false,
3705
+ properties: {
3706
+ alias: {
3707
+ type: "string",
3708
+ required: true
3709
+ },
3710
+ host: {
3711
+ type: "string",
3712
+ required: true
3713
+ },
3714
+ port: {
3715
+ type: "integer",
3716
+ required: true
3717
+ },
3718
+ user: {
3719
+ type: "string",
3720
+ required: true
3721
+ },
3722
+ auth: {
3723
+ type: "string",
3724
+ enum: ["key", "password"],
3725
+ required: true
3726
+ },
3727
+ keyReady: {
3728
+ type: "boolean",
3729
+ required: true
3730
+ },
3731
+ managedKey: {
3732
+ type: "boolean",
3733
+ required: true
3734
+ },
3735
+ description: { type: "string" },
3736
+ tags: {
3737
+ type: "array",
3738
+ items: { type: "string" },
3739
+ required: true
3740
+ },
3741
+ createdAt: {
3742
+ type: "integer",
3743
+ required: true
3744
+ },
3745
+ updatedAt: {
3746
+ type: "integer",
3747
+ required: true
3748
+ }
3749
+ }
3750
+ }
3751
+ } }
3752
+ },
3753
+ render: (args, value) => text(renderHosts(value.hosts ?? [], args.query))
3754
+ },
3755
+ isConcurrencySafe: () => true,
3756
+ async execute(args) {
3757
+ const query = typeof args.query === "string" && args.query !== "" ? args.query.toLowerCase() : void 0;
3758
+ return { hosts: deps.store.list().map((h) => deps.store.summarize(h)).filter((h) => query === void 0 || h.alias.toLowerCase().includes(query) || h.host.toLowerCase().includes(query) || (h.description ?? "").toLowerCase().includes(query)).map(toAgentHostRow) };
3759
+ }
3760
+ });
3761
+ }
3762
+ function remoteExecTool(deps) {
3763
+ return defineTool({
3764
+ name: "remote_exec",
3765
+ description: "Run a non-interactive shell command on a remote SSH host. stdout/stderr are returned verbatim (may carry secrets).",
3766
+ parameters: {
3767
+ alias: {
3768
+ type: "string",
3769
+ description: "Host alias from remote_hosts."
3770
+ },
3771
+ command: {
3772
+ type: "string",
3773
+ description: "Shell command to run on the remote host."
3774
+ },
3775
+ timeoutMs: {
3776
+ type: "number",
3777
+ description: "Timeout in ms (default 60000, max 600000)."
3778
+ }
3779
+ },
3780
+ output: {
3781
+ schema: {
3782
+ type: "object",
3783
+ additionalProperties: false,
3784
+ properties: {
3785
+ success: {
3786
+ type: "boolean",
3787
+ required: true
3788
+ },
3789
+ exitCode: { type: "integer" },
3790
+ timedOut: {
3791
+ type: "boolean",
3792
+ required: true
3793
+ },
3794
+ stdout: {
3795
+ type: "string",
3796
+ required: true
3797
+ },
3798
+ stderr: {
3799
+ type: "string",
3800
+ required: true
3801
+ },
3802
+ durationMs: {
3803
+ type: "number",
3804
+ required: true
3805
+ },
3806
+ error: { type: "string" }
3807
+ }
3808
+ },
3809
+ render: (_args, value) => text(renderExec({
3810
+ ...value,
3811
+ exitCode: value.exitCode ?? null
3812
+ }))
3813
+ },
3814
+ async execute(args) {
3815
+ const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? Math.min(Math.max(Math.floor(args.timeoutMs), 1e3), 6e5) : void 0;
3816
+ const result = await deps.pool.exec(args.alias, args.command, timeoutMs === void 0 ? {} : { timeoutMs });
3817
+ return {
3818
+ success: result.success,
3819
+ timedOut: result.timedOut,
3820
+ stdout: result.stdout,
3821
+ stderr: result.stderr,
3822
+ durationMs: result.durationMs,
3823
+ ...result.exitCode !== null ? { exitCode: result.exitCode } : {},
3824
+ ...result.error !== void 0 ? { error: result.error } : {}
3825
+ };
3826
+ }
3827
+ });
3828
+ }
3829
+ function remoteLsTool(deps) {
3830
+ return defineTool({
3831
+ name: "remote_ls",
3832
+ description: "List one remote directory over SFTP (absolute POSIX path).",
3833
+ parameters: {
3834
+ alias: {
3835
+ type: "string",
3836
+ description: "Host alias from remote_hosts."
3837
+ },
3838
+ path: {
3839
+ type: "string",
3840
+ description: "Absolute remote directory path."
3841
+ }
3842
+ },
3843
+ output: {
3844
+ schema: {
3845
+ type: "object",
3846
+ additionalProperties: false,
3847
+ properties: { entries: {
3848
+ type: "array",
3849
+ required: true,
3850
+ items: {
3851
+ type: "object",
3852
+ additionalProperties: false,
3853
+ properties: {
3854
+ name: {
3855
+ type: "string",
3856
+ required: true
3857
+ },
3858
+ path: {
3859
+ type: "string",
3860
+ required: true
3861
+ },
3862
+ isDir: {
3863
+ type: "boolean",
3864
+ required: true
3865
+ },
3866
+ size: {
3867
+ type: "integer",
3868
+ required: true
3869
+ },
3870
+ mtime: {
3871
+ type: "integer",
3872
+ required: true
3873
+ },
3874
+ mode: {
3875
+ type: "integer",
3876
+ required: true
3877
+ },
3878
+ isSymlink: {
3879
+ type: "boolean",
3880
+ required: true
3881
+ }
3882
+ }
3883
+ }
3884
+ } }
3885
+ },
3886
+ render: (_args, value) => text((value.entries ?? []).map((e) => `${entryKind(e)} ${e.size} ${e.name}`).join("\n") || "(empty directory)")
3887
+ },
3888
+ async execute(args) {
3889
+ return { entries: await new RemoteFiles(deps.pool).list(args.alias, args.path) };
3890
+ }
3891
+ });
3892
+ }
3893
+ function remoteReadTool(deps) {
3894
+ return defineTool({
3895
+ name: "remote_read",
3896
+ description: "Read a remote text file over SFTP (absolute POSIX path, first 8 MiB window). Refuses binary files.",
3897
+ parameters: {
3898
+ alias: {
3899
+ type: "string",
3900
+ description: "Host alias from remote_hosts."
3901
+ },
3902
+ path: {
3903
+ type: "string",
3904
+ description: "Absolute remote file path."
3905
+ }
3906
+ },
3907
+ output: {
3908
+ schema: {
3909
+ type: "object",
3910
+ additionalProperties: false,
3911
+ properties: {
3912
+ size: {
3913
+ type: "integer",
3914
+ required: true
3915
+ },
3916
+ truncated: {
3917
+ type: "boolean",
3918
+ required: true
3919
+ },
3920
+ text: {
3921
+ type: "string",
3922
+ required: true
3923
+ }
3924
+ }
3925
+ },
3926
+ render: (_args, value) => text((value.text ?? "") + (value.truncated === true ? "\n[... truncated: file exceeds 8 MiB ...]" : ""))
3927
+ },
3928
+ async execute(args) {
3929
+ const result = await new RemoteFiles(deps.pool).read(args.alias, args.path);
3930
+ if (result.binary) throw new Error("binary file: remote_read serves text only");
3931
+ return {
3932
+ size: result.size,
3933
+ truncated: result.truncated,
3934
+ text: result.text ?? ""
3935
+ };
3936
+ }
3937
+ });
3938
+ }
3939
+ function remoteWriteTool(deps) {
3940
+ return defineTool({
3941
+ name: "remote_write",
3942
+ description: "Write a remote text file over SFTP (absolute POSIX path, creates parent dirs). Overwrites without confirmation.",
3943
+ parameters: {
3944
+ alias: {
3945
+ type: "string",
3946
+ description: "Host alias from remote_hosts."
3947
+ },
3948
+ path: {
3949
+ type: "string",
3950
+ description: "Absolute remote file path."
3951
+ },
3952
+ content: {
3953
+ type: "string",
3954
+ description: "UTF-8 text content to write."
3955
+ }
3956
+ },
3957
+ output: {
3958
+ schema: {
3959
+ type: "object",
3960
+ additionalProperties: false,
3961
+ properties: { size: {
3962
+ type: "integer",
3963
+ required: true
3964
+ } }
3965
+ },
3966
+ render: (_args, value) => text(`wrote ${value.size ?? 0} bytes`)
3967
+ },
3968
+ async execute(args) {
3969
+ return new RemoteFiles(deps.pool).write(args.alias, args.path, args.content, "utf8");
3970
+ }
3971
+ });
3972
+ }
3973
+ //#endregion
3974
+ //#region src/index.ts
3975
+ /** Stable cordis plugin name. */
3976
+ const name = "remote-vs";
3977
+ /**
3978
+ * Services required before the remote surfaces can mount. MINIMAL ON PURPOSE
3979
+ * (2026-10-13 deadlock lesson): our routers REPLACE fs/subprocess/spillStore
3980
+ * at root, so anything downstream of them (workingDirectory, agents, …)
3981
+ * transitively waits on us — declaring them here cycles the whole Host
3982
+ * (remote-vs → workingDirectory → fs-router → remoteWorlds → remote-vs).
3983
+ * Every other service is read SOFT via ctx.get() at call time (the
3984
+ * tool-bash pattern: no startup wait, no throw, undefined when absent).
3985
+ */
3986
+ const inject = [
3987
+ "webServer",
3988
+ "tools",
3989
+ "systemPrompt"
3990
+ ];
3991
+ /**
3992
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
3993
+ * entry's settings page; every field is `volatile()` so edits reach the
3994
+ * running instance without a remount.
3995
+ *
3996
+ * The two timeout fields exist because "large project" is host-specific: a cold
3997
+ * `git status` on a big monorepo outlived the old fixed 45 s client deadline,
3998
+ * so the limit is a setting rather than a constant. Both are clamped into
3999
+ * [MIN_TIMEOUT_MS, MAX_TIMEOUT_MS] (see `timeouts.ts`) on every read, so a bad
4000
+ * value degrades to the default instead of disabling the explorer.
4001
+ */
4002
+ const Config = z.object({
4003
+ announceToAgent: z.boolean().default(false).volatile(),
4004
+ enabled: z.boolean().default(true).volatile(),
4005
+ routingMode: z.union([
4006
+ "off",
4007
+ "shadow",
4008
+ "enforce"
4009
+ ]).default("off").volatile(),
4010
+ gitTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_GIT_TIMEOUT_MS).volatile(),
4011
+ readTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_READ_TIMEOUT_MS).volatile()
4012
+ });
4013
+ function readField(field, fallback) {
4014
+ if (field === void 0) return fallback;
4015
+ if (typeof field === "object" && field !== null && typeof field.get === "function") {
4016
+ const value = field.get();
4017
+ return value === void 0 ? fallback : value;
4018
+ }
4019
+ return field;
4020
+ }
4021
+ const TOOL_NAMES = [
4022
+ "remote_hosts",
4023
+ "remote_exec",
4024
+ "remote_ls",
4025
+ "remote_read",
4026
+ "remote_write"
4027
+ ];
4028
+ const GUIDANCE = "本机已安装 dsh-remote-vs 插件(DSH 远程开发):侧边栏「远程」tab 管理 SSH 主机与远端工作区。能力:主机配置存 $DSH_HOME/dsh-remote.json(证书/密码,与 dsh-ssh.json 独立);remote_hosts 列出主机、remote_exec 执行远程命令、remote_ls/remote_read/remote_write 经 SFTP 读写远端文件;推送流水线做版本比对后推送 helper 可执行文件;Remote 预设提供远端 bash 工具集。限制:主机必须先在 GUI 里配好,agent 不可建主机;密码明文存于 0600 私有文件;远端输出原样返回,可能含敏感信息;只支持直连主机(跳板机/代理命令未排期)。";
4029
+ /**
4030
+ * Per-session remote mapping block (dynamic runtime context provider).
4031
+ * Remote sessions under enforce get their alias→host→root mapping next to
4032
+ * the cwd block; everything else (local route, off/shadow mode, tombstoned
4033
+ * binding, missing host entry details) answers '' — empty text contributes
4034
+ * nothing, so local sessions stay noise-free.
4035
+ *
4036
+ * Evaluated at every assembly: sync memory reads only (mode flag, route
4037
+ * table scan, store maps — no I/O, no RPC), and it must never throw (an
4038
+ * assembly provider throwing fails the model step — callers wrap in
4039
+ * try/catch and treat any error as '').
4040
+ */
4041
+ function workspaceMappingText(worlds, store, cwd) {
4042
+ if (worlds.mode !== "enforce") return "";
4043
+ let route;
4044
+ try {
4045
+ route = worlds.routeDir(cwd);
4046
+ } catch {
4047
+ return "";
4048
+ }
4049
+ if (route.kind !== "remote") return "";
4050
+ const entry = store.get(route.alias);
4051
+ const host = entry !== void 0 ? `${route.alias} (${entry.user}@${entry.host}:${entry.port})` : `${route.alias} (host entry missing)`;
4052
+ return [
4053
+ `Remote workspace (transparent execution ON): session cwd ${route.aliasPath} is an empty local alias; tools resolve here:`,
4054
+ `- host: ${host}`,
4055
+ `- remote root: ${route.remotePath}`,
4056
+ "Relative paths and POSIX absolutes run on the remote host; the alias dir holds no real files.",
4057
+ "- File paths in your reply: use the remote absolute path (e.g. /home/...) or a path relative to the remote root above — never a path relative to your current directory; a click resolves against the remote root."
4058
+ ].join("\n");
4059
+ }
4060
+ function apply(ctx, config) {
4061
+ const store = new RemoteStore();
4062
+ const pool = new RemotePool(store);
4063
+ const worlds = new RemoteWorlds(ctx, {
4064
+ store,
4065
+ pool,
4066
+ mode: () => readField(config?.routingMode, "off")
4067
+ });
4068
+ ctx.provide("remoteWorlds", worlds);
4069
+ ctx.effect(() => () => {
4070
+ pool.dispose();
4071
+ }, "dsh-remote-vs: pool");
4072
+ ctx.effect(() => () => {
4073
+ worlds.dispose().catch(() => void 0);
4074
+ }, "dsh-remote-vs: worlds");
4075
+ worlds.syncWorkspaces().catch((error) => {
4076
+ try {
4077
+ ctx.logger.warn(`[remote-vs] workspace sync failed: ${error instanceof Error ? error.message : String(error)}`);
4078
+ } catch {}
4079
+ });
4080
+ try {
4081
+ const seed = seedHelperCache();
4082
+ if (!seed.vendorMissing && (seed.seeded.length > 0 || seed.failed.length > 0)) try {
4083
+ ctx.logger.info(`[remote-vs] helper cache seeded: ${seed.seeded.length} copied, ${seed.skipped.length} kept${seed.failed.length > 0 ? `, FAILED: ${seed.failed.join(", ")}` : ""}`);
4084
+ } catch {}
4085
+ } catch {}
4086
+ ctx.effect(() => ctx.systemPrompt.context({
4087
+ name: "remote-vs:workspace-mapping",
4088
+ order: 105,
4089
+ required: true,
4090
+ interpolate: false,
4091
+ text: (context) => {
4092
+ try {
4093
+ const session = context.agent?.session;
4094
+ if (session === void 0) return "";
4095
+ const workingDirectory = ctx.get("workingDirectory");
4096
+ if (workingDirectory === void 0) return "";
4097
+ let cwd;
4098
+ try {
4099
+ cwd = workingDirectory.get(session);
4100
+ } catch {
4101
+ return "";
4102
+ }
4103
+ return workspaceMappingText(worlds, store, cwd);
4104
+ } catch {
4105
+ return "";
4106
+ }
4107
+ }
4108
+ }), "dsh-remote-vs: workspace mapping");
4109
+ const resolve = () => ({
4110
+ announceToAgent: readField(config?.announceToAgent, false),
4111
+ enabled: readField(config?.enabled, true)
4112
+ });
4113
+ const resolveTimeoutsNow = () => resolveTimeouts({
4114
+ gitTimeoutMs: readField(config?.gitTimeoutMs, DEFAULT_GIT_TIMEOUT_MS),
4115
+ readTimeoutMs: readField(config?.readTimeoutMs, DEFAULT_READ_TIMEOUT_MS)
4116
+ });
4117
+ /**
4118
+ * Which sessions get a ledger.
4119
+ *
4120
+ * Subagent sessions are skipped on purpose, the same policy DSH's own
4121
+ * recorder uses: a delegated session writes into the same workspace, so its
4122
+ * files show up in the parent session's diff anyway, while recording it
4123
+ * separately would run the scans once per subagent turn (a Team run fans out,
4124
+ * and the scan is the expensive part).
4125
+ */
4126
+ const ledgerTarget = (session) => {
4127
+ const header = session.header;
4128
+ if (header?.origin === "subagent" || (header?.delegationDepth ?? 0) > 0) return void 0;
4129
+ let cwd = header?.cwd ?? "";
4130
+ try {
4131
+ const current = ctx.get("workingDirectory")?.get?.(session);
4132
+ if (typeof current === "string" && current !== "") cwd = current;
4133
+ } catch {}
4134
+ return cwd === "" ? void 0 : {
4135
+ sessionId: session.id,
4136
+ cwd
4137
+ };
4138
+ };
4139
+ const changes = new SessionChanges({
4140
+ pool,
4141
+ worlds,
4142
+ timeouts: resolveTimeoutsNow,
4143
+ log: (level, message) => {
4144
+ try {
4145
+ if (level === "warn") ctx.logger.warn(`[remote-vs] changes: ${message}`);
4146
+ else ctx.logger.info(`[remote-vs] changes: ${message}`);
4147
+ } catch {}
4148
+ }
4149
+ });
4150
+ ctx.on("session/event", (session, event) => {
4151
+ try {
4152
+ if (event.type !== "turn/start" && event.type !== "turn/end") return;
4153
+ const target = ledgerTarget(session);
4154
+ if (target === void 0) return;
4155
+ if (event.type === "turn/start") changes.beginTurn(target);
4156
+ else changes.endTurn(target, event.data.turn);
4157
+ } catch {}
4158
+ });
4159
+ ctx.on("session/disposed", (session) => {
4160
+ changes.forget(session.id);
4161
+ });
4162
+ ctx.on("agent/turn-stopping", (payload) => {
4163
+ try {
4164
+ const target = ledgerTarget(payload.agent.session);
4165
+ if (target !== void 0) changes.endTurn(target, payload.turn);
4166
+ } catch {}
4167
+ });
4168
+ ctx.effect(() => () => {
4169
+ changes.dispose();
4170
+ }, "dsh-remote-vs: changes ledger");
4171
+ const { routes, upgrade, terminalSessions } = makeRoutes({
4172
+ store,
4173
+ pool,
4174
+ worlds,
4175
+ ctx,
4176
+ timeouts: resolveTimeoutsNow,
4177
+ changes
4178
+ });
4179
+ let disposeRoutes;
4180
+ const tools = [
4181
+ remoteHostsTool({
4182
+ store,
4183
+ pool
4184
+ }),
4185
+ remoteExecTool({
4186
+ store,
4187
+ pool
4188
+ }),
4189
+ remoteLsTool({
4190
+ store,
4191
+ pool
4192
+ }),
4193
+ remoteReadTool({
4194
+ store,
4195
+ pool
4196
+ }),
4197
+ remoteWriteTool({
4198
+ store,
4199
+ pool
4200
+ })
4201
+ ];
4202
+ let disposeTools;
4203
+ let disposeSection;
4204
+ const sync = () => {
4205
+ if (disposeSection !== void 0) {
4206
+ disposeSection();
4207
+ disposeSection = void 0;
4208
+ }
4209
+ if (disposeRoutes !== void 0) {
4210
+ disposeRoutes();
4211
+ disposeRoutes = void 0;
4212
+ }
4213
+ if (disposeTools !== void 0) {
4214
+ disposeTools();
4215
+ disposeTools = void 0;
4216
+ }
4217
+ const value = resolve();
4218
+ if (!value.enabled) return;
4219
+ if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
4220
+ name: "plugin:dsh-remote-vs",
4221
+ order: 60,
4222
+ text: () => {
4223
+ if (TOOL_NAMES.filter((n) => ctx.tools.get(n) !== void 0).length === 0) return "";
4224
+ return GUIDANCE;
4225
+ }
4226
+ });
4227
+ disposeRoutes = ctx.effect(() => {
4228
+ const disposers = routes.map((route) => ctx.webServer.register(route));
4229
+ const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade);
4230
+ return () => {
4231
+ for (const dispose of disposers) dispose();
4232
+ upgradeDisposer();
4233
+ terminalSessions.dispose();
4234
+ };
4235
+ }, "dsh-remote-vs: routes");
4236
+ disposeTools = ctx.effect(() => {
4237
+ const disposers = tools.map((tool) => ctx.tools.register(tool));
4238
+ return () => {
4239
+ for (const dispose of disposers) dispose();
4240
+ };
4241
+ }, "dsh-remote-vs: tools");
4242
+ };
4243
+ ctx.on("loader/volatile-update", () => {
4244
+ sync();
4245
+ });
4246
+ ctx.on("agent/created", (payload) => {
4247
+ if (payload.source !== "startup") return void 0;
4248
+ setTimeout(() => {
4249
+ (async () => {
4250
+ try {
4251
+ const presets = ctx.get("agentPresets");
4252
+ if (presets === void 0) return;
4253
+ if (worlds.mode !== "enforce") return;
4254
+ const current = presets.composedPreset(payload.agent.ctx);
4255
+ if (current !== presets.defaultId || current === "remote") return;
4256
+ const workingDirectory = ctx.get("workingDirectory");
4257
+ if (workingDirectory === void 0) return;
4258
+ let cwd;
4259
+ try {
4260
+ cwd = workingDirectory.get(payload.agent.session);
4261
+ } catch {
4262
+ return;
4263
+ }
4264
+ let remote = false;
4265
+ try {
4266
+ remote = worlds.routeDir(cwd).kind === "remote";
4267
+ } catch {
4268
+ return;
4269
+ }
4270
+ if (!remote) return;
4271
+ await presets.select(payload.agent, "remote");
4272
+ try {
4273
+ ctx.logger.info(`[remote-vs] new remote session entered ${cwd}; preset switched to remote`);
4274
+ } catch {}
4275
+ } catch (error) {
4276
+ if (error instanceof Error && /locked|already started/i.test(error.message)) return;
4277
+ try {
4278
+ ctx.logger.warn(`[remote-vs] preset auto-switch skipped: ${error instanceof Error ? error.message : String(error)}`);
4279
+ } catch {}
4280
+ }
4281
+ })();
4282
+ }, 2500).unref?.();
4283
+ });
4284
+ sync();
4285
+ }
4286
+ //#endregion
4287
+ export { Config, apply, inject, name };